Merge feat/external-mcp-ui-parity: external MCP UI parity for start/observe
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
970
docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md
Normal file
970
docs/superpowers/plans/2026-05-30-external-mcp-ui-parity.md
Normal file
@@ -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>`; `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<string> e) => Proxy;
|
||||||
|
public IClientProxy Client(string c) => Proxy;
|
||||||
|
public IClientProxy Clients(IReadOnlyList<string> c) => Proxy;
|
||||||
|
public IClientProxy Group(string g) => Proxy;
|
||||||
|
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => Proxy;
|
||||||
|
public IClientProxy Groups(IReadOnlyList<string> g) => Proxy;
|
||||||
|
public IClientProxy User(string u) => Proxy;
|
||||||
|
public IClientProxy Users(IReadOnlyList<string> u) => Proxy;
|
||||||
|
}
|
||||||
|
internal sealed class ListToolsClientProxy : IClientProxy
|
||||||
|
{
|
||||||
|
public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
internal sealed class ListToolsHubContext : IHubContext<WorkerHub>
|
||||||
|
{
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
_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<ListSummaryDto> 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<ListSummaryDto> 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<string> 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<TaskConfigDto?> 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<InvalidOperationException>(() =>
|
||||||
|
_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<IReadOnlyList<RunDto>> 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<RunDto> 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<string> 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<IReadOnlyList<AgentInfo>> 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<WorktreeManager>.Instance);
|
||||||
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
|
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
|
||||||
|
return new LifecycleMcpTools(_tasks, reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> 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<InvalidOperationException>(() =>
|
||||||
|
sut.ResetFailedTask(task.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetFailedTask_NotFound_Throws()
|
||||||
|
{
|
||||||
|
var sut = BuildSut();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
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<ClaudeDoDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||||
|
|
||||||
|
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
|
||||||
|
public async Task<AppSettingsReadDto> 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<T>()`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add service + tool registrations**
|
||||||
|
|
||||||
|
In the `if (cfg.ExternalMcpPort > 0)` block, after the existing
|
||||||
|
`externalBuilder.Services.AddScoped<ExternalMcpService>();` line, add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
||||||
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
|
||||||
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
||||||
|
externalBuilder.Services.AddScoped<TaskResetService>();
|
||||||
|
externalBuilder.Services.AddScoped<ListMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
||||||
|
```
|
||||||
|
|
||||||
|
And extend the `AddMcpServer()` chain:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
externalBuilder.Services.AddMcpServer()
|
||||||
|
.WithHttpTransport()
|
||||||
|
.WithTools<ExternalMcpService>()
|
||||||
|
.WithTools<ListMcpTools>()
|
||||||
|
.WithTools<ConfigMcpTools>()
|
||||||
|
.WithTools<RunHistoryMcpTools>()
|
||||||
|
.WithTools<AgentMcpTools>()
|
||||||
|
.WithTools<LifecycleMcpTools>()
|
||||||
|
.WithTools<AppSettingsMcpTools>();
|
||||||
|
```
|
||||||
|
|
||||||
|
> **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<TaskResetService>` — 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`).
|
||||||
@@ -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<T>()` 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.
|
||||||
@@ -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`.
|
- **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).
|
- **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`.
|
- **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<T>()`. 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
|
## Status Model
|
||||||
|
|
||||||
|
|||||||
18
src/ClaudeDo.Worker/External/AgentMcpTools.cs
vendored
Normal file
18
src/ClaudeDo.Worker/External/AgentMcpTools.cs
vendored
Normal file
@@ -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<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
|
||||||
|
=> await _agents.ScanAsync(cancellationToken);
|
||||||
|
}
|
||||||
31
src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
vendored
Normal file
31
src/ClaudeDo.Worker/External/AppSettingsMcpTools.cs
vendored
Normal file
@@ -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<ClaudeDoDbContext> _dbFactory;
|
||||||
|
|
||||||
|
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||||
|
|
||||||
|
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
|
||||||
|
public async Task<AppSettingsReadDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
Normal file
66
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
Normal file
@@ -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<TaskConfigDto?> 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;
|
||||||
|
}
|
||||||
31
src/ClaudeDo.Worker/External/LifecycleMcpTools.cs
vendored
Normal file
31
src/ClaudeDo.Worker/External/LifecycleMcpTools.cs
vendored
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/ClaudeDo.Worker/External/ListMcpTools.cs
vendored
Normal file
74
src/ClaudeDo.Worker/External/ListMcpTools.cs
vendored
Normal file
@@ -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<ListSummaryDto> 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<ListSummaryDto> 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);
|
||||||
|
}
|
||||||
63
src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs
vendored
Normal file
63
src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs
vendored
Normal file
@@ -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<IReadOnlyList<RunDto>> 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<RunDto> 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<string> 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);
|
||||||
|
}
|
||||||
@@ -200,10 +200,26 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||||
externalBuilder.Services.AddScoped<TaskRepository>();
|
externalBuilder.Services.AddScoped<TaskRepository>();
|
||||||
externalBuilder.Services.AddScoped<ListRepository>();
|
externalBuilder.Services.AddScoped<ListRepository>();
|
||||||
|
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
||||||
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
|
||||||
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
||||||
|
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>());
|
||||||
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
||||||
|
externalBuilder.Services.AddScoped<ListMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<RunHistoryMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<AgentMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
|
||||||
|
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
||||||
externalBuilder.Services.AddMcpServer()
|
externalBuilder.Services.AddMcpServer()
|
||||||
.WithHttpTransport()
|
.WithHttpTransport()
|
||||||
.WithTools<ExternalMcpService>();
|
.WithTools<ExternalMcpService>()
|
||||||
|
.WithTools<ListMcpTools>()
|
||||||
|
.WithTools<ConfigMcpTools>()
|
||||||
|
.WithTools<RunHistoryMcpTools>()
|
||||||
|
.WithTools<AgentMcpTools>()
|
||||||
|
.WithTools<LifecycleMcpTools>()
|
||||||
|
.WithTools<AppSettingsMcpTools>();
|
||||||
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
|
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
|
||||||
|
|
||||||
externalApp = externalBuilder.Build();
|
externalApp = externalBuilder.Build();
|
||||||
|
|||||||
80
tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
vendored
Normal file
80
tests/ClaudeDo.Worker.Tests/External/ConfigMcpToolsTests.cs
vendored
Normal file
@@ -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<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
vendored
Normal file
90
tests/ClaudeDo.Worker.Tests/External/LifecycleMcpToolsTests.cs
vendored
Normal file
@@ -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<WorktreeManager>.Instance);
|
||||||
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
|
var reset = new TaskResetService(dbFactory, wtManager, broadcaster, state, NullLogger<TaskResetService>.Instance);
|
||||||
|
return new LifecycleMcpTools(_tasks, reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskEntity> 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<InvalidOperationException>(() =>
|
||||||
|
sut.ResetFailedTask(task.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetFailedTask_NotFound_Throws()
|
||||||
|
{
|
||||||
|
var sut = BuildSut();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
sut.ResetFailedTask("missing", CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
66
tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
vendored
Normal file
66
tests/ClaudeDo.Worker.Tests/External/ListMcpToolsTests.cs
vendored
Normal file
@@ -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<InvalidOperationException>(() =>
|
||||||
|
_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));
|
||||||
|
}
|
||||||
|
}
|
||||||
146
tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
vendored
Normal file
146
tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
vendored
Normal file
@@ -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<InvalidOperationException>(() =>
|
||||||
|
_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<InvalidOperationException>(() =>
|
||||||
|
_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<InvalidOperationException>(() =>
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user