288 lines
10 KiB
C#
288 lines
10 KiB
C#
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
|
|
};
|
|
}
|