feat(data,worker): add repositories, stale-task recovery, and test foundation
Data: TagRepository, ListRepository, TaskRepository (incl. queue selection via effective agent tag + scheduled_for filter), WorktreeRepository. All CRUD via parameterized SqliteCommand; enums roundtrip as lowercase strings matching the schema CHECK constraints. Worker: StaleTaskRecovery IHostedService flips running -> failed on startup and marks the result column with a [stale] reason. All four repositories registered as singletons. Tests: DbFixture with temp-file SQLite + schema bootstrap, covering TaskRepository (queue pick via list-tag and task-tag, schedule filter, transitions, stale flip), ListRepository CRUD + junctions, and StaleTaskRecovery. 14 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
102
src/ClaudeDo.Data/Repositories/WorktreeRepository.cs
Normal file
102
src/ClaudeDo.Data/Repositories/WorktreeRepository.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class WorktreeRepository
|
||||
{
|
||||
private readonly SqliteConnectionFactory _factory;
|
||||
|
||||
public WorktreeRepository(SqliteConnectionFactory factory) => _factory = factory;
|
||||
|
||||
private static string ToDb(WorktreeState s) => s switch
|
||||
{
|
||||
WorktreeState.Active => "active",
|
||||
WorktreeState.Merged => "merged",
|
||||
WorktreeState.Discarded => "discarded",
|
||||
WorktreeState.Kept => "kept",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
private static WorktreeState FromDb(string s) => s switch
|
||||
{
|
||||
"active" => WorktreeState.Active,
|
||||
"merged" => WorktreeState.Merged,
|
||||
"discarded" => WorktreeState.Discarded,
|
||||
"kept" => WorktreeState.Kept,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(s)),
|
||||
};
|
||||
|
||||
public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO worktrees (task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at)
|
||||
VALUES (@task_id, @path, @branch_name, @base_commit, @head_commit, @diff_stat, @state, @created_at)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@task_id", entity.TaskId);
|
||||
cmd.Parameters.AddWithValue("@path", entity.Path);
|
||||
cmd.Parameters.AddWithValue("@branch_name", entity.BranchName);
|
||||
cmd.Parameters.AddWithValue("@base_commit", entity.BaseCommit);
|
||||
cmd.Parameters.AddWithValue("@head_commit", (object?)entity.HeadCommit ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@diff_stat", (object?)entity.DiffStat ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("@state", ToDb(entity.State));
|
||||
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT task_id, path, branch_name, base_commit, head_commit, diff_stat, state, created_at FROM worktrees WHERE task_id = @task_id";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct)) return null;
|
||||
return ReadWorktree(reader);
|
||||
}
|
||||
|
||||
public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE worktrees SET head_commit = @head_commit, diff_stat = @diff_stat WHERE task_id = @task_id";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
cmd.Parameters.AddWithValue("@head_commit", headCommit);
|
||||
cmd.Parameters.AddWithValue("@diff_stat", (object?)diffStat ?? DBNull.Value);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE worktrees SET state = @state WHERE task_id = @task_id";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
cmd.Parameters.AddWithValue("@state", ToDb(state));
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await using var conn = _factory.Open();
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM worktrees WHERE task_id = @task_id";
|
||||
cmd.Parameters.AddWithValue("@task_id", taskId);
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
private static WorktreeEntity ReadWorktree(SqliteDataReader r) => new()
|
||||
{
|
||||
TaskId = r.GetString(0),
|
||||
Path = r.GetString(1),
|
||||
BranchName = r.GetString(2),
|
||||
BaseCommit = r.GetString(3),
|
||||
HeadCommit = r.IsDBNull(4) ? null : r.GetString(4),
|
||||
DiffStat = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
State = FromDb(r.GetString(6)),
|
||||
CreatedAt = DateTime.Parse(r.GetString(7)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user