merge: MCP surface — worktree/diff/merge/log tools + status-enum docs
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.Diagnostics;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ModelContextProtocol.Server;
|
||||
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 DeleteTaskResult(bool Deleted, string Id);
|
||||
public sealed record CancelTaskResult(bool Cancelled, string Id);
|
||||
public sealed record StatusValueDto(string Status, string Meaning);
|
||||
|
||||
public sealed record TaskDto(
|
||||
string Id,
|
||||
@@ -25,6 +32,23 @@ public sealed record TaskDto(
|
||||
DateTime? StartedAt,
|
||||
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]
|
||||
public sealed class ExternalMcpService
|
||||
{
|
||||
@@ -33,19 +57,31 @@ public sealed class ExternalMcpService
|
||||
private readonly QueueService _queue;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly GitService _git;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorktreeMaintenanceService _maintenance;
|
||||
private readonly TaskMergeService _merge;
|
||||
|
||||
public ExternalMcpService(
|
||||
TaskRepository tasks,
|
||||
ListRepository lists,
|
||||
QueueService queue,
|
||||
HubBroadcaster broadcaster,
|
||||
ITaskStateService state)
|
||||
ITaskStateService state,
|
||||
GitService git,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeMaintenanceService maintenance,
|
||||
TaskMergeService merge)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_lists = lists;
|
||||
_queue = queue;
|
||||
_broadcaster = broadcaster;
|
||||
_state = state;
|
||||
_git = git;
|
||||
_dbFactory = dbFactory;
|
||||
_maintenance = maintenance;
|
||||
_merge = merge;
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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(
|
||||
string listId,
|
||||
string? createdBy,
|
||||
@@ -66,7 +104,8 @@ public sealed class ExternalMcpService
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -80,7 +119,10 @@ public sealed class ExternalMcpService
|
||||
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)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -120,7 +162,6 @@ public sealed class ExternalMcpService
|
||||
|
||||
if (queueImmediately)
|
||||
{
|
||||
// Routes through TaskStateService so the queue is woken automatically.
|
||||
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
||||
if (!enqueue.Ok)
|
||||
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
|
||||
@@ -154,14 +195,19 @@ public sealed class ExternalMcpService
|
||||
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(
|
||||
string taskId,
|
||||
string status,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
@@ -181,7 +227,7 @@ public sealed class ExternalMcpService
|
||||
|
||||
default:
|
||||
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))!;
|
||||
@@ -227,6 +273,256 @@ public sealed class ExternalMcpService
|
||||
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(
|
||||
t.Id,
|
||||
t.ListId,
|
||||
|
||||
@@ -11,6 +11,12 @@ public sealed record RunDto(
|
||||
int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut,
|
||||
DateTime? StartedAt, DateTime? FinishedAt);
|
||||
|
||||
public sealed record TaskLogResult(
|
||||
bool Available,
|
||||
IReadOnlyList<string> Entries,
|
||||
int TotalLines,
|
||||
bool Truncated);
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class RunHistoryMcpTools
|
||||
{
|
||||
@@ -33,26 +39,68 @@ public sealed class RunHistoryMcpTools
|
||||
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)
|
||||
[McpServerTool, Description(
|
||||
"Fetch log entries from a task's latest run. " +
|
||||
"Returns { available, entries, totalLines, truncated }. " +
|
||||
"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)
|
||||
?? 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 run = await _runs.GetLatestByTaskIdAsync(taskId, cancellationToken);
|
||||
if (run is null || string.IsNullOrWhiteSpace(run.LogPath) || !File.Exists(run.LogPath))
|
||||
return new TaskLogResult(false, [], 0, false);
|
||||
|
||||
var totalBytes = new FileInfo(run.LogPath).Length;
|
||||
if (totalBytes <= MaxLogBytes)
|
||||
return await File.ReadAllTextAsync(run.LogPath, cancellationToken);
|
||||
string allText;
|
||||
try
|
||||
{
|
||||
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];
|
||||
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}";
|
||||
var lines = allText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var totalLines = lines.Length;
|
||||
|
||||
IReadOnlyList<string> entries;
|
||||
bool truncated;
|
||||
|
||||
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(
|
||||
|
||||
@@ -204,6 +204,9 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<WorktreeManager>());
|
||||
externalBuilder.Services.AddSingleton(app.Services.GetRequiredService<AgentFileService>());
|
||||
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<ListMcpTools>();
|
||||
externalBuilder.Services.AddScoped<ConfigMcpTools>();
|
||||
|
||||
@@ -5,10 +5,12 @@ using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Tests.Services;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -87,9 +89,17 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
return task;
|
||||
}
|
||||
|
||||
private ExternalMcpService BuildSut(QueueService queue) =>
|
||||
new(_tasks, _lists, queue, _broadcaster,
|
||||
TaskStateServiceBuilder.Build(_db.CreateFactory()).State);
|
||||
private ExternalMcpService BuildSut(QueueService queue)
|
||||
{
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -54,13 +54,16 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_NoLog_Throws()
|
||||
public async Task GetTaskLog_NoRun_ReturnsUnavailable()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.GetTaskLog(taskId, CancellationToken.None));
|
||||
var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Empty(result.Entries);
|
||||
Assert.Equal(0, result.TotalLines);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -69,24 +72,26 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
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 File.WriteAllTextAsync(logPath, "line1\nline2\nline3");
|
||||
await _runs.AddAsync(new TaskRunEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1,
|
||||
IsRetry = false, Prompt = "p", LogPath = logPath,
|
||||
});
|
||||
|
||||
string content;
|
||||
TaskLogResult result;
|
||||
try
|
||||
{
|
||||
content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||
result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(logPath);
|
||||
}
|
||||
|
||||
Assert.Equal("hello log", content);
|
||||
Assert.True(result.Available);
|
||||
Assert.Equal(3, result.TotalLines);
|
||||
Assert.Contains("line1", result.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,7 +102,7 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_RunExistsButNoLogPath_Throws()
|
||||
public async Task GetTaskLog_RunExistsButNoLogPath_ReturnsUnavailable()
|
||||
{
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId);
|
||||
@@ -107,22 +112,22 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
IsRetry = false, Prompt = "p", LogPath = null,
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_sut.GetTaskLog(taskId, CancellationToken.None));
|
||||
var result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.False(result.Available);
|
||||
Assert.Empty(result.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTaskLog_LargeFile_ReturnsTruncatedTail()
|
||||
public async Task GetTaskLog_ManyLines_DefaultTailReturnsLast50()
|
||||
{
|
||||
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);
|
||||
// Write 108 lines (the observed real-world size that exceeded token limits)
|
||||
var lines = Enumerable.Range(1, 108).Select(i => $"{{\"line\":{i}}}");
|
||||
await File.WriteAllLinesAsync(logPath, lines);
|
||||
|
||||
await _runs.AddAsync(new TaskRunEntity
|
||||
{
|
||||
@@ -130,17 +135,84 @@ public sealed class RunHistoryMcpToolsTests : IDisposable
|
||||
IsRetry = false, Prompt = "p", LogPath = logPath,
|
||||
});
|
||||
|
||||
string content;
|
||||
TaskLogResult result;
|
||||
try
|
||||
{
|
||||
content = await _sut.GetTaskLog(taskId, CancellationToken.None);
|
||||
result = await _sut.GetTaskLog(taskId, cancellationToken: CancellationToken.None);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(logPath);
|
||||
}
|
||||
|
||||
Assert.StartsWith("[truncated:", content);
|
||||
Assert.True(content.Length < 300 * 1024);
|
||||
Assert.True(result.Available);
|
||||
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