diff --git a/.gitignore b/.gitignore index 57ee77c..8b6f6fa 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,9 @@ docker-compose.override.yml **/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/ diff --git a/README.md b/README.md index c5a54eb..9c88144 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ 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. +> πŸ“‹ **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** > 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 @@ -170,6 +174,38 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`): ## 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): +```json +{ + "ok": true, + "command": "create_task", + "data": { ... }, + "error": null, + "timestamp": "2026-06-22T15:30:00.000Z" +} +``` + ### Health & Auth (public or rate-limited) | Method | Path | Auth | Description | diff --git a/backend-tests/Nexus.Api.Tests.csproj b/backend-tests/Nexus.Api.Tests.csproj index 202ccb2..bf13817 100644 --- a/backend-tests/Nexus.Api.Tests.csproj +++ b/backend-tests/Nexus.Api.Tests.csproj @@ -9,6 +9,8 @@ + + diff --git a/backend-tests/OperationsSnapshotTests.cs b/backend-tests/OperationsSnapshotTests.cs index 1be6ade..c901950 100644 --- a/backend-tests/OperationsSnapshotTests.cs +++ b/backend-tests/OperationsSnapshotTests.cs @@ -143,4 +143,7 @@ internal sealed class SnapshotAgentServiceStub : IAgentService public Task GetAgentAsync(string id, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task> GetAllowedAgentIdsAsync(CancellationToken cancellationToken) + => Task.FromResult>(new HashSet(StringComparer.OrdinalIgnoreCase) { "iris" }); } diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index 0be01cf..493d4c0 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -15,7 +15,9 @@ public class DashboardController( IDashboardService dashboardService, ITaskService taskService, IActivityRepository activityService, - IHttpContextAccessor httpContextAccessor) : ControllerBase + IHttpContextAccessor httpContextAccessor, + INotificationService notificationService, + ILiveUpdateService liveUpdateService) : ControllerBase { [HttpGet("status")] public async Task GetStatus() @@ -193,6 +195,69 @@ public class DashboardController( public async Task GetBoard(CancellationToken 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")] public async Task> MoveTask( Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct) diff --git a/backend/Controllers/GatewayBridgeController.cs b/backend/Controllers/GatewayBridgeController.cs new file mode 100644 index 0000000..d9a0f44 --- /dev/null +++ b/backend/Controllers/GatewayBridgeController.cs @@ -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; + +/// +/// 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 +/// +[ApiController] +[Route("api/bridge")] +[EnableRateLimiting("agents")] +public class GatewayBridgeController( + ITaskBridgeService bridge, + IAgentService agentService, + ILogger 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>> 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>> 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>> 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>> 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>> 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> 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>> 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>> 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>>> 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> + { + Ok = true, + Command = "get_activity", + Data = entries + }); + } + + [HttpGet("agent-overview")] + public async Task> 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(TaskBridgeResult result, string command) where T : class + { + if (result.Outcome == TaskBridgeOutcome.Success) + return new OkObjectResult(new TaskBridgeCommandResponse + { + 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 + { + Ok = false, + Command = command, + Error = result.Error ?? "Unknown error" + }) { StatusCode = statusCode }; + } + + private static ActionResult MapActivityResult(TaskBridgeResult result, string command) + { + if (result.Outcome == TaskBridgeOutcome.Success) + return new OkObjectResult(new TaskBridgeCommandResponse + { + 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 + { + Ok = false, + Command = command, + Error = result.Error ?? "Unknown error" + }) { StatusCode = statusCode }; + } +} + +public sealed class TaskBridgeCommandResponse +{ + 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 +); diff --git a/backend/Controllers/HealthController.cs b/backend/Controllers/HealthController.cs index 1afcd4e..f49e328 100644 --- a/backend/Controllers/HealthController.cs +++ b/backend/Controllers/HealthController.cs @@ -40,14 +40,14 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh { status = e.Value.Status.ToString(), description = e.Value.Description, - data = e.Value.Data + data = (IReadOnlyDictionary)e.Value.Data }); entries["runtime"] = new { status = runtimeStatus, - description = runtimeDetail ?? "Runtime status checked", - data = (IReadOnlyDictionary)new Dictionary() + description = runtimeDetail, + data = (IReadOnlyDictionary)new Dictionary() }; var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online"; diff --git a/backend/Controllers/NotificationsController.cs b/backend/Controllers/NotificationsController.cs index 8ea9995..75dff51 100644 --- a/backend/Controllers/NotificationsController.cs +++ b/backend/Controllers/NotificationsController.cs @@ -31,6 +31,16 @@ public class NotificationsController(INotificationService notificationService) : return Ok(new UnreadCountDto(count)); } + [HttpGet("snapshot")] + public async Task> 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")] public async Task MarkAsRead(Guid id, CancellationToken ct = default) { diff --git a/backend/Controllers/ProjectsController.cs b/backend/Controllers/ProjectsController.cs index d9f93a4..1a55a94 100644 --- a/backend/Controllers/ProjectsController.cs +++ b/backend/Controllers/ProjectsController.cs @@ -1,9 +1,11 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Nexus.Api.DTOs; using Nexus.Api.Services; namespace Nexus.Api.Controllers; +[Authorize] [ApiController] [Route("api/v1/projects")] public class ProjectsController(IProjectService projectService) : ControllerBase diff --git a/backend/Controllers/TasksController.cs b/backend/Controllers/TasksController.cs index d833b36..fb7d7e9 100644 --- a/backend/Controllers/TasksController.cs +++ b/backend/Controllers/TasksController.cs @@ -7,9 +7,10 @@ using Nexus.Api.Services; namespace Nexus.Api.Controllers; +[Authorize] [ApiController] [Route("api/v1/tasks")] -public class TasksController(ITaskService taskService) : ControllerBase +public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase { [HttpGet] public async Task GetAll(CancellationToken ct) @@ -111,21 +112,57 @@ public class TasksController(ITaskService taskService) : ControllerBase /// /// Gibt das Task-Board zurΓΌck (gruppiert nach Status, priorisiert sortiert). /// 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. /// - [AllowAnonymous] [HttpGet("board")] public async Task 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)); + } /// /// Setzt stale Tasks (InProgress, Γ€lter als N Stunden) zurΓΌck auf Backlog. /// 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. /// - [AllowAnonymous] [HttpPost("reset-stale")] public async Task 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); return Results.Ok(new ResetStaleResponse(count)); } + + private async Task 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; + } } diff --git a/backend/Extensions/ServiceCollectionExtensions.cs b/backend/Extensions/ServiceCollectionExtensions.cs index 773f7a4..420e206 100644 --- a/backend/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Extensions/ServiceCollectionExtensions.cs @@ -216,9 +216,13 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); + // ── Backend Bridge (Agent-Command-Service) ── + services.AddScoped(); + return services; } diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index cd5ea9e..ba28d93 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -93,7 +93,11 @@ public sealed record DashboardTaskDto( bool IsAgentTask = false, string? ExpectedFrom = null, string? LastActivityMessage = null, - DateTimeOffset? LastActivityAt = null + DateTimeOffset? LastActivityAt = null, + List? ChildTasks = null, + int ChildTaskCount = 0, + int OpenChildTaskCount = 0, + bool HasVisibleDelegation = false ); public sealed record CreateDashboardTaskRequest( @@ -182,3 +186,34 @@ public sealed record NotificationDto( ); 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 Notifications, + int UnreadCount, + string ForUser +); + +public sealed record DashboardLiveSnapshotDto( + BoardResponse Board, + NotificationSnapshotDto Notifications, + LiveCursorDto Cursor +); + +public sealed record DashboardLiveEventDto( + LiveUpdateEnvelope Envelope, + LiveCursorDto Cursor +); diff --git a/backend/Services/AgentService.cs b/backend/Services/AgentService.cs index 40cc4f5..5a82016 100644 --- a/backend/Services/AgentService.cs +++ b/backend/Services/AgentService.cs @@ -73,6 +73,7 @@ public interface IAgentService { Task> GetAgentsAsync(CancellationToken cancellationToken); Task GetAgentAsync(string id, CancellationToken cancellationToken); + Task> GetAllowedAgentIdsAsync(CancellationToken cancellationToken); } public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService @@ -151,6 +152,16 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run ); } + + public async Task> GetAllowedAgentIdsAsync(CancellationToken cancellationToken) + { + var configs = await LoadAgentConfigsAsync(cancellationToken); + return configs + .Where(config => !string.IsNullOrWhiteSpace(config.Id)) + .Select(config => config.Id.Trim().ToLowerInvariant()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch { "iris" => "Orchestrator", diff --git a/backend/Services/ILiveUpdateService.cs b/backend/Services/ILiveUpdateService.cs new file mode 100644 index 0000000..1a7ff29 --- /dev/null +++ b/backend/Services/ILiveUpdateService.cs @@ -0,0 +1,17 @@ +using System.Threading.Channels; +using Nexus.Api.Models; + +namespace Nexus.Api.Services; + +public interface ILiveUpdateService +{ + Task 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 Reader { get; init; } = default!; + public long StartingSequence { get; init; } +} diff --git a/backend/Services/INotificationService.cs b/backend/Services/INotificationService.cs index 71157a1..e1ad909 100644 --- a/backend/Services/INotificationService.cs +++ b/backend/Services/INotificationService.cs @@ -10,4 +10,5 @@ public interface INotificationService Task MarkAsReadAsync(Guid id, CancellationToken ct = default); Task MarkAllAsReadAsync(string forUser, CancellationToken ct = default); Task GetUnreadCountAsync(string forUser, CancellationToken ct = default); + Task GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default); } diff --git a/backend/Services/ITaskBridgeService.cs b/backend/Services/ITaskBridgeService.cs new file mode 100644 index 0000000..164b824 --- /dev/null +++ b/backend/Services/ITaskBridgeService.cs @@ -0,0 +1,133 @@ +using Nexus.Api.Data; +using Nexus.Api.DTOs; +using Nexus.Api.Models; + +namespace Nexus.Api.Services; + +/// +/// 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. +/// +public interface ITaskBridgeService +{ + // ── Task CRUD (Agent-Commands) ── + + /// + /// Creates a new top-level task (parent or standalone). + /// Returns the created task DTO. + /// + Task> CreateTaskAsync( + string title, + string? detail = null, + string? source = "iris", + string? priority = "Normal", + string? assignedTo = null, + Guid? projectId = null, + CancellationToken ct = default); + + /// + /// 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. + /// + Task> CreateChildTaskAsync( + Guid parentTaskId, + string title, + string? detail = null, + string? source = "iris", + string? priority = "Normal", + string? assignedTo = null, + string? expectedFrom = null, + CancellationToken ct = default); + + /// + /// Updates the status/state of a task. + /// Enforces CanChangeState rules (only iris/bao/nexus-system may change state). + /// + Task> UpdateStatusAsync( + Guid taskId, + string state, + string? callerAgent = null, + CancellationToken ct = default); + + /// + /// Appends an activity entry to a task (comment, status note, agent note). + /// Used by agents to annotate their progress on the board. + /// + Task> AppendActivityAsync( + Guid taskId, + string message, + string? type = "comment", + CancellationToken ct = default); + + /// + /// Handles a task handoff: sets ExpectedFrom to the target agent, + /// appends a handoff activity entry, and optionally updates assigned-to. + /// + Task> HandoffAsync( + Guid taskId, + string targetAgent, + string? note = null, + CancellationToken ct = default); + + // ── Query (Read) ── + + /// + /// Returns the full task board state (grouped by status column). + /// + Task GetBoardAsync(CancellationToken ct = default); + + /// + /// Returns a single task by ID. + /// + Task> GetTaskAsync( + Guid taskId, + CancellationToken ct = default); + + /// + /// Returns all child tasks for a given parent task. + /// + Task> GetChildTasksAsync( + Guid parentTaskId, + CancellationToken ct = default); + + /// + /// Returns task activity history. + /// + Task> GetTaskActivityAsync( + Guid taskId, + CancellationToken ct = default); + + // ── Agent Workflow ── + + /// + /// Returns the agent-workflow overview: who is expected to respond, + /// stale tasks, workload distribution. + /// + Task GetAgentOverviewAsync( + TimeSpan? staleThreshold = null, + CancellationToken ct = default); +} + +/// +/// Result pattern for task-bridge operations. +/// WorkTask? is null on NotFound; state is stored in the Outcome. +/// +public sealed record TaskBridgeResult( + TaskBridgeOutcome Outcome, + T? Data = default, + string? Error = null +); + +public enum TaskBridgeOutcome +{ + Success, + NotFound, + InvalidState, + Unauthorized, + ValidationError +} diff --git a/backend/Services/LiveUpdateService.cs b/backend/Services/LiveUpdateService.cs new file mode 100644 index 0000000..e0da099 --- /dev/null +++ b/backend/Services/LiveUpdateService.cs @@ -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> _subscribers = new(); + private readonly object _historyLock = new(); + private readonly Queue _history = new(); + private long _sequence; + + public long CurrentSequence => Interlocked.Read(ref _sequence); + + public Task SubscribeAsync(long? afterSequence = null, CancellationToken ct = default) + { + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + + var id = Guid.NewGuid(); + _subscribers[id] = channel; + + var replay = afterSequence.HasValue ? GetReplay(afterSequence.Value) : Array.Empty(); + 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(); + } + } +} diff --git a/backend/Services/NotificationService.cs b/backend/Services/NotificationService.cs index 14652bf..048528f 100644 --- a/backend/Services/NotificationService.cs +++ b/backend/Services/NotificationService.cs @@ -4,7 +4,7 @@ using Nexus.Api.Models; namespace Nexus.Api.Services; -public sealed class NotificationService(NexusDbContext db) : INotificationService +public sealed class NotificationService(NexusDbContext db, ILiveUpdateService liveUpdateService) : INotificationService { public async Task CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default) { @@ -18,6 +18,7 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic }; db.Notifications.Add(notification); await db.SaveChangesAsync(ct); + await PublishSnapshotAsync(notification.ForUser, ct); return notification; } @@ -42,14 +43,17 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic notification.IsRead = true; await db.SaveChangesAsync(ct); + await PublishSnapshotAsync(notification.ForUser, ct); return true; } public async Task MarkAllAsReadAsync(string forUser, CancellationToken ct = default) { + var normalizedUser = forUser.ToLowerInvariant(); var count = await db.Notifications - .Where(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead) + .Where(n => n.ForUser == normalizedUser && !n.IsRead) .ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct); + await PublishSnapshotAsync(normalizedUser, ct); return count; } @@ -58,4 +62,25 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic return await db.Notifications .CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct); } + + public async Task 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); } diff --git a/backend/Services/TaskBridgeService.cs b/backend/Services/TaskBridgeService.cs new file mode 100644 index 0000000..57032ae --- /dev/null +++ b/backend/Services/TaskBridgeService.cs @@ -0,0 +1,248 @@ +using Nexus.Api.Data; +using Nexus.Api.DTOs; +using Nexus.Api.Models; +using Nexus.Api.Repositories; + +namespace Nexus.Api.Services; + +/// +/// Concrete implementation of ITaskBridgeService. +/// Wraps ITaskService, IActivityRepository, INotificationService, and ILiveUpdateService +/// into structured, predictable commands for agent-facing usage. +/// +/// All operations produce typed TaskBridgeResult with explicit error codes, +/// making agent consumption safe and debuggable. +/// +public sealed class TaskBridgeService( + ITaskService taskService, + IActivityRepository activityRepo, + INotificationService notificationService, + ILiveUpdateService liveUpdateService) : ITaskBridgeService +{ + private static readonly HashSet ValidStates = + new(TaskStateHelper.AllStates, StringComparer.OrdinalIgnoreCase); + + // ──────────────────────────────── Create Task ──────────────────────────────── + + public async Task> 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(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> 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(TaskBridgeOutcome.ValidationError, "Title is required."); + + // Verify parent exists + var parent = await taskService.GetByIdAsync(parentTaskId, ct); + if (parent is null) + return Error(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> UpdateStatusAsync( + Guid taskId, + string state, + string? callerAgent = null, + CancellationToken ct = default) + { + if (!ValidStates.Contains(state)) + return Error(TaskBridgeOutcome.ValidationError, + $"Invalid state '{state}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}"); + + var task = await taskService.GetByIdAsync(taskId, ct); + if (task is null) + return Error(TaskBridgeOutcome.NotFound, $"Task {taskId} not found."); + + // Check authorization + if (!TaskStateHelper.CanChangeState(callerAgent, task)) + return Error(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(TaskBridgeOutcome.InvalidState, "Status update rejected."); + + var dto = MapToDto(result.Task!); + return Success(dto); + } + + // ──────────────────────────────── Append Activity ──────────────────────────── + + public async Task> AppendActivityAsync( + Guid taskId, + string message, + string? type = "comment", + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(message)) + return Error(TaskBridgeOutcome.ValidationError, "Message is required."); + + var task = await taskService.GetByIdAsync(taskId, ct); + if (task is null) + return Error(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> HandoffAsync( + Guid taskId, + string targetAgent, + string? note = null, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(targetAgent)) + return Error(TaskBridgeOutcome.ValidationError, "Target agent is required."); + + var task = await taskService.GetByIdAsync(taskId, ct); + if (task is null) + return Error(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 GetBoardAsync(CancellationToken ct = default) + => await taskService.GetBoardAsync(ct); + + public async Task> GetTaskAsync( + Guid taskId, CancellationToken ct = default) + { + var dto = await taskService.GetDashboardTaskByIdAsync(taskId, ct); + return dto is null + ? Error(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.") + : Success(dto); + } + + public async Task> GetChildTasksAsync( + Guid parentTaskId, CancellationToken ct = default) + { + var children = await taskService.GetChildTasksAsync(parentTaskId, ct); + return children.Select(MapToDto).ToList(); + } + + public async Task> GetTaskActivityAsync( + Guid taskId, CancellationToken ct = default) + => await taskService.GetTaskActivityAsync(taskId, ct); + + public async Task GetAgentOverviewAsync( + TimeSpan? staleThreshold = null, CancellationToken ct = default) + { + var threshold = staleThreshold ?? TimeSpan.FromHours(2); + return await taskService.GetAgentWorkflowOverviewAsync(threshold, ct); + } + + // ──────────────────────────────── Helpers ──────────────────────────────────── + + private static TaskBridgeResult Success(T data) => + new(TaskBridgeOutcome.Success, data); + + private static TaskBridgeResult Error(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 { "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); +} diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs index 5311d9a..c1a4072 100644 --- a/backend/Services/TaskService.cs +++ b/backend/Services/TaskService.cs @@ -9,7 +9,8 @@ public sealed class TaskService( ITaskRepository taskRepo, IActivityRepository activityRepo, INotificationService notificationService, - IHttpContextAccessor httpContextAccessor) : ITaskService + IHttpContextAccessor httpContextAccessor, + ILiveUpdateService liveUpdateService) : ITaskService { private static readonly HashSet ValidAssignees = ["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"]; @@ -22,11 +23,12 @@ public sealed class TaskService( public async Task GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default) { - var task = await taskRepo.GetByIdAsync(id, ct); + var allTasks = (await taskRepo.GetAllAsync(ct)).ToList(); + var task = allTasks.FirstOrDefault(t => t.Id == id); if (task is null) return null; - var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct); - return MapToDtoWithActivity(task, activity); + var activity = await activityRepo.GetRecentForTasksAsync(allTasks.Select(t => t.Id), ct); + return MapToDtoWithChildren(task, allTasks, activity); } public async Task> GetPendingApprovalAsync(CancellationToken ct = default) @@ -42,6 +44,7 @@ public sealed class TaskService( }; await taskRepo.AddAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct); + await PublishBoardSnapshotAsync(ct); return task; } @@ -56,6 +59,7 @@ public sealed class TaskService( task.State = TaskStateHelper.ToStateString(TaskState.Done); await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct); + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } @@ -70,6 +74,7 @@ public sealed class TaskService( task.State = TaskStateHelper.ToStateString(TaskState.Backlog); await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct); + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } @@ -81,7 +86,6 @@ public sealed class TaskService( var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); - // Enforce workflow rules var caller = ResolveCaller(); if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); @@ -90,6 +94,7 @@ public sealed class TaskService( await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct); await CreateStatusChangeNotificationsAsync(task, canonical, ct); + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } @@ -112,13 +117,14 @@ public sealed class TaskService( } if (request.ProjectId.HasValue) { - changes.Add($"Projekt-ID geΓ€ndert"); + changes.Add("Projekt-ID geΓ€ndert"); task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; } await taskRepo.UpdateAsync(task, ct); var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Γ„nderungen"; await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", TaskId = task.Id }, ct); + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } @@ -132,11 +138,10 @@ public sealed class TaskService( await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct); await taskRepo.DeleteAsync(task, ct); + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success); } - // ── Dashboard-facing operations ── - public async Task> GetOpenAsync(CancellationToken ct = default) { var all = await taskRepo.GetAllAsync(ct); @@ -145,10 +150,6 @@ public sealed class TaskService( .ToList(); } - /// - /// 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. - /// public async Task> GetWaitingTasksAsync(CancellationToken ct = default) { var all = await taskRepo.GetAllAsync(ct); @@ -159,22 +160,15 @@ public sealed class TaskService( .ToList(); } - /// - /// 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. - /// public async Task GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default) { - var all = await taskRepo.GetAllAsync(ct); + var all = (await taskRepo.GetAllAsync(ct)).ToList(); var threshold = DateTimeOffset.UtcNow - staleThreshold; - var agentTasks = all.Where(t => t.IsAgentTask).ToList(); - var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct); List map(IEnumerable tasks) - => tasks.Select(task => MapToDtoWithActivity(task, activity)).ToList(); + => tasks.Select(task => MapToDtoWithChildren(task, all, activity)).ToList(); var waitingForBao = map(agentTasks .Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) && @@ -193,19 +187,15 @@ public sealed class TaskService( })); var staleTasks = map(agentTasks - .Where(t => - string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && - t.UpdatedAt < threshold)); + .Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold)); - return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, - staleTasks, staleThreshold); + return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, staleTasks, staleThreshold); } public async Task CreateDashboardTaskAsync( string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default) { - // Validate parent task exists if specified if (parentTaskId.HasValue) { var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct); @@ -215,6 +205,7 @@ public sealed class TaskService( var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant(); var normalizedAssignee = ValidateAssignedTo(assignedTo); + var isVisibleDelegation = parentTaskId.HasValue; var task = new WorkTask { @@ -224,16 +215,24 @@ public sealed class TaskService( Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(), AssignedTo = normalizedAssignee, ParentTaskId = parentTaskId, - IsAgentTask = parentTaskId.HasValue + IsAgentTask = isVisibleDelegation }; await taskRepo.AddAsync(task, ct); - var message = $"Task \"{task.Title}\" created ({task.Source})"; + var activityMessages = new List { $"Task \"{task.Title}\" created ({task.Source})" }; if (parentTaskId.HasValue) - message += $" [child of {parentTaskId.Value}]"; - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct); + { + activityMessages.Add($"Sichtbare Delegation erstellt: Child-Task von {parentTaskId.Value}."); + await activityRepo.AddAsync(new ActivityEvent + { + Type = "delegation", + Message = $"Board-first Delegation: Child-Task \"{task.Title}\" fΓΌr {normalizedAssignee ?? task.Source} sichtbar angelegt.", + TaskId = parentTaskId.Value + }, ct); + } + + await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = string.Join(" ", activityMessages), TaskId = task.Id }, ct); - // Auto-notify: if assigned to bao, create a task_assigned notification if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase)) { await notificationService.CreateAsync( @@ -245,6 +244,7 @@ public sealed class TaskService( ct); } + await PublishBoardSnapshotAsync(ct); return task; } @@ -252,13 +252,12 @@ public sealed class TaskService( string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default) { + var normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant(); var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct); task.IsAgentTask = true; - task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant(); + task.ExpectedFrom = normalizedExpectedFrom; task.State = TaskStateHelper.ToStateString(TaskState.InProgress); - - // Persist the agent-task-specific fields await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent @@ -268,7 +267,16 @@ public sealed class TaskService( TaskId = task.Id }, ct); - // Notify iris about new agent-task + if (parentTaskId.HasValue) + { + await activityRepo.AddAsync(new ActivityEvent + { + Type = "delegation", + Message = $"Parent-/Child-Delegation sichtbar: Parent {parentTaskId.Value}, Child {task.Id}, wartet auf {task.ExpectedFrom ?? task.AssignedTo ?? "unbekannt"}.", + TaskId = parentTaskId.Value + }, ct); + } + await notificationService.CreateAsync( "agent_task_created", $"Neuer Agent-Task: {task.Title}", @@ -277,6 +285,7 @@ public sealed class TaskService( task.Id, ct); + await PublishBoardSnapshotAsync(ct); return task; } @@ -320,13 +329,10 @@ public sealed class TaskService( task.AssignedTo = validated; } } - if (dueDate.HasValue) + if (dueDate.HasValue && task.DueDate?.Date != dueDate.Value.Date) { - if (task.DueDate?.Date != dueDate.Value.Date) - { - changes.Add($"FΓ€llig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} β†’ {dueDate.Value:yyyy-MM-dd}"); - task.DueDate = dueDate; - } + changes.Add($"FΓ€llig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} β†’ {dueDate.Value:yyyy-MM-dd}"); + task.DueDate = dueDate; } await taskRepo.UpdateAsync(task, ct); @@ -339,18 +345,18 @@ public sealed class TaskService( TaskId = task.Id }, ct); - // Notification: wenn Bao die Task geΓ€ndert hat, Iris benachrichtigen if (changes.Count > 0 && caller == "bao") { await notificationService.CreateAsync( "task_content_changed", $"Bao hat \"{task.Title}\" geΓ€ndert", - $"{changeSummary}", + changeSummary, "iris", task.Id, ct); } + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } @@ -362,7 +368,6 @@ public sealed class TaskService( var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); - // Enforce workflow rules var caller = ResolveCaller(); if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); @@ -372,6 +377,7 @@ public sealed class TaskService( await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" β†’ {canonical}", TaskId = task.Id }, ct); await CreateStatusChangeNotificationsAsync(task, canonical, ct); + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } @@ -383,6 +389,7 @@ public sealed class TaskService( task.State = "Done"; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct); + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } @@ -401,14 +408,15 @@ public sealed class TaskService( await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority β†’ {task.Priority}", TaskId = task.Id }, ct); + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } - // ── Board operations ── - public async Task 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(); var inProgress = new List(); var review = new List(); @@ -417,21 +425,15 @@ public sealed class TaskService( foreach (var task in all) { - var dto = MapToDto(task); + var dto = MapToDtoWithChildren(task, all, activity); switch (task.State.ToLowerInvariant()) { - case "backlog": - offen.Add(dto); break; - case "in progress": - inProgress.Add(dto); break; - case "review": - review.Add(dto); break; - case "blocked": - blocked.Add(dto); break; - case "done": - done.Add(dto); break; - default: - offen.Add(dto); break; + case "backlog": offen.Add(dto); break; + case "in progress": inProgress.Add(dto); break; + case "review": review.Add(dto); break; + case "blocked": blocked.Add(dto); break; + case "done": done.Add(dto); break; + default: offen.Add(dto); break; } } @@ -444,6 +446,12 @@ public sealed class TaskService( return new BoardResponse(offen, inProgress, review, blocked, done); } + private async Task PublishBoardSnapshotAsync(CancellationToken ct = default) + { + var board = await GetBoardAsync(ct); + liveUpdateService.Publish("tasks.board.snapshot", board, "board"); + } + private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b) { var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority)); @@ -461,23 +469,14 @@ public sealed class TaskService( public async Task MoveTaskAsync(Guid id, string newState, CancellationToken ct = default) { - // Resolve canonical state: accept board group keys or canonical strings - var canonical = TaskStateHelper.AllStates - .FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase)); - - if (canonical is null) - { - // Try mapping from board group key - canonical = TaskStateHelper.BoardGroupToState(newState); - } - + var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase)) + ?? TaskStateHelper.BoardGroupToState(newState); if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState); var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); - // Enforce workflow rules var caller = ResolveCaller(); if (!TaskStateHelper.CanChangeState(caller, task)) return new TaskOperationResult(TaskOperationOutcome.InvalidState); @@ -486,6 +485,7 @@ public sealed class TaskService( await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct); await CreateStatusChangeNotificationsAsync(task, canonical, ct); + await PublishBoardSnapshotAsync(ct); return new TaskOperationResult(TaskOperationOutcome.Success, task); } @@ -499,9 +499,7 @@ public sealed class TaskService( { var all = await taskRepo.GetAllAsync(ct); var threshold = DateTimeOffset.UtcNow - staleThreshold; - var staleTasks = all.Where(t => - string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && - t.UpdatedAt < threshold).ToList(); + var staleTasks = all.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold).ToList(); foreach (var task in staleTasks) { @@ -516,6 +514,9 @@ public sealed class TaskService( }, ct); } + if (staleTasks.Count > 0) + await PublishBoardSnapshotAsync(ct); + return staleTasks.Count; } @@ -533,12 +534,31 @@ public sealed class TaskService( return all.Where(e => e.TaskId == taskId).ToList(); } + private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList allTasks, IEnumerable activity) + { + var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id) + .OrderByDescending(t => t.UpdatedAt) + .ToList(); + + var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList(); + var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase)); + + var dto = MapToDtoWithActivity(task, activity, allTasks); + return dto with + { + ChildTasks = childDtos, + ChildTaskCount = childDtos.Count, + OpenChildTaskCount = openChildTaskCount, + HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask + }; + } + private static DashboardTaskDto MapToDto(WorkTask t) => new( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, t.IsAgentTask, t.ExpectedFrom); - private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable activity) + private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable activity, IReadOnlyList? _allTasks = null) { var last = activity .Where(e => e.TaskId == t.Id) @@ -550,13 +570,13 @@ public sealed class TaskService( t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, t.IsAgentTask, t.ExpectedFrom, last?.Message, - last?.CreatedAt); + last?.CreatedAt, + null, + 0, + 0, + t.ParentTaskId.HasValue || t.IsAgentTask); } - /// - /// Validates AssignedTo β€” only recognized agent values are accepted. - /// Returns null for invalid values. - /// private static string? ValidateAssignedTo(string? assignedTo) { if (string.IsNullOrWhiteSpace(assignedTo)) return null; @@ -564,15 +584,10 @@ public sealed class TaskService( return ValidAssignees.Contains(lower) ? lower : null; } - /// - /// 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). - /// private string ResolveCaller() { var httpContext = httpContextAccessor.HttpContext; - if (httpContext is null) return "nexus-system"; // internal system ops allowed + if (httpContext is null) return "nexus-system"; var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault(); if (!string.IsNullOrWhiteSpace(agentHeader)) @@ -583,12 +598,6 @@ public sealed class TaskService( return nameClaim?.ToLowerInvariant() ?? ""; } - /// - /// 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 - /// private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct) { var caller = ResolveCaller(); @@ -615,7 +624,6 @@ public sealed class TaskService( } else { - // Allgemeine StatusΓ€nderung: GegenΓΌber benachrichtigen if (caller == "bao") { await notificationService.CreateAsync( diff --git a/docs/architecture-board-first-orchestration.md b/docs/architecture-board-first-orchestration.md new file mode 100644 index 0000000..f68f8eb --- /dev/null +++ b/docs/architecture-board-first-orchestration.md @@ -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 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ 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 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ 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 +``` + +**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 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 diff --git a/docs/gateway-api-research.md b/docs/gateway-api-research.md index 54f8850..0ff4293 100644 --- a/docs/gateway-api-research.md +++ b/docs/gateway-api-research.md @@ -1,7 +1,12 @@ # Gateway API Research -> Generated: 2026-06-10 +> Generated: 2026-06-10 | Updated: 2026-06-22 > 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 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 50f2809..4b90039 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -24,8 +24,37 @@ server { 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/ { proxy_pass http://api:8080; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3dc2c57..43766a3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -9,6 +9,11 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) if (auth.accessToken) headers.set('Authorization', `Bearer ${auth.accessToken}`) if (auth.isIris) headers.set('X-Agent-Id', 'iris') 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' }) } diff --git a/frontend/src/services/live.ts b/frontend/src/services/live.ts new file mode 100644 index 0000000..afe8ec7 --- /dev/null +++ b/frontend/src/services/live.ts @@ -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 +} + +export async function openDashboardLiveStream( + onEvent: (event: LiveEventName, data: unknown) => void, + options: { forUser?: string; notificationLimit?: number; afterSequence?: number | null; signal?: AbortSignal } = {}, +): Promise { + 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 } +} diff --git a/frontend/src/stores/live-sync.ts b/frontend/src/stores/live-sync.ts new file mode 100644 index 0000000..0cbedfd --- /dev/null +++ b/frontend/src/stores/live-sync.ts @@ -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 | 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) + }, + }, +}) diff --git a/frontend/src/stores/notifications.ts b/frontend/src/stores/notifications.ts index dc1e81c..45f4586 100644 --- a/frontend/src/stores/notifications.ts +++ b/frontend/src/stores/notifications.ts @@ -84,7 +84,6 @@ export const useNotificationStore = defineStore('notifications', { }, startPolling(forUser = 'bao') { - // Unread count polling every 30s (for sidebar badge) if (!this.countRefreshInterval) { this.fetchUnreadCount(forUser) this.countRefreshInterval = setInterval(() => { diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 03d0cf7..fbd1899 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -359,8 +359,12 @@ export const useTaskStore = defineStore('tasks', { } }, - startBoardPolling() { - if (this.boardRefreshInterval) return + startBoardPolling(force = false) { + if (this.boardRefreshInterval && !force) return + if (this.boardRefreshInterval && force) { + clearInterval(this.boardRefreshInterval) + this.boardRefreshInterval = null + } this.fetchBoard() this.boardRefreshInterval = setInterval(() => { this.fetchBoard() diff --git a/frontend/src/views/Dashboard/FlowBoard.vue b/frontend/src/views/Dashboard/FlowBoard.vue index daf4958..e7b6155 100644 --- a/frontend/src/views/Dashboard/FlowBoard.vue +++ b/frontend/src/views/Dashboard/FlowBoard.vue @@ -12,11 +12,13 @@ * * 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 { useChatStore } from '../../stores/chat' import { useDashboardStore } from '../../stores/dashboard' import { useTaskStore } from '../../stores/tasks' +import { useLiveSyncStore } from '../../stores/live-sync' import AlertBar from '../../components/dashboard/v2/AlertBar.vue' import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue' import IrisChat from '../../components/dashboard/v2/IrisChat.vue' @@ -29,6 +31,8 @@ const agentStore = useAgentStore() const chatStore = useChatStore() const dashboardStore = useDashboardStore() const taskStore = useTaskStore() +const liveSyncStore = useLiveSyncStore() +const router = useRouter() const { addAgent, @@ -42,18 +46,21 @@ const { updatePositions, } = useFlowBoardState(agentStore, chatStore) +const blockedTasks = computed(() => taskStore.taskList.filter(task => task.status === 'blocked')) + function handleBlockerClick() { - console.log('[FlowBoard] blocker clicked') + if (!blockedTasks.value.length) return + router.push('/tasks') } function blockerLabel() { - const blockedTask = taskStore.taskList.find(task => task.status === 'blocked') + const blockedTask = blockedTasks.value[0] 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() { - return taskStore.taskList.filter(task => task.status === 'blocked').length + return blockedTasks.value.length } /* ── Lifecycle ────────────────────────────────────── */ @@ -62,6 +69,8 @@ onMounted(() => { chatStore.startPolling() dashboardStore.startPolling() taskStore.startPolling() + taskStore.startBoardPolling() + liveSyncStore.connect() }) onUnmounted(() => { @@ -69,6 +78,8 @@ onUnmounted(() => { chatStore.stopPolling() dashboardStore.stopPolling() taskStore.stopPolling() + taskStore.stopBoardPolling() + liveSyncStore.disconnect() }) diff --git a/frontend/src/views/NotificationsView.vue b/frontend/src/views/NotificationsView.vue index 96ab968..a6ca6c7 100644 --- a/frontend/src/views/NotificationsView.vue +++ b/frontend/src/views/NotificationsView.vue @@ -2,10 +2,12 @@ import { onMounted, onUnmounted, computed } from 'vue' import { useRouter } from 'vue-router' import { useNotificationStore } from '../stores/notifications' +import { useLiveSyncStore } from '../stores/live-sync' import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue' const store = useNotificationStore() const router = useRouter() +const liveSyncStore = useLiveSyncStore() const sortedNotifications = computed(() => { return [...store.notifications].sort( @@ -53,10 +55,12 @@ function onNotificationClick(n: { id: string, taskId: string | null }) { onMounted(() => { store.startListPolling() + liveSyncStore.connect() }) onUnmounted(() => { store.stopListPolling() + liveSyncStore.disconnect() }) diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue index 623b956..c6fb935 100644 --- a/frontend/src/views/TaskBoardView.vue +++ b/frontend/src/views/TaskBoardView.vue @@ -17,6 +17,7 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A import { useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' import { useTaskStore } from '../stores/tasks' +import { useLiveSyncStore } from '../stores/live-sync' type BoardTask = ReturnType[number] @@ -32,6 +33,7 @@ type TaskFormState = { const authStore = useAuthStore() const taskStore = useTaskStore() const router = useRouter() +const liveSyncStore = useLiveSyncStore() const showCreateModal = ref(false) const showDetailPanel = ref(false) const showIrisPanel = ref(false) @@ -215,6 +217,8 @@ function hydrateDetailForm(task: BoardTask | null) { const staleCount = computed(() => taskStore.staleTasksList.length) const waitingForIrisCount = computed(() => taskStore.waitingForIrisTasks.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 { if (!expected) return '' @@ -277,6 +281,14 @@ function hasChildTasks(taskId: string): boolean { 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 { return expectedFromLabel(assignedTo) } @@ -382,12 +394,14 @@ watch(showDetailPanel, (open) => { }) /* ── Lifecycle ────────────────────────────────────── */ +let agentOverviewInterval: ReturnType | null = null + onMounted(() => { taskStore.startBoardPolling() taskStore.fetchAgentOverview() + liveSyncStore.connect() window.addEventListener('keydown', onGlobalKeydown) - // Refresh agent overview on the same interval - setInterval(() => taskStore.fetchAgentOverview(), 30000) + agentOverviewInterval = setInterval(() => taskStore.fetchAgentOverview(), 30000) }) onBeforeUnmount(() => { @@ -396,6 +410,8 @@ onBeforeUnmount(() => { onUnmounted(() => { taskStore.stopBoardPolling() + liveSyncStore.disconnect() + if (agentOverviewInterval) clearInterval(agentOverviewInterval) window.removeEventListener('keydown', onGlobalKeydown) }) @@ -406,6 +422,10 @@ onUnmounted(() => {

