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.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/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 @@ + + 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/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/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/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 new file mode 100644 index 0000000..302fe08 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs @@ -0,0 +1,128 @@ +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; + +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, + 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( + 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( + 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) + { + 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; + } + + [McpServerTool, Description("Update an existing draft child task. Only Draft tasks may be modified.")] + public async Task UpdateChildTask( + string taskId, + string? title, + string? description, + IReadOnlyList? tags, + 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) + 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); + + 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( + 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) + 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); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); + } + + [McpServerTool, Description("Update the title and/or description of the parent planning task itself.")] + public async Task UpdatePlanningTask( + string? title, + string? description, + CancellationToken cancellationToken) + { + var ctx = _contextAccessor.Current; + await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken); + await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); + } + + [McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")] + public async Task Finalize( + bool queueAgentTasks, + CancellationToken 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/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..84f5991 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -0,0 +1,186 @@ +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; + +public sealed class PlanningSessionManager +{ + private const string McpServerUrl = "http://127.0.0.1:47821/mcp"; + + 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) + { + _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 (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."); + if (task.Status != TaskStatus.Manual) + throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning."); + + var token = GenerateToken(); + var started = 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 ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), files); + } + + 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 (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 (tasks, _, ctx) = CreateRepos(); + await using var __ = ctx; + + 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 (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."); + 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 ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), task.PlanningSessionId, mcpConfigPath); + } + + 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/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); + } +} diff --git a/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs new file mode 100644 index 0000000..cb03995 --- /dev/null +++ b/src/ClaudeDo.Worker/Planning/WindowsTerminalPlanningLauncher.cs @@ -0,0 +1,125 @@ +// 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 +// 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; + +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 psi = new ProcessStartInfo + { + FileName = resolvedWt, + UseShellExecute = false, + CreateNoWindow = false, + }; + + psi.Environment["MAX_THINKING_TOKENS"] = "20000"; + + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(ctx.WorkingDir); + 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."); + + 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}"); + + 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 = false, + CreateNoWindow = false, + }; + + psi.ArgumentList.Add("-d"); + psi.ArgumentList.Add(ctx.WorkingDir); + 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."); + + return Task.CompletedTask; + } + + 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/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 5f4977a..25c9ece 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,25 @@ 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(sp => + sp.GetRequiredService>().CreateDbContext()); +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 +95,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/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() { } +} 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/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)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs new file mode 100644 index 0000000..837912a --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs @@ -0,0 +1,181 @@ +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; + + public PlanningMcpServiceTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + 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(); + 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))!; + } + + [Fact] + public async Task CreateChildTask_CreatesDraft() + { + var parent = await SeedPlanningParentAsync(); + var sut = BuildSut(parent.Id); + + var result = await sut.CreateChildTask("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 sut = BuildSut(parent.Id); + var list = await sut.ListChildTasks(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); + + var sut = BuildSut(parent.Id); + await Assert.ThrowsAsync(() => + sut.UpdateChildTask(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); + + var sut = BuildSut(parent.Id); + await Assert.ThrowsAsync(() => + sut.UpdateChildTask(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); + + var sut = BuildSut(parent.Id); + await sut.DeleteChildTask(c.Id, CancellationToken.None); + + Assert.Null(await _tasks.GetByIdAsync(c.Id)); + } + + [Fact] + public async Task UpdatePlanningTask_SetsTitleAndDescription() + { + var parent = await SeedPlanningParentAsync(); + + 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); + 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 sut = BuildSut(parent.Id); + var count = await sut.Finalize(true, CancellationToken.None); + + Assert.Equal(2, count); + var loaded = await _tasks.GetByIdAsync(parent.Id); + Assert.Equal(TaskStatus.Planned, loaded!.Status); + Assert.Null(loaded.PlanningSessionToken); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs new file mode 100644 index 0000000..b0196f6 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningSessionManagerTests.cs @@ -0,0 +1,209 @@ +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)); + } + + [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)); + } + + [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 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() + { + 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 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); + } +}