using ClaudeDo.Data.Models; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Data.Repositories; public sealed class TaskRepository { private readonly ClaudeDoDbContext _context; public TaskRepository(ClaudeDoDbContext context) => _context = context; #region CRUD public async Task AddAsync(TaskEntity entity, CancellationToken ct = default) { // Append at bottom of the list by default: SortOrder = max(listId) + 1. var maxSort = await _context.Tasks .Where(t => t.ListId == entity.ListId) .Select(t => (int?)t.SortOrder) .MaxAsync(ct); entity.SortOrder = (maxSort ?? -1) + 1; _context.Tasks.Add(entity); await _context.SaveChangesAsync(ct); } public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default) { _context.Tasks.Update(entity); await _context.SaveChangesAsync(ct); } public async Task DeleteAsync(string taskId, CancellationToken ct = default) { await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct); } public async Task GetByIdAsync(string taskId, CancellationToken ct = default) { return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); } public async Task> GetByListIdAsync(string listId, CancellationToken ct = default) { return await _context.Tasks .Where(t => t.ListId == listId) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); } /// /// Renumbers tasks in a list to 0..N-1 according to . /// Ids not belonging to the list are ignored; ids missing from the list are untouched. /// public async Task ReorderAsync(string listId, IReadOnlyList orderedTaskIds, CancellationToken ct = default) { if (orderedTaskIds.Count == 0) return; var idSet = orderedTaskIds.ToHashSet(); var tasks = await _context.Tasks .Where(t => t.ListId == listId && idSet.Contains(t.Id)) .ToListAsync(ct); for (int i = 0; i < orderedTaskIds.Count; i++) { var task = tasks.FirstOrDefault(t => t.Id == orderedTaskIds[i]); if (task is not null) task.SortOrder = i; } await _context.SaveChangesAsync(ct); } // 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); } public async Task ResetToManualAsync(string taskId, CancellationToken ct = default) { await _context.Tasks .Where(t => t.Id == taskId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Manual) .SetProperty(t => t.StartedAt, (DateTime?)null) .SetProperty(t => t.FinishedAt, (DateTime?)null) .SetProperty(t => t.Result, (string?)null), ct); } #endregion #region Agent settings public async Task UpdateAgentSettingsAsync( string taskId, string? model, string? systemPrompt, string? agentPath, CancellationToken ct = default) { await _context.Tasks .Where(t => t.Id == taskId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Model, model) .SetProperty(t => t.SystemPrompt, systemPrompt) .SetProperty(t => t.AgentPath, agentPath), ct); } #endregion #region Tags public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default) { var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct); if (task is null) return; 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) { var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct); if (task is null) return; 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) { 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 #region Planning public async Task> GetChildrenAsync(string parentId, CancellationToken ct = default) { return await _context.Tasks .AsNoTracking() .Where(t => t.ParentTaskId == parentId) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); } public async Task CreateChildAsync( string parentId, string title, string? description, IReadOnlyList? tagNames, string? commitType, CancellationToken ct = default) { var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct); if (parent is null) throw new InvalidOperationException($"Parent task {parentId} not found."); var maxSort = await _context.Tasks .Where(t => t.ListId == parent.ListId) .Select(t => (int?)t.SortOrder) .MaxAsync(ct); var child = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = parent.ListId, Title = title, Description = description, Status = TaskStatus.Draft, CreatedAt = DateTime.UtcNow, CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType, ParentTaskId = parentId, SortOrder = (maxSort ?? -1) + 1, }; _context.Tasks.Add(child); if (tagNames is not null && tagNames.Count > 0) { foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase)) { var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct); if (tag is null) { tag = new TagEntity { Name = tagName }; _context.Tags.Add(tag); await _context.SaveChangesAsync(ct); } child.Tags.Add(tag); } } await _context.SaveChangesAsync(ct); return child; } public async Task SetPlanningStartedAsync( string taskId, string sessionToken, CancellationToken ct = default) { var affected = await _context.Tasks .Where(t => t.Id == taskId && t.Status == TaskStatus.Manual) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Planning) .SetProperty(t => t.PlanningSessionToken, sessionToken), ct); if (affected == 0) return null; return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); } public async Task UpdatePlanningSessionIdAsync( string parentId, string sessionId, CancellationToken ct = default) { await _context.Tasks .Where(t => t.Id == parentId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.PlanningSessionId, sessionId), ct); } public async Task FindByPlanningTokenAsync( string token, CancellationToken ct = default) { if (string.IsNullOrEmpty(token)) return null; return await _context.Tasks .AsNoTracking() .FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct); } public async Task FinalizePlanningAsync( string parentId, bool queueAgentTasks, CancellationToken ct = default) { using var tx = await _context.Database.BeginTransactionAsync(ct); var parent = await _context.Tasks .AsNoTracking() .Include(t => t.List).ThenInclude(l => l.Tags) .FirstOrDefaultAsync(t => t.Id == parentId, ct); if (parent is null || parent.Status != TaskStatus.Planning) throw new InvalidOperationException($"Task {parentId} is not in Planning state."); var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent"); var drafts = await _context.Tasks .Include(t => t.Tags) .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) .ToListAsync(ct); int count = 0; foreach (var draft in drafts) { var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent"); var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag); draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual; count++; } var finalizedAt = DateTime.UtcNow; await _context.Tasks .Where(t => t.Id == parentId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Planned) .SetProperty(t => t.PlanningFinalizedAt, finalizedAt) .SetProperty(t => t.PlanningSessionToken, (string?)null), ct); await _context.SaveChangesAsync(ct); await tx.CommitAsync(ct); return count; } public async Task DiscardPlanningAsync( string parentId, CancellationToken ct = default) { using var tx = await _context.Database.BeginTransactionAsync(ct); var parent = await _context.Tasks .AsNoTracking() .FirstOrDefaultAsync(t => t.Id == parentId, ct); if (parent is null || parent.Status != TaskStatus.Planning) { await tx.RollbackAsync(ct); return false; } await _context.Tasks .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft) .ExecuteDeleteAsync(ct); await _context.Tasks .Where(t => t.Id == parentId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Manual) .SetProperty(t => t.PlanningSessionId, (string?)null) .SetProperty(t => t.PlanningSessionToken, (string?)null) .SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct); await tx.CommitAsync(ct); return true; } public async Task TryCompleteParentAsync( string parentId, CancellationToken ct = default) { var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct); if (parent is null || parent.Status != TaskStatus.Planned) return; var children = await _context.Tasks .Where(t => t.ParentTaskId == parentId) .Select(t => t.Status) .ToListAsync(ct); if (children.Count == 0) return; bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed); if (!allTerminal) return; bool anyFailed = children.Any(s => s == TaskStatus.Failed); var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done; var finishedAt = DateTime.UtcNow; await _context.Tasks .Where(t => t.Id == parentId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, finalStatus) .SetProperty(t => t.FinishedAt, finishedAt), ct); } #endregion #region Queue selection public async Task GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) { // 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 WHERE t.status = 'queued' 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.sort_order ASC, t.created_at ASC LIMIT 1 ) RETURNING * """, nowStr).ToListAsync(ct); return result.FirstOrDefault(); } #endregion }