feat(data): add TaskRunRepository with CRUD and query methods

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mika Kuns
2026-04-14 11:31:34 +02:00
parent 02aaa9da64
commit 19a210406e
2 changed files with 309 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
using System.Globalization;
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data.Repositories;
public sealed class TaskRunRepository
{
private readonly SqliteConnectionFactory _factory;
public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory;
public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt,
result_markdown, structured_output, error_markdown, exit_code,
turn_count, tokens_in, tokens_out, log_path, started_at, finished_at)
VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt,
@result_markdown, @structured_output, @error_markdown, @exit_code,
@turn_count, @tokens_in, @tokens_out, @log_path, @started_at, @finished_at)
""";
BindRun(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE task_runs SET session_id = @session_id,
result_markdown = @result_markdown,
structured_output = @structured_output,
error_markdown = @error_markdown,
exit_code = @exit_code,
turn_count = @turn_count,
tokens_in = @tokens_in,
tokens_out = @tokens_out,
finished_at = @finished_at
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value);
cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<TaskRunEntity?> GetByIdAsync(string runId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id";
cmd.Parameters.AddWithValue("@id", runId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadRun(reader);
}
public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TaskRunEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadRun(reader));
return result;
}
public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadRun(reader);
}
#region Helpers
private static void BindRun(SqliteCommand cmd, TaskRunEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
cmd.Parameters.AddWithValue("@run_number", e.RunNumber);
cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0);
cmd.Parameters.AddWithValue("@prompt", e.Prompt);
cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value);
cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value);
cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value);
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
}
private static TaskRunEntity ReadRun(SqliteDataReader r) => new()
{
Id = r.GetString(0),
TaskId = r.GetString(1),
RunNumber = r.GetInt32(2),
SessionId = r.IsDBNull(3) ? null : r.GetString(3),
IsRetry = r.GetInt32(4) != 0,
Prompt = r.GetString(5),
ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6),
StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7),
ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8),
ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9),
TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10),
TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11),
TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12),
LogPath = r.IsDBNull(13) ? null : r.GetString(13),
StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14), null, DateTimeStyles.RoundtripKind),
FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15), null, DateTimeStyles.RoundtripKind),
};
#endregion
}

View File

@@ -0,0 +1,170 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Repositories;
public sealed class TaskRunRepositoryTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly TaskRunRepository _runs;
private readonly string _taskId;
public TaskRunRepositoryTests()
{
_runs = new TaskRunRepository(_db.Factory);
// Seed a list and task for all tests
var lists = new ListRepository(_db.Factory);
var tasks = new TaskRepository(_db.Factory);
var listId = Guid.NewGuid().ToString();
lists.AddAsync(new ListEntity
{
Id = listId,
Name = "Test List",
CreatedAt = DateTime.UtcNow,
}).GetAwaiter().GetResult();
_taskId = Guid.NewGuid().ToString();
tasks.AddAsync(new TaskEntity
{
Id = _taskId,
ListId = listId,
Title = "Test Task",
Status = Data.Models.TaskStatus.Queued,
CreatedAt = DateTime.UtcNow,
CommitType = "feat",
}).GetAwaiter().GetResult();
}
public void Dispose() => _db.Dispose();
private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
{
Id = Guid.NewGuid().ToString(),
TaskId = _taskId,
RunNumber = runNumber,
IsRetry = isRetry,
Prompt = $"Do something (run {runNumber})",
StartedAt = DateTime.UtcNow,
};
[Fact]
public async Task Add_And_GetById_Roundtrips()
{
var entity = new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = _taskId,
RunNumber = 1,
SessionId = "sess-abc",
IsRetry = false,
Prompt = "Fix the bug",
ResultMarkdown = "All done",
StructuredOutputJson = """{"ok":true}""",
ErrorMarkdown = null,
ExitCode = 0,
TurnCount = 5,
TokensIn = 1000,
TokensOut = 2000,
LogPath = "/tmp/run1.ndjson",
StartedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
FinishedAt = new DateTime(2026, 1, 1, 0, 5, 0, DateTimeKind.Utc),
};
await _runs.AddAsync(entity);
var loaded = await _runs.GetByIdAsync(entity.Id);
Assert.NotNull(loaded);
Assert.Equal(entity.Id, loaded.Id);
Assert.Equal(entity.TaskId, loaded.TaskId);
Assert.Equal(entity.RunNumber, loaded.RunNumber);
Assert.Equal(entity.SessionId, loaded.SessionId);
Assert.Equal(entity.IsRetry, loaded.IsRetry);
Assert.Equal(entity.Prompt, loaded.Prompt);
Assert.Equal(entity.ResultMarkdown, loaded.ResultMarkdown);
Assert.Equal(entity.StructuredOutputJson, loaded.StructuredOutputJson);
Assert.Null(loaded.ErrorMarkdown);
Assert.Equal(entity.ExitCode, loaded.ExitCode);
Assert.Equal(entity.TurnCount, loaded.TurnCount);
Assert.Equal(entity.TokensIn, loaded.TokensIn);
Assert.Equal(entity.TokensOut, loaded.TokensOut);
Assert.Equal(entity.LogPath, loaded.LogPath);
Assert.Equal(entity.StartedAt, loaded.StartedAt);
Assert.Equal(entity.FinishedAt, loaded.FinishedAt);
}
[Fact]
public async Task GetByTaskId_Returns_Ordered_By_RunNumber()
{
var run3 = MakeRun(3);
var run1 = MakeRun(1);
var run2 = MakeRun(2);
await _runs.AddAsync(run3);
await _runs.AddAsync(run1);
await _runs.AddAsync(run2);
var runs = await _runs.GetByTaskIdAsync(_taskId);
Assert.Equal(3, runs.Count);
Assert.Equal(1, runs[0].RunNumber);
Assert.Equal(2, runs[1].RunNumber);
Assert.Equal(3, runs[2].RunNumber);
}
[Fact]
public async Task GetLatestByTaskId_Returns_Highest_RunNumber()
{
var run1 = MakeRun(1);
var run2 = MakeRun(2);
await _runs.AddAsync(run1);
await _runs.AddAsync(run2);
var latest = await _runs.GetLatestByTaskIdAsync(_taskId);
Assert.NotNull(latest);
Assert.Equal(run2.Id, latest.Id);
Assert.Equal(2, latest.RunNumber);
}
[Fact]
public async Task Update_Persists_Completion_Fields()
{
var run = MakeRun(1);
await _runs.AddAsync(run);
run.SessionId = "sess-xyz";
run.ResultMarkdown = "Task completed";
run.StructuredOutputJson = """{"status":"done"}""";
run.ErrorMarkdown = null;
run.ExitCode = 0;
run.TurnCount = 12;
run.TokensIn = 5000;
run.TokensOut = 8000;
run.FinishedAt = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
await _runs.UpdateAsync(run);
var loaded = await _runs.GetByIdAsync(run.Id);
Assert.NotNull(loaded);
Assert.Equal("sess-xyz", loaded.SessionId);
Assert.Equal("Task completed", loaded.ResultMarkdown);
Assert.Equal("""{"status":"done"}""", loaded.StructuredOutputJson);
Assert.Null(loaded.ErrorMarkdown);
Assert.Equal(0, loaded.ExitCode);
Assert.Equal(12, loaded.TurnCount);
Assert.Equal(5000, loaded.TokensIn);
Assert.Equal(8000, loaded.TokensOut);
Assert.Equal(new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc), loaded.FinishedAt);
}
[Fact]
public async Task GetLatestByTaskId_Returns_Null_When_No_Runs()
{
var latest = await _runs.GetLatestByTaskIdAsync(Guid.NewGuid().ToString());
Assert.Null(latest);
}
}