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
+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;
}
}