using System.ComponentModel; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ModelContextProtocol.Server; namespace ClaudeDo.Worker.External; public sealed record RunDto( string Id, int RunNumber, string? SessionId, bool IsRetry, string? ResultMarkdown, string? StructuredOutputJson, string? ErrorMarkdown, int? ExitCode, int? TurnCount, int? TokensIn, int? TokensOut, DateTime? StartedAt, DateTime? FinishedAt); public sealed record TaskLogResult( bool Available, IReadOnlyList Entries, int TotalLines, bool Truncated); [McpServerToolType] public sealed class RunHistoryMcpTools { private readonly TaskRunRepository _runs; public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs; [McpServerTool, Description( "List all execution runs for a task — metadata, tokens, turns, result, and error per run — ordered " + "oldest to newest by run number, so the last entry is the most recent. Use a run's id from here with " + "get_run to fetch it individually.")] public async Task> ListRuns(string taskId, CancellationToken cancellationToken) { var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken); return runs.Select(ToDto).ToList(); } [McpServerTool, Description("Get one execution run's full detail by its run id, obtained from list_runs.")] public async Task GetRun(string runId, CancellationToken cancellationToken) { var run = await _runs.GetByIdAsync(runId, cancellationToken) ?? throw new InvalidOperationException($"Run {runId} not found."); return ToDto(run); } [McpServerTool, Description( "Fetch NDJSON log lines from a task's latest run — use this to check progress or debug a task without " + "opening the log file. Defaults to the last 50 lines. available=false means no log exists yet (queued " + "or just started — not an error); truncated=true when fewer entries are returned than totalLines.")] public async Task GetTaskLog( string taskId, [Description("Number of trailing entries to return; ignored if offset or limit is set. Default 50.")] int? tail = null, [Description("0-based entry index to start from; overrides tail when set. Combine with limit to page through the log.")] int? offset = null, [Description("Max entries to return starting at offset. Omit to return everything from offset to the end.")] int? limit = null, CancellationToken cancellationToken = default) { 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); 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 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( r.Id, r.RunNumber, r.SessionId, r.IsRetry, r.ResultMarkdown, r.StructuredOutputJson, r.ErrorMarkdown, r.ExitCode, r.TurnCount, r.TokensIn, r.TokensOut, r.StartedAt, r.FinishedAt); }