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:
Mika Kuns
2026-04-13 12:08:06 +02:00
parent f81ef02273
commit 9f51ff0b17
11 changed files with 989 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
namespace ClaudeDo.Data.Repositories;
public sealed class ListRepository
{
private readonly SqliteConnectionFactory _factory;
public ListRepository(SqliteConnectionFactory factory) => _factory = factory;
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO lists (id, name, created_at, working_dir, default_commit_type)
VALUES (@id, @name, @created_at, @working_dir, @default_commit_type)
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@name", entity.Name);
cmd.Parameters.AddWithValue("@created_at", entity.CreatedAt.ToString("o"));
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE lists SET name = @name, working_dir = @working_dir,
default_commit_type = @default_commit_type
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@name", entity.Name);
cmd.Parameters.AddWithValue("@working_dir", (object?)entity.WorkingDir ?? DBNull.Value);
cmd.Parameters.AddWithValue("@default_commit_type", entity.DefaultCommitType);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task DeleteAsync(string listId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM lists WHERE id = @id";
cmd.Parameters.AddWithValue("@id", listId);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists WHERE id = @id";
cmd.Parameters.AddWithValue("@id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadList(reader);
}
public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, created_at, working_dir, default_commit_type FROM lists ORDER BY created_at";
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<ListEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadList(reader));
return result;
}
public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT t.id, t.name FROM tags t
JOIN list_tags lt ON lt.tag_id = t.id
WHERE lt.list_id = @list_id
""";
cmd.Parameters.AddWithValue("@list_id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<TagEntity>();
while (await reader.ReadAsync(ct))
result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) });
return result;
}
public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT OR IGNORE INTO list_tags (list_id, tag_id) VALUES (@list_id, @tag_id)";
cmd.Parameters.AddWithValue("@list_id", listId);
cmd.Parameters.AddWithValue("@tag_id", tagId);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM list_tags WHERE list_id = @list_id AND tag_id = @tag_id";
cmd.Parameters.AddWithValue("@list_id", listId);
cmd.Parameters.AddWithValue("@tag_id", tagId);
await cmd.ExecuteNonQueryAsync(ct);
}
private static ListEntity ReadList(SqliteDataReader reader) => new()
{
Id = reader.GetString(0),
Name = reader.GetString(1),
CreatedAt = DateTime.Parse(reader.GetString(2)),
WorkingDir = reader.IsDBNull(3) ? null : reader.GetString(3),
DefaultCommitType = reader.GetString(4),
};
}