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