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

- GatewayBridgeController: MCP-artiger Kommando-Adapter für Agent-zu-Backend
- TaskBridgeService + LiveUpdateService: SSE Live-Sync + Bridge-Kommandos
- FlowBoard.vue: Board-first orchestration dashboard panel
- live-sync.ts store + live.ts service: SSE-basierte Live-Updates
- Nullability-Warnung in HealthController.cs gefixt
- nginx.conf: SSE-Proxy + CORS für Bridge-Endpunkte
- .gitignore: pnpm/corepack local caches ausgeschlossen
- docs: architecture-board-first-orchestration.md hinzugefügt
- README: Backend Bridge API dokumentiert
This commit is contained in:
2026-06-22 19:56:45 +02:00
parent de1fc198cb
commit df94ed3cd4
34 changed files with 2115 additions and 118 deletions
+3 -1
View File
@@ -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/
+36
View File
@@ -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<T>):
```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 |
+2
View File
@@ -9,6 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
+3
View File
@@ -143,4 +143,7 @@ internal sealed class SnapshotAgentServiceStub : IAgentService
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris" });
}
+66 -1
View File
@@ -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<DashboardStatus> GetStatus()
@@ -193,6 +195,69 @@ public class DashboardController(
public async Task<BoardResponse> 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<ActionResult<DashboardTaskDto>> MoveTask(
Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct)
@@ -0,0 +1,368 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// MCP-style (structured-command) backend bridge for agent-facing operations.
///
/// This is the SINGLE entrypoint for agents (Iris + sub-agents) to interact with
/// the Nexus task board, activity log, and delegation workflow.
///
/// AUTHENTICATION: Requires X-Nexus-Api-Key or a known allowed X-Agent-Id.
/// The browser NEVER uses this controller — only backend-to-backend and gateway-to-backend.
///
/// DESIGN PRINCIPLE: No MCP protocol between Nexus and Gateway — instead, the Gateway
/// calls these structured HTTP endpoints (same pattern, simpler transport).
///
/// COMMANDS:
/// create_task → POST /api/bridge/tasks
/// create_child_task → POST /api/bridge/tasks/{id}/children
/// update_status → PATCH /api/bridge/tasks/{id}/status
/// append_activity → POST /api/bridge/tasks/{id}/activity
/// handoff → POST /api/bridge/tasks/{id}/handoff
/// get_board → GET /api/bridge/board
/// get_task → GET /api/bridge/tasks/{id}
/// get_children → GET /api/bridge/tasks/{id}/children
/// get_activity → GET /api/bridge/tasks/{id}/activity
/// get_agent_overview → GET /api/bridge/agent-overview
/// </summary>
[ApiController]
[Route("api/bridge")]
[EnableRateLimiting("agents")]
public class GatewayBridgeController(
ITaskBridgeService bridge,
IAgentService agentService,
ILogger<GatewayBridgeController> logger) : ControllerBase
{
private const string ApikeyErrorMessage =
"Bridge endpoints require X-Nexus-Api-Key or X-Agent-Id header with a recognized agent identity.";
[HttpGet("health")]
public IResult Health()
{
return Results.Ok(new
{
status = "ok",
service = "nexus-bridge",
version = "1.0.0",
commands = new[]
{
"create_task", "create_child_task", "update_status",
"append_activity", "handoff", "get_board", "get_task",
"get_children", "get_activity", "get_agent_overview"
}
});
}
[HttpPost("tasks")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateTask(
[FromBody] BridgeCreateTaskCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var agentId = resolution.AgentId;
var result = await bridge.CreateTaskAsync(
title: command.Title,
detail: command.Detail,
source: ResolveSource(agentId),
priority: command.Priority ?? "Normal",
assignedTo: command.AssignedTo ?? agentId,
projectId: command.ProjectId,
ct: ct);
return MapResult(result, "create_task");
}
[HttpPost("tasks/{parentTaskId:guid}/children")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateChildTask(
Guid parentTaskId,
[FromBody] BridgeCreateChildTaskCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var agentId = resolution.AgentId;
var result = await bridge.CreateChildTaskAsync(
parentTaskId: parentTaskId,
title: command.Title,
detail: command.Detail,
source: ResolveSource(agentId),
priority: command.Priority ?? "Normal",
assignedTo: command.AssignedTo,
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
ct: ct);
return MapResult(result, "create_child_task");
}
[HttpPatch("tasks/{taskId:guid}/status")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> UpdateStatus(
Guid taskId,
[FromBody] BridgeUpdateStatusCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var agentId = resolution.AgentId;
var result = await bridge.UpdateStatusAsync(
taskId: taskId,
state: command.State,
callerAgent: agentId,
ct: ct);
return MapResult(result, "update_status");
}
[HttpPost("tasks/{taskId:guid}/activity")]
public async Task<ActionResult<TaskBridgeCommandResponse<ActivityEntryDto>>> AppendActivity(
Guid taskId,
[FromBody] BridgeAppendActivityCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var result = await bridge.AppendActivityAsync(
taskId: taskId,
message: command.Message,
type: command.Type ?? "comment",
ct: ct);
return MapActivityResult(result, "append_activity");
}
[HttpPost("tasks/{taskId:guid}/handoff")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> Handoff(
Guid taskId,
[FromBody] BridgeHandoffCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var result = await bridge.HandoffAsync(
taskId: taskId,
targetAgent: command.TargetAgent,
note: command.Note,
ct: ct);
return MapResult(result, "handoff");
}
[HttpGet("board")]
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
return Ok(await bridge.GetBoardAsync(ct));
}
[HttpGet("tasks/{taskId:guid}")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> GetTask(
Guid taskId, CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var result = await bridge.GetTaskAsync(taskId, ct);
return MapResult(result, "get_task");
}
[HttpGet("tasks/{taskId:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(
Guid taskId, CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
return Ok(await bridge.GetChildTasksAsync(taskId, ct));
}
[HttpGet("tasks/{taskId:guid}/activity")]
public async Task<ActionResult<TaskBridgeCommandResponse<List<ActivityEntryDto>>>> GetActivity(
Guid taskId, CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var events = await bridge.GetTaskActivityAsync(taskId, ct);
var entries = events.Select(e => new ActivityEntryDto(e.Id, e.Type, e.Message, e.CreatedAt)).ToList();
return Ok(new TaskBridgeCommandResponse<List<ActivityEntryDto>>
{
Ok = true,
Command = "get_activity",
Data = entries
});
}
[HttpGet("agent-overview")]
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
CancellationToken ct,
[FromQuery] int staleHours = 2)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
return Ok(await bridge.GetAgentOverviewAsync(threshold, ct));
}
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
{
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedAgentIds.Contains(normalizedHeader))
return (true, normalizedHeader, null);
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
normalizedHeader,
HttpContext.Connection.RemoteIpAddress);
}
if (User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedAgentIds.Contains(normalizedClaim))
return (true, normalizedClaim, null);
if (User.IsInRole("owner") || User.IsInRole("admin") || User.IsInRole("member"))
return (true, "bao", null);
}
if (User.IsInRole("Service") && allowedAgentIds.Contains("nexus-system"))
return (true, "nexus-system", null);
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, unauthorized);
}
private static string ResolveSource(string agentId) => agentId switch
{
"bao" or "nexus-system" => "bao",
_ => agentId
};
private static ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<T>
{
Ok = true,
Command = command,
Data = result.Data
});
var statusCode = result.Outcome switch
{
TaskBridgeOutcome.NotFound => 404,
TaskBridgeOutcome.InvalidState => 422,
TaskBridgeOutcome.Unauthorized => 403,
TaskBridgeOutcome.ValidationError => 400,
_ => 500
};
return new ObjectResult(new TaskBridgeCommandResponse<T>
{
Ok = false,
Command = command,
Error = result.Error ?? "Unknown error"
}) { StatusCode = statusCode };
}
private static ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
{
Ok = true,
Command = command,
Data = result.Data is null ? null : new ActivityEntryDto(
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt)
});
var statusCode = result.Outcome switch
{
TaskBridgeOutcome.NotFound => 404,
TaskBridgeOutcome.ValidationError => 400,
_ => 500
};
return new ObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
{
Ok = false,
Command = command,
Error = result.Error ?? "Unknown error"
}) { StatusCode = statusCode };
}
}
public sealed class TaskBridgeCommandResponse<T>
{
public bool Ok { get; init; }
public string Command { get; init; } = string.Empty;
public T? Data { get; init; }
public string? Error { get; init; }
public string Timestamp { get; init; } = DateTimeOffset.UtcNow.ToString("o");
}
public sealed record BridgeCreateTaskCommand(
string Title,
string? Detail = null,
string? Priority = null,
string? AssignedTo = null,
Guid? ProjectId = null
);
public sealed record BridgeCreateChildTaskCommand(
string Title,
string? Detail = null,
string? Priority = null,
string? AssignedTo = null,
string? ExpectedFrom = null
);
public sealed record BridgeUpdateStatusCommand(string State);
public sealed record BridgeAppendActivityCommand(
string Message,
string? Type = null
);
public sealed record BridgeHandoffCommand(
string TargetAgent,
string? Note = null
);
public sealed record ActivityEntryDto(
long Id,
string Type,
string Message,
DateTimeOffset CreatedAt
);
+3 -3
View File
@@ -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<string, object?>)e.Value.Data
});
entries["runtime"] = new
{
status = runtimeStatus,
description = runtimeDetail ?? "Runtime status checked",
data = (IReadOnlyDictionary<string, object>)new Dictionary<string, object>()
description = runtimeDetail,
data = (IReadOnlyDictionary<string, object?>)new Dictionary<string, object?>()
};
var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online";
@@ -31,6 +31,16 @@ public class NotificationsController(INotificationService notificationService) :
return Ok(new UnreadCountDto(count));
}
[HttpGet("snapshot")]
public async Task<ActionResult<NotificationSnapshotDto>> GetSnapshot(
[FromQuery] string forUser = "bao",
[FromQuery] int limit = 50,
[FromQuery] bool unreadOnly = false,
CancellationToken ct = default)
{
return Ok(await notificationService.GetSnapshotAsync(forUser, limit, unreadOnly, ct));
}
[HttpPatch("{id:guid}/read")]
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
{
@@ -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
+41 -4
View File
@@ -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<IResult> GetAll(CancellationToken ct)
@@ -111,21 +112,57 @@ public class TasksController(ITaskService taskService) : ControllerBase
/// <summary>
/// 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.
/// </summary>
[AllowAnonymous]
[HttpGet("board")]
public async Task<IResult> GetBoard(CancellationToken ct)
=> Results.Ok(await taskService.GetBoardAsync(ct));
{
// Erfordert mindestens einen identifizierbaren Agent-Aufrufer
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
var isApiKey = HttpContext.User.IsInRole("Service");
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
return Results.Unauthorized();
return Results.Ok(await taskService.GetBoardAsync(ct));
}
/// <summary>
/// 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.
/// </summary>
[AllowAnonymous]
[HttpPost("reset-stale")]
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
{
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
var isApiKey = HttpContext.User.IsInRole("Service");
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
// Nur iris, nexus-system (ApiKey) oder JWT-authenticated user
var isIris = string.Equals(agentHeader, "iris", StringComparison.OrdinalIgnoreCase);
if (!isIris && !isApiKey && !isAuth)
return Results.Unauthorized();
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
return Results.Ok(new ResetStaleResponse(count));
}
private async Task<string?> GetAllowedAgentHeaderAsync(CancellationToken ct)
{
var headerValue = HttpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerValue))
return null;
var normalized = headerValue.Trim().ToLowerInvariant();
var allowed = await agentService.GetAllowedAgentIdsAsync(ct);
return allowed.Contains(normalized) ? normalized : null;
}
}
@@ -216,9 +216,13 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IMemoryService, MemoryService>();
services.AddSingleton<IIncidentService, IncidentService>();
services.AddSingleton<IDocService, DocService>();
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ICalendarService, CalendarService>();
// ── Backend Bridge (Agent-Command-Service) ──
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
return services;
}
+36 -1
View File
@@ -93,7 +93,11 @@ public sealed record DashboardTaskDto(
bool IsAgentTask = false,
string? ExpectedFrom = null,
string? LastActivityMessage = null,
DateTimeOffset? LastActivityAt = null
DateTimeOffset? LastActivityAt = null,
List<DashboardTaskDto>? ChildTasks = null,
int ChildTaskCount = 0,
int OpenChildTaskCount = 0,
bool HasVisibleDelegation = false
);
public sealed record CreateDashboardTaskRequest(
@@ -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<NotificationDto> Notifications,
int UnreadCount,
string ForUser
);
public sealed record DashboardLiveSnapshotDto(
BoardResponse Board,
NotificationSnapshotDto Notifications,
LiveCursorDto Cursor
);
public sealed record DashboardLiveEventDto(
LiveUpdateEnvelope Envelope,
LiveCursorDto Cursor
);
+11
View File
@@ -73,6 +73,7 @@ public interface IAgentService
{
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
}
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
@@ -151,6 +152,16 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
);
}
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
{
var configs = await LoadAgentConfigsAsync(cancellationToken);
return configs
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
.Select(config => config.Id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
+17
View File
@@ -0,0 +1,17 @@
using System.Threading.Channels;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface ILiveUpdateService
{
Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default);
LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard");
long CurrentSequence { get; }
}
public sealed class LiveUpdateSubscription
{
public ChannelReader<LiveUpdateEnvelope> Reader { get; init; } = default!;
public long StartingSequence { get; init; }
}
+1
View File
@@ -10,4 +10,5 @@ public interface INotificationService
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
}
+133
View File
@@ -0,0 +1,133 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
/// <summary>
/// Structured backend bridge for agent/task commands.
/// Provides a clean, typed API for agents (Iris and sub-agents) to interact
/// with the task board, activity log, and delegation workflow.
///
/// This is the internal service layer — never exposed directly to the browser.
/// The GatewayBridgeController wraps this for agent-facing HTTP access.
/// </summary>
public interface ITaskBridgeService
{
// ── Task CRUD (Agent-Commands) ──
/// <summary>
/// Creates a new top-level task (parent or standalone).
/// Returns the created task DTO.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
Guid? projectId = null,
CancellationToken ct = default);
/// <summary>
/// Creates a child task linked to an existing parent.
/// This is the primary delegation command: iris creates a child task,
/// assigns it to a sub-agent, and tracks it on the board.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
Guid parentTaskId,
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
CancellationToken ct = default);
/// <summary>
/// Updates the status/state of a task.
/// Enforces CanChangeState rules (only iris/bao/nexus-system may change state).
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
Guid taskId,
string state,
string? callerAgent = null,
CancellationToken ct = default);
/// <summary>
/// Appends an activity entry to a task (comment, status note, agent note).
/// Used by agents to annotate their progress on the board.
/// </summary>
Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default);
/// <summary>
/// Handles a task handoff: sets ExpectedFrom to the target agent,
/// appends a handoff activity entry, and optionally updates assigned-to.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default);
// ── Query (Read) ──
/// <summary>
/// Returns the full task board state (grouped by status column).
/// </summary>
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
/// <summary>
/// Returns a single task by ID.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
Guid taskId,
CancellationToken ct = default);
/// <summary>
/// Returns all child tasks for a given parent task.
/// </summary>
Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId,
CancellationToken ct = default);
/// <summary>
/// Returns task activity history.
/// </summary>
Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId,
CancellationToken ct = default);
// ── Agent Workflow ──
/// <summary>
/// Returns the agent-workflow overview: who is expected to respond,
/// stale tasks, workload distribution.
/// </summary>
Task<AgentWorkflowOverview> GetAgentOverviewAsync(
TimeSpan? staleThreshold = null,
CancellationToken ct = default);
}
/// <summary>
/// Result pattern for task-bridge operations.
/// WorkTask? is null on NotFound; state is stored in the Outcome.
/// </summary>
public sealed record TaskBridgeResult<T>(
TaskBridgeOutcome Outcome,
T? Data = default,
string? Error = null
);
public enum TaskBridgeOutcome
{
Success,
NotFound,
InvalidState,
Unauthorized,
ValidationError
}
+87
View File
@@ -0,0 +1,87 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class LiveUpdateService : ILiveUpdateService
{
private const int ReplayLimit = 256;
private readonly ConcurrentDictionary<Guid, Channel<LiveUpdateEnvelope>> _subscribers = new();
private readonly object _historyLock = new();
private readonly Queue<LiveUpdateEnvelope> _history = new();
private long _sequence;
public long CurrentSequence => Interlocked.Read(ref _sequence);
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
{
var channel = Channel.CreateUnbounded<LiveUpdateEnvelope>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
var id = Guid.NewGuid();
_subscribers[id] = channel;
var replay = afterSequence.HasValue ? GetReplay(afterSequence.Value) : Array.Empty<LiveUpdateEnvelope>();
foreach (var envelope in replay)
{
channel.Writer.TryWrite(envelope);
}
ct.Register(() =>
{
if (_subscribers.TryRemove(id, out var removed))
{
removed.Writer.TryComplete();
}
});
return Task.FromResult(new LiveUpdateSubscription
{
Reader = channel.Reader,
StartingSequence = replay.LastOrDefault()?.Sequence ?? CurrentSequence
});
}
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
{
var envelope = new LiveUpdateEnvelope(
type,
DateTimeOffset.UtcNow,
payload,
Interlocked.Increment(ref _sequence),
channel);
lock (_historyLock)
{
_history.Enqueue(envelope);
while (_history.Count > ReplayLimit)
{
_history.Dequeue();
}
}
foreach (var (id, subscriber) in _subscribers)
{
if (!subscriber.Writer.TryWrite(envelope) && _subscribers.TryRemove(id, out var removed))
{
removed.Writer.TryComplete();
}
}
return envelope;
}
private LiveUpdateEnvelope[] GetReplay(long afterSequence)
{
lock (_historyLock)
{
return _history.Where(item => item.Sequence > afterSequence).ToArray();
}
}
}
+27 -2
View File
@@ -4,7 +4,7 @@ using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class NotificationService(NexusDbContext db) : INotificationService
public sealed class NotificationService(NexusDbContext db, ILiveUpdateService liveUpdateService) : INotificationService
{
public async Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
{
@@ -18,6 +18,7 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
};
db.Notifications.Add(notification);
await db.SaveChangesAsync(ct);
await PublishSnapshotAsync(notification.ForUser, ct);
return notification;
}
@@ -42,14 +43,17 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
notification.IsRead = true;
await db.SaveChangesAsync(ct);
await PublishSnapshotAsync(notification.ForUser, ct);
return true;
}
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
{
var normalizedUser = forUser.ToLowerInvariant();
var count = await db.Notifications
.Where(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead)
.Where(n => n.ForUser == normalizedUser && !n.IsRead)
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
await PublishSnapshotAsync(normalizedUser, ct);
return count;
}
@@ -58,4 +62,25 @@ public sealed class NotificationService(NexusDbContext db) : INotificationServic
return await db.Notifications
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
}
public async Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var normalizedUser = forUser.ToLowerInvariant();
var notifications = await GetForUserAsync(normalizedUser, limit, unreadOnly, ct);
var unreadCount = await GetUnreadCountAsync(normalizedUser, ct);
return new NotificationSnapshotDto(
notifications.Select(MapToDto).ToList(),
unreadCount,
normalizedUser);
}
private async Task PublishSnapshotAsync(string forUser, CancellationToken ct)
{
var snapshot = await GetSnapshotAsync(forUser, ct: ct);
liveUpdateService.Publish("notifications.snapshot", snapshot, "notifications");
}
private static NotificationDto MapToDto(Notification n) => new(
n.Id, n.Type, n.Title, n.Message,
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
}
+248
View File
@@ -0,0 +1,248 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
/// <summary>
/// Concrete implementation of ITaskBridgeService.
/// Wraps ITaskService, IActivityRepository, INotificationService, and ILiveUpdateService
/// into structured, predictable commands for agent-facing usage.
///
/// All operations produce typed TaskBridgeResult<T> with explicit error codes,
/// making agent consumption safe and debuggable.
/// </summary>
public sealed class TaskBridgeService(
ITaskService taskService,
IActivityRepository activityRepo,
INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ITaskBridgeService
{
private static readonly HashSet<string> ValidStates =
new(TaskStateHelper.AllStates, StringComparer.OrdinalIgnoreCase);
// ──────────────────────────────── Create Task ────────────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
Guid? projectId = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
var normalizedSource = NormalizeSource(source);
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateDashboardTaskAsync(
title.Trim(), detail?.Trim(), normalizedSource, priority, normalizedAssignee, parentTaskId: null, ct);
var dto = MapToDto(task);
return Success(dto);
}
// ──────────────────────────────── Create Child Task ──────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
Guid parentTaskId,
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
// Verify parent exists
var parent = await taskService.GetByIdAsync(parentTaskId, ct);
if (parent is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateAgentTaskAsync(
title.Trim(), detail?.Trim(), NormalizeSource(source),
priority, normalizedAssignee, expectedFrom, parentTaskId, ct);
// If parent was in Backlog, move it to InProgress (coordination starts)
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase))
{
await taskService.UpdateStatusAsync(parentTaskId, "In progress", ct);
}
var dto = MapToDto(task);
return Success(dto);
}
// ──────────────────────────────── Update Status ──────────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
Guid taskId,
string state,
string? callerAgent = null,
CancellationToken ct = default)
{
if (!ValidStates.Contains(state))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError,
$"Invalid state '{state}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}");
var task = await taskService.GetByIdAsync(taskId, ct);
if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
// Check authorization
if (!TaskStateHelper.CanChangeState(callerAgent, task))
return Error<DashboardTaskDto>(TaskBridgeOutcome.Unauthorized,
$"Agent '{callerAgent}' is not authorized to change task state. Only iris and bao may move tasks.");
var result = await taskService.UpdateStatusAsync(taskId, state, ct);
if (result.Outcome != TaskOperationOutcome.Success)
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
var dto = MapToDto(result.Task!);
return Success(dto);
}
// ──────────────────────────────── Append Activity ────────────────────────────
public async Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(message))
return Error<ActivityEvent>(TaskBridgeOutcome.ValidationError, "Message is required.");
var task = await taskService.GetByIdAsync(taskId, ct);
if (task is null)
return Error<ActivityEvent>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var ev = new ActivityEvent
{
Type = type ?? "comment",
Message = message.Trim(),
TaskId = taskId
};
await activityRepo.AddAsync(ev, ct);
// Trigger live update so the board refreshes
var board = await taskService.GetBoardAsync(ct);
liveUpdateService.Publish("tasks.board.snapshot", board);
return Success(ev);
}
// ──────────────────────────────── Handoff ────────────────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(targetAgent))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Target agent is required.");
var task = await taskService.GetByIdAsync(taskId, ct);
if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var normalizedTarget = targetAgent.Trim().ToLowerInvariant();
var handoffNote = string.IsNullOrWhiteSpace(note)
? $"Handoff → {normalizedTarget}"
: $"Handoff → {normalizedTarget}: {note.Trim()}";
// Update expected-from and optionally assigned-to
task.ExpectedFrom = normalizedTarget;
// If this is a child task (has parent), keep assigned-to on the child
// If standalone, set assigned-to to the target
if (!task.ParentTaskId.HasValue)
task.AssignedTo = normalizedTarget;
await taskService.UpdateDashboardTaskAsync(
taskId, title: null, detail: null, source: null,
priority: null, assignedTo: task.AssignedTo, dueDate: null, ct);
// Append handoff activity
await AppendActivityAsync(taskId, handoffNote, "handoff", ct);
// Notify the target
await notificationService.CreateAsync(
"task_assigned",
$"Handoff: {task.Title}",
handoffNote,
normalizedTarget,
task.Id,
ct);
var dto = MapToDto(task);
return Success(dto);
}
// ──────────────────────────────── Query ──────────────────────────────────────
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
=> await taskService.GetBoardAsync(ct);
public async Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
Guid taskId, CancellationToken ct = default)
{
var dto = await taskService.GetDashboardTaskByIdAsync(taskId, ct);
return dto is null
? Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.")
: Success(dto);
}
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default)
{
var children = await taskService.GetChildTasksAsync(parentTaskId, ct);
return children.Select(MapToDto).ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId, CancellationToken ct = default)
=> await taskService.GetTaskActivityAsync(taskId, ct);
public async Task<AgentWorkflowOverview> GetAgentOverviewAsync(
TimeSpan? staleThreshold = null, CancellationToken ct = default)
{
var threshold = staleThreshold ?? TimeSpan.FromHours(2);
return await taskService.GetAgentWorkflowOverviewAsync(threshold, ct);
}
// ──────────────────────────────── Helpers ────────────────────────────────────
private static TaskBridgeResult<T> Success<T>(T data) =>
new(TaskBridgeOutcome.Success, data);
private static TaskBridgeResult<T> Error<T>(TaskBridgeOutcome outcome, string error) =>
new(outcome, Data: default, Error: error);
private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
private static string? NormalizeAssignedTo(string? assignedTo)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
var valid = new HashSet<string> { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" };
var lower = assignedTo.Trim().ToLowerInvariant();
return valid.Contains(lower) ? lower : null;
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
}
+102 -94
View File
@@ -9,7 +9,8 @@ public sealed class TaskService(
ITaskRepository taskRepo,
IActivityRepository activityRepo,
INotificationService notificationService,
IHttpContextAccessor httpContextAccessor) : ITaskService
IHttpContextAccessor httpContextAccessor,
ILiveUpdateService liveUpdateService) : ITaskService
{
private static readonly HashSet<string> ValidAssignees =
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
@@ -22,11 +23,12 @@ public sealed class TaskService(
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
var allTasks = (await taskRepo.GetAllAsync(ct)).ToList();
var task = allTasks.FirstOrDefault(t => t.Id == id);
if (task is null) return null;
var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct);
return MapToDtoWithActivity(task, activity);
var activity = await activityRepo.GetRecentForTasksAsync(allTasks.Select(t => t.Id), ct);
return MapToDtoWithChildren(task, allTasks, activity);
}
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
@@ -42,6 +44,7 @@ public sealed class TaskService(
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return task;
}
@@ -56,6 +59,7 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -70,6 +74,7 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -81,7 +86,6 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
@@ -90,6 +94,7 @@ public sealed class TaskService(
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -112,13 +117,14 @@ public sealed class TaskService(
}
if (request.ProjectId.HasValue)
{
changes.Add($"Projekt-ID geändert");
changes.Add("Projekt-ID geändert");
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
}
await taskRepo.UpdateAsync(task, ct);
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -132,11 +138,10 @@ public sealed class TaskService(
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
await taskRepo.DeleteAsync(task, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success);
}
// ── Dashboard-facing operations ──
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
@@ -145,10 +150,6 @@ public sealed class TaskService(
.ToList();
}
/// <summary>
/// Returns agent-tasks that are still open and where an agent is expected to respond.
/// Iris Dashboard uses this to see who she is waiting for.
/// </summary>
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
@@ -159,22 +160,15 @@ public sealed class TaskService(
.ToList();
}
/// <summary>
/// Returns agent-tasks grouped by which agent is expected to respond,
/// with stale-detection: parent tasks that remain in progress while child work
/// is active, and any in-progress task that has not been updated within the stale threshold.
/// </summary>
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
=> tasks.Select(task => MapToDtoWithActivity(task, activity)).ToList();
=> tasks.Select(task => MapToDtoWithChildren(task, all, activity)).ToList();
var waitingForBao = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
@@ -193,19 +187,15 @@ public sealed class TaskService(
}));
var staleTasks = map(agentTasks
.Where(t =>
string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
t.UpdatedAt < threshold));
.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold));
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
staleTasks, staleThreshold);
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, staleTasks, staleThreshold);
}
public async Task<WorkTask> CreateDashboardTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
{
// Validate parent task exists if specified
if (parentTaskId.HasValue)
{
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
@@ -215,6 +205,7 @@ public sealed class TaskService(
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
var normalizedAssignee = ValidateAssignedTo(assignedTo);
var isVisibleDelegation = parentTaskId.HasValue;
var task = new WorkTask
{
@@ -224,16 +215,24 @@ public sealed class TaskService(
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = normalizedAssignee,
ParentTaskId = parentTaskId,
IsAgentTask = parentTaskId.HasValue
IsAgentTask = isVisibleDelegation
};
await taskRepo.AddAsync(task, ct);
var message = $"Task \"{task.Title}\" created ({task.Source})";
var activityMessages = new List<string> { $"Task \"{task.Title}\" created ({task.Source})" };
if (parentTaskId.HasValue)
message += $" [child of {parentTaskId.Value}]";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct);
{
activityMessages.Add($"Sichtbare Delegation erstellt: Child-Task von {parentTaskId.Value}.");
await activityRepo.AddAsync(new ActivityEvent
{
Type = "delegation",
Message = $"Board-first Delegation: Child-Task \"{task.Title}\" für {normalizedAssignee ?? task.Source} sichtbar angelegt.",
TaskId = parentTaskId.Value
}, ct);
}
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = string.Join(" ", activityMessages), TaskId = task.Id }, ct);
// Auto-notify: if assigned to bao, create a task_assigned notification
if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
@@ -245,6 +244,7 @@ public sealed class TaskService(
ct);
}
await PublishBoardSnapshotAsync(ct);
return task;
}
@@ -252,13 +252,12 @@ public sealed class TaskService(
string title, string? detail, string? source, string? priority,
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
{
var normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
task.IsAgentTask = true;
task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
task.ExpectedFrom = normalizedExpectedFrom;
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
// Persist the agent-task-specific fields
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
@@ -268,7 +267,16 @@ public sealed class TaskService(
TaskId = task.Id
}, ct);
// Notify iris about new agent-task
if (parentTaskId.HasValue)
{
await activityRepo.AddAsync(new ActivityEvent
{
Type = "delegation",
Message = $"Parent-/Child-Delegation sichtbar: Parent {parentTaskId.Value}, Child {task.Id}, wartet auf {task.ExpectedFrom ?? task.AssignedTo ?? "unbekannt"}.",
TaskId = parentTaskId.Value
}, ct);
}
await notificationService.CreateAsync(
"agent_task_created",
$"Neuer Agent-Task: {task.Title}",
@@ -277,6 +285,7 @@ public sealed class TaskService(
task.Id,
ct);
await PublishBoardSnapshotAsync(ct);
return task;
}
@@ -320,13 +329,10 @@ public sealed class TaskService(
task.AssignedTo = validated;
}
}
if (dueDate.HasValue)
if (dueDate.HasValue && task.DueDate?.Date != dueDate.Value.Date)
{
if (task.DueDate?.Date != dueDate.Value.Date)
{
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
task.DueDate = dueDate;
}
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
task.DueDate = dueDate;
}
await taskRepo.UpdateAsync(task, ct);
@@ -339,18 +345,18 @@ public sealed class TaskService(
TaskId = task.Id
}, ct);
// Notification: wenn Bao die Task geändert hat, Iris benachrichtigen
if (changes.Count > 0 && caller == "bao")
{
await notificationService.CreateAsync(
"task_content_changed",
$"Bao hat \"{task.Title}\" geändert",
$"{changeSummary}",
changeSummary,
"iris",
task.Id,
ct);
}
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -362,7 +368,6 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
@@ -372,6 +377,7 @@ public sealed class TaskService(
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -383,6 +389,7 @@ public sealed class TaskService(
task.State = "Done";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -401,14 +408,15 @@ public sealed class TaskService(
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
// ── Board operations ──
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
var offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
@@ -417,21 +425,15 @@ public sealed class TaskService(
foreach (var task in all)
{
var dto = MapToDto(task);
var dto = MapToDtoWithChildren(task, all, activity);
switch (task.State.ToLowerInvariant())
{
case "backlog":
offen.Add(dto); break;
case "in progress":
inProgress.Add(dto); break;
case "review":
review.Add(dto); break;
case "blocked":
blocked.Add(dto); break;
case "done":
done.Add(dto); break;
default:
offen.Add(dto); break;
case "backlog": offen.Add(dto); break;
case "in progress": inProgress.Add(dto); break;
case "review": review.Add(dto); break;
case "blocked": blocked.Add(dto); break;
case "done": done.Add(dto); break;
default: offen.Add(dto); break;
}
}
@@ -444,6 +446,12 @@ public sealed class TaskService(
return new BoardResponse(offen, inProgress, review, blocked, done);
}
private async Task PublishBoardSnapshotAsync(CancellationToken ct = default)
{
var board = await GetBoardAsync(ct);
liveUpdateService.Publish("tasks.board.snapshot", board, "board");
}
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
@@ -461,23 +469,14 @@ public sealed class TaskService(
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
{
// Resolve canonical state: accept board group keys or canonical strings
var canonical = TaskStateHelper.AllStates
.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase));
if (canonical is null)
{
// Try mapping from board group key
canonical = TaskStateHelper.BoardGroupToState(newState);
}
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase))
?? TaskStateHelper.BoardGroupToState(newState);
if (canonical is null)
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
@@ -486,6 +485,7 @@ public sealed class TaskService(
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -499,9 +499,7 @@ public sealed class TaskService(
{
var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = all.Where(t =>
string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
t.UpdatedAt < threshold).ToList();
var staleTasks = all.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold).ToList();
foreach (var task in staleTasks)
{
@@ -516,6 +514,9 @@ public sealed class TaskService(
}, ct);
}
if (staleTasks.Count > 0)
await PublishBoardSnapshotAsync(ct);
return staleTasks.Count;
}
@@ -533,12 +534,31 @@ public sealed class TaskService(
return all.Where(e => e.TaskId == taskId).ToList();
}
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
{
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
.OrderByDescending(t => t.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity, allTasks);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
};
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity)
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity, IReadOnlyList<WorkTask>? _allTasks = null)
{
var last = activity
.Where(e => e.TaskId == t.Id)
@@ -550,13 +570,13 @@ public sealed class TaskService(
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom,
last?.Message,
last?.CreatedAt);
last?.CreatedAt,
null,
0,
0,
t.ParentTaskId.HasValue || t.IsAgentTask);
}
/// <summary>
/// Validates AssignedTo — only recognized agent values are accepted.
/// Returns null for invalid values.
/// </summary>
private static string? ValidateAssignedTo(string? assignedTo)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
@@ -564,15 +584,10 @@ public sealed class TaskService(
return ValidAssignees.Contains(lower) ? lower : null;
}
/// <summary>
/// Resolves the caller identity from the HTTP context.
/// Reads the X-Agent-Id header for agent calls, falls back to JWT name.
/// Outside HTTP context → "nexus-system" (allowed for internal Cron/ResetStale ops).
/// </summary>
private string ResolveCaller()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "nexus-system"; // internal system ops allowed
if (httpContext is null) return "nexus-system";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
@@ -583,12 +598,6 @@ public sealed class TaskService(
return nameClaim?.ToLowerInvariant() ?? "";
}
/// <summary>
/// Creates status-change notifications when a task moves to a new state.
/// - Wenn Bao ändert → Iris benachrichtigen
/// - Wenn Iris ändert → Bao benachrichtigen
/// - Review/Blocked bekommen spezifische Töne
/// </summary>
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
{
var caller = ResolveCaller();
@@ -615,7 +624,6 @@ public sealed class TaskService(
}
else
{
// Allgemeine Statusänderung: Gegenüber benachrichtigen
if (caller == "bao")
{
await notificationService.CreateAsync(
@@ -0,0 +1,503 @@
# Nexus Board-First Orchestration & Sichere OpenClaw-Integration
> Gegenprüfung: Architekt, 2026-06-22
> Gegenstand: Sicherster Pfad für Board-first-Agent-Orchestrierung und MCP-artige/strukturierte
> OpenClaw-Integration im Nexus-Backend
> Kein Frontend-direkter MCP-Pfad. Kein Deploy.
## 1. Executive Summary
### 1.1 Prüfergebnis
Der eingeschlagene Pfad ist **architektonisch korrekt und sicher**. Das Board-first-Modell mit
Nexus-Backend als zentraler Brücke zwischen Benutzer, Board und OpenClaw-Gateway ist der richtige
Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren.
### 1.2 Kernbewertung
| Aspekt | Status | Bewertung |
|--------|--------|-----------|
| Board-first Architektur | ✅ Umsetzung läuft | Parent/Child-Modell korrekt implementiert |
| Kein Frontend-direkter Gateway-Zugriff | ✅ Eingehalten | Frontend spricht NUR mit Nexus-Backend |
| Backend als sichere Brücke | ✅ Eingehalten | API-Container proxyt alle Gateway-Calls |
| Auth-/Rechte-Modell | ✅ Solide | JWT + ApiKey + X-Agent-Id Enforcement |
| Gateway-Security | ⚠️ Verbesserbar | `loopback`-Bind muss auf `lan` für Docker |
| Migration-Reihenfolge | 📋 Vorgeschlagen | (siehe Abschnitt 8) |
## 2. Ist-Architektur: Wer spricht mit wem?
```
┌──────────────────────────────────────────────────────────────────┐
│ Browser (Bao/Iris) │
│ auth.ts → JWT Access Token (15m) + HttpOnly Refresh Cookie │
│ api.ts → fetch /api/v1/* → Authorization: Bearer <JWT> │
└───────────────────────────┬──────────────────────────────────────┘
│ HTTPS :443 (Traefik/npm)
┌──────────────────────────────────────────────────────────────────┐
│ Nexus web (nginx container) │
│ location /api/ → proxy_pass http://api:8080 │
│ location / → SPA (index.html) │
│ CSP: connect-src 'self' — kein externer Gateway-Call möglich │
└───────────────────────────┬──────────────────────────────────────┘
│ HTTP :8080 (internal network)
┌──────────────────────────────────────────────────────────────────┐
│ Nexus API (.NET 10 Container) ← SICHERE BRÜCKE │
│ │
│ Auth-Middleware (JWT → Controller) │
│ ApiKey-Middleware (X-Nexus-Api-Key → Role=Service) │
│ SecurityHeaders-Middleware (HSTS, CSP, XFO) │
│ Rate Limiter (auth: 5/min/caller, agents: 30/min/caller) │
│ │
│ IOpenClawGatewayClient │
│ ├─ InvokeToolAsync("session_status", ...) │
│ ├─ InvokeToolAsync("sessions_list", ...) │
│ ├─ InvokeToolAsync("sessions_history", ...) │
│ ├─ InvokeToolAsync("memory_search", ...) │
│ ├─ InvokeToolAsync("sessions_send", ...) │
│ └─ InvokeToolAsync("cron", ...) │
│ │
│ GatewayBridgeController (/api/bridge/*) <- NEU (2026-06-22) │
│ └─ ITaskBridgeService │
│ create_task / create_child_task / update_status / │
│ append_activity / handoff / get_board / get_agent_overview │
│ │
│ IAgentRuntime (OpenClawRuntime) │
│ └─ POST /v1/chat/completions (OpenAI-compat) │
│ │
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
└──────────────┬──────────────────────────────┬────────────────────┘
│ host.docker.internal:18789 │
│ (Gateway loopback/lan) │
▼ │
┌──────────────────────────────┐ │
│ OpenClaw Gateway Container │ │
│ Port 18789 │ │
│ Auth: password │ │
│ Bind: loopback (127.0.0.1) │ │
│ │ │
│ HTTP Deny-List (default): │ │
│ exec, spawn, shell, │ │
│ fs_write, fs_delete, │ │
│ fs_move, apply_patch, │ │
│ sessions_spawn, sessions_send│ │
│ cron, gateway, nodes, │ │
│ whatsapp_login │ │
└───────────────────────────────┴──────────────┘
```
### 2.1 Wichtig: Das Frontend sieht den Gateway NICHT
```
Browser ─── [Nexus API] ─── Gateway
+─ Gateway-Passwort lebt NUR im Backend
+─ CSP: connect-src 'self' blockiert jeden Direktzugriff
+─ Gateway HTTP Deny-List blockiert alle RCE-Tools
```
**Das ist die korrekte Architektur.** Kein Frontend-komponenten-direkter MCP-Pfad existiert
und keiner sollte eingeführt werden.
## 3. Board-First Orchestrierung: Ist-Stand & Bewertung
### 3.1 Das Parent/Child-Task-Modell (Phase 3)
```
Parent-Task (Owner: Iris)
├── Child-Task A (AssignedTo: programmer)
├── Child-Task B (AssignedTo: reviewer)
└── Child-Task C (AssignedTo: architekt)
```
**Status:** ✅ Implementiert (2026-06-21)
**Datenmodell (WorkTask):**
- `ParentTaskId` (Guid?) — verknüpft Child mit Parent
- `IsAgentTask` (bool) — markiert programmatisch erstellte Agent-Tasks
- `ExpectedFrom` (string?) — wer als nächstes antworten soll
- `AssignedTo` (string?) — operativer Owner der Task
**State Machine:**
```
Parent: Backlog → InProgress → Review → Done
↘ Blocked → Backlog
Child: Backlog → InProgress → Done
↘ Blocked → Backlog
```
**Rules (aus TaskStateHelper.CanChangeState):**
- **Nur Iris & Bao** dürfen State-Änderungen vornehmen
- Sub-Agenten (programmer, reviewer, architekt, researcher, executor) **niemals**
- `nexus-system` als technischer Fallback für Cron/Reset-Stale
### 3.2 Bewertung: Architektonisch korrekt
| Kriterium | Bewertung | Begründung |
|-----------|-----------|------------|
| Board als Single Source of Truth | ✅ | Task Board = sichtbare Aufgabenwahrheit |
| Delegation sichtbar | ✅ | Child-Tasks statt unsichtbarem Delegations-Status |
| Feingliedrige Berechtigung | ✅ | State-Change nur durch Iris/Bao |
| Agenten arbeiten gegen Child-Tasks | ✅ | Klare Ownership durch `AssignedTo` |
| Parent bleibt bei Iris | ✅ | Parent in `InProgress` während Koordination |
| Review-Gate für Bao | ✅ | Parent erst in `Review`, dann Bao-Entscheidung |
### 3.3 Offene Punkte im Board-Modell
1. **Keine automatische Child-Task-Erstellung** — Das Board-Modell setzt voraus, dass Iris manuell
Child-Tasks anlegt. Ein strukturierter Workflow für automatische Child-Task-Erstellung bei
`spawn`/Subagent-Aufrufen fehlt.
2. **Keine Task→Session-Verknüpfung** — Es gibt keine direkte Verknüpfung zwischen einer
Child-Task und der OpenClaw-Subagent-Session, die sie bearbeitet. Der `AgentService` kennt
Sessions, `TaskService` kennt Tasks — aber sie sind nicht verknüpft.
3. **Reset-Stale ist ungeschützt**`POST /api/v1/tasks/reset-stale` hat `[AllowAnonymous]`
und kann von jedem aufgerufen werden (siehe Risiko #1).
## 4. Auth- und Rechte-Modell
### 4.1 Authentifizierungsebenen
```
Ebene 1: Browser-JWT (Access Token 15m, Refresh Token HttpOnly Cookie)
Ebene 2: X-Nexus-Api-Key (Service-zu-Service, Role=Service)
Ebene 3: Gateway-Password (Backend → Gateway, Bearer Authorization)
Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
```
### 4.2 Berechtigungsmatrix
| Aktion | Bao | Iris | Sub-Agent | nexus-system | Service (ApiKey) |
|--------|-----|------|-----------|--------------|-------------------|
| Task State ändern | ✅ | ✅ | ❌ | ✅ (intern) | ❌ |
| Task Inhalt editieren | ✅ | ✅ | ✅ | ✅ | ✅ |
| Agent-Config lesen | ✅ | ✅ | ❌ | ❌ | ✅ |
| Gateway-Tool aufrufen | ❌ | ❌ | ❌ | ❌ | ✅ (intern) |
| Dashboard-Metriken sehen | ✅ | ✅ | ❌ | ❌ | ✅ |
### 4.3 Bewertung
**Positiv:**
- JWT-Sicherheit entspricht Best Practices (PBKDF2-SHA256, 210k Iterationen, Rotating Refresh Tokens)
- Refresh-Token-Reuse-Detection verhindert Token-Theft
- Rate-Limiting auf Login und Refresh
- CSRF-Protection via `X-CSRF-TOKEN` + `nexus-csrf` Cookie
- Security Headers (HSTS, CSP, XFO, Referrer-Policy)
**Kritisch:**
- `[AllowAnonymous]` auf `/tasks/board` und `/tasks/reset-stale` — siehe Risiko-Analyse
- Kein Scoped-ApiKey — der `X-Nexus-Api-Key` gibt volle Service-Rechte
- Keine Audit-Protokollierung für ApiKey-Nutzung
## 5. Gateway-Integration: Sicherheitsanalyse
### 5.1 Tool-Invoke-Pfad (Ist-Stand)
```
POST /api/v1/operations/snapshot
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
→ POST http://host.docker.internal:18789/tools/invoke
Authorization: Bearer <Gateway-Password>
```
**Aufgerufene Tools (durch Nexus-Backend):**
- `session_status` — Agent-Status abfragen (read-only)
- `sessions_list` — Session-Liste (read-only)
- `sessions_history` — Chat-Verlauf (read-only)
- `memory_search` — Memory-Suche (read-only)
- `sessions_send` — Chat-Nachricht senden (write, aber kontrolliert)
- `cron` — Cron-Jobs verwalten (write)
**Gateway HTTP Deny-List blockiert:**
- Alle Exec-Tools (exec, spawn, shell)
- Alle Filesystem-Tools (fs_write, fs_delete, fs_move, apply_patch)
- Gateway-Control-Plane (gateway)
- Node-Relay (nodes)
- Session-Orchestrierung (sessions_spawn, sessions_send)
### 5.2 Docker-Netzwerk & Gateway-Bind
**Aktuelles Problem:**
```
compose.yaml:
api:
extra_hosts:
- host.docker.internal:host-gateway
networks:
- nexus
- openclaw_default ← API-Container ist im Gateway-Netzwerk
Gateway-Konfiguration:
gateway.bind: "loopback" ← Bindet nur 127.0.0.1 IM GATEWAY-CONTAINER
```
**Ergebnis:**
- `host.docker.internal:18789` funktioniert, weil `extra_hosts` auf den Docker-Host zeigt
- ABER: Docker-Port-Forward (wenn vorhanden) sendet an Container-IP, nicht loopback
- Die `openclaw_default` Netzwerk-Mitgliedschaft des API-Containers wird NICHT genutzt
**Empfehlung (siehe gateway-api-research.md, Abschnitt 6):**
```json5
// openclaw.json
{
gateway: {
bind: "lan" // war "loopback"
}
}
```
Alternativ: API-Container über Gateway-Container-Namen ansprechen:
```yaml
Integrations__OpenClaw__BaseUrl: http://openclaw_gateway:18789
```
(Vorausgesetzt der Gateway-Container heißt `openclaw_gateway` und ist im `openclaw_default` Netzwerk)
### 5.3 MCP-artige Integration: Bewertung
**Das Gateway `/tools/invoke` ist bereits MCP-artig:**
- JSON-RPC-ähnliche Aufrufe mit `tool` + `args` + `sessionKey`
- Strukturierte Responses mit `{ ok, result, error }`
- Tool-Discovery via Policy (Deny/Allow-List)
- Request/Response mit eindeutiger Fehlersemantik
**Was fehlt für ein vollständiges MCP-Interface:**
- Keine Tool-Listing/Discovery über API (kein `tools/list`)
- Keine Schema-Validierung für Tool-Arguments
- Keine Structured Outputs (function-calling-ähnliches Format)
**Empfehlung: NICHT ein MCP-Protokoll zwischen Nexus und Gateway einführen.**
Stattdessen den bestehenden `/tools/invoke`-Pfad weiter nutzen und strukturieren.
### 5.4 Neue Backend-Bridge (Implementiert 2026-06-22)
Der Nexus-eigene strukturierte Kommando-Adapter wurde eingeführt:
```
Nexus Backend
├─ GatewayToolClient (bestehender OpenClawGatewayClient)
│ └─ POST /tools/invoke (Gateway)
├─ GatewayBridgeController (NEU — /api/bridge/)
│ ├─ POST /api/bridge/tasks (create_task)
│ ├─ POST /api/bridge/tasks/{id}/children (create_child_task)
│ ├─ PATCH /api/bridge/tasks/{id}/status (update_status)
│ ├─ POST /api/bridge/tasks/{id}/activity (append_activity)
│ ├─ POST /api/bridge/tasks/{id}/handoff (handoff)
│ ├─ GET /api/bridge/board (get_board)
│ ├─ GET /api/bridge/tasks/{id} (get_task)
│ ├─ GET /api/bridge/tasks/{id}/children (get_children)
│ ├─ GET /api/bridge/tasks/{id}/activity (get_activity)
│ └─ GET /api/bridge/agent-overview (get_agent_overview)
├─ ITaskBridgeService / TaskBridgeService (NEU)
│ └─ Typisierte TaskBridgeResult<T> mit Outcome: Success/NotFound/InvalidState/Unauthorized/ValidationError
├─ BoardOrchestrationService (offen)
│ ├─ Tasks erstellen/aktualisieren
│ ├─ Session-Status abfragen
│ └─ Agent-Progress berechnen
└─ AgentDelegationService (offen)
├─ Subagent-Task anlegen
├─ Session verfolgen
└─ Ergebnis integrieren
```
**Auth-Modell für /api/bridge:**
- Primär: `X-Agent-Id` Header (Agent-Identität vom Gateway)
- Fallback: JWT (Browser-authenticated user → bao)
- Fallback: `X-Nexus-Api-Key` (Backend-zu-Backend Service-Identität)
- Rate-Limiting: 30 Requests/Minute (agents-Policy)
**Das Frontend sieht /api/bridge NICHT.** Der Pfad ist ausschließlich für Agent-zu-Backend-Kommunikation.
## 6. Risikoanalyse
### 6.1 KRITISCH — `[AllowAnonymous]` auf Board-Endpunkten ✅ BEHOBEN (2026-06-22)
**Betroffen (vorher):**
```csharp
// TasksController.cs
[AllowAnonymous]
[HttpGet("board")] // Gab ALLE Tasks zurück — inkl. Detail-Texte
[AllowAnonymous]
[HttpPost("reset-stale")] // Konnte Tasks zurücksetzen — datenändernd
```
**Fix angewandt:**
- `[AllowAnonymous]` entfernt
- Inline-Auth-Check: `X-Agent-Id` Header, ApiKey-Rolle, oder JWT erforderlich
- `reset-stale` erfordert zusätzlich `X-Agent-Id: iris` Identität (nur Iris darf)
- Neuer `/api/bridge/` Pfad als sauberer Agent-zu-Backend-Adapter
- Rate-Limiting (agents-Policy: 30/min) auf Bridge-Endpunkte
### 6.2 MITTEL — Keine Task→Session-Verknüpfung
Wenn ein Subagent eine Child-Task bearbeitet, gibt es keine technische Verknüpfung zwischen
der Child-Task und der OpenClaw-Session. Das bedeutet:
- Keine automatische Status-Aktualisierung bei Session-Abschluss
- Keine Sitzungs-Historie direkt von der Task aus erreichbar
- Iris muss manuell prüfen, ob ein Agent fertig ist
**Empfehlung:**
- `WorkTask` um `SessionKey` (string?) erweitern
- Bei Child-Task-Erstellung Session-Key speichern
- Status-Polling: Wenn Session inaktiv, Child-Task auf Done/Blocked prüfen
### 6.3 MITTEL — Gateway-Passwort in Config-Dateien
Das Gateway-Passwort `ieDmOjBiVfbbDM0ibrEebPAg` ist:
- In `.env` auf dem Host (OK)
- In `gateway-api-research.md` (maskiert: `ieDm...PAg`)
- Im `openclaw.json` auf dem Host (OK)
- In der API-Container-Umgebungsvariable (notwendig)
**Empfehlung:**
- Gateway-Rate-Limiting aktiv halten (10 attempts/60s → 5min lockout)
- Gateway-Bind auf `lan` ändern (nicht öffentlich exponiert)
- `gateway-api-research.md` aus dem öffentlichen Repo entfernen oder Passwort entfernen
### 6.4 NIEDRIG — Keine Scoped API-Keys
Der `X-Nexus-Api-Key` gibt volle Service-Rechte. Es gibt keine Möglichkeit, verschiedene
API-Keys mit unterschiedlichen Rechten zu vergeben.
**Empfehlung (spätere Phase):**
- API-Key-Scopes einführen (read, write, admin)
- Rate-Limiting pro API-Key
- Audit-Log für ApiKey-Nutzung
### 6.5 NIEDRIG — Kein Structured Output vom Gateway
Die `/tools/invoke`-Responses sind JSON, aber ohne Schema-Garantie. Die Backend-Logik
extrahiert Felder mit vielen Fallbacks (`??`-Kettenantworten).
**Empfehlung:**
- Response-Typen für jedes Tool definieren (DTOs)
- Deserialisierung mit Schema-Validierung
- Fallback-Logik zentralisieren
## 7. Migrationsreihenfolge (Vorschlag)
### Phase 3b — Sichere Board-Grundlage (JETZT)
```
1. [AllowAnonymous] auf /tasks/board und /tasks/reset-stale fixen
→ ApiKey-Auth plus X-Agent-Id-Validierung
→ README/Iris-Doku aktualisieren
2. Gateway-Bind von loopback auf lan ändern
→ API-Container über openclaw_default Netzwerk ansprechen
→ host.docker.internal-Fallback entfernen
```
### Phase 4 — Strukturierte Orchestrierung
```
3. Task→Session-Verknüpfung einführen
→ WorkTask.SessionKey (string?)
→ Bei Child-Task-Erstellung Session speichern
4. AgentDelegationService
→ Zentralisierte Subagent-Task-Erstellung
→ Session-Status-Monitoring
→ Automatische Status-Propagation (Session done → Task done)
5. BoardOrchestrationService
→ Refresh-Intervall für Agent-Progress
→ Stale-Erkennung mit Session-Status
→ Priorisierung nach Workload
```
### Phase 5 — Erweiterte Integration
```
6. Structured Tool Responses
→ DTOs für jedes Gateway-Tool
→ Schema-Validierung
→ Caching für häufige Abfragen
7. API-Key-Scopes
→ read/write/admin Scopes
→ Audit-Log für ApiKey-Nutzung
8. Automatische Child-Task-Erstellung
→ Bei spawn/subagent-Aufrufen automatisch Child-Task anlegen
→ Session-Key verknüpfen
→ Activity-Feed erweitern
```
## 8. Sichere Brücke: Architekturprinzipien
### 8.1 Das Backend ist die einzige Brücke
```
Browser ←→ Nexus API ←→ OpenClaw Gateway
↑ ↑
JWT Auth Gateway Password
(pro User) (nur im Backend)
```
**Niemals:**
- Gateway-Passwort im Frontend
- Direkter Browser→Gateway API-Call
- MCP-Protokoll zwischen Frontend und Gateway
- Agent-Sessions direkt aus dem Frontend steuern
### 8.2 Prinzipien für jede neue Integration
1. **Neue Endpunkte immer im Nexus-Backend**
2. **Auth über bestehendes JWT/ApiKey-System**
3. **Gateway-Calls immer serverseitig mit Gateway-Passwort**
4. **Kein Gateway-Tool direkt aus dem Frontend aufrufen**
5. **State-Änderungen nur durch Iris/Bao (via Backend-Enforcement)**
6. **Activity/Audit für jede State-Änderung**
### 8.3 Strukturierte OpenClaw-Integration (MCP-artig)
Der Gateway `/tools/invoke`-Endpunkt ist bereits strukturell MCP-artig. Eine formale
MCP-Implementierung zwischen Nexus und Gateway ist **nicht notwendig**. Stattdessen:
```
Nexus.Backend.Services
├── IOpenClawGatewayClient (Gateway-Tool-Abstraktion)
│ └── InvokeToolAsync(tool, args) → JsonNode?
├── IBoardOrchestrationService (NEU)
│ ├── CreateDelegateTask(agentId, title, detail) → WorkTask
│ ├── WatchAgentSession(agentId) → SessionWatcher
│ └── SyncAgentProgress() → Progress[]
└── IAgentDelegationService (NEU)
├── DelegateToAgent(parentTaskId, agentId, instruction)
├── CollectResult(subTaskId) → AgentResult
└── HandleBlocker(subTaskId, reason) → void
```
## 9. Zusammenfassung
### Was gut ist (nicht ändern):
- Board-first-Ansatz mit Parent/Child-Tasks
- Backend als einzige Gateway-Brücke
- JWT + Gateway-Password-Trennung
- State-Change-Restriktion auf Iris/Bao
- HTTP Deny-List des Gateways
- CSP im Frontend (kein externer Connect)
### Was verbessert werden muss:
- `[AllowAnonymous]` auf Board-Endpunkten → ApiKey-Auth
- Gateway-Bind loopback → lan (oder Netzwerk-Routing korrigieren)
- Task→Session-Verknüpfung fehlt
### Was später kommen kann:
- Automatische Child-Task-Erstellung bei Subagent-Aufrufen
- Structured Gateway Responses mit Schema-Validierung
- API-Key-Scopes und Audit
- Session-gesteuertes Progress-Tracking
### Was niemals kommen darf:
- Gateway-Passwort im Frontend
- Direkter MCP-Pfad Browser→Gateway
- Agent-Session-Steuerung aus dem Frontend
- Task-State-Änderung durch Sub-Agenten
+6 -1
View File
@@ -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
+29
View File
@@ -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;
+5
View File
@@ -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' })
}
+94
View File
@@ -0,0 +1,94 @@
import { apiFetch } from './api'
export type LiveEventName = 'snapshot' | 'update' | 'heartbeat'
export type LiveMode = 'live' | 'polling'
export interface LiveCursorDto {
sequence: number
timestamp: string
mode: LiveMode
}
export interface LiveUpdateEnvelope {
type: string
timestamp: string
payload: unknown
sequence: number
channel: string
}
export interface DashboardLiveEventDto {
envelope: LiveUpdateEnvelope
cursor: LiveCursorDto
}
export interface OpenDashboardLiveStreamResult {
closed: Promise<void>
}
export async function openDashboardLiveStream(
onEvent: (event: LiveEventName, data: unknown) => void,
options: { forUser?: string; notificationLimit?: number; afterSequence?: number | null; signal?: AbortSignal } = {},
): Promise<OpenDashboardLiveStreamResult> {
const params = new URLSearchParams({
forUser: options.forUser ?? 'bao',
notificationLimit: String(options.notificationLimit ?? 50),
})
if (typeof options.afterSequence === 'number' && Number.isFinite(options.afterSequence) && options.afterSequence > 0) {
params.set('afterSequence', String(options.afterSequence))
}
const response = await apiFetch(`/api/dashboard/live?${params}`, {
method: 'GET',
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' },
signal: options.signal,
})
if (!response.ok || !response.body) {
throw new Error(`Live stream unavailable: HTTP ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const flushBlock = (block: string) => {
const lines = block.split('\n')
let eventName: LiveEventName = 'update'
const dataLines: string[] = []
for (const rawLine of lines) {
const line = rawLine.trimEnd()
if (line.startsWith('event:')) eventName = line.slice(6).trim() as LiveEventName
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
}
if (!dataLines.length) return
try {
onEvent(eventName, JSON.parse(dataLines.join('\n')))
} catch (error) {
console.warn('[live] failed to parse event payload', error)
}
}
const closed = (async () => {
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() ?? ''
for (const part of parts) {
if (part.trim()) flushBlock(part)
}
}
if (buffer.trim()) {
flushBlock(buffer)
buffer = ''
}
})()
return { closed }
}
+172
View File
@@ -0,0 +1,172 @@
import { defineStore } from 'pinia'
import { openDashboardLiveStream } from '../services/live'
import type { BoardGroup, DashboardTaskDto } from './tasks'
import type { NotificationItem } from './notifications'
import type { TaskItem } from '../components/dashboard/v2/types'
import { useTaskStore } from './tasks'
import { useNotificationStore } from './notifications'
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
interface NotificationSnapshotDto {
notifications: NotificationItem[]
unreadCount: number
forUser: string
}
interface DashboardLiveSnapshotDto {
board: BoardGroup
notifications: NotificationSnapshotDto
cursor: LiveCursorDto
}
function isBoardGroup(value: unknown): value is BoardGroup {
const v = value as BoardGroup
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
}
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
}
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
detail: t.detail,
source: t.source,
}
}
export const useLiveSyncStore = defineStore('liveSync', {
state: () => ({
connected: false,
connecting: false,
lastEventAt: null as string | null,
lastHeartbeatAt: null as string | null,
error: null as string | null,
controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
mode: 'polling' as 'polling' | 'live',
lastSequence: 0,
reconnectAttempts: 0,
}),
getters: {
liveIndicatorLabel: (state) => {
if (state.connecting) return 'Verbinde…'
if (state.connected) return `Live · #${state.lastSequence}`
return state.mode === 'polling' ? 'Polling' : 'Offline'
},
connectionHealth: (state) => {
if (state.connected) return 'healthy'
if (state.connecting) return 'connecting'
return 'degraded'
},
},
actions: {
async connect(forUser = 'bao') {
if (this.connecting || this.connected) return
this.connecting = true
this.error = null
this.controller = new AbortController()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
try {
const stream = await openDashboardLiveStream((event, data) => {
this.lastEventAt = new Date().toISOString()
if (event === 'heartbeat') {
const cursor = data as LiveCursorDto
this.lastHeartbeatAt = cursor.timestamp
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
return
}
if (event === 'snapshot') {
const snapshot = data as DashboardLiveSnapshotDto
taskStore.board = snapshot.board
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
notificationStore.notifications = snapshot.notifications.notifications
notificationStore.unreadCount = snapshot.notifications.unreadCount
this.lastSequence = snapshot.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
return
}
const eventDto = data as DashboardLiveEventDto
this.applyEnvelope(eventDto.envelope, forUser)
this.lastSequence = eventDto.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed
} catch (error) {
if (this.controller?.signal.aborted) return
console.warn('[liveSync] stream failed, falling back to polling', error)
this.error = 'Live updates unavailable'
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally {
this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) {
this.mode = 'polling'
}
}
},
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
taskStore.board = envelope.payload
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
return
}
if (envelope.type === 'notifications.snapshot') {
const snapshot = envelope.payload as NotificationSnapshotDto
if (snapshot.forUser !== forUser) return
notificationStore.notifications = snapshot.notifications
notificationStore.unreadCount = snapshot.unreadCount
}
},
disconnect() {
this.controller?.abort()
this.controller = null
this.connected = false
this.connecting = false
this.mode = 'polling'
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
},
scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect(forUser)
}, delay)
},
},
})
-1
View File
@@ -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(() => {
+6 -2
View File
@@ -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()
+16 -5
View File
@@ -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()
})
</script>
+4
View File
@@ -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()
})
</script>
+35 -2
View File
@@ -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<typeof flattenBoard>[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<typeof setInterval> | 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)
})
</script>
@@ -406,6 +422,10 @@ onUnmounted(() => {
<div>
<h1><span class="grad-text">Aufgaben</span></h1>
<p class="board-subtitle">Task Board Übersicht aller Arbeitspakete</p>
<div class="board-live-row">
<span class="live-pill" :class="liveModeClass">{{ liveModeLabel }}</span>
<span v-if="liveSyncStore.lastEventAt" class="live-meta">Letztes Event {{ relativeTime(liveSyncStore.lastEventAt) }}</span>
</div>
</div>
<div class="board-header-actions">
<button
@@ -546,6 +566,9 @@ onUnmounted(() => {
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
{{ delegationBadge(task) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
@@ -595,6 +618,9 @@ onUnmounted(() => {
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
{{ delegationBadge(task) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
@@ -644,6 +670,9 @@ onUnmounted(() => {
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
{{ delegationBadge(task) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
@@ -737,6 +766,9 @@ onUnmounted(() => {
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span v-if="delegationBadge(task)" class="expected-badge expected-badge--delegation" title="Sichtbare Delegation im Board">
{{ delegationBadge(task) }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
@@ -837,6 +869,7 @@ onUnmounted(() => {
<span v-if="selectedTask.isAgentTask" class="meta-agent-tag">🤖 Agent-Task</span>
<span v-if="selectedTask.expectedFrom" class="meta-expected"> Erwartet: {{ selectedTask.expectedFrom }}</span>
<span v-if="selectedTask.parentTaskId" class="meta-expected"> Child-Task</span>
<span v-if="delegationBadge(selectedTask)" class="meta-expected"> {{ delegationBadge(selectedTask) }}</span>
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
<span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</span>
<span v-if="selectedTask.isAgentTask"><MessageSquareText :size="13" /> Letzter Status {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }}</span>
+11 -1
View File
@@ -169,6 +169,13 @@ function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'ex
return taskLike.lastActivityMessage?.trim() || childStatusSummary(taskLike.id) || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
}
function delegationSummary(taskLike: TaskDto): string | null {
if (taskLike.parentTaskId) return 'Sichtbare Child-Delegation'
if (children.value.length) return `${children.value.length} sichtbare Child-Tasks`
if (taskLike.isAgentTask) return 'Delegation im Board sichtbar'
return null
}
/* ── API calls ───────────────────────────────── */
async function loadTask() {
loading.value = true
@@ -380,6 +387,7 @@ function handleKeydown(e: KeyboardEvent) {
<span v-if="task.isAgentTask" class="meta-chip">🤖 Agent-Task</span>
<span v-if="task.expectedFrom" class="meta-chip"> Erwartet: {{ task.expectedFrom }}</span>
<span v-if="task.parentTaskId" class="meta-chip"> Sichtbare Child-Task</span>
<span v-if="delegationSummary(task)" class="meta-chip">{{ delegationSummary(task) }}</span>
</div>
</div>
@@ -408,8 +416,9 @@ function handleKeydown(e: KeyboardEvent) {
</span>
</div>
<div v-if="task.isAgentTask || childStatusSummary(task.id)" class="progress-banner">
<div v-if="task.isAgentTask || childStatusSummary(task.id) || delegationSummary(task)" class="progress-banner">
<strong>Letzter Fortschritt:</strong> {{ progressHint(task) }}
<span v-if="delegationSummary(task)" class="delegation-inline">· {{ delegationSummary(task) }}</span>
</div>
<!-- Description -->
@@ -577,6 +586,7 @@ function handleKeydown(e: KeyboardEvent) {
<dl class="info-list">
<div><dt>ID</dt><dd>#{{ task.id.slice(0, 8) }}</dd></div>
<div><dt>Quelle</dt><dd>{{ task.source || '—' }}</dd></div>
<div v-if="delegationSummary(task)"><dt>Delegation</dt><dd>{{ delegationSummary(task) }}</dd></div>
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
+32
View File
@@ -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;
+2
View File
@@ -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)