feat(data): rewrite all repositories to use EF Core ClaudeDoDbContext

Replace raw ADO.NET implementations with EF Core LINQ queries and
ExecuteUpdate/ExecuteDelete for bulk operations. TaskRepository preserves
FlipAllRunningToFailedAsync(reason) signature and keeps raw SQL for the
atomic queue claim (UPDATE...RETURNING). GetByListAsync alias kept for
backwards compat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-16 08:58:57 +02:00
parent 51a5dcbb73
commit 34ca1b018f
6 changed files with 227 additions and 641 deletions

View File

@@ -1,157 +1,89 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class ListRepository public sealed class ListRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public ListRepository(SqliteConnectionFactory factory) => _factory = factory; public ListRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(ListEntity entity, CancellationToken ct = default) public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Lists.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
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) public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Lists.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
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) public async Task DeleteAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
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) public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct);
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) public async Task<List<ListEntity>> GetAllAsync(CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct);
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) public async Task<List<TagEntity>> GetTagsAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Lists
await using var cmd = conn.CreateCommand(); .Where(l => l.Id == listId)
cmd.CommandText = """ .SelectMany(l => l.Tags)
SELECT t.id, t.name FROM tags t .ToListAsync(ct);
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) public async Task AddTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct);
await using var cmd = conn.CreateCommand(); var tag = await _context.Tags.FindAsync([tagId], ct);
cmd.CommandText = "INSERT OR IGNORE INTO list_tags (list_id, tag_id) VALUES (@list_id, @tag_id)"; if (tag is not null && !list.Tags.Any(t => t.Id == tagId))
cmd.Parameters.AddWithValue("@list_id", listId); {
cmd.Parameters.AddWithValue("@tag_id", tagId); list.Tags.Add(tag);
await cmd.ExecuteNonQueryAsync(ct); await _context.SaveChangesAsync(ct);
}
} }
public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string listId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct);
await using var cmd = conn.CreateCommand(); var tag = list.Tags.FirstOrDefault(t => t.Id == tagId);
cmd.CommandText = "DELETE FROM list_tags WHERE list_id = @list_id AND tag_id = @tag_id"; if (tag is not null)
cmd.Parameters.AddWithValue("@list_id", listId); {
cmd.Parameters.AddWithValue("@tag_id", tagId); list.Tags.Remove(tag);
await cmd.ExecuteNonQueryAsync(ct); await _context.SaveChangesAsync(ct);
}
} }
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default) public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id";
cmd.Parameters.AddWithValue("@list_id", listId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return new ListConfigEntity
{
ListId = reader.GetString(0),
Model = reader.IsDBNull(1) ? null : reader.GetString(1),
SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2),
AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3),
};
} }
public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default) public async Task SetConfigAsync(ListConfigEntity config, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct);
await using var cmd = conn.CreateCommand(); if (existing is null)
cmd.CommandText = """ {
INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path) _context.ListConfigs.Add(config);
VALUES (@list_id, @model, @system_prompt, @agent_path)
""";
cmd.Parameters.AddWithValue("@list_id", entity.ListId);
cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value);
cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
} }
else
private static ListEntity ReadList(SqliteDataReader reader) => new()
{ {
Id = reader.GetString(0), existing.Model = config.Model;
Name = reader.GetString(1), existing.SystemPrompt = config.SystemPrompt;
CreatedAt = DateTime.Parse(reader.GetString(2)), existing.AgentPath = config.AgentPath;
WorkingDir = reader.IsDBNull(3) ? null : reader.GetString(3), }
DefaultCommitType = reader.GetString(4), await _context.SaveChangesAsync(ct);
}; }
} }

View File

@@ -1,81 +1,41 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class SubtaskRepository public sealed class SubtaskRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory; public SubtaskRepository(ClaudeDoDbContext context) => _context = context;
public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, task_id, title, completed, order_num, created_at FROM subtasks WHERE task_id = @task_id ORDER BY order_num";
cmd.Parameters.AddWithValue("@task_id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
var result = new List<SubtaskEntity>();
while (await reader.ReadAsync(ct))
result.Add(ReadSubtask(reader));
return result;
}
public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default) public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Subtasks.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """ }
INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at)
VALUES (@id, @task_id, @title, @completed, @order_num, @created_at) public async Task<List<SubtaskEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
"""; {
BindSubtask(cmd, entity); return await _context.Subtasks
await cmd.ExecuteNonQueryAsync(ct); .Where(s => s.TaskId == taskId)
.OrderBy(s => s.OrderNum)
.ToListAsync(ct);
} }
public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default) public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Subtasks.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
UPDATE subtasks SET title = @title, completed = @completed, order_num = @order_num
WHERE id = @id
""";
cmd.Parameters.AddWithValue("@id", entity.Id);
cmd.Parameters.AddWithValue("@title", entity.Title);
cmd.Parameters.AddWithValue("@completed", entity.Completed ? 1 : 0);
cmd.Parameters.AddWithValue("@order_num", entity.OrderNum);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task DeleteAsync(string id, CancellationToken ct = default) public async Task DeleteAsync(string subtaskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM subtasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
await cmd.ExecuteNonQueryAsync(ct);
} }
private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e) public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default)
{ {
cmd.Parameters.AddWithValue("@id", e.Id); await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct);
cmd.Parameters.AddWithValue("@task_id", e.TaskId);
cmd.Parameters.AddWithValue("@title", e.Title);
cmd.Parameters.AddWithValue("@completed", e.Completed ? 1 : 0);
cmd.Parameters.AddWithValue("@order_num", e.OrderNum);
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
} }
private static SubtaskEntity ReadSubtask(SqliteDataReader r) => new()
{
Id = r.GetString(0),
TaskId = r.GetString(1),
Title = r.GetString(2),
Completed = r.GetInt64(3) != 0,
OrderNum = r.GetInt32(4),
CreatedAt = DateTime.Parse(r.GetString(5)),
};
} }

