* TaskRepository.UpdateAsync defensively detaches any locally tracked entity with the same Id before attaching the patched copy, preventing EF identity conflicts when callers load via AsNoTracking and write back through the same DbContext (surfaced by ExternalMcpService UpdateTask integration tests). * TasksIslandViewModel auto-collapse now only fires for Finalized planning parents that are not yet Done. Active-phase parents stay expanded while the user is editing the plan, and Done parents stay expanded so all completed children land in CompletedItems alongside the parent. * Update three Ui.Tests fakes (ConflictResolution, PlanningDiff, DetailsIslandPlanning) to implement the two new IWorkerClient members (OpenInteractiveTerminalAsync, QueuePlanningSubtasksAsync). * Rewrite StreamLineFormatterTests to exercise the current assistant/user/result/system message format instead of the legacy stream_event parsing that was removed in the formatter rewrite. * Align AppSettingsRepository seed-default assertion with the permission-mode default that flipped from bypassPermissions to auto. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
468 lines
17 KiB
C#
468 lines
17 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 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 SetTagsAsync(string taskId, IReadOnlyList<string> tagNames, CancellationToken ct = default)
|
|
{
|
|
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
|
if (task is null) return;
|
|
|
|
task.Tags.Clear();
|
|
|
|
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
|
if (tag is null)
|
|
{
|
|
tag = new TagEntity { Name = name };
|
|
_context.Tags.Add(tag);
|
|
}
|
|
task.Tags.Add(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.Idle,
|
|
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 UpdateChildAsync(
|
|
string taskId,
|
|
string? title,
|
|
string? description,
|
|
string? commitType,
|
|
IReadOnlyList<string>? tagNames,
|
|
TaskStatus? status,
|
|
CancellationToken ct = default)
|
|
{
|
|
var task = await _context.Tasks.Include(t => t.Tags).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;
|
|
|
|
if (tagNames is not null)
|
|
{
|
|
task.Tags.Clear();
|
|
foreach (var name in tagNames.Where(n => !string.IsNullOrWhiteSpace(n)).Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == name, ct);
|
|
if (tag is null)
|
|
{
|
|
tag = new TagEntity { Name = name };
|
|
_context.Tags.Add(tag);
|
|
}
|
|
task.Tags.Add(tag);
|
|
}
|
|
}
|
|
|
|
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<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.PlanningPhase != PlanningPhase.Active)
|
|
{
|
|
await tx.RollbackAsync(ct);
|
|
return false;
|
|
}
|
|
|
|
// Children created during the planning session are Status=Idle, PlanningPhase=None.
|
|
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 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.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
|
|
}
|