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:
mika kuns
2026-04-24 14:54:46 +02:00
parent e62485db3b
commit 5a03dc8430
2 changed files with 95 additions and 15 deletions

View File

@@ -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;
} }

View File

@@ -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);
}
} }