feat(worker): add external MCP run-history and log tools
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
51
src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs
vendored
Normal file
51
src/ClaudeDo.Worker/External/RunHistoryMcpTools.cs
vendored
Normal file
@@ -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<IReadOnlyList<RunDto>> 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<RunDto> 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<string> 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);
|
||||||
|
}
|
||||||
84
tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
vendored
Normal file
84
tests/ClaudeDo.Worker.Tests/External/RunHistoryMcpToolsTests.cs
vendored
Normal file
@@ -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<InvalidOperationException>(() =>
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user