feat(board): master-task board, non-destructive stall watchdog, review flow
Board is now a clean master-task view:
- GetBoardAsync returns only top-level (master) tasks; child-tasks render
nested inside their parent card instead of as separate column cards, so a
big task split into many sub-tasks stays one card (orphans treated as master)
- New DoneChildTaskCount on the DTO for real progress bars
- Child/detail consumers (GetChildren endpoint, TaskBridgeService) query
children directly instead of scraping the flat board
Stall watchdog (replaces destructive auto-reset):
- StaleTaskRecoveryService.FlagStalledInProgressTasksAsync marks In-progress
tasks with no activity past the threshold as stalled (activity event +
Iris notification) WITHOUT resetting the column — no work is discarded.
Idempotent: a task is not re-flagged until real progress happens
- BackgroundService now runs this watchdog (TaskRecovery:StalledMinutes=40,
interval 10m); hard reset kept only on the explicit manual endpoint
Review flow (Bao/Iris only):
- POST tasks/{id}/approve (Review -> Done)
- POST tasks/{id}/request-changes (Review -> target, mandatory comment,
ExpectedFrom=iris, notifies Iris)
Frontend:
- BoardCard component: master card with ball chip (who has it), progress from
children, expand to show children grouped by agent with per-child state +
stalled marker, stalled chip on the master, review action buttons
- Request-changes modal; tasks store approveReview/requestChanges actions
Tests: watchdog flag/idempotency + review threshold; 135 backend tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -322,6 +322,52 @@ public class DashboardController(
|
||||
};
|
||||
}
|
||||
|
||||
// ── Review-Aktionen (Bao/Iris) ──
|
||||
|
||||
/// <summary>Review abnehmen: Review → Done. Nur Bao/Iris.</summary>
|
||||
[HttpPost("tasks/{id:guid}/approve")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> ApproveReview(Guid id, CancellationToken ct)
|
||||
{
|
||||
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||
if (currentTask is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||
return StatusCode(403, new { error = "Review-Abnahme ist nur Iris und Bao vorbehalten." });
|
||||
|
||||
var result = await taskService.ApproveReviewAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können abgenommen werden." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Änderung anfordern: Review → Zielspalte mit Pflichtkommentar. Nur Bao/Iris.</summary>
|
||||
[HttpPost("tasks/{id:guid}/request-changes")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> RequestChanges(
|
||||
Guid id, [FromBody] RequestChangesRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||
return BadRequest(new { error = "Ein Kommentar ist erforderlich, damit Iris weiß, was zu ändern ist." });
|
||||
|
||||
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||
if (currentTask is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||
return StatusCode(403, new { error = "Review-Entscheidungen sind nur Iris und Bao vorbehalten." });
|
||||
|
||||
var result = await taskService.RequestChangesAsync(id, request.Comment, request.TargetState, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können zurückgegeben werden." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
|
||||
/// Falls back to empty string (which authorization helpers reject accordingly).
|
||||
@@ -353,19 +399,7 @@ public class DashboardController(
|
||||
|
||||
[HttpGet("tasks/{id:guid}/children")]
|
||||
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
||||
{
|
||||
var board = await taskService.GetBoardAsync(ct);
|
||||
var children = board.Offen
|
||||
.Concat(board.InProgress)
|
||||
.Concat(board.Review)
|
||||
.Concat(board.Blocked)
|
||||
.Concat(board.Done)
|
||||
.Where(task => task.ParentTaskId == id)
|
||||
.OrderByDescending(task => task.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
return Ok(children);
|
||||
}
|
||||
=> Ok(await taskService.GetChildTaskDtosAsync(id, ct));
|
||||
|
||||
[HttpGet("tasks/{id:guid}")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
|
||||
|
||||
@@ -99,7 +99,8 @@ public sealed record DashboardTaskDto(
|
||||
List<DashboardTaskDto>? ChildTasks = null,
|
||||
int ChildTaskCount = 0,
|
||||
int OpenChildTaskCount = 0,
|
||||
bool HasVisibleDelegation = false
|
||||
bool HasVisibleDelegation = false,
|
||||
int DoneChildTaskCount = 0
|
||||
);
|
||||
|
||||
public sealed record CreateDashboardTaskRequest(
|
||||
@@ -183,6 +184,11 @@ public sealed record PostActivityRequest(
|
||||
string? Type = null
|
||||
);
|
||||
|
||||
public sealed record RequestChangesRequest(
|
||||
string Comment,
|
||||
string? TargetState = null
|
||||
);
|
||||
|
||||
// ── Agent Workflow DTOs ──
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,5 +2,9 @@ namespace Nexus.Api.Services;
|
||||
|
||||
public interface IStaleTaskRecoveryService
|
||||
{
|
||||
/// <summary>Nicht-destruktiv: markiert hängende In-progress-Tasks und benachrichtigt Iris.</summary>
|
||||
Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Destruktiv (nur manuell): setzt hängende In-progress-Tasks hart auf Backlog.</summary>
|
||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -33,9 +33,12 @@ public interface ITaskService
|
||||
// Task Board
|
||||
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default);
|
||||
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
|
||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
||||
Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default);
|
||||
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ public sealed class StaleTaskRecoveryBackgroundService(
|
||||
{
|
||||
try
|
||||
{
|
||||
var resetCount = await RunRecoveryOnceAsync(stoppingToken);
|
||||
if (resetCount > 0)
|
||||
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount);
|
||||
var flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
|
||||
if (flaggedCount > 0)
|
||||
logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -37,10 +37,10 @@ public sealed class StaleTaskRecoveryBackgroundService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> RunRecoveryOnceAsync(CancellationToken ct = default)
|
||||
public async Task<int> RunWatchdogOnceAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
|
||||
return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct);
|
||||
return await recoveryService.FlagStalledInProgressTasksAsync(optionsMonitor.CurrentValue.GetStalledThreshold(), ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,16 @@ public sealed class StaleTaskRecoveryOptions
|
||||
{
|
||||
public const string SectionName = "TaskRecovery";
|
||||
|
||||
/// <summary>Schwelle (Minuten) ohne Aktivität, ab der ein In-progress-Task als hängend gilt.</summary>
|
||||
public int StalledMinutes { get; set; } = 40;
|
||||
|
||||
/// <summary>Prüfintervall des Watchdogs.</summary>
|
||||
public int IntervalMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>Nur für den manuellen Hard-Reset-Endpoint: Alter (Stunden) ab dem hart zurückgesetzt wird.</summary>
|
||||
public int StaleHours { get; set; } = 2;
|
||||
public int IntervalMinutes { get; set; } = 30;
|
||||
|
||||
public TimeSpan GetStalledThreshold() => TimeSpan.FromMinutes(Math.Max(1, StalledMinutes));
|
||||
|
||||
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
|
||||
|
||||
|
||||
@@ -7,8 +7,81 @@ namespace Nexus.Api.Services;
|
||||
public sealed class StaleTaskRecoveryService(
|
||||
ITaskRepository taskRepository,
|
||||
IActivityRepository activityRepository,
|
||||
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
|
||||
ILiveUpdateService liveUpdateService,
|
||||
INotificationService notificationService) : IStaleTaskRecoveryService
|
||||
{
|
||||
private const string StalledActivityType = "stalled";
|
||||
|
||||
/// <summary>
|
||||
/// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit
|
||||
/// <paramref name="stalledThreshold"/> als hängend (Activity-Event + Notification an Iris),
|
||||
/// OHNE die Spalte zu ändern oder Arbeit zu verwerfen. Iris eskaliert dann (nachfragen,
|
||||
/// neu delegieren, ggf. auf Blocked setzen). Dedup: bereits gemeldete Hänger werden nicht
|
||||
/// erneut gemeldet, solange kein neuer Fortschritt (andere Activity) dazwischen liegt.
|
||||
/// </summary>
|
||||
public async Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var threshold = now - stalledThreshold;
|
||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||
|
||||
var inProgress = allTasks
|
||||
.Where(t => string.Equals(t.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (inProgress.Count == 0)
|
||||
return 0;
|
||||
|
||||
var activities = await activityRepository.GetRecentForTasksAsync(inProgress.Select(t => t.Id), ct);
|
||||
var activityByTask = activities
|
||||
.Where(a => a.TaskId.HasValue)
|
||||
.GroupBy(a => a.TaskId!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedAt).ToList());
|
||||
|
||||
var flaggedCount = 0;
|
||||
|
||||
foreach (var task in inProgress)
|
||||
{
|
||||
activityByTask.TryGetValue(task.Id, out var taskActivity);
|
||||
var latest = taskActivity?.FirstOrDefault();
|
||||
var lastProgressAt = latest?.CreatedAt ?? task.UpdatedAt;
|
||||
|
||||
if (lastProgressAt >= threshold)
|
||||
continue;
|
||||
|
||||
// Dedup: schon als hängend gemeldet und seither kein neuer Fortschritt.
|
||||
if (latest is not null && string.Equals(latest.Type, StalledActivityType, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var silentFor = now - lastProgressAt;
|
||||
await activityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = StalledActivityType,
|
||||
Message = $"Watchdog: keine Aktivität seit {FormatDuration(silentFor)} (Schwelle {FormatDuration(stalledThreshold)}). Task bleibt In progress, Iris zur Eskalation benachrichtigt.",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
"task_stalled",
|
||||
$"Task hängt: {task.Title}",
|
||||
$"Seit {FormatDuration(silentFor)} keine Aktivität. Bitte nachfassen, neu delegieren oder blockieren.",
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
flaggedCount++;
|
||||
}
|
||||
|
||||
if (flaggedCount > 0)
|
||||
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
|
||||
|
||||
return flaggedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destruktiver Fallback (nur manuell via Endpoint / expliziter Cron): setzt hängende
|
||||
/// „In progress"-Tasks hart auf Backlog zurück. Verwirft laufenden Kontext — daher NICHT
|
||||
/// mehr der Standard-Watchdog, sondern nur noch auf Anforderung.
|
||||
/// </summary>
|
||||
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||
@@ -80,88 +153,8 @@ public sealed class StaleTaskRecoveryService(
|
||||
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
|
||||
{
|
||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||
var taskIds = allTasks.Select(task => task.Id).ToList();
|
||||
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
|
||||
|
||||
var backlog = new List<DashboardTaskDto>();
|
||||
var inProgress = new List<DashboardTaskDto>();
|
||||
var review = new List<DashboardTaskDto>();
|
||||
var blocked = new List<DashboardTaskDto>();
|
||||
var done = new List<DashboardTaskDto>();
|
||||
|
||||
foreach (var task in allTasks)
|
||||
{
|
||||
var dto = MapToDtoWithChildren(task, allTasks, activity);
|
||||
switch (task.State.ToLowerInvariant())
|
||||
{
|
||||
case "backlog": backlog.Add(dto); break;
|
||||
case "in progress": inProgress.Add(dto); break;
|
||||
case "review": review.Add(dto); break;
|
||||
case "blocked": blocked.Add(dto); break;
|
||||
case "done": done.Add(dto); break;
|
||||
default: backlog.Add(dto); break;
|
||||
}
|
||||
}
|
||||
|
||||
backlog.Sort(SortByPriorityThenCreatedAt);
|
||||
inProgress.Sort(SortByPriorityThenCreatedAt);
|
||||
review.Sort(SortByPriorityThenCreatedAt);
|
||||
blocked.Sort(SortByPriorityThenCreatedAt);
|
||||
done.Sort(SortByPriorityThenCreatedAt);
|
||||
|
||||
return new BoardResponse(backlog, inProgress, review, blocked, done);
|
||||
}
|
||||
|
||||
private static DashboardTaskDto MapToDtoWithChildren(
|
||||
WorkTask task,
|
||||
IReadOnlyList<WorkTask> allTasks,
|
||||
IEnumerable<ActivityEvent> activity)
|
||||
{
|
||||
var childTasks = allTasks
|
||||
.Where(candidate => candidate.ParentTaskId == task.Id)
|
||||
.OrderByDescending(candidate => candidate.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity)).ToList();
|
||||
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
||||
var dto = MapToDtoWithActivity(task, activity);
|
||||
|
||||
return dto with
|
||||
{
|
||||
ChildTasks = childDtos,
|
||||
ChildTaskCount = childDtos.Count,
|
||||
OpenChildTaskCount = openChildTaskCount,
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
|
||||
};
|
||||
}
|
||||
|
||||
private static DashboardTaskDto MapToDtoWithActivity(WorkTask task, IEnumerable<ActivityEvent> activity)
|
||||
{
|
||||
var last = activity
|
||||
.Where(entry => entry.TaskId == task.Id)
|
||||
.OrderByDescending(entry => entry.CreatedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new DashboardTaskDto(
|
||||
task.Id,
|
||||
task.Title,
|
||||
task.Detail,
|
||||
task.Source,
|
||||
task.State,
|
||||
task.Priority,
|
||||
task.AssignedTo,
|
||||
task.ParentTaskId,
|
||||
task.DueDate,
|
||||
task.CreatedAt,
|
||||
task.UpdatedAt,
|
||||
task.IsAgentTask,
|
||||
task.ExpectedFrom,
|
||||
last?.Message,
|
||||
last?.CreatedAt,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
task.ParentTaskId.HasValue || task.IsAgentTask);
|
||||
var activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
|
||||
return TaskService.BuildMasterBoard(allTasks, activity);
|
||||
}
|
||||
|
||||
private static string BuildActivityMessage(
|
||||
@@ -190,20 +183,9 @@ public sealed class StaleTaskRecoveryService(
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan duration)
|
||||
=> duration.ToString(@"dd\.hh\:mm\:ss");
|
||||
|
||||
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
|
||||
{
|
||||
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
|
||||
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
|
||||
if (duration.TotalHours >= 1)
|
||||
return $"{(int)duration.TotalHours}h {duration.Minutes}min";
|
||||
return $"{Math.Max(0, (int)duration.TotalMinutes)}min";
|
||||
}
|
||||
|
||||
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
|
||||
{
|
||||
"high" => 3,
|
||||
"medium" => 2,
|
||||
"normal" => 2,
|
||||
"low" => 1,
|
||||
_ => 2
|
||||
};
|
||||
}
|
||||
|
||||
@@ -214,13 +214,7 @@ public sealed class TaskBridgeService(
|
||||
|
||||
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||
Guid parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
var board = await taskService.GetBoardAsync(ct);
|
||||
return FlattenBoard(board)
|
||||
.Where(task => task.ParentTaskId == parentTaskId)
|
||||
.OrderByDescending(task => task.UpdatedAt)
|
||||
.ToList();
|
||||
}
|
||||
=> await taskService.GetChildTaskDtosAsync(parentTaskId, ct);
|
||||
|
||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||
Guid taskId, CancellationToken ct = default)
|
||||
@@ -250,13 +244,6 @@ public sealed class TaskBridgeService(
|
||||
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
||||
}
|
||||
|
||||
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
|
||||
=> board.Offen
|
||||
.Concat(board.InProgress)
|
||||
.Concat(board.Review)
|
||||
.Concat(board.Blocked)
|
||||
.Concat(board.Done);
|
||||
|
||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||
|
||||
@@ -422,6 +422,19 @@ public sealed class TaskService(
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
return BuildMasterBoard(all, activity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut das Board aus NUR den Master-Tasks (Top-Level). Child-Tasks erscheinen
|
||||
/// nicht als eigene Karten, sondern verschachtelt in ihrem Parent — so bleibt das
|
||||
/// Board übersichtlich, auch wenn Iris eine große Aufgabe in viele Teilaufgaben
|
||||
/// zerlegt. Waisen (Parent existiert nicht mehr) werden als Master behandelt,
|
||||
/// damit nichts unsichtbar wird.
|
||||
/// </summary>
|
||||
internal static BoardResponse BuildMasterBoard(IReadOnlyList<WorkTask> all, IReadOnlyList<ActivityEvent> activity)
|
||||
{
|
||||
var ids = all.Select(t => t.Id).ToHashSet();
|
||||
|
||||
var offen = new List<DashboardTaskDto>();
|
||||
var inProgress = new List<DashboardTaskDto>();
|
||||
@@ -431,9 +444,10 @@ public sealed class TaskService(
|
||||
|
||||
foreach (var task in all)
|
||||
{
|
||||
// Ohne verschachtelte Child-DTOs: Children sind als eigene Karten im Board,
|
||||
// die Nested-Duplikate haben die Payload nur verdoppelt (Counts bleiben).
|
||||
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: false);
|
||||
var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
|
||||
if (!isMaster) continue;
|
||||
|
||||
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: true);
|
||||
switch (task.State.ToLowerInvariant())
|
||||
{
|
||||
case "backlog": offen.Add(dto); break;
|
||||
@@ -492,6 +506,78 @@ public sealed class TaskService(
|
||||
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt.
|
||||
/// </summary>
|
||||
public async Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
task.ExpectedFrom = null;
|
||||
return await UpdateTaskStatusInternalAsync(
|
||||
task,
|
||||
TaskStateHelper.ToStateString(TaskState.Done),
|
||||
caller,
|
||||
"review",
|
||||
$"Review abgenommen von {caller}: \"{task.Title}\" → Done",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar.
|
||||
/// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet.
|
||||
/// </summary>
|
||||
public async Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
var target = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(targetState, StringComparison.OrdinalIgnoreCase))
|
||||
?? TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
// Aus dem Review geht es zurück in die Arbeit — nie direkt nach Done oder Review.
|
||||
if (string.Equals(target, "Done", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(target, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
target = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
|
||||
var trimmed = comment.Trim();
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "review_changes_requested",
|
||||
Message = $"Änderung angefordert von {caller}: {trimmed}",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
task.ExpectedFrom = "iris";
|
||||
var result = await UpdateTaskStatusInternalAsync(
|
||||
task, target, caller, "review",
|
||||
$"Review zurückgegeben von {caller} → {target}", ct);
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
"task_changes_requested",
|
||||
$"Änderung angefordert: {task.Title}",
|
||||
trimmed,
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedHours = Math.Max(1, staleHours);
|
||||
@@ -509,20 +595,33 @@ public sealed class TaskService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Child-Tasks eines Parents als DTOs — direkt aus dem Repo, nicht aus dem Board
|
||||
/// (das zeigt Children ja nur noch verschachtelt an). Für Detailansicht + Bridge.
|
||||
/// </summary>
|
||||
public async Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default)
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
return all.Where(t => t.ParentTaskId == parentId)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.Select(child => MapToDtoWithChildren(child, all, activity, includeChildren: false))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var all = await activityRepo.GetRecentAsync(100, ct);
|
||||
return all.Where(e => e.TaskId == taskId).ToList();
|
||||
}
|
||||
|
||||
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
||||
private static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
||||
{
|
||||
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
// includeChildren=false (Board/SSE-Snapshot): Children erscheinen dort ohnehin
|
||||
// als eigene Karten — verschachtelte Child-DTOs verdoppeln nur die Payload.
|
||||
// includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
|
||||
var childDtos = includeChildren
|
||||
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
|
||||
: null;
|
||||
@@ -534,6 +633,7 @@ public sealed class TaskService(
|
||||
ChildTasks = childDtos,
|
||||
ChildTaskCount = childTasks.Count,
|
||||
OpenChildTaskCount = openChildTaskCount,
|
||||
DoneChildTaskCount = childTasks.Count - openChildTaskCount,
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@
|
||||
"RefreshTokenExpirationDays": 7
|
||||
},
|
||||
"TaskRecovery": {
|
||||
"StaleHours": 2,
|
||||
"IntervalMinutes": 30
|
||||
"StalledMinutes": 40,
|
||||
"IntervalMinutes": 10,
|
||||
"StaleHours": 2
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user