feat/planning-sessions-worker #7

Merged
mikakuns merged 15 commits from feat/planning-sessions-worker into main 2026-04-24 06:02:50 +00:00
10 changed files with 277 additions and 4 deletions
Showing only changes of commit b6bec1e63c - Show all commits

View File

@@ -1,4 +1,6 @@
using System; using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -8,6 +10,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260416064948_InitialCreate")]
public partial class InitialCreate : Migration public partial class InitialCreate : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -1,10 +1,14 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260420075929_AddTaskFlagsAndNotes")]
public partial class AddTaskFlagsAndNotes : Migration public partial class AddTaskFlagsAndNotes : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -1,10 +1,14 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260421113614_AddAppSettings")]
public partial class AddAppSettings : Migration public partial class AddAppSettings : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -1,3 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -5,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260422120000_AddTaskSortOrder")]
public partial class AddTaskSortOrder : Migration public partial class AddTaskSortOrder : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -1,4 +1,6 @@
using System; using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -6,6 +8,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Migrations namespace ClaudeDo.Data.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260423154708_AddPlanningSupport")]
public partial class AddPlanningSupport : Migration public partial class AddPlanningSupport : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -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);

View File

@@ -0,0 +1,7 @@
namespace ClaudeDo.Worker.Planning;
public sealed record PlanningSessionFiles(
string SessionDirectory,
string McpConfigPath,
string SystemPromptPath,
string InitialPromptPath);

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();
}
}

View File

@@ -10,9 +10,9 @@ public sealed class DbFixture : IDisposable
public DbFixture() public DbFixture()
{ {
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db"); 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(); using var ctx = CreateContext();
ctx.Database.Migrate(); ctx.Database.EnsureCreated();
} }
public ClaudeDoDbContext CreateContext() public ClaudeDoDbContext CreateContext()

View File

@@ -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<TaskEntity> 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<InvalidOperationException>(() =>
_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<InvalidOperationException>(() =>
_sut.StartAsync(child.Id, CancellationToken.None));
}
}