feat: board-first orchestration with Gateway Bridge, live-update, and flow-board
- GatewayBridgeController: MCP-artiger Kommando-Adapter für Agent-zu-Backend - TaskBridgeService + LiveUpdateService: SSE Live-Sync + Bridge-Kommandos - FlowBoard.vue: Board-first orchestration dashboard panel - live-sync.ts store + live.ts service: SSE-basierte Live-Updates - Nullability-Warnung in HealthController.cs gefixt - nginx.conf: SSE-Proxy + CORS für Bridge-Endpunkte - .gitignore: pnpm/corepack local caches ausgeschlossen - docs: architecture-board-first-orchestration.md hinzugefügt - README: Backend Bridge API dokumentiert
This commit is contained in:
@@ -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
|
||||
);
|
||||
Reference in New Issue
Block a user