diff --git a/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs new file mode 100644 index 0000000..70b50e0 --- /dev/null +++ b/src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs @@ -0,0 +1,51 @@ +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); + +[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 the raw log output of a task's latest run. Throws if no log is available.")] + public async Task GetTaskLog(string taskId, CancellationToken cancellationToken) + { + 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."); + return await File.ReadAllTextAsync(run.LogPath, cancellationToken); + } + + 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); +} diff --git a/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs new file mode 100644 index 0000000..71649c2 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs @@ -0,0 +1,84 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.External; +using ClaudeDo.Worker.Tests.Infrastructure; + +namespace ClaudeDo.Worker.Tests.External; + +public sealed class RunHistoryMcpToolsTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRunRepository _runs; + private readonly RunHistoryMcpTools _sut; + + public RunHistoryMcpToolsTests() + { + _ctx = _db.CreateContext(); + _runs = new TaskRunRepository(_ctx); + _sut = new RunHistoryMcpTools(_runs); + } + + public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + + private async Task SeedTaskAsync(string taskId) + { + var lists = new ListRepository(_ctx); + var tasks = new TaskRepository(_ctx); + var listId = Guid.NewGuid().ToString(); + await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + await tasks.AddAsync(new TaskEntity + { + Id = taskId, ListId = listId, Title = "t", + Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore", + }); + } + + [Fact] + public async Task ListRuns_ReturnsProjectedRuns() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + await _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20, + }); + + var list = await _sut.ListRuns(taskId, CancellationToken.None); + + Assert.Single(list); + Assert.Equal("done", list[0].ResultMarkdown); + Assert.Equal(10, list[0].TokensIn); + } + + [Fact] + public async Task GetTaskLog_NoLog_Throws() + { + var taskId = Guid.NewGuid().ToString(); + await SeedTaskAsync(taskId); + + await Assert.ThrowsAsync(() => + _sut.GetTaskLog(taskId, CancellationToken.None)); + } + + [Fact] + public async Task GetTaskLog_ReadsLatestRunLogFile() + { + 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 _runs.AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, + IsRetry = false, Prompt = "p", LogPath = logPath, + }); + + var content = await _sut.GetTaskLog(taskId, CancellationToken.None); + + Assert.Equal("hello log", content); + File.Delete(logPath); + } +}