feat(claude-do): MCP surface: worktree/diff/merge/log tools + status-enum doc
BUNDLE — all changes live in src/ClaudeDo.Worker/External/ExternalMcpService.cs only, so this is one worktree / one merge. Do NOT touch run-recording or data-layer code (those are separate tasks). Reuse the existing services behind the UI modals (WorktreesOverviewModalView, DiffModalView, MergeModalView) — do not reimplement git plumbing. Build green after each addition. Add these MCP tools: 1. g ClaudeDo-Task: f6bdfb5b-8cbf-4e65-93d4-6c758a160484
This commit is contained in:
312
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
312
src/ClaudeDo.Worker/External/ExternalMcpService.cs
vendored
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user