merge: MCP surface — worktree/diff/merge/log tools + status-enum docs

This commit is contained in:
Mika Kuns
2026-06-01 16:21:51 +02:00
5 changed files with 477 additions and 48 deletions

View File

@@ -1,9 +1,15 @@
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.State; using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -12,6 +18,7 @@ namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir); public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id); public sealed record DeleteTaskResult(bool Deleted, string Id);
public sealed record CancelTaskResult(bool Cancelled, string Id); public sealed record CancelTaskResult(bool Cancelled, string Id);
public sealed record StatusValueDto(string Status, string Meaning);
public sealed record TaskDto( public sealed record TaskDto(
string Id, string Id,
@@ -25,6 +32,23 @@ public sealed record TaskDto(
DateTime? StartedAt, DateTime? StartedAt,
DateTime? FinishedAt); DateTime? FinishedAt);
public sealed record WorktreeInfoDto(
string Path, string Branch, string HeadCommit, string BaseCommit,
int Ahead, int Behind, bool IsDirty);
public sealed record TaskDiffDto(
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
public sealed record MergeTaskResultDto(
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts);
public sealed record WorktreeListItemDto(
string? TaskId, string Path, string Branch,
string HeadCommit, bool IsDirty, bool MergedIntoMain);
public sealed record CleanupWorktreeResult(
bool Removed, string WorktreePath, bool BranchDeleted);
[McpServerToolType] [McpServerToolType]
public sealed class ExternalMcpService public sealed class ExternalMcpService
{ {
@@ -33,19 +57,31 @@ public sealed class ExternalMcpService
private readonly QueueService _queue; private readonly QueueService _queue;
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly ITaskStateService _state; private readonly ITaskStateService _state;
private readonly GitService _git;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _maintenance;
private readonly TaskMergeService _merge;
public ExternalMcpService( public ExternalMcpService(
TaskRepository tasks, TaskRepository tasks,
ListRepository lists, ListRepository lists,
QueueService queue, QueueService queue,
HubBroadcaster broadcaster, HubBroadcaster broadcaster,
ITaskStateService state) ITaskStateService state,
GitService git,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService maintenance,
TaskMergeService merge)
{ {
_tasks = tasks; _tasks = tasks;
_lists = lists; _lists = lists;
_queue = queue; _queue = queue;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_state = state; _state = state;
_git = git;
_dbFactory = dbFactory;
_maintenance = maintenance;
_merge = merge;
} }
[McpServerTool, Description("List all task lists available in ClaudeDo.")] [McpServerTool, Description("List all task lists available in ClaudeDo.")]
@@ -55,7 +91,9 @@ public sealed class ExternalMcpService
return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList(); return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList();
} }
[McpServerTool, Description("List tasks in a given list. Optionally filter by creator (CreatedBy) and/or status.")] [McpServerTool, Description(
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
"Valid status values: Idle, Queued, Running, Done, Failed, Cancelled.")]
public async Task<IReadOnlyList<TaskDto>> ListTasks( public async Task<IReadOnlyList<TaskDto>> ListTasks(
string listId, string listId,
string? createdBy, string? createdBy,
@@ -66,7 +104,8 @@ public sealed class ExternalMcpService
if (!string.IsNullOrWhiteSpace(status)) if (!string.IsNullOrWhiteSpace(status))
{ {
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed)) if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new InvalidOperationException($"Unknown status '{status}'."); throw new InvalidOperationException(
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");
statusFilter = parsed; statusFilter = parsed;
} }
@@ -80,7 +119,10 @@ public sealed class ExternalMcpService
return query.Select(ToDto).ToList(); return query.Select(ToDto).ToList();
} }
[McpServerTool, Description("Get a single task by id, including its current status and result.")] [McpServerTool, Description(
"Get a single task by id, including its current status and result. " +
"Status lifecycle: Idle → Queued → Running → Done | Failed | Cancelled. " +
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken) public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
{ {
var task = await _tasks.GetByIdAsync(taskId, cancellationToken) var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -120,7 +162,6 @@ public sealed class ExternalMcpService
if (queueImmediately) if (queueImmediately)
{ {
// Routes through TaskStateService so the queue is woken automatically.
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken); var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
if (!enqueue.Ok) if (!enqueue.Ok)
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task."); throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
@@ -154,14 +195,19 @@ public sealed class ExternalMcpService
return ToDto(reload); return ToDto(reload);
} }
[McpServerTool, Description("Update a task's status. Only 'Idle' and 'Queued' are permitted — use RunTaskNow or CancelTask for execution control.")] [McpServerTool, Description(
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
"use run_task_now or cancel_task for execution control. " +
"Settable: Idle (reset to editable), Queued (enqueue for execution). " +
"Full lifecycle: Idle → Queued → Running → Done | Failed | Cancelled.")]
public async Task<TaskDto> UpdateTaskStatus( public async Task<TaskDto> UpdateTaskStatus(
string taskId, string taskId,
string status, string status,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target)) if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
throw new InvalidOperationException($"Unknown status '{status}'."); throw new InvalidOperationException(
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");
var task = await _tasks.GetByIdAsync(taskId, cancellationToken) var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found."); ?? throw new InvalidOperationException($"Task {taskId} not found.");
@@ -181,7 +227,7 @@ public sealed class ExternalMcpService
default: default:
throw new InvalidOperationException( throw new InvalidOperationException(
$"Status '{target}' is not settable externally. Use RunTaskNow or CancelTask."); $"Status '{target}' is not settable externally. Use run_task_now or cancel_task.");
} }
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
@@ -227,6 +273,256 @@ public sealed class ExternalMcpService
return new DeleteTaskResult(true, taskId); return new DeleteTaskResult(true, taskId);
} }
// ── Status reference ─────────────────────────────────────────────────────
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
Task.FromResult<IReadOnlyList<StatusValueDto>>([
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
new("Done", "Completed successfully; result text is available in the result field. Can be reset to Idle for re-execution."),
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
]);
// ── Worktree / git tools ──────────────────────────────────────────────────
[McpServerTool, Description(
"Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " +
"baseCommit (SHA where the branch was created), ahead (commits on branch since base), " +
"behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " +
"isDirty (has uncommitted changes in the worktree directory). " +
"Throws if the task or its worktree does not exist.")]
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
{
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
var headCommit = !string.IsNullOrWhiteSpace(wt.HeadCommit)
? wt.HeadCommit
: await TryRunGitAsync(wt.Path, ["rev-parse", "HEAD"], cancellationToken) ?? wt.BaseCommit;
var isDirty = Directory.Exists(wt.Path) && await _git.HasChangesAsync(wt.Path, cancellationToken);
var ahead = await GitRevListCountAsync(wt.Path, $"{wt.BaseCommit}..HEAD", cancellationToken);
var behind = await GitRevListCountAsync(wt.Path, "HEAD..main", cancellationToken);
return new WorktreeInfoDto(wt.Path, wt.BranchName, headCommit!, wt.BaseCommit, ahead, behind, isDirty);
}
[McpServerTool, Description(
"Get the diff for a task's worktree relative to its base commit. " +
"stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " +
"stat=true: returns a --stat summary (changed files with insertion/deletion counts). " +
"files always lists the changed file paths regardless of stat mode. " +
"totalBytes is the uncapped diff size (useful when truncated=true). " +
"Throws if the task has no worktree or the worktree directory is missing from disk.")]
public async Task<TaskDiffDto> GetTaskDiff(
string taskId, bool stat = false, CancellationToken cancellationToken = default)
{
var (_, _, wt) = await LoadWorktreeContextAsync(taskId, cancellationToken);
if (!Directory.Exists(wt.Path))
throw new InvalidOperationException($"Worktree directory does not exist on disk: {wt.Path}");
const int maxBytes = 200 * 1024;
if (stat)
{
var diffStat = await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", cancellationToken);
return new TaskDiffDto(diffStat, ParseDiffStatFileNames(diffStat), false, diffStat.Length);
}
var diff = await _git.GetBranchDiffAsync(wt.Path, wt.BaseCommit, cancellationToken);
var files = ParseDiffFileNames(diff);
if (diff.Length <= maxBytes)
return new TaskDiffDto(diff, files, false, diff.Length);
return new TaskDiffDto(diff[..maxBytes], files, true, diff.Length);
}
[McpServerTool, Description(
"Merge a task's worktree branch into targetBranch (default: main). " +
"noFf=true (default): always creates a merge commit (--no-ff). " +
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
"Refuses if task status is not Done (status values: Idle, Queued, Running, Done, Failed, Cancelled). " +
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
"On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")]
public async Task<MergeTaskResultDto> MergeTask(
string taskId,
string targetBranch = "main",
bool noFf = true,
bool dryRun = false,
CancellationToken cancellationToken = default)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Done)
throw new InvalidOperationException(
$"Task must be Done to merge (current status: {task.Status}). " +
"Valid statuses for merge: Done.");
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
if (dryRun)
{
using var ctx = _dbFactory.CreateDbContext();
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
if (wt.State != WorktreeState.Active)
throw new InvalidOperationException(
$"Worktree state must be Active to merge (current: {wt.State}).");
return new MergeTaskResultDto(false, null, []);
}
var commitMessage = $"Merge task branch for: {task.Title}";
var result = await _merge.MergeAsync(
taskId, targetBranch, removeWorktree: false, commitMessage, cancellationToken);
if (result.Status == TaskMergeService.StatusMerged)
{
string? mergeCommit = null;
try
{
if (!string.IsNullOrWhiteSpace(list?.WorkingDir) && Directory.Exists(list.WorkingDir))
mergeCommit = await _git.RevParseHeadAsync(list.WorkingDir, cancellationToken);
}
catch { /* mergeCommit is optional */ }
return new MergeTaskResultDto(true, mergeCommit, []);
}
if (result.Status == TaskMergeService.StatusConflict)
return new MergeTaskResultDto(false, null, result.ConflictFiles);
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
}
[McpServerTool, Description(
"List all ClaudeDo-tracked worktrees. " +
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
"isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " +
"Only worktrees recorded in the ClaudeDo database are returned.")]
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
{
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
var results = await Task.WhenAll(rows.Select(async row =>
{
var isDirty = row.PathExistsOnDisk && await TryGetIsDirtyAsync(row.Path, cancellationToken);
var headCommit = row.PathExistsOnDisk
? (await TryRunGitAsync(row.Path, ["rev-parse", "HEAD"], cancellationToken) ?? "")
: "";
return new WorktreeListItemDto(
row.TaskId, row.Path, row.BranchName, headCommit,
isDirty, row.State == WorktreeState.Merged);
}));
return results;
}
[McpServerTool, Description(
"Remove a task's worktree directory and delete its git branch. " +
"force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " +
"force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " +
"Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")]
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
string taskId, bool force = false, CancellationToken cancellationToken = default)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot remove worktree of a running task.");
if (!force && Directory.Exists(wt.Path))
{
var isDirty = await _git.HasChangesAsync(wt.Path, cancellationToken);
if (isDirty)
throw new InvalidOperationException(
"Worktree has uncommitted changes. Use force=true to remove anyway (changes will be lost).");
}
var path = wt.Path;
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
return new CleanupWorktreeResult(result.Removed, path, result.Removed);
}
// ── Private helpers ───────────────────────────────────────────────────────
private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity Wt)> LoadWorktreeContextAsync(
string taskId, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} has no worktree.");
return (task, list, wt);
}
private async Task<bool> TryGetIsDirtyAsync(string path, CancellationToken ct)
{
try { return await _git.HasChangesAsync(path, ct); }
catch { return false; }
}
// Minimal git runner for operations not covered by GitService (rev-list --count, rev-parse from worktree).
private static async Task<string?> TryRunGitAsync(string dir, string[] args, CancellationToken ct)
{
try
{
var psi = new ProcessStartInfo("git")
{
UseShellExecute = false,
RedirectStandardOutput = true,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(dir);
foreach (var a in args) psi.ArgumentList.Add(a);
using var proc = Process.Start(psi)!;
await using var _ = ct.Register(() => { try { proc.Kill(entireProcessTree: true); } catch { } });
var stdout = await proc.StandardOutput.ReadToEndAsync();
await proc.WaitForExitAsync(CancellationToken.None);
ct.ThrowIfCancellationRequested();
return proc.ExitCode == 0 ? stdout.Trim() : null;
}
catch (OperationCanceledException) { throw; }
catch { return null; }
}
private static async Task<int> GitRevListCountAsync(string dir, string range, CancellationToken ct)
{
var result = await TryRunGitAsync(dir, ["rev-list", "--count", range], ct);
return int.TryParse(result, out var n) ? n : 0;
}
private static IReadOnlyList<string> ParseDiffFileNames(string diff)
{
var files = new List<string>();
foreach (var line in diff.Split('\n'))
{
var s = line.TrimEnd('\r');
if (s.StartsWith("+++ b/", StringComparison.Ordinal))
files.Add(s[6..]);
}
return files;
}
private static IReadOnlyList<string> ParseDiffStatFileNames(string stat)
{
var files = new List<string>();
foreach (var line in stat.Split('\n'))
{
var idx = line.IndexOf('|');
if (idx > 0) files.Add(line[..idx].Trim());
}
return files;
}
private static TaskDto ToDto(TaskEntity t) => new( private static TaskDto ToDto(TaskEntity t) => new(
t.Id, t.Id,
t.ListId, t.ListId,

View File

@@ -11,6 +11,12 @@ public sealed record RunDto(
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut, int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
DateTime? StartedAt, DateTime? FinishedAt); DateTime? StartedAt, DateTime? FinishedAt);
public sealed record TaskLogResult(
bool Available,
IReadOnlyList<string> Entries,
int TotalLines,
bool Truncated);
[McpServerToolType] [McpServerToolType]
public sealed class RunHistoryMcpTools public sealed class RunHistoryMcpTools
{ {
@@ -33,26 +39,68 @@ public sealed class RunHistoryMcpTools
return ToDto(run); return ToDto(run);
} }
private const int MaxLogBytes = 256 * 1024; [McpServerTool, Description(
"Fetch log entries from a task's latest run. " +
[McpServerTool, Description("Fetch the raw log output of a task's latest run. Throws if no log is available.")] "Returns { available, entries, totalLines, truncated }. " +
public async Task<string> GetTaskLog(string taskId, CancellationToken cancellationToken) "available=false means no log exists yet (task is queued or just started — not an error). " +
"entries are the individual lines (NDJSON messages) from Claude's streaming output. " +
"Default: returns the last 50 entries (tail=50). " +
"tail: override the number of trailing entries to return. " +
"offset+limit: return entries starting at position offset (0-based); overrides tail when provided. " +
"truncated=true when fewer entries are returned than totalLines.")]
public async Task<TaskLogResult> GetTaskLog(
string taskId,
int? tail = null,
int? offset = null,
int? limit = null,
CancellationToken cancellationToken = default)
{ {
var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken) var run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken);
?? throw new InvalidOperationException($"No runs found for task {taskId}."); if (run is null || string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
if (string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath)) return new TaskLogResult(false, [], 0, false);
throw new InvalidOperationException("No log available for the latest run.");
var totalBytes = new FileInfo(run.LogPath).Length; string allText;
if (totalBytes <= MaxLogBytes) try
return await File.ReadAllTextAsync(run.LogPath, cancellationToken); {
await using var fs = new FileStream(
run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(fs);
allText = await reader.ReadToEndAsync(cancellationToken);
}
catch (IOException)
{
return new TaskLogResult(false, [], 0, false);
}
var buffer = new byte[MaxLogBytes]; var lines = allText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
await using var fs = new FileStream(run.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); var totalLines = lines.Length;
fs.Seek(totalBytes - MaxLogBytes, SeekOrigin.Begin);
var read = await fs.ReadAsync(buffer, cancellationToken); IReadOnlyList<string> entries;
var tail = System.Text.Encoding.UTF8.GetString(buffer, 0, read); bool truncated;
return $"[truncated: showing last {MaxLogBytes} of {totalBytes} bytes]\n{tail}";
if (offset.HasValue || limit.HasValue)
{
var start = Math.Max(0, offset ?? 0);
var count = limit.HasValue ? Math.Min(limit.Value, totalLines - start) : totalLines - start;
entries = lines.Skip(start).Take(count).ToArray();
truncated = start > 0 || (start + count) < totalLines;
}
else
{
var take = tail ?? 50;
if (totalLines <= take)
{
entries = lines;
truncated = false;
}
else
{
entries = lines[^take..];
truncated = true;
}
}
return new TaskLogResult(true, entries, totalLines, truncated);
} }
private static RunDto ToDto(TaskRunEntity r) => new( private static RunDto ToDto(TaskRunEntity r) => new(

View File

@@ -204,6 +204,9 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskResetService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<GitService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeMaintenanceService>());
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<TaskMergeService>());
externalBuilder.Services.AddScoped<ExternalMcpService>(); externalBuilder.Services.AddScoped<ExternalMcpService>();
externalBuilder.Services.AddScoped<ListMcpTools>(); externalBuilder.Services.AddScoped<ListMcpTools>();
externalBuilder.Services.AddScoped<ConfigMcpTools>(); externalBuilder.Services.AddScoped<ConfigMcpTools>();

View File

@@ -5,10 +5,12 @@ using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.External; using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services; using ClaudeDo.Worker.Tests.Services;
using ClaudeDo.Worker.Worktrees;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -87,9 +89,17 @@ public sealed class ExternalMcpServiceTests : IDisposable
return task; return task;
} }
private ExternalMcpService BuildSut(QueueService queue) => private ExternalMcpService BuildSut(QueueService queue)
new(_tasks, _lists, queue, _broadcaster, {
TaskStateServiceBuilder.Build(_db.CreateFactory()).State); var git = new GitService();
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, NullLogger<TaskMergeService>.Instance);
return new ExternalMcpService(
_tasks, _lists, queue, _broadcaster,
TaskStateServiceBuilder.Build(factory).State,
git, factory, maintenance, merge);
}
private QueueService CreateQueue() private QueueService CreateQueue()
{ {

View File

@@ -54,13 +54,16 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
} }
[Fact] [Fact]
public async Task GetTaskLog_NoLog_Throws() public async Task GetTaskLog_NoRun_ReturnsUnavailable()
{ {
var taskId = Guid.NewGuid().ToString(); var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId); await SeedTaskAsync(taskId);
await Assert.ThrowsAsync<InvalidOperationException>(() => var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
_sut.GetTaskLog(taskId, CancellationToken.None));
Assert.False(result.Available);
Assert.Empty(result.Entries);
Assert.Equal(0, result.TotalLines);
} }
[Fact] [Fact]
@@ -69,24 +72,26 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
var taskId = Guid.NewGuid().ToString(); var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId); await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
await File.WriteAllTextAsync(logPath, "hello log"); await File.WriteAllTextAsync(logPath, "line1\nline2\nline3");
await _runs.AddAsync(new TaskRunEntity await _runs.AddAsync(new TaskRunEntity
{ {
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", LogPath = logPath, IsRetry = false, Prompt = "p", LogPath = logPath,
}); });
string content; TaskLogResult result;
try try
{ {
content = await _sut.GetTaskLog(taskId, CancellationToken.None); result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
} }
finally finally
{ {
File.Delete(logPath); File.Delete(logPath);
} }
Assert.Equal("hello log", content); Assert.True(result.Available);
Assert.Equal(3, result.TotalLines);
Assert.Contains("line1", result.Entries);
} }
[Fact] [Fact]
@@ -97,7 +102,7 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
} }
[Fact] [Fact]
public async Task GetTaskLog_RunExistsButNoLogPath_Throws() public async Task GetTaskLog_RunExistsButNoLogPath_ReturnsUnavailable()
{ {
var taskId = Guid.NewGuid().ToString(); var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId); await SeedTaskAsync(taskId);
@@ -107,22 +112,22 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
IsRetry = false, Prompt = "p", LogPath = null, IsRetry = false, Prompt = "p", LogPath = null,
}); });
await Assert.ThrowsAsync<InvalidOperationException>(() => var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
_sut.GetTaskLog(taskId, CancellationToken.None));
Assert.False(result.Available);
Assert.Empty(result.Entries);
} }
[Fact] [Fact]
public async Task GetTaskLog_LargeFile_ReturnsTruncatedTail() public async Task GetTaskLog_ManyLines_DefaultTailReturnsLast50()
{ {
var taskId = Guid.NewGuid().ToString(); var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId); await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
// Write 300 KB so it exceeds the 256 KB cap // Write 108 lines (the observed real-world size that exceeded token limits)
var chunk = new string('A', 1024); var lines = Enumerable.Range(1, 108).Select(i => $"{{\"line\":{i}}}");
await using (var w = new StreamWriter(logPath, append: false)) await File.WriteAllLinesAsync(logPath, lines);
for (var i = 0; i < 300; i++)
await w.WriteAsync(chunk);
await _runs.AddAsync(new TaskRunEntity await _runs.AddAsync(new TaskRunEntity
{ {
@@ -130,17 +135,84 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
IsRetry = false, Prompt = "p", LogPath = logPath, IsRetry = false, Prompt = "p", LogPath = logPath,
}); });
string content; TaskLogResult result;
try try
{ {
content = await _sut.GetTaskLog(taskId, CancellationToken.None); result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
} }
finally finally
{ {
File.Delete(logPath); File.Delete(logPath);
} }
Assert.StartsWith("[truncated:", content); Assert.True(result.Available);
Assert.True(content.Length < 300 * 1024); Assert.True(result.Truncated);
Assert.Equal(108, result.TotalLines);
Assert.Equal(50, result.Entries.Count);
Assert.Contains("{\"line\":108}", result.Entries); // last line is present
Assert.DoesNotContain("{\"line\":1}", result.Entries); // first line is not
}
[Fact]
public async Task GetTaskLog_TailParam_ReturnsRequestedCount()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
var lines = Enumerable.Range(1, 20).Select(i => $"line{i}");
await File.WriteAllLinesAsync(logPath, lines);
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", LogPath = logPath,
});
TaskLogResult result;
try
{
result = await _sut.GetTaskLog(taskId, tail: 5, cancellationToken: CancellationToken.None);
}
finally
{
File.Delete(logPath);
}
Assert.True(result.Available);
Assert.True(result.Truncated);
Assert.Equal(5, result.Entries.Count);
Assert.Equal("line20", result.Entries[^1]);
}
[Fact]
public async Task GetTaskLog_OffsetLimit_ReturnsSlice()
{
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId);
var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt");
var lines = Enumerable.Range(1, 10).Select(i => $"line{i}");
await File.WriteAllLinesAsync(logPath, lines);
await _runs.AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
IsRetry = false, Prompt = "p", LogPath = logPath,
});
TaskLogResult result;
try
{
result = await _sut.GetTaskLog(taskId, offset: 2, limit: 3, cancellationToken: CancellationToken.None);
}
finally
{
File.Delete(logPath);
}
Assert.True(result.Available);
Assert.Equal(3, result.Entries.Count);
Assert.Equal("line3", result.Entries[0]);
Assert.Equal("line5", result.Entries[^1]);
Assert.True(result.Truncated);
} }
} }