Files
nexus/backend/Services/TaskService.cs
T
devops f564ecfbc7
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
feat: unified rail shell, robust live sync, task board performance
Shell:
- One shared NexusLayout for ALL routes (dashboard + pages): compact 68px
  icon rail with hover-expand overlay, replaces both old sidebars + topbars
- Single flat nav source (railNav) — same menu everywhere incl. Settings
- Removed dead shell components (AppSidebar, AppHeader, Topbar, NavGroup,
  NavItem, ModuleView) and dead nav routes; App.vue is now just RouterView

Live updates:
- Fix SSE loop in DashboardController: PeriodicTimer.WaitForNextTickAsync and
  ChannelReader.ReadAsync were re-invoked while pending — every published
  update threw InvalidOperationException and killed ALL live streams
- liveSync store: treat graceful stream close as disconnect (was stuck
  connected=true with polling stopped -> page frozen until manual reload),
  fast first retry, heartbeat watchdog (65s), reconnect on online/visibility
- One app-wide SSE connection owned by the layout instead of per-view
  connect/disconnect churn; removed duplicate live-sync.ts store

Performance:
- Board endpoint: drop nested childTasks duplication (counts stay) — payload
  104KB -> 65KB; SSE snapshots shrink equally
- nginx: gzip for JSON/JS/CSS (board 18KB, bundle 95KB over the wire);
  text/event-stream excluded to keep SSE unbuffered

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:25:26 +02:00

668 lines
28 KiB
C#

