Files
nexus/backend/Controllers/GatewayBridgeController.cs
T
devops df94ed3cd4
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
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
2026-06-22 19:56:45 +02:00

369 lines
13 KiB
C#

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
);