diff --git a/backend-tests/StaleTaskRecoveryTests.cs b/backend-tests/StaleTaskRecoveryTests.cs index e1ee878..9934ec8 100644 --- a/backend-tests/StaleTaskRecoveryTests.cs +++ b/backend-tests/StaleTaskRecoveryTests.cs @@ -131,7 +131,8 @@ public sealed class StaleTaskRecoveryTests var recoveryService = new StaleTaskRecoveryService( taskRepository, activityRepository, - liveUpdateService); + liveUpdateService, + new FakeNotificationService()); var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None); @@ -143,7 +144,51 @@ public sealed class StaleTaskRecoveryTests } [Fact] - public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService() + public async Task FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting() + { + await using var fixture = await TaskWorkflowFixture.CreateAsync(); + var stalledTimestamp = DateTimeOffset.UtcNow.AddHours(-3); + + var stalled = await fixture.TaskRepository.AddAsync(new WorkTask + { + Title = "Stalled agent task", + State = "In progress", + Source = "iris", + UpdatedAt = stalledTimestamp, + CreatedAt = stalledTimestamp + }, CancellationToken.None); + + var fresh = await fixture.TaskRepository.AddAsync(new WorkTask + { + Title = "Fresh in progress", + State = "In progress", + Source = "iris", + UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + CreatedAt = stalledTimestamp + }, CancellationToken.None); + + var flagged = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync( + TimeSpan.FromMinutes(40), CancellationToken.None); + + Assert.Equal(1, flagged); + // Nicht-destruktiv: bleibt In progress, kein Reset auf Backlog. + Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(stalled.Id, CancellationToken.None))!.State); + Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(fresh.Id, CancellationToken.None))!.State); + + var activity = await fixture.TaskService.GetTaskActivityAsync(stalled.Id, CancellationToken.None); + Assert.Contains(activity, entry => string.Equals(entry.Type, "stalled", StringComparison.OrdinalIgnoreCase)); + + var irisNotifications = await fixture.NotificationService.GetForUserAsync("iris", 50, false, CancellationToken.None); + Assert.Contains(irisNotifications, n => n.Type == "task_stalled" && n.TaskId == stalled.Id); + + // Idempotent: erneuter Lauf meldet denselben Hänger nicht nochmal. + var flaggedAgain = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync( + TimeSpan.FromMinutes(40), CancellationToken.None); + Assert.Equal(0, flaggedAgain); + } + + [Fact] + public async Task BackgroundService_RunWatchdogOnceAsync_UsesStalledThreshold_AndFlags() { var fakeRecoveryService = new FakeStaleTaskRecoveryService(); var services = new ServiceCollection(); @@ -154,20 +199,21 @@ public sealed class StaleTaskRecoveryTests provider.GetRequiredService(), new TestOptionsMonitor(new StaleTaskRecoveryOptions { - StaleHours = 4, - IntervalMinutes = 30 + StalledMinutes = 45, + IntervalMinutes = 10 }), NullLogger.Instance); - var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None); + var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None); - Assert.Equal(1, fakeRecoveryService.CallCount); - Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold); - Assert.Equal(7, resetCount); + Assert.Equal(1, fakeRecoveryService.FlagCallCount); + Assert.Equal(0, fakeRecoveryService.ResetCallCount); + Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold); + Assert.Equal(7, flaggedCount); } [Fact] - public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval() + public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval() { var fakeRecoveryService = new FakeStaleTaskRecoveryService(); var firstCall = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -181,8 +227,8 @@ public sealed class StaleTaskRecoveryTests provider.GetRequiredService(), new TestOptionsMonitor(new StaleTaskRecoveryOptions { - StaleHours = 2, - IntervalMinutes = 30 + StalledMinutes = 40, + IntervalMinutes = 10 }), NullLogger.Instance); @@ -191,8 +237,8 @@ public sealed class StaleTaskRecoveryTests await firstCall.Task.WaitAsync(cts.Token); await backgroundService.StopAsync(CancellationToken.None); - Assert.True(fakeRecoveryService.CallCount >= 1); - Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold); + Assert.True(fakeRecoveryService.FlagCallCount >= 1); + Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold); } [Fact] @@ -325,19 +371,52 @@ file sealed class FakeLiveUpdateService : ILiveUpdateService file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService { - public int CallCount { get; private set; } + public int FlagCallCount { get; private set; } + public int ResetCallCount { get; private set; } public TimeSpan LastThreshold { get; private set; } public Action? OnCall { get; set; } + public Task FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default) + { + FlagCallCount++; + LastThreshold = stalledThreshold; + OnCall?.Invoke(); + return Task.FromResult(7); + } + public Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) { - CallCount++; + ResetCallCount++; LastThreshold = staleThreshold; OnCall?.Invoke(); return Task.FromResult(7); } } +file sealed class FakeNotificationService : INotificationService +{ + public List Created { get; } = []; + + public Task CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default) + { + var notification = new Notification { Type = type, Title = title, Message = message, ForUser = forUser, TaskId = taskId }; + Created.Add(notification); + return Task.FromResult(notification); + } + + public Task> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default) + => Task.FromResult>(Created.Where(n => n.ForUser == forUser).ToList()); + + public Task MarkAsReadAsync(Guid id, CancellationToken ct = default) => Task.FromResult(true); + + public Task MarkAllAsReadAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0); + + public Task GetUnreadCountAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0); + + public Task GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default) + => Task.FromResult(new NotificationSnapshotDto([], 0, forUser)); +} + file sealed class TestOptionsMonitor(T currentValue) : IOptionsMonitor { public T CurrentValue { get; private set; } = currentValue; diff --git a/backend-tests/TaskWorkflowTests.cs b/backend-tests/TaskWorkflowTests.cs index aa22274..d8da84c 100644 --- a/backend-tests/TaskWorkflowTests.cs +++ b/backend-tests/TaskWorkflowTests.cs @@ -440,7 +440,8 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable var staleTaskRecoveryService = new StaleTaskRecoveryService( taskRepository, activityRepository, - liveUpdateService); + liveUpdateService, + notificationService); var taskService = new TaskService( taskRepository, diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index 738f5dd..86b6084 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -322,6 +322,52 @@ public class DashboardController( }; } + // ── Review-Aktionen (Bao/Iris) ── + + /// Review abnehmen: Review → Done. Nur Bao/Iris. + [HttpPost("tasks/{id:guid}/approve")] + public async Task> 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!)) + }; + } + + /// Änderung anfordern: Review → Zielspalte mit Pflichtkommentar. Nur Bao/Iris. + [HttpPost("tasks/{id:guid}/request-changes")] + public async Task> 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!)) + }; + } + /// /// 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>> 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> GetTask(Guid id, CancellationToken ct) diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index b2fdb44..4069f56 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -99,7 +99,8 @@ public sealed record DashboardTaskDto( List? 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 ── /// diff --git a/backend/Services/IStaleTaskRecoveryService.cs b/backend/Services/IStaleTaskRecoveryService.cs index 14344e0..1b95623 100644 --- a/backend/Services/IStaleTaskRecoveryService.cs +++ b/backend/Services/IStaleTaskRecoveryService.cs @@ -2,5 +2,9 @@ namespace Nexus.Api.Services; public interface IStaleTaskRecoveryService { + /// Nicht-destruktiv: markiert hängende In-progress-Tasks und benachrichtigt Iris. + Task FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default); + + /// Destruktiv (nur manuell): setzt hängende In-progress-Tasks hart auf Backlog. Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); } diff --git a/backend/Services/ITaskService.cs b/backend/Services/ITaskService.cs index 04ad940..1b9fadf 100644 --- a/backend/Services/ITaskService.cs +++ b/backend/Services/ITaskService.cs @@ -33,9 +33,12 @@ public interface ITaskService // Task Board Task GetBoardAsync(CancellationToken ct = default); Task MoveTaskAsync(Guid id, string newState, CancellationToken ct = default); + Task ApproveReviewAsync(Guid id, CancellationToken ct = default); + Task RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default); Task ResetStaleAsync(int staleHours, CancellationToken ct = default); Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); Task> GetChildTasksAsync(Guid parentId, CancellationToken ct = default); + Task> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default); Task> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default); Task GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default); diff --git a/backend/Services/StaleTaskRecoveryBackgroundService.cs b/backend/Services/StaleTaskRecoveryBackgroundService.cs index 7d729ba..deabbb3 100644 --- a/backend/Services/StaleTaskRecoveryBackgroundService.cs +++ b/backend/Services/StaleTaskRecoveryBackgroundService.cs @@ -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 RunRecoveryOnceAsync(CancellationToken ct = default) + public async Task RunWatchdogOnceAsync(CancellationToken ct = default) { await using var scope = scopeFactory.CreateAsyncScope(); var recoveryService = scope.ServiceProvider.GetRequiredService(); - return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct); + return await recoveryService.FlagStalledInProgressTasksAsync(optionsMonitor.CurrentValue.GetStalledThreshold(), ct); } } diff --git a/backend/Services/StaleTaskRecoveryOptions.cs b/backend/Services/StaleTaskRecoveryOptions.cs index bc3f517..f3cf254 100644 --- a/backend/Services/StaleTaskRecoveryOptions.cs +++ b/backend/Services/StaleTaskRecoveryOptions.cs @@ -4,8 +4,16 @@ public sealed class StaleTaskRecoveryOptions { public const string SectionName = "TaskRecovery"; + /// Schwelle (Minuten) ohne Aktivität, ab der ein In-progress-Task als hängend gilt. + public int StalledMinutes { get; set; } = 40; + + /// Prüfintervall des Watchdogs. + public int IntervalMinutes { get; set; } = 10; + + /// Nur für den manuellen Hard-Reset-Endpoint: Alter (Stunden) ab dem hart zurückgesetzt wird. 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)); diff --git a/backend/Services/StaleTaskRecoveryService.cs b/backend/Services/StaleTaskRecoveryService.cs index 59e486f..013ad9d 100644 --- a/backend/Services/StaleTaskRecoveryService.cs +++ b/backend/Services/StaleTaskRecoveryService.cs @@ -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"; + + /// + /// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit + /// 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. + /// + public async Task 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; + } + + /// + /// 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. + /// public async Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) { var threshold = DateTimeOffset.UtcNow - staleThreshold; @@ -80,88 +153,8 @@ public sealed class StaleTaskRecoveryService( private async Task 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(); - var inProgress = new List(); - var review = new List(); - var blocked = new List(); - var done = new List(); - - 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 allTasks, - IEnumerable 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 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 - }; } diff --git a/backend/Services/TaskBridgeService.cs b/backend/Services/TaskBridgeService.cs index 4ad896c..ffe7d33 100644 --- a/backend/Services/TaskBridgeService.cs +++ b/backend/Services/TaskBridgeService.cs @@ -214,13 +214,7 @@ public sealed class TaskBridgeService( public async Task> 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> GetTaskActivityAsync( Guid taskId, CancellationToken ct = default) @@ -250,13 +244,6 @@ public sealed class TaskBridgeService( return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors); } - private static IEnumerable 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, diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index 3b2f691..ec39b75 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -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); + } + + /// + /// 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. + /// + internal static BoardResponse BuildMasterBoard(IReadOnlyList all, IReadOnlyList activity) + { + var ids = all.Select(t => t.Id).ToHashSet(); var offen = new List(); var inProgress = new List(); @@ -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); } + /// + /// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt. + /// + public async Task 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); + } + + /// + /// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar. + /// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet. + /// + public async Task 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 ResetStaleAsync(int staleHours, CancellationToken ct = default) { var normalizedHours = Math.Max(1, staleHours); @@ -509,20 +595,33 @@ public sealed class TaskService( .ToList(); } + /// + /// 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. + /// + public async Task> 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> 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 allTasks, IEnumerable activity, bool includeChildren = true) + private static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList allTasks, IEnumerable 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 }; } diff --git a/backend/appsettings.json b/backend/appsettings.json index 2b55c25..c9fe45a 100644 --- a/backend/appsettings.json +++ b/backend/appsettings.json @@ -23,8 +23,9 @@ "RefreshTokenExpirationDays": 7 }, "TaskRecovery": { - "StaleHours": 2, - "IntervalMinutes": 30 + "StalledMinutes": 40, + "IntervalMinutes": 10, + "StaleHours": 2 }, "AllowedHosts": "*" } diff --git a/frontend/src/components/board/BoardCard.vue b/frontend/src/components/board/BoardCard.vue new file mode 100644 index 0000000..ab5ccf8 --- /dev/null +++ b/frontend/src/components/board/BoardCard.vue @@ -0,0 +1,290 @@ + + + + + diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 6e715eb..b2cd388 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -30,6 +30,7 @@ export interface DashboardTaskDto { lastActivityMessage?: string | null lastActivityAt?: string | null childTasks?: DashboardTaskDto[] | null + doneChildTaskCount?: number childTaskCount?: number openChildTaskCount?: number hasVisibleDelegation?: boolean @@ -215,6 +216,29 @@ export const useTaskStore = defineStore('tasks', { } }, + /* ── API: Review abnehmen (Review → Done) ─────── */ + async approveReview(id: string) { + const res = await apiFetch(`/api/dashboard/tasks/${id}/approve`, { method: 'POST' }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `HTTP ${res.status}`) + } + await this.fetchBoard() + }, + + /* ── API: Änderung anfordern (Review → Zielspalte) ── */ + async requestChanges(id: string, comment: string, targetState = 'In progress') { + const res = await apiFetch(`/api/dashboard/tasks/${id}/request-changes`, { + method: 'POST', + body: JSON.stringify({ comment, targetState }), + }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body.error || `HTTP ${res.status}`) + } + await this.fetchBoard() + }, + /* ── API: Create task ─────────────────────────── */ async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) { try { diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index 8d796d1..122f320 100644 --- a/frontend/src/views/TaskBoardView.vue +++ b/frontend/src/views/TaskBoardView.vue @@ -13,12 +13,17 @@ * - Waiting section for Iris overview */ import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue' -import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText } from '@lucide/vue' +import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, AlertTriangle, Eye, Bot, ShieldBan, MessageSquareText, RotateCcw } from '@lucide/vue' import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' -import { useTaskStore } from '../stores/tasks' +import { useTaskStore, type DashboardTaskDto } from '../stores/tasks' import { useLiveSyncStore } from '../stores/liveSync' import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool' +import BoardCard from '../components/board/BoardCard.vue' + +/** Schwelle (min) ohne Aktivität, ab der ein In-Bearbeitung-Task als „hängt" gilt. + * Spiegelt die Backend-Watchdog-Schwelle (TaskRecovery:StalledMinutes). */ +const STALL_THRESHOLD_MIN = 40 type BoardTask = ReturnType[number] @@ -95,6 +100,46 @@ async function handleCreateTask() { } } +/* ── Review-Aktionen (Bao/Iris) ───────────────────── */ +const reviewError = ref('') +const showChangesModal = ref(false) +const changesTask = ref(null) +const changesComment = ref('') +const changesTarget = ref('In progress') +const changesSubmitting = ref(false) + +async function handleApprove(id: string) { + reviewError.value = '' + try { + await taskStore.approveReview(id) + } catch (err) { + reviewError.value = err instanceof Error ? err.message : 'Abnahme fehlgeschlagen' + } +} + +function openRequestChanges(task: DashboardTaskDto) { + changesTask.value = task + changesComment.value = '' + changesTarget.value = 'In progress' + reviewError.value = '' + showChangesModal.value = true +} + +async function submitRequestChanges() { + if (!changesTask.value || !changesComment.value.trim()) return + changesSubmitting.value = true + reviewError.value = '' + try { + await taskStore.requestChanges(changesTask.value.id, changesComment.value.trim(), changesTarget.value) + showChangesModal.value = false + changesTask.value = null + } catch (err) { + reviewError.value = err instanceof Error ? err.message : 'Konnte nicht zurückgegeben werden' + } finally { + changesSubmitting.value = false + } +} + /* ── Drag & Drop ────────────────────────────────── */ const draggedTaskId = ref(null) @@ -141,20 +186,6 @@ async function onDrop(e: DragEvent, targetState: string) { } /* ── Helpers ──────────────────────────────────────── */ -function priorityLabel(p: string): string { - const lower = p.toLowerCase() - if (lower === 'high') return 'High' - if (lower === 'low') return 'Low' - return 'Med' -} - -function priorityColor(p: string): string { - const lower = p.toLowerCase() - if (lower === 'high') return '#f87171' - if (lower === 'low') return '#60a5fa' - return '#facc15' -} - function statusTone(state: string): string { switch (state.toLowerCase()) { case 'done': return 'is-done' @@ -202,6 +233,15 @@ const canSaveDetail = computed(() => detailForm.title.trim().length > 0 && !deta */ const canChangeState = computed(() => authStore.isIris || authStore.isBao) +/* Spalten-Konfiguration — Board zeigt nur Master-Tasks (Children nested in der Karte). */ +const columns = computed(() => [ + { key: 'offen', name: 'Offen', tasks: taskStore.board.offen, dot: 'var(--st-queue)', ring: 'rgba(251,191,36,.25)' }, + { key: 'inProgress', name: 'In Bearbeitung', tasks: taskStore.board.inProgress, dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' }, + { key: 'review', name: 'Review', tasks: taskStore.board.review, dot: '#fb923c', ring: 'rgba(251,146,60,.25)' }, + { key: 'done', name: 'Erledigt', tasks: taskStore.board.done, dot: 'var(--st-work)', ring: 'rgba(61,220,151,.25)' }, + { key: 'blocked', name: 'Blockiert', tasks: taskStore.board.blocked, dot: 'var(--st-block)', ring: 'rgba(251,113,133,.25)' }, +]) + function hydrateDetailForm(task: BoardTask | null) { detailError.value = '' detailSuccess.value = '' @@ -283,18 +323,6 @@ function delegationBadge(task: BoardTask): string | null { return null } -function assigneeLabel(assignedTo: string | null | undefined): string { - return expectedFromLabel(assignedTo) -} - -function assigneeClass(assignedTo: string | null | undefined): string { - if (!assignedTo) return '' - const lower = assignedTo.toLowerCase() - if (lower === 'iris') return 'assignee-iris' - if (lower === 'bao') return 'assignee-bao' - return 'assignee-agent' -} - /* ── Task Navigation ───────────────────────────── */ function navigateToTask(taskId: string) { router.push('/tasks/' + taskId) @@ -306,15 +334,6 @@ async function openQuickPeek(taskId: string) { await loadDetailContext(taskId) } -function handleCardClick(event: MouseEvent, taskId: string) { - if (event.ctrlKey || event.metaKey || event.shiftKey) { - event.preventDefault() - openQuickPeek(taskId) - } else { - navigateToTask(taskId) - } -} - function closeDetailPanel() { showDetailPanel.value = false selectedTaskId.value = null @@ -527,254 +546,34 @@ onUnmounted(() => {
- - Offen - {{ taskStore.board.offen.length }} + + {{ col.name }} + {{ col.tasks.length }}
- -
Keine Aufgaben
-
-
- -
-
- - In Bearbeitung - {{ taskStore.board.inProgress.length }} -
-
- -
Keine Aufgaben
-
-
- -
-
- - Review - {{ taskStore.board.review.length }} -
-
- -
Keine Aufgaben
-
-
- -
-
- - Erledigt - {{ taskStore.board.done.length }} -
-
- -
Keine Aufgaben
-
-
- -
-
- - Blockiert - {{ taskStore.board.blocked.length }} -
-
- -
Keine Blockierer
+ /> +
Keine Aufgaben
@@ -841,6 +640,45 @@ onUnmounted(() => { + + + +