Files
ClaudeDo/src/ClaudeDo.Data/Repositories/TaskRepository.cs
Mika Kuns 8eafa71ed3 fix: restore green test suite across all projects
* 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>
2026-04-28 08:30:26 +02:00

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
}