feat: board-first orchestration with Gateway Bridge, live-update, and flow-board
CI - Build & Test / Backend (.NET) (push) Successful in 1m19s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s

- GatewayBridgeController: MCP-artiger Kommando-Adapter für Agent-zu-Backend
- TaskBridgeService + LiveUpdateService: SSE Live-Sync + Bridge-Kommandos
- FlowBoard.vue: Board-first orchestration dashboard panel
- live-sync.ts store + live.ts service: SSE-basierte Live-Updates
- Nullability-Warnung in HealthController.cs gefixt
- nginx.conf: SSE-Proxy + CORS für Bridge-Endpunkte
- .gitignore: pnpm/corepack local caches ausgeschlossen
- docs: architecture-board-first-orchestration.md hinzugefügt
- README: Backend Bridge API dokumentiert
This commit is contained in:
2026-06-22 19:56:45 +02:00
parent de1fc198cb
commit df94ed3cd4
34 changed files with 2115 additions and 118 deletions
+11
View File
@@ -73,6 +73,7 @@ public interface IAgentService
{
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
}
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
@@ -151,6 +152,16 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
);
}
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
{
var configs = await LoadAgentConfigsAsync(cancellationToken);
return configs
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
.Select(config => config.Id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
+17
View File
@@ -0,0 +1,17 @@
using System.Threading.Channels;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface ILiveUpdateService
{
Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default);
LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard");
long CurrentSequence { get; }
}
public sealed class LiveUpdateSubscription
{
public ChannelReader<LiveUpdateEnvelope> Reader { get; init; } = default!;
public long StartingSequence { get; init; }
}
+1
View File
@@ -10,4 +10,5 @@ public interface INotificationService
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
}
+133
View File
@@ -0,0 +1,133 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
/// <summary>
/// Structured backend bridge for agent/task commands.
/// Provides a clean, typed API for agents (Iris and sub-agents) to interact
/// with the task board, activity log, and delegation workflow.
///
/// This is the internal service layer — never exposed directly to the browser.
/// The GatewayBridgeController wraps this for agent-facing HTTP access.
/// </summary>
public interface ITaskBridgeService
{
// ── Task CRUD (Agent-Commands) ──
/// <summary>
/// Creates a new top-level task (parent or standalone).
/// Returns the created task DTO.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
Guid? projectId = null,
CancellationToken ct = default);
/// <summary>
/// Creates a child task linked to an existing parent.
/// This is the primary delegation command: iris creates a child task,
/// assigns it to a sub-agent, and tracks it on the board.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
Guid parentTaskId,
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
CancellationToken ct = default);
/// <summary>
/// Updates the status/state of a task.
/// Enforces CanChangeState rules (only iris/bao/nexus-system may change state).
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
Guid taskId,
string state,
string? callerAgent = null,
CancellationToken ct = default);
/// <summary>
/// Appends an activity entry to a task (comment, status note, agent note).
/// Used by agents to annotate their progress on the board.
/// </summary>
Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default);
/// <summary>
/// Handles a task handoff: sets ExpectedFrom to the target agent,
/// appends a handoff activity entry, and optionally updates assigned-to.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default);
// ── Query (Read) ──
/// <summary>
/// Returns the full task board state (grouped by status column).
/// </summary>
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
/// <summary>
/// Returns a single task by ID.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
Guid taskId,
CancellationToken ct = default);
/// <summary>
/// Returns all child tasks for a given parent task.
/// </summary>
Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId,
CancellationToken ct = default);
/// <summary>
/// Returns task activity history.
/// </summary>
Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId,
CancellationToken ct = default);
// ── Agent Workflow ──
/// <summary>
/// Returns the agent-workflow overview: who is expected to respond,
/// stale tasks, workload distribution.
/// </summary>
Task<AgentWorkflowOverview> GetAgentOverviewAsync(
TimeSpan? staleThreshold = null,
CancellationToken ct = default);
}
/// <summary>
/// Result pattern for task-bridge operations.
/// WorkTask? is null on NotFound; state is stored in the Outcome.
/// </summary>
public sealed record TaskBridgeResult<T>(
TaskBridgeOutcome Outcome,
T? Data = default,
string? Error = null
);
public enum TaskBridgeOutcome
{
Success,
NotFound,
InvalidState,
Unauthorized,
ValidationError
}
+87
View File
@@ -0,0 +1,87 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class LiveUpdateService : ILiveUpdateService
{
private const int ReplayLimit = 256;
private readonly ConcurrentDictionary<Guid, Channel<LiveUpdateEnvelope>> _subscribers = new();
private readonly object _historyLock = new();
private readonly Queue<LiveUpdateEnvelope> _history = new();
private long _sequence;
public long CurrentSequence => Interlocked.Read(ref _sequence);
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
{
var channel = Channel.CreateUnbounded<LiveUpdateEnvelope>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
var id = Guid.NewGuid();
_subscribers[id] = channel;
var replay = afterSequence.HasValue ? GetReplay(afterSequence.Value) : Array.Empty<LiveUpdateEnvelope>();
foreach (var envelope in replay)
{
channel.Writer.TryWrite(envelope);
}
ct.Register(() =>
{
if (_subscribers.TryRemove(id, out var removed))
{
removed.Writer.TryComplete();
}
});
return Task.FromResult(new LiveUpdateSubscription
{
Reader = channel.Reader,
StartingSequence = replay.LastOrDefault()?.Sequence ?? CurrentSequence
});
}
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
{
var envelope = new LiveUpdateEnvelope(
type,
DateTimeOffset.UtcNow,
payload,
Interlocked.Increment(ref _sequence),
channel);
lock (_historyLock)
{
_history.Enqueue(envelope);
while (_history.Count > ReplayLimit)
{
_history.Dequeue();
}
}
foreach (var (id, subscriber) in _subscribers)
{
if (!subscriber.Writer.TryWrite(envelope) && _subscribers.TryRemove(id, out var removed))
{
removed.Writer.TryComplete();
}
}
return envelope;
}
private LiveUpdateEnvelope[] GetReplay(long afterSequence)
{
lock (_historyLock)
{
return _history.Where(item => item.Sequence > afterSequence).ToArray();
}
}
}
+27 -2
View File
@@ -4,7 +4,7 @@ using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class NotificationService(NexusDbContext db) : INotificationService
public sealed class NotificationService(NexusDbContext db, ILiveUpdateService liveUpdateService) : INotificationService
{
public async Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
{
@@ -18,6 +18,7 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
};
db.Notifications.Add(notification);
await db.SaveChangesAsync(ct);
await PublishSnapshotAsync(notification.ForUser, ct);
return notification;
}
@@ -42,14 +43,17 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
notification.IsRead = true;
await db.SaveChangesAsync(ct);
await PublishSnapshotAsync(notification.ForUser, ct);
return true;
}
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
{
var normalizedUser = forUser.ToLowerInvariant();
var count = await db.Notifications
.Where(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead)
.Where(n => n.ForUser == normalizedUser && !n.IsRead)
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
await PublishSnapshotAsync(normalizedUser, ct);
return count;
}
@@ -58,4 +62,25 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
return await db.Notifications
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
}
public async Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var normalizedUser = forUser.ToLowerInvariant();
var notifications = await GetForUserAsync(normalizedUser, limit, unreadOnly, ct);
var unreadCount = await GetUnreadCountAsync(normalizedUser, ct);
return new NotificationSnapshotDto(
notifications.Select(MapToDto).ToList(),
unreadCount,
normalizedUser);
}
private async Task PublishSnapshotAsync(string forUser, CancellationToken ct)
{
var snapshot = await GetSnapshotAsync(forUser, ct: ct);
liveUpdateService.Publish("notifications.snapshot", snapshot, "notifications");
}
private static NotificationDto MapToDto(Notification n) => new(
n.Id, n.Type, n.Title, n.Message,
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
}
+248
View File
@@ -0,0 +1,248 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
/// <summary>
/// Concrete implementation of ITaskBridgeService.
/// Wraps ITaskService, IActivityRepository, INotificationService, and ILiveUpdateService
/// into structured, predictable commands for agent-facing usage.
///
/// All operations produce typed TaskBridgeResult<T> with explicit error codes,
/// making agent consumption safe and debuggable.
/// </summary>
public sealed class TaskBridgeService(
ITaskService taskService,
IActivityRepository activityRepo,
INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ITaskBridgeService
{
private static readonly HashSet<string> ValidStates =
new(TaskStateHelper.AllStates, StringComparer.OrdinalIgnoreCase);
// ──────────────────────────────── Create Task ────────────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
Guid? projectId = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
var normalizedSource = NormalizeSource(source);
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateDashboardTaskAsync(
title.Trim(), detail?.Trim(), normalizedSource, priority, normalizedAssignee, parentTaskId: null, ct);
var dto = MapToDto(task);
return Success(dto);
}
// ──────────────────────────────── Create Child Task ──────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
Guid parentTaskId,
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
// Verify parent exists
var parent = await taskService.GetByIdAsync(parentTaskId, ct);
if (parent is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateAgentTaskAsync(
title.Trim(), detail?.Trim(), NormalizeSource(source),
priority, normalizedAssignee, expectedFrom, parentTaskId, ct);
// If parent was in Backlog, move it to InProgress (coordination starts)
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase))
{
await taskService.UpdateStatusAsync(parentTaskId, "In progress", ct);
}
var dto = MapToDto(task);
return Success(dto);
}
// ──────────────────────────────── Update Status ──────────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
Guid taskId,
string state,
string? callerAgent = null,
CancellationToken ct = default)
{
if (!ValidStates.Contains(state))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError,
$"Invalid state '{state}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}");
var task = await taskService.GetByIdAsync(taskId, ct);
if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
// Check authorization
if (!TaskStateHelper.CanChangeState(callerAgent, task))
return Error<DashboardTaskDto>(TaskBridgeOutcome.Unauthorized,
$"Agent '{callerAgent}' is not authorized to change task state. Only iris and bao may move tasks.");
var result = await taskService.UpdateStatusAsync(taskId, state, ct);
if (result.Outcome != TaskOperationOutcome.Success)
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
var dto = MapToDto(result.Task!);
return Success(dto);
}
// ──────────────────────────────── Append Activity ────────────────────────────
public async Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(message))
return Error<ActivityEvent>(TaskBridgeOutcome.ValidationError, "Message is required.");
var task = await taskService.GetByIdAsync(taskId, ct);
if (task is null)
return Error<ActivityEvent>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var ev = new ActivityEvent
{
Type = type ?? "comment",
Message = message.Trim(),
TaskId = taskId
};
await activityRepo.AddAsync(ev, ct);
// Trigger live update so the board refreshes
var board = await taskService.GetBoardAsync(ct);
liveUpdateService.Publish("tasks.board.snapshot", board);
return Success(ev);
}
// ──────────────────────────────── Handoff ────────────────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(targetAgent))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Target agent is required.");
var task = await taskService.GetByIdAsync(taskId, ct);
if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var normalizedTarget = targetAgent.Trim().ToLowerInvariant();
var handoffNote = string.IsNullOrWhiteSpace(note)
? $"Handoff → {normalizedTarget}"
: $"Handoff → {normalizedTarget}: {note.Trim()}";
// Update expected-from and optionally assigned-to
task.ExpectedFrom = normalizedTarget;
// If this is a child task (has parent), keep assigned-to on the child
// If standalone, set assigned-to to the target
if (!task.ParentTaskId.HasValue)
task.AssignedTo = normalizedTarget;
await taskService.UpdateDashboardTaskAsync(
taskId, title: null, detail: null, source: null,
priority: null, assignedTo: task.AssignedTo, dueDate: null, ct);
// Append handoff activity
await AppendActivityAsync(taskId, handoffNote, "handoff", ct);
// Notify the target
await notificationService.CreateAsync(
"task_assigned",
$"Handoff: {task.Title}",
handoffNote,
normalizedTarget,
task.Id,
ct);
var dto = MapToDto(task);
return Success(dto);
}
// ──────────────────────────────── Query ──────────────────────────────────────
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
=> await taskService.GetBoardAsync(ct);
public async Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
Guid taskId, CancellationToken ct = default)
{
var dto = await taskService.GetDashboardTaskByIdAsync(taskId, ct);
return dto is null
? Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.")
: Success(dto);
}
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default)
{
var children = await taskService.GetChildTasksAsync(parentTaskId, ct);
return children.Select(MapToDto).ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId, CancellationToken ct = default)
=> await taskService.GetTaskActivityAsync(taskId, ct);
public async Task<AgentWorkflowOverview> GetAgentOverviewAsync(
TimeSpan? staleThreshold = null, CancellationToken ct = default)
{
var threshold = staleThreshold ?? TimeSpan.FromHours(2);
return await taskService.GetAgentWorkflowOverviewAsync(threshold, ct);
}
// ──────────────────────────────── Helpers ────────────────────────────────────
private static TaskBridgeResult<T> Success<T>(T data) =>
new(TaskBridgeOutcome.Success, data);
private static TaskBridgeResult<T> Error<T>(TaskBridgeOutcome outcome, string error) =>
new(outcome, Data: default, Error: error);
private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
private static string? NormalizeAssignedTo(string? assignedTo)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
var valid = new HashSet<string> { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" };
var lower = assignedTo.Trim().ToLowerInvariant();
return valid.Contains(lower) ? lower : null;
}
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);
}
+102 -94
View File
@@ -9,7 +9,8 @@ public sealed class TaskService(
ITaskRepository taskRepo,
IActivityRepository activityRepo,
INotificationService notificationService,
IHttpContextAccessor httpContextAccessor) : ITaskService
IHttpContextAccessor httpContextAccessor,
ILiveUpdateService liveUpdateService) : ITaskService
{
private static readonly HashSet<string> ValidAssignees =
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
@@ -22,11 +23,12 @@ public sealed class TaskService(
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
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([task.Id], ct);
return MapToDtoWithActivity(task, activity);
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)
@@ -42,6 +44,7 @@ public sealed class TaskService(
};
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;
}
@@ -56,6 +59,7 @@ public sealed class TaskService(
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);
}
@@ -70,6 +74,7 @@ public sealed class TaskService(
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);
}
@@ -81,7 +86,6 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
@@ -90,6 +94,7 @@ public sealed class TaskService(
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -112,13 +117,14 @@ public sealed class TaskService(
}
if (request.ProjectId.HasValue)
{
changes.Add($"Projekt-ID geändert");
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);
}
@@ -132,11 +138,10 @@ public sealed class TaskService(
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);
}
// ── Dashboard-facing operations ──
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
@@ -145,10 +150,6 @@ public sealed class TaskService(
.ToList();
}
/// <summary>
/// Returns agent-tasks that are still open and where an agent is expected to respond.
/// Iris Dashboard uses this to see who she is waiting for.
/// </summary>
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
@@ -159,22 +160,15 @@ public sealed class TaskService(
.ToList();
}
/// <summary>
/// Returns agent-tasks grouped by which agent is expected to respond,
/// with stale-detection: parent tasks that remain in progress while child work
/// is active, and any in-progress task that has not been updated within the stale threshold.
/// </summary>
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
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 => MapToDtoWithActivity(task, activity)).ToList();
=> tasks.Select(task => MapToDtoWithChildren(task, all, activity)).ToList();
var waitingForBao = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
@@ -193,19 +187,15 @@ public sealed class TaskService(
}));
var staleTasks = map(agentTasks
.Where(t =>
string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
t.UpdatedAt < threshold));
.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold));
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
staleTasks, staleThreshold);
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)
{
// Validate parent task exists if specified
if (parentTaskId.HasValue)
{
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
@@ -215,6 +205,7 @@ public sealed class TaskService(
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
var normalizedAssignee = ValidateAssignedTo(assignedTo);
var isVisibleDelegation = parentTaskId.HasValue;
var task = new WorkTask
{
@@ -224,16 +215,24 @@ public sealed class TaskService(
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = normalizedAssignee,
ParentTaskId = parentTaskId,
IsAgentTask = parentTaskId.HasValue
IsAgentTask = isVisibleDelegation
};
await taskRepo.AddAsync(task, ct);
var message = $"Task \"{task.Title}\" created ({task.Source})";
var activityMessages = new List<string> { $"Task \"{task.Title}\" created ({task.Source})" };
if (parentTaskId.HasValue)
message += $" [child of {parentTaskId.Value}]";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct);
{
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);
// Auto-notify: if assigned to bao, create a task_assigned notification
if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
@@ -245,6 +244,7 @@ public sealed class TaskService(
ct);
}
await PublishBoardSnapshotAsync(ct);
return task;
}
@@ -252,13 +252,12 @@ public sealed class TaskService(
string title, string? detail, string? source, string? priority,
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
{
var normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
task.IsAgentTask = true;
task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
task.ExpectedFrom = normalizedExpectedFrom;
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
// Persist the agent-task-specific fields
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
@@ -268,7 +267,16 @@ public sealed class TaskService(
TaskId = task.Id
}, ct);
// Notify iris about new agent-task
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}",
@@ -277,6 +285,7 @@ public sealed class TaskService(
task.Id,
ct);
await PublishBoardSnapshotAsync(ct);
return task;
}
@@ -320,13 +329,10 @@ public sealed class TaskService(
task.AssignedTo = validated;
}
}
if (dueDate.HasValue)
if (dueDate.HasValue && task.DueDate?.Date != dueDate.Value.Date)
{
if (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;
}
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);
@@ -339,18 +345,18 @@ public sealed class TaskService(
TaskId = task.Id
}, ct);
// Notification: wenn Bao die Task geändert hat, Iris benachrichtigen
if (changes.Count > 0 && caller == "bao")
{
await notificationService.CreateAsync(
"task_content_changed",
$"Bao hat \"{task.Title}\" geändert",
$"{changeSummary}",
changeSummary,
"iris",
task.Id,
ct);
}
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -362,7 +368,6 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
@@ -372,6 +377,7 @@ public sealed class TaskService(
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -383,6 +389,7 @@ public sealed class TaskService(
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);
}
@@ -401,14 +408,15 @@ public sealed class TaskService(
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);
}
// ── Board operations ──
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
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>();
@@ -417,21 +425,15 @@ public sealed class TaskService(
foreach (var task in all)
{
var dto = MapToDto(task);
var dto = MapToDtoWithChildren(task, all, activity);
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;
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;
}
}
@@ -444,6 +446,12 @@ public sealed class TaskService(
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));
@@ -461,23 +469,14 @@ public sealed class TaskService(
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
{
// Resolve canonical state: accept board group keys or canonical strings
var canonical = TaskStateHelper.AllStates
.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase));
if (canonical is null)
{
// Try mapping from board group key
canonical = TaskStateHelper.BoardGroupToState(newState);
}
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);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
@@ -486,6 +485,7 @@ public sealed class TaskService(
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -499,9 +499,7 @@ public sealed class TaskService(
{
var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = all.Where(t =>
string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
t.UpdatedAt < threshold).ToList();
var staleTasks = all.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold).ToList();
foreach (var task in staleTasks)
{
@@ -516,6 +514,9 @@ public sealed class TaskService(
}, ct);
}
if (staleTasks.Count > 0)
await PublishBoardSnapshotAsync(ct);
return staleTasks.Count;
}
@@ -533,12 +534,31 @@ public sealed class TaskService(
return all.Where(e => e.TaskId == taskId).ToList();
}
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
{
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
.OrderByDescending(t => t.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity, allTasks);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.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)
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity, IReadOnlyList<WorkTask>? _allTasks = null)
{
var last = activity
.Where(e => e.TaskId == t.Id)
@@ -550,13 +570,13 @@ public sealed class TaskService(
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom,
last?.Message,
last?.CreatedAt);
last?.CreatedAt,
null,
0,
0,
t.ParentTaskId.HasValue || t.IsAgentTask);
}
/// <summary>
/// Validates AssignedTo — only recognized agent values are accepted.
/// Returns null for invalid values.
/// </summary>
private static string? ValidateAssignedTo(string? assignedTo)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
@@ -564,15 +584,10 @@ public sealed class TaskService(
return ValidAssignees.Contains(lower) ? lower : null;
}
/// <summary>
/// Resolves the caller identity from the HTTP context.
/// Reads the X-Agent-Id header for agent calls, falls back to JWT name.
/// Outside HTTP context → "nexus-system" (allowed for internal Cron/ResetStale ops).
/// </summary>
private string ResolveCaller()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "nexus-system"; // internal system ops allowed
if (httpContext is null) return "nexus-system";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
@@ -583,12 +598,6 @@ public sealed class TaskService(
return nameClaim?.ToLowerInvariant() ?? "";
}
/// <summary>
/// Creates status-change notifications when a task moves to a new state.
/// - Wenn Bao ändert → Iris benachrichtigen
/// - Wenn Iris ändert → Bao benachrichtigen
/// - Review/Blocked bekommen spezifische Töne
/// </summary>
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
{
var caller = ResolveCaller();
@@ -615,7 +624,6 @@ public sealed class TaskService(
}
else
{
// Allgemeine Statusänderung: Gegenüber benachrichtigen
if (caller == "bao")
{
await notificationService.CreateAsync(