diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index ff0a639..456c933 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -1,157 +1,89 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; 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) { - 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); + _context.Lists.Add(entity); + await _context.SaveChangesAsync(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); + _context.Lists.Update(entity); + await _context.SaveChangesAsync(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); + await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct); } public async Task 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); + return await _context.Lists.FirstOrDefaultAsync(l => l.Id == listId, ct); } public async Task> 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(); - while (await reader.ReadAsync(ct)) - result.Add(ReadList(reader)); - return result; + return await _context.Lists.OrderBy(l => l.CreatedAt).ToListAsync(ct); } public async Task> 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(); - while (await reader.ReadAsync(ct)) - result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) }); - return result; + return await _context.Lists + .Where(l => l.Id == listId) + .SelectMany(l => l.Tags) + .ToListAsync(ct); } 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); + var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct); + var tag = await _context.Tags.FindAsync([tagId], ct); + if (tag is not null && !list.Tags.Any(t => t.Id == tagId)) + { + list.Tags.Add(tag); + await _context.SaveChangesAsync(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); + var list = await _context.Lists.Include(l => l.Tags).FirstAsync(l => l.Id == listId, ct); + var tag = list.Tags.FirstOrDefault(t => t.Id == tagId); + if (tag is not null) + { + list.Tags.Remove(tag); + await _context.SaveChangesAsync(ct); + } } public async Task GetConfigAsync(string listId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - 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); + return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct); + } - await using var reader = await cmd.ExecuteReaderAsync(ct); - if (!await reader.ReadAsync(ct)) return null; - return new ListConfigEntity + public async Task SetConfigAsync(ListConfigEntity config, CancellationToken ct = default) + { + var existing = await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == config.ListId, ct); + if (existing is null) { - 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), - }; + _context.ListConfigs.Add(config); + } + else + { + existing.Model = config.Model; + existing.SystemPrompt = config.SystemPrompt; + existing.AgentPath = config.AgentPath; + } + await _context.SaveChangesAsync(ct); } - - public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default) - { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path) - 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); - } - - 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), - }; } diff --git a/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs b/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs index 68a77b9..5369701 100644 --- a/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/SubtaskRepository.cs @@ -1,81 +1,41 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; public sealed class SubtaskRepository { - private readonly SqliteConnectionFactory _factory; + private readonly ClaudeDoDbContext _context; - public SubtaskRepository(SqliteConnectionFactory factory) => _factory = factory; - - public async Task> 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(); - while (await reader.ReadAsync(ct)) - result.Add(ReadSubtask(reader)); - return result; - } + public SubtaskRepository(ClaudeDoDbContext context) => _context = context; public async Task AddAsync(SubtaskEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - INSERT INTO subtasks (id, task_id, title, completed, order_num, created_at) - VALUES (@id, @task_id, @title, @completed, @order_num, @created_at) - """; - BindSubtask(cmd, entity); - await cmd.ExecuteNonQueryAsync(ct); + _context.Subtasks.Add(entity); + await _context.SaveChangesAsync(ct); + } + + public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default) + { + return await _context.Subtasks + .Where(s => s.TaskId == taskId) + .OrderBy(s => s.OrderNum) + .ToListAsync(ct); } public async Task UpdateAsync(SubtaskEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - 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); + _context.Subtasks.Update(entity); + await _context.SaveChangesAsync(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 using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM subtasks WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", id); - await cmd.ExecuteNonQueryAsync(ct); + await _context.Subtasks.Where(s => s.Id == subtaskId).ExecuteDeleteAsync(ct); } - private static void BindSubtask(SqliteCommand cmd, SubtaskEntity e) + public async Task DeleteByTaskIdAsync(string taskId, CancellationToken ct = default) { - cmd.Parameters.AddWithValue("@id", e.Id); - 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")); + await _context.Subtasks.Where(s => s.TaskId == taskId).ExecuteDeleteAsync(ct); } - - 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)), - }; } diff --git a/src/ClaudeDo.Data/Repositories/TagRepository.cs b/src/ClaudeDo.Data/Repositories/TagRepository.cs index d15fa0e..647a927 100644 --- a/src/ClaudeDo.Data/Repositories/TagRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TagRepository.cs @@ -1,47 +1,28 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; 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> GetAllAsync(CancellationToken ct = default) { - await using var conn = _factory.Open(); - 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(); - while (await reader.ReadAsync(ct)) - result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) }); - return result; + return await _context.Tags.OrderBy(t => t.Id).ToListAsync(ct); } public async Task GetOrCreateAsync(string name, CancellationToken ct = default) { - await using var conn = _factory.Open(); - return await GetOrCreateAsync(conn, name, ct); - } - - public static async Task 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); + var existing = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct); if (existing is not null) - return (long)existing; + return existing.Id; - await using var ins = conn.CreateCommand(); - ins.CommandText = "INSERT INTO tags (name) VALUES (@name) RETURNING id"; - ins.Parameters.AddWithValue("@name", name); - - return (long)(await ins.ExecuteScalarAsync(ct))!; + var tag = new TagEntity { Name = name }; + _context.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + return tag.Id; } } diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 64de4d2..d7cc260 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -1,171 +1,146 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Data.Repositories; public sealed class TaskRepository { - private readonly SqliteConnectionFactory _factory; + private readonly ClaudeDoDbContext _context; - public TaskRepository(SqliteConnectionFactory factory) => _factory = factory; - - #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 + public TaskRepository(ClaudeDoDbContext context) => _context = context; #region CRUD public async Task AddAsync(TaskEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - 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); + _context.Tasks.Add(entity); + await _context.SaveChangesAsync(ct); } public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - 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); + _context.Tasks.Update(entity); + await _context.SaveChangesAsync(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 tasks WHERE id = @id"; - cmd.Parameters.AddWithValue("@id", taskId); - await cmd.ExecuteNonQueryAsync(ct); + await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct); } public async Task GetByIdAsync(string taskId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - 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); + return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); } - public async Task> GetByListAsync(string listId, CancellationToken ct = default) + public async Task> GetByListIdAsync(string listId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - 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 list_id = @list_id ORDER BY created_at"; - cmd.Parameters.AddWithValue("@list_id", listId); + return await _context.Tasks + .Where(t => t.ListId == listId) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(ct); + } - await using var reader = await cmd.ExecuteReaderAsync(ct); - var result = new List(); - while (await reader.ReadAsync(ct)) - result.Add(ReadTask(reader)); - return result; + // Kept for backwards-compatibility with callers using the old name. + public Task> GetByListAsync(string listId, CancellationToken ct = default) + => GetByListIdAsync(listId, ct); + + #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 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 - #region Tag junction - - public async Task> 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(); - while (await reader.ReadAsync(ct)) - result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) }); - return result; - } + #region Tags public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "INSERT OR IGNORE INTO task_tags (task_id, tag_id) VALUES (@task_id, @tag_id)"; - cmd.Parameters.AddWithValue("@task_id", taskId); - cmd.Parameters.AddWithValue("@tag_id", tagId); - await cmd.ExecuteNonQueryAsync(ct); + var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct); + var tag = await _context.Tags.FindAsync([tagId], ct); + if (tag is not null && !task.Tags.Any(t => t.Id == tagId)) + { + task.Tags.Add(tag); + await _context.SaveChangesAsync(ct); + } } public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "DELETE FROM task_tags WHERE task_id = @task_id AND tag_id = @tag_id"; - cmd.Parameters.AddWithValue("@task_id", taskId); - cmd.Parameters.AddWithValue("@tag_id", tagId); - await cmd.ExecuteNonQueryAsync(ct); + var task = await _context.Tasks.Include(t => t.Tags).FirstAsync(t => t.Id == taskId, ct); + var tag = task.Tags.FirstOrDefault(t => t.Id == tagId); + if (tag is not null) + { + task.Tags.Remove(tag); + await _context.SaveChangesAsync(ct); + } + } + + public async Task> GetTagsAsync(string taskId, CancellationToken ct = default) + { + return await _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.Tags) + .ToListAsync(ct); } public async Task> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default) { - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - SELECT DISTINCT t.id, t.name FROM tags t - WHERE t.id IN ( - SELECT tag_id FROM task_tags WHERE task_id = @task_id - UNION - 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(); - while (await reader.ReadAsync(ct)) - result.Add(new TagEntity { Id = reader.GetInt64(0), Name = reader.GetString(1) }); - return result; + var taskTags = _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.Tags); + var listTags = _context.Tasks + .Where(t => t.Id == taskId) + .SelectMany(t => t.List.Tags); + return await taskTags.Union(listTags).Distinct().ToListAsync(ct); } #endregion @@ -174,146 +149,38 @@ public sealed class TaskRepository public async Task GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) { - // Atomically claim the next queued agent task: the UPDATE flips its - // status to 'running' in the same statement that returns its row, - // eliminating the TOCTOU gap where two queue-loop iterations could - // both select the same queued task before either marked it running. - // The caller is responsible for populating started_at shortly after. - await using var conn = _factory.Open(); - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - UPDATE tasks - SET status = 'running' + // Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races. + // Uses raw SQL because EF cannot express UPDATE...RETURNING. + // Includes both task-level and list-level "agent" tag so lists tagged "agent" + // automatically enqueue all their tasks without per-task tagging. + // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison. + var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff"); + var result = await _context.Tasks.FromSqlRaw(""" + UPDATE tasks SET status = 'running' WHERE id = ( - SELECT t.id - FROM tasks t + SELECT t.id FROM tasks t WHERE t.status = 'queued' - AND (t.scheduled_for IS NULL OR t.scheduled_for <= @now) - AND EXISTS ( - SELECT 1 FROM task_tags tt - JOIN tags tg ON tg.id = tt.tag_id - WHERE tt.task_id = t.id AND tg.name = 'agent' - UNION - SELECT 1 FROM list_tags lt - JOIN tags tg ON tg.id = lt.tag_id - WHERE lt.list_id = t.list_id AND tg.name = 'agent' + AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0}) + AND ( + EXISTS ( + SELECT 1 FROM task_tags tt + JOIN tags tg ON tg.id = tt.tag_id + WHERE tt.task_id = t.id AND tg.name = 'agent' + ) + OR EXISTS ( + SELECT 1 FROM list_tags lt + JOIN tags tg ON tg.id = lt.tag_id + WHERE lt.list_id = t.list_id AND tg.name = 'agent' + ) ) ORDER BY t.created_at ASC LIMIT 1 ) - RETURNING id, list_id, title, description, status, scheduled_for, - result, log_path, created_at, started_at, finished_at, commit_type, - model, system_prompt, agent_path - """; - cmd.Parameters.AddWithValue("@now", now.ToString("o")); + RETURNING * + """, nowStr).ToListAsync(ct); - await using var reader = await cmd.ExecuteReaderAsync(ct); - if (!await reader.ReadAsync(ct)) return null; - return ReadTask(reader); + return result.FirstOrDefault(); } #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 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 } diff --git a/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs index a635113..0830523 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRunRepository.cs @@ -1,139 +1,44 @@ -using System.Globalization; using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; 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) { - 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); + _context.TaskRuns.Add(entity); + await _context.SaveChangesAsync(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); + _context.TaskRuns.Update(entity); + await _context.SaveChangesAsync(ct); } - public async Task GetByIdAsync(string runId, CancellationToken ct = default) + public async Task GetByIdAsync(string id, 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); + return await _context.TaskRuns.FirstOrDefaultAsync(r => r.Id == id, ct); } public async Task> 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(); - while (await reader.ReadAsync(ct)) - result.Add(ReadRun(reader)); - return result; + return await _context.TaskRuns + .Where(r => r.TaskId == taskId) + .OrderBy(r => r.RunNumber) + .ToListAsync(ct); } public async Task 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); + return await _context.TaskRuns + .Where(r => r.TaskId == taskId) + .OrderByDescending(r => r.RunNumber) + .FirstOrDefaultAsync(ct); } - - #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 } diff --git a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs index ae96a54..39bf109 100644 --- a/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs +++ b/src/ClaudeDo.Data/Repositories/WorktreeRepository.cs @@ -1,102 +1,43 @@ using ClaudeDo.Data.Models; -using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; namespace ClaudeDo.Data.Repositories; public sealed class WorktreeRepository { - private readonly SqliteConnectionFactory _factory; + private readonly ClaudeDoDbContext _context; - 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 WorktreeRepository(ClaudeDoDbContext context) => _context = context; 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); + _context.Worktrees.Add(entity); + await _context.SaveChangesAsync(ct); } public async Task 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); + return await _context.Worktrees.FirstOrDefaultAsync(w => w.TaskId == taskId, ct); } 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); + await _context.Worktrees + .Where(w => w.TaskId == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(w => w.HeadCommit, headCommit) + .SetProperty(w => w.DiffStat, diffStat), 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); + await _context.Worktrees + .Where(w => w.TaskId == taskId) + .ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), 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); + await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(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)), - }; }