From b32621a4e5927739ee1fad2f7342002f7ee28644 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 18:34:51 +0200 Subject: [PATCH 01/15] chore(worker): add ModelContextProtocol package --- src/ClaudeDo.Worker/ClaudeDo.Worker.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj index 25e8539..5f937f6 100644 --- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj @@ -7,6 +7,8 @@ + + -- 2.49.1 From b6bec1e63c61c6088acd2a05f02deb3f1d62ae69 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 20:33:21 +0200 Subject: [PATCH 02/15] feat(worker): PlanningSessionManager.StartAsync Add PlanningSessionFiles, PlanningSessionStartContext/ResumeContext DTOs, PlanningSessionManager.StartAsync (file scaffolding + status transition), and integration tests. Also fix migration discovery by adding [DbContext] attribute to all migration classes and switch DbFixture to EnsureCreated. Co-Authored-By: Claude Sonnet 4.6 --- .../20260416064948_InitialCreate.cs | 4 + .../20260420075929_AddTaskFlagsAndNotes.cs | 6 +- .../20260421113614_AddAppSettings.cs | 6 +- .../20260422120000_AddTaskSortOrder.cs | 4 + .../20260423154708_AddPlanningSupport.cs | 4 + .../Planning/PlanningSessionContext.cs | 12 ++ .../Planning/PlanningSessionFiles.cs | 7 + .../Planning/PlanningSessionManager.cs | 111 ++++++++++++++++ .../Infrastructure/DbFixture.cs | 4 +- .../Planning/PlanningSessionManagerTests.cs | 123 ++++++++++++++++++ 10 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs create mode 100644 src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs create mode 100644 src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs diff --git a/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs b/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs index 301b931..5d0f072 100644 --- a/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs +++ b/src/ClaudeDo.Data/Migrations/20260416064948_InitialCreate.cs @@ -1,4 +1,6 @@ using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -8,6 +10,8 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace ClaudeDo.Data.Migrations { /// + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260416064948_InitialCreate")] public partial class InitialCreate : Migration { /// diff --git a/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs b/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs index 79ba1ac..27b2fe8 100644 --- a/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs +++ b/src/ClaudeDo.Data/Migrations/20260420075929_AddTaskFlagsAndNotes.cs @@ -1,10 +1,14 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace ClaudeDo.Data.Migrations { /// + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260420075929_AddTaskFlagsAndNotes")] public partial class AddTaskFlagsAndNotes : Migration { /// diff --git a/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs b/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs index 083c4cb..99eef30 100644 --- a/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs +++ b/src/ClaudeDo.Data/Migrations/20260421113614_AddAppSettings.cs @@ -1,10 +1,14 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace ClaudeDo.Data.Migrations { /// + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260421113614_AddAppSettings")] public partial class AddAppSettings : Migration { /// diff --git a/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs b/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs index 6918635..3f275bd 100644 --- a/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs +++ b/src/ClaudeDo.Data/Migrations/20260422120000_AddTaskSortOrder.cs @@ -1,3 +1,5 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace ClaudeDo.Data.Migrations { /// + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260422120000_AddTaskSortOrder")] public partial class AddTaskSortOrder : Migration { /// diff --git a/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs b/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs index ef24f78..9efb30a 100644 --- a/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs +++ b/src/ClaudeDo.Data/Migrations/20260423154708_AddPlanningSupport.cs @@ -1,4 +1,6 @@ using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -6,6 +8,8 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace ClaudeDo.Data.Migrations { /// + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260423154708_AddPlanningSupport")] public partial class AddPlanningSupport : Migration { /// diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs new file mode 100644 index 0000000..ea1b7fc --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionContext.cs @@ -0,0 +1,12 @@ +namespace ClaudeDo.Worker.Planning; + +public sealed record PlanningSessionStartContext( + string ParentTaskId, + string WorkingDir, + PlanningSessionFiles Files); + +public sealed record PlanningSessionResumeContext( + string ParentTaskId, + string WorkingDir, + string ClaudeSessionId, + string McpConfigPath); \ No newline at end of file diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs new file mode 100644 index 0000000..ffe4390 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionFiles.cs @@ -0,0 +1,7 @@ +namespace ClaudeDo.Worker.Planning; + +public sealed record PlanningSessionFiles( + string SessionDirectory, + string McpConfigPath, + string SystemPromptPath, + string InitialPromptPath); \ No newline at end of file diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs new file mode 100644 index 0000000..7080960 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -0,0 +1,111 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Planning; + +public sealed class PlanningSessionManager +{ + private const string McpServerUrl = "http://127.0.0.1:47821/mcp"; + + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly string _rootDirectory; + + public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory) + { + _tasks = tasks; + _lists = lists; + _rootDirectory = rootDirectory; + } + + public async Task StartAsync(string taskId, CancellationToken ct) + { + var task = await _tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.ParentTaskId is not null) + throw new InvalidOperationException("Cannot start a planning session on a child task."); + if (task.Status != TaskStatus.Manual) + throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning."); + + var token = GenerateToken(); + _ = await _tasks.SetPlanningStartedAsync(taskId, token, ct) + ?? throw new InvalidOperationException("Failed to transition task to Planning."); + + var sessionDir = Path.Combine(_rootDirectory, taskId); + Directory.CreateDirectory(sessionDir); + + var files = new PlanningSessionFiles( + sessionDir, + Path.Combine(sessionDir, "mcp.json"), + Path.Combine(sessionDir, "system-prompt.md"), + Path.Combine(sessionDir, "initial-prompt.txt")); + + await File.WriteAllTextAsync(files.McpConfigPath, BuildMcpConfigJson(token), ct); + await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct); + await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct); + + var list = await _lists.GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException($"List {task.ListId} not found."); + return new PlanningSessionStartContext(taskId, list.WorkingDir, files); + } + + private static string GenerateToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + } + + private static string BuildMcpConfigJson(string token) + { + var payload = new + { + mcpServers = new + { + claudedo = new + { + type = "http", + url = McpServerUrl, + headers = new Dictionary + { + ["Authorization"] = $"Bearer {token}" + } + } + } + }; + return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + } + + private static string BuildSystemPrompt() => + """ + You are a planning assistant for ClaudeDo. + Your role is to help break down a task into smaller, actionable subtasks. + + Use the available MCP tools (mcp__claudedo__*) to create child tasks. + When you are done planning, finalize the session. + + Be concise and focused. Each subtask should be independently executable. + """; + + private static string BuildInitialPrompt(TaskEntity task) + { + var sb = new StringBuilder(); + sb.AppendLine($"# Task: {task.Title}"); + if (!string.IsNullOrWhiteSpace(task.Description)) + { + sb.AppendLine(); + sb.AppendLine(task.Description); + } + sb.AppendLine(); + sb.AppendLine("---"); + sb.AppendLine(); + sb.AppendLine("Please analyze this task and break it down into concrete subtasks."); + return sb.ToString(); + } +} \ No newline at end of file diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs index d15c574..d47a976 100644 --- a/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/DbFixture.cs @@ -10,9 +10,9 @@ public sealed class DbFixture : IDisposable public DbFixture() { DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db"); - // Apply migrations so the schema is created. + // EnsureCreated uses the current model directly — no Designer.cs needed. using var ctx = CreateContext(); - ctx.Database.Migrate(); + ctx.Database.EnsureCreated(); } public ClaudeDoDbContext CreateContext() diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs new file mode 100644 index 0000000..e9c16c7 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -0,0 +1,123 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Planning; + +public sealed class PlanningSessionManagerTests : 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 _sut; + + public PlanningSessionManagerTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _rootDir = Path.Combine(Path.GetTempPath(), $"cd_planning_{Guid.NewGuid():N}"); + _sut = new PlanningSessionManager(_tasks, _lists, _rootDir); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + try { Directory.Delete(_rootDir, recursive: true); } catch { /* ignore */ } + } + + private async Task<(string listId, string workingDir)> SeedListAsync() + { + 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 = "Test", + WorkingDir = wd, + CreatedAt = DateTime.UtcNow, + }); + return (listId, wd); + } + + private async Task SeedManualTaskAsync(string listId) + { + var t = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "Brainstorm auth", + Description = "- review tokens\n- plan rollout", + Status = TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = "feat", + }; + await _tasks.AddAsync(t); + return t; + } + + [Fact] + public async Task StartAsync_CreatesSessionFiles_AndTransitionsTaskToPlanning() + { + var (listId, wd) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + + var ctx = await _sut.StartAsync(parent.Id, CancellationToken.None); + + Assert.Equal(parent.Id, ctx.ParentTaskId); + Assert.Equal(wd, ctx.WorkingDir); + Assert.True(File.Exists(ctx.Files.McpConfigPath)); + Assert.True(File.Exists(ctx.Files.SystemPromptPath)); + Assert.True(File.Exists(ctx.Files.InitialPromptPath)); + + var mcp = await File.ReadAllTextAsync(ctx.Files.McpConfigPath); + Assert.Contains("\"type\": \"http\"", mcp); + Assert.Contains("Bearer ", mcp); + + var initial = await File.ReadAllTextAsync(ctx.Files.InitialPromptPath); + Assert.Contains("Brainstorm auth", initial); + Assert.Contains("review tokens", initial); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planning, loaded!.Status); + Assert.NotNull(loaded.PlanningSessionToken); + } + + [Fact] + public async Task StartAsync_TaskNotManual_Throws() + { + var (listId, _) = await SeedListAsync(); + var queuedTask = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "x", + Status = TaskStatus.Queued, + CreatedAt = DateTime.UtcNow, + CommitType = "feat", + }; + await _tasks.AddAsync(queuedTask); + + await Assert.ThrowsAsync(() => + _sut.StartAsync(queuedTask.Id, CancellationToken.None)); + } + + [Fact] + public async Task StartAsync_ChildTask_Throws() + { + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + await _tasks.SetPlanningStartedAsync(parent.Id, "t"); + var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + + await Assert.ThrowsAsync(() => + _sut.StartAsync(child.Id, CancellationToken.None)); + } +} \ No newline at end of file -- 2.49.1 From 84b0ba8670d38153b830005ff7f7235b243a5fe3 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 20:55:01 +0200 Subject: [PATCH 03/15] feat(worker): PlanningSessionManager.ResumeAsync --- .../Planning/PlanningSessionManager.cs | 19 +++++++++ .../Planning/PlanningSessionManagerTests.cs | 39 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 7080960..67194ab 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -53,6 +53,25 @@ public sealed class PlanningSessionManager return new PlanningSessionStartContext(taskId, list.WorkingDir, files); } + public async Task ResumeAsync(string taskId, CancellationToken ct) + { + var task = await _tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status != TaskStatus.Planning) + throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning."); + if (string.IsNullOrEmpty(task.PlanningSessionId)) + throw new InvalidOperationException("No Claude session ID captured yet; cannot resume."); + + var sessionDir = Path.Combine(_rootDirectory, taskId); + var mcpConfigPath = Path.Combine(sessionDir, "mcp.json"); + if (!File.Exists(mcpConfigPath)) + throw new InvalidOperationException($"Session directory missing: {sessionDir}"); + + var list = await _lists.GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException($"List {task.ListId} not found."); + return new PlanningSessionResumeContext(taskId, list.WorkingDir, task.PlanningSessionId, mcpConfigPath); + } + private static string GenerateToken() { var bytes = RandomNumberGenerator.GetBytes(32); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs index e9c16c7..a587d89 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -120,4 +120,43 @@ public sealed class PlanningSessionManagerTests : IDisposable await Assert.ThrowsAsync(() => _sut.StartAsync(child.Id, CancellationToken.None)); } + + [Fact] + public async Task ResumeAsync_ReturnsExistingSessionDetails() + { + var (listId, wd) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None); + await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-session-42"); + + var resumeCtx = await _sut.ResumeAsync(parent.Id, CancellationToken.None); + + Assert.Equal(parent.Id, resumeCtx.ParentTaskId); + Assert.Equal(wd, resumeCtx.WorkingDir); + Assert.Equal("claude-session-42", resumeCtx.ClaudeSessionId); + Assert.Equal(startCtx.Files.McpConfigPath, resumeCtx.McpConfigPath); + Assert.True(File.Exists(resumeCtx.McpConfigPath)); + } + + [Fact] + public async Task ResumeAsync_NotPlanning_Throws() + { + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + // did not start + await Assert.ThrowsAsync(() => + _sut.ResumeAsync(parent.Id, CancellationToken.None)); + } + + [Fact] + public async Task ResumeAsync_NoClaudeSessionId_Throws() + { + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + await _sut.StartAsync(parent.Id, CancellationToken.None); + // UpdatePlanningSessionIdAsync not called + + await Assert.ThrowsAsync(() => + _sut.ResumeAsync(parent.Id, CancellationToken.None)); + } } \ No newline at end of file -- 2.49.1 From 84e6c2d5fca78185868c91a3ef4a47c72564915a Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 20:58:55 +0200 Subject: [PATCH 04/15] feat(worker): PlanningSessionManager.DiscardAsync --- .../Planning/PlanningSessionManager.cs | 13 +++++++++++++ .../Planning/PlanningSessionManagerTests.cs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 67194ab..c312873 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -53,6 +53,19 @@ public sealed class PlanningSessionManager return new PlanningSessionStartContext(taskId, list.WorkingDir, files); } + public async Task DiscardAsync(string taskId, CancellationToken ct) + { + var ok = await _tasks.DiscardPlanningAsync(taskId, ct); + var sessionDir = Path.Combine(_rootDirectory, taskId); + if (Directory.Exists(sessionDir)) + { + try { Directory.Delete(sessionDir, recursive: true); } + catch { /* best effort */ } + } + if (!ok) + throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard."); + } + public async Task ResumeAsync(string taskId, CancellationToken ct) { var task = await _tasks.GetByIdAsync(taskId, ct) diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs index a587d89..daf1712 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -159,4 +159,20 @@ public sealed class PlanningSessionManagerTests : IDisposable await Assert.ThrowsAsync(() => _sut.ResumeAsync(parent.Id, CancellationToken.None)); } + + [Fact] + public async Task DiscardAsync_DeletesSessionDirAndResetsTask() + { + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + var startCtx = await _sut.StartAsync(parent.Id, CancellationToken.None); + Assert.True(Directory.Exists(startCtx.Files.SessionDirectory)); + + await _sut.DiscardAsync(parent.Id, CancellationToken.None); + + Assert.False(Directory.Exists(startCtx.Files.SessionDirectory)); + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Manual, loaded!.Status); + Assert.Null(loaded.PlanningSessionToken); + } } \ No newline at end of file -- 2.49.1 From 77f7cf142324c4a5500ba43ef27d791b11e38c59 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 21:01:22 +0200 Subject: [PATCH 05/15] feat(worker): PlanningSessionManager.FinalizeAsync --- .../Planning/PlanningSessionManager.cs | 3 +++ .../Planning/PlanningSessionManagerTests.cs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index c312873..61de7c0 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -53,6 +53,9 @@ public sealed class PlanningSessionManager return new PlanningSessionStartContext(taskId, list.WorkingDir, files); } + public Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) + => _tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); + public async Task DiscardAsync(string taskId, CancellationToken ct) { var ok = await _tasks.DiscardPlanningAsync(taskId, ct); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs index daf1712..6d6e4bc 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -160,6 +160,22 @@ public sealed class PlanningSessionManagerTests : IDisposable _sut.ResumeAsync(parent.Id, CancellationToken.None)); } + [Fact] + public async Task FinalizeAsync_PromotesDraftsAndMarksPlanned() + { + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + await _sut.StartAsync(parent.Id, CancellationToken.None); + await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); + await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); + + var count = await _sut.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None); + + Assert.Equal(2, count); + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, loaded!.Status); + } + [Fact] public async Task DiscardAsync_DeletesSessionDirAndResetsTask() { -- 2.49.1 From d28164caf4a04c0c6cdbbdbf27a3c5ad56b8160d Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 21:04:06 +0200 Subject: [PATCH 06/15] feat(worker): PlanningSessionManager.GetPendingDraftCountAsync --- .../Planning/PlanningSessionManager.cs | 6 ++++++ .../Planning/PlanningSessionManagerTests.cs | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 61de7c0..e126e15 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -56,6 +56,12 @@ public sealed class PlanningSessionManager public Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) => _tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); + public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct) + { + var children = await _tasks.GetChildrenAsync(taskId, ct); + return children.Count(c => c.Status == TaskStatus.Draft); + } + public async Task DiscardAsync(string taskId, CancellationToken ct) { var ok = await _tasks.DiscardPlanningAsync(taskId, ct); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs index 6d6e4bc..b0196f6 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -176,6 +176,21 @@ public sealed class PlanningSessionManagerTests : IDisposable Assert.Equal(TaskStatus.Planned, loaded!.Status); } + [Fact] + public async Task GetPendingDraftCountAsync_ReturnsDraftCount() + { + var (listId, _) = await SeedListAsync(); + var parent = await SeedManualTaskAsync(listId); + await _sut.StartAsync(parent.Id, CancellationToken.None); + await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); + await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); + await _tasks.CreateChildAsync(parent.Id, "c3", null, null, null); + + var n = await _sut.GetPendingDraftCountAsync(parent.Id, CancellationToken.None); + + Assert.Equal(3, n); + } + [Fact] public async Task DiscardAsync_DeletesSessionDirAndResetsTask() { -- 2.49.1 From 43a374098064e6c4ffb9d1412d0b19f0e0386f94 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 21:08:15 +0200 Subject: [PATCH 07/15] feat(worker): WindowsTerminalPlanningLauncher with pre-flight checks --- .../Planning/IPlanningTerminalLauncher.cs | 12 ++ .../WindowsTerminalPlanningLauncher.cs | 132 ++++++++++++++++++ .../WindowsTerminalPlanningLauncherTests.cs | 47 +++++++ 3 files changed, 191 insertions(+) create mode 100644 src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs create mode 100644 src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs diff --git a/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs b/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs new file mode 100644 index 0000000..ddbe016 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/IPlanningTerminalLauncher.cs @@ -0,0 +1,12 @@ +namespace ClaudeDo.Worker.Planning; + +public interface IPlanningTerminalLauncher +{ + Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken); + Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken); +} + +public sealed class PlanningLaunchException : Exception +{ + public PlanningLaunchException(string message) : base(message) { } +} diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs new file mode 100644 index 0000000..a38948c --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs @@ -0,0 +1,132 @@ +// Claude CLI flags (verified 2026-04-23 via Context7): +// Thinking budget: env var MAX_THINKING_TOKENS=20000 (no CLI flag exists) +// Allowed-tools: --allowedTools (camelCase), comma-separated tokens +// System prompt: --append-system-prompt-file (file form) +// Session ID: no pre-assign flag; resume with --resume + +using System.Diagnostics; + +namespace ClaudeDo.Worker.Planning; + +public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher +{ + private const string AllowedTools = "mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill"; + private const string Model = "claude-sonnet-4-6"; + + private readonly string _wtPath; + private readonly string _claudePath; + + public WindowsTerminalPlanningLauncher(string wtPath, string claudePath) + { + _wtPath = wtPath; + _claudePath = claudePath; + } + + public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); + + if (!File.Exists(ctx.Files.McpConfigPath)) + throw new PlanningLaunchException($"MCP config file not found: {ctx.Files.McpConfigPath}"); + + var resolvedWt = Resolve(_wtPath); + if (resolvedWt is null) + throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}"); + + var resolvedClaude = Resolve(_claudePath); + if (resolvedClaude is null) + throw new PlanningLaunchException($"claude executable not found: {_claudePath}"); + + var initialPrompt = File.Exists(ctx.Files.InitialPromptPath) + ? File.ReadAllText(ctx.Files.InitialPromptPath) + : string.Empty; + + // Build cmd line: set MAX_THINKING_TOKENS=20000 && claude "prompt" + // UseShellExecute=true means we cannot set psi.Environment, so we inject via cmd /k. + var claudeArgs = BuildStartArgs(ctx, initialPrompt, resolvedClaude); + var cmdLine = $"set MAX_THINKING_TOKENS=20000 && {claudeArgs}"; + + var psi = new ProcessStartInfo + { + FileName = resolvedWt, + UseShellExecute = true, + WorkingDirectory = ctx.WorkingDir, + }; + // wt.exe -d cmd /k "" + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(ctx.WorkingDir); + psi.ArgumentList.Add("cmd"); + psi.ArgumentList.Add("/k"); + psi.ArgumentList.Add(cmdLine); + + Process.Start(psi); + return Task.CompletedTask; + } + + public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) + { + if (!Directory.Exists(ctx.WorkingDir)) + throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); + + if (!File.Exists(ctx.McpConfigPath)) + throw new PlanningLaunchException($"MCP config file not found: {ctx.McpConfigPath}"); + + var resolvedWt = Resolve(_wtPath); + if (resolvedWt is null) + throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}"); + + var resolvedClaude = Resolve(_claudePath); + if (resolvedClaude is null) + throw new PlanningLaunchException($"claude executable not found: {_claudePath}"); + + var psi = new ProcessStartInfo + { + FileName = resolvedWt, + UseShellExecute = true, + WorkingDirectory = ctx.WorkingDir, + }; + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(ctx.WorkingDir); + psi.ArgumentList.Add("cmd"); + psi.ArgumentList.Add("/k"); + psi.ArgumentList.Add(BuildResumeArgs(ctx, resolvedClaude)); + + Process.Start(psi); + return Task.CompletedTask; + } + + private static string BuildStartArgs(PlanningSessionStartContext ctx, string initialPrompt, string claudePath) + { + // Build as a flat string for cmd /k; quote paths that may contain spaces. + return $"\"{claudePath}\" --mcp-config \"{ctx.Files.McpConfigPath}\" --append-system-prompt-file \"{ctx.Files.SystemPromptPath}\" --allowedTools \"{AllowedTools}\" --model {Model} \"{EscapeArg(initialPrompt)}\""; + } + + private static string BuildResumeArgs(PlanningSessionResumeContext ctx, string claudePath) + { + return $"\"{claudePath}\" --resume {ctx.ClaudeSessionId} --mcp-config \"{ctx.McpConfigPath}\""; + } + + private static string EscapeArg(string value) => value.Replace("\"", "\\\""); + + private static string? Resolve(string pathOrName) + { + if (File.Exists(pathOrName)) + return pathOrName; + + // Try PATH resolution + var envPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var extensions = new[] { "", ".exe", ".cmd", ".bat" }; + foreach (var dir in envPath.Split(Path.PathSeparator)) + { + foreach (var ext in extensions) + { + var candidate = Path.Combine(dir, pathOrName + ext); + if (File.Exists(candidate)) + return candidate; + } + } + + return null; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs new file mode 100644 index 0000000..cc19d05 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Planning/WindowsTerminalPlanningLauncherTests.cs @@ -0,0 +1,47 @@ +using ClaudeDo.Worker.Planning; + +namespace ClaudeDo.Worker.Tests.Planning; + +public sealed class WindowsTerminalPlanningLauncherTests +{ + private static PlanningSessionStartContext MakeStartCtx(string? wd = null) + { + var workingDir = wd ?? Path.GetTempPath(); + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(dir); + return new PlanningSessionStartContext( + ParentTaskId: "task-1", + WorkingDir: workingDir, + Files: new PlanningSessionFiles( + SessionDirectory: dir, + McpConfigPath: Path.Combine(dir, "mcp.json"), + SystemPromptPath: Path.Combine(dir, "system-prompt.md"), + InitialPromptPath: Path.Combine(dir, "initial-prompt.txt"))); + } + + [Fact] + public async Task LaunchStartAsync_WorkingDirMissing_Throws() + { + var ctx = MakeStartCtx(wd: Path.Combine(Path.GetTempPath(), "nonexistent_" + Guid.NewGuid())); + var sut = new WindowsTerminalPlanningLauncher(wtPath: "wt", claudePath: "claude"); + var ex = await Assert.ThrowsAsync(() => + sut.LaunchStartAsync(ctx, CancellationToken.None)); + Assert.Contains("Working directory", ex.Message); + } + + [Fact] + public async Task LaunchStartAsync_WtMissing_Throws() + { + var ctx = MakeStartCtx(); + File.WriteAllText(ctx.Files.McpConfigPath, "{}"); + File.WriteAllText(ctx.Files.SystemPromptPath, "sp"); + File.WriteAllText(ctx.Files.InitialPromptPath, "ip"); + + var sut = new WindowsTerminalPlanningLauncher( + wtPath: "C:/no/such/wt.exe", + claudePath: "claude"); + var ex = await Assert.ThrowsAsync(() => + sut.LaunchStartAsync(ctx, CancellationToken.None)); + Assert.Contains("Windows Terminal", ex.Message); + } +} -- 2.49.1 From 9e09ae6b4ebf21014e3e3a7c4aa57ae48aa16865 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 21:13:23 +0200 Subject: [PATCH 08/15] =?UTF-8?q?fix(worker):=20planning=20launcher=20?= =?UTF-8?q?=E2=80=94=20avoid=20cmd=20shell=20to=20prevent=20prompt=20injec?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WindowsTerminalPlanningLauncher.cs | 69 +++++++++---------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs index a38948c..cb03995 100644 --- a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs @@ -3,6 +3,8 @@ // Allowed-tools: --allowedTools (camelCase), comma-separated tokens // System prompt: --append-system-prompt-file (file form) // Session ID: no pre-assign flag; resume with --resume +// Launch model: wt.exe directly spawns claude.exe via argv (UseShellExecute=false). +// No cmd /k shim — arbitrary initial-prompt content would be re-parsed by cmd.exe otherwise. using System.Diagnostics; @@ -38,29 +40,31 @@ public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher if (resolvedClaude is null) throw new PlanningLaunchException($"claude executable not found: {_claudePath}"); - var initialPrompt = File.Exists(ctx.Files.InitialPromptPath) - ? File.ReadAllText(ctx.Files.InitialPromptPath) - : string.Empty; - - // Build cmd line: set MAX_THINKING_TOKENS=20000 && claude "prompt" - // UseShellExecute=true means we cannot set psi.Environment, so we inject via cmd /k. - var claudeArgs = BuildStartArgs(ctx, initialPrompt, resolvedClaude); - var cmdLine = $"set MAX_THINKING_TOKENS=20000 && {claudeArgs}"; - var psi = new ProcessStartInfo { FileName = resolvedWt, - UseShellExecute = true, - WorkingDirectory = ctx.WorkingDir, + UseShellExecute = false, + CreateNoWindow = false, }; - // wt.exe -d cmd /k "" + + psi.Environment["MAX_THINKING_TOKENS"] = "20000"; + psi.ArgumentList.Add("-d"); psi.ArgumentList.Add(ctx.WorkingDir); - psi.ArgumentList.Add("cmd"); - psi.ArgumentList.Add("/k"); - psi.ArgumentList.Add(cmdLine); + psi.ArgumentList.Add(resolvedClaude); + psi.ArgumentList.Add("--model"); + psi.ArgumentList.Add(Model); + psi.ArgumentList.Add("--mcp-config"); + psi.ArgumentList.Add(ctx.Files.McpConfigPath); + psi.ArgumentList.Add("--append-system-prompt-file"); + psi.ArgumentList.Add(ctx.Files.SystemPromptPath); + psi.ArgumentList.Add("--allowedTools"); + psi.ArgumentList.Add(AllowedTools); + psi.ArgumentList.Add(File.ReadAllText(ctx.Files.InitialPromptPath)); + + var proc = Process.Start(psi) + ?? throw new PlanningLaunchException("Failed to start Windows Terminal process."); - Process.Start(psi); return Task.CompletedTask; } @@ -69,9 +73,6 @@ public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher if (!Directory.Exists(ctx.WorkingDir)) throw new PlanningLaunchException($"Working directory does not exist: {ctx.WorkingDir}"); - if (!File.Exists(ctx.McpConfigPath)) - throw new PlanningLaunchException($"MCP config file not found: {ctx.McpConfigPath}"); - var resolvedWt = Resolve(_wtPath); if (resolvedWt is null) throw new PlanningLaunchException($"Windows Terminal not found: {_wtPath}"); @@ -83,32 +84,24 @@ public sealed class WindowsTerminalPlanningLauncher : IPlanningTerminalLauncher var psi = new ProcessStartInfo { FileName = resolvedWt, - UseShellExecute = true, - WorkingDirectory = ctx.WorkingDir, + UseShellExecute = false, + CreateNoWindow = false, }; + psi.ArgumentList.Add("-d"); psi.ArgumentList.Add(ctx.WorkingDir); - psi.ArgumentList.Add("cmd"); - psi.ArgumentList.Add("/k"); - psi.ArgumentList.Add(BuildResumeArgs(ctx, resolvedClaude)); + psi.ArgumentList.Add(resolvedClaude); + psi.ArgumentList.Add("--resume"); + psi.ArgumentList.Add(ctx.ClaudeSessionId); + psi.ArgumentList.Add("--mcp-config"); + psi.ArgumentList.Add(ctx.McpConfigPath); + + var proc = Process.Start(psi) + ?? throw new PlanningLaunchException("Failed to start Windows Terminal process."); - Process.Start(psi); return Task.CompletedTask; } - private static string BuildStartArgs(PlanningSessionStartContext ctx, string initialPrompt, string claudePath) - { - // Build as a flat string for cmd /k; quote paths that may contain spaces. - return $"\"{claudePath}\" --mcp-config \"{ctx.Files.McpConfigPath}\" --append-system-prompt-file \"{ctx.Files.SystemPromptPath}\" --allowedTools \"{AllowedTools}\" --model {Model} \"{EscapeArg(initialPrompt)}\""; - } - - private static string BuildResumeArgs(PlanningSessionResumeContext ctx, string claudePath) - { - return $"\"{claudePath}\" --resume {ctx.ClaudeSessionId} --mcp-config \"{ctx.McpConfigPath}\""; - } - - private static string EscapeArg(string value) => value.Replace("\"", "\\\""); - private static string? Resolve(string pathOrName) { if (File.Exists(pathOrName)) -- 2.49.1 From b115a4c5122980adc6920977fe357e2e318df25f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 21:15:38 +0200 Subject: [PATCH 09/15] feat(worker): MCP bearer-token auth middleware --- .../Planning/PlanningMcpContext.cs | 6 +++ .../Planning/PlanningTokenAuth.cs | 40 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs create mode 100644 src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs new file mode 100644 index 0000000..bf29dcd --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpContext.cs @@ -0,0 +1,6 @@ +namespace ClaudeDo.Worker.Planning; + +public sealed class PlanningMcpContext +{ + public required string ParentTaskId { get; init; } +} diff --git a/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs b/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs new file mode 100644 index 0000000..6cf7043 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs @@ -0,0 +1,40 @@ +using ClaudeDo.Data.Repositories; +using Microsoft.AspNetCore.Http; + +namespace ClaudeDo.Worker.Planning; + +public sealed class PlanningTokenAuthMiddleware +{ + private readonly RequestDelegate _next; + + public PlanningTokenAuthMiddleware(RequestDelegate next) => _next = next; + + public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks) + { + if (!ctx.Request.Path.StartsWithSegments("/mcp")) + { + await _next(ctx); + return; + } + + var auth = ctx.Request.Headers["Authorization"].ToString(); + if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Missing bearer token"); + return; + } + + var token = auth.Substring("Bearer ".Length).Trim(); + var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted); + if (parent is null || parent.Status != ClaudeDo.Data.Models.TaskStatus.Planning) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("Invalid or expired planning token"); + return; + } + + ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id }; + await _next(ctx); + } +} -- 2.49.1 From 0088d6e0e08e46253cccb2d6acc6eb7fad411101 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 22:57:27 +0200 Subject: [PATCH 10/15] feat(worker): MCP tools for child-task CRUD --- .../Planning/PlanningMcpService.cs | 85 ++++++++++++++ .../Planning/PlanningMcpServiceTests.cs | 107 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/ClaudeDo.Worker/Planning/PlanningMcpService.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs new file mode 100644 index 0000000..2bb1a34 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -0,0 +1,85 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Planning; + +public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList Tags); +public sealed record CreatedChildDto(string TaskId, string Status); + +public sealed class PlanningMcpService +{ + private readonly TaskRepository _tasks; + + public PlanningMcpService(TaskRepository tasks) => _tasks = tasks; + + public async Task CreateChildTask( + PlanningMcpContext ctx, + string title, + string? description, + IReadOnlyList? tags, + string? commitType, + CancellationToken cancellationToken) + { + var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken); + return new CreatedChildDto(child.Id, "Draft"); + } + + public async Task> ListChildTasks( + PlanningMcpContext ctx, + CancellationToken cancellationToken) + { + var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); + var list = new List(children.Count); + foreach (var c in children) + { + var tagList = await _tasks.GetTagsAsync(c.Id, cancellationToken); + list.Add(new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString(), tagList.Select(t => t.Name).ToList())); + } + return list; + } + + public async Task UpdateChildTask( + PlanningMcpContext ctx, + string taskId, + string? title, + string? description, + IReadOnlyList? tags, + string? commitType, + CancellationToken cancellationToken) + { + var child = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (child.ParentTaskId != ctx.ParentTaskId) + throw new InvalidOperationException("Task is not a child of this planning session."); + if (child.Status != TaskStatus.Draft) + throw new InvalidOperationException("Cannot modify a finalized task."); + + if (title is not null) child.Title = title; + if (description is not null) child.Description = description; + if (commitType is not null) child.CommitType = commitType; + await _tasks.UpdateAsync(child, cancellationToken); + + // Tag handling omitted for v1 simplicity — tags set at create time. + // If Claude asks to update tags, it can delete and re-create. + + var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; + var tagList = await _tasks.GetTagsAsync(reload.Id, cancellationToken); + return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString(), tagList.Select(t => t.Name).ToList()); + } + + public async Task DeleteChildTask( + PlanningMcpContext ctx, + string taskId, + CancellationToken cancellationToken) + { + var child = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (child.ParentTaskId != ctx.ParentTaskId) + throw new InvalidOperationException("Task is not a child of this planning session."); + if (child.Status != TaskStatus.Draft) + throw new InvalidOperationException("Cannot delete a finalized task."); + + await _tasks.DeleteAsync(taskId, cancellationToken); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs new file mode 100644 index 0000000..fdc0505 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -0,0 +1,107 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Planning; + +public sealed class PlanningMcpServiceTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly PlanningMcpService _sut; + + public PlanningMcpServiceTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + _sut = new PlanningMcpService(_tasks); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task SeedPlanningParentAsync() + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "p", + Status = TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + await _tasks.SetPlanningStartedAsync(parent.Id, "tok"); + return (await _tasks.GetByIdAsync(parent.Id))!; + } + + private static PlanningMcpContext Ctx(string parentId) => new() { ParentTaskId = parentId }; + + [Fact] + public async Task CreateChildTask_CreatesDraft() + { + var parent = await SeedPlanningParentAsync(); + + var result = await _sut.CreateChildTask(Ctx(parent.Id), "My child", "desc", null, null, CancellationToken.None); + + Assert.Equal("Draft", result.Status); + var child = await _tasks.GetByIdAsync(result.TaskId); + Assert.Equal("My child", child!.Title); + Assert.Equal(TaskStatus.Draft, child.Status); + } + + [Fact] + public async Task ListChildTasks_ReturnsOnlyThisParentsChildren() + { + var parent = await SeedPlanningParentAsync(); + var other = await SeedPlanningParentAsync(); + + await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null); + await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null); + + var list = await _sut.ListChildTasks(Ctx(parent.Id), CancellationToken.None); + Assert.Single(list); + Assert.Equal("mine", list[0].Title); + } + + [Fact] + public async Task UpdateChildTask_NotAChild_Throws() + { + var parent = await SeedPlanningParentAsync(); + var other = await SeedPlanningParentAsync(); + var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null); + + await Assert.ThrowsAsync(() => + _sut.UpdateChildTask(Ctx(parent.Id), otherChild.Id, "new", null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task UpdateChildTask_NotDraft_Throws() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); + + await Assert.ThrowsAsync(() => + _sut.UpdateChildTask(Ctx(parent.Id), c.Id, "new", null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task DeleteChildTask_RemovesDraft() + { + var parent = await SeedPlanningParentAsync(); + var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); + + await _sut.DeleteChildTask(Ctx(parent.Id), c.Id, CancellationToken.None); + + Assert.Null(await _tasks.GetByIdAsync(c.Id)); + } +} -- 2.49.1 From 99c6a71e4c807a09eef1e8bca2421c399d650fb8 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 23:03:42 +0200 Subject: [PATCH 11/15] feat(worker): MCP tools update_planning_task and finalize --- .../Repositories/TaskRepository.cs | 17 ++++++++++++ .../Planning/PlanningMcpService.cs | 15 +++++++++++ .../Planning/PlanningMcpServiceTests.cs | 27 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index d746589..232f00d 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -267,6 +267,23 @@ public sealed class TaskRepository return child; } + public async Task UpdatePlanningTaskAsync( + string taskId, + string? title, + string? description, + CancellationToken ct = default) + { + var entity = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct) + ?? throw new InvalidOperationException("Planning task not found."); + if (title is not null) entity.Title = title; + if (description is not null) entity.Description = description; + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Title, entity.Title) + .SetProperty(t => t.Description, entity.Description), ct); + } + public async Task SetPlanningStartedAsync( string taskId, string sessionToken, diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs index 2bb1a34..f22feb1 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -82,4 +82,19 @@ public sealed class PlanningMcpService await _tasks.DeleteAsync(taskId, cancellationToken); } + + public async Task UpdatePlanningTask( + PlanningMcpContext ctx, + string? title, + string? description, + CancellationToken cancellationToken) + { + await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken); + } + + public Task Finalize( + PlanningMcpContext ctx, + bool queueAgentTasks, + CancellationToken cancellationToken) + => _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken); } diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs index fdc0505..6a9fe5e 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -104,4 +104,31 @@ public sealed class PlanningMcpServiceTests : IDisposable Assert.Null(await _tasks.GetByIdAsync(c.Id)); } + + [Fact] + public async Task UpdatePlanningTask_SetsTitleAndDescription() + { + var parent = await SeedPlanningParentAsync(); + + await _sut.UpdatePlanningTask(Ctx(parent.Id), "new title", "new desc", CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal("new title", loaded!.Title); + Assert.Equal("new desc", loaded.Description); + } + + [Fact] + public async Task Finalize_PromotesDraftsAndInvalidatesToken() + { + var parent = await SeedPlanningParentAsync(); + await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); + await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); + + var count = await _sut.Finalize(Ctx(parent.Id), true, CancellationToken.None); + + Assert.Equal(2, count); + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, loaded!.Status); + Assert.Null(loaded.PlanningSessionToken); + } } -- 2.49.1 From 6cb20a9213ced69bae1b78a5564debc1a294ae86 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 23:12:24 +0200 Subject: [PATCH 12/15] feat(worker): map MCP HTTP endpoint and broadcast TaskUpdated - Add PlanningMcpContextAccessor (Option A) to read PlanningMcpContext from HttpContext.Items set by PlanningTokenAuthMiddleware - Annotate PlanningMcpService with [McpServerToolType]/[McpServerTool] and remove PlanningMcpContext ctx parameter from all tool methods - Broadcast TaskUpdated(parentTaskId) via HubBroadcaster after every mutation in PlanningMcpService - Refactor PlanningSessionManager to accept IDbContextFactory for singleton-safe use in DI; keep direct-repo ctor for tests - Register PlanningSessionManager (singleton), IPlanningTerminalLauncher, PlanningMcpContextAccessor, PlanningMcpService, and MCP server in Program.cs; wire PlanningTokenAuthMiddleware and MapMcp("/mcp") - Update PlanningMcpServiceTests with fake HttpContext accessor and no-op HubBroadcaster (avoids Moq dependency) Co-Authored-By: Claude Sonnet 4.6 --- .../Planning/PlanningMcpContextAccessor.cs | 14 ++++ .../Planning/PlanningMcpService.cs | 52 ++++++++++---- .../Planning/PlanningSessionManager.cs | 60 ++++++++++++---- src/ClaudeDo.Worker/Program.cs | 19 +++++ .../Planning/PlanningMcpServiceTests.cs | 69 ++++++++++++++++--- 5 files changed, 178 insertions(+), 36 deletions(-) create mode 100644 src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs new file mode 100644 index 0000000..98bb220 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpContextAccessor.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; + +namespace ClaudeDo.Worker.Planning; + +public sealed class PlanningMcpContextAccessor +{ + private readonly IHttpContextAccessor _http; + + public PlanningMcpContextAccessor(IHttpContextAccessor http) => _http = http; + + public PlanningMcpContext Current => + (_http.HttpContext?.Items["PlanningContext"] as PlanningMcpContext) + ?? throw new InvalidOperationException("No planning context on request."); +} diff --git a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs index f22feb1..302fe08 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -1,5 +1,8 @@ +using System.ComponentModel; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Planning; @@ -7,28 +10,45 @@ namespace ClaudeDo.Worker.Planning; public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status, IReadOnlyList Tags); public sealed record CreatedChildDto(string TaskId, string Status); +[McpServerToolType] public sealed class PlanningMcpService { private readonly TaskRepository _tasks; + private readonly PlanningMcpContextAccessor _contextAccessor; + private readonly HubBroadcaster _broadcaster; - public PlanningMcpService(TaskRepository tasks) => _tasks = tasks; + public PlanningMcpService( + TaskRepository tasks, + PlanningMcpContextAccessor contextAccessor, + HubBroadcaster broadcaster) + { + _tasks = tasks; + _contextAccessor = contextAccessor; + _broadcaster = broadcaster; + } + private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct) + => _broadcaster.TaskUpdated(taskId); + + [McpServerTool, Description("Create a new draft child task under the current planning session's parent task.")] public async Task CreateChildTask( - PlanningMcpContext ctx, string title, string? description, IReadOnlyList? tags, string? commitType, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, tags, commitType, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); return new CreatedChildDto(child.Id, "Draft"); } + [McpServerTool, Description("List all child tasks under the current planning session's parent task.")] public async Task> ListChildTasks( - PlanningMcpContext ctx, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken); var list = new List(children.Count); foreach (var c in children) @@ -39,8 +59,8 @@ public sealed class PlanningMcpService return list; } + [McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")] public async Task UpdateChildTask( - PlanningMcpContext ctx, string taskId, string? title, string? description, @@ -48,6 +68,7 @@ public sealed class PlanningMcpService string? commitType, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; var child = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (child.ParentTaskId != ctx.ParentTaskId) @@ -60,19 +81,18 @@ public sealed class PlanningMcpService if (commitType is not null) child.CommitType = commitType; await _tasks.UpdateAsync(child, cancellationToken); - // Tag handling omitted for v1 simplicity — tags set at create time. - // If Claude asks to update tags, it can delete and re-create. - var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var tagList = await _tasks.GetTagsAsync(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()); } + [McpServerTool, Description("Delete a draft child task. Only Draft tasks may be deleted.")] public async Task DeleteChildTask( - PlanningMcpContext ctx, string taskId, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; var child = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (child.ParentTaskId != ctx.ParentTaskId) @@ -81,20 +101,28 @@ public sealed class PlanningMcpService throw new InvalidOperationException("Cannot delete a finalized task."); await _tasks.DeleteAsync(taskId, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); } + [McpServerTool, Description("Update the title and/or description of the parent planning task itself.")] public async Task UpdatePlanningTask( - PlanningMcpContext ctx, string? title, string? description, CancellationToken cancellationToken) { + var ctx = _contextAccessor.Current; await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); } - public Task Finalize( - PlanningMcpContext ctx, + [McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")] + public async Task Finalize( bool queueAgentTasks, CancellationToken cancellationToken) - => _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken); + { + var ctx = _contextAccessor.Current; + var count = await _tasks.FinalizePlanningAsync(ctx.ParentTaskId, queueAgentTasks, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); + return count; + } } diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index e126e15..1f0f88d 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -1,8 +1,10 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Planning; @@ -11,20 +13,40 @@ public sealed class PlanningSessionManager { private const string McpServerUrl = "http://127.0.0.1:47821/mcp"; - private readonly TaskRepository _tasks; - private readonly ListRepository _lists; + private readonly IDbContextFactory? _factory; + private readonly TaskRepository? _tasksOverride; + private readonly ListRepository? _listsOverride; private readonly string _rootDirectory; + // DI constructor — uses factory so this singleton can create scoped repos per call. + public PlanningSessionManager(IDbContextFactory factory, string rootDirectory) + { + _factory = factory; + _rootDirectory = rootDirectory; + } + + // Test constructor — accepts repos directly (single shared context, test-scoped). public PlanningSessionManager(TaskRepository tasks, ListRepository lists, string rootDirectory) { - _tasks = tasks; - _lists = lists; + _tasksOverride = tasks; + _listsOverride = lists; _rootDirectory = rootDirectory; } + private (TaskRepository tasks, ListRepository lists, ClaudeDoDbContext? ctx) CreateRepos() + { + if (_tasksOverride is not null) + return (_tasksOverride, _listsOverride!, null); + var ctx = _factory!.CreateDbContext(); + return (new TaskRepository(ctx), new ListRepository(ctx), ctx); + } + public async Task StartAsync(string taskId, CancellationToken ct) { - var task = await _tasks.GetByIdAsync(taskId, ct) + var (tasks, lists, ctx) = CreateRepos(); + await using var _ = ctx; + + var task = await tasks.GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.ParentTaskId is not null) throw new InvalidOperationException("Cannot start a planning session on a child task."); @@ -32,7 +54,7 @@ public sealed class PlanningSessionManager throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning."); var token = GenerateToken(); - _ = await _tasks.SetPlanningStartedAsync(taskId, token, ct) + var started = await tasks.SetPlanningStartedAsync(taskId, token, ct) ?? throw new InvalidOperationException("Failed to transition task to Planning."); var sessionDir = Path.Combine(_rootDirectory, taskId); @@ -48,23 +70,32 @@ public sealed class PlanningSessionManager await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct); await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct); - var list = await _lists.GetByIdAsync(task.ListId, ct) + var list = await lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); return new PlanningSessionStartContext(taskId, list.WorkingDir, files); } - public Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) - => _tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); + public async Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) + { + var (tasks, _, ctx) = CreateRepos(); + await using var __ = ctx; + return await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct); + } public async Task GetPendingDraftCountAsync(string taskId, CancellationToken ct) { - var children = await _tasks.GetChildrenAsync(taskId, ct); + var (tasks, _, ctx) = CreateRepos(); + await using var __ = ctx; + var children = await tasks.GetChildrenAsync(taskId, ct); return children.Count(c => c.Status == TaskStatus.Draft); } public async Task DiscardAsync(string taskId, CancellationToken ct) { - var ok = await _tasks.DiscardPlanningAsync(taskId, ct); + var (tasks, _, ctx) = CreateRepos(); + await using var __ = ctx; + + var ok = await tasks.DiscardPlanningAsync(taskId, ct); var sessionDir = Path.Combine(_rootDirectory, taskId); if (Directory.Exists(sessionDir)) { @@ -77,7 +108,10 @@ public sealed class PlanningSessionManager public async Task ResumeAsync(string taskId, CancellationToken ct) { - var task = await _tasks.GetByIdAsync(taskId, ct) + var (tasks, lists, ctx) = CreateRepos(); + await using var _ = ctx; + + var task = await tasks.GetByIdAsync(taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (task.Status != TaskStatus.Planning) throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning."); @@ -89,7 +123,7 @@ public sealed class PlanningSessionManager if (!File.Exists(mcpConfigPath)) throw new InvalidOperationException($"Session directory missing: {sessionDir}"); - var list = await _lists.GetByIdAsync(task.ListId, ct) + var list = await lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); return new PlanningSessionResumeContext(taskId, list.WorkingDir, task.PlanningSessionId, mcpConfigPath); } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 5f4977a..5d0f199 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -3,6 +3,7 @@ using ClaudeDo.Data.Git; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Services; using Microsoft.EntityFrameworkCore; @@ -51,6 +52,22 @@ builder.Services.AddSingleton(sp => new DefaultAgentSeeder( builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// Planning session services. +var planningSessionsDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".todo-app", "planning-sessions"); +builder.Services.AddSingleton(sp => + new PlanningSessionManager( + sp.GetRequiredService>(), + planningSessionsDir)); +builder.Services.AddSingleton(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools(); + // Loopback-only bind. Firewall is irrelevant for 127.0.0.1. builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); @@ -75,7 +92,9 @@ catch (Exception ex) app.Logger.LogWarning(ex, "Default agent seeding failed"); } +app.UseMiddleware(); app.MapHub("/hub"); +app.MapMcp("/mcp"); app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", cfg.SignalRPort, cfg.DbPath); diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs index 6a9fe5e..837912a 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -1,30 +1,72 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Planning; +// Minimal fakes — avoids Moq dependency. +file sealed class FakeHttpContextAccessor : IHttpContextAccessor +{ + public HttpContext? HttpContext { get; set; } +} + +file sealed class NullHubClients : IHubClients +{ + public IClientProxy All => NullClientProxy.Instance; + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => NullClientProxy.Instance; + public IClientProxy Client(string connectionId) => NullClientProxy.Instance; + public IClientProxy Clients(IReadOnlyList connectionIds) => NullClientProxy.Instance; + public IClientProxy Group(string groupName) => NullClientProxy.Instance; + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => NullClientProxy.Instance; + public IClientProxy Groups(IReadOnlyList groupNames) => NullClientProxy.Instance; + public IClientProxy User(string userId) => NullClientProxy.Instance; + public IClientProxy Users(IReadOnlyList userIds) => NullClientProxy.Instance; +} + +file sealed class NullClientProxy : IClientProxy +{ + public static readonly NullClientProxy Instance = new(); + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +file sealed class FakeHubContext : IHubContext +{ + public IHubClients Clients { get; } = new NullHubClients(); + public IGroupManager Groups => throw new NotImplementedException(); +} + public sealed class PlanningMcpServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; - private readonly PlanningMcpService _sut; public PlanningMcpServiceTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); - _sut = new PlanningMcpService(_tasks); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + 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()); + return new PlanningMcpService(_tasks, accessor, broadcaster); + } + private async Task SeedPlanningParentAsync() { var listId = Guid.NewGuid().ToString(); @@ -43,14 +85,13 @@ public sealed class PlanningMcpServiceTests : IDisposable return (await _tasks.GetByIdAsync(parent.Id))!; } - private static PlanningMcpContext Ctx(string parentId) => new() { ParentTaskId = parentId }; - [Fact] public async Task CreateChildTask_CreatesDraft() { var parent = await SeedPlanningParentAsync(); + var sut = BuildSut(parent.Id); - var result = await _sut.CreateChildTask(Ctx(parent.Id), "My child", "desc", null, null, CancellationToken.None); + var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None); Assert.Equal("Draft", result.Status); var child = await _tasks.GetByIdAsync(result.TaskId); @@ -67,7 +108,8 @@ public sealed class PlanningMcpServiceTests : IDisposable await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null); await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null); - var list = await _sut.ListChildTasks(Ctx(parent.Id), CancellationToken.None); + var sut = BuildSut(parent.Id); + var list = await sut.ListChildTasks(CancellationToken.None); Assert.Single(list); Assert.Equal("mine", list[0].Title); } @@ -79,8 +121,9 @@ public sealed class PlanningMcpServiceTests : IDisposable var other = await SeedPlanningParentAsync(); var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null); + var sut = BuildSut(parent.Id); await Assert.ThrowsAsync(() => - _sut.UpdateChildTask(Ctx(parent.Id), otherChild.Id, "new", null, null, null, CancellationToken.None)); + sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None)); } [Fact] @@ -90,8 +133,9 @@ public sealed class PlanningMcpServiceTests : IDisposable var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); + var sut = BuildSut(parent.Id); await Assert.ThrowsAsync(() => - _sut.UpdateChildTask(Ctx(parent.Id), c.Id, "new", null, null, null, CancellationToken.None)); + sut.UpdateChildTask(c.Id, "new", null, null, null, CancellationToken.None)); } [Fact] @@ -100,7 +144,8 @@ public sealed class PlanningMcpServiceTests : IDisposable var parent = await SeedPlanningParentAsync(); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); - await _sut.DeleteChildTask(Ctx(parent.Id), c.Id, CancellationToken.None); + var sut = BuildSut(parent.Id); + await sut.DeleteChildTask(c.Id, CancellationToken.None); Assert.Null(await _tasks.GetByIdAsync(c.Id)); } @@ -110,7 +155,8 @@ public sealed class PlanningMcpServiceTests : IDisposable { var parent = await SeedPlanningParentAsync(); - await _sut.UpdatePlanningTask(Ctx(parent.Id), "new title", "new desc", CancellationToken.None); + var sut = BuildSut(parent.Id); + await sut.UpdatePlanningTask("new title", "new desc", CancellationToken.None); var loaded = await _tasks.GetByIdAsync(parent.Id); Assert.Equal("new title", loaded!.Title); @@ -124,7 +170,8 @@ public sealed class PlanningMcpServiceTests : IDisposable await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null); await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null); - var count = await _sut.Finalize(Ctx(parent.Id), true, CancellationToken.None); + var sut = BuildSut(parent.Id); + var count = await sut.Finalize(true, CancellationToken.None); Assert.Equal(2, count); var loaded = await _tasks.GetByIdAsync(parent.Id); -- 2.49.1 From c048264b954685ab55fa97cd1fafbedd0aebbc12 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 23:17:30 +0200 Subject: [PATCH 13/15] fix(worker): register TaskRepository in DI and guard null WorkingDir --- src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs | 4 ++-- src/ClaudeDo.Worker/Program.cs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 1f0f88d..84f5991 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -72,7 +72,7 @@ public sealed class PlanningSessionManager var list = await lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); - return new PlanningSessionStartContext(taskId, list.WorkingDir, files); + return new PlanningSessionStartContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), files); } public async Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) @@ -125,7 +125,7 @@ public sealed class PlanningSessionManager var list = await lists.GetByIdAsync(task.ListId, ct) ?? throw new InvalidOperationException($"List {task.ListId} not found."); - return new PlanningSessionResumeContext(taskId, list.WorkingDir, task.PlanningSessionId, mcpConfigPath); + return new PlanningSessionResumeContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), task.PlanningSessionId, mcpConfigPath); } private static string GenerateToken() diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 5d0f199..25c9ece 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -63,6 +63,9 @@ builder.Services.AddSingleton(sp => builder.Services.AddSingleton(); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); +builder.Services.AddScoped(sp => + sp.GetRequiredService>().CreateDbContext()); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddMcpServer() .WithHttpTransport() -- 2.49.1 From 7b67e357205c609d53e563da7eaaaf821a6e0481 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 23:26:12 +0200 Subject: [PATCH 14/15] 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() { } +} -- 2.49.1 From 0782ba574b26edb8d904be0b84ca754261ec351e Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 23:31:01 +0200 Subject: [PATCH 15/15] test(worker): planning session end-to-end --- .../Planning/PlanningEndToEndTests.cs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs new file mode 100644 index 0000000..4451cde --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningEndToEndTests.cs @@ -0,0 +1,109 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Planning; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.SignalR; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Planning; + +// Inline fakes — test isolation beats DRY; mirrors PlanningMcpServiceTests pattern. +file sealed class E2EFakeHttpContextAccessor : IHttpContextAccessor +{ + public HttpContext? HttpContext { get; set; } +} + +file sealed class E2ENullHubClients : IHubClients +{ + public IClientProxy All => E2ENullClientProxy.Instance; + public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => E2ENullClientProxy.Instance; + public IClientProxy Client(string connectionId) => E2ENullClientProxy.Instance; + public IClientProxy Clients(IReadOnlyList connectionIds) => E2ENullClientProxy.Instance; + public IClientProxy Group(string groupName) => E2ENullClientProxy.Instance; + public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => E2ENullClientProxy.Instance; + public IClientProxy Groups(IReadOnlyList groupNames) => E2ENullClientProxy.Instance; + public IClientProxy User(string userId) => E2ENullClientProxy.Instance; + public IClientProxy Users(IReadOnlyList userIds) => E2ENullClientProxy.Instance; +} + +file sealed class E2ENullClientProxy : IClientProxy +{ + public static readonly E2ENullClientProxy Instance = new(); + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +file sealed class E2EFakeHubContext : IHubContext +{ + public IHubClients Clients { get; } = new E2ENullHubClients(); + public IGroupManager Groups => throw new NotImplementedException(); +} + +public sealed class PlanningEndToEndTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private readonly PlanningSessionManager _manager; + private readonly DefaultHttpContext _httpContext; + private readonly PlanningMcpContextAccessor _accessor; + private readonly PlanningMcpService _svc; + + public PlanningEndToEndTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + + var root = Path.Combine(Path.GetTempPath(), $"cd_e2e_{Guid.NewGuid():N}"); + _manager = new PlanningSessionManager(_tasks, _lists, root); + + _httpContext = new DefaultHttpContext(); + _accessor = new PlanningMcpContextAccessor(new E2EFakeHttpContextAccessor { HttpContext = _httpContext }); + var broadcaster = new HubBroadcaster(new E2EFakeHubContext()); + _svc = new PlanningMcpService(_tasks, _accessor, broadcaster); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + [Fact] + public async Task StartThenCreateThenFinalize_FullFlow() + { + var listId = Guid.NewGuid().ToString(); + var wd = Path.GetTempPath(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow }); + + var parent = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "Big Task", + Status = TaskStatus.Manual, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(parent); + + var startCtx = await _manager.StartAsync(parent.Id, CancellationToken.None); + Assert.True(File.Exists(startCtx.Files.McpConfigPath)); + + // Wire the ambient context so _svc reads the correct parent + _httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id }; + + await _svc.CreateChildTask("sub 1", null, null, null, CancellationToken.None); + await _svc.CreateChildTask("sub 2", null, null, null, CancellationToken.None); + + var count = await _svc.Finalize(true, CancellationToken.None); + Assert.Equal(2, count); + + var reload = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, reload!.Status); + + var kids = await _tasks.GetChildrenAsync(parent.Id); + Assert.All(kids, k => Assert.Equal(TaskStatus.Manual, k.Status)); + } +} -- 2.49.1