From c0978df19ab12585021a12a1abb85855ad5bb4a8 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 1 Jun 2026 16:15:26 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfeat(claude-do):=20MCP=20surface:=20wo?= =?UTF-8?q?rktree/diff/merge/log=20tools=20+=20status-enum=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../External/ExternalMcpService.cs | 312 +++++++++++++++++- .../External/RunHistoryMcpTools.cs | 82 ++++- src/ClaudeDo.Worker/Program.cs | 3 + .../External/ExternalMcpServiceTests.cs | 16 +- .../External/RunHistoryMcpToolsTests.cs | 112 +++++-- 5 files changed, 477 insertions(+), 48 deletions(-) diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 3cf46ad..1102245 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -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 Files, bool Truncated, int TotalBytes); + +public sealed record MergeTaskResultDto( + bool Merged, string? MergeCommit, IReadOnlyList 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 _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 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> ListTasks( string listId, string? createdBy, @@ -66,7 +104,8 @@ public sealed class ExternalMcpService if (!string.IsNullOrWhiteSpace(status)) { if (!Enum.TryParse(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 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 UpdateTaskStatus( string taskId, string status, CancellationToken cancellationToken) { if (!Enum.TryParse(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> GetTaskStatusValues() => + Task.FromResult>([ + 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 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 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 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> 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 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 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 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 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 ParseDiffFileNames(string diff) + { + var files = new List(); + 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 ParseDiffStatFileNames(string stat) + { + var files = new List(); + 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, diff --git a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs index 1081a28..06e4d79 100644 --- a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs +++ b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs @@ -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 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 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 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 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( diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 62780fe..680ac6d 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -204,6 +204,9 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); + externalBuilder.Services.AddSingleton(app.Services.GetRequiredService()); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 6c0b41a..f6478db 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -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.Instance); + var merge = new TaskMergeService(factory, git, _broadcaster, NullLogger.Instance); + return new ExternalMcpService( + _tasks, _lists, queue, _broadcaster, + TaskStateServiceBuilder.Build(factory).State, + git, factory, maintenance, merge); + } private QueueService CreateQueue() { diff --git a/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs index 730806c..25cc5f0 100644 --- a/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs @@ -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(() => - _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(() => - _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); } }