feat: ship agent-first mission control v0.2.57
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s

This commit is contained in:
AzuTear
2026-07-31 22:39:47 +02:00
parent 3bc7622977
commit f5552218bc
535 changed files with 95242 additions and 8791 deletions
@@ -0,0 +1,287 @@
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;
[Authorize]
[ApiController]
[Route("api/v1/openclaw/runs")]
public sealed class OpenClawRunsController(IOpenClawRunService runs) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<OpenClawRunCollectionDto>> Get(
[FromQuery] int limit = 50,
[FromQuery] string? cursor = null,
[FromQuery] string? status = null,
[FromQuery] Guid? taskId = null,
[FromQuery] Guid? projectId = null,
[FromQuery] string? sessionKey = null,
CancellationToken cancellationToken = default)
=> Ok(await runs.GetAsync(
new OpenClawRunQuery(limit, cursor, status, taskId, projectId, sessionKey),
cancellationToken));
[HttpGet("{id:guid}", Name = "GetOpenClawRun")]
public async Task<ActionResult<OpenClawRunDto>> GetById(
Guid id,
CancellationToken cancellationToken)
{
var run = await runs.GetByIdAsync(id, cancellationToken);
return run is null ? NotFound() : Ok(run);
}
[HttpGet("{id:guid}/history")]
public async Task<ActionResult<OpenClawRunHistoryResponse>> GetHistory(
Guid id,
[FromQuery] int gatewayLimit = 200,
CancellationToken cancellationToken = default)
{
var result = await runs.GetHistoryAsync(
id,
Math.Clamp(gatewayLimit, 1, 1000),
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawRunOperationDto>> Start(
[FromBody] StartOpenClawRunRequest request,
CancellationToken cancellationToken)
{
var validation = ValidateStart(request);
if (validation is not null)
return validation;
if (!TryBuildInvocation(out var invocation, out var metadataError))
return metadataError!;
try
{
var result = await runs.StartAsync(request, invocation!, cancellationToken);
if (result.Ok)
{
return CreatedAtRoute(
"GetOpenClawRun",
new { id = result.Run.Id },
result);
}
if (result.State != "idempotency_conflict")
{
return AcceptedAtRoute(
"GetOpenClawRun",
new { id = result.Run.Id },
result);
}
return StatusCode(StatusFor(result.State), result);
}
catch (OpenClawRunValidationException exception)
{
return new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
[exception.Field] = [exception.Message]
}));
}
}
[HttpPost("{id:guid}/stop")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawRunOperationDto>> Stop(
Guid id,
[FromBody] OpenClawRunActionRequest? request,
CancellationToken cancellationToken)
{
var reasonError = ValidateReason(request?.Reason);
if (reasonError is not null)
return reasonError;
if (!TryBuildInvocation(out var invocation, out var metadataError))
return metadataError!;
var result = await runs.StopAsync(
id,
request?.Reason,
invocation!,
cancellationToken);
return MapOperation(result);
}
[HttpPost("{id:guid}/resume")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawRunOperationDto>> Resume(
Guid id,
[FromBody] OpenClawRunActionRequest? request,
CancellationToken cancellationToken)
{
var reasonError = ValidateReason(request?.Reason);
if (reasonError is not null)
return reasonError;
if (!TryBuildInvocation(out var invocation, out var metadataError))
return metadataError!;
var result = await runs.ResumeAsync(
id,
request?.Reason,
invocation!,
cancellationToken);
return MapOperation(result);
}
[HttpPost("{id:guid}/retry")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawRunOperationDto>> Retry(
Guid id,
[FromBody] OpenClawRunActionRequest? request,
CancellationToken cancellationToken)
{
var reasonError = ValidateReason(request?.Reason);
if (reasonError is not null)
return reasonError;
if (!TryBuildInvocation(out var invocation, out var metadataError))
return metadataError!;
var result = await runs.RetryAsync(
id,
request?.Reason,
invocation!,
cancellationToken);
if (result is null)
return NotFound();
if (result.ResultRun is not null)
{
return result.Ok
? CreatedAtRoute(
"GetOpenClawRun",
new { id = result.ResultRun.Id },
result)
: AcceptedAtRoute(
"GetOpenClawRun",
new { id = result.ResultRun.Id },
result);
}
return StatusCode(StatusFor(result.State), result);
}
private ActionResult<OpenClawRunOperationDto> MapOperation(OpenClawRunOperationDto? result)
{
if (result is null)
return NotFound();
return result.Ok
? Ok(result)
: StatusCode(StatusFor(result.State), result);
}
private bool TryBuildInvocation(
out OpenClawInvocationMetadata? invocation,
out ActionResult<OpenClawRunOperationDto>? 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-user";
Response.Headers["X-Correlation-ID"] = correlationId;
invocation = new OpenClawInvocationMetadata(
idempotencyKey,
correlationId,
actor,
traceParent);
return true;
}
private static ActionResult<OpenClawRunOperationDto>? ValidateStart(
StartOpenClawRunRequest request)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.Prompt))
errors["prompt"] = ["Prompt is required."];
else if (request.Prompt.Length > 50_000)
errors["prompt"] = ["Prompt must contain at most 50000 characters."];
if (string.IsNullOrWhiteSpace(request.AgentId))
errors["agentId"] = ["Agent id is required."];
else if (request.AgentId.Length > 200)
errors["agentId"] = ["Agent id must contain at most 200 characters."];
if (string.IsNullOrWhiteSpace(request.SessionKey))
errors["sessionKey"] = ["Session key is required."];
else if (request.SessionKey.Length > 500)
errors["sessionKey"] = ["Session key must contain at most 500 characters."];
if (request.Title?.Length > 160)
errors["title"] = ["Title must contain at most 160 characters."];
if (errors.Count == 0)
return null;
return new ActionResult<OpenClawRunOperationDto>(
new BadRequestObjectResult(new ValidationProblemDetails(errors)));
}
private static ActionResult<OpenClawRunOperationDto>? ValidateReason(string? reason)
{
if (reason?.Length is not > 1000)
return null;
return new ActionResult<OpenClawRunOperationDto>(
new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["reason"] = ["Reason must contain at most 1000 characters."]
})));
}
private static int StatusFor(string state)
=> state switch
{
"idempotency_conflict" or "invalid_state" or "unsupported" => StatusCodes.Status409Conflict,
"blocked" => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
}