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 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-23 20:33:21 +02:00
parent b32621a4e5
commit b6bec1e63c
10 changed files with 277 additions and 4 deletions

View File

@@ -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<PlanningSessionStartContext> 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<string, string>
{
["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();
}
}