using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class TaskService(
ITaskRepository taskRepo,
IActivityRepository activityRepo,
INotificationService notificationService,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor,
ILiveUpdateService liveUpdateService,
IStaleTaskRecoveryService staleTaskRecoveryService) : ITaskService
{
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct);
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await taskRepo.GetByIdAsync(id, ct);
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
{
var allTasks = (await taskRepo.GetAllAsync(ct)).ToList();
var task = allTasks.FirstOrDefault(t => t.Id == id);
if (task is null) return null;
var activity = await activityRepo.GetRecentForTasksAsync(allTasks.Select(t => t.Id), ct);
return MapToDtoWithChildren(task, allTasks, activity);
}
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> await taskRepo.GetPendingApprovalAsync(ct);
public async Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default)
{
var task = new WorkTask
{
Title = request.Title.Trim(),
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
ProjectId = request.ProjectId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return task;
}
public async Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default)
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState);
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);
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task {task.Title} moved to {canonical}", ct);
}
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var changes = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Title) && !string.Equals(task.Title, request.Title.Trim(), StringComparison.Ordinal))
{
changes.Add($"Titel: \"{task.Title}\" → \"{request.Title.Trim()}\"");
task.Title = request.Title.Trim();
}
if (!string.IsNullOrWhiteSpace(request.Priority) && !string.Equals(task.Priority, request.Priority.Trim(), StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Priorität: {task.Priority} → {request.Priority.Trim()}");
task.Priority = request.Priority.Trim();
}
if (request.ProjectId.HasValue)
{
changes.Add("Projekt-ID geändert");
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
}
await taskRepo.UpdateAsync(task, ct);
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
await taskRepo.DeleteAsync(task, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success);
}
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt)
.ToList();
}
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all
.Where(t => t.IsAgentTask && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderBy(t => t.ExpectedFrom != null ? 0 : 1)
.ThenByDescending(t => t.UpdatedAt)
.ToList();
}
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
=> tasks.Select(task => MapToDtoWithChildren(task, all, activity)).ToList();
var waitingForBao = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForIris = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForOthers = map(agentTasks
.Where(t =>
{
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
}));
var staleTasks = map(agentTasks
.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold));
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, staleTasks, staleThreshold);
}
public async Task<WorkTask> CreateDashboardTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
{
if (parentTaskId.HasValue)
{
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
if (parent is null)
throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId));
}
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct);
var isVisibleDelegation = parentTaskId.HasValue;
var task = new WorkTask
{
Title = title.Trim(),
Detail = detail?.Trim(),
Source = normalizedSource,
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = normalizedAssignee,
ParentTaskId = parentTaskId,
IsAgentTask = isVisibleDelegation
};
await taskRepo.AddAsync(task, ct);
var activityMessages = new List<string> { $"Task \"{task.Title}\" created ({task.Source})" };
if (parentTaskId.HasValue)
{
activityMessages.Add($"Sichtbare Delegation erstellt: Child-Task von {parentTaskId.Value}.");
await activityRepo.AddAsync(new ActivityEvent
{
Type = "delegation",
Message = $"Board-first Delegation: Child-Task \"{task.Title}\" für {normalizedAssignee ?? task.Source} sichtbar angelegt.",
TaskId = parentTaskId.Value
}, ct);
}
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = string.Join(" ", activityMessages), TaskId = task.Id }, ct);
if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_assigned",
$"Neue Aufgabe: {task.Title}",
detail,
"bao",
task.Id,
ct);
}
await PublishBoardSnapshotAsync(ct);
return task;
}
public async Task<WorkTask> CreateAgentTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default)
{
var normalizedExpectedFrom = await NormalizeActorAsync(expectedFrom, ct);
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
task.IsAgentTask = true;
task.ExpectedFrom = normalizedExpectedFrom;
task.State = ResolveInitialAgentTaskState(startsInProgress, initialState);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "agent_task",
Message = $"Agent-Task created: \"{task.Title}\" (Source: {task.Source}, Expected: {task.ExpectedFrom ?? "none"})",
TaskId = task.Id
}, ct);
if (parentTaskId.HasValue)
{
await activityRepo.AddAsync(new ActivityEvent
{
Type = "delegation",
Message = $"Parent-/Child-Delegation sichtbar: Parent {parentTaskId.Value}, Child {task.Id}, wartet auf {task.ExpectedFrom ?? task.AssignedTo ?? "unbekannt"}.",
TaskId = parentTaskId.Value
}, ct);
}
await notificationService.CreateAsync(
"agent_task_created",
$"Neuer Agent-Task: {task.Title}",
detail,
"iris",
task.Id,
ct);
await PublishBoardSnapshotAsync(ct);
return task;
}
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
Guid id, string? title, string? detail, string? source,
string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default)
{
var caller = ResolveCaller();
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var changes = new List<string>();
if (!string.IsNullOrWhiteSpace(title) && !string.Equals(task.Title, title.Trim(), StringComparison.Ordinal))
{
changes.Add($"Titel: \"{task.Title}\" → \"{title.Trim()}\"");
task.Title = title.Trim();
}
if (detail is not null)
{
var newDetail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
if (!string.Equals(task.Detail ?? "", newDetail ?? "", StringComparison.Ordinal))
{
changes.Add("Beschreibung aktualisiert");
task.Detail = newDetail;
}
}
if (!string.IsNullOrWhiteSpace(source))
task.Source = source.Trim();
if (!string.IsNullOrWhiteSpace(priority) && !string.Equals(task.Priority, priority.Trim(), StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Priorität: {task.Priority} → {priority.Trim()}");
task.Priority = priority.Trim();
}
if (assignedTo is not null)
{
var validated = await NormalizeActorAsync(assignedTo, ct);
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
task.AssignedTo = validated;
}
}
if (dueDate.HasValue && task.DueDate?.Date != dueDate.Value.Date)
{
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
task.DueDate = dueDate;
}
await taskRepo.UpdateAsync(task, ct);
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" aktualisiert von {caller}: {changeSummary}",
TaskId = task.Id
}, ct);
if (changes.Count > 0 && caller == "bao")
{
await notificationService.CreateAsync(
"task_content_changed",
$"Bao hat \"{task.Title}\" geändert",
changeSummary,
"iris",
task.Id,
ct);
}
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
{
if (!TaskStateHelper.IsValidState(status))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
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);
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct);
}
public async Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.Equals(task.State, "Backlog", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.Success, task);
return await UpdateTaskStatusInternalAsync(
task,
canonical: TaskStateHelper.ToStateString(TaskState.InProgress),
actor: "nexus-system",
activityType: "delegation",
activityMessage: $"Task \"{task.Title}\" → In progress (coordination started by child-task creation)",
ct: ct);
}
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.State = "Done";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.Priority = task.Priority.ToLowerInvariant() switch
{
"high" => "Medium",
"medium" => "Low",
"low" => "High",
_ => "Medium"
};
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
{
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
var offen = 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 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);
switch (task.State.ToLowerInvariant())
{
case "backlog": offen.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: offen.Add(dto); break;
}
}
offen.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(offen, inProgress, review, blocked, done);
}
private async Task PublishBoardSnapshotAsync(CancellationToken ct = default)
{
var board = await GetBoardAsync(ct);
liveUpdateService.Publish("tasks.board.snapshot", board, "board");
}
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);
}
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase))
?? TaskStateHelper.BoardGroupToState(newState);
if (canonical is null)
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
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);
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
}
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
{
var normalizedHours = Math.Max(1, staleHours);
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
}
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
=> staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct);
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all.Where(t => t.ParentTaskId == parentId)
.OrderByDescending(t => t.CreatedAt)
.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)
{
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.
var childDtos = includeChildren
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
: null;
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity, allTasks);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childTasks.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
};
}
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,
t.IsAgentTask, t.ExpectedFrom);
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity, IReadOnlyList<WorkTask>? _allTasks = null)
{
var last = activity
.Where(e => e.TaskId == t.Id)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom,
last?.Message,
last?.CreatedAt,
null,
0,
0,
t.ParentTaskId.HasValue || t.IsAgentTask);
}
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
}
private static string ResolveInitialAgentTaskState(bool startsInProgress, string? initialState)
{
if (!string.IsNullOrWhiteSpace(initialState))
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(state =>
state.Equals(initialState, StringComparison.OrdinalIgnoreCase));
if (canonical is not null)
return canonical;
}
return startsInProgress
? TaskStateHelper.ToStateString(TaskState.InProgress)
: TaskStateHelper.ToStateString(TaskState.Backlog);
}
private string ResolveCaller()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "nexus-system";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
var user = httpContext.User;
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
private async Task<TaskOperationResult> UpdateTaskStatusInternalAsync(
WorkTask task,
string canonical,
string actor,
string activityType,
string? activityMessage,
CancellationToken ct)
{
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = activityType,
Message = activityMessage ?? $"Task \"{task.Title}\" → {canonical}",
TaskId = task.Id
}, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, actor, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, string caller, CancellationToken ct)
{
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_review",
$"Task zur Überprüfung: {task.Title}",
$"Status auf Review geändert von {caller}",
"bao",
task.Id,
ct);
}
else if (string.Equals(canonical, "Blocked", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_blocked",
$"Aufgabe blockiert: {task.Title}",
$"Die Task wurde von {caller} auf Blockiert gesetzt.",
"iris",
task.Id,
ct);
}
else
{
if (caller == "bao")
{
await notificationService.CreateAsync(
"task_status_changed",
$"Bao hat Status geändert: {task.Title}",
$"Status → {canonical}",
"iris",
task.Id,
ct);
}
else if (caller == "iris")
{
await notificationService.CreateAsync(
"task_status_changed",
$"Iris hat Status geändert: {task.Title}",
$"Status → {canonical}",
"bao",
task.Id,
ct);
}
}
}
}