feat: board-first orchestration with Gateway Bridge, live-update, and flow-board
- 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:
+3
-1
@@ -34,7 +34,9 @@ docker-compose.override.yml
|
|||||||
**/core
|
**/core
|
||||||
**/core.*
|
**/core.*
|
||||||
|
|
||||||
# pnpm (lockfile IS committed for reproducible CI builds)
|
# pnpm / corepack local caches (lockfile IS committed for reproducible CI builds)
|
||||||
|
frontend/.pnpm-home/
|
||||||
|
frontend/.corepack-home/
|
||||||
|
|
||||||
# Claude local config (per-developer, not repo-shared)
|
# Claude local config (per-developer, not repo-shared)
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
||||||
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
||||||
|
|
||||||
|
> 📋 **Architektur-Review** (2026-06-22): Board-first Orchestrierung, sichere
|
||||||
|
> Backend-Brücke und Gateway-Integration geprüft. Siehe
|
||||||
|
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
|
||||||
|
|
||||||
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
||||||
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
|
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
|
||||||
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
|
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
|
||||||
@@ -170,6 +174,38 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
|||||||
|
|
||||||
## API endpoints
|
## API endpoints
|
||||||
|
|
||||||
|
### Backend Bridge (Agent-zu-Backend, NICHT Frontend)
|
||||||
|
|
||||||
|
Der `/api/bridge/` Pfad ist ein strukturierter MCP-artiger Kommando-Adapter für die
|
||||||
|
Agent-zu-Backend-Kommunikation. Kein Frontend-Code ruft diese Endpunkte auf.
|
||||||
|
|
||||||
|
Auth: `X-Agent-Id` Header, `X-Nexus-Api-Key`, oder JWT. Rate-Limited (30/min).
|
||||||
|
|
||||||
|
| Methode | Pfad | Kommando | Beschreibung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET` | `/api/bridge/health` | — | Bridge-Health-Check |
|
||||||
|
| `POST` | `/api/bridge/tasks` | `create_task` | Neue Top-Level-Task erstellen |
|
||||||
|
| `POST` | `/api/bridge/tasks/{id}/children` | `create_child_task` | Child-Task unter Parent erstellen |
|
||||||
|
| `PATCH` | `/api/bridge/tasks/{id}/status` | `update_status` | Task-Status ändern |
|
||||||
|
| `POST` | `/api/bridge/tasks/{id}/activity` | `append_activity` | Aktivitätseintrag anhängen |
|
||||||
|
| `POST` | `/api/bridge/tasks/{id}/handoff` | `handoff` | Task an anderen Agent übergeben |
|
||||||
|
| `GET` | `/api/bridge/board` | `get_board` | Vollständiges Task-Board |
|
||||||
|
| `GET` | `/api/bridge/tasks/{id}` | `get_task` | Einzelne Task abrufen |
|
||||||
|
| `GET` | `/api/bridge/tasks/{id}/children` | `get_children` | Child-Tasks abrufen |
|
||||||
|
| `GET` | `/api/bridge/tasks/{id}/activity` | `get_activity` | Task-Aktivität abrufen |
|
||||||
|
| `GET` | `/api/bridge/agent-overview` | `get_agent_overview` | Agent-Workflow-Übersicht |
|
||||||
|
|
||||||
|
Response-Format (TaskBridgeCommandResponse<T>):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"command": "create_task",
|
||||||
|
"data": { ... },
|
||||||
|
"error": null,
|
||||||
|
"timestamp": "2026-06-22T15:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Health & Auth (public or rate-limited)
|
### Health & Auth (public or rate-limited)
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||||
|
|||||||
@@ -143,4 +143,7 @@ internal sealed class SnapshotAgentServiceStub : IAgentService
|
|||||||
|
|
||||||
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ public class DashboardController(
|
|||||||
IDashboardService dashboardService,
|
IDashboardService dashboardService,
|
||||||
ITaskService taskService,
|
ITaskService taskService,
|
||||||
IActivityRepository activityService,
|
IActivityRepository activityService,
|
||||||
IHttpContextAccessor httpContextAccessor) : ControllerBase
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
INotificationService notificationService,
|
||||||
|
ILiveUpdateService liveUpdateService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("status")]
|
[HttpGet("status")]
|
||||||
public async Task<DashboardStatus> GetStatus()
|
public async Task<DashboardStatus> GetStatus()
|
||||||
@@ -193,6 +195,69 @@ public class DashboardController(
|
|||||||
public async Task<BoardResponse> GetBoard(CancellationToken ct)
|
public async Task<BoardResponse> GetBoard(CancellationToken ct)
|
||||||
=> await taskService.GetBoardAsync(ct);
|
=> await taskService.GetBoardAsync(ct);
|
||||||
|
|
||||||
|
[HttpGet("live")]
|
||||||
|
public async Task Live(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
[FromQuery] int notificationLimit = 50,
|
||||||
|
[FromQuery] long? afterSequence = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Response.Headers.Append("Content-Type", "text/event-stream");
|
||||||
|
Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
Response.Headers.Append("Connection", "keep-alive");
|
||||||
|
Response.Headers.Append("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
async Task WriteEventAsync(string eventName, object payload)
|
||||||
|
{
|
||||||
|
await Response.WriteAsync($"event: {eventName}\n", ct);
|
||||||
|
await Response.WriteAsync($"data: {System.Text.Json.JsonSerializer.Serialize(payload)}\n\n", ct);
|
||||||
|
await Response.Body.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSequence = liveUpdateService.CurrentSequence;
|
||||||
|
var initial = new DashboardLiveSnapshotDto(
|
||||||
|
await taskService.GetBoardAsync(ct),
|
||||||
|
await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct),
|
||||||
|
new LiveCursorDto(currentSequence, DateTimeOffset.UtcNow, "live"));
|
||||||
|
await WriteEventAsync("snapshot", initial);
|
||||||
|
|
||||||
|
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
|
||||||
|
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||||
|
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||||
|
|
||||||
|
if (completed == readTask)
|
||||||
|
{
|
||||||
|
var envelope = await readTask;
|
||||||
|
if (envelope.Type == "notifications.snapshot")
|
||||||
|
{
|
||||||
|
var snapshot = envelope.Payload as NotificationSnapshotDto
|
||||||
|
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
||||||
|
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
envelope = envelope with { Payload = snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.Type == "tasks.board.snapshot")
|
||||||
|
{
|
||||||
|
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteEventAsync("update", new DashboardLiveEventDto(
|
||||||
|
envelope,
|
||||||
|
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
||||||
|
}
|
||||||
|
else if (await heartbeatTask)
|
||||||
|
{
|
||||||
|
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPatch("tasks/{id:guid}/move")]
|
[HttpPatch("tasks/{id:guid}/move")]
|
||||||
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
|
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
|
||||||
Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct)
|
Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP-style (structured-command) backend bridge for agent-facing operations.
|
||||||
|
///
|
||||||
|
/// This is the SINGLE entrypoint for agents (Iris + sub-agents) to interact with
|
||||||
|
/// the Nexus task board, activity log, and delegation workflow.
|
||||||
|
///
|
||||||
|
/// AUTHENTICATION: Requires X-Nexus-Api-Key or a known allowed X-Agent-Id.
|
||||||
|
/// The browser NEVER uses this controller — only backend-to-backend and gateway-to-backend.
|
||||||
|
///
|
||||||
|
/// DESIGN PRINCIPLE: No MCP protocol between Nexus and Gateway — instead, the Gateway
|
||||||
|
/// calls these structured HTTP endpoints (same pattern, simpler transport).
|
||||||
|
///
|
||||||
|
/// COMMANDS:
|
||||||
|
/// create_task → POST /api/bridge/tasks
|
||||||
|
/// create_child_task → POST /api/bridge/tasks/{id}/children
|
||||||
|
/// update_status → PATCH /api/bridge/tasks/{id}/status
|
||||||
|
/// append_activity → POST /api/bridge/tasks/{id}/activity
|
||||||
|
/// handoff → POST /api/bridge/tasks/{id}/handoff
|
||||||
|
/// get_board → GET /api/bridge/board
|
||||||
|
/// get_task → GET /api/bridge/tasks/{id}
|
||||||
|
/// get_children → GET /api/bridge/tasks/{id}/children
|
||||||
|
/// get_activity → GET /api/bridge/tasks/{id}/activity
|
||||||
|
/// get_agent_overview → GET /api/bridge/agent-overview
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/bridge")]
|
||||||
|
[EnableRateLimiting("agents")]
|
||||||
|
public class GatewayBridgeController(
|
||||||
|
ITaskBridgeService bridge,
|
||||||
|
IAgentService agentService,
|
||||||
|
ILogger<GatewayBridgeController> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string ApikeyErrorMessage =
|
||||||
|
"Bridge endpoints require X-Nexus-Api-Key or X-Agent-Id header with a recognized agent identity.";
|
||||||
|
|
||||||
|
[HttpGet("health")]
|
||||||
|
public IResult Health()
|
||||||
|
{
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
status = "ok",
|
||||||
|
service = "nexus-bridge",
|
||||||
|
version = "1.0.0",
|
||||||
|
commands = new[]
|
||||||
|
{
|
||||||
|
"create_task", "create_child_task", "update_status",
|
||||||
|
"append_activity", "handoff", "get_board", "get_task",
|
||||||
|
"get_children", "get_activity", "get_agent_overview"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateTask(
|
||||||
|
[FromBody] BridgeCreateTaskCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var agentId = resolution.AgentId;
|
||||||
|
var result = await bridge.CreateTaskAsync(
|
||||||
|
title: command.Title,
|
||||||
|
detail: command.Detail,
|
||||||
|
source: ResolveSource(agentId),
|
||||||
|
priority: command.Priority ?? "Normal",
|
||||||
|
assignedTo: command.AssignedTo ?? agentId,
|
||||||
|
projectId: command.ProjectId,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapResult(result, "create_task");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks/{parentTaskId:guid}/children")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateChildTask(
|
||||||
|
Guid parentTaskId,
|
||||||
|
[FromBody] BridgeCreateChildTaskCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var agentId = resolution.AgentId;
|
||||||
|
var result = await bridge.CreateChildTaskAsync(
|
||||||
|
parentTaskId: parentTaskId,
|
||||||
|
title: command.Title,
|
||||||
|
detail: command.Detail,
|
||||||
|
source: ResolveSource(agentId),
|
||||||
|
priority: command.Priority ?? "Normal",
|
||||||
|
assignedTo: command.AssignedTo,
|
||||||
|
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapResult(result, "create_child_task");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("tasks/{taskId:guid}/status")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> UpdateStatus(
|
||||||
|
Guid taskId,
|
||||||
|
[FromBody] BridgeUpdateStatusCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var agentId = resolution.AgentId;
|
||||||
|
var result = await bridge.UpdateStatusAsync(
|
||||||
|
taskId: taskId,
|
||||||
|
state: command.State,
|
||||||
|
callerAgent: agentId,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapResult(result, "update_status");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks/{taskId:guid}/activity")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<ActivityEntryDto>>> AppendActivity(
|
||||||
|
Guid taskId,
|
||||||
|
[FromBody] BridgeAppendActivityCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var result = await bridge.AppendActivityAsync(
|
||||||
|
taskId: taskId,
|
||||||
|
message: command.Message,
|
||||||
|
type: command.Type ?? "comment",
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapActivityResult(result, "append_activity");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks/{taskId:guid}/handoff")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> Handoff(
|
||||||
|
Guid taskId,
|
||||||
|
[FromBody] BridgeHandoffCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var result = await bridge.HandoffAsync(
|
||||||
|
taskId: taskId,
|
||||||
|
targetAgent: command.TargetAgent,
|
||||||
|
note: command.Note,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapResult(result, "handoff");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("board")]
|
||||||
|
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
return Ok(await bridge.GetBoardAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{taskId:guid}")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> GetTask(
|
||||||
|
Guid taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var result = await bridge.GetTaskAsync(taskId, ct);
|
||||||
|
return MapResult(result, "get_task");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{taskId:guid}/children")]
|
||||||
|
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(
|
||||||
|
Guid taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
return Ok(await bridge.GetChildTasksAsync(taskId, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{taskId:guid}/activity")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<List<ActivityEntryDto>>>> GetActivity(
|
||||||
|
Guid taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var events = await bridge.GetTaskActivityAsync(taskId, ct);
|
||||||
|
var entries = events.Select(e => new ActivityEntryDto(e.Id, e.Type, e.Message, e.CreatedAt)).ToList();
|
||||||
|
|
||||||
|
return Ok(new TaskBridgeCommandResponse<List<ActivityEntryDto>>
|
||||||
|
{
|
||||||
|
Ok = true,
|
||||||
|
Command = "get_activity",
|
||||||
|
Data = entries
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("agent-overview")]
|
||||||
|
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
|
||||||
|
CancellationToken ct,
|
||||||
|
[FromQuery] int staleHours = 2)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
|
||||||
|
return Ok(await bridge.GetAgentOverviewAsync(threshold, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||||
|
|
||||||
|
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||||
|
{
|
||||||
|
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
|
||||||
|
if (allowedAgentIds.Contains(normalizedHeader))
|
||||||
|
return (true, normalizedHeader, null);
|
||||||
|
|
||||||
|
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
|
||||||
|
normalizedHeader,
|
||||||
|
HttpContext.Connection.RemoteIpAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedAgentIds.Contains(normalizedClaim))
|
||||||
|
return (true, normalizedClaim, null);
|
||||||
|
|
||||||
|
if (User.IsInRole("owner") || User.IsInRole("admin") || User.IsInRole("member"))
|
||||||
|
return (true, "bao", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (User.IsInRole("Service") && allowedAgentIds.Contains("nexus-system"))
|
||||||
|
return (true, "nexus-system", null);
|
||||||
|
|
||||||
|
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
|
||||||
|
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
|
||||||
|
return (false, string.Empty, unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveSource(string agentId) => agentId switch
|
||||||
|
{
|
||||||
|
"bao" or "nexus-system" => "bao",
|
||||||
|
_ => agentId
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
|
||||||
|
{
|
||||||
|
if (result.Outcome == TaskBridgeOutcome.Success)
|
||||||
|
return new OkObjectResult(new TaskBridgeCommandResponse<T>
|
||||||
|
{
|
||||||
|
Ok = true,
|
||||||
|
Command = command,
|
||||||
|
Data = result.Data
|
||||||
|
});
|
||||||
|
|
||||||
|
var statusCode = result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskBridgeOutcome.NotFound => 404,
|
||||||
|
TaskBridgeOutcome.InvalidState => 422,
|
||||||
|
TaskBridgeOutcome.Unauthorized => 403,
|
||||||
|
TaskBridgeOutcome.ValidationError => 400,
|
||||||
|
_ => 500
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ObjectResult(new TaskBridgeCommandResponse<T>
|
||||||
|
{
|
||||||
|
Ok = false,
|
||||||
|
Command = command,
|
||||||
|
Error = result.Error ?? "Unknown error"
|
||||||
|
}) { StatusCode = statusCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
|
||||||
|
{
|
||||||
|
if (result.Outcome == TaskBridgeOutcome.Success)
|
||||||
|
return new OkObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
|
||||||
|
{
|
||||||
|
Ok = true,
|
||||||
|
Command = command,
|
||||||
|
Data = result.Data is null ? null : new ActivityEntryDto(
|
||||||
|
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt)
|
||||||
|
});
|
||||||
|
|
||||||
|
var statusCode = result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskBridgeOutcome.NotFound => 404,
|
||||||
|
TaskBridgeOutcome.ValidationError => 400,
|
||||||
|
_ => 500
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
|
||||||
|
{
|
||||||
|
Ok = false,
|
||||||
|
Command = command,
|
||||||
|
Error = result.Error ?? "Unknown error"
|
||||||
|
}) { StatusCode = statusCode };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TaskBridgeCommandResponse<T>
|
||||||
|
{
|
||||||
|
public bool Ok { get; init; }
|
||||||
|
public string Command { get; init; } = string.Empty;
|
||||||
|
public T? Data { get; init; }
|
||||||
|
public string? Error { get; init; }
|
||||||
|
public string Timestamp { get; init; } = DateTimeOffset.UtcNow.ToString("o");
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record BridgeCreateTaskCommand(
|
||||||
|
string Title,
|
||||||
|
string? Detail = null,
|
||||||
|
string? Priority = null,
|
||||||
|
string? AssignedTo = null,
|
||||||
|
Guid? ProjectId = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record BridgeCreateChildTaskCommand(
|
||||||
|
string Title,
|
||||||
|
string? Detail = null,
|
||||||
|
string? Priority = null,
|
||||||
|
string? AssignedTo = null,
|
||||||
|
string? ExpectedFrom = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record BridgeUpdateStatusCommand(string State);
|
||||||
|
|
||||||
|
public sealed record BridgeAppendActivityCommand(
|
||||||
|
string Message,
|
||||||
|
string? Type = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record BridgeHandoffCommand(
|
||||||
|
string TargetAgent,
|
||||||
|
string? Note = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ActivityEntryDto(
|
||||||
|
long Id,
|
||||||
|
string Type,
|
||||||
|
string Message,
|
||||||
|
DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
@@ -40,14 +40,14 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
|
|||||||
{
|
{
|
||||||
status = e.Value.Status.ToString(),
|
status = e.Value.Status.ToString(),
|
||||||
description = e.Value.Description,
|
description = e.Value.Description,
|
||||||
data = e.Value.Data
|
data = (IReadOnlyDictionary<string, object?>)e.Value.Data
|
||||||
});
|
});
|
||||||
|
|
||||||
entries["runtime"] = new
|
entries["runtime"] = new
|
||||||
{
|
{
|
||||||
status = runtimeStatus,
|
status = runtimeStatus,
|
||||||
description = runtimeDetail ?? "Runtime status checked",
|
description = runtimeDetail,
|
||||||
data = (IReadOnlyDictionary<string, object>)new Dictionary<string, object>()
|
data = (IReadOnlyDictionary<string, object?>)new Dictionary<string, object?>()
|
||||||
};
|
};
|
||||||
|
|
||||||
var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online";
|
var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online";
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ public class NotificationsController(INotificationService notificationService) :
|
|||||||
return Ok(new UnreadCountDto(count));
|
return Ok(new UnreadCountDto(count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("snapshot")]
|
||||||
|
public async Task<ActionResult<NotificationSnapshotDto>> GetSnapshot(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
[FromQuery] int limit = 50,
|
||||||
|
[FromQuery] bool unreadOnly = false,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return Ok(await notificationService.GetSnapshotAsync(forUser, limit, unreadOnly, ct));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}/read")]
|
[HttpPatch("{id:guid}/read")]
|
||||||
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
|
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/projects")]
|
[Route("api/v1/projects")]
|
||||||
public class ProjectsController(IProjectService projectService) : ControllerBase
|
public class ProjectsController(IProjectService projectService) : ControllerBase
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ using Nexus.Api.Services;
|
|||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/tasks")]
|
[Route("api/v1/tasks")]
|
||||||
public class TasksController(ITaskService taskService) : ControllerBase
|
public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
@@ -111,21 +112,57 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
|
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
|
||||||
/// Wird vom Iris Autonomous Worker genutzt.
|
/// Wird vom Iris Autonomous Worker genutzt.
|
||||||
|
///
|
||||||
|
/// SICHERHEIT: Erfordert X-Agent-Id Header (bel. erkannter Agent) ODER
|
||||||
|
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
|
||||||
|
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpGet("board")]
|
[HttpGet("board")]
|
||||||
public async Task<IResult> GetBoard(CancellationToken ct)
|
public async Task<IResult> GetBoard(CancellationToken ct)
|
||||||
=> Results.Ok(await taskService.GetBoardAsync(ct));
|
{
|
||||||
|
// Erfordert mindestens einen identifizierbaren Agent-Aufrufer
|
||||||
|
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
||||||
|
var isApiKey = HttpContext.User.IsInRole("Service");
|
||||||
|
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
return Results.Ok(await taskService.GetBoardAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Setzt stale Tasks (InProgress, älter als N Stunden) zurück auf Backlog.
|
/// Setzt stale Tasks (InProgress, älter als N Stunden) zurück auf Backlog.
|
||||||
/// Wird vom Iris Autonomous Worker genutzt.
|
/// Wird vom Iris Autonomous Worker genutzt.
|
||||||
|
///
|
||||||
|
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
|
||||||
|
/// X-Nexus-Api-Key / JWT-authenticated user.
|
||||||
|
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[AllowAnonymous]
|
|
||||||
[HttpPost("reset-stale")]
|
[HttpPost("reset-stale")]
|
||||||
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
|
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
||||||
|
var isApiKey = HttpContext.User.IsInRole("Service");
|
||||||
|
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
||||||
|
|
||||||
|
// Nur iris, nexus-system (ApiKey) oder JWT-authenticated user
|
||||||
|
var isIris = string.Equals(agentHeader, "iris", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!isIris && !isApiKey && !isAuth)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
||||||
return Results.Ok(new ResetStaleResponse(count));
|
return Results.Ok(new ResetStaleResponse(count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> GetAllowedAgentHeaderAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var headerValue = HttpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||||
|
if (string.IsNullOrWhiteSpace(headerValue))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normalized = headerValue.Trim().ToLowerInvariant();
|
||||||
|
var allowed = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||||
|
return allowed.Contains(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,9 +216,13 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<IMemoryService, MemoryService>();
|
services.AddSingleton<IMemoryService, MemoryService>();
|
||||||
services.AddSingleton<IIncidentService, IncidentService>();
|
services.AddSingleton<IIncidentService, IncidentService>();
|
||||||
services.AddSingleton<IDocService, DocService>();
|
services.AddSingleton<IDocService, DocService>();
|
||||||
|
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
|
||||||
services.AddScoped<INotificationService, NotificationService>();
|
services.AddScoped<INotificationService, NotificationService>();
|
||||||
services.AddScoped<ICalendarService, CalendarService>();
|
services.AddScoped<ICalendarService, CalendarService>();
|
||||||
|
|
||||||
|
// ── Backend Bridge (Agent-Command-Service) ──
|
||||||
|
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ public sealed record DashboardTaskDto(
|
|||||||
bool IsAgentTask = false,
|
bool IsAgentTask = false,
|
||||||
string? ExpectedFrom = null,
|
string? ExpectedFrom = null,
|
||||||
string? LastActivityMessage = null,
|
string? LastActivityMessage = null,
|
||||||
DateTimeOffset? LastActivityAt = null
|
DateTimeOffset? LastActivityAt = null,
|
||||||
|
List<DashboardTaskDto>? ChildTasks = null,
|
||||||
|
int ChildTaskCount = 0,
|
||||||
|
int OpenChildTaskCount = 0,
|
||||||
|
bool HasVisibleDelegation = false
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record CreateDashboardTaskRequest(
|
public sealed record CreateDashboardTaskRequest(
|
||||||
@@ -182,3 +186,34 @@ public sealed record NotificationDto(
|
|||||||
);
|
);
|
||||||
|
|
||||||
public sealed record UnreadCountDto(int Count);
|
public sealed record UnreadCountDto(int Count);
|
||||||
|
|
||||||
|
public sealed record LiveUpdateEnvelope(
|
||||||
|
string Type,
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
object Payload,
|
||||||
|
long Sequence,
|
||||||
|
string Channel
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record LiveCursorDto(
|
||||||
|
long Sequence,
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
string Mode
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record NotificationSnapshotDto(
|
||||||
|
List<NotificationDto> Notifications,
|
||||||
|
int UnreadCount,
|
||||||
|
string ForUser
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record DashboardLiveSnapshotDto(
|
||||||
|
BoardResponse Board,
|
||||||
|
NotificationSnapshotDto Notifications,
|
||||||
|
LiveCursorDto Cursor
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record DashboardLiveEventDto(
|
||||||
|
LiveUpdateEnvelope Envelope,
|
||||||
|
LiveCursorDto Cursor
|
||||||
|
);
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public interface IAgentService
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
|
||||||
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
|
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
|
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
|
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"iris" => "Orchestrator",
|
"iris" => "Orchestrator",
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -10,4 +10,5 @@ public interface INotificationService
|
|||||||
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
|
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
|
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
|
||||||
Task<int> GetUnreadCountAsync(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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ using Nexus.Api.Models;
|
|||||||
|
|
||||||
namespace Nexus.Api.Services;
|
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)
|
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);
|
db.Notifications.Add(notification);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
await PublishSnapshotAsync(notification.ForUser, ct);
|
||||||
return notification;
|
return notification;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,14 +43,17 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
|
|||||||
|
|
||||||
notification.IsRead = true;
|
notification.IsRead = true;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
await PublishSnapshotAsync(notification.ForUser, ct);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
|
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
var normalizedUser = forUser.ToLowerInvariant();
|
||||||
var count = await db.Notifications
|
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);
|
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
|
||||||
|
await PublishSnapshotAsync(normalizedUser, ct);
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,4 +62,25 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
|
|||||||
return await db.Notifications
|
return await db.Notifications
|
||||||
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
|
.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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -9,7 +9,8 @@ public sealed class TaskService(
|
|||||||
ITaskRepository taskRepo,
|
ITaskRepository taskRepo,
|
||||||
IActivityRepository activityRepo,
|
IActivityRepository activityRepo,
|
||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
IHttpContextAccessor httpContextAccessor) : ITaskService
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
ILiveUpdateService liveUpdateService) : ITaskService
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> ValidAssignees =
|
private static readonly HashSet<string> ValidAssignees =
|
||||||
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
|
["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)
|
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;
|
if (task is null) return null;
|
||||||
|
|
||||||
var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct);
|
var activity = await activityRepo.GetRecentForTasksAsync(allTasks.Select(t => t.Id), ct);
|
||||||
return MapToDtoWithActivity(task, activity);
|
return MapToDtoWithChildren(task, allTasks, activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||||
@@ -42,6 +44,7 @@ public sealed class TaskService(
|
|||||||
};
|
};
|
||||||
await taskRepo.AddAsync(task, ct);
|
await taskRepo.AddAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ public sealed class TaskService(
|
|||||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, 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);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +74,7 @@ public sealed class TaskService(
|
|||||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
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 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);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +86,6 @@ public sealed class TaskService(
|
|||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
// Enforce workflow rules
|
|
||||||
var caller = ResolveCaller();
|
var caller = ResolveCaller();
|
||||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
@@ -90,6 +94,7 @@ public sealed class TaskService(
|
|||||||
await taskRepo.UpdateAsync(task, ct);
|
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 activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
|
||||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,13 +117,14 @@ public sealed class TaskService(
|
|||||||
}
|
}
|
||||||
if (request.ProjectId.HasValue)
|
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;
|
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
|
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 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);
|
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 activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
|
||||||
await taskRepo.DeleteAsync(task, ct);
|
await taskRepo.DeleteAsync(task, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success);
|
return new TaskOperationResult(TaskOperationOutcome.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dashboard-facing operations ──
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var all = await taskRepo.GetAllAsync(ct);
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
@@ -145,10 +150,6 @@ public sealed class TaskService(
|
|||||||
.ToList();
|
.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)
|
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var all = await taskRepo.GetAllAsync(ct);
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
@@ -159,22 +160,15 @@ public sealed class TaskService(
|
|||||||
.ToList();
|
.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)
|
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 threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||||
|
|
||||||
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
|
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
|
||||||
|
|
||||||
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
|
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
|
||||||
|
|
||||||
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
|
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
|
var waitingForBao = map(agentTasks
|
||||||
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
|
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
|
||||||
@@ -193,19 +187,15 @@ public sealed class TaskService(
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
var staleTasks = map(agentTasks
|
var staleTasks = map(agentTasks
|
||||||
.Where(t =>
|
.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold));
|
||||||
string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
t.UpdatedAt < threshold));
|
|
||||||
|
|
||||||
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
|
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, staleTasks, staleThreshold);
|
||||||
staleTasks, staleThreshold);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkTask> CreateDashboardTaskAsync(
|
public async Task<WorkTask> CreateDashboardTaskAsync(
|
||||||
string title, string? detail, string? source, string? priority,
|
string title, string? detail, string? source, string? priority,
|
||||||
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
|
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Validate parent task exists if specified
|
|
||||||
if (parentTaskId.HasValue)
|
if (parentTaskId.HasValue)
|
||||||
{
|
{
|
||||||
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
|
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 normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
|
||||||
var normalizedAssignee = ValidateAssignedTo(assignedTo);
|
var normalizedAssignee = ValidateAssignedTo(assignedTo);
|
||||||
|
var isVisibleDelegation = parentTaskId.HasValue;
|
||||||
|
|
||||||
var task = new WorkTask
|
var task = new WorkTask
|
||||||
{
|
{
|
||||||
@@ -224,16 +215,24 @@ public sealed class TaskService(
|
|||||||
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
|
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
|
||||||
AssignedTo = normalizedAssignee,
|
AssignedTo = normalizedAssignee,
|
||||||
ParentTaskId = parentTaskId,
|
ParentTaskId = parentTaskId,
|
||||||
IsAgentTask = parentTaskId.HasValue
|
IsAgentTask = isVisibleDelegation
|
||||||
};
|
};
|
||||||
await taskRepo.AddAsync(task, ct);
|
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)
|
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))
|
if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await notificationService.CreateAsync(
|
await notificationService.CreateAsync(
|
||||||
@@ -245,6 +244,7 @@ public sealed class TaskService(
|
|||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,13 +252,12 @@ public sealed class TaskService(
|
|||||||
string title, string? detail, string? source, string? priority,
|
string title, string? detail, string? source, string? priority,
|
||||||
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
|
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);
|
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
|
||||||
|
|
||||||
task.IsAgentTask = true;
|
task.IsAgentTask = true;
|
||||||
task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
|
task.ExpectedFrom = normalizedExpectedFrom;
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
|
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||||
|
|
||||||
// Persist the agent-task-specific fields
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
@@ -268,7 +267,16 @@ public sealed class TaskService(
|
|||||||
TaskId = task.Id
|
TaskId = task.Id
|
||||||
}, ct);
|
}, 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(
|
await notificationService.CreateAsync(
|
||||||
"agent_task_created",
|
"agent_task_created",
|
||||||
$"Neuer Agent-Task: {task.Title}",
|
$"Neuer Agent-Task: {task.Title}",
|
||||||
@@ -277,6 +285,7 @@ public sealed class TaskService(
|
|||||||
task.Id,
|
task.Id,
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,13 +329,10 @@ public sealed class TaskService(
|
|||||||
task.AssignedTo = validated;
|
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);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
@@ -339,18 +345,18 @@ public sealed class TaskService(
|
|||||||
TaskId = task.Id
|
TaskId = task.Id
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
// Notification: wenn Bao die Task geändert hat, Iris benachrichtigen
|
|
||||||
if (changes.Count > 0 && caller == "bao")
|
if (changes.Count > 0 && caller == "bao")
|
||||||
{
|
{
|
||||||
await notificationService.CreateAsync(
|
await notificationService.CreateAsync(
|
||||||
"task_content_changed",
|
"task_content_changed",
|
||||||
$"Bao hat \"{task.Title}\" geändert",
|
$"Bao hat \"{task.Title}\" geändert",
|
||||||
$"{changeSummary}",
|
changeSummary,
|
||||||
"iris",
|
"iris",
|
||||||
task.Id,
|
task.Id,
|
||||||
ct);
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +368,6 @@ public sealed class TaskService(
|
|||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
// Enforce workflow rules
|
|
||||||
var caller = ResolveCaller();
|
var caller = ResolveCaller();
|
||||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
@@ -372,6 +377,7 @@ public sealed class TaskService(
|
|||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
|
||||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +389,7 @@ public sealed class TaskService(
|
|||||||
task.State = "Done";
|
task.State = "Done";
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, 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);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,14 +408,15 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, 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);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Board operations ──
|
|
||||||
|
|
||||||
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
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 offen = new List<DashboardTaskDto>();
|
||||||
var inProgress = new List<DashboardTaskDto>();
|
var inProgress = new List<DashboardTaskDto>();
|
||||||
var review = new List<DashboardTaskDto>();
|
var review = new List<DashboardTaskDto>();
|
||||||
@@ -417,21 +425,15 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
foreach (var task in all)
|
foreach (var task in all)
|
||||||
{
|
{
|
||||||
var dto = MapToDto(task);
|
var dto = MapToDtoWithChildren(task, all, activity);
|
||||||
switch (task.State.ToLowerInvariant())
|
switch (task.State.ToLowerInvariant())
|
||||||
{
|
{
|
||||||
case "backlog":
|
case "backlog": offen.Add(dto); break;
|
||||||
offen.Add(dto); break;
|
case "in progress": inProgress.Add(dto); break;
|
||||||
case "in progress":
|
case "review": review.Add(dto); break;
|
||||||
inProgress.Add(dto); break;
|
case "blocked": blocked.Add(dto); break;
|
||||||
case "review":
|
case "done": done.Add(dto); break;
|
||||||
review.Add(dto); break;
|
default: offen.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);
|
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)
|
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
|
||||||
{
|
{
|
||||||
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
|
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)
|
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))
|
||||||
var canonical = TaskStateHelper.AllStates
|
?? TaskStateHelper.BoardGroupToState(newState);
|
||||||
.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (canonical is null)
|
|
||||||
{
|
|
||||||
// Try mapping from board group key
|
|
||||||
canonical = TaskStateHelper.BoardGroupToState(newState);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canonical is null)
|
if (canonical is null)
|
||||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
// Enforce workflow rules
|
|
||||||
var caller = ResolveCaller();
|
var caller = ResolveCaller();
|
||||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
@@ -486,6 +485,7 @@ public sealed class TaskService(
|
|||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, 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 CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,9 +499,7 @@ public sealed class TaskService(
|
|||||||
{
|
{
|
||||||
var all = await taskRepo.GetAllAsync(ct);
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||||
var staleTasks = all.Where(t =>
|
var staleTasks = all.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold).ToList();
|
||||||
string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
|
|
||||||
t.UpdatedAt < threshold).ToList();
|
|
||||||
|
|
||||||
foreach (var task in staleTasks)
|
foreach (var task in staleTasks)
|
||||||
{
|
{
|
||||||
@@ -516,6 +514,9 @@ public sealed class TaskService(
|
|||||||
}, ct);
|
}, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (staleTasks.Count > 0)
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
|
|
||||||
return staleTasks.Count;
|
return staleTasks.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,12 +534,31 @@ public sealed class TaskService(
|
|||||||
return all.Where(e => e.TaskId == taskId).ToList();
|
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(
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||||
t.IsAgentTask, t.ExpectedFrom);
|
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
|
var last = activity
|
||||||
.Where(e => e.TaskId == t.Id)
|
.Where(e => e.TaskId == t.Id)
|
||||||
@@ -550,13 +570,13 @@ public sealed class TaskService(
|
|||||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||||
t.IsAgentTask, t.ExpectedFrom,
|
t.IsAgentTask, t.ExpectedFrom,
|
||||||
last?.Message,
|
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)
|
private static string? ValidateAssignedTo(string? assignedTo)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
||||||
@@ -564,15 +584,10 @@ public sealed class TaskService(
|
|||||||
return ValidAssignees.Contains(lower) ? lower : null;
|
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()
|
private string ResolveCaller()
|
||||||
{
|
{
|
||||||
var httpContext = httpContextAccessor.HttpContext;
|
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();
|
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||||
if (!string.IsNullOrWhiteSpace(agentHeader))
|
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||||
@@ -583,12 +598,6 @@ public sealed class TaskService(
|
|||||||
return nameClaim?.ToLowerInvariant() ?? "";
|
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)
|
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var caller = ResolveCaller();
|
var caller = ResolveCaller();
|
||||||
@@ -615,7 +624,6 @@ public sealed class TaskService(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Allgemeine Statusänderung: Gegenüber benachrichtigen
|
|
||||||
if (caller == "bao")
|
if (caller == "bao")
|
||||||
{
|
{
|
||||||
await notificationService.CreateAsync(
|
await notificationService.CreateAsync(
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
# Nexus Board-First Orchestration & Sichere OpenClaw-Integration
|
||||||
|
|
||||||
|
> Gegenprüfung: Architekt, 2026-06-22
|
||||||
|
> Gegenstand: Sicherster Pfad für Board-first-Agent-Orchestrierung und MCP-artige/strukturierte
|
||||||
|
> OpenClaw-Integration im Nexus-Backend
|
||||||
|
> Kein Frontend-direkter MCP-Pfad. Kein Deploy.
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
### 1.1 Prüfergebnis
|
||||||
|
|
||||||
|
Der eingeschlagene Pfad ist **architektonisch korrekt und sicher**. Das Board-first-Modell mit
|
||||||
|
Nexus-Backend als zentraler Brücke zwischen Benutzer, Board und OpenClaw-Gateway ist der richtige
|
||||||
|
Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren.
|
||||||
|
|
||||||
|
### 1.2 Kernbewertung
|
||||||
|
|
||||||
|
| Aspekt | Status | Bewertung |
|
||||||
|
|--------|--------|-----------|
|
||||||
|
| Board-first Architektur | ✅ Umsetzung läuft | Parent/Child-Modell korrekt implementiert |
|
||||||
|
| Kein Frontend-direkter Gateway-Zugriff | ✅ Eingehalten | Frontend spricht NUR mit Nexus-Backend |
|
||||||
|
| Backend als sichere Brücke | ✅ Eingehalten | API-Container proxyt alle Gateway-Calls |
|
||||||
|
| Auth-/Rechte-Modell | ✅ Solide | JWT + ApiKey + X-Agent-Id Enforcement |
|
||||||
|
| Gateway-Security | ⚠️ Verbesserbar | `loopback`-Bind muss auf `lan` für Docker |
|
||||||
|
| Migration-Reihenfolge | 📋 Vorgeschlagen | (siehe Abschnitt 8) |
|
||||||
|
|
||||||
|
## 2. Ist-Architektur: Wer spricht mit wem?
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser (Bao/Iris) │
|
||||||
|
│ auth.ts → JWT Access Token (15m) + HttpOnly Refresh Cookie │
|
||||||
|
│ api.ts → fetch /api/v1/* → Authorization: Bearer <JWT> │
|
||||||
|
└───────────────────────────┬──────────────────────────────────────┘
|
||||||
|
│ HTTPS :443 (Traefik/npm)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Nexus web (nginx container) │
|
||||||
|
│ location /api/ → proxy_pass http://api:8080 │
|
||||||
|
│ location / → SPA (index.html) │
|
||||||
|
│ CSP: connect-src 'self' — kein externer Gateway-Call möglich │
|
||||||
|
└───────────────────────────┬──────────────────────────────────────┘
|
||||||
|
│ HTTP :8080 (internal network)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Nexus API (.NET 10 Container) ← SICHERE BRÜCKE │
|
||||||
|
│ │
|
||||||
|
│ Auth-Middleware (JWT → Controller) │
|
||||||
|
│ ApiKey-Middleware (X-Nexus-Api-Key → Role=Service) │
|
||||||
|
│ SecurityHeaders-Middleware (HSTS, CSP, XFO) │
|
||||||
|
│ Rate Limiter (auth: 5/min/caller, agents: 30/min/caller) │
|
||||||
|
│ │
|
||||||
|
│ IOpenClawGatewayClient │
|
||||||
|
│ ├─ InvokeToolAsync("session_status", ...) │
|
||||||
|
│ ├─ InvokeToolAsync("sessions_list", ...) │
|
||||||
|
│ ├─ InvokeToolAsync("sessions_history", ...) │
|
||||||
|
│ ├─ InvokeToolAsync("memory_search", ...) │
|
||||||
|
│ ├─ InvokeToolAsync("sessions_send", ...) │
|
||||||
|
│ └─ InvokeToolAsync("cron", ...) │
|
||||||
|
│ │
|
||||||
|
│ GatewayBridgeController (/api/bridge/*) <- NEU (2026-06-22) │
|
||||||
|
│ └─ ITaskBridgeService │
|
||||||
|
│ create_task / create_child_task / update_status / │
|
||||||
|
│ append_activity / handoff / get_board / get_agent_overview │
|
||||||
|
│ │
|
||||||
|
│ IAgentRuntime (OpenClawRuntime) │
|
||||||
|
│ └─ POST /v1/chat/completions (OpenAI-compat) │
|
||||||
|
│ │
|
||||||
|
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
|
||||||
|
└──────────────┬──────────────────────────────┬────────────────────┘
|
||||||
|
│ host.docker.internal:18789 │
|
||||||
|
│ (Gateway loopback/lan) │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────────────────┐ │
|
||||||
|
│ OpenClaw Gateway Container │ │
|
||||||
|
│ Port 18789 │ │
|
||||||
|
│ Auth: password │ │
|
||||||
|
│ Bind: loopback (127.0.0.1) │ │
|
||||||
|
│ │ │
|
||||||
|
│ HTTP Deny-List (default): │ │
|
||||||
|
│ exec, spawn, shell, │ │
|
||||||
|
│ fs_write, fs_delete, │ │
|
||||||
|
│ fs_move, apply_patch, │ │
|
||||||
|
│ sessions_spawn, sessions_send│ │
|
||||||
|
│ cron, gateway, nodes, │ │
|
||||||
|
│ whatsapp_login │ │
|
||||||
|
└───────────────────────────────┴──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 Wichtig: Das Frontend sieht den Gateway NICHT
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ─── [Nexus API] ─── Gateway
|
||||||
|
│
|
||||||
|
+─ Gateway-Passwort lebt NUR im Backend
|
||||||
|
+─ CSP: connect-src 'self' blockiert jeden Direktzugriff
|
||||||
|
+─ Gateway HTTP Deny-List blockiert alle RCE-Tools
|
||||||
|
```
|
||||||
|
|
||||||
|
**Das ist die korrekte Architektur.** Kein Frontend-komponenten-direkter MCP-Pfad existiert
|
||||||
|
und keiner sollte eingeführt werden.
|
||||||
|
|
||||||
|
## 3. Board-First Orchestrierung: Ist-Stand & Bewertung
|
||||||
|
|
||||||
|
### 3.1 Das Parent/Child-Task-Modell (Phase 3)
|
||||||
|
|
||||||
|
```
|
||||||
|
Parent-Task (Owner: Iris)
|
||||||
|
├── Child-Task A (AssignedTo: programmer)
|
||||||
|
├── Child-Task B (AssignedTo: reviewer)
|
||||||
|
└── Child-Task C (AssignedTo: architekt)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status:** ✅ Implementiert (2026-06-21)
|
||||||
|
|
||||||
|
**Datenmodell (WorkTask):**
|
||||||
|
- `ParentTaskId` (Guid?) — verknüpft Child mit Parent
|
||||||
|
- `IsAgentTask` (bool) — markiert programmatisch erstellte Agent-Tasks
|
||||||
|
- `ExpectedFrom` (string?) — wer als nächstes antworten soll
|
||||||
|
- `AssignedTo` (string?) — operativer Owner der Task
|
||||||
|
|
||||||
|
**State Machine:**
|
||||||
|
```
|
||||||
|
Parent: Backlog → InProgress → Review → Done
|
||||||
|
↘ Blocked → Backlog
|
||||||
|
|
||||||
|
Child: Backlog → InProgress → Done
|
||||||
|
↘ Blocked → Backlog
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules (aus TaskStateHelper.CanChangeState):**
|
||||||
|
- **Nur Iris & Bao** dürfen State-Änderungen vornehmen
|
||||||
|
- Sub-Agenten (programmer, reviewer, architekt, researcher, executor) **niemals**
|
||||||
|
- `nexus-system` als technischer Fallback für Cron/Reset-Stale
|
||||||
|
|
||||||
|
### 3.2 Bewertung: Architektonisch korrekt
|
||||||
|
|
||||||
|
| Kriterium | Bewertung | Begründung |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| Board als Single Source of Truth | ✅ | Task Board = sichtbare Aufgabenwahrheit |
|
||||||
|
| Delegation sichtbar | ✅ | Child-Tasks statt unsichtbarem Delegations-Status |
|
||||||
|
| Feingliedrige Berechtigung | ✅ | State-Change nur durch Iris/Bao |
|
||||||
|
| Agenten arbeiten gegen Child-Tasks | ✅ | Klare Ownership durch `AssignedTo` |
|
||||||
|
| Parent bleibt bei Iris | ✅ | Parent in `InProgress` während Koordination |
|
||||||
|
| Review-Gate für Bao | ✅ | Parent erst in `Review`, dann Bao-Entscheidung |
|
||||||
|
|
||||||
|
### 3.3 Offene Punkte im Board-Modell
|
||||||
|
|
||||||
|
1. **Keine automatische Child-Task-Erstellung** — Das Board-Modell setzt voraus, dass Iris manuell
|
||||||
|
Child-Tasks anlegt. Ein strukturierter Workflow für automatische Child-Task-Erstellung bei
|
||||||
|
`spawn`/Subagent-Aufrufen fehlt.
|
||||||
|
|
||||||
|
2. **Keine Task→Session-Verknüpfung** — Es gibt keine direkte Verknüpfung zwischen einer
|
||||||
|
Child-Task und der OpenClaw-Subagent-Session, die sie bearbeitet. Der `AgentService` kennt
|
||||||
|
Sessions, `TaskService` kennt Tasks — aber sie sind nicht verknüpft.
|
||||||
|
|
||||||
|
3. **Reset-Stale ist ungeschützt** — `POST /api/v1/tasks/reset-stale` hat `[AllowAnonymous]`
|
||||||
|
und kann von jedem aufgerufen werden (siehe Risiko #1).
|
||||||
|
|
||||||
|
## 4. Auth- und Rechte-Modell
|
||||||
|
|
||||||
|
### 4.1 Authentifizierungsebenen
|
||||||
|
|
||||||
|
```
|
||||||
|
Ebene 1: Browser-JWT (Access Token 15m, Refresh Token HttpOnly Cookie)
|
||||||
|
Ebene 2: X-Nexus-Api-Key (Service-zu-Service, Role=Service)
|
||||||
|
Ebene 3: Gateway-Password (Backend → Gateway, Bearer Authorization)
|
||||||
|
Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Berechtigungsmatrix
|
||||||
|
|
||||||
|
| Aktion | Bao | Iris | Sub-Agent | nexus-system | Service (ApiKey) |
|
||||||
|
|--------|-----|------|-----------|--------------|-------------------|
|
||||||
|
| Task State ändern | ✅ | ✅ | ❌ | ✅ (intern) | ❌ |
|
||||||
|
| Task Inhalt editieren | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Agent-Config lesen | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||||
|
| Gateway-Tool aufrufen | ❌ | ❌ | ❌ | ❌ | ✅ (intern) |
|
||||||
|
| Dashboard-Metriken sehen | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
### 4.3 Bewertung
|
||||||
|
|
||||||
|
**Positiv:**
|
||||||
|
- JWT-Sicherheit entspricht Best Practices (PBKDF2-SHA256, 210k Iterationen, Rotating Refresh Tokens)
|
||||||
|
- Refresh-Token-Reuse-Detection verhindert Token-Theft
|
||||||
|
- Rate-Limiting auf Login und Refresh
|
||||||
|
- CSRF-Protection via `X-CSRF-TOKEN` + `nexus-csrf` Cookie
|
||||||
|
- Security Headers (HSTS, CSP, XFO, Referrer-Policy)
|
||||||
|
|
||||||
|
**Kritisch:**
|
||||||
|
- `[AllowAnonymous]` auf `/tasks/board` und `/tasks/reset-stale` — siehe Risiko-Analyse
|
||||||
|
- Kein Scoped-ApiKey — der `X-Nexus-Api-Key` gibt volle Service-Rechte
|
||||||
|
- Keine Audit-Protokollierung für ApiKey-Nutzung
|
||||||
|
|
||||||
|
## 5. Gateway-Integration: Sicherheitsanalyse
|
||||||
|
|
||||||
|
### 5.1 Tool-Invoke-Pfad (Ist-Stand)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/operations/snapshot
|
||||||
|
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
|
||||||
|
→ POST http://host.docker.internal:18789/tools/invoke
|
||||||
|
Authorization: Bearer <Gateway-Password>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aufgerufene Tools (durch Nexus-Backend):**
|
||||||
|
- `session_status` — Agent-Status abfragen (read-only)
|
||||||
|
- `sessions_list` — Session-Liste (read-only)
|
||||||
|
- `sessions_history` — Chat-Verlauf (read-only)
|
||||||
|
- `memory_search` — Memory-Suche (read-only)
|
||||||
|
- `sessions_send` — Chat-Nachricht senden (write, aber kontrolliert)
|
||||||
|
- `cron` — Cron-Jobs verwalten (write)
|
||||||
|
|
||||||
|
**Gateway HTTP Deny-List blockiert:**
|
||||||
|
- Alle Exec-Tools (exec, spawn, shell)
|
||||||
|
- Alle Filesystem-Tools (fs_write, fs_delete, fs_move, apply_patch)
|
||||||
|
- Gateway-Control-Plane (gateway)
|
||||||
|
- Node-Relay (nodes)
|
||||||
|
- Session-Orchestrierung (sessions_spawn, sessions_send)
|
||||||
|
|
||||||
|
### 5.2 Docker-Netzwerk & Gateway-Bind
|
||||||
|
|
||||||
|
**Aktuelles Problem:**
|
||||||
|
```
|
||||||
|
compose.yaml:
|
||||||
|
api:
|
||||||
|
extra_hosts:
|
||||||
|
- host.docker.internal:host-gateway
|
||||||
|
networks:
|
||||||
|
- nexus
|
||||||
|
- openclaw_default ← API-Container ist im Gateway-Netzwerk
|
||||||
|
|
||||||
|
Gateway-Konfiguration:
|
||||||
|
gateway.bind: "loopback" ← Bindet nur 127.0.0.1 IM GATEWAY-CONTAINER
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ergebnis:**
|
||||||
|
- `host.docker.internal:18789` funktioniert, weil `extra_hosts` auf den Docker-Host zeigt
|
||||||
|
- ABER: Docker-Port-Forward (wenn vorhanden) sendet an Container-IP, nicht loopback
|
||||||
|
- Die `openclaw_default` Netzwerk-Mitgliedschaft des API-Containers wird NICHT genutzt
|
||||||
|
|
||||||
|
**Empfehlung (siehe gateway-api-research.md, Abschnitt 6):**
|
||||||
|
```json5
|
||||||
|
// openclaw.json
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "lan" // war "loopback"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ: API-Container über Gateway-Container-Namen ansprechen:
|
||||||
|
```yaml
|
||||||
|
Integrations__OpenClaw__BaseUrl: http://openclaw_gateway:18789
|
||||||
|
```
|
||||||
|
(Vorausgesetzt der Gateway-Container heißt `openclaw_gateway` und ist im `openclaw_default` Netzwerk)
|
||||||
|
|
||||||
|
### 5.3 MCP-artige Integration: Bewertung
|
||||||
|
|
||||||
|
**Das Gateway `/tools/invoke` ist bereits MCP-artig:**
|
||||||
|
- JSON-RPC-ähnliche Aufrufe mit `tool` + `args` + `sessionKey`
|
||||||
|
- Strukturierte Responses mit `{ ok, result, error }`
|
||||||
|
- Tool-Discovery via Policy (Deny/Allow-List)
|
||||||
|
- Request/Response mit eindeutiger Fehlersemantik
|
||||||
|
|
||||||
|
**Was fehlt für ein vollständiges MCP-Interface:**
|
||||||
|
- Keine Tool-Listing/Discovery über API (kein `tools/list`)
|
||||||
|
- Keine Schema-Validierung für Tool-Arguments
|
||||||
|
- Keine Structured Outputs (function-calling-ähnliches Format)
|
||||||
|
|
||||||
|
**Empfehlung: NICHT ein MCP-Protokoll zwischen Nexus und Gateway einführen.**
|
||||||
|
Stattdessen den bestehenden `/tools/invoke`-Pfad weiter nutzen und strukturieren.
|
||||||
|
|
||||||
|
### 5.4 Neue Backend-Bridge (Implementiert 2026-06-22)
|
||||||
|
|
||||||
|
Der Nexus-eigene strukturierte Kommando-Adapter wurde eingeführt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nexus Backend
|
||||||
|
├─ GatewayToolClient (bestehender OpenClawGatewayClient)
|
||||||
|
│ └─ POST /tools/invoke (Gateway)
|
||||||
|
│
|
||||||
|
├─ GatewayBridgeController (NEU — /api/bridge/)
|
||||||
|
│ ├─ POST /api/bridge/tasks (create_task)
|
||||||
|
│ ├─ POST /api/bridge/tasks/{id}/children (create_child_task)
|
||||||
|
│ ├─ PATCH /api/bridge/tasks/{id}/status (update_status)
|
||||||
|
│ ├─ POST /api/bridge/tasks/{id}/activity (append_activity)
|
||||||
|
│ ├─ POST /api/bridge/tasks/{id}/handoff (handoff)
|
||||||
|
│ ├─ GET /api/bridge/board (get_board)
|
||||||
|
│ ├─ GET /api/bridge/tasks/{id} (get_task)
|
||||||
|
│ ├─ GET /api/bridge/tasks/{id}/children (get_children)
|
||||||
|
│ ├─ GET /api/bridge/tasks/{id}/activity (get_activity)
|
||||||
|
│ └─ GET /api/bridge/agent-overview (get_agent_overview)
|
||||||
|
│
|
||||||
|
├─ ITaskBridgeService / TaskBridgeService (NEU)
|
||||||
|
│ └─ Typisierte TaskBridgeResult<T> mit Outcome: Success/NotFound/InvalidState/Unauthorized/ValidationError
|
||||||
|
│
|
||||||
|
├─ BoardOrchestrationService (offen)
|
||||||
|
│ ├─ Tasks erstellen/aktualisieren
|
||||||
|
│ ├─ Session-Status abfragen
|
||||||
|
│ └─ Agent-Progress berechnen
|
||||||
|
│
|
||||||
|
└─ AgentDelegationService (offen)
|
||||||
|
├─ Subagent-Task anlegen
|
||||||
|
├─ Session verfolgen
|
||||||
|
└─ Ergebnis integrieren
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth-Modell für /api/bridge:**
|
||||||
|
- Primär: `X-Agent-Id` Header (Agent-Identität vom Gateway)
|
||||||
|
- Fallback: JWT (Browser-authenticated user → bao)
|
||||||
|
- Fallback: `X-Nexus-Api-Key` (Backend-zu-Backend Service-Identität)
|
||||||
|
- Rate-Limiting: 30 Requests/Minute (agents-Policy)
|
||||||
|
|
||||||
|
**Das Frontend sieht /api/bridge NICHT.** Der Pfad ist ausschließlich für Agent-zu-Backend-Kommunikation.
|
||||||
|
|
||||||
|
## 6. Risikoanalyse
|
||||||
|
|
||||||
|
### 6.1 KRITISCH — `[AllowAnonymous]` auf Board-Endpunkten ✅ BEHOBEN (2026-06-22)
|
||||||
|
|
||||||
|
**Betroffen (vorher):**
|
||||||
|
```csharp
|
||||||
|
// TasksController.cs
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("board")] // Gab ALLE Tasks zurück — inkl. Detail-Texte
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("reset-stale")] // Konnte Tasks zurücksetzen — datenändernd
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix angewandt:**
|
||||||
|
- `[AllowAnonymous]` entfernt
|
||||||
|
- Inline-Auth-Check: `X-Agent-Id` Header, ApiKey-Rolle, oder JWT erforderlich
|
||||||
|
- `reset-stale` erfordert zusätzlich `X-Agent-Id: iris` Identität (nur Iris darf)
|
||||||
|
- Neuer `/api/bridge/` Pfad als sauberer Agent-zu-Backend-Adapter
|
||||||
|
- Rate-Limiting (agents-Policy: 30/min) auf Bridge-Endpunkte
|
||||||
|
|
||||||
|
### 6.2 MITTEL — Keine Task→Session-Verknüpfung
|
||||||
|
|
||||||
|
Wenn ein Subagent eine Child-Task bearbeitet, gibt es keine technische Verknüpfung zwischen
|
||||||
|
der Child-Task und der OpenClaw-Session. Das bedeutet:
|
||||||
|
- Keine automatische Status-Aktualisierung bei Session-Abschluss
|
||||||
|
- Keine Sitzungs-Historie direkt von der Task aus erreichbar
|
||||||
|
- Iris muss manuell prüfen, ob ein Agent fertig ist
|
||||||
|
|
||||||
|
**Empfehlung:**
|
||||||
|
- `WorkTask` um `SessionKey` (string?) erweitern
|
||||||
|
- Bei Child-Task-Erstellung Session-Key speichern
|
||||||
|
- Status-Polling: Wenn Session inaktiv, Child-Task auf Done/Blocked prüfen
|
||||||
|
|
||||||
|
### 6.3 MITTEL — Gateway-Passwort in Config-Dateien
|
||||||
|
|
||||||
|
Das Gateway-Passwort `ieDmOjBiVfbbDM0ibrEebPAg` ist:
|
||||||
|
- In `.env` auf dem Host (OK)
|
||||||
|
- In `gateway-api-research.md` (maskiert: `ieDm...PAg`)
|
||||||
|
- Im `openclaw.json` auf dem Host (OK)
|
||||||
|
- In der API-Container-Umgebungsvariable (notwendig)
|
||||||
|
|
||||||
|
**Empfehlung:**
|
||||||
|
- Gateway-Rate-Limiting aktiv halten (10 attempts/60s → 5min lockout)
|
||||||
|
- Gateway-Bind auf `lan` ändern (nicht öffentlich exponiert)
|
||||||
|
- `gateway-api-research.md` aus dem öffentlichen Repo entfernen oder Passwort entfernen
|
||||||
|
|
||||||
|
### 6.4 NIEDRIG — Keine Scoped API-Keys
|
||||||
|
|
||||||
|
Der `X-Nexus-Api-Key` gibt volle Service-Rechte. Es gibt keine Möglichkeit, verschiedene
|
||||||
|
API-Keys mit unterschiedlichen Rechten zu vergeben.
|
||||||
|
|
||||||
|
**Empfehlung (spätere Phase):**
|
||||||
|
- API-Key-Scopes einführen (read, write, admin)
|
||||||
|
- Rate-Limiting pro API-Key
|
||||||
|
- Audit-Log für ApiKey-Nutzung
|
||||||
|
|
||||||
|
### 6.5 NIEDRIG — Kein Structured Output vom Gateway
|
||||||
|
|
||||||
|
Die `/tools/invoke`-Responses sind JSON, aber ohne Schema-Garantie. Die Backend-Logik
|
||||||
|
extrahiert Felder mit vielen Fallbacks (`??`-Kettenantworten).
|
||||||
|
|
||||||
|
**Empfehlung:**
|
||||||
|
- Response-Typen für jedes Tool definieren (DTOs)
|
||||||
|
- Deserialisierung mit Schema-Validierung
|
||||||
|
- Fallback-Logik zentralisieren
|
||||||
|
|
||||||
|
## 7. Migrationsreihenfolge (Vorschlag)
|
||||||
|
|
||||||
|
### Phase 3b — Sichere Board-Grundlage (JETZT)
|
||||||
|
```
|
||||||
|
1. [AllowAnonymous] auf /tasks/board und /tasks/reset-stale fixen
|
||||||
|
→ ApiKey-Auth plus X-Agent-Id-Validierung
|
||||||
|
→ README/Iris-Doku aktualisieren
|
||||||
|
|
||||||
|
2. Gateway-Bind von loopback auf lan ändern
|
||||||
|
→ API-Container über openclaw_default Netzwerk ansprechen
|
||||||
|
→ host.docker.internal-Fallback entfernen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4 — Strukturierte Orchestrierung
|
||||||
|
```
|
||||||
|
3. Task→Session-Verknüpfung einführen
|
||||||
|
→ WorkTask.SessionKey (string?)
|
||||||
|
→ Bei Child-Task-Erstellung Session speichern
|
||||||
|
|
||||||
|
4. AgentDelegationService
|
||||||
|
→ Zentralisierte Subagent-Task-Erstellung
|
||||||
|
→ Session-Status-Monitoring
|
||||||
|
→ Automatische Status-Propagation (Session done → Task done)
|
||||||
|
|
||||||
|
5. BoardOrchestrationService
|
||||||
|
→ Refresh-Intervall für Agent-Progress
|
||||||
|
→ Stale-Erkennung mit Session-Status
|
||||||
|
→ Priorisierung nach Workload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5 — Erweiterte Integration
|
||||||
|
```
|
||||||
|
6. Structured Tool Responses
|
||||||
|
→ DTOs für jedes Gateway-Tool
|
||||||
|
→ Schema-Validierung
|
||||||
|
→ Caching für häufige Abfragen
|
||||||
|
|
||||||
|
7. API-Key-Scopes
|
||||||
|
→ read/write/admin Scopes
|
||||||
|
→ Audit-Log für ApiKey-Nutzung
|
||||||
|
|
||||||
|
8. Automatische Child-Task-Erstellung
|
||||||
|
→ Bei spawn/subagent-Aufrufen automatisch Child-Task anlegen
|
||||||
|
→ Session-Key verknüpfen
|
||||||
|
→ Activity-Feed erweitern
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Sichere Brücke: Architekturprinzipien
|
||||||
|
|
||||||
|
### 8.1 Das Backend ist die einzige Brücke
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ←→ Nexus API ←→ OpenClaw Gateway
|
||||||
|
↑ ↑
|
||||||
|
JWT Auth Gateway Password
|
||||||
|
(pro User) (nur im Backend)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Niemals:**
|
||||||
|
- Gateway-Passwort im Frontend
|
||||||
|
- Direkter Browser→Gateway API-Call
|
||||||
|
- MCP-Protokoll zwischen Frontend und Gateway
|
||||||
|
- Agent-Sessions direkt aus dem Frontend steuern
|
||||||
|
|
||||||
|
### 8.2 Prinzipien für jede neue Integration
|
||||||
|
|
||||||
|
1. **Neue Endpunkte immer im Nexus-Backend**
|
||||||
|
2. **Auth über bestehendes JWT/ApiKey-System**
|
||||||
|
3. **Gateway-Calls immer serverseitig mit Gateway-Passwort**
|
||||||
|
4. **Kein Gateway-Tool direkt aus dem Frontend aufrufen**
|
||||||
|
5. **State-Änderungen nur durch Iris/Bao (via Backend-Enforcement)**
|
||||||
|
6. **Activity/Audit für jede State-Änderung**
|
||||||
|
|
||||||
|
### 8.3 Strukturierte OpenClaw-Integration (MCP-artig)
|
||||||
|
|
||||||
|
Der Gateway `/tools/invoke`-Endpunkt ist bereits strukturell MCP-artig. Eine formale
|
||||||
|
MCP-Implementierung zwischen Nexus und Gateway ist **nicht notwendig**. Stattdessen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nexus.Backend.Services
|
||||||
|
├── IOpenClawGatewayClient (Gateway-Tool-Abstraktion)
|
||||||
|
│ └── InvokeToolAsync(tool, args) → JsonNode?
|
||||||
|
│
|
||||||
|
├── IBoardOrchestrationService (NEU)
|
||||||
|
│ ├── CreateDelegateTask(agentId, title, detail) → WorkTask
|
||||||
|
│ ├── WatchAgentSession(agentId) → SessionWatcher
|
||||||
|
│ └── SyncAgentProgress() → Progress[]
|
||||||
|
│
|
||||||
|
└── IAgentDelegationService (NEU)
|
||||||
|
├── DelegateToAgent(parentTaskId, agentId, instruction)
|
||||||
|
├── CollectResult(subTaskId) → AgentResult
|
||||||
|
└── HandleBlocker(subTaskId, reason) → void
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Zusammenfassung
|
||||||
|
|
||||||
|
### Was gut ist (nicht ändern):
|
||||||
|
- Board-first-Ansatz mit Parent/Child-Tasks
|
||||||
|
- Backend als einzige Gateway-Brücke
|
||||||
|
- JWT + Gateway-Password-Trennung
|
||||||
|
- State-Change-Restriktion auf Iris/Bao
|
||||||
|
- HTTP Deny-List des Gateways
|
||||||
|
- CSP im Frontend (kein externer Connect)
|
||||||
|
|
||||||
|
### Was verbessert werden muss:
|
||||||
|
- `[AllowAnonymous]` auf Board-Endpunkten → ApiKey-Auth
|
||||||
|
- Gateway-Bind loopback → lan (oder Netzwerk-Routing korrigieren)
|
||||||
|
- Task→Session-Verknüpfung fehlt
|
||||||
|
|
||||||
|
### Was später kommen kann:
|
||||||
|
- Automatische Child-Task-Erstellung bei Subagent-Aufrufen
|
||||||
|
- Structured Gateway Responses mit Schema-Validierung
|
||||||
|
- API-Key-Scopes und Audit
|
||||||
|
- Session-gesteuertes Progress-Tracking
|
||||||
|
|
||||||
|
### Was niemals kommen darf:
|
||||||
|
- Gateway-Passwort im Frontend
|
||||||
|
- Direkter MCP-Pfad Browser→Gateway
|
||||||
|
- Agent-Session-Steuerung aus dem Frontend
|
||||||
|
- Task-State-Änderung durch Sub-Agenten
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
# Gateway API Research
|
# Gateway API Research
|
||||||
|
|
||||||
> Generated: 2026-06-10
|
> Generated: 2026-06-10 | Updated: 2026-06-22
|
||||||
> Auth mode: password (not token)
|
> Auth mode: password (not token)
|
||||||
|
>
|
||||||
|
> ⚠️ **Security note:** Diese Datei enthält Infrastruktur-Details zur Gateway-Integration.
|
||||||
|
> Sie gehört nicht ins öffentliche Repository. Bis zur Bereinigung: Gateway-Passwort
|
||||||
|
> maskiert als `ieDm...PAg`. Vollständige Architektur-Analyse in
|
||||||
|
> [`architecture-board-first-orchestration.md`](architecture-board-first-orchestration.md).
|
||||||
|
|
||||||
## 1. Authentication
|
## 1. Authentication
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,37 @@ server {
|
|||||||
add_header Expires "0";
|
add_header Expires "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Bridge-Endpunkte (Agent-zu-Backend): separater Pfad ohne CSP-Einschränkungen
|
||||||
|
# für Gateway-Calls. Kein Caching, kein Buffering.
|
||||||
|
location /api/bridge/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Agent-Id $http_x_agent_id;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dashboard SSE stream: single dedicated non-buffered block.
|
||||||
|
location = /api/dashboard/live {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
add_header X-Accel-Buffering no always;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8080;
|
proxy_pass http://api:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {})
|
|||||||
if (auth.accessToken) headers.set('Authorization', `Bearer ${auth.accessToken}`)
|
if (auth.accessToken) headers.set('Authorization', `Bearer ${auth.accessToken}`)
|
||||||
if (auth.isIris) headers.set('X-Agent-Id', 'iris')
|
if (auth.isIris) headers.set('X-Agent-Id', 'iris')
|
||||||
else if (auth.isBao) headers.set('X-Agent-Id', 'bao')
|
else if (auth.isBao) headers.set('X-Agent-Id', 'bao')
|
||||||
|
// Set Content-Type for JSON body requests — needed because fetch() defaults
|
||||||
|
// to text/plain for string bodies, which ASP.NET rejects for [FromBody] binding.
|
||||||
|
if (typeof init.body === 'string' && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
}
|
||||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
return fetch(input, { ...init, headers, credentials: 'include' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { apiFetch } from './api'
|
||||||
|
|
||||||
|
export type LiveEventName = 'snapshot' | 'update' | 'heartbeat'
|
||||||
|
export type LiveMode = 'live' | 'polling'
|
||||||
|
|
||||||
|
export interface LiveCursorDto {
|
||||||
|
sequence: number
|
||||||
|
timestamp: string
|
||||||
|
mode: LiveMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveUpdateEnvelope {
|
||||||
|
type: string
|
||||||
|
timestamp: string
|
||||||
|
payload: unknown
|
||||||
|
sequence: number
|
||||||
|
channel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardLiveEventDto {
|
||||||
|
envelope: LiveUpdateEnvelope
|
||||||
|
cursor: LiveCursorDto
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenDashboardLiveStreamResult {
|
||||||
|
closed: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDashboardLiveStream(
|
||||||
|
onEvent: (event: LiveEventName, data: unknown) => void,
|
||||||
|
options: { forUser?: string; notificationLimit?: number; afterSequence?: number | null; signal?: AbortSignal } = {},
|
||||||
|
): Promise<OpenDashboardLiveStreamResult> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
forUser: options.forUser ?? 'bao',
|
||||||
|
notificationLimit: String(options.notificationLimit ?? 50),
|
||||||
|
})
|
||||||
|
if (typeof options.afterSequence === 'number' && Number.isFinite(options.afterSequence) && options.afterSequence > 0) {
|
||||||
|
params.set('afterSequence', String(options.afterSequence))
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiFetch(`/api/dashboard/live?${params}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||||
|
signal: options.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Live stream unavailable: HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
const flushBlock = (block: string) => {
|
||||||
|
const lines = block.split('\n')
|
||||||
|
let eventName: LiveEventName = 'update'
|
||||||
|
const dataLines: string[] = []
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trimEnd()
|
||||||
|
if (line.startsWith('event:')) eventName = line.slice(6).trim() as LiveEventName
|
||||||
|
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataLines.length) return
|
||||||
|
try {
|
||||||
|
onEvent(eventName, JSON.parse(dataLines.join('\n')))
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[live] failed to parse event payload', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closed = (async () => {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
const parts = buffer.split('\n\n')
|
||||||
|
buffer = parts.pop() ?? ''
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.trim()) flushBlock(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.trim()) {
|
||||||
|
flushBlock(buffer)
|
||||||
|
buffer = ''
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return { closed }
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { openDashboardLiveStream } from '../services/live'
|
||||||
|
import type { BoardGroup, DashboardTaskDto } from './tasks'
|
||||||
|
import type { NotificationItem } from './notifications'
|
||||||
|
import type { TaskItem } from '../components/dashboard/v2/types'
|
||||||
|
import { useTaskStore } from './tasks'
|
||||||
|
import { useNotificationStore } from './notifications'
|
||||||
|
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
|
||||||
|
|
||||||
|
interface NotificationSnapshotDto {
|
||||||
|
notifications: NotificationItem[]
|
||||||
|
unreadCount: number
|
||||||
|
forUser: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardLiveSnapshotDto {
|
||||||
|
board: BoardGroup
|
||||||
|
notifications: NotificationSnapshotDto
|
||||||
|
cursor: LiveCursorDto
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoardGroup(value: unknown): value is BoardGroup {
|
||||||
|
const v = value as BoardGroup
|
||||||
|
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
|
||||||
|
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
agent: t.assignedTo ?? '—',
|
||||||
|
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
|
||||||
|
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
|
||||||
|
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
|
||||||
|
detail: t.detail,
|
||||||
|
source: t.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLiveSyncStore = defineStore('liveSync', {
|
||||||
|
state: () => ({
|
||||||
|
connected: false,
|
||||||
|
connecting: false,
|
||||||
|
lastEventAt: null as string | null,
|
||||||
|
lastHeartbeatAt: null as string | null,
|
||||||
|
error: null as string | null,
|
||||||
|
controller: null as AbortController | null,
|
||||||
|
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
|
mode: 'polling' as 'polling' | 'live',
|
||||||
|
lastSequence: 0,
|
||||||
|
reconnectAttempts: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
liveIndicatorLabel: (state) => {
|
||||||
|
if (state.connecting) return 'Verbinde…'
|
||||||
|
if (state.connected) return `Live · #${state.lastSequence}`
|
||||||
|
return state.mode === 'polling' ? 'Polling' : 'Offline'
|
||||||
|
},
|
||||||
|
connectionHealth: (state) => {
|
||||||
|
if (state.connected) return 'healthy'
|
||||||
|
if (state.connecting) return 'connecting'
|
||||||
|
return 'degraded'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async connect(forUser = 'bao') {
|
||||||
|
if (this.connecting || this.connected) return
|
||||||
|
this.connecting = true
|
||||||
|
this.error = null
|
||||||
|
this.controller = new AbortController()
|
||||||
|
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await openDashboardLiveStream((event, data) => {
|
||||||
|
this.lastEventAt = new Date().toISOString()
|
||||||
|
|
||||||
|
if (event === 'heartbeat') {
|
||||||
|
const cursor = data as LiveCursorDto
|
||||||
|
this.lastHeartbeatAt = cursor.timestamp
|
||||||
|
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'snapshot') {
|
||||||
|
const snapshot = data as DashboardLiveSnapshotDto
|
||||||
|
taskStore.board = snapshot.board
|
||||||
|
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
|
||||||
|
notificationStore.notifications = snapshot.notifications.notifications
|
||||||
|
notificationStore.unreadCount = snapshot.notifications.unreadCount
|
||||||
|
this.lastSequence = snapshot.cursor.sequence
|
||||||
|
this.connected = true
|
||||||
|
this.mode = 'live'
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
taskStore.stopBoardPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventDto = data as DashboardLiveEventDto
|
||||||
|
this.applyEnvelope(eventDto.envelope, forUser)
|
||||||
|
this.lastSequence = eventDto.cursor.sequence
|
||||||
|
this.connected = true
|
||||||
|
this.mode = 'live'
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
taskStore.stopBoardPolling()
|
||||||
|
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||||
|
|
||||||
|
await stream.closed
|
||||||
|
} catch (error) {
|
||||||
|
if (this.controller?.signal.aborted) return
|
||||||
|
console.warn('[liveSync] stream failed, falling back to polling', error)
|
||||||
|
this.error = 'Live updates unavailable'
|
||||||
|
this.connected = false
|
||||||
|
this.mode = 'polling'
|
||||||
|
taskStore.startBoardPolling()
|
||||||
|
this.scheduleReconnect(forUser)
|
||||||
|
} finally {
|
||||||
|
this.connecting = false
|
||||||
|
if (!this.controller?.signal.aborted && !this.connected) {
|
||||||
|
this.mode = 'polling'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
|
||||||
|
taskStore.board = envelope.payload
|
||||||
|
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.type === 'notifications.snapshot') {
|
||||||
|
const snapshot = envelope.payload as NotificationSnapshotDto
|
||||||
|
if (snapshot.forUser !== forUser) return
|
||||||
|
notificationStore.notifications = snapshot.notifications
|
||||||
|
notificationStore.unreadCount = snapshot.unreadCount
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.controller?.abort()
|
||||||
|
this.controller = null
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.mode = 'polling'
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer)
|
||||||
|
this.reconnectTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleReconnect(forUser = 'bao') {
|
||||||
|
if (this.reconnectTimer) return
|
||||||
|
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||||
|
this.reconnectAttempts += 1
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.connect(forUser)
|
||||||
|
}, delay)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -84,7 +84,6 @@ export const useNotificationStore = defineStore('notifications', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
startPolling(forUser = 'bao') {
|
startPolling(forUser = 'bao') {
|
||||||
// Unread count polling every 30s (for sidebar badge)
|
|
||||||
if (!this.countRefreshInterval) {
|
if (!this.countRefreshInterval) {
|
||||||
this.fetchUnreadCount(forUser)
|
this.fetchUnreadCount(forUser)
|
||||||
this.countRefreshInterval = setInterval(() => {
|
this.countRefreshInterval = setInterval(() => {
|
||||||
|
|||||||
@@ -359,8 +359,12 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
startBoardPolling() {
|
startBoardPolling(force = false) {
|
||||||
if (this.boardRefreshInterval) return
|
if (this.boardRefreshInterval && !force) return
|
||||||
|
if (this.boardRefreshInterval && force) {
|
||||||
|
clearInterval(this.boardRefreshInterval)
|
||||||
|
this.boardRefreshInterval = null
|
||||||
|
}
|
||||||
this.fetchBoard()
|
this.fetchBoard()
|
||||||
this.boardRefreshInterval = setInterval(() => {
|
this.boardRefreshInterval = setInterval(() => {
|
||||||
this.fetchBoard()
|
this.fetchBoard()
|
||||||
|
|||||||
@@ -12,11 +12,13 @@
|
|||||||
*
|
*
|
||||||
* Polling startet bei Mount, stoppt bei Unmount.
|
* Polling startet bei Mount, stoppt bei Unmount.
|
||||||
*/
|
*/
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useAgentStore } from '../../stores/agents'
|
import { useAgentStore } from '../../stores/agents'
|
||||||
import { useChatStore } from '../../stores/chat'
|
import { useChatStore } from '../../stores/chat'
|
||||||
import { useDashboardStore } from '../../stores/dashboard'
|
import { useDashboardStore } from '../../stores/dashboard'
|
||||||
import { useTaskStore } from '../../stores/tasks'
|
import { useTaskStore } from '../../stores/tasks'
|
||||||
|
import { useLiveSyncStore } from '../../stores/live-sync'
|
||||||
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
||||||
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||||
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||||
@@ -29,6 +31,8 @@ const agentStore = useAgentStore()
|
|||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const dashboardStore = useDashboardStore()
|
const dashboardStore = useDashboardStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
|
const liveSyncStore = useLiveSyncStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
addAgent,
|
addAgent,
|
||||||
@@ -42,18 +46,21 @@ const {
|
|||||||
updatePositions,
|
updatePositions,
|
||||||
} = useFlowBoardState(agentStore, chatStore)
|
} = useFlowBoardState(agentStore, chatStore)
|
||||||
|
|
||||||
|
const blockedTasks = computed(() => taskStore.taskList.filter(task => task.status === 'blocked'))
|
||||||
|
|
||||||
function handleBlockerClick() {
|
function handleBlockerClick() {
|
||||||
console.log('[FlowBoard] blocker clicked')
|
if (!blockedTasks.value.length) return
|
||||||
|
router.push('/tasks')
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockerLabel() {
|
function blockerLabel() {
|
||||||
const blockedTask = taskStore.taskList.find(task => task.status === 'blocked')
|
const blockedTask = blockedTasks.value[0]
|
||||||
if (!blockedTask) return undefined
|
if (!blockedTask) return undefined
|
||||||
return `${taskStore.taskList.filter(task => task.status === 'blocked').length} Blocker — ${blockedTask.title}`
|
return `${blockedTasks.value.length} Blocker — ${blockedTask.title}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function blockerCount() {
|
function blockerCount() {
|
||||||
return taskStore.taskList.filter(task => task.status === 'blocked').length
|
return blockedTasks.value.length
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Lifecycle ────────────────────────────────────── */
|
/* ── Lifecycle ────────────────────────────────────── */
|
||||||
@@ -62,6 +69,8 @@ onMounted(() => {
|
|||||||
chatStore.startPolling()
|
chatStore.startPolling()
|
||||||
dashboardStore.startPolling()
|
dashboardStore.startPolling()
|
||||||
taskStore.startPolling()
|
taskStore.startPolling()
|
||||||
|
taskStore.startBoardPolling()
|
||||||
|
liveSyncStore.connect()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -69,6 +78,8 @@ onUnmounted(() => {
|
|||||||
chatStore.stopPolling()
|
chatStore.stopPolling()
|
||||||
dashboardStore.stopPolling()
|
dashboardStore.stopPolling()
|
||||||
taskStore.stopPolling()
|
taskStore.stopPolling()
|
||||||
|
taskStore.stopBoardPolling()
|
||||||
|
liveSyncStore.disconnect()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import { onMounted, onUnmounted, computed } from 'vue'
|
import { onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useNotificationStore } from '../stores/notifications'
|
import { useNotificationStore } from '../stores/notifications'
|
||||||
|
import { useLiveSyncStore } from '../stores/live-sync'
|
||||||
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
|
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
|
||||||
|
|
||||||
const store = useNotificationStore()
|
const store = useNotificationStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const liveSyncStore = useLiveSyncStore()
|
||||||
|
|
||||||
const sortedNotifications = computed(() => {
|
const sortedNotifications = computed(() => {
|
||||||
return [...store.notifications].sort(
|
return [...store.notifications].sort(
|
||||||
@@ -53,10 +55,12 @@ function onNotificationClick(n: { id: string, taskId: string | null }) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.startListPolling()
|
store.startListPolling()
|
||||||
|
liveSyncStore.connect()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
store.stopListPolling()
|
store.stopListPolling()
|
||||||
|
liveSyncStore.disconnect()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useTaskStore } from '../stores/tasks'
|
import { useTaskStore } from '../stores/tasks'
|
||||||
|
import { useLiveSyncStore } from '../stores/live-sync'
|
||||||
|
|
||||||
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ type TaskFormState = {
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const liveSyncStore = useLiveSyncStore()
|
||||||
const showCreateModal = ref(false)
|
const showCreateModal = ref(false)
|
||||||
const showDetailPanel = ref(false)
|
const showDetailPanel = ref(false)
|
||||||
const showIrisPanel = ref(false)
|
const showIrisPanel = ref(false)
|
||||||
@@ -215,6 +217,8 @@ function hydrateDetailForm(task: BoardTask | null) {
|
|||||||
const staleCount = computed(() => taskStore.staleTasksList.length)
|
const staleCount = computed(() => taskStore.staleTasksList.length)
|
||||||
const waitingForIrisCount = computed(() => taskStore.waitingForIrisTasks.length)
|
const waitingForIrisCount = computed(() => taskStore.waitingForIrisTasks.length)
|
||||||
const waitingForBaoCount = computed(() => taskStore.waitingForBaoTasks.length)
|
const waitingForBaoCount = computed(() => taskStore.waitingForBaoTasks.length)
|
||||||
|
const liveModeLabel = computed(() => liveSyncStore.liveIndicatorLabel)
|
||||||
|
const liveModeClass = computed(() => `live-pill-${liveSyncStore.connectionHealth}`)
|
||||||
|
|
||||||
function expectedFromLabel(expected: string | null | undefined): string {
|
function expectedFromLabel(expected: string | null | undefined): string {
|
||||||
if (!expected) return ''
|
if (!expected) return ''
|
||||||
@@ -277,6 +281,14 @@ function hasChildTasks(taskId: string): boolean {
|
|||||||
return allBoardTasks.value.some(task => task.parentTaskId === taskId)
|
return allBoardTasks.value.some(task => task.parentTaskId === taskId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function delegationBadge(task: BoardTask): string | null {
|
||||||
|
if (task.childTaskCount && task.openChildTaskCount) return `${task.openChildTaskCount}/${task.childTaskCount} aktiv`
|
||||||
|
if (task.childTaskCount) return `${task.childTaskCount} Child-Tasks`
|
||||||
|
if (task.parentTaskId) return 'Child-Task'
|
||||||
|
if (task.isAgentTask || task.hasVisibleDelegation) return 'delegiert'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function assigneeLabel(assignedTo: string | null | undefined): string {
|
function assigneeLabel(assignedTo: string | null | undefined): string {
|
||||||
return expectedFromLabel(assignedTo)
|
return expectedFromLabel(assignedTo)
|
||||||
}
|
}
|
||||||
@@ -382,12 +394,14 @@ watch(showDetailPanel, (open) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/* ── Lifecycle ────────────────────────────────────── */
|
/* ── Lifecycle ────────────────────────────────────── */
|
||||||
|
let agentOverviewInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
taskStore.startBoardPolling()
|
taskStore.startBoardPolling()
|
||||||
taskStore.fetchAgentOverview()
|
taskStore.fetchAgentOverview()
|
||||||
|
liveSyncStore.connect()
|
||||||
window.addEventListener('keydown', onGlobalKeydown)
|
window.addEventListener('keydown', onGlobalKeydown)
|
||||||
// Refresh agent overview on the same interval
|
agentOverviewInterval = setInterval(() => taskStore.fetchAgentOverview(), 30000)
|
||||||
setInterval(() => taskStore.fetchAgentOverview(), 30000)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -396,6 +410,8 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
taskStore.stopBoardPolling()
|
taskStore.stopBoardPolling()
|
||||||
|
liveSyncStore.disconnect()
|
||||||
|
if (agentOverviewInterval) clearInterval(agentOverviewInterval)
|
||||||
window.removeEventListener('keydown', onGlobalKeydown)
|
window.removeEventListener('keydown', onGlobalKeydown)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -406,6 +422,10 @@ onUnmounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h1><span class="grad-text">Aufgaben</span></h1>
|
<h1><span class="grad-text">Aufgaben</span></h1>
|
||||||
<p class="board-subtitle">Task Board — Übersicht aller Arbeitspakete</p>
|
<p class="board-subtitle">Task Board — Übersicht aller Arbeitspakete</p>
|
||||||
|
<div class="board-live-row">
|
||||||
|
<span class="live-pill" :class="liveModeClass">{{ liveModeLabel }}</span>
|
||||||
|
<span v-if="liveSyncStore.lastEventAt" class="live-meta">Letztes Event {{ relativeTime(liveSyncStore.lastEventAt) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="board-header-actions">
|
<div class="board-header-actions">
|
||||||
<button
|
<button
|
||||||
@@ -546,6 +566,9 @@ onUnmounted(() => {
|
|||||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||||
⏳ {{ task.expectedFrom }}
|
⏳ {{ task.expectedFrom }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
||||||
|
↳ {{ delegationBadge(task) }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="task.assignedTo"
|
v-if="task.assignedTo"
|
||||||
class="assignee"
|
class="assignee"
|
||||||
@@ -595,6 +618,9 @@ onUnmounted(() => {
|
|||||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||||
⏳ {{ task.expectedFrom }}
|
⏳ {{ task.expectedFrom }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
||||||
|
↳ {{ delegationBadge(task) }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="task.assignedTo"
|
v-if="task.assignedTo"
|
||||||
class="assignee"
|
class="assignee"
|
||||||
@@ -644,6 +670,9 @@ onUnmounted(() => {
|
|||||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||||
⏳ {{ task.expectedFrom }}
|
⏳ {{ task.expectedFrom }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
||||||
|
↳ {{ delegationBadge(task) }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="task.assignedTo"
|
v-if="task.assignedTo"
|
||||||
class="assignee"
|
class="assignee"
|
||||||
@@ -737,6 +766,9 @@ onUnmounted(() => {
|
|||||||
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
|
||||||
⏳ {{ task.expectedFrom }}
|
⏳ {{ task.expectedFrom }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
|
||||||
|
↳ {{ delegationBadge(task) }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="task.assignedTo"
|
v-if="task.assignedTo"
|
||||||
class="assignee"
|
class="assignee"
|
||||||
@@ -837,6 +869,7 @@ onUnmounted(() => {
|
|||||||
<span v-if="selectedTask.isAgentTask" class="meta-agent-tag">🤖 Agent-Task</span>
|
<span v-if="selectedTask.isAgentTask" class="meta-agent-tag">🤖 Agent-Task</span>
|
||||||
<span v-if="selectedTask.expectedFrom" class="meta-expected">⏳ Erwartet: {{ selectedTask.expectedFrom }}</span>
|
<span v-if="selectedTask.expectedFrom" class="meta-expected">⏳ Erwartet: {{ selectedTask.expectedFrom }}</span>
|
||||||
<span v-if="selectedTask.parentTaskId" class="meta-expected">↳ Child-Task</span>
|
<span v-if="selectedTask.parentTaskId" class="meta-expected">↳ Child-Task</span>
|
||||||
|
<span v-if="delegationBadge(selectedTask)" class="meta-expected">↳ {{ delegationBadge(selectedTask) }}</span>
|
||||||
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
|
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
|
||||||
<span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</span>
|
<span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</span>
|
||||||
<span v-if="selectedTask.isAgentTask"><MessageSquareText :size="13" /> Letzter Status {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }}</span>
|
<span v-if="selectedTask.isAgentTask"><MessageSquareText :size="13" /> Letzter Status {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }}</span>
|
||||||
|
|||||||
@@ -169,6 +169,13 @@ function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'ex
|
|||||||
return taskLike.lastActivityMessage?.trim() || childStatusSummary(taskLike.id) || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
|
return taskLike.lastActivityMessage?.trim() || childStatusSummary(taskLike.id) || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function delegationSummary(taskLike: TaskDto): string | null {
|
||||||
|
if (taskLike.parentTaskId) return 'Sichtbare Child-Delegation'
|
||||||
|
if (children.value.length) return `${children.value.length} sichtbare Child-Tasks`
|
||||||
|
if (taskLike.isAgentTask) return 'Delegation im Board sichtbar'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/* ── API calls ───────────────────────────────── */
|
/* ── API calls ───────────────────────────────── */
|
||||||
async function loadTask() {
|
async function loadTask() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -380,6 +387,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<span v-if="task.isAgentTask" class="meta-chip">🤖 Agent-Task</span>
|
<span v-if="task.isAgentTask" class="meta-chip">🤖 Agent-Task</span>
|
||||||
<span v-if="task.expectedFrom" class="meta-chip">⏳ Erwartet: {{ task.expectedFrom }}</span>
|
<span v-if="task.expectedFrom" class="meta-chip">⏳ Erwartet: {{ task.expectedFrom }}</span>
|
||||||
<span v-if="task.parentTaskId" class="meta-chip">↳ Sichtbare Child-Task</span>
|
<span v-if="task.parentTaskId" class="meta-chip">↳ Sichtbare Child-Task</span>
|
||||||
|
<span v-if="delegationSummary(task)" class="meta-chip">{{ delegationSummary(task) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -408,8 +416,9 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="task.isAgentTask || childStatusSummary(task.id)" class="progress-banner">
|
<div v-if="task.isAgentTask || childStatusSummary(task.id) || delegationSummary(task)" class="progress-banner">
|
||||||
<strong>Letzter Fortschritt:</strong> {{ progressHint(task) }}
|
<strong>Letzter Fortschritt:</strong> {{ progressHint(task) }}
|
||||||
|
<span v-if="delegationSummary(task)" class="delegation-inline">· {{ delegationSummary(task) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
@@ -577,6 +586,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<dl class="info-list">
|
<dl class="info-list">
|
||||||
<div><dt>ID</dt><dd>#{{ task.id.slice(0, 8) }}</dd></div>
|
<div><dt>ID</dt><dd>#{{ task.id.slice(0, 8) }}</dd></div>
|
||||||
<div><dt>Quelle</dt><dd>{{ task.source || '—' }}</dd></div>
|
<div><dt>Quelle</dt><dd>{{ task.source || '—' }}</dd></div>
|
||||||
|
<div v-if="delegationSummary(task)"><dt>Delegation</dt><dd>{{ delegationSummary(task) }}</dd></div>
|
||||||
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
|
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
|
||||||
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
|
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
|
||||||
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
|
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
|
||||||
|
|||||||
@@ -30,8 +30,39 @@ server {
|
|||||||
|
|
||||||
client_max_body_size 16m;
|
client_max_body_size 16m;
|
||||||
|
|
||||||
|
# Bridge-Endpunkte: Gateway-zu-Backend-Agent-Pfad
|
||||||
|
# X-Agent-Id wird durchgereicht für Agent-Identität
|
||||||
|
location /api/bridge/ {
|
||||||
|
proxy_pass http://127.0.0.1:18880;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Agent-Id $http_x_agent_id;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dashboard SSE stream: single dedicated non-buffered block.
|
||||||
|
location = /api/dashboard/live {
|
||||||
|
proxy_pass http://127.0.0.1:18880;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_send_timeout 1h;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
add_header X-Accel-Buffering no always;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:18880;
|
proxy_pass http://127.0.0.1:18880;
|
||||||
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -41,6 +72,7 @@ server {
|
|||||||
# API-Direktzugriff falls nötig
|
# API-Direktzugriff falls nötig
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:18880;
|
proxy_pass http://127.0.0.1:18880;
|
||||||
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -24,3 +24,5 @@
|
|||||||
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
|
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
|
||||||
- Model-Routing läuft zentral über OpenClaw Gateway (kein direct provider routing)
|
- Model-Routing läuft zentral über OpenClaw Gateway (kein direct provider routing)
|
||||||
- API kommuniziert via `host.docker.internal:18789` (Gateway loopback — wird über `openclaw_default` Netzwerk gefixt)
|
- API kommuniziert via `host.docker.internal:18789` (Gateway loopback — wird über `openclaw_default` Netzwerk gefixt)
|
||||||
|
- **Achtung:** `[AllowAnonymous]` auf `/tasks/board` und `/tasks/reset-stale` muss durch ApiKey-Auth ersetzt werden (siehe [Architektur-Review](../docs/architecture-board-first-orchestration.md#61-kritisch--allowanonymous-auf-board-endpunkten))
|
||||||
|
- Vollständige Architektur- und Sicherheitsanalyse: [architecture-board-first-orchestration.md](../docs/architecture-board-first-orchestration.md)
|
||||||
|
|||||||
Reference in New Issue
Block a user