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:
139
src/ClaudeDo.Data/Repositories/TaskRunRepository.cs
Normal file
139
src/ClaudeDo.Data/Repositories/TaskRunRepository.cs
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user