Files
ClaudeDo/src/ClaudeDo.Data/Repositories/TaskRepository.cs
mika kuns 623ebf147b refactor(tags): remove tag entity and all references
Drops TagEntity, TagRepository, and tag wiring across data layer, worker,
and UI. Adds RemoveTags migration to clean up schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:07:24 +02:00

495 lines
19 KiB
C#

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<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
}
public async Task<List<TaskEntity>> 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);
}
/// <summary>
/// Renumbers tasks in a list to 0..N-1 according to <paramref name="orderedTaskIds"/>.
/// Ids not belonging to the list are ignored; ids missing from the list are untouched.
/// </summary>
public async Task ReorderAsync(string listId, IReadOnlyList<string> 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<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
=> GetByListIdAsync(listId, ct);
public async Task<List<TaskEntity>> 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<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);
}
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<List<TaskEntity>> 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<TaskEntity> 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<TaskEntity?> 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<TaskEntity?> 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<DiscardPlanningOutcome> 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);
}
/// <summary>
/// Dequeues child tasks whose parent is missing or no longer in a planning phase:
/// sets <c>Status</c> from <c>Queued</c> to <c>Idle</c> and clears
/// <c>BlockedByTaskId</c>. <c>ParentTaskId</c> stays intact — the child remains
/// part of its (former) planning chain for historical context. Returns the
/// number of rows dequeued. Idempotent.
/// </summary>
internal async Task<int> 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);
}
/// <summary>
/// Restores a planning-session lineage that lost its <c>parent_task_id</c> links.
/// Given a candidate parent task and a single unambiguous orphan chain in the
/// same list (linked via <c>BlockedByTaskId</c>), re-attaches the chain members
/// to the parent, marks the parent as <c>Finalized</c>, and dequeues queued
/// chain members. No-op if conditions are not met. Returns the number of
/// re-attached children (0 if skipped).
/// </summary>
internal async Task<int> 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<TaskEntity> { 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
}