From 7b67e357205c609d53e563da7eaaaf821a6e0481 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 23:26:12 +0200 Subject: [PATCH] feat(worker): SignalR hub endpoints for planning sessions --- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 48 +++- .../Hub/PlanningHubTests.cs | 221 ++++++++++++++++++ 2 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 57ba89c..0371cee 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -2,6 +2,7 @@ using System.Reflection; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Services; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -43,6 +44,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly WorktreeMaintenanceService _wtMaintenance; private readonly TaskResetService _resetService; private readonly TaskMergeService _mergeService; + private readonly PlanningSessionManager _planning; + private readonly IPlanningTerminalLauncher _launcher; public WorkerHub( QueueService queue, @@ -52,7 +55,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub IDbContextFactory dbFactory, WorktreeMaintenanceService wtMaintenance, TaskResetService resetService, - TaskMergeService mergeService) + TaskMergeService mergeService, + PlanningSessionManager planning, + IPlanningTerminalLauncher launcher) { _queue = queue; _agentService = agentService; @@ -62,6 +67,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _wtMaintenance = wtMaintenance; _resetService = resetService; _mergeService = mergeService; + _planning = planning; + _launcher = launcher; } public string Ping() => $"pong v{Version}"; @@ -284,5 +291,44 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub await _broadcaster.TaskUpdated(dto.TaskId); } + public async Task StartPlanningSessionAsync(string taskId) + { + var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted); + try + { + await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted); + } + catch (PlanningLaunchException) + { + await _planning.DiscardAsync(taskId, Context.ConnectionAborted); + throw; + } + await Clients.All.SendAsync("TaskUpdated", taskId); + return ctx; + } + + public async Task ResumePlanningSessionAsync(string taskId) + { + var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted); + await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted); + return ctx; + } + + public async Task DiscardPlanningSessionAsync(string taskId) + { + await _planning.DiscardAsync(taskId, Context.ConnectionAborted); + await Clients.All.SendAsync("TaskUpdated", taskId); + } + + public async Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true) + { + var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted); + await Clients.All.SendAsync("TaskUpdated", taskId); + return count; + } + + public Task GetPendingDraftCountAsync(string taskId) + => _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted); + private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; } diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs new file mode 100644 index 0000000..614161e --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -0,0 +1,221 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Services; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.AspNetCore.SignalR; +using Xunit; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Hub; + +public sealed class PlanningHubTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly string _rootDir; + private readonly PlanningSessionManager _planning; + private readonly FakePlanningLauncher _launcher; + private readonly RecordingClientProxy _proxy; + + public PlanningHubTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _rootDir = Path.Combine(Path.GetTempPath(), $"cd_hub_planning_{Guid.NewGuid():N}"); + _planning = new PlanningSessionManager(_tasks, _lists, _rootDir); + _launcher = new FakePlanningLauncher(); + _proxy = new RecordingClientProxy(); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + try { Directory.Delete(_rootDir, recursive: true); } catch { } + } + + private WorkerHub CreateHub() + { + var hub = new WorkerHub( + null!, null!, null!, null!, null!, null!, null!, null!, + _planning, _launcher); + hub.Clients = new FakeHubCallerClients(_proxy); + hub.Context = new FakeHubCallerContext(); + return hub; + } + + private async Task<(string listId, string taskId)> SeedAsync() + { + var listId = Guid.NewGuid().ToString(); + var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}"); + Directory.CreateDirectory(wd); + await _lists.AddAsync(new ListEntity + { + Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow, + }); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "Do something", + Status = TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = "feat", + }; + await _tasks.AddAsync(task); + return (listId, task.Id); + } + + [Fact] + public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher() + { + var (_, taskId) = await SeedAsync(); + var hub = CreateHub(); + + var ctx = await hub.StartPlanningSessionAsync(taskId); + + Assert.Equal(taskId, ctx.ParentTaskId); + Assert.Equal(1, _launcher.LaunchStartCalls); + Assert.Equal(0, _launcher.LaunchResumeCalls); + + var loaded = await _tasks.GetByIdAsync(taskId); + Assert.Equal(TaskStatus.Planning, loaded!.Status); + + Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated"); + } + + [Fact] + public async Task StartPlanningSessionAsync_LauncherFails_Discards() + { + var (_, taskId) = await SeedAsync(); + _launcher.ShouldThrow = true; + var hub = CreateHub(); + + await Assert.ThrowsAsync(() => + hub.StartPlanningSessionAsync(taskId)); + + var loaded = await _tasks.GetByIdAsync(taskId); + Assert.Equal(TaskStatus.Manual, loaded!.Status); + + var sessionDir = Path.Combine(_rootDir, taskId); + Assert.False(Directory.Exists(sessionDir)); + } + + [Fact] + public async Task DiscardPlanningSessionAsync_ResetsTask_AndBroadcasts() + { + var (_, taskId) = await SeedAsync(); + // Put task into Planning state first + await _planning.StartAsync(taskId, CancellationToken.None); + _proxy.Sent.Clear(); + + var hub = CreateHub(); + await hub.DiscardPlanningSessionAsync(taskId); + + var loaded = await _tasks.GetByIdAsync(taskId); + Assert.Equal(TaskStatus.Manual, loaded!.Status); + Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated"); + } + + [Fact] + public async Task FinalizePlanningSessionAsync_PromotesDraftsAndBroadcasts() + { + var (_, taskId) = await SeedAsync(); + await _planning.StartAsync(taskId, CancellationToken.None); + await _tasks.CreateChildAsync(taskId, "child 1", null, null, null); + await _tasks.CreateChildAsync(taskId, "child 2", null, null, null); + _proxy.Sent.Clear(); + + var hub = CreateHub(); + var count = await hub.FinalizePlanningSessionAsync(taskId, queueAgentTasks: false); + + Assert.Equal(2, count); + Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated"); + } + + [Fact] + public async Task GetPendingDraftCountAsync_ReturnsCount() + { + var (_, taskId) = await SeedAsync(); + await _planning.StartAsync(taskId, CancellationToken.None); + await _tasks.CreateChildAsync(taskId, "c1", null, null, null); + await _tasks.CreateChildAsync(taskId, "c2", null, null, null); + + var hub = CreateHub(); + var count = await hub.GetPendingDraftCountAsync(taskId); + + Assert.Equal(2, count); + } +} + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher +{ + public bool ShouldThrow { get; set; } + public int LaunchStartCalls { get; private set; } + public int LaunchResumeCalls { get; private set; } + + public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) + { + if (ShouldThrow) throw new PlanningLaunchException("fake launch failure"); + LaunchStartCalls++; + return Task.CompletedTask; + } + + public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) + { + LaunchResumeCalls++; + return Task.CompletedTask; + } +} + +internal sealed class RecordingClientProxy : IClientProxy +{ + public List<(string method, object?[] args)> Sent { get; } = new(); + + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + { + Sent.Add((method, args)); + return Task.CompletedTask; + } +} + +internal sealed class FakeHubCallerClients : IHubCallerClients +{ + private readonly IClientProxy _all; + public FakeHubCallerClients(IClientProxy proxy) => _all = proxy; + + public IClientProxy All => _all; + public IClientProxy Caller => _all; + public IClientProxy Others => _all; + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => _all; + public IClientProxy Client(string connectionId) => _all; + public IClientProxy Clients(IReadOnlyList connectionIds) => _all; + public IClientProxy Group(string groupName) => _all; + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => _all; + public IClientProxy Groups(IReadOnlyList groupNames) => _all; + public IClientProxy OthersInGroup(string groupName) => _all; + public IClientProxy User(string userId) => _all; + public IClientProxy Users(IReadOnlyList userIds) => _all; +} + +internal sealed class FakeHubCallerContext : HubCallerContext +{ + public override string ConnectionId => "test-conn"; + public override string? UserIdentifier => null; + public override System.Security.Claims.ClaimsPrincipal? User => null; + public override IDictionary Items { get; } = new Dictionary(); + public override Microsoft.AspNetCore.Http.Features.IFeatureCollection Features { get; } = + new Microsoft.AspNetCore.Http.Features.FeatureCollection(); + public override CancellationToken ConnectionAborted => CancellationToken.None; + public override void Abort() { } +}