448 lines
16 KiB
C#
448 lines
16 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)
|
|
{
|
|
_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);
|
|
|
|
#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);
|
|
}
|
|
|
|
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<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)
|
|
{
|
|
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<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,
|
|
IReadOnlyList<string>? 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<TaskEntity?> 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<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<int> 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<bool> 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<TaskEntity?> 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
|
|
}
|