feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 & 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
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")}",
|
||||
|
||||
Reference in New Issue
Block a user