View File

@@ -1,47 +1,28 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class TagRepository public sealed class TagRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public TagRepository(SqliteConnectionFactory factory) => _factory = factory; public TagRepository(ClaudeDoDbContext context) => _context = context;
public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default) public async Task<List<TagEntity>> GetAllAsync(CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name FROM tags ORDER BY id";
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<long> GetOrCreateAsync(string name, CancellationToken ct = default) public async Task<long> GetOrCreateAsync(string name, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
return await GetOrCreateAsync(conn, name, ct);
}
public static async Task<long> GetOrCreateAsync(SqliteConnection conn, string name, CancellationToken ct = default)
{
await using var sel = conn.CreateCommand();
sel.CommandText = "SELECT id FROM tags WHERE name = @name";
sel.Parameters.AddWithValue("@name", name);
var existing = await sel.ExecuteScalarAsync(ct);
if (existing is not null) if (existing is not null)
return (long)existing; return existing.Id;
await using var ins = conn.CreateCommand(); var tag = new TagEntity { Name = name };
ins.CommandText = "INSERT INTO tags (name) VALUES (@name) RETURNING id"; _context.Tags.Add(tag);
ins.Parameters.AddWithValue("@name", name); await _context.SaveChangesAsync(ct);
return tag.Id;
return (long)(await ins.ExecuteScalarAsync(ct))!;
} }
} }

View File

