From 19a210406e76425567d9fd2f06425f056627f8dc Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 14 Apr 2026 11:31:34 +0200 Subject: [PATCH] feat(data): add TaskRunRepository with CRUD and query methods Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Repositories/TaskRunRepository.cs | 139 ++++++++++++++ .../Repositories/TaskRunRepositoryTests.cs | 170 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/ClaudeDo.Data/Repositories/TaskRunRepository.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs diff --git a/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs new file mode 100644 index 0000000..a635113 --- /dev/null +++ b/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs @@ -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 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> 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(); + while (await reader.ReadAsync(ct)) + result.Add(ReadRun(reader)); + return result; + } + + public async Task 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 +} diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs new file mode 100644 index 0000000..0d06bb1 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs @@ -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); + } +}