2 Commits

Author SHA1 Message Date
mika kuns
09b52140ce refactor(ui): remove unused Instance statics on bool converters
App.axaml registers these converters via the resource dictionary; the static
Instance members were never referenced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:46:52 +02:00
mika kuns
e7d595244e fix(ui): Planned status uses blue badge style
Previously both Planning and Planned rendered the same amber badge because a
single <Border class="badge planning"> was used. Split into two borders gated
by IsPlanning / IsPlanned so Planned picks up the blue badge.planned style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:46:39 +02:00
28 changed files with 14 additions and 1415 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -267,23 +267,6 @@ 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<TaskEntity?> SetPlanningStartedAsync(
string taskId,
string sessionToken,

View File

@@ -6,8 +6,6 @@ namespace ClaudeDo.Ui.Converters;
public sealed class BoolToDraftOpacityConverter : IValueConverter
{
public static BoolToDraftOpacityConverter Instance { get; } = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is true ? 0.7 : 1.0;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)

View File

@@ -7,8 +7,6 @@ namespace ClaudeDo.Ui.Converters;
public sealed class BoolToItalicConverter : IValueConverter
{
public static BoolToItalicConverter Instance { get; } = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is true ? FontStyle.Italic : FontStyle.Normal;
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)

View File

@@ -35,6 +35,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
public bool IsPlanningParent => Status == TaskStatus.Planning || Status == TaskStatus.Planned;
public bool IsPlanning => Status == TaskStatus.Planning;
public bool IsPlanned => Status == TaskStatus.Planned;
public bool IsDraft => Status == TaskStatus.Draft;
public bool CanOpenPlanningSession => Status == TaskStatus.Manual && !IsChild;
@@ -77,6 +79,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(HasLiveTail));
OnPropertyChanged(nameof(IsPlanningParent));
OnPropertyChanged(nameof(IsPlanning));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(PlanningBadge));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(CanOpenPlanningSession));

View File

@@ -99,8 +99,11 @@
<Border Classes="badge draft" IsVisible="{Binding IsDraft}">
<TextBlock Text="DRAFT"/>
</Border>
<Border Classes="badge planning" IsVisible="{Binding IsPlanningParent}">
<TextBlock Text="{Binding PlanningBadge}"/>
<Border Classes="badge planning" IsVisible="{Binding IsPlanning}">
<TextBlock Text="PLANNING"/>
</Border>
<Border Classes="badge planned" IsVisible="{Binding IsPlanned}">
<TextBlock Text="PLANNED"/>
</Border>
</StackPanel>
</StackPanel>

View File

@@ -7,8 +7,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -2,7 +2,6 @@ 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;
@@ -44,8 +43,6 @@ 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,
@@ -55,9 +52,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService,
TaskMergeService mergeService,
PlanningSessionManager planning,
IPlanningTerminalLauncher launcher)
TaskMergeService mergeService)
{
_queue = queue;
_agentService = agentService;
@@ -67,8 +62,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_wtMaintenance = wtMaintenance;
_resetService = resetService;
_mergeService = mergeService;
_planning = planning;
_launcher = launcher;
}
public string Ping() => $"pong v{Version}";
@@ -291,44 +284,5 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
await _broadcaster.TaskUpdated(dto.TaskId);
}
public async Task<PlanningSessionStartContext> 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<PlanningSessionResumeContext> 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<int> 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<int> GetPendingDraftCountAsync(string taskId)
=> _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
}

View File

@@ -1,12 +0,0 @@
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) { }
}

View File

@@ -1,6 +0,0 @@
namespace ClaudeDo.Worker.Planning;
public sealed class PlanningMcpContext
{
public required string ParentTaskId { get; init; }
}

View File

@@ -1,14 +0,0 @@
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.");
}

View File

@@ -1,128 +0,0 @@
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<string> 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<CreatedChildDto> CreateChildTask(
string title,
string? description,
IReadOnlyList<string>? 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<IReadOnlyList<ChildTaskDto>> ListChildTasks(
CancellationToken cancellationToken)
{
var ctx = _contextAccessor.Current;
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
var list = new List<ChildTaskDto>(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<ChildTaskDto> UpdateChildTask(
string taskId,
string? title,
string? description,
IReadOnlyList<string>? 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<int> 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;
}
}

View File

@@ -1,12 +0,0 @@
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

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

View File

@@ -1,186 +0,0 @@
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<ClaudeDoDbContext>? _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<ClaudeDoDbContext> 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<PlanningSessionStartContext> 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<int> 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<int> 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<PlanningSessionResumeContext> 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<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

@@ -1,40 +0,0 @@
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);
}
}

