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> 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> 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> 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> 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 { [exception.Field] = [exception.Message] })); } } [HttpPost("{id:guid}/stop")] [Authorize(Roles = "owner")] [EnableRateLimiting("agents")] public async Task> 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> 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> 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 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? 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 { ["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 { ["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 { ["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? ValidateStart( StartOpenClawRunRequest request) { var errors = new Dictionary(); 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( new BadRequestObjectResult(new ValidationProblemDetails(errors))); } private static ActionResult? ValidateReason(string? reason) { if (reason?.Length is not > 1000) return null; return new ActionResult( new BadRequestObjectResult(new ValidationProblemDetails( new Dictionary { ["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 }; }