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
+19 -9
View File
@@ -1,14 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/activity")]
public class ActivityController(IActivityRepository activityRepo) : ControllerBase
public sealed class ActivityController(IActivityRepository activityRepo) : ControllerBase
{
[HttpGet]
public async Task<IResult> Get(
[ProducesResponseType(typeof(ActivityPageDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<ActivityPageDto>> Get(
[FromQuery] string? type,
[FromQuery] string? sort,
[FromQuery] int? page,
@@ -20,13 +25,18 @@ public class ActivityController(IActivityRepository activityRepo) : ControllerBa
var (items, totalCount) = await activityRepo.GetPagedAsync(type, sort, pageNum, take, ct);
return Results.Ok(new
{
items = items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }),
return Ok(new ActivityPageDto(
items.Select(item => new ActivityItemDto(
item.Id,
item.Type,
item.Message,
item.CreatedAt,
item.TaskId is Guid taskId
? new EntityRefDto("task", taskId.ToString(), null)
: null)).ToArray(),
totalCount,
page = pageNum,
pageSize = take,
totalPages = (int)Math.Ceiling((double)totalCount / take)
});
pageNum,
take,
(int)Math.Ceiling((double)totalCount / take)));
}
}
+104 -42
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
@@ -13,13 +14,14 @@ namespace Nexus.Api.Controllers;
[Route("api/v1/agents")]
public class AgentsController(
IAgentService agentService,
IAgentRuntime runtime,
IOpenClawChatService chat,
IActivityRepository activityRepo,
IAgentConfigService agentConfigService,
IOpenClawAgentConfigurationService agentConfiguration,
IDashboardService dashboardService,
ILogger<AgentsController> logger) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<AgentListResponse>), StatusCodes.Status200OK)]
public async Task<IResult> GetAgents(CancellationToken ct)
{
var agents = await agentService.GetAgentsAsync(ct);
@@ -28,6 +30,8 @@ public class AgentsController(
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(AgentDetailResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IResult> GetAgent(string id, CancellationToken ct)
{
var agent = await agentService.GetAgentAsync(id, ct);
@@ -39,6 +43,7 @@ public class AgentsController(
}
[HttpGet("{id}/activity")]
[ProducesResponseType(typeof(IReadOnlyList<AgentActivityResponse>), StatusCodes.Status200OK)]
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
{
var items = await activityRepo.GetByAgentAsync(id, 50, ct);
@@ -56,6 +61,7 @@ public class AgentsController(
}
[HttpGet("{id}/summary")]
[ProducesResponseType(typeof(AgentSummaryResponse), StatusCodes.Status200OK)]
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
{
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
@@ -64,7 +70,11 @@ public class AgentsController(
}
[HttpPost("{id}/command")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(AgentCommandResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> SendCommand(string id, [FromBody] AgentCommandRequest request, CancellationToken ct)
{
var message = request.Message?.Trim();
@@ -75,8 +85,30 @@ public class AgentsController(
try
{
var result = await runtime.ChatAsync(message, conversationId, id, ct);
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
var context = OpenClawInvocationContextFactory.Create(
User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.Identity?.Name,
Request.Headers["Idempotency-Key"].FirstOrDefault(),
Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? HttpContext.TraceIdentifier,
Request.Headers["traceparent"].FirstOrDefault()
?? Activity.Current?.Id);
var result = await chat.SendAsync(
message,
conversationId,
id,
new Models.OpenClawInvocationMetadata(
context.IdempotencyKey,
context.CorrelationId,
context.Actor,
context.TraceParent),
ct);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "agent",
Message = $"Command dispatched to agent {id} as durable run {result.RunId}"
}, ct);
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
}
catch (Exception exception)
@@ -92,16 +124,35 @@ public class AgentsController(
// ── Config Editor ──
[HttpGet("{id}/config")]
public IResult GetConfig(string id)
=> Results.Ok(agentConfigService.GetConfigFiles(id));
[Authorize(Roles = "owner")]
public async Task<IResult> GetConfig(string id, CancellationToken ct)
{
var files = await agentConfiguration.GetAgentFilesAsync(id, ct);
return Results.Ok(files.Files.Select(file => new
{
FileName = file.Name,
file.Size,
ModifiedAt = file.UpdatedAt,
file.Missing,
file.ContentHash
}));
}
[HttpGet("{id}/config/{fileName}")]
[Authorize(Roles = "owner")]
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
{
var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
return file is null
var file = await agentConfiguration.GetAgentFileAsync(id, fileName, ct);
return file.Missing
? Results.NotFound()
: Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
: Results.Ok(new
{
FileName = file.Name,
file.Content,
file.Size,
ModifiedAt = file.UpdatedAt,
file.ContentHash
});
}
[HttpPut("{id}/config/{fileName}")]
@@ -110,59 +161,70 @@ public class AgentsController(
{
if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." });
if (string.IsNullOrWhiteSpace(request.ExpectedHash))
return Results.BadRequest(new { error = "ExpectedHash is required." });
try
{
var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
var caller = DescribeCaller(HttpContext.User);
if (attempt.Failure is not null)
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = attempt.Failure.Validation.Errors.ToArray()
});
return Results.BadRequest(new { error = "Idempotency-Key header is required." });
}
var result = attempt.SaveResult!;
var invocation = OpenClawInvocationContext.Create(
caller,
idempotencyKey,
Request.Headers["X-Correlation-ID"].FirstOrDefault(),
Request.Headers["traceparent"].FirstOrDefault());
var result = await agentConfiguration.SetAgentFileAsync(
id,
fileName,
new Nexus.Api.Models.UpdateOpenClawAgentFileRequest(request.Content, request.ExpectedHash),
invocation,
ct);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
Message = $"Config save agent={id} file={fileName} caller={caller} verified={result.Verified} state={result.State}",
}, ct);
return Results.Ok(new
{
result.FileName,
result.Size,
result.ModifiedAt,
result.Validation,
result.Backup,
ReloadCheck = result.ReloadCheck
FileName = result.File.Name,
result.File.Size,
ModifiedAt = result.File.UpdatedAt,
result.File.ContentHash,
result.Verified,
result.State,
result.Message
});
}
catch (UnauthorizedAccessException ex)
catch (OpenClawAgentConfigurationConflictException ex)
{
logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "Permission denied",
detail: $"Cannot write config file '{fileName}' for agent '{id}'. The target path may be owned by a different user.",
statusCode: StatusCodes.Status500InternalServerError);
return Results.Json(
new
{
code = ex.Code,
message = ex.Message,
ex.ExpectedHash,
ex.CurrentHash
},
statusCode: StatusCodes.Status409Conflict);
}
catch (IOException ex)
catch (OpenClawAgentConfigurationValidationException ex)
{
logger.LogError(ex, "I/O error saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "File write error",
detail: $"Failed to write config file '{fileName}' for agent '{id}': {ex.Message}",
statusCode: StatusCodes.Status500InternalServerError);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={DescribeCaller(HttpContext.User)} validation=failed code=validation_failed",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[ex.Field] = [ex.Message]
});
}
}
+6
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
@@ -19,6 +20,7 @@ public class AuthController(
LoginAttemptTracker attemptTracker) : ControllerBase
{
[HttpGet("csrf")]
[AllowAnonymous]
public IActionResult GetCsrfToken()
{
var tokens = antiforgery.GetAndStoreTokens(HttpContext);
@@ -26,6 +28,7 @@ public class AuthController(
}
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("auth")]
public async Task<IResult> Login([FromBody] LoginRequest request, CancellationToken ct)
{
@@ -68,6 +71,7 @@ public class AuthController(
}
[HttpPost("refresh")]
[AllowAnonymous]
[EnableRateLimiting("auth")]
public async Task<IResult> Refresh(CancellationToken ct)
{
@@ -89,6 +93,7 @@ public class AuthController(
}
[HttpPost("logout")]
[AllowAnonymous]
public async Task<IResult> Logout(CancellationToken ct)
{
if (Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
@@ -123,6 +128,7 @@ public class AuthController(
}
[HttpPost("admin-reset-password")]
[AllowAnonymous]
[EnableRateLimiting("agents")]
public async Task<IResult> AdminResetPassword([FromBody] AdminResetPasswordRequest request, CancellationToken ct)
{
@@ -0,0 +1,85 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Models;
using Nexus.Api.Observability;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/telemetry/browser")]
[EnableRateLimiting("agents")]
public sealed class BrowserTelemetryController : ControllerBase
{
private static readonly HashSet<string> AllowedNames =
[
"CLS",
"FCP",
"INP",
"LCP",
"TTFB",
"board_content_visible",
"board_delta_painted",
"mutation_confirmed",
"agent_proposal_readback"
];
private static readonly HashSet<string> AllowedRatings =
["good", "needs-improvement", "poor", "custom"];
[HttpPost]
public IResult Record([FromBody] BrowserMetricRequest request)
{
if (!AllowedNames.Contains(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["name"] = ["Unsupported browser metric."]
});
if (!double.IsFinite(request.Value) || request.Value < 0 || request.Value > 86_400_000)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["value"] = ["Metric value must be finite and within the accepted range."]
});
if (!AllowedRatings.Contains(request.Rating))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["rating"] = ["Unsupported metric rating."]
});
var route = NormalizeBoundedDimension(request.RouteName, "unknown", 80);
var liveMode = request.LiveMode is "live" or "polling" ? request.LiveMode : "unknown";
var navigationType = NormalizeBoundedDimension(request.NavigationType, "unknown", 40);
var tags = new TagList
{
{ "metric.name", request.Name },
{ "metric.rating", request.Rating },
{ "route.name", route },
{ "nexus.live_mode", liveMode },
{ "navigation.type", navigationType }
};
if (request.Name == "CLS")
NexusTelemetry.BrowserScore.Record(request.Value, tags);
else
NexusTelemetry.BrowserDuration.Record(request.Value, tags);
return Results.NoContent();
}
private static string NormalizeBoundedDimension(string? value, string fallback, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
return fallback;
var normalized = new string(value
.Where(character => char.IsAsciiLetterOrDigit(character) || character is ' ' or '_' or '-')
.Take(maxLength)
.ToArray());
return string.IsNullOrWhiteSpace(normalized) ? fallback : normalized;
}
}
+37 -4
View File
@@ -1,15 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[Authorize(Roles = "owner")]
[ApiController]
[Route("api/v1/chat")]
public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase
public class ChatController(
IOpenClawChatService chat,
ILogger<ChatController> logger) : ControllerBase
{
[HttpPost]
[EnableRateLimiting("agents")]
@@ -31,7 +35,35 @@ public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logge
try
{
return Results.Ok(await runtime.ChatAsync(message, conversationId, agentId, ct));
var contextualMessage = MissionControlContextFormatter.Format(message, request.Context);
var invocationContext = OpenClawInvocationContextFactory.Create(
User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.Identity?.Name,
Request.Headers["Idempotency-Key"].FirstOrDefault(),
Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? HttpContext.TraceIdentifier,
Request.Headers["traceparent"].FirstOrDefault()
?? Activity.Current?.Id);
var invocation = new Nexus.Api.Models.OpenClawInvocationMetadata(
invocationContext.IdempotencyKey,
invocationContext.CorrelationId,
invocationContext.Actor,
invocationContext.TraceParent);
Response.Headers["X-Correlation-ID"] = invocation.CorrelationId;
return Results.Ok(await chat.SendAsync(
contextualMessage,
conversationId,
agentId,
invocation,
ct));
}
catch (ArgumentException exception)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["requestMetadata"] = [exception.Message]
});
}
catch (Exception exception)
{
@@ -42,4 +74,5 @@ public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logge
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
}
+125 -44
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
@@ -36,6 +37,9 @@ public class DashboardController(
=> await dashboardService.GetOperationsAsync(limit, agent);
[HttpPost("chat/send")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[Obsolete("Legacy adapter. Use POST /api/v1/chat.")]
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
@@ -61,6 +65,8 @@ public class DashboardController(
=> await dashboardService.GetGatewayInfoAsync(ct);
[HttpDelete("queue/{id}")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
@@ -71,6 +77,14 @@ public class DashboardController(
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
QueueDeleteOutcome.Ignored when string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)
=> StatusCode(
StatusCodes.Status410Gone,
new
{
error = "Legacy cron deletion has been removed",
recovery = $"Use DELETE /api/v1/openclaw/cron/{Uri.EscapeDataString(id)} with Idempotency-Key and a current resource hash."
}),
_ => StatusCode(500, new { error = "Internal error" })
};
}
@@ -112,8 +126,8 @@ public class DashboardController(
=> await dashboardService.GetAgentActivityAsync(id, limit);
[HttpGet("models")]
public ActionResult<List<ModelOption>> GetAvailableModels()
=> Ok(dashboardService.GetAvailableModels());
public async Task<ActionResult<List<ModelOption>>> GetAvailableModels(CancellationToken ct)
=> Ok(await dashboardService.GetAvailableModelsAsync(ct));
// ── Task Endpoints ──
@@ -121,7 +135,7 @@ public class DashboardController(
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
{
var tasks = await taskService.GetOpenAsync(ct);
return tasks.Select(MapToDto).ToList();
return tasks.Select(task => MapToDto(task)).ToList();
}
[HttpPost("tasks")]
@@ -135,7 +149,9 @@ public class DashboardController(
{
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
return Created(
$"/api/dashboard/tasks/{task.Id}",
MapToDto(task, TaskOperation(task, "created")));
}
catch (ArgumentException ex)
{
@@ -152,7 +168,9 @@ public class DashboardController(
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
@@ -164,7 +182,9 @@ public class DashboardController(
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
_ => NoContent()
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "deleted")))
};
}
@@ -178,7 +198,7 @@ public class DashboardController(
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
var callerAgent = await ResolveCallerAgentAsync(ct);
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
@@ -191,13 +211,14 @@ public class DashboardController(
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
// ── Task Board Endpoints ──
[AllowAnonymous]
[HttpGet("tasks/board")]
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
{
@@ -227,24 +248,45 @@ public class DashboardController(
}
var currentSequence = liveUpdateService.CurrentSequence;
// Subscribe before loading the snapshot so updates published while the
// snapshot is assembled are queued and delivered afterwards.
var subscription = await liveUpdateService.SubscribeAsync(currentSequence, ct);
var initial = new DashboardLiveSnapshotDto(
await taskService.GetBoardAsync(ct),
await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct),
new LiveCursorDto(currentSequence, DateTimeOffset.UtcNow, "live"));
await WriteEventAsync("snapshot", initial);
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
// A fresh snapshot is the authoritative baseline for this legacy
// adapter. Only advance this cursor after an update has actually been
// written; CurrentSequence may include events still queued below.
var lastSent = currentSequence;
while (!ct.IsCancellationRequested)
{
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
using var iteration = CancellationTokenSource.CreateLinkedTokenSource(ct);
var readTask = subscription.Reader.WaitToReadAsync(iteration.Token).AsTask();
var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), iteration.Token);
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == readTask)
if (completed == heartbeatTask)
{
var envelope = await readTask;
iteration.Cancel();
await WriteEventAsync(
"heartbeat",
new LiveCursorDto(lastSent, DateTimeOffset.UtcNow, "live"));
continue;
}
iteration.Cancel();
if (!await readTask)
break;
while (subscription.Reader.TryRead(out var envelope))
{
if (envelope.Sequence <= lastSent)
continue;
if (envelope.Type == "notifications.snapshot")
{
var snapshot = envelope.Payload as NotificationSnapshotDto
@@ -262,10 +304,7 @@ public class DashboardController(
await WriteEventAsync("update", new DashboardLiveEventDto(
envelope,
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
}
else if (await heartbeatTask)
{
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
lastSent = envelope.Sequence;
}
}
}
@@ -283,7 +322,7 @@ public class DashboardController(
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
var callerAgent = await ResolveCallerAgentAsync(ct);
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
@@ -296,24 +335,36 @@ public class DashboardController(
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
/// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly).
/// Resolves the caller identity from a verified principal. X-Agent-Id is
/// treated only as an authenticated, allow-listed actor hint.
/// </summary>
private string ResolveCallerAgent()
private async Task<string> ResolveCallerAgentAsync(CancellationToken ct)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(
httpContext,
agentService,
configuration,
ct);
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
return agentHeader;
var user = httpContext.User;
if (user?.Identity?.IsAuthenticated != true)
return "";
if (user.IsInRole("owner") || user.IsInRole("admin"))
return "bao";
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
@@ -326,7 +377,11 @@ public class DashboardController(
{
var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours));
var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct);
return Ok(new ResetStaleResponse(count));
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
count > 0 ? "completed" : "noop",
new EntityRefDto("task-board", "active", "Task Board"));
return Ok(new ResetStaleResponse(count, operation));
}
[HttpGet("tasks/{id:guid}/children")]
@@ -361,7 +416,7 @@ public class DashboardController(
}
[HttpPost("tasks/{id:guid}/activity")]
public async Task<ActionResult<ActivityEvent>> PostTaskActivity(
public async Task<ActionResult<ActivityItemDto>> PostTaskActivity(
Guid id, [FromBody] PostActivityRequest request, CancellationToken ct)
{
var task = await taskService.GetByIdAsync(id, ct);
@@ -378,7 +433,21 @@ public class DashboardController(
};
await activityService.AddAsync(ev, ct);
return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev);
var entity = new EntityRefDto("task", task.Id.ToString(), task.Title);
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
"created",
new EntityRefDto("activity", ev.Id.ToString(), ev.Type),
affectedRefs: [entity]);
return Created(
$"/api/dashboard/tasks/{id}/activity/{ev.Id}",
new ActivityItemDto(
ev.Id,
ev.Type,
ev.Message,
ev.CreatedAt,
entity,
operation));
}
// ── Agent Workflow Endpoints (Iris Overview) ──
@@ -391,7 +460,7 @@ public class DashboardController(
public async Task<ActionResult<List<DashboardTaskDto>>> GetAgentWaitingTasks(CancellationToken ct)
{
var waiting = await taskService.GetWaitingTasksAsync(ct);
return Ok(waiting.Select(MapToDto).ToList());
return Ok(waiting.Select(task => MapToDto(task)).ToList());
}
/// <summary>
@@ -424,7 +493,9 @@ public class DashboardController(
request.Priority, request.AssignedTo, request.ExpectedFrom,
request.ParentTaskId, request.StartsInProgress, request.InitialState, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
return Created(
$"/api/dashboard/tasks/{task.Id}",
MapToDto(task, TaskOperation(task, "created")));
}
catch (ArgumentException ex)
{
@@ -432,20 +503,30 @@ public class DashboardController(
}
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
private OperationResultDto TaskOperation(WorkTask task, string status)
{
var affected = new List<EntityRefDto>();
if (task.ProjectId is { } projectId)
affected.Add(new EntityRefDto("project", projectId.ToString()));
if (task.ParentTaskId is { } parentTaskId)
affected.Add(new EntityRefDto("task", parentTaskId.ToString(), "Parent task"));
return OperationResultFactory.FromHttpContext(
HttpContext,
status,
new EntityRefDto("task", task.Id.ToString(), task.Title),
affectedRefs: affected);
}
private static DashboardTaskDto MapToDto(
WorkTask t,
OperationResultDto? operation = null) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
t.IsAgentTask, t.ExpectedFrom,
ProjectId: t.ProjectId,
Operation: operation);
private async Task<bool> CanReadBoardAsync(CancellationToken ct)
{
var allowedAgent = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
if (!string.IsNullOrWhiteSpace(allowedAgent))
return true;
if (RequestAuthorizationHelper.HasValidServiceKey(HttpContext, configuration))
return true;
return User.Identity?.IsAuthenticated == true;
}
private Task<bool> CanReadBoardAsync(CancellationToken _)
=> Task.FromResult(RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration));
}
+48 -6
View File
@@ -1,23 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize(Roles = "owner")]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status403Forbidden)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status409Conflict)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status502BadGateway)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status503ServiceUnavailable)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status504GatewayTimeout)]
[ApiController]
[Route("api/v1/docs")]
public class DocsController(IDocService docService) : ControllerBase
{
[HttpGet]
public IResult GetAll()
=> Results.Ok(docService.GetAll());
[ProducesResponseType(typeof(IReadOnlyList<DocFileInfo>), StatusCodes.Status200OK)]
public Task<IResult> GetAll(
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
Results.Ok(await docService.GetAllAsync(
agentId,
cancellationToken)));
[HttpGet("{**path}")]
public async Task<IResult> GetFile(string path)
[ProducesResponseType(typeof(DocFileContent), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public Task<IResult> GetFile(
string path,
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path))
return Results.BadRequest("Path required.");
{
return Task.FromResult<IResult>(
Results.ValidationProblem(new Dictionary<string, string[]>
{
["path"] = ["Path is required."]
}));
}
var file = await docService.GetFileAsync(path);
return file is null ? Results.NotFound() : Results.Ok(file);
return OpenClawContentReadEndpoint.ExecuteAsync(async () =>
{
var file = await docService.GetFileAsync(
path,
agentId,
cancellationToken);
return file is null ? Results.NotFound() : Results.Ok(file);
});
}
}
@@ -0,0 +1,233 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Observability;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/events")]
public sealed class DomainEventsController(
NexusDbContext db,
IDomainEventStreamService eventStream,
ILogger<DomainEventsController> logger) : ControllerBase
{
private const int MaximumReplay = 512;
[HttpGet]
public async Task Get(
[FromQuery] string? channels,
[FromQuery] long? afterSequence,
CancellationToken cancellationToken)
{
Response.Headers.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache, no-store, must-revalidate";
Response.Headers.Connection = "keep-alive";
Response.Headers["X-Accel-Buffering"] = "no";
var requestedChannels = ParseChannels(channels);
var cursor = ResolveCursor(afterSequence);
await using var subscription = eventStream.Subscribe(requestedChannels);
var lastSent = cursor ?? subscription.StartingSequence;
if (cursor.HasValue)
{
var earliest = await db.OutboxEvents
.AsNoTracking()
.Where(item => item.PublishedAt != null)
.OrderBy(item => item.Sequence)
.Select(item => (long?)item.Sequence)
.FirstOrDefaultAsync(cancellationToken);
if (earliest.HasValue && cursor.Value < earliest.Value - 1)
{
await WriteResyncRequiredAsync(
cursor.Value,
"retention_gap",
cancellationToken);
return;
}
var replay = await db.OutboxEvents
.AsNoTracking()
.Where(item =>
item.PublishedAt != null &&
item.Sequence > cursor.Value &&
item.Sequence <= subscription.StartingSequence)
.OrderBy(item => item.Sequence)
.Take(MaximumReplay + 1)
.ToListAsync(cancellationToken);
if (replay.Count > MaximumReplay)
{
await WriteResyncRequiredAsync(
cursor.Value,
"replay_limit",
cancellationToken);
return;
}
foreach (var item in replay)
{
var domainEvent = DomainEventStreamService.Map(item);
if (!MatchesChannel(domainEvent, requestedChannels))
continue;
await WriteEventAsync("domain", domainEvent.Sequence, domainEvent, cancellationToken);
lastSent = domainEvent.Sequence;
}
}
try
{
while (!cancellationToken.IsCancellationRequested)
{
using var iteration = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
var readTask = subscription.Reader
.WaitToReadAsync(iteration.Token)
.AsTask();
var heartbeatTask = Task.Delay(
TimeSpan.FromSeconds(20),
iteration.Token);
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == heartbeatTask)
{
iteration.Cancel();
await WriteEventAsync(
"heartbeat",
lastSent,
new
{
sequence = eventStream.CurrentSequence,
timestamp = DateTimeOffset.UtcNow
},
cancellationToken);
continue;
}
iteration.Cancel();
if (!await readTask)
break;
while (subscription.Reader.TryRead(out var domainEvent))
{
if (domainEvent.Sequence <= lastSent)
continue;
await WriteEventAsync(
"domain",
domainEvent.Sequence,
domainEvent,
cancellationToken);
lastSent = domainEvent.Sequence;
}
}
}
catch (DomainEventSubscriberOverflowException)
{
await WriteResyncRequiredAsync(lastSent, "subscriber_overflow", cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Normal browser disconnect.
}
catch (Exception exception)
{
logger.LogWarning(exception, "Domain event stream ended unexpectedly");
}
}
private long? ResolveCursor(long? queryCursor)
{
if (queryCursor.HasValue)
return queryCursor.Value >= 0 ? queryCursor : null;
if (!Request.Headers.TryGetValue("Last-Event-ID", out var header))
return null;
return long.TryParse(header.FirstOrDefault(), out var parsed) && parsed >= 0
? parsed
: null;
}
private async Task WriteResyncRequiredAsync(
long staleSequence,
string reason,
CancellationToken cancellationToken)
{
NexusTelemetry.SseResyncs.Add(1);
// Resume after the latest sequence that was visible when the resync
// decision was made. Re-emitting the stale cursor would make a client
// reconnect into the same retention/replay gap forever.
var resumeSequence = eventStream.CurrentSequence;
var payload = JsonSerializer.SerializeToElement(new
{
reason,
staleSequence,
resumeSequence
});
var domainEvent = new DomainEventDto(
resumeSequence,
DomainEventTypes.ResyncRequired,
new EntityRefDto("event-stream", "*"),
0,
DateTimeOffset.UtcNow,
payload);
await WriteEventAsync(
DomainEventTypes.ResyncRequired,
resumeSequence,
domainEvent,
cancellationToken);
}
private async Task WriteEventAsync(
string eventName,
long sequence,
object payload,
CancellationToken cancellationToken)
{
await Response.WriteAsync($"id: {sequence}\n", cancellationToken);
await Response.WriteAsync($"event: {eventName}\n", cancellationToken);
await Response.WriteAsync(
$"data: {JsonSerializer.Serialize(payload, JsonOptions)}\n\n",
cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
}
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web);
private static IReadOnlySet<string> ParseChannels(string? channels)
{
if (string.IsNullOrWhiteSpace(channels))
return new HashSet<string>(["*"], StringComparer.OrdinalIgnoreCase);
var parsed = channels
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(channel => channel.ToLowerInvariant())
.Where(channel => channel.All(character =>
char.IsLetterOrDigit(character) || character is '-' or '_'))
.Take(16)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return parsed.Count == 0
? new HashSet<string>(["*"], StringComparer.OrdinalIgnoreCase)
: parsed;
}
private static bool MatchesChannel(
DomainEventDto domainEvent,
IReadOnlySet<string> channels)
{
if (channels.Contains("*"))
return true;
var channel = domainEvent.Entity.Type switch
{
"agent-proposal" => "agents",
"task" => "tasks",
"run" => "runs",
"cron" => "cron",
_ => $"{domainEvent.Entity.Type}s"
};
return channels.Contains(channel);
}
}
+65 -18
View File
@@ -14,7 +14,8 @@ namespace Nexus.Api.Controllers;
/// This is the SINGLE entrypoint for agents (Iris + sub-agents) to interact with
/// the Nexus task board, activity log, and delegation workflow.
///
/// AUTHENTICATION: Requires X-Nexus-Api-Key or a known allowed X-Agent-Id.
/// AUTHENTICATION: Requires a verified JWT or X-Nexus-Api-Key. X-Agent-Id is
/// accepted only as an actor hint for a service or privileged user principal.
/// The browser NEVER uses this controller — only backend-to-backend and gateway-to-backend.
///
/// DESIGN PRINCIPLE: No MCP protocol between Nexus and Gateway — instead, the Gateway
@@ -34,6 +35,7 @@ namespace Nexus.Api.Controllers;
/// </summary>
[ApiController]
[Route("api/bridge")]
[Authorize]
[EnableRateLimiting("agents")]
public class GatewayBridgeController(
ITaskBridgeService bridge,
@@ -42,7 +44,7 @@ public class GatewayBridgeController(
ILogger<GatewayBridgeController> logger) : ControllerBase
{
private const string ApikeyErrorMessage =
"Bridge endpoints require X-Nexus-Api-Key or X-Agent-Id header with a recognized agent identity.";
"Bridge endpoints require a verified JWT or X-Nexus-Api-Key.";
[HttpGet("health")]
public IResult Health()
@@ -236,18 +238,26 @@ public class GatewayBridgeController(
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
if (!RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedActorIds.Contains(normalizedHeader))
return (true, normalizedHeader, null);
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
normalizedHeader,
HttpContext.Connection.RemoteIpAddress);
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, unauthorized);
}
var agentHeader = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
HttpContext,
agentService,
configuration,
ct);
if (agentHeader.AgentId is not null)
return (true, agentHeader.AgentId, null);
if (agentHeader.HeaderProvided && !agentHeader.IsRecognized)
logger.LogWarning(
"Bridge: ignoring unknown X-Agent-Id from authenticated caller at {Ip} and continuing identity fallback",
HttpContext.Connection.RemoteIpAddress);
if (User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
@@ -264,9 +274,9 @@ public class GatewayBridgeController(
allowedActorIds.Contains("nexus-system"))
return (true, "nexus-system", null);
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, unauthorized);
var forbidden = StatusCode(StatusCodes.Status403Forbidden, new { error = "Authenticated caller has no permitted bridge identity." });
logger.LogWarning("Bridge: authenticated caller has no permitted identity from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, forbidden);
}
private static string ResolveSource(string agentId) => agentId switch
@@ -275,14 +285,15 @@ public class GatewayBridgeController(
_ => agentId
};
private static ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
private ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<T>
{
Ok = true,
Command = command,
Data = result.Data
Data = result.Data,
Operation = BuildBridgeOperation(command, result.Data)
});
var statusCode = result.Outcome switch
@@ -302,7 +313,7 @@ public class GatewayBridgeController(
}) { StatusCode = statusCode };
}
private static ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
private ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
@@ -310,7 +321,19 @@ public class GatewayBridgeController(
Ok = true,
Command = command,
Data = result.Data is null ? null : new ActivityEntryDto(
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt)
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
Operation = result.Data is null
? null
: OperationResultFactory.FromHttpContext(
HttpContext,
"completed",
new EntityRefDto(
"activity",
result.Data.Id.ToString(),
result.Data.Type),
affectedRefs: result.Data.TaskId is { } taskId
? [new EntityRefDto("task", taskId.ToString())]
: [])
});
var statusCode = result.Outcome switch
@@ -327,6 +350,29 @@ public class GatewayBridgeController(
Error = result.Error ?? "Unknown error"
}) { StatusCode = statusCode };
}
private OperationResultDto? BuildBridgeOperation<T>(string command, T? data)
where T : class
{
if (command.StartsWith("get_", StringComparison.Ordinal) || data is null)
return null;
if (data is DashboardTaskDto task)
{
var affected = new List<EntityRefDto>();
if (task.ProjectId is { } projectId)
affected.Add(new EntityRefDto("project", projectId.ToString()));
if (task.ParentTaskId is { } parentTaskId)
affected.Add(new EntityRefDto("task", parentTaskId.ToString(), "Parent task"));
return OperationResultFactory.FromHttpContext(
HttpContext,
"completed",
new EntityRefDto("task", task.Id.ToString(), task.Title),
affectedRefs: affected);
}
return null;
}
}
public sealed class TaskBridgeCommandResponse<T>
@@ -335,6 +381,7 @@ public sealed class TaskBridgeCommandResponse<T>
public string Command { get; init; } = string.Empty;
public T? Data { get; init; }
public string? Error { get; init; }
public OperationResultDto? Operation { get; init; }
public string Timestamp { get; init; } = DateTimeOffset.UtcNow.ToString("o");
}
@@ -24,7 +24,10 @@ public class GatewayHealthController(IGatewayConnector connector) : ControllerBa
gatewayVersion = connector.GatewayVersion ?? "unknown",
requiredVersion = connector.RequiredVersion,
versionPinned = connector.RequiredVersion is not null,
protocolVersion = connector.ProtocolVersion,
advertisedMethodCount = connector.AdvertisedMethods.Count,
lastConnectedAt = connector.LastConnectedAt?.ToString("o"),
lastEventAt = connector.LastEventAt?.ToString("o"),
reconnectAttempts = connector.ReconnectAttempts,
message = connector.StatusMessage,
timestamp = DateTimeOffset.UtcNow.ToString("o")
+6 -21
View File
@@ -11,29 +11,14 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
[AllowAnonymous]
[HttpGet("/health/live")]
public IResult Live()
{
var agentCount = 0;
try
=> Results.Ok(new
{
var path = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? "/app",
"..");
var configPath = "/home/node/.openclaw/agents-sanitized.json";
if (System.IO.File.Exists(configPath))
{
var json = System.IO.File.ReadAllText(configPath);
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("agents", out var agentsEl)
&& agentsEl.TryGetProperty("list", out var listEl))
agentCount = listEl.GetArrayLength();
}
}
catch { }
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow, agentCount });
}
status = "Healthy",
timestamp = DateTimeOffset.UtcNow,
agentSource = "openclaw-rpc"
});
[AllowAnonymous]
[HttpGet("/health")]
public async Task<IResult> Get(CancellationToken ct)
{
+42 -7
View File
@@ -1,20 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize(Roles = "owner")]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status403Forbidden)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status409Conflict)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status502BadGateway)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status503ServiceUnavailable)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status504GatewayTimeout)]
[ApiController]
[Route("api/v1/incidents")]
public class IncidentsController(IIncidentService incidentService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll()
=> Results.Ok(await incidentService.GetAllAsync());
[ProducesResponseType(typeof(IReadOnlyList<IncidentSummary>), StatusCodes.Status200OK)]
public Task<IResult> GetAll(
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
Results.Ok(await incidentService.GetAllAsync(
agentId,
cancellationToken)));
[HttpGet("{name}")]
public async Task<IResult> GetOne(string name)
{
var incident = await incidentService.GetByNameAsync(name);
return incident is null ? Results.NotFound() : Results.Ok(incident);
}
[ProducesResponseType(typeof(IncidentDetail), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public Task<IResult> GetOne(
string name,
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
{
var incident = await incidentService.GetByNameAsync(
name,
agentId,
cancellationToken);
return incident is null
? Results.NotFound()
: Results.Ok(incident);
});
}
+58 -10
View File
@@ -1,29 +1,77 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize(Roles = "owner")]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status403Forbidden)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status409Conflict)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status502BadGateway)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status503ServiceUnavailable)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status504GatewayTimeout)]
[ApiController]
[Route("api/v1/memory")]
public class MemoryController(IMemoryService memoryService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll()
=> Results.Ok(await memoryService.GetAllAsync());
[ProducesResponseType(typeof(IReadOnlyList<MemoryFileInfo>), StatusCodes.Status200OK)]
public Task<IResult> GetAll(
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
Results.Ok(await memoryService.GetAllAsync(
agentId,
cancellationToken)));
[HttpGet("search")]
public async Task<IResult> Search([FromQuery] string q)
[ProducesResponseType(typeof(IReadOnlyList<MemorySearchResult>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public Task<IResult> Search(
[FromQuery] string q,
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Results.BadRequest("Query must be at least 2 characters.");
{
return Task.FromResult<IResult>(
Results.ValidationProblem(new Dictionary<string, string[]>
{
["q"] = ["Query must be at least 2 characters."]
}));
}
return Results.Ok(await memoryService.SearchAsync(q));
return OpenClawContentReadEndpoint.ExecuteAsync(async () =>
Results.Ok(await memoryService.SearchAsync(
q,
agentId,
cancellationToken)));
}
[HttpGet("{name}")]
public async Task<IResult> GetFile(string name)
{
var file = await memoryService.GetFileAsync(name);
return file is null ? Results.NotFound() : Results.Ok(file);
}
[ProducesResponseType(typeof(MemoryFileContent), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public Task<IResult> GetFile(
string name,
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
{
var file = await memoryService.GetFileAsync(
name,
agentId,
cancellationToken);
return file is null ? Results.NotFound() : Results.Ok(file);
});
}
+41 -8
View File
@@ -19,7 +19,7 @@ public class NotificationsController(INotificationService notificationService) :
CancellationToken ct = default)
{
var notifications = await notificationService.GetForUserAsync(forUser, limit, unreadOnly, ct);
return Ok(notifications.Select(MapToDto).ToList());
return Ok(notifications.Select(notification => MapToDto(notification)).ToList());
}
[HttpGet("unread-count")]
@@ -42,22 +42,55 @@ public class NotificationsController(INotificationService notificationService) :
}
[HttpPatch("{id:guid}/read")]
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
[ProducesResponseType(typeof(NotificationDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<NotificationDto>> MarkAsRead(
Guid id,
CancellationToken ct = default)
{
var ok = await notificationService.MarkAsReadAsync(id, ct);
return ok ? NoContent() : NotFound(new { error = "Notification not found." });
var readResult = await notificationService.MarkAsReadAsync(id, ct);
var notification = readResult.Notification;
if (notification is null)
return NotFound(new ProblemDetails
{
Title = "Notification not found",
Detail = $"Notification '{id}' does not exist.",
Status = StatusCodes.Status404NotFound
});
var primary = new EntityRefDto(
"notification",
notification.Id.ToString(),
notification.Title);
var affected = notification.TaskId is { } taskId
? new[] { new EntityRefDto("task", taskId.ToString()) }
: [];
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
readResult.Changed ? "completed" : "noop",
primary,
revision: readResult.Changed ? 1 : 0,
affectedRefs: affected);
return Ok(MapToDto(notification, operation));
}
[HttpPatch("read-all")]
public async Task<ActionResult> MarkAllAsRead(
[ProducesResponseType(typeof(NotificationReadAllResultDto), StatusCodes.Status200OK)]
public async Task<ActionResult<NotificationReadAllResultDto>> MarkAllAsRead(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.MarkAllAsReadAsync(forUser, ct);
return Ok(new { marked = count });
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
count > 0 ? "completed" : "noop",
new EntityRefDto("notification", "*", "Benachrichtigungen"));
return Ok(new NotificationReadAllResultDto(count, operation));
}
private static NotificationDto MapToDto(Notification n) => new(
private static NotificationDto MapToDto(
Notification n,
OperationResultDto? operation = null) => new(
n.Id, n.Type, n.Title, n.Message,
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt, operation);
}
@@ -0,0 +1,256 @@
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 proxy for sensitive OpenClaw agent files, workspace previews,
/// and schema-validated configuration changes.
/// </summary>
[Authorize(Roles = "owner")]
[ApiController]
[Route("api/v1/openclaw")]
public sealed class OpenClawAgentConfigurationController(
IOpenClawAgentConfigurationService configuration) : ControllerBase
{
[HttpGet("agents/{agentId}/files")]
public Task<ActionResult<OpenClawAgentFileCollectionDto>> GetAgentFiles(
string agentId,
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetAgentFilesAsync(agentId, cancellationToken));
[HttpGet("agents/{agentId}/files/{fileName}")]
public Task<ActionResult<OpenClawAgentFileDto>> GetAgentFile(
string agentId,
string fileName,
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetAgentFileAsync(
agentId,
fileName,
cancellationToken));
[HttpPut("agents/{agentId}/files/{fileName}")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawAgentFileWriteDto>> SetAgentFile(
string agentId,
string fileName,
[FromBody] UpdateOpenClawAgentFileRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return await ExecuteAsync(() => configuration.SetAgentFileAsync(
agentId,
fileName,
request,
invocation!,
cancellationToken));
}
[HttpGet("agents/{agentId}/workspace")]
public Task<ActionResult<OpenClawWorkspaceCollectionDto>> GetWorkspace(
string agentId,
[FromQuery] string? path = null,
[FromQuery] int offset = 0,
[FromQuery] int limit = 250,
CancellationToken cancellationToken = default)
=> ExecuteAsync(() => configuration.GetWorkspaceAsync(
agentId,
path,
offset,
limit,
cancellationToken));
[HttpGet("agents/{agentId}/workspace/file")]
public Task<ActionResult<OpenClawWorkspaceFileDto>> GetWorkspaceFile(
string agentId,
[FromQuery] string path,
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetWorkspaceFileAsync(
agentId,
path,
cancellationToken));
[HttpGet("config/schema")]
public Task<ActionResult<OpenClawConfigSchemaLookupDto>> GetConfigSchema(
[FromQuery] string path,
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetConfigSchemaAsync(path, cancellationToken));
[HttpGet("config")]
public Task<ActionResult<OpenClawConfigSnapshotDto>> GetConfig(
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetConfigAsync(cancellationToken));
[HttpPatch("config")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawConfigPatchDto>> PatchConfig(
[FromBody] PatchOpenClawConfigRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return await ExecuteAsync(() => configuration.PatchConfigAsync(
request,
invocation!,
cancellationToken));
}
private async Task<ActionResult<T>> ExecuteAsync<T>(Func<Task<T>> action)
{
try
{
return Ok(await action());
}
catch (OpenClawAgentConfigurationValidationException exception)
{
return new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
[exception.Field] = [exception.Message]
}));
}
catch (OpenClawAgentConfigurationConflictException exception)
{
return StatusCode(
StatusCodes.Status409Conflict,
new
{
code = exception.Code,
message = exception.Message,
expectedHash = exception.ExpectedHash,
currentHash = exception.CurrentHash
});
}
catch (OpenClawAgentConfigurationUnavailableException exception)
{
var status = exception.State switch
{
"forbidden" or "management_disabled" => StatusCodes.Status403Forbidden,
"disconnected" => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status409Conflict
};
return StatusCode(
status,
new OpenClawAgentConfigurationErrorDto(
exception.State,
exception.Message,
exception.Method,
exception.RequiredScope));
}
catch (OpenClawAgentConfigurationVerificationException)
{
return StatusCode(
StatusCodes.Status502BadGateway,
new OpenClawAgentConfigurationErrorDto(
"verification_failed",
"OpenClaw-Antwort konnte nicht sicher verifiziert werden."));
}
catch (OpenClawGatewayRpcException exception)
{
var code = exception.Code.ToUpperInvariant();
var status = code switch
{
"FORBIDDEN" or "AUTH_SCOPE_MISMATCH" => StatusCodes.Status403Forbidden,
"INVALID_REQUEST" or "BAD_REQUEST" => StatusCodes.Status400BadRequest,
"METHOD_UNAVAILABLE" or "METHOD_NOT_FOUND" or "NOT_IMPLEMENTED" =>
StatusCodes.Status409Conflict,
"GATEWAY_DISCONNECTED" or "UNAVAILABLE" => StatusCodes.Status503ServiceUnavailable,
"GATEWAY_TIMEOUT" or "TIMEOUT" => StatusCodes.Status504GatewayTimeout,
_ when code.StartsWith("AUTH_", StringComparison.Ordinal) ||
code.StartsWith("DEVICE_AUTH_", StringComparison.Ordinal) =>
StatusCodes.Status403Forbidden,
_ => StatusCodes.Status502BadGateway
};
return StatusCode(
status,
new OpenClawAgentConfigurationErrorDto(
code.ToLowerInvariant(),
SafeGatewayMessage(status)));
}
}
private bool TryBuildInvocation(
out OpenClawInvocationContext? invocation,
out BadRequestObjectResult? validationError)
{
invocation = null;
validationError = null;
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey) ||
idempotencyKey.Length > 128 ||
idempotencyKey.Any(char.IsControl))
{
validationError = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["Idempotency-Key"] =
[
"A non-empty Idempotency-Key header with at most 128 non-control characters is required."
]
}));
return false;
}
var correlationId = Request.Headers["X-Correlation-ID"].FirstOrDefault()?.Trim();
if (!string.IsNullOrEmpty(correlationId) &&
(correlationId.Length > 128 || correlationId.Any(char.IsControl)))
{
validationError = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["X-Correlation-ID"] =
[
"X-Correlation-ID must contain at most 128 non-control characters."
]
}));
return false;
}
var actor = User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst(ClaimTypes.Email)?.Value
?? User.Identity?.Name
?? "authenticated-owner";
var traceParent = Request.Headers["traceparent"].FirstOrDefault()?.Trim();
try
{
invocation = OpenClawInvocationContext.Create(
actor,
idempotencyKey,
correlationId,
traceParent,
includeIdempotencyParameter: false);
}
catch (ArgumentException exception)
{
validationError = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["traceparent"] = [exception.Message]
}));
return false;
}
Response.Headers["Idempotency-Key"] = invocation.IdempotencyKey;
Response.Headers["X-Correlation-ID"] = invocation.CorrelationId;
return true;
}
private static string SafeGatewayMessage(int status)
=> status switch
{
StatusCodes.Status400BadRequest => "OpenClaw hat die Anfrage als ungültig abgelehnt.",
StatusCodes.Status403Forbidden => "OpenClaw hat Nexus nicht die erforderliche Berechtigung gewährt.",
StatusCodes.Status409Conflict => "Die verbundene OpenClaw-Version unterstützt diese Aktion nicht.",
StatusCodes.Status503ServiceUnavailable => "OpenClaw Gateway ist nicht verfügbar.",
StatusCodes.Status504GatewayTimeout => "OpenClaw hat nicht rechtzeitig geantwortet.",
_ => "OpenClaw-Anfrage ist fehlgeschlagen."
};
}
@@ -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
};
}
@@ -0,0 +1,81 @@
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
internal static class OpenClawContentReadEndpoint
{
public static async Task<IResult> ExecuteAsync(Func<Task<IResult>> action)
{
try
{
return await action();
}
catch (OpenClawAgentConfigurationValidationException exception)
{
return Results.ValidationProblem(
new Dictionary<string, string[]>
{
[exception.Field] = [exception.Message]
});
}
catch (OpenClawAgentConfigurationUnavailableException exception)
{
var status = exception.State switch
{
"forbidden" or "management_disabled" =>
StatusCodes.Status403Forbidden,
"disconnected" => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status409Conflict
};
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
exception.State,
exception.Message,
exception.Method,
exception.RequiredScope),
statusCode: status);
}
catch (OpenClawAgentConfigurationVerificationException)
{
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
"verification_failed",
"OpenClaw-Antwort konnte nicht sicher verifiziert werden."),
statusCode: StatusCodes.Status502BadGateway);
}
catch (OpenClawGatewayRpcException exception)
{
var code = exception.Code.ToUpperInvariant();
var status = code switch
{
"FORBIDDEN" or "AUTH_SCOPE_MISMATCH" =>
StatusCodes.Status403Forbidden,
"METHOD_UNAVAILABLE" or "METHOD_NOT_FOUND"
or "NOT_IMPLEMENTED" =>
StatusCodes.Status409Conflict,
"GATEWAY_DISCONNECTED" or "UNAVAILABLE" =>
StatusCodes.Status503ServiceUnavailable,
"GATEWAY_TIMEOUT" or "TIMEOUT" =>
StatusCodes.Status504GatewayTimeout,
_ => StatusCodes.Status502BadGateway
};
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
code.ToLowerInvariant(),
status switch
{
StatusCodes.Status403Forbidden =>
"OpenClaw hat Nexus nicht die erforderliche Leseberechtigung gewährt.",
StatusCodes.Status409Conflict =>
"Die verbundene OpenClaw-Version unterstützt diese Leseoperation nicht.",
StatusCodes.Status503ServiceUnavailable =>
"OpenClaw Gateway ist nicht verfügbar.",
StatusCodes.Status504GatewayTimeout =>
"OpenClaw hat nicht rechtzeitig geantwortet.",
_ => "OpenClaw-Leseoperation ist fehlgeschlagen."
}),
statusCode: status);
}
}
}
+390
View File
@@ -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)
};
}
}
@@ -0,0 +1,153 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// Authenticated browser-safe Server-Sent Events projection over the bounded
/// Gateway event buffer. The browser never receives Gateway credentials.
/// </summary>
[Authorize]
[ApiController]
[Route("api/v1/openclaw/events")]
public sealed class OpenClawEventsController(IOpenClawEventProjectionService events) : ControllerBase
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(750);
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15);
[HttpGet]
public async Task Stream(
[FromQuery] string? lastEventId = null,
[FromQuery] bool follow = true,
CancellationToken cancellationToken = default)
{
Response.StatusCode = StatusCodes.Status200OK;
Response.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache, no-store";
Response.Headers.Connection = "keep-alive";
Response.Headers["X-Accel-Buffering"] = "no";
var headerCursor = Request.Headers["Last-Event-ID"].FirstOrDefault();
var cursor = string.IsNullOrWhiteSpace(headerCursor) ? lastEventId : headerCursor;
var lastHeartbeatAt = DateTimeOffset.UtcNow;
var connectionEvent = events.CreateConnectionEvent(cursor);
var connectionSignature = GetConnectionSignature(connectionEvent);
await Response.WriteAsync("retry: 2000\n\n", cancellationToken);
await WriteEventAsync(connectionEvent, cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
try
{
do
{
connectionEvent = events.CreateConnectionEvent(cursor);
var currentConnectionSignature = GetConnectionSignature(connectionEvent);
if (!string.Equals(
connectionSignature,
currentConnectionSignature,
StringComparison.Ordinal))
{
await WriteEventAsync(connectionEvent, cancellationToken);
connectionSignature = currentConnectionSignature;
}
var batch = events.Project(cursor);
if (batch.ReplayBoundaryMissed)
{
await WriteEventAsync(CreateGapEvent(cursor, batch), cancellationToken);
}
foreach (var item in batch.Events)
{
await WriteEventAsync(item, cancellationToken);
}
cursor = batch.Cursor ?? cursor;
var now = DateTimeOffset.UtcNow;
if (!follow || now - lastHeartbeatAt >= HeartbeatInterval)
{
await WriteEventAsync(events.CreateHeartbeatEvent(cursor), cancellationToken);
lastHeartbeatAt = now;
}
await Response.Body.FlushAsync(cancellationToken);
if (!follow)
break;
await Task.Delay(PollInterval, cancellationToken);
} while (!cancellationToken.IsCancellationRequested);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Expected when EventSource disconnects or the request is aborted.
}
}
private async Task WriteEventAsync(
OpenClawStreamEventDto item,
CancellationToken cancellationToken)
{
var id = StripSseControlCharacters(item.Id);
var eventType = StripSseControlCharacters(item.Type);
var data = JsonSerializer.Serialize(item, JsonOptions);
await Response.WriteAsync(
$"id: {id}\nevent: {eventType}\ndata: {data}\n\n",
cancellationToken);
}
private static OpenClawStreamEventDto CreateGapEvent(
string? requestedCursor,
OpenClawEventBatch batch)
{
var occurredAt = DateTimeOffset.UtcNow;
var gapCursor = batch.Events.Count == 0
? batch.Cursor ?? "origin"
: string.IsNullOrWhiteSpace(requestedCursor) ? "origin" : requestedCursor;
return new OpenClawStreamEventDto(
gapCursor,
"openclaw.gap",
"gap",
"gateway",
null,
null,
null,
true,
false,
null,
null,
occurredAt,
new JsonObject
{
["reason"] = "last-event-id-outside-buffer",
["requestedLastEventId"] = requestedCursor,
["oldestAvailableId"] = batch.OldestAvailableId,
["latestAvailableId"] = batch.LatestAvailableId,
["requiresAuthoritativeRefresh"] = true
});
}
private static string StripSseControlCharacters(string value)
=> value.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal);
private static string GetConnectionSignature(OpenClawStreamEventDto item)
=> string.Join(
"\u001f",
item.Payload?["state"]?.ToJsonString() ?? string.Empty,
item.Payload?["connected"]?.ToJsonString() ?? string.Empty,
item.Payload?["gatewayVersion"]?.ToJsonString() ?? string.Empty,
item.Payload?["protocolVersion"]?.ToJsonString() ?? string.Empty,
item.Payload?["deviceId"]?.ToJsonString() ?? string.Empty,
item.Payload?["deviceTokenConfigured"]?.ToJsonString() ?? string.Empty,
item.Payload?["pairingRequired"]?.ToJsonString() ?? string.Empty,
item.Payload?["pairingRequestId"]?.ToJsonString() ?? string.Empty,
item.Payload?["lastConnectedAt"]?.ToJsonString() ?? string.Empty,
item.Payload?["reconnectAttempts"]?.ToJsonString() ?? string.Empty,
item.Payload?["message"]?.ToJsonString() ?? string.Empty);
}
@@ -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
};
}
@@ -0,0 +1,228 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Models;
using Nexus.Api.Services;
using System.Security.Claims;
namespace Nexus.Api.Controllers;
/// <summary>
/// Owner-only orchestration for the single OpenClaw Attach &amp; Adopt profile.
/// The browser never receives Gateway, bootstrap or provider credentials.
/// </summary>
[Authorize(Roles = "owner")]
[ApiController]
[Route("api/v1/openclaw/setup")]
public sealed class OpenClawSetupController(
IOpenClawSetupService setup,
IOpenClawWizardService wizard) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<OpenClawSetupStatusDto>> GetStatus(
CancellationToken cancellationToken)
=> Ok(await setup.GetStatusAsync(cancellationToken));
[HttpPost("discover")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawDiscoveryDto>> Discover(
[FromBody] OpenClawDiscoveryRequest? request,
CancellationToken cancellationToken)
=> Ok(await setup.DiscoverAsync(
request ?? new OpenClawDiscoveryRequest(),
cancellationToken));
[HttpPost("probe")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawProbeDto>>> Probe(
[FromBody] ProbeOpenClawRequest request,
CancellationToken cancellationToken)
=> Map(await setup.ProbeAsync(request, cancellationToken));
[HttpPost("attach")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> Attach(
[FromBody] AttachOpenClawRequest request,
CancellationToken cancellationToken)
=> Map(await setup.AttachAsync(request, cancellationToken));
[HttpPost("verify")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> Verify(
[FromBody] VerifyOpenClawRequest request,
CancellationToken cancellationToken)
=> Map(await setup.VerifyAsync(request, cancellationToken));
[HttpPost("adopt")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawAdoptionInventoryDto>>> Adopt(
[FromBody] AdoptOpenClawRequest request,
CancellationToken cancellationToken)
=> Map(await setup.AdoptAsync(request, cancellationToken));
[HttpPost("management")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> SetManagement(
[FromBody] SetOpenClawManagementRequest request,
CancellationToken cancellationToken)
=> Map(await setup.SetManagementAsync(request, cancellationToken));
[HttpDelete("connection")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> DeleteConnection(
[FromBody] DeleteOpenClawConnectionRequest request,
CancellationToken cancellationToken)
=> Map(await setup.DeleteAsync(request, cancellationToken));
[HttpPost("wizard/start")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawWizardResultDto>> StartWizard(
[FromBody] StartOpenClawWizardRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return MapWizard(await wizard.StartAsync(
request,
invocation!,
cancellationToken));
}
[HttpPost("wizard/next")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawWizardResultDto>> AdvanceWizard(
[FromBody] AdvanceOpenClawWizardRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return MapWizard(await wizard.NextAsync(
request,
invocation!,
cancellationToken));
}
[HttpGet("wizard/{sessionId}")]
public async Task<ActionResult<OpenClawWizardResultDto>> GetWizardStatus(
string sessionId,
CancellationToken cancellationToken)
=> MapWizard(await wizard.GetStatusAsync(sessionId, cancellationToken));
[HttpPost("wizard/{sessionId}/cancel")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawWizardResultDto>> CancelWizard(
string sessionId,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return MapWizard(await wizard.CancelAsync(
sessionId,
invocation!,
cancellationToken));
}
private ActionResult<OpenClawSetupOperationDto<T>> Map<T>(
OpenClawSetupOperationDto<T> result)
{
if (result.Ok)
return Ok(result);
return StatusCode(StatusFor(result.State), result);
}
private ActionResult<OpenClawWizardResultDto> MapWizard(
OpenClawWizardResultDto result)
{
if (result.Ok)
return Ok(result);
var status = result.State switch
{
"invalid" or "confirmation_required"
=> StatusCodes.Status400BadRequest,
"not_found"
=> StatusCodes.Status404NotFound,
"management_disabled" or "scope_upgrade_required"
=> StatusCodes.Status403Forbidden,
"conflict" or "server_secret_required" or "unsupported"
=> StatusCodes.Status409Conflict,
"disconnected" or "gateway_error"
=> StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
return StatusCode(status, result);
}
private bool TryBuildInvocation(
out OpenClawInvocationContext? invocation,
out BadRequestObjectResult? validationError)
{
invocation = null;
validationError = null;
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey) ||
idempotencyKey.Length > 128 ||
idempotencyKey.Any(char.IsControl))
{
validationError = BadRequest(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["Idempotency-Key"] =
[
"A non-empty Idempotency-Key header with at most 128 non-control characters is required."
]
}));
return false;
}
var actor = User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst(ClaimTypes.Email)?.Value
?? User.Identity?.Name
?? "authenticated-owner";
try
{
invocation = OpenClawInvocationContext.Create(
actor,
idempotencyKey,
Request.Headers["X-Correlation-ID"].FirstOrDefault(),
Request.Headers["traceparent"].FirstOrDefault(),
includeIdempotencyParameter: false);
}
catch (ArgumentException exception)
{
validationError = BadRequest(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["request"] = [exception.Message]
}));
return false;
}
Response.Headers["Idempotency-Key"] = invocation.IdempotencyKey;
Response.Headers["X-Correlation-ID"] = invocation.CorrelationId;
return true;
}
private static int StatusFor(string state)
=> state switch
{
OpenClawSetupStates.InvalidEndpoint
or OpenClawSetupStates.InvalidRequest
=> StatusCodes.Status400BadRequest,
OpenClawSetupStates.NotFound
=> StatusCodes.Status404NotFound,
OpenClawSetupStates.ConcurrencyConflict
or OpenClawSetupStates.DynamicEndpointUnsupported
or OpenClawSetupStates.ExperimentalBlocked
or OpenClawSetupStates.ExcessiveScope
or OpenClawSetupStates.PairingRequired
or OpenClawSetupStates.ScopeUpgradeRequired
=> StatusCodes.Status409Conflict,
OpenClawSetupStates.Disconnected
or OpenClawSetupStates.GatewayUnavailable
=> StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
}
+82 -13
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
@@ -11,42 +12,110 @@ namespace Nexus.Api.Controllers;
public class ProjectsController(IProjectService projectService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await projectService.GetAllAsync(ct));
[ProducesResponseType(typeof(IReadOnlyList<ProjectDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<ProjectDto>>> GetAll(CancellationToken ct)
=> Ok((await projectService.GetAllAsync(ct)).Select(project => Map(project)).ToArray());
[HttpGet("{id:guid}")]
public async Task<IResult> GetById(Guid id, CancellationToken ct)
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProjectDto>> GetById(Guid id, CancellationToken ct)
{
var project = await projectService.GetByIdAsync(id, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
return project is null ? NotFound() : Ok(Map(project));
}
[HttpGet("{id:guid}/tasks")]
[ProducesResponseType(typeof(IReadOnlyList<ProjectTaskDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<ProjectTaskDto>>> GetTasks(
Guid id,
CancellationToken ct)
{
if (await projectService.GetByIdAsync(id, ct) is null)
return NotFound();
var tasks = await projectService.GetTasksAsync(id, ct);
return Ok(tasks.Select(task => new ProjectTaskDto(
task.Id,
task.Title,
task.State,
task.Priority,
id,
task.AssignedTo,
task.ExpectedFrom,
task.IsAgentTask,
task.UpdatedAt)).ToArray());
}
[HttpPost]
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ProjectDto>> Create(
[FromBody] CreateProjectRequest request,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
{
ModelState.AddModelError("name", "Name is required.");
return ValidationProblem(ModelState);
}
var project = await projectService.CreateAsync(request, ct);
return Results.Created($"/api/v1/projects/{project.Id}", project);
return Created(
$"/api/v1/projects/{project.Id}",
Map(project, ProjectOperation(project, "created")));
}
[HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProjectDto>> Update(
Guid id,
[FromBody] UpdateProjectRequest request,
CancellationToken ct)
{
var project = await projectService.UpdateAsync(id, request, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
return project is null
? NotFound()
: Ok(Map(project, ProjectOperation(project, "updated")));
}
[HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct)
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var result = await projectService.DeleteAsync(id, ct);
return result.Outcome switch
{
ProjectDeleteOutcome.NotFound => Results.NotFound(),
ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
_ => Results.NoContent()
ProjectDeleteOutcome.NotFound => NotFound(),
ProjectDeleteOutcome.Archived => Ok(Map(
result.Project!,
ProjectOperation(result.Project!, "archived"))),
_ => Ok(Map(
result.Project!,
ProjectOperation(result.Project!, "deleted")))
};
}
private OperationResultDto ProjectOperation(
Nexus.Api.Data.Project project,
string status)
=> OperationResultFactory.FromHttpContext(
HttpContext,
status,
new EntityRefDto("project", project.Id.ToString(), project.Name));
private static ProjectDto Map(
Nexus.Api.Data.Project project,
OperationResultDto? operation = null)
=> new(
project.Id,
project.Name,
project.Description,
project.Status.ToString(),
project.Progress,
project.UpdatedAt,
operation);
}
+20 -11
View File
@@ -1,12 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
namespace Nexus.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/v1/security")]
public class SecurityController(IConfiguration config) : ControllerBase
{
[HttpGet("status")]
[ProducesResponseType(typeof(SecurityStatusDto), StatusCodes.Status200OK)]
public IResult GetStatus()
{
var jwtIssuer = config["Jwt:Issuer"] ?? "nexus";
@@ -14,16 +18,21 @@ public class SecurityController(IConfiguration config) : ControllerBase
var refreshDays = config.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
var accessTokenMinutes = config.GetValue<int>("Jwt:AccessTokenExpirationMinutes", 30);
return Results.Ok(new
{
authMethod = "JWT + PBKDF2",
tokenConfig = new { refreshTokenDays = refreshDays, accessTokenMinutes },
rateLimit = "5 login attempts per minute per IP",
passwordPolicy = "Minimum 10 characters",
cookieConfig = new { httpOnly = true, secure = true, sameSite = "Strict" },
twoFactorEnabled = false,
passkeyEnabled = false,
checkedAt = DateTimeOffset.UtcNow
});
return Results.Ok(new SecurityStatusDto(
"JWT + PBKDF2",
new SecurityTokenConfigDto(
jwtIssuer,
jwtAudience,
refreshDays,
accessTokenMinutes),
"5 login attempts per minute per IP",
"Minimum 10 characters",
new SecurityCookieConfigDto(
HttpOnly: true,
Secure: true,
SameSite: "Strict"),
TwoFactorEnabled: false,
PasskeyEnabled: false,
DateTimeOffset.UtcNow));
}
}
+155 -31
View File
@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Observability;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
@@ -29,7 +31,9 @@ public class TasksController(
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
var task = await taskService.CreateAsync(request, ct);
return Results.Created($"/api/v1/tasks/{task.Id}", task);
return Results.Created(
$"/api/v1/tasks/{task.Id}",
MapTask(task, TaskOperation(task, "created")));
}
[HttpGet("pending-approval")]
@@ -53,7 +57,9 @@ public class TasksController(
title: "Approval denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "completed")))
};
}
@@ -70,7 +76,9 @@ public class TasksController(
title: "Rejection denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "completed")))
};
}
@@ -88,7 +96,9 @@ public class TasksController(
title: "Action denied",
detail: "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
@@ -99,7 +109,9 @@ public class TasksController(
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
_ => Results.Ok(result.Task)
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
@@ -114,64 +126,176 @@ public class TasksController(
title: "Task deletion denied",
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.NoContent()
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "deleted")))
};
}
// ── Board & Stale-Reset (für Iris Autonomous Worker) ──
/// <summary>
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
/// Wird vom Iris Autonomous Worker genutzt.
/// Gibt alle aktiven Task-Spalten und eine keyset-paginierte Done-History
/// zurück. Der Done-Cursor ist opak und darf vom Client nicht verändert
/// oder interpretiert werden.
///
/// SICHERHEIT: Erfordert X-Agent-Id Header (bel. erkannter Agent) ODER
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
/// SICHERHEIT: Erfordert eine verifizierte JWT- oder
/// X-Nexus-Api-Key-Authentisierung.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
/// </summary>
[AllowAnonymous]
[HttpGet("board")]
public async Task<IResult> GetBoard(CancellationToken ct)
[ProducesResponseType(typeof(TaskBoardPageDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IResult> GetBoard(
CancellationToken ct,
[FromQuery] int doneLimit = 50,
[FromQuery] string? doneCursor = null)
{
var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
var isApiKey = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
if (!RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration))
return Results.Unauthorized();
return Results.Ok(await taskService.GetBoardAsync(ct));
if (doneLimit is < 1 or > 100)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["doneLimit"] = ["Done limit must be between 1 and 100."]
});
}
try
{
using var activity = NexusTelemetry.ActivitySource.StartActivity(
"nexus.task.board.query",
ActivityKind.Internal);
var stopwatch = Stopwatch.StartNew();
var board = await taskService.GetBoardPageAsync(doneLimit, doneCursor, ct);
stopwatch.Stop();
var cardCount = board.Offen.Count
+ board.InProgress.Count
+ board.Review.Count
+ board.Blocked.Count
+ board.Done.Count;
NexusTelemetry.TaskBoardDuration.Record(
stopwatch.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("result", "success"));
activity?.SetTag("nexus.task.count", cardCount);
activity?.SetTag("nexus.task.done_page_size", board.Done.Count);
activity?.SetTag("nexus.task.has_more_done", board.HasMoreDone);
if (NexusTelemetry.TaskBoardPayload.Enabled)
{
var payloadBytes = System.Text.Json.JsonSerializer
.SerializeToUtf8Bytes(
board,
new System.Text.Json.JsonSerializerOptions(
System.Text.Json.JsonSerializerDefaults.Web))
.LongLength;
NexusTelemetry.TaskBoardPayload.Record(
payloadBytes,
new KeyValuePair<string, object?>(
"page",
string.IsNullOrWhiteSpace(doneCursor) ? "initial" : "done"));
}
Response.Headers["Server-Timing"] =
$"board;dur={stopwatch.Elapsed.TotalMilliseconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture)}";
return Results.Ok(board);
}
catch (InvalidTaskBoardCursorException)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["doneCursor"] = ["Done cursor is invalid or no longer supported."]
});
}
}
/// <summary>
/// Returns one compact board projection for applying a persisted
/// domain-event delta without reloading every active and Done card.
/// </summary>
[HttpGet("{id:guid}/board-card")]
[ProducesResponseType(typeof(TaskBoardCardDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<TaskBoardCardDto>> GetBoardCard(
Guid id,
CancellationToken ct)
{
var card = await taskService.GetBoardCardAsync(id, ct);
return card is null ? NotFound() : Ok(card);
}
/// <summary>
/// Setzt stale Tasks (InProgress, älter als N Stunden) zurück auf Backlog.
/// Wird vom Iris Autonomous Worker genutzt.
///
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
/// X-Nexus-Api-Key / Service-Principal ODER owner/admin JWT.
/// SICHERHEIT: Erfordert eine verifizierte Authentisierung. Ein
/// X-Agent-Id-Hinweis für Iris wird nur für Service-Principals oder
/// owner/admin JWT ausgewertet.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
/// </summary>
[AllowAnonymous]
[HttpPost("reset-stale")]
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
{
var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(HttpContext, agentService, ct);
if (!RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration))
return Results.Unauthorized();
var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
HttpContext,
agentService,
configuration,
ct);
var isService = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isPrivilegedUser = RequestAuthorizationHelper.IsPrivilegedUser(HttpContext);
var isIris = string.Equals(agentHeaderResolution.AgentId, "iris", StringComparison.OrdinalIgnoreCase);
if (!isIris && !isService && !isPrivilegedUser)
{
// A presented but unrecognized agent header is an invalid credential, not a missing one.
if (HttpContext.User.Identity?.IsAuthenticated == true || agentHeaderResolution.HeaderProvided)
return Results.Forbid();
return Results.Unauthorized();
}
return Results.Forbid();
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
return Results.Ok(new ResetStaleResponse(count));
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
count > 0 ? "completed" : "noop",
new EntityRefDto("task-board", "active", "Task Board"));
return Results.Ok(new ResetStaleResponse(count, operation));
}
private OperationResultDto TaskOperation(WorkTask task, string status)
{
var affected = new List<EntityRefDto>();
if (task.ProjectId is { } projectId)
affected.Add(new EntityRefDto("project", projectId.ToString()));
if (task.ParentTaskId is { } parentTaskId)
affected.Add(new EntityRefDto("task", parentTaskId.ToString(), "Parent task"));
return OperationResultFactory.FromHttpContext(
HttpContext,
status,
new EntityRefDto("task", task.Id.ToString(), task.Title),
affectedRefs: affected);
}
private static DashboardTaskDto MapTask(
WorkTask task,
OperationResultDto? operation = null)
=> new(
task.Id,
task.Title,
task.Detail,
task.Source,
task.State,
task.Priority,
task.AssignedTo,
task.ParentTaskId,
task.DueDate,
task.CreatedAt,
task.UpdatedAt,
task.IsAgentTask,
task.ExpectedFrom,
ProjectId: task.ProjectId,
Operation: operation);
private async Task WriteApprovalAuditAsync(
Guid taskId,
string action,
@@ -179,7 +303,7 @@ public class TasksController(
string? state,
CancellationToken ct)
{
await activityRepository.AddAsync(new ActivityEvent
await activityRepository.AddAsync(new Nexus.Api.Data.ActivityEvent
{
Type = "task_approval_audit",
Message = $"Task approval task={taskId} action={action} caller={DescribeCaller(HttpContext.User)} outcome={outcome} checkpoint={(state ?? "none")}",