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 ctx = _contextAccessor.Current;
|
||||||
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
return new CreatedChildDto(child.Id, "Draft");
|
return new CreatedChildDto(child.Id, "Draft");
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,7 @@ public sealed class PlanningMcpService
|
|||||||
|
|
||||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
|
var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken);
|
||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList());
|
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.");
|
throw new InvalidOperationException("Cannot delete a finalized task.");
|
||||||
|
|
||||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||||
|
await BroadcastTaskUpdatedAsync(taskId, cancellationToken);
|
||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +124,11 @@ public sealed class PlanningMcpService
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var ctx = _contextAccessor.Current;
|
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);
|
var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken);
|
||||||
|
foreach (var id in childIds)
|
||||||
|
await BroadcastTaskUpdatedAsync(id, cancellationToken);
|
||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,29 +16,34 @@ file sealed class FakeHttpContextAccessor : IHttpContextAccessor
|
|||||||
public HttpContext? HttpContext { get; set; }
|
public HttpContext? HttpContext { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
file sealed class NullHubClients : IHubClients
|
file sealed class RecordingHubClients : IHubClients
|
||||||
{
|
{
|
||||||
public IClientProxy All => NullClientProxy.Instance;
|
public RecordingClientProxy Proxy { get; } = new();
|
||||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
|
public IClientProxy All => Proxy;
|
||||||
public IClientProxy Client(string connectionId) => NullClientProxy.Instance;
|
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NullClientProxy.Instance;
|
public IClientProxy Client(string connectionId) => Proxy;
|
||||||
public IClientProxy Group(string groupName) => NullClientProxy.Instance;
|
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
|
||||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
|
public IClientProxy Group(string groupName) => Proxy;
|
||||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NullClientProxy.Instance;
|
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
|
||||||
public IClientProxy User(string userId) => NullClientProxy.Instance;
|
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
|
||||||
public IClientProxy Users(IReadOnlyList<string> userIds) => NullClientProxy.Instance;
|
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)
|
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>
|
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();
|
public IGroupManager Groups => throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,15 +63,25 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
|
|
||||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||||
|
|
||||||
|
private List<(string Method, object?[] Args)> _hubCalls = new();
|
||||||
|
|
||||||
private PlanningMcpService BuildSut(string parentTaskId)
|
private PlanningMcpService BuildSut(string parentTaskId)
|
||||||
{
|
{
|
||||||
var httpContext = new DefaultHttpContext();
|
var httpContext = new DefaultHttpContext();
|
||||||
httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId };
|
httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId };
|
||||||
var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext });
|
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);
|
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()
|
private async Task<TaskEntity> SeedPlanningParentAsync()
|
||||||
{
|
{
|
||||||
var listId = Guid.NewGuid().ToString();
|
var listId = Guid.NewGuid().ToString();
|
||||||
@@ -178,4 +193,62 @@ public sealed class PlanningMcpServiceTests : IDisposable
|
|||||||
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
||||||
Assert.Null(loaded.PlanningSessionToken);
|
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