feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
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>
|
||||
/// Authenticated, browser-safe OpenClaw control-plane facade.
|
||||
/// Gateway credentials and raw protocol payloads remain inside the backend.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/openclaw")]
|
||||
public sealed class OpenClawController(IOpenClawControlService openClaw) : ControllerBase
|
||||
{
|
||||
[HttpGet("connection")]
|
||||
[ProducesResponseType(typeof(OpenClawConnectionDto), StatusCodes.Status200OK)]
|
||||
public IResult GetConnection()
|
||||
=> Results.Ok(openClaw.GetConnection());
|
||||
|
||||
[HttpGet("capabilities")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<OpenClawCapabilityDto>), StatusCodes.Status200OK)]
|
||||
public IResult GetCapabilities()
|
||||
=> Results.Ok(openClaw.GetCapabilities());
|
||||
|
||||
[HttpGet("overview")]
|
||||
[ProducesResponseType(typeof(OpenClawOverviewDto), StatusCodes.Status200OK)]
|
||||
public async Task<IResult> GetOverview(CancellationToken cancellationToken)
|
||||
=> Results.Ok(await openClaw.GetOverviewAsync(cancellationToken));
|
||||
|
||||
[HttpGet("tasks")]
|
||||
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawTaskDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IResult> GetTasks(
|
||||
[FromQuery] int limit = 100,
|
||||
[FromQuery] string? cursor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Results.Ok(await openClaw.GetTasksAsync(limit, cursor, cancellationToken));
|
||||
|
||||
[HttpPost("tasks/{taskId}/cancel")]
|
||||
[Authorize(Roles = "owner")]
|
||||
[EnableRateLimiting("agents")]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawTaskDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IResult> CancelTask(
|
||||
string taskId,
|
||||
[FromBody] CancelOpenClawTaskRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(taskId))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["taskId"] = ["Task id is required."]
|
||||
});
|
||||
|
||||
return Results.Ok(await openClaw.CancelTaskAsync(
|
||||
taskId,
|
||||
request.Reason,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("sessions")]
|
||||
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawSessionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IResult> GetSessions(
|
||||
[FromQuery] int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Results.Ok(await openClaw.GetSessionsAsync(limit, cancellationToken));
|
||||
|
||||
[HttpPost("sessions/abort")]
|
||||
[Authorize(Roles = "owner")]
|
||||
[EnableRateLimiting("agents")]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IResult> AbortSession(
|
||||
[FromBody] AbortOpenClawSessionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.SessionKey))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["sessionKey"] = ["Session key is required."]
|
||||
});
|
||||
|
||||
return Results.Ok(await openClaw.AbortSessionAsync(
|
||||
request.SessionKey,
|
||||
request.RunId,
|
||||
request.ClearQueued,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("sessions/model")]
|
||||
[Authorize(Roles = "owner")]
|
||||
[EnableRateLimiting("agents")]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IResult> PatchSessionModel(
|
||||
[FromBody] PatchOpenClawSessionModelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var errors = new Dictionary<string, string[]>();
|
||||
if (string.IsNullOrWhiteSpace(request.SessionKey))
|
||||
errors["sessionKey"] = ["Session key is required."];
|
||||
if (string.IsNullOrWhiteSpace(request.Model))
|
||||
errors["model"] = ["Model is required."];
|
||||
if (errors.Count > 0)
|
||||
return Results.ValidationProblem(errors);
|
||||
|
||||
return Results.Ok(await openClaw.PatchSessionModelAsync(
|
||||
request.SessionKey,
|
||||
request.Model,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("cron")]
|
||||
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawCronJobDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IResult> GetCronJobs(
|
||||
[FromQuery] bool includeDisabled = true,
|
||||
[FromQuery] int limit = 100,
|
||||
[FromQuery] string? cursor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Results.Ok(await openClaw.GetCronJobsAsync(
|
||||
includeDisabled,
|
||||
limit,
|
||||
cursor,
|
||||
cancellationToken));
|
||||
|
||||
[HttpGet("cron/{jobId}")]
|
||||
[Authorize(Roles = "owner")]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IResult> GetCronJob(
|
||||
string jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
return MissingCronJobId();
|
||||
|
||||
var result = await openClaw.GetCronJobAsync(jobId, cancellationToken);
|
||||
return result.State == "not_found"
|
||||
? Results.NotFound(result)
|
||||
: Results.Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("cron/{jobId}/runs")]
|
||||
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawCronRunDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IResult> GetCronRuns(
|
||||
string jobId,
|
||||
[FromQuery] int limit = 100,
|
||||
[FromQuery] string? cursor = null,
|
||||
[FromQuery] string? runId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
return MissingCronJobId();
|
||||
|
||||
return Results.Ok(await openClaw.GetCronRunsAsync(
|
||||
jobId,
|
||||
limit,
|
||||
cursor,
|
||||
runId,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("cron")]
|
||||
[Authorize(Roles = "owner")]
|
||||
[EnableRateLimiting("agents")]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IResult> CreateCronJob(
|
||||
[FromBody] CreateOpenClawCronJobRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var headerError = RequireIdempotencyKey();
|
||||
if (headerError is not null)
|
||||
return headerError;
|
||||
|
||||
return CronMutationResult(await openClaw.CreateCronJobAsync(
|
||||
request,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPatch("cron/{jobId}")]
|
||||
[Authorize(Roles = "owner")]
|
||||
[EnableRateLimiting("agents")]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IResult> PatchCronJob(
|
||||
string jobId,
|
||||
[FromBody] PatchOpenClawCronJobRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
return MissingCronJobId();
|
||||
if (request.Patch is null)
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["patch"] = ["Cron patch is required."]
|
||||
});
|
||||
var headerError = RequireIdempotencyKey();
|
||||
if (headerError is not null)
|
||||
return headerError;
|
||||
var expectedHash = ResolveExpectedHash(request.ExpectedHash);
|
||||
if (string.IsNullOrWhiteSpace(expectedHash))
|
||||
return MissingExpectedHash();
|
||||
|
||||
return CronMutationResult(await openClaw.PatchCronJobAsync(
|
||||
jobId,
|
||||
request.Patch,
|
||||
expectedHash,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpDelete("cron/{jobId}")]
|
||||
[Authorize(Roles = "owner")]
|
||||
[EnableRateLimiting("agents")]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IResult> DeleteCronJob(
|
||||
string jobId,
|
||||
[FromQuery] string? expectedHash = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
return MissingCronJobId();
|
||||
var headerError = RequireIdempotencyKey();
|
||||
if (headerError is not null)
|
||||
return headerError;
|
||||
expectedHash = ResolveExpectedHash(expectedHash);
|
||||
if (string.IsNullOrWhiteSpace(expectedHash))
|
||||
return MissingExpectedHash();
|
||||
|
||||
return CronMutationResult(await openClaw.DeleteCronJobAsync(
|
||||
jobId,
|
||||
expectedHash,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpPost("cron/{jobId}/run")]
|
||||
[Authorize(Roles = "owner")]
|
||||
[EnableRateLimiting("agents")]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status409Conflict)]
|
||||
public async Task<IResult> RunCronJob(
|
||||
string jobId,
|
||||
[FromQuery] string? expectedHash = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jobId))
|
||||
return MissingCronJobId();
|
||||
var headerError = RequireIdempotencyKey();
|
||||
if (headerError is not null)
|
||||
return headerError;
|
||||
expectedHash = ResolveExpectedHash(expectedHash);
|
||||
if (string.IsNullOrWhiteSpace(expectedHash))
|
||||
return MissingExpectedHash();
|
||||
|
||||
return CronMutationResult(await openClaw.RunCronJobAsync(
|
||||
jobId,
|
||||
expectedHash,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("approvals")]
|
||||
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawApprovalDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IResult> GetApprovals(
|
||||
[FromQuery] int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Results.Ok(await openClaw.GetApprovalsAsync(limit, cancellationToken));
|
||||
|
||||
[HttpPost("approvals/{approvalId}/resolve")]
|
||||
[Authorize(Roles = "owner")]
|
||||
[EnableRateLimiting("agents")]
|
||||
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawApprovalDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
public async Task<IResult> ResolveApproval(
|
||||
string approvalId,
|
||||
[FromBody] ResolveOpenClawApprovalRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(approvalId))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["approvalId"] = ["Approval id is required."]
|
||||
});
|
||||
if (string.IsNullOrWhiteSpace(request.Kind))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["kind"] = ["Approval kind is required."]
|
||||
});
|
||||
|
||||
return Results.Ok(await openClaw.ResolveApprovalAsync(
|
||||
approvalId,
|
||||
request.Kind,
|
||||
request.Decision,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("activity")]
|
||||
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawActivityDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IResult> GetActivity(
|
||||
[FromQuery] int limit = 100,
|
||||
[FromQuery] string? cursor = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Results.Ok(await openClaw.GetActivityAsync(limit, cursor, cancellationToken));
|
||||
|
||||
[HttpGet("models")]
|
||||
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawModelDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IResult> GetModels(CancellationToken cancellationToken)
|
||||
=> Results.Ok(await openClaw.GetModelsAsync(cancellationToken));
|
||||
|
||||
[HttpGet("models/auth-status")]
|
||||
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawModelAuthProviderDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IResult> GetModelAuthStatus(
|
||||
[FromQuery] bool refresh = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> Results.Ok(await openClaw.GetModelAuthStatusAsync(refresh, cancellationToken));
|
||||
|
||||
[HttpGet("agents")]
|
||||
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawAgentDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IResult> GetAgents(CancellationToken cancellationToken)
|
||||
=> Results.Ok(await openClaw.GetAgentsAsync(cancellationToken));
|
||||
|
||||
private string? ResolveExpectedHash(string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
return Request.Headers.IfMatch.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static IResult MissingCronJobId()
|
||||
=> Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["jobId"] = ["Cron job id is required."]
|
||||
});
|
||||
|
||||
private static IResult MissingExpectedHash()
|
||||
=> Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["expectedHash"] =
|
||||
[
|
||||
"A current OpenClaw resource hash is required for this cron mutation."
|
||||
]
|
||||
});
|
||||
|
||||
private IResult? RequireIdempotencyKey()
|
||||
{
|
||||
var value = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(value) &&
|
||||
value.Length <= 128 &&
|
||||
!value.Any(char.IsControl))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["Idempotency-Key"] =
|
||||
[
|
||||
"A non-empty Idempotency-Key header with at most 128 non-control characters is required."
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult CronMutationResult<T>(OpenClawOperationDto<T> result)
|
||||
{
|
||||
return result.State switch
|
||||
{
|
||||
"conflict" => Results.Json(result, statusCode: StatusCodes.Status409Conflict),
|
||||
"not_found" => Results.Json(result, statusCode: StatusCodes.Status404NotFound),
|
||||
"invalid" => Results.Json(result, statusCode: StatusCodes.Status400BadRequest),
|
||||
"restricted" or "management_disabled" or "forbidden" =>
|
||||
Results.Json(result, statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.Ok(result)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user