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 (newest run metadata, tokens, turns, result, error).")] public async Task> ListRuns(string taskId, CancellationToken cancellationToken) { var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken); return runs.Select(ToDto).ToList(); } [McpServerTool, Description("Get a single execution run by its run id.")] 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 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); 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); }