diff --git a/docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md b/docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md new file mode 100644 index 0000000..e69ce47 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md @@ -0,0 +1,970 @@ +# External MCP — UI Parity (Start & Observe) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add MCP tools so an external Claude session can fully *start* and *observe* ClaudeDo sessions (list/config management, run history, logs, agent listing, reset-failed, app-settings read), reaching UI parity for those concerns. + +**Architecture:** New focused `[McpServerToolType]` classes in `src/ClaudeDo.Worker/External/`, each injecting an existing worker service (no logic duplication). All registered in the *external* `WebApplication` DI container in `Program.cs`. Mutations broadcast the same SignalR events the hub raises, keeping the UI in sync. + +**Tech Stack:** .NET 8, `ModelContextProtocol.Server`, EF Core (SQLite), xUnit integration tests (real SQLite via `DbFixture`). + +> **Build/test note (from project memory):** `dotnet build ClaudeDo.slnx` fails on .NET 8. Build the csproj directly: +> `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +> Test: `dotnet test tests/ClaudeDo.Worker.Tests` + +--- + +## File Structure + +**Create:** +- `src/ClaudeDo.Worker/External/ListMcpTools.cs` — list create/update/delete tools +- `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` — list-config + task-config tools + DTO +- `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs` — run history + log read tools + DTO +- `src/ClaudeDo.Worker/External/AgentMcpTools.cs` — agent listing tool +- `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs` — reset-failed-task tool +- `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs` — app-settings read tool +- `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs` +- `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs` +- `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs` +- `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs` + +**Modify:** +- `src/ClaudeDo.Worker/Program.cs:188-217` — register new tool classes + services in the external builder +- `src/ClaudeDo.Worker/CLAUDE.md:27` — remove stale tag tools, refresh the External MCP tool inventory + +**Reference (existing, do not change):** +- `ListRepository` — `AddAsync`, `UpdateAsync`, `DeleteAsync`, `GetByIdAsync`, `GetAllAsync`, `GetConfigAsync`, `SetConfigAsync`, `DeleteConfigAsync` +- `TaskRepository.UpdateAgentSettingsAsync(taskId, model?, systemPrompt?, agentPath?)` +- `TaskRunRepository` — `GetByTaskIdAsync`, `GetByIdAsync`, `GetLatestByTaskIdAsync` +- `TaskResetService.ResetAsync(taskId, ct)` — refuses Running, discards worktree, resets to Idle +- `AgentFileService.ScanAsync(ct)` → `List`; `AgentInfo(string Name, string Description, string Path)` +- `AppSettingsRepository.GetAsync()` → `AppSettingsEntity` +- `TaskRunEntity` fields: `Id, TaskId, RunNumber, SessionId, IsRetry, ResultMarkdown, StructuredOutputJson, ErrorMarkdown, ExitCode, TurnCount, TokensIn, TokensOut, LogPath, StartedAt, FinishedAt` +- `CommitTypeRegistry.DefaultType` +- `HubBroadcaster.ListUpdated(id)`, `.TaskUpdated(id)` + +> **Spec refinement (YAGNI):** the spec listed an agent "refresh" tool. `AgentFileService.ScanAsync` reads disk fresh on every call, so a separate refresh is redundant for an MCP client. We implement `ListAgents` only. + +--- + +## Task 1: List management tools (`ListMcpTools`) + +**Files:** +- Create: `src/ClaudeDo.Worker/External/ListMcpTools.cs` +- Test: `tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.AspNetCore.SignalR; + +namespace ClaudeDo.Worker.Tests.External; + +internal sealed class ListToolsHubClients : IHubClients +{ + public ListToolsClientProxy Proxy { get; } = new(); + public IClientProxy All => Proxy; + public IClientProxy AllExcept(IReadOnlyList e) => Proxy; + public IClientProxy Client(string c) => Proxy; + public IClientProxy Clients(IReadOnlyList c) => Proxy; + public IClientProxy Group(string g) => Proxy; + public IClientProxy GroupExcept(string g, IReadOnlyList e) => Proxy; + public IClientProxy Groups(IReadOnlyList g) => Proxy; + public IClientProxy User(string u) => Proxy; + public IClientProxy Users(IReadOnlyList u) => Proxy; +} +internal sealed class ListToolsClientProxy : IClientProxy +{ + public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask; +} +internal sealed class ListToolsHubContext : IHubContext +{ + public ListToolsHubClients RecordingClients { get; } = new(); + public IHubClients Clients => RecordingClients; + public IGroupManager Groups => throw new NotImplementedException(); +} + +public sealed class ListMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly ListRepository _lists; + private readonly ListMcpTools _sut; + + public ListMcpToolsTests() + { + _ctx = _db.CreateContext(); + _lists = new ListRepository(_ctx); + _sut = new ListMcpTools(_lists, new HubBroadcaster(new ListToolsHubContext())); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + [Fact] + public async Task CreateList_PersistsWithDefaults() + { + var dto = await _sut.CreateList("My List", null, null, CancellationToken.None); + + Assert.Equal("My List", dto.Name); + var loaded = await _lists.GetByIdAsync(dto.Id); + Assert.NotNull(loaded); + Assert.Equal("chore", loaded!.DefaultCommitType); + } + + [Fact] + public async Task UpdateList_PatchesNameWorkingDirAndCommitType() + { + var created = await _sut.CreateList("orig", null, null, CancellationToken.None); + + var dto = await _sut.UpdateList(created.Id, "renamed", "C:/work", "feat", CancellationToken.None); + + Assert.Equal("renamed", dto.Name); + Assert.Equal("C:/work", dto.WorkingDir); + var loaded = await _lists.GetByIdAsync(created.Id); + Assert.Equal("feat", loaded!.DefaultCommitType); + } + + [Fact] + public async Task UpdateList_NotFound_Throws() + { + await Assert.ThrowsAsync(() => + _sut.UpdateList("missing", "x", null, null, CancellationToken.None)); + } + + [Fact] + public async Task DeleteList_RemovesList() + { + var created = await _sut.CreateList("gone", null, null, CancellationToken.None); + + await _sut.DeleteList(created.Id, CancellationToken.None); + + Assert.Null(await _lists.GetByIdAsync(created.Id)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests` +Expected: FAIL — `ListMcpTools` does not exist (compile error). + +- [ ] **Step 3: Write minimal implementation** + +```csharp +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); + +[McpServerToolType] +public sealed class ListMcpTools +{ + private readonly ListRepository _lists; + private readonly HubBroadcaster _broadcaster; + + public ListMcpTools(ListRepository lists, HubBroadcaster broadcaster) + { + _lists = lists; + _broadcaster = broadcaster; + } + + [McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")] + public async Task CreateList( + string name, string? workingDir, string? commitType, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException("name is required."); + + var entity = new ListEntity + { + Id = Guid.NewGuid().ToString(), + Name = name, + WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir, + DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType, + CreatedAt = DateTime.UtcNow, + }; + await _lists.AddAsync(entity, cancellationToken); + await _broadcaster.ListUpdated(entity.Id); + return ToDto(entity); + } + + [McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")] + public async Task UpdateList( + string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken) + { + var entity = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + + if (name is not null) entity.Name = name; + if (workingDir is not null) + entity.WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir; + if (commitType is not null) + entity.DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType; + + await _lists.UpdateAsync(entity, cancellationToken); + await _broadcaster.ListUpdated(listId); + return ToDto(entity); + } + + [McpServerTool, Description("Delete a list and its tasks. Irreversible.")] + public async Task DeleteList(string listId, CancellationToken cancellationToken) + { + _ = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + await _lists.DeleteAsync(listId, cancellationToken); + await _broadcaster.ListUpdated(listId); + } + + private static ListSummaryDto ToDto(ListEntity l) => + new(l.Id, l.Name, l.WorkingDir, l.DefaultCommitType); +} +``` + +> If `CommitTypeRegistry` is not in scope, add `using ClaudeDo.Data;` (verify its namespace with a quick grep before assuming). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ListMcpToolsTests` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/External/ListMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs +git commit -m "feat(worker): add external MCP list-management tools" +``` + +--- + +## Task 2: List & task config tools (`ConfigMcpTools`) + +**Files:** +- Create: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` +- Test: `tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class ConfigMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly ListRepository _lists; + private readonly TaskRepository _tasks; + private readonly ConfigMcpTools _sut; + + public ConfigMcpToolsTests() + { + _ctx = _db.CreateContext(); + _lists = new ListRepository(_ctx); + _tasks = new TaskRepository(_ctx); + _sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new ListToolsHubContext())); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task SeedListAsync() + { + var id = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); + return id; + } + + [Fact] + public async Task SetAndGetListConfig_RoundTrips() + { + var listId = await SeedListAsync(); + + await _sut.SetListConfig(listId, "sonnet", "be terse", null, CancellationToken.None); + var cfg = await _sut.GetListConfig(listId, CancellationToken.None); + + Assert.NotNull(cfg); + Assert.Equal("sonnet", cfg!.Model); + Assert.Equal("be terse", cfg.SystemPrompt); + Assert.Null(cfg.AgentPath); + } + + [Fact] + public async Task SetListConfig_AllNull_ClearsConfig() + { + var listId = await SeedListAsync(); + await _sut.SetListConfig(listId, "sonnet", null, null, CancellationToken.None); + + await _sut.SetListConfig(listId, null, null, null, CancellationToken.None); + + Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None)); + } + + [Fact] + public async Task SetTaskConfig_PersistsOverrides() + { + var listId = await SeedListAsync(); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "t", + Status = ClaudeDo.Data.Models.TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(task); + + await _sut.SetTaskConfig(task.Id, "opus", null, null, CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal("opus", loaded!.Model); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests` +Expected: FAIL — `ConfigMcpTools` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath); + +[McpServerToolType] +public sealed class ConfigMcpTools +{ + private readonly ListRepository _lists; + private readonly TaskRepository _tasks; + private readonly HubBroadcaster _broadcaster; + + public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster) + { + _lists = lists; + _tasks = tasks; + _broadcaster = broadcaster; + } + + [McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")] + public async Task GetListConfig(string listId, CancellationToken cancellationToken) + { + var cfg = await _lists.GetConfigAsync(listId, cancellationToken); + return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath); + } + + [McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")] + public async Task SetListConfig( + string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken) + { + _ = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + + var m = Nullify(model); + var sp = Nullify(systemPrompt); + var ap = Nullify(agentPath); + + if (m is null && sp is null && ap is null) + await _lists.DeleteConfigAsync(listId, cancellationToken); + else + await _lists.SetConfigAsync(new ListConfigEntity + { + ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, + }, cancellationToken); + + await _broadcaster.ListUpdated(listId); + } + + [McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null to clear a field.")] + public async Task SetTaskConfig( + string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken) + { + _ = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken); + await _broadcaster.TaskUpdated(taskId); + } + + private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; +} +``` + +> Verify `UpdateAgentSettingsAsync` accepts a `CancellationToken` (read `TaskRepository.cs:157`). If it does not, drop the `cancellationToken` argument from that call. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter ConfigMcpToolsTests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs +git commit -m "feat(worker): add external MCP list/task config tools" +``` + +--- + +## Task 3: Run history & log tools (`RunHistoryMcpTools`) + +**Files:** +- Create: `src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs` +- Test: `tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class RunHistoryMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRunRepository _runs; + private readonly RunHistoryMcpTools _sut; + + public RunHistoryMcpToolsTests() + { + _ctx = _db.CreateContext(); + _runs = new TaskRunRepository(_ctx); + _sut = new RunHistoryMcpTools(_runs); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task SeedTaskAsync(string taskId) + { + var lists = new ListRepository(_ctx); + var tasks = new TaskRepository(_ctx); + var listId = Guid.NewGuid().ToString(); + await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + await tasks.AddAsync(new TaskEntity + { + Id = taskId, ListId = listId, Title = "t", + Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore", + }); + } + + [Fact] + public async Task ListRuns_ReturnsProjectedRuns() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + await _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20, + }); + + var list = await _sut.ListRuns(taskId, CancellationToken.None); + + Assert.Single(list); + Assert.Equal("done", list[0].ResultMarkdown); + Assert.Equal(10, list[0].TokensIn); + } + + [Fact] + public async Task GetTaskLog_NoLog_Throws() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + + await Assert.ThrowsAsync(() => + _sut.GetTaskLog(taskId, CancellationToken.None)); + } + + [Fact] + public async Task GetTaskLog_ReadsLatestRunLogFile() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); + await File.WriteAllTextAsync(logPath, "hello log"); + await _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", LogPath = logPath, + }); + + var content = await _sut.GetTaskLog(taskId, CancellationToken.None); + + Assert.Equal("hello log", content); + File.Delete(logPath); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests` +Expected: FAIL — `RunHistoryMcpTools` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record RunDto( + string Id, int RunNumber, string? SessionId, bool IsRetry, + string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown, + int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut, + DateTime? StartedAt, DateTime? FinishedAt); + +[McpServerToolType] +public sealed class RunHistoryMcpTools +{ + private readonly TaskRunRepository _runs; + + public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs; + + [McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")] + public async Task> ListRuns(string taskId, CancellationToken cancellationToken) + { + var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken); + return runs.Select(ToDto).ToList(); + } + + [McpServerTool, Description("Get a single execution run by its run id.")] + public async Task GetRun(string runId, CancellationToken cancellationToken) + { + var run = await _runs.GetByIdAsync(runId, cancellationToken) + ?? throw new InvalidOperationException($"Run {runId} not found."); + return ToDto(run); + } + + [McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")] + public async Task GetTaskLog(string taskId, CancellationToken cancellationToken) + { + var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"No runs found for task {taskId}."); + if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath)) + throw new InvalidOperationException("No log available for the latest run."); + return await File.ReadAllTextAsync(run.LogPath, cancellationToken); + } + + private static RunDto ToDto(TaskRunEntity r) => new( + r.Id, r.RunNumber, r.SessionId, r.IsRetry, + r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown, + r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut, + r.StartedAt, r.FinishedAt); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter RunHistoryMcpToolsTests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs +git commit -m "feat(worker): add external MCP run-history and log tools" +``` + +--- + +## Task 4: Agent listing tool (`AgentMcpTools`) + +**Files:** +- Create: `src/ClaudeDo.Worker/External/AgentMcpTools.cs` +- Test: none new — covered indirectly; `AgentFileService` already has unit coverage. (This tool is a thin pass-through.) + +- [ ] **Step 1: Write minimal implementation** + +```csharp +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Agents; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +[McpServerToolType] +public sealed class AgentMcpTools +{ + private readonly AgentFileService _agents; + + public AgentMcpTools(AgentFileService agents) => _agents = agents; + + [McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")] + public async Task> ListAgents(CancellationToken cancellationToken) + => await _agents.ScanAsync(cancellationToken); +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Worker/External/AgentMcpTools.cs +git commit -m "feat(worker): add external MCP agent-listing tool" +``` + +--- + +## Task 5: Reset-failed-task tool (`LifecycleMcpTools`) + +**Files:** +- Create: `src/ClaudeDo.Worker/External/LifecycleMcpTools.cs` +- Test: `tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs` + +`TaskResetService.ResetAsync` already refuses Running tasks and discards the worktree. The MCP tool adds a guard that the task must be `Failed` (the only sensible reset target via this surface) and delegates. + +- [ ] **Step 1: Write the failing test** + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.State; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Tests.Services; +using ClaudeDo.Data.Git; +using ClaudeDo.Worker.Config; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class LifecycleMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public LifecycleMcpToolsTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private LifecycleMcpTools BuildSut() + { + var cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"), + LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"), + }; + var dbFactory = _db.CreateFactory(); + var broadcaster = new HubBroadcaster(new ListToolsHubContext()); + var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger.Instance); + return new LifecycleMcpTools(_tasks, reset); + } + + private async Task SeedTaskAsync(TaskStatus status) + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", + Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", + }; + await _tasks.AddAsync(task); + return task; + } + + [Fact] + public async Task ResetFailedTask_OnFailed_ResetsToIdle() + { + var task = await SeedTaskAsync(TaskStatus.Failed); + var sut = BuildSut(); + + await sut.ResetFailedTask(task.Id, CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Idle, loaded!.Status); + } + + [Fact] + public async Task ResetFailedTask_OnNonFailed_Throws() + { + var task = await SeedTaskAsync(TaskStatus.Done); + var sut = BuildSut(); + + await Assert.ThrowsAsync(() => + sut.ResetFailedTask(task.Id, CancellationToken.None)); + } + + [Fact] + public async Task ResetFailedTask_NotFound_Throws() + { + var sut = BuildSut(); + await Assert.ThrowsAsync(() => + sut.ResetFailedTask("missing", CancellationToken.None)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests` +Expected: FAIL — `LifecycleMcpTools` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +using System.ComponentModel; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Lifecycle; +using ModelContextProtocol.Server; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.External; + +[McpServerToolType] +public sealed class LifecycleMcpTools +{ + private readonly TaskRepository _tasks; + private readonly TaskResetService _reset; + + public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset) + { + _tasks = tasks; + _reset = reset; + } + + [McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")] + public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken) + { + var task = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status != TaskStatus.Failed) + throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool."); + + await _reset.ResetAsync(taskId, cancellationToken); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter LifecycleMcpToolsTests` +Expected: PASS (3 tests). (Git-dependent worktree discard is skipped when no worktree row exists — these tasks have none.) + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/External/LifecycleMcpTools.cs tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs +git commit -m "feat(worker): add external MCP reset-failed-task tool" +``` + +--- + +## Task 6: App-settings read tool (`AppSettingsMcpTools`) + +**Files:** +- Create: `src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs` +- Test: none new — thin read-only pass-through over `AppSettingsRepository.GetAsync`. + +This tool is read-only by design (writing app settings is out of scope). It uses the db factory (registered as a singleton in the external builder) to open a context per call, mirroring the hub's pattern. + +- [ ] **Step 1: Write minimal implementation** + +```csharp +using System.ComponentModel; +using ClaudeDo.Data; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record AppSettingsReadDto( + string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode, + string WorktreeStrategy, string? CentralWorktreeRoot, + bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays); + +[McpServerToolType] +public sealed class AppSettingsMcpTools +{ + private readonly IDbContextFactory _dbFactory; + + public AppSettingsMcpTools(IDbContextFactory dbFactory) => _dbFactory = dbFactory; + + [McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")] + public async Task GetAppSettings(CancellationToken cancellationToken) + { + using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + var row = await new AppSettingsRepository(ctx).GetAsync(); + return new AppSettingsReadDto( + row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode, + row.WorktreeStrategy, row.CentralWorktreeRoot, + row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays); + } +} +``` + +> Verify `AppSettingsRepository.GetAsync` signature (it may take a `CancellationToken`). Adjust the call if so. Confirm `AppSettingsEntity` property names match (`DefaultModel`, `DefaultMaxTurns`, `DefaultPermissionMode`, `WorktreeStrategy`, `CentralWorktreeRoot`, `WorktreeAutoCleanupEnabled`, `WorktreeAutoCleanupDays`) — they are used identically in `WorkerHub.GetAppSettings` (lines 206-219). + +- [ ] **Step 2: Verify it compiles** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs +git commit -m "feat(worker): add external MCP app-settings read tool" +``` + +--- + +## Task 7: Register new tools in the external MCP app + +**Files:** +- Modify: `src/ClaudeDo.Worker/Program.cs:188-217` + +The external `WebApplication` has its own DI container. Each new tool class and every service it needs must be registered there, and each class added via `.WithTools()`. + +- [ ] **Step 1: Add service + tool registrations** + +In the `if (cfg.ExternalMcpPort > 0)` block, after the existing +`externalBuilder.Services.AddScoped();` line, add: + +```csharp + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); +``` + +And extend the `AddMcpServer()` chain: + +```csharp + externalBuilder.Services.AddMcpServer() + .WithHttpTransport() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); +``` + +> **Verify before editing:** confirm `WorktreeManager` and `AgentFileService` are registered as singletons in the *main* `app` container (grep `Program.cs` for `WorktreeManager` and `AgentFileService`). If `AgentFileService` is constructed with a directory string rather than DI-resolved, register it in the external builder the same way the main app does (e.g. `new AgentFileService(agentsDir)`), not via `GetRequiredService`. `TaskResetService` depends on `WorktreeManager`, `IDbContextFactory`, `HubBroadcaster`, `ITaskStateService`, `ILogger` — all already singletons in the external builder except `WorktreeManager` (added above) and the logger (provided by default logging). + +- [ ] **Step 2: Build the worker** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: Build succeeded, no DI-related compile errors. + +- [ ] **Step 3: Run the full worker test suite** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests` +Expected: PASS (all existing + new tests). + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Worker/Program.cs +git commit -m "feat(worker): register new external MCP tool classes" +``` + +--- + +## Task 8: Documentation cleanup + +**Files:** +- Modify: `src/ClaudeDo.Worker/CLAUDE.md:27` + +- [ ] **Step 1: Replace the stale External MCP inventory line** + +Replace the line beginning `- **External/ExternalMcpService** — always-on MCP tools…` with an accurate inventory that drops the (non-existent) tag tools and lists the new surface: + +```markdown +- **External/*** — always-on MCP tools for general Claude sessions, organized by concern: + - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle`/`Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask` + - `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList` + - `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `SetTaskConfig` + - `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` + - `AgentMcpTools` — `ListAgents` + - `LifecycleMcpTools` — `ResetFailedTask` + - `AppSettingsMcpTools` — `GetAppSettings` (read-only) + - Purpose is scoped to *starting* and *observing* sessions — no worktree/merge, multi-turn, planning, or app-settings writes. Auth via optional `X-ClaudeDo-Key` header. +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ClaudeDo.Worker/CLAUDE.md +git commit -m "docs(worker): correct external MCP tool inventory, drop removed tags" +``` + +--- + +## Self-Review + +**Spec coverage:** +- List management → Task 1 ✓ +- List & task config → Task 2 ✓ +- Run history & logs → Task 3 ✓ +- Agents (read-only) → Task 4 ✓ +- Reset failed task → Task 5 ✓ +- App settings (read-only) → Task 6 ✓ +- DI wiring (separate external app) → Task 7 ✓ +- Tag doc cleanup → Task 8 ✓ +- Out-of-scope items (multi-turn, worktree ops, planning, app-settings writes, tags, agent create/edit) → not implemented ✓ + +**Placeholder scan:** No TBD/TODO. The three "verify before editing" notes point at real signatures the implementer must confirm (cancellation-token overloads, `AgentFileService` construction, registry namespaces) — these are verification steps with concrete fallbacks, not placeholders. + +**Type consistency:** `ListSummaryDto`, `TaskConfigDto`, `RunDto`, `AppSettingsReadDto` defined once and used consistently. `AgentInfo` reused directly (no new DTO). Tool method names match between implementation, tests, and the Task-8 doc inventory (`CreateList`/`UpdateList`/`DeleteList`, `GetListConfig`/`SetListConfig`/`SetTaskConfig`, `ListRuns`/`GetRun`/`GetTaskLog`, `ListAgents`, `ResetFailedTask`, `GetAppSettings`). diff --git a/docs/superpowers/specs/2026-05-30-external-mcp-ui-parity-design.md b/docs/superpowers/specs/2026-05-30-external-mcp-ui-parity-design.md new file mode 100644 index 0000000..a4399fc --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-external-mcp-ui-parity-design.md @@ -0,0 +1,125 @@ +# External MCP — UI Parity for Start & Observe + +**Date:** 2026-05-30 +**Status:** Approved (design) + +## Goal + +Expand the always-on **External MCP server** (`ExternalMcpService`, exposed on +`cfg.ExternalMcpPort` under `/mcp`) so an external Claude session can **start and +observe** ClaudeDo work sessions end-to-end, reaching parity with the desktop UI +for those two concerns. + +The server's purpose is deliberately scoped: **help the user start sessions and +observe them.** It is *not* a git/worktree console — branch merging, worktree +resets, and multi-turn continuation are things Claude does *inside* a task, so +they stay out of the tool surface. + +## Scope + +### In scope + +**START — set up and launch a session** +- *(existing)* `AddTask`, `UpdateTask`, `UpdateTaskStatus` (Idle/Queued), `RunTaskNow`, `CancelTask`, `DeleteTask` +- **List management** — create / rename / delete lists; set working dir + default commit type +- **List & task config** — per-list defaults and per-task overrides for `model`, `system_prompt`, `agent_path` +- **Agents (read-only)** — list agent files and refresh, so Claude can choose a valid `agent_path` +- **Reset failed task** — discard the failed worktree and reset the row to Idle (the retry path) + +**OBSERVE** +- *(existing)* `ListTaskLists`, `ListTasks`, `GetTask` +- **Run history** — read `task_runs` for a task (session id, tokens, turns, result, structured output, error) +- **Logs** — fetch a task's (or run's) log output +- **App settings (read-only)** — read worker app settings + +### Out of scope (explicitly excluded) +- **Tags** — already removed from the system (migration `20260519044715_RemoveTags`); only the stale doc reference in `src/ClaudeDo.Worker/CLAUDE.md` needs deleting. +- **Multi-turn continue** (`--resume`) — Claude's own concern inside a task. +- **Worktree ops** — merge, merge targets, cleanup-finished, reset-all, force-remove, set-state. +- **Start planning session** — not needed via MCP. +- **App settings writes** — risky (e.g. flips permission mode); read-only only. +- **Agent file create/edit/delete** — not part of "starting a session". + +## Approach (chosen: A) + +**Reuse existing worker services; split the growing tool surface into focused +`[McpServerToolType]` classes.** No business logic is duplicated — each new tool +injects the same service the SignalR hub already uses, so MCP behavior stays +identical to the UI. + +Adding ~12 tools to the single `ExternalMcpService` would push it past 600 lines +across eight unrelated jobs. Instead, organize tools by category, mirroring the +existing `External/` + `Planning/` layout: + +| Class (new, in `External/`) | Tools | Backing service | +|---|---|---| +| `ExternalMcpService` *(existing, unchanged scope)* | task CRUD + run/cancel/status | `TaskRepository`, `QueueService`, `ITaskStateService` | +| `ListMcpTools` | `CreateList`, `RenameList`, `DeleteList`, `SetListWorkingDir` (name/dir/commitType) | `ListRepository` | +| `ConfigMcpTools` | `GetListConfig`, `SetListConfig`, `SetTaskConfig` (model/system_prompt/agent_path) | `ListRepository`, `TaskRepository.UpdateAgentSettingsAsync` | +| `RunHistoryMcpTools` | `ListRuns`, `GetRun`, `GetTaskLog` | `TaskRunRepository`, log file read | +| `AgentMcpTools` | `ListAgents`, `RefreshAgents` | `AgentFileService.ScanAsync` | +| `LifecycleMcpTools` | `ResetFailedTask` | `TaskResetService.ResetAsync` | +| `AppSettingsMcpTools` | `GetAppSettings` | `AppSettingsRepository.GetAsync` | + +(Exact class grouping may be tuned during planning, but each class stays small +and single-purpose.) + +## Architecture & wiring + +The external MCP server is a **separate `WebApplication`** built in +`Program.cs` (≈ lines 188–217) with its own DI container, distinct from the main +SignalR app. Shared singletons (`HubBroadcaster`, `QueueService`, +`ITaskStateService`, db factory, `WorkerConfig`) are injected by instance so both +apps act on the same runtime state. + +Each new tool class must be: +1. Registered in the **external** builder (`externalBuilder.Services.AddScoped<…>()`), + alongside any newly required services (`TaskRunRepository`, `AgentFileService`, + `TaskResetService` + their dependencies). +2. Registered as tools via additional `.WithTools()` calls on the external + `AddMcpServer()` chain. + +No change to auth: the existing `ExternalMcpAuthMiddleware` (optional +`X-ClaudeDo-Key`, loopback-only otherwise) covers all tools uniformly. No +per-tool gating — the surface is read/observe + start, with the one borderline +write (`ResetFailedTask`) being a normal retry affordance. + +## Data flow + +- **Start:** Claude calls e.g. `CreateList` → `SetListConfig` → `AddTask(queueImmediately: true)`. Writes go through `ListRepository` / `TaskStateService`, which wake the queue and broadcast `ListUpdated` / `TaskUpdated` so the UI reflects changes live. +- **Observe:** Claude calls `ListTasks` / `GetTask` → `ListRuns` / `GetRun` → `GetTaskLog`. Pure reads from `TaskRepository` / `TaskRunRepository` and the log file at `TaskRunEntity.LogPath`. +- **Mutations broadcast** the same SignalR events the hub raises, keeping the desktop UI in sync. + +## DTOs + +- `RunDto` — projection of `TaskRunEntity`: `Id`, `RunNumber`, `SessionId`, `IsRetry`, `ResultMarkdown`, `StructuredOutputJson`, `ErrorMarkdown`, `ExitCode`, `TurnCount`, `TokensIn`, `TokensOut`, `StartedAt`, `FinishedAt`. +- `AgentDto` — from `AgentInfo` (`Name`, `Description`, `Path`). +- `ListConfigDto` — `Model`, `SystemPrompt`, `AgentPath` (reuse the shape already used by the hub). +- App-settings read reuses the existing `AppSettingsDto` shape (read-only subset is fine). +- Log fetch returns the file contents as a string (with a size cap / tail option decided in planning). + +## Error handling + +Follow the existing `ExternalMcpService` convention: throw +`InvalidOperationException` with a clear message for not-found / invalid-input / +illegal-state (e.g. "List {id} not found", "Cannot reset a non-failed task"). +Reuse the guard patterns already present (required-field checks, status checks). +`ResetFailedTask` must refuse non-`Failed` tasks. + +## Testing + +Extend `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs` (and add +sibling test files per new tool class) using the existing real-SQLite + real-git +integration pattern: +- List CRUD round-trips; rename/delete propagate; delete blocked/handled sensibly. +- List + task config set/get round-trips; clearing all three fields removes list config (matches hub behavior). +- Run history reads return correct projections; `GetTaskLog` returns file contents and errors cleanly when no log exists. +- `ResetFailedTask` succeeds on a Failed task and refuses other statuses. +- Agent listing reflects files on disk after refresh. +- App-settings read returns current values. + +## Doc cleanup (part of this work) + +- `src/ClaudeDo.Worker/CLAUDE.md` — remove the stale `SetTaskTags` / `ListTags` / + "AddTask (with tags)" claim; replace the External MCP tool inventory with the + new surface. diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 86ee7ab..aa63830 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -24,7 +24,14 @@ Worker/ - **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. -- **External/ExternalMcpService** — always-on MCP tools for general Claude sessions: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask` (with tags), `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `SetTaskTags`, `ListTags`, `DeleteTask`, `RunTaskNow`, `CancelTask`. Auth via optional `X-ClaudeDo-Key` header. +- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools()`. Organized by concern: + - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `RunTaskNow`, `CancelTask`, `DeleteTask` + - `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList` + - `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `SetTaskConfig` + - `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB) + - `AgentMcpTools` — `ListAgents` + - `LifecycleMcpTools` — `ResetFailedTask` + - `AppSettingsMcpTools` — `GetAppSettings` (read-only) ## Status Model diff --git a/src/ClaudeDo.Worker/External/AgentMcpTools.cs b/src/ClaudeDo.Worker/External/AgentMcpTools.cs new file mode 100644 index 0000000..8050960 --- /dev/null +++ b/src/ClaudeDo.Worker/External/AgentMcpTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Agents; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +[McpServerToolType] +public sealed class AgentMcpTools +{ + private readonly AgentFileService _agents; + + public AgentMcpTools(AgentFileService agents) => _agents = agents; + + [McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")] + public async Task> ListAgents(CancellationToken cancellationToken) + => await _agents.ScanAsync(cancellationToken); +} diff --git a/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs b/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs new file mode 100644 index 0000000..8e065f5 --- /dev/null +++ b/src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; +using ClaudeDo.Data; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record AppSettingsReadDto( + string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode, + string WorktreeStrategy, string? CentralWorktreeRoot, + bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays); + +[McpServerToolType] +public sealed class AppSettingsMcpTools +{ + private readonly IDbContextFactory _dbFactory; + + public AppSettingsMcpTools(IDbContextFactory dbFactory) => _dbFactory = dbFactory; + + [McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")] + public async Task GetAppSettings(CancellationToken cancellationToken) + { + using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + var row = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); + return new AppSettingsReadDto( + row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode, + row.WorktreeStrategy, row.CentralWorktreeRoot, + row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays); + } +} diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs new file mode 100644 index 0000000..beb2fa1 --- /dev/null +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -0,0 +1,66 @@ +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record TaskConfigDto(string? Model, string? SystemPrompt, string? AgentPath); + +[McpServerToolType] +public sealed class ConfigMcpTools +{ + private readonly ListRepository _lists; + private readonly TaskRepository _tasks; + private readonly HubBroadcaster _broadcaster; + + public ConfigMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster) + { + _lists = lists; + _tasks = tasks; + _broadcaster = broadcaster; + } + + [McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns null if no config is set.")] + public async Task GetListConfig(string listId, CancellationToken cancellationToken) + { + var cfg = await _lists.GetConfigAsync(listId, cancellationToken); + return cfg is null ? null : new TaskConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath); + } + + [McpServerTool, Description("Set a list's default model/system prompt/agent path. Passing all three as null clears the list config.")] + public async Task SetListConfig( + string listId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken) + { + _ = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + + var m = Nullify(model); + var sp = Nullify(systemPrompt); + var ap = Nullify(agentPath); + + if (m is null && sp is null && ap is null) + await _lists.DeleteConfigAsync(listId, cancellationToken); + else + await _lists.SetConfigAsync(new ListConfigEntity + { + ListId = listId, Model = m, SystemPrompt = sp, AgentPath = ap, + }, cancellationToken); + + await _broadcaster.ListUpdated(listId); + } + + [McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path). Pass null for any field to clear that override.")] + public async Task SetTaskConfig( + string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken cancellationToken) + { + _ = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + await _tasks.UpdateAgentSettingsAsync(taskId, Nullify(model), Nullify(systemPrompt), Nullify(agentPath), cancellationToken); + await _broadcaster.TaskUpdated(taskId); + } + + private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; +} diff --git a/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs new file mode 100644 index 0000000..5fe2338 --- /dev/null +++ b/src/ClaudeDo.Worker/External/LifecycleMcpTools.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Lifecycle; +using ModelContextProtocol.Server; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.External; + +[McpServerToolType] +public sealed class LifecycleMcpTools +{ + private readonly TaskRepository _tasks; + private readonly TaskResetService _reset; + + public LifecycleMcpTools(TaskRepository tasks, TaskResetService reset) + { + _tasks = tasks; + _reset = reset; + } + + [McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted.")] + public async Task ResetFailedTask(string taskId, CancellationToken cancellationToken) + { + var task = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status != TaskStatus.Failed) + throw new InvalidOperationException($"Task {taskId} is {task.Status}, not Failed. Only failed tasks can be reset via this tool."); + + await _reset.ResetAsync(taskId, cancellationToken); + } +} diff --git a/src/ClaudeDo.Worker/External/ListMcpTools.cs b/src/ClaudeDo.Worker/External/ListMcpTools.cs new file mode 100644 index 0000000..15479a8 --- /dev/null +++ b/src/ClaudeDo.Worker/External/ListMcpTools.cs @@ -0,0 +1,74 @@ +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record ListSummaryDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); + +[McpServerToolType] +public sealed class ListMcpTools +{ + private readonly ListRepository _lists; + private readonly HubBroadcaster _broadcaster; + + public ListMcpTools(ListRepository lists, HubBroadcaster broadcaster) + { + _lists = lists; + _broadcaster = broadcaster; + } + + [McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")] + public async Task CreateList( + string name, string? workingDir, string? commitType, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException("name is required."); + + var entity = new ListEntity + { + Id = Guid.NewGuid().ToString(), + Name = name, + WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir, + DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType, + CreatedAt = DateTime.UtcNow, + }; + await _lists.AddAsync(entity, cancellationToken); + await _broadcaster.ListUpdated(entity.Id); + return ToDto(entity); + } + + [McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")] + public async Task UpdateList( + string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken) + { + var entity = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + + if (name is not null && string.IsNullOrWhiteSpace(name)) + throw new InvalidOperationException("name cannot be blank."); + if (name is not null) entity.Name = name; + if (workingDir is not null) + entity.WorkingDir = string.IsNullOrWhiteSpace(workingDir) ? null : workingDir; + if (commitType is not null) + entity.DefaultCommitType = string.IsNullOrWhiteSpace(commitType) ? CommitTypeRegistry.DefaultType : commitType; + + await _lists.UpdateAsync(entity, cancellationToken); + await _broadcaster.ListUpdated(listId); + return ToDto(entity); + } + + [McpServerTool, Description("Delete a list and its tasks. Irreversible.")] + public async Task DeleteList(string listId, CancellationToken cancellationToken) + { + _ = await _lists.GetByIdAsync(listId, cancellationToken) + ?? throw new InvalidOperationException($"List {listId} not found."); + await _lists.DeleteAsync(listId, cancellationToken); + await _broadcaster.ListUpdated(listId); + } + + private static ListSummaryDto ToDto(ListEntity l) => + new(l.Id, l.Name, l.WorkingDir, l.DefaultCommitType); +} diff --git a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs new file mode 100644 index 0000000..1081a28 --- /dev/null +++ b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs @@ -0,0 +1,63 @@ +using System.ComponentModel; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ModelContextProtocol.Server; + +namespace ClaudeDo.Worker.External; + +public sealed record RunDto( + string Id, int RunNumber, string? SessionId, bool IsRetry, + string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown, + int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut, + DateTime? StartedAt, DateTime? FinishedAt); + +[McpServerToolType] +public sealed class RunHistoryMcpTools +{ + private readonly TaskRunRepository _runs; + + public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs; + + [McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")] + public async Task> ListRuns(string taskId, CancellationToken cancellationToken) + { + var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken); + return runs.Select(ToDto).ToList(); + } + + [McpServerTool, Description("Get a single execution run by its run id.")] + public async Task GetRun(string runId, CancellationToken cancellationToken) + { + var run = await _runs.GetByIdAsync(runId, cancellationToken) + ?? throw new InvalidOperationException($"Run {runId} not found."); + return ToDto(run); + } + + private const int MaxLogBytes = 256 * 1024; + + [McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")] + public async Task GetTaskLog(string taskId, CancellationToken cancellationToken) + { + var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"No runs found for task {taskId}."); + if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath)) + throw new InvalidOperationException("No log available for the latest run."); + + var totalBytes = new FileInfo(run.LogPath).Length; + if (totalBytes <= MaxLogBytes) + return await File.ReadAllTextAsync(run.LogPath, cancellationToken); + + var buffer = new byte[MaxLogBytes]; + await using var fs = new FileStream(run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + fs.Seek(totalBytes - MaxLogBytes, SeekOrigin.Begin); + var read = await fs.ReadAsync(buffer, cancellationToken); + var tail = System.Text.Encoding.UTF8.GetString(buffer, 0, read); + return $"[truncated: showing last {MaxLogBytes} of {totalBytes} bytes]\n{tail}"; + } + + private static RunDto ToDto(TaskRunEntity r) => new( + r.Id, r.RunNumber, r.SessionId, r.IsRetry, + r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown, + r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut, + r.StartedAt, r.FinishedAt); +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 276e040..62780fe 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -200,10 +200,26 @@ if (cfg.ExternalMcpPort > 0) sp.GetRequiredService>().CreateDbContext()); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); + externalBuilder.Services.AddScoped(); externalBuilder.Services.AddMcpServer() .WithHttpTransport() - .WithTools(); + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools() + .WithTools(); externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}"); externalApp = externalBuilder.Build(); diff --git a/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs new file mode 100644 index 0000000..81ee4ce --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs @@ -0,0 +1,80 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class ConfigMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly ListRepository _lists; + private readonly TaskRepository _tasks; + private readonly ConfigMcpTools _sut; + + public ConfigMcpToolsTests() + { + _ctx = _db.CreateContext(); + _lists = new ListRepository(_ctx); + _tasks = new TaskRepository(_ctx); + _sut = new ConfigMcpTools(_lists, _tasks, new HubBroadcaster(new CapturingHubContext())); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task SeedListAsync() + { + var id = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); + return id; + } + + [Fact] + public async Task SetAndGetListConfig_RoundTrips() + { + var listId = await SeedListAsync(); + + await _sut.SetListConfig(listId, "sonnet", "be terse", null, CancellationToken.None); + var cfg = await _sut.GetListConfig(listId, CancellationToken.None); + + Assert.NotNull(cfg); + Assert.Equal("sonnet", cfg!.Model); + Assert.Equal("be terse", cfg.SystemPrompt); + Assert.Null(cfg.AgentPath); + } + + [Fact] + public async Task SetListConfig_AllNull_ClearsConfig() + { + var listId = await SeedListAsync(); + await _sut.SetListConfig(listId, "sonnet", null, null, CancellationToken.None); + + await _sut.SetListConfig(listId, null, null, null, CancellationToken.None); + + Assert.Null(await _sut.GetListConfig(listId, CancellationToken.None)); + } + + [Fact] + public async Task SetTaskConfig_PersistsOverrides() + { + var listId = await SeedListAsync(); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "t", + Status = ClaudeDo.Data.Models.TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + CommitType = "chore", + }; + await _tasks.AddAsync(task); + + await _sut.SetTaskConfig(task.Id, "opus", null, null, CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal("opus", loaded!.Model); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs new file mode 100644 index 0000000..03f678b --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs @@ -0,0 +1,90 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.State; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Data.Git; +using ClaudeDo.Worker.Config; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class LifecycleMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + + public LifecycleMcpToolsTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private LifecycleMcpTools BuildSut() + { + var cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"), + LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"), + }; + var dbFactory = _db.CreateFactory(); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger.Instance); + return new LifecycleMcpTools(_tasks, reset); + } + + private async Task SeedTaskAsync(TaskStatus status) + { + var listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", + Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", + }; + await _tasks.AddAsync(task); + return task; + } + + [Fact] + public async Task ResetFailedTask_OnFailed_ResetsToIdle() + { + var task = await SeedTaskAsync(TaskStatus.Failed); + var sut = BuildSut(); + + await sut.ResetFailedTask(task.Id, CancellationToken.None); + + var loaded = await _tasks.GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Idle, loaded!.Status); + } + + [Fact] + public async Task ResetFailedTask_OnNonFailed_Throws() + { + var task = await SeedTaskAsync(TaskStatus.Done); + var sut = BuildSut(); + + await Assert.ThrowsAsync(() => + sut.ResetFailedTask(task.Id, CancellationToken.None)); + } + + [Fact] + public async Task ResetFailedTask_NotFound_Throws() + { + var sut = BuildSut(); + await Assert.ThrowsAsync(() => + sut.ResetFailedTask("missing", CancellationToken.None)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs new file mode 100644 index 0000000..50f1f05 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs @@ -0,0 +1,66 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class ListMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly ListRepository _lists; + private readonly ListMcpTools _sut; + + public ListMcpToolsTests() + { + _ctx = _db.CreateContext(); + _lists = new ListRepository(_ctx); + _sut = new ListMcpTools(_lists, new HubBroadcaster(new CapturingHubContext())); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + [Fact] + public async Task CreateList_PersistsWithDefaults() + { + var dto = await _sut.CreateList("My List", null, null, CancellationToken.None); + + Assert.Equal("My List", dto.Name); + var loaded = await _lists.GetByIdAsync(dto.Id); + Assert.NotNull(loaded); + Assert.Equal("chore", loaded!.DefaultCommitType); + } + + [Fact] + public async Task UpdateList_PatchesNameWorkingDirAndCommitType() + { + var created = await _sut.CreateList("orig", null, null, CancellationToken.None); + + var dto = await _sut.UpdateList(created.Id, "renamed", "C:/work", "feat", CancellationToken.None); + + Assert.Equal("renamed", dto.Name); + Assert.Equal("C:/work", dto.WorkingDir); + var loaded = await _lists.GetByIdAsync(created.Id); + Assert.Equal("feat", loaded!.DefaultCommitType); + } + + [Fact] + public async Task UpdateList_NotFound_Throws() + { + await Assert.ThrowsAsync(() => + _sut.UpdateList("missing", "x", null, null, CancellationToken.None)); + } + + [Fact] + public async Task DeleteList_RemovesList() + { + var created = await _sut.CreateList("gone", null, null, CancellationToken.None); + + await _sut.DeleteList(created.Id, CancellationToken.None); + + Assert.Null(await _lists.GetByIdAsync(created.Id)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs new file mode 100644 index 0000000..730806c --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs @@ -0,0 +1,146 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class RunHistoryMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRunRepository _runs; + private readonly RunHistoryMcpTools _sut; + + public RunHistoryMcpToolsTests() + { + _ctx = _db.CreateContext(); + _runs = new TaskRunRepository(_ctx); + _sut = new RunHistoryMcpTools(_runs); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task SeedTaskAsync(string taskId) + { + var lists = new ListRepository(_ctx); + var tasks = new TaskRepository(_ctx); + var listId = Guid.NewGuid().ToString(); + await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + await tasks.AddAsync(new TaskEntity + { + Id = taskId, ListId = listId, Title = "t", + Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore", + }); + } + + [Fact] + public async Task ListRuns_ReturnsProjectedRuns() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + await _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20, + }); + + var list = await _sut.ListRuns(taskId, CancellationToken.None); + + Assert.Single(list); + Assert.Equal("done", list[0].ResultMarkdown); + Assert.Equal(10, list[0].TokensIn); + } + + [Fact] + public async Task GetTaskLog_NoLog_Throws() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + + await Assert.ThrowsAsync(() => + _sut.GetTaskLog(taskId, CancellationToken.None)); + } + + [Fact] + public async Task GetTaskLog_ReadsLatestRunLogFile() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); + await File.WriteAllTextAsync(logPath, "hello log"); + await _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", LogPath = logPath, + }); + + string content; + try + { + content = await _sut.GetTaskLog(taskId, CancellationToken.None); + } + finally + { + File.Delete(logPath); + } + + Assert.Equal("hello log", content); + } + + [Fact] + public async Task GetRun_NotFound_Throws() + { + await Assert.ThrowsAsync(() => + _sut.GetRun("missing", CancellationToken.None)); + } + + [Fact] + public async Task GetTaskLog_RunExistsButNoLogPath_Throws() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + await _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", LogPath = null, + }); + + await Assert.ThrowsAsync(() => + _sut.GetTaskLog(taskId, CancellationToken.None)); + } + + [Fact] + public async Task GetTaskLog_LargeFile_ReturnsTruncatedTail() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); + + // Write 300 KB so it exceeds the 256 KB cap + var chunk = new string('A', 1024); + await using (var w = new StreamWriter(logPath, append: false)) + for (var i = 0; i < 300; i++) + await w.WriteAsync(chunk); + + await _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", LogPath = logPath, + }); + + string content; + try + { + content = await _sut.GetTaskLog(taskId, CancellationToken.None); + } + finally + { + File.Delete(logPath); + } + + Assert.StartsWith("[truncated:", content); + Assert.True(content.Length < 300 * 1024); + } +}