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
+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(