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) { var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id); if (tracked is not null && !ReferenceEquals(tracked, entity)) _context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached; _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); public async Task> GetByCreatorAsync(string createdBy, CancellationToken ct = default) { return await _context.Tasks .AsNoTracking() .Where(t => t.CreatedBy == createdBy) .OrderByDescending(t => t.CreatedAt) .ToListAsync(ct); } #endregion #region Status transitions internal 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); } internal 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); } internal 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); } internal 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.Idle) .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 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, string? commitType, CancellationToken ct = default) { // AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which // bypasses the change tracker; a tracked Find would return stale data. var parent = await _context.Tasks.AsNoTracking() .FirstOrDefaultAsync(t => t.Id == parentId, ct); if (parent is null) throw new InvalidOperationException($"Parent task {parentId} not found."); if (parent.PlanningPhase == PlanningPhase.None) throw new InvalidOperationException( $"Parent task {parentId} is not in a planning phase; cannot attach children."); 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.Idle, CreatedAt = DateTime.UtcNow, CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType, ParentTaskId = parentId, SortOrder = (maxSort ?? -1) + 1, }; _context.Tasks.Add(child); await _context.SaveChangesAsync(ct); return child; } public async Task UpdateChildAsync( string taskId, string? title, string? description, string? commitType, TaskStatus? status, CancellationToken ct = default) { var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, ct) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (title is not null) task.Title = title; if (description is not null) task.Description = description; if (commitType is not null) task.CommitType = commitType; if (status.HasValue) task.Status = status.Value; await _context.SaveChangesAsync(ct); } public async Task UpdatePlanningTaskAsync( string taskId, string? title, string? description, CancellationToken ct = default) { var entity = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct) ?? throw new InvalidOperationException("Planning task not found."); if (title is not null) entity.Title = title; if (description is not null) entity.Description = description; await _context.Tasks .Where(t => t.Id == taskId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Title, entity.Title) .SetProperty(t => t.Description, entity.Description), ct); } public async Task SetPlanningStartedAsync( string taskId, string sessionToken, CancellationToken ct = default) { var affected = await _context.Tasks .Where(t => t.Id == taskId && t.Status == TaskStatus.Idle && t.PlanningPhase == PlanningPhase.None) .ExecuteUpdateAsync(s => s .SetProperty(t => t.PlanningPhase, PlanningPhase.Active) .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 SetPlanningSessionTokenAsync( string taskId, string sessionToken, CancellationToken ct = default) { await _context.Tasks .Where(t => t.Id == taskId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.PlanningSessionToken, sessionToken), 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 DiscardPlanningAsync( string parentId, bool dequeueQueuedChildren, 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.PlanningPhase != PlanningPhase.Active) { await tx.RollbackAsync(ct); return new DiscardPlanningOutcome(DiscardPlanningResult.NotInPlanning, 0, 0); } var children = await _context.Tasks .Where(t => t.ParentTaskId == parentId) .Select(t => new { t.Id, t.Status }) .ToListAsync(ct); var runningCount = children.Count(c => c.Status == TaskStatus.Running); if (runningCount > 0) { await tx.RollbackAsync(ct); return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByRunningChildren, 0, runningCount); } var queuedIds = children.Where(c => c.Status == TaskStatus.Queued).Select(c => c.Id).ToList(); if (queuedIds.Count > 0) { if (!dequeueQueuedChildren) { await tx.RollbackAsync(ct); return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByQueuedChildren, queuedIds.Count, 0); } await _context.Tasks .Where(t => queuedIds.Contains(t.Id)) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Idle) .SetProperty(t => t.BlockedByTaskId, (string?)null), ct); } // Terminal children (Done/Failed/Cancelled) stay attached to the parent even // though its PlanningPhase will be reset to None. The lineage is preserved as // historical context; the UI nests them under their parent regardless of phase. // Idle children created during this planning session are dropped. await _context.Tasks .Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Idle && t.PlanningPhase == PlanningPhase.None) .ExecuteDeleteAsync(ct); await _context.Tasks .Where(t => t.Id == parentId) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Idle) .SetProperty(t => t.PlanningPhase, PlanningPhase.None) .SetProperty(t => t.PlanningSessionId, (string?)null) .SetProperty(t => t.PlanningSessionToken, (string?)null) .SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct); await tx.CommitAsync(ct); return new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, queuedIds.Count, 0); } /// /// Dequeues child tasks whose parent is missing or no longer in a planning phase: /// sets Status from Queued to Idle and clears /// BlockedByTaskId. ParentTaskId stays intact — the child remains /// part of its (former) planning chain for historical context. Returns the /// number of rows dequeued. Idempotent. /// internal async Task DequeueOrphanedChildrenAsync(CancellationToken ct = default) { var orphanIds = await _context.Tasks .Where(t => t.ParentTaskId != null && t.Status == TaskStatus.Queued) .Where(t => !_context.Tasks.Any(p => p.Id == t.ParentTaskId && p.PlanningPhase != PlanningPhase.None)) .Select(t => t.Id) .ToListAsync(ct); if (orphanIds.Count == 0) return 0; return await _context.Tasks .Where(t => orphanIds.Contains(t.Id)) .ExecuteUpdateAsync(s => s .SetProperty(t => t.Status, TaskStatus.Idle) .SetProperty(t => t.BlockedByTaskId, (string?)null), ct); } /// /// Restores a planning-session lineage that lost its parent_task_id links. /// Given a candidate parent task and a single unambiguous orphan chain in the /// same list (linked via BlockedByTaskId), re-attaches the chain members /// to the parent, marks the parent as Finalized, and dequeues queued /// chain members. No-op if conditions are not met. Returns the number of /// re-attached children (0 if skipped). /// internal async Task RestorePlanningLineageAsync(string parentId, CancellationToken ct = default) { var parent = await _context.Tasks.AsNoTracking() .FirstOrDefaultAsync(t => t.Id == parentId, ct); if (parent is null) return 0; if (parent.PlanningPhase != PlanningPhase.None) return 0; if (parent.Status is TaskStatus.Done or TaskStatus.Failed or TaskStatus.Cancelled) return 0; // Candidates: unattached tasks in the same list, excluding the parent itself. var candidates = await _context.Tasks.AsNoTracking() .Where(t => t.ListId == parent.ListId && t.ParentTaskId == null && t.Id != parent.Id) .ToListAsync(ct); // A chain is a maximal linear sequence linked via BlockedByTaskId. Find heads // (BlockedByTaskId == null) that have at least one successor. var bySource = candidates .Where(c => c.BlockedByTaskId != null) .ToLookup(c => c.BlockedByTaskId!); var heads = candidates .Where(c => c.BlockedByTaskId == null && bySource[c.Id].Any()) .ToList(); // Bail unless exactly one chain anchors a successor — anything else is // ambiguous and we refuse to guess. if (heads.Count != 1) return 0; var chain = new List { heads[0] }; var current = heads[0]; while (true) { var next = bySource[current.Id].FirstOrDefault(); if (next is null) break; chain.Add(next); current = next; } var chainIds = chain.Select(c => c.Id).ToList(); await _context.Tasks .Where(t => t.Id == parentId) .ExecuteUpdateAsync(s => s.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized), ct); await _context.Tasks .Where(t => chainIds.Contains(t.Id)) .ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, (string?)parentId), ct); // Dequeue queued chain members; blocked_by stays intact so chain order is // preserved for manual re-queueing. await _context.Tasks .Where(t => chainIds.Contains(t.Id) && t.Status == TaskStatus.Queued) .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Idle), ct); return chainIds.Count; } 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.PlanningPhase != PlanningPhase.Finalized) 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 }