Aufgaben

Task Board β€” Übersicht aller Arbeitspakete

+
+ {{ liveModeLabel }} + Letztes Event {{ relativeTime(liveSyncStore.lastEventAt) }} +
@@ -408,8 +416,9 @@ function handleKeydown(e: KeyboardEvent) { -
+
Letzter Fortschritt: {{ progressHint(task) }} + Β· {{ delegationSummary(task) }}
@@ -577,6 +586,7 @@ function handleKeydown(e: KeyboardEvent) {
ID
#{{ task.id.slice(0, 8) }}
Quelle
{{ task.source || 'β€”' }}
+
Delegation
{{ delegationSummary(task) }}
Erstellt
{{ formatDate(task.createdAt) }}
GeΓ€ndert
{{ formatDate(task.updatedAt, true) }}
Letzter Status
{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}
diff --git a/ops/nginx-nexus.conf b/ops/nginx-nexus.conf index ef9fa8b..9839f80 100644 --- a/ops/nginx-nexus.conf +++ b/ops/nginx-nexus.conf @@ -30,8 +30,39 @@ server { 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 / { proxy_pass http://127.0.0.1:18880; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -41,6 +72,7 @@ server { # API-Direktzugriff falls nΓΆtig location /api/ { proxy_pass http://127.0.0.1:18880; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/phases/runtime-routing.md b/phases/runtime-routing.md index e26fd91..132dcf1 100644 --- a/phases/runtime-routing.md +++ b/phases/runtime-routing.md @@ -24,3 +24,5 @@ - Einzige aktive Integration: `OpenClawRuntime` ΓΌber `IAgentRuntime` - 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) +- **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)