@@ -1,171 +1,146 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class TaskRepository public sealed class TaskRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public TaskRepository(SqliteConnectionFactory factory) => _factory = factory; public TaskRepository(ClaudeDoDbContext context) => _context = context;
#region Status mapping
private static string ToDb(TaskStatus s) => s switch
{
TaskStatus.Manual => "manual",
TaskStatus.Queued => "queued",
TaskStatus.Running => "running",
TaskStatus.Done => "done",
TaskStatus.Failed => "failed",
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
private static TaskStatus FromDb(string s) => s switch
{
"manual" => TaskStatus.Manual,
"queued" => TaskStatus.Queued,
"running" => TaskStatus.Running,
"done" => TaskStatus.Done,
"failed" => TaskStatus.Failed,
_ => throw new ArgumentOutOfRangeException(nameof(s)),
};
#endregion
#region CRUD #region CRUD
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default) public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Tasks.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
result, log_path, created_at, started_at, finished_at, commit_type,
model, system_prompt, agent_path)
VALUES (@id, @list_id, @title, @description, @status, @scheduled_for,
@result, @log_path, @created_at, @started_at, @finished_at, @commit_type,
@model, @system_prompt, @agent_path)
""";
BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default) public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Tasks.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
cmd.CommandText = """
UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
status = @status, scheduled_for = @scheduled_for, result = @result,
log_path = @log_path, started_at = @started_at,
finished_at = @finished_at, commit_type = @commit_type,
model = @model, system_prompt = @system_prompt, agent_path = @agent_path
WHERE id = @id
""";
BindTask(cmd, entity);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task DeleteAsync(string taskId, CancellationToken ct = default) public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default) public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
await using var reader = await cmd.ExecuteReaderAsync(ct);
if (!await reader.ReadAsync(ct)) return null;
return ReadTask(reader);
} }
public async Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default) public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Tasks
await using var cmd = conn.CreateCommand(); .Where(t => t.ListId == listId)
cmd.CommandText = "SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks WHERE list_id = @list_id ORDER BY created_at"; .OrderByDescending(t => t.CreatedAt)
cmd.Parameters.AddWithValue("@list_id", listId); .ToListAsync(ct);
}
await using var reader = await cmd.ExecuteReaderAsync(ct); // Kept for backwards-compatibility with callers using the old name.
var result = new List<TaskEntity>(); public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
while (await reader.ReadAsync(ct)) => GetByListIdAsync(listId, ct);
result.Add(ReadTask(reader));
return result; #endregion
#region Status transitions
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Running)
.SetProperty(t => t.StartedAt, startedAt), ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Done)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
var resultText = "[stale] " + reason;
var now = DateTime.UtcNow;
return await _context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, now)
.SetProperty(t => t.Result, resultText), ct);
} }
#endregion #endregion
#region Tag junction #region Tags
public async Task<List<TagEntity>> GetTagsAsync(string taskId, 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 task_tags tt ON tt.tag_id = t.id
WHERE tt.task_id = @task_id
""";
cmd.Parameters.AddWithValue("@task_id", taskId);
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 taskId, long tagId, CancellationToken ct = default) public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct);
await using var cmd = conn.CreateCommand(); var tag = await _context.Tags.FindAsync([tagId], ct);
cmd.CommandText = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (@task_id, @tag_id)"; if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
cmd.Parameters.AddWithValue("@task_id", taskId); {
cmd.Parameters.AddWithValue("@tag_id", tagId); task.Tags.Add(tag);
await cmd.ExecuteNonQueryAsync(ct); await _context.SaveChangesAsync(ct);
}
} }
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default) public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct);
await using var cmd = conn.CreateCommand(); var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
cmd.CommandText = "DELETE FROM task_tags WHERE task_id = @task_id AND tag_id = @tag_id"; if (tag is not null)
cmd.Parameters.AddWithValue("@task_id", taskId); {
cmd.Parameters.AddWithValue("@tag_id", tagId); task.Tags.Remove(tag);
await cmd.ExecuteNonQueryAsync(ct); await _context.SaveChangesAsync(ct);
}
}
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags)
.ToListAsync(ct);
} }
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default) public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); var taskTags = _context.Tasks
await using var cmd = conn.CreateCommand(); .Where(t => t.Id == taskId)
cmd.CommandText = """ .SelectMany(t => t.Tags);
SELECT DISTINCT t.id, t.name FROM tags t var listTags = _context.Tasks
WHERE t.id IN ( .Where(t => t.Id == taskId)
SELECT tag_id FROM task_tags WHERE task_id = @task_id .SelectMany(t => t.List.Tags);
UNION return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
SELECT lt.tag_id FROM list_tags lt
JOIN tasks tk ON tk.list_id = lt.list_id
WHERE tk.id = @task_id
)
""";
cmd.Parameters.AddWithValue("@task_id", taskId);
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;
} }
#endregion #endregion
@@ -174,146 +149,38 @@ public sealed class TaskRepository
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
{ {
// Atomically claim the next queued agent task: the UPDATE flips its // Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
// status to 'running' in the same statement that returns its row, // Uses raw SQL because EF cannot express UPDATE...RETURNING.
// eliminating the TOCTOU gap where two queue-loop iterations could // Includes both task-level and list-level "agent" tag so lists tagged "agent"
// both select the same queued task before either marked it running. // automatically enqueue all their tasks without per-task tagging.
// The caller is responsible for populating started_at shortly after. // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
await using var conn = _factory.Open(); var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
await using var cmd = conn.CreateCommand(); var result = await _context.Tasks.FromSqlRaw("""
cmd.CommandText = """ UPDATE tasks SET status = 'running'
UPDATE tasks
SET status = 'running'
WHERE id = ( WHERE id = (
SELECT t.id SELECT t.id FROM tasks t
FROM tasks t
WHERE t.status = 'queued' WHERE t.status = 'queued'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now) AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
AND EXISTS ( AND (
EXISTS (
SELECT 1 FROM task_tags tt SELECT 1 FROM task_tags tt
JOIN tags tg ON tg.id = tt.tag_id JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.task_id = t.id AND tg.name = 'agent' WHERE tt.task_id = t.id AND tg.name = 'agent'
UNION )
OR EXISTS (
SELECT 1 FROM list_tags lt SELECT 1 FROM list_tags lt
JOIN tags tg ON tg.id = lt.tag_id JOIN tags tg ON tg.id = lt.tag_id
WHERE lt.list_id = t.list_id AND tg.name = 'agent' WHERE lt.list_id = t.list_id AND tg.name = 'agent'
) )
)
ORDER BY t.created_at ASC ORDER BY t.created_at ASC
LIMIT 1 LIMIT 1
) )
RETURNING id, list_id, title, description, status, scheduled_for, RETURNING *
result, log_path, created_at, started_at, finished_at, commit_type, """, nowStr).ToListAsync(ct);
model, system_prompt, agent_path
""";
cmd.Parameters.AddWithValue("@now", now.ToString("o"));
await using var reader = await cmd.ExecuteReaderAsync(ct); return result.FirstOrDefault();
if (!await reader.ReadAsync(ct)) return null;
return ReadTask(reader);
} }
#endregion #endregion
#region Transitions
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET log_path = @log_path WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@log_path", logPath);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'running', started_at = @started_at WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@started_at", startedAt.ToString("o"));
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'done', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)result ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? errorMarkdown, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET status = 'failed', finished_at = @finished_at, result = @result WHERE id = @id";
cmd.Parameters.AddWithValue("@id", taskId);
cmd.Parameters.AddWithValue("@finished_at", finishedAt.ToString("o"));
cmd.Parameters.AddWithValue("@result", (object?)errorMarkdown ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
await using var conn = _factory.Open();
await using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE tasks SET status = 'failed',
finished_at = @now,
result = '[stale] ' || @reason
WHERE status = 'running'
""";
cmd.Parameters.AddWithValue("@now", DateTime.UtcNow.ToString("o"));
cmd.Parameters.AddWithValue("@reason", reason);
return await cmd.ExecuteNonQueryAsync(ct);
}
#endregion
#region Helpers
private static void BindTask(SqliteCommand cmd, TaskEntity e)
{
cmd.Parameters.AddWithValue("@id", e.Id);
cmd.Parameters.AddWithValue("@list_id", e.ListId);
cmd.Parameters.AddWithValue("@title", e.Title);
cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("@status", ToDb(e.Status));
cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value);
cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value);
cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
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);
cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
}
private static TaskEntity ReadTask(SqliteDataReader r) => new()
{
Id = r.GetString(0),
ListId = r.GetString(1),
Title = r.GetString(2),
Description = r.IsDBNull(3) ? null : r.GetString(3),
Status = FromDb(r.GetString(4)),
ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)),
Result = r.IsDBNull(6) ? null : r.GetString(6),
LogPath = r.IsDBNull(7) ? null : r.GetString(7),
CreatedAt = DateTime.Parse(r.GetString(8)),
StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
CommitType = r.GetString(11),
Model = r.IsDBNull(12) ? null : r.GetString(12),
SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
};
#endregion
} }

View File

@@ -1,139 +1,44 @@
using System.Globalization;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class TaskRunRepository public sealed class TaskRunRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory; public TaskRunRepository(ClaudeDoDbContext context) => _context = context;
public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default) public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.TaskRuns.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
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) public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.TaskRuns.Update(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
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) public async Task<TaskRunEntity?> GetByIdAsync(string id, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct);
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) public async Task<List<TaskRunEntity>> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.TaskRuns
await using var cmd = conn.CreateCommand(); .Where(r => r.TaskId == taskId)
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"; .OrderBy(r => r.RunNumber)
cmd.Parameters.AddWithValue("@task_id", taskId); .ToListAsync(ct);
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) public async Task<TaskRunEntity?> GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.TaskRuns
await using var cmd = conn.CreateCommand(); .Where(r => r.TaskId == taskId)
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"; .OrderByDescending(r => r.RunNumber)
cmd.Parameters.AddWithValue("@task_id", taskId); .FirstOrDefaultAsync(ct);
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
} }

View File

@@ -1,102 +1,43 @@
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories; namespace ClaudeDo.Data.Repositories;
public sealed class WorktreeRepository public sealed class WorktreeRepository
{ {
private readonly SqliteConnectionFactory _factory; private readonly ClaudeDoDbContext _context;
public WorktreeRepository(SqliteConnectionFactory factory) => _factory = factory; public WorktreeRepository(ClaudeDoDbContext context) => _context = context;
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) public async Task AddAsync(WorktreeEntity entity, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); _context.Worktrees.Add(entity);
await using var cmd = conn.CreateCommand(); await _context.SaveChangesAsync(ct);
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) public async Task<WorktreeEntity?> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct);
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) public async Task UpdateHeadAsync(string taskId, string headCommit, string? diffStat, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Worktrees
await using var cmd = conn.CreateCommand(); .Where(w => w.TaskId == taskId)
cmd.CommandText = "UPDATE worktrees SET head_commit = @head_commit, diff_stat = @diff_stat WHERE task_id = @task_id"; .ExecuteUpdateAsync(s => s
cmd.Parameters.AddWithValue("@task_id", taskId); .SetProperty(w => w.HeadCommit, headCommit)
cmd.Parameters.AddWithValue("@head_commit", headCommit); .SetProperty(w => w.DiffStat, diffStat), ct);
cmd.Parameters.AddWithValue("@diff_stat", (object?)diffStat ?? DBNull.Value);
await cmd.ExecuteNonQueryAsync(ct);
} }
public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default) public async Task SetStateAsync(string taskId, WorktreeState state, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Worktrees
await using var cmd = conn.CreateCommand(); .Where(w => w.TaskId == taskId)
cmd.CommandText = "UPDATE worktrees SET state = @state WHERE task_id = @task_id"; .ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct);
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) public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{ {
await using var conn = _factory.Open(); await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
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)),
};
} }