feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Owner-only approval boundary for OpenClaw agent creation. Proposal creation
|
||||
/// never mutates OpenClaw; approval queues a durable request only after every
|
||||
/// live management gate passes.
|
||||
/// </summary>
|
||||
[Authorize(Roles = "owner")]
|
||||
[ApiController]
|
||||
[Route("api/v1/openclaw/agent-proposals")]
|
||||
public sealed class OpenClawAgentProposalsController(
|
||||
IAgentProposalService proposals) : ControllerBase
|
||||
{
|
||||
[HttpGet("~/api/v1/openclaw/agents/create-options")]
|
||||
public async Task<ActionResult<AgentCreateOptionsDto>> GetCreateOptions(
|
||||
CancellationToken cancellationToken)
|
||||
=> Ok(await proposals.GetCreateOptionsAsync(cancellationToken));
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<AgentProposalCollectionDto>> Get(
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] string? cursor = null,
|
||||
[FromQuery] string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await proposals.GetAsync(
|
||||
limit,
|
||||
cursor,
|
||||
status,
|
||||
cancellationToken));
|
||||
}
|
||||
catch (AgentProposalValidationException exception)
|
||||
{
|
||||
return Validation(exception);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}", Name = "GetAgentProposal")]
|
||||
public async Task<ActionResult<AgentProposalDto>> GetById(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var proposal = await proposals.GetByIdAsync(
|
||||
id,
|
||||
includeFileContent: true,
|
||||
cancellationToken);
|
||||
return proposal is null ? NotFound() : Ok(proposal);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[EnableRateLimiting("agents")]
|
||||
public async Task<ActionResult<AgentProposalOperationDto>> Create(
|
||||
[FromBody] CreateAgentProposalRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryBuildInvocation(out var invocation, out var error))
|
||||
return error!;
|
||||
try
|
||||
{
|
||||
var result = await proposals.CreateAsync(
|
||||
request,
|
||||
"manual",
|
||||
invocation!,
|
||||
cancellationToken);
|
||||
return result.Ok
|
||||
? CreatedAtRoute(
|
||||
"GetAgentProposal",
|
||||
new { id = result.Proposal!.Id },
|
||||
result)
|
||||
: StatusCode(StatusFor(result.State), result);
|
||||
}
|
||||
catch (AgentProposalValidationException exception)
|
||||
{
|
||||
return Validation(exception);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
[EnableRateLimiting("agents")]
|
||||
public Task<ActionResult<AgentProposalOperationDto>> Approve(
|
||||
Guid id,
|
||||
[FromBody] AgentProposalActionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> Mutate(
|
||||
id,
|
||||
request,
|
||||
proposals.ApproveAsync,
|
||||
acceptedWhenProvisioning: true,
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
[EnableRateLimiting("agents")]
|
||||
public Task<ActionResult<AgentProposalOperationDto>> Reject(
|
||||
Guid id,
|
||||
[FromBody] AgentProposalActionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> Mutate(
|
||||
id,
|
||||
request,
|
||||
proposals.RejectAsync,
|
||||
acceptedWhenProvisioning: false,
|
||||
cancellationToken);
|
||||
|
||||
[HttpPost("{id:guid}/retry")]
|
||||
[EnableRateLimiting("agents")]
|
||||
public Task<ActionResult<AgentProposalOperationDto>> Retry(
|
||||
Guid id,
|
||||
[FromBody] AgentProposalActionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
=> Mutate(
|
||||
id,
|
||||
request,
|
||||
proposals.RetryAsync,
|
||||
acceptedWhenProvisioning: true,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<ActionResult<AgentProposalOperationDto>> Mutate(
|
||||
Guid id,
|
||||
AgentProposalActionRequest request,
|
||||
Func<
|
||||
Guid,
|
||||
AgentProposalActionRequest,
|
||||
OpenClawInvocationMetadata,
|
||||
CancellationToken,
|
||||
Task<AgentProposalOperationDto>> operation,
|
||||
bool acceptedWhenProvisioning,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryBuildInvocation(out var invocation, out var error))
|
||||
return error!;
|
||||
try
|
||||
{
|
||||
var result = await operation(
|
||||
id,
|
||||
request,
|
||||
invocation!,
|
||||
cancellationToken);
|
||||
if (!result.Ok)
|
||||
return StatusCode(StatusFor(result.State), result);
|
||||
if (acceptedWhenProvisioning && result.State == "provisioning")
|
||||
{
|
||||
return AcceptedAtRoute(
|
||||
"GetAgentProposal",
|
||||
new { id = result.Proposal!.Id },
|
||||
result);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (AgentProposalValidationException exception)
|
||||
{
|
||||
return Validation(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryBuildInvocation(
|
||||
out OpenClawInvocationMetadata? invocation,
|
||||
out ActionResult<AgentProposalOperationDto>? error)
|
||||
{
|
||||
invocation = null;
|
||||
error = null;
|
||||
var idempotencyKey = Request.Headers["Idempotency-Key"]
|
||||
.FirstOrDefault()
|
||||
?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(idempotencyKey)
|
||||
|| idempotencyKey.Length > 200)
|
||||
{
|
||||
error = new BadRequestObjectResult(new ValidationProblemDetails(
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
["Idempotency-Key"] =
|
||||
[
|
||||
"A non-empty Idempotency-Key header with at most 200 characters is required."
|
||||
]
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
var correlationId = Request.Headers["X-Correlation-ID"]
|
||||
.FirstOrDefault()
|
||||
?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
correlationId = HttpContext.TraceIdentifier;
|
||||
if (correlationId.Length > 200)
|
||||
{
|
||||
error = new BadRequestObjectResult(new ValidationProblemDetails(
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
["X-Correlation-ID"] =
|
||||
[
|
||||
"X-Correlation-ID must contain at most 200 characters."
|
||||
]
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
var traceParent = Request.Headers["traceparent"].FirstOrDefault()?.Trim()
|
||||
?? Activity.Current?.Id;
|
||||
if (traceParent?.Length > 128
|
||||
|| (!string.IsNullOrWhiteSpace(traceParent)
|
||||
&& !ActivityContext.TryParse(traceParent, null, out _)))
|
||||
{
|
||||
error = new BadRequestObjectResult(new ValidationProblemDetails(
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
["traceparent"] =
|
||||
[
|
||||
"traceparent must be a valid W3C trace context with at most 128 characters."
|
||||
]
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
|
||||
var actor = User.FindFirst("sub")?.Value
|
||||
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? User.FindFirst(ClaimTypes.Email)?.Value
|
||||
?? User.Identity?.Name
|
||||
?? "authenticated-owner";
|
||||
Response.Headers["X-Correlation-ID"] = correlationId;
|
||||
invocation = new OpenClawInvocationMetadata(
|
||||
idempotencyKey,
|
||||
correlationId,
|
||||
actor,
|
||||
traceParent);
|
||||
return true;
|
||||
}
|
||||
|
||||
private BadRequestObjectResult Validation(
|
||||
AgentProposalValidationException exception)
|
||||
=> new BadRequestObjectResult(new ValidationProblemDetails(
|
||||
new Dictionary<string, string[]>
|
||||
{
|
||||
[exception.Field] = [exception.Message]
|
||||
}));
|
||||
|
||||
private static int StatusFor(string state)
|
||||
=> state switch
|
||||
{
|
||||
"invalid"
|
||||
or "invalid_state"
|
||||
or "invalid_stage"
|
||||
=> StatusCodes.Status400BadRequest,
|
||||
"not_found"
|
||||
=> StatusCodes.Status404NotFound,
|
||||
"concurrency_conflict"
|
||||
or "idempotency_conflict"
|
||||
or "agent_exists"
|
||||
=> StatusCodes.Status409Conflict,
|
||||
"experimental_blocked"
|
||||
or "management_disabled"
|
||||
or "scope_upgrade_required"
|
||||
or "capability_missing"
|
||||
or "capability_drift"
|
||||
or "version_mismatch"
|
||||
or "workspace_root_invalid"
|
||||
=> StatusCodes.Status403Forbidden,
|
||||
"gateway_unavailable"
|
||||
or "inventory_unavailable"
|
||||
=> StatusCodes.Status503ServiceUnavailable,
|
||||
_ => StatusCodes.Status502BadGateway
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user