feat(worker): broadcast child TaskUpdated events on planning CRUD
So the UI refreshes individual child rows alongside the parent during create/update/delete/finalize from the planning MCP service. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ public sealed class PlanningMcpService
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return new CreatedChildDto(child.Id, "Draft");
|
||||
}
|
||||
@@ -83,6 +84,7 @@ public sealed class PlanningMcpService
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
|
||||
}
|
||||
@@ -101,6 +103,7 @@ public sealed class PlanningMcpService
|
||||
throw new InvalidOperationException("Cannot delete a finalized task.");
|
||||
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(taskId, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
}
|
||||
|
||||
@@ -121,7 +124,11 @@ public sealed class PlanningMcpService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _contextAccessor.Current;
|
||||
var childIds = (await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken))
|
||||
.Select(c => c.Id).ToList();
|
||||
var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken);
|
||||
foreach (var id in childIds)
|
||||
await BroadcastTaskUpdatedAsync(id, cancellationToken);
|
||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -16,29 +16,34 @@ file sealed class FakeHttpContextAccessor : IHttpContextAccessor
|
||||
public HttpContext? HttpContext { get; set; }
|
||||
}
|
||||
|
||||
file sealed class NullHubClients : IHubClients
|
||||
file sealed class RecordingHubClients : IHubClients
|
||||
{
|
||||
public IClientProxy All => NullClientProxy.Instance;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
|
||||
public IClientProxy Client(string connectionId) => NullClientProxy.Instance;
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NullClientProxy.Instance;
|
||||
public IClientProxy Group(string groupName) => NullClientProxy.Instance;
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NullClientProxy.Instance;
|
||||
public IClientProxy User(string userId) => NullClientProxy.Instance;
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => NullClientProxy.Instance;
|
||||
public RecordingClientProxy Proxy { get; } = new();
|
||||
public IClientProxy All => Proxy;
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||
public IClientProxy Client(string connectionId) => Proxy;
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
||||
public IClientProxy Group(string groupName) => Proxy;
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
||||
public IClientProxy User(string userId) => Proxy;
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
|
||||
}
|
||||
|
||||
file sealed class NullClientProxy : IClientProxy
|
||||
file sealed class RecordingClientProxy : IClientProxy
|
||||
{
|
||||
public static readonly NullClientProxy Instance = new();
|
||||
public List<(string Method, object?[] Args)> Calls { get; } = new();
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
{
|
||||
Calls.Add((method, args));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeHubContext : IHubContext<WorkerHub>
|
||||
{
|
||||
public IHubClients Clients { get; } = new NullHubClients();
|
||||
public RecordingHubClients RecordingClients { get; } = new();
|
||||
public IHubClients Clients => RecordingClients;
|
||||
public IGroupManager Groups => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
@@ -58,15 +63,25 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private List<(string Method, object?[] Args)> _hubCalls = new();
|
||||
|
||||
private PlanningMcpService BuildSut(string parentTaskId)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId };
|
||||
var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext });
|
||||
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
||||
var hub = new FakeHubContext();
|
||||
_hubCalls = hub.RecordingClients.Proxy.Calls;
|
||||
var broadcaster = new HubBroadcaster(hub);
|
||||
return new PlanningMcpService(_tasks, accessor, broadcaster);
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> TaskUpdatedIds() =>
|
||||
_hubCalls
|
||||
.Where(c => c.Method == "TaskUpdated")
|
||||
.Select(c => (string)c.Args[0]!)
|
||||
.ToList();
|
||||
|
||||
private async Task<TaskEntity> SeedPlanningParentAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
@@ -178,4 +193,62 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
||||
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||
Assert.Null(loaded.PlanningSessionToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChildTask_BroadcastsBothChildAndParent()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var sut = BuildSut(parent.Id);
|
||||
|
||||
var result = await sut.CreateChildTask("c", null, null, null, CancellationToken.None);
|
||||
|
||||
var ids = TaskUpdatedIds();
|
||||
Assert.Contains(result.TaskId, ids);
|
||||
Assert.Contains(parent.Id, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateChildTask_BroadcastsBothChildAndParent()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||
_ctx.ChangeTracker.Clear();
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
await sut.UpdateChildTask(c.Id, "new title", null, null, null, CancellationToken.None);
|
||||
|
||||
var ids = TaskUpdatedIds();
|
||||
Assert.Contains(c.Id, ids);
|
||||
Assert.Contains(parent.Id, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteChildTask_BroadcastsBothChildAndParent()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
await sut.DeleteChildTask(c.Id, CancellationToken.None);
|
||||
|
||||
var ids = TaskUpdatedIds();
|
||||
Assert.Contains(c.Id, ids);
|
||||
Assert.Contains(parent.Id, ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finalize_BroadcastsEachChildAndParent()
|
||||
{
|
||||
var parent = await SeedPlanningParentAsync();
|
||||
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
||||
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
||||
|
||||
var sut = BuildSut(parent.Id);
|
||||
await sut.Finalize(true, CancellationToken.None);
|
||||
|
||||
var ids = TaskUpdatedIds();
|
||||
Assert.Contains(c1.Id, ids);
|
||||
Assert.Contains(c2.Id, ids);
|
||||
Assert.Contains(parent.Id, ids);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user