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