View File

@@ -1,125 +0,0 @@
// 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 <path> (file form)
// Session ID: no pre-assign flag; resume with --resume <id>
// 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;
}
}

View File

@@ -3,7 +3,6 @@ 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;
@@ -52,25 +51,6 @@ builder.Services.AddSingleton(sp => new DefaultAgentSeeder(
builder.Services.AddSingleton<QueueService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
// Planning session services.
var planningSessionsDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".todo-app", "planning-sessions");
builder.Services.AddSingleton(sp =>
new PlanningSessionManager(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
planningSessionsDir));
builder.Services.AddSingleton<IPlanningTerminalLauncher, WindowsTerminalPlanningLauncher>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<PlanningMcpContextAccessor>();
builder.Services.AddScoped<ClaudeDoDbContext>(sp =>
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
builder.Services.AddScoped<TaskRepository>();
builder.Services.AddScoped<PlanningMcpService>();
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<PlanningMcpService>();
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
@@ -95,9 +75,7 @@ catch (Exception ex)
app.Logger.LogWarning(ex, "Default agent seeding failed");
}
app.UseMiddleware<PlanningTokenAuthMiddleware>();
app.MapHub<WorkerHub>("/hub");
app.MapMcp("/mcp");
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
cfg.SignalRPort, cfg.DbPath);

View File

@@ -1,221 +0,0 @@
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<PlanningLaunchException>(() =>
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<string> excludedConnectionIds) => _all;
public IClientProxy Client(string connectionId) => _all;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => _all;
public IClientProxy Group(string groupName) => _all;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => _all;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => _all;
public IClientProxy OthersInGroup(string groupName) => _all;
public IClientProxy User(string userId) => _all;
public IClientProxy Users(IReadOnlyList<string> 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<object, object?> Items { get; } = new Dictionary<object, object?>();
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() { }
}

View File

@@ -10,9 +10,9 @@ public sealed class DbFixture : IDisposable
public DbFixture()
{
DbPath = Path.Combine(Path.GetTempPath(), $"claudedo_test_{Guid.NewGuid():N}.db");
// EnsureCreated uses the current model directly — no Designer.cs needed.
// Apply migrations so the schema is created.
using var ctx = CreateContext();
ctx.Database.EnsureCreated();
ctx.Database.Migrate();
}
public ClaudeDoDbContext CreateContext()

View File

@@ -1,109 +0,0 @@
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<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
public IClientProxy Client(string connectionId) => E2ENullClientProxy.Instance;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => E2ENullClientProxy.Instance;
public IClientProxy Group(string groupName) => E2ENullClientProxy.Instance;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => E2ENullClientProxy.Instance;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => E2ENullClientProxy.Instance;
public IClientProxy User(string userId) => E2ENullClientProxy.Instance;
public IClientProxy Users(IReadOnlyList<string> 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<WorkerHub>
{
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));
}
}

View File

@@ -1,181 +0,0 @@
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<string> excludedConnectionIds) => NullClientProxy.Instance;
public IClientProxy Client(string connectionId) => NullClientProxy.Instance;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => NullClientProxy.Instance;
public IClientProxy Group(string groupName) => NullClientProxy.Instance;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => NullClientProxy.Instance;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => NullClientProxy.Instance;
public IClientProxy User(string userId) => NullClientProxy.Instance;
public IClientProxy Users(IReadOnlyList<string> 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<WorkerHub>
{
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<TaskEntity> 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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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);
}
}

View File

@@ -1,209 +0,0 @@
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));
}
[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<InvalidOperationException>(() =>
_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<InvalidOperationException>(() =>
_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);
}
}

View File

@@ -1,47 +0,0 @@
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<PlanningLaunchException>(() =>
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<PlanningLaunchException>(() =>
sut.LaunchStartAsync(ctx, CancellationToken.None));
Assert.Contains("Windows Terminal", ex.Message);
}
}