feat: ship agent-first mission control v0.2.57
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s

This commit is contained in:
AzuTear
2026-07-31 22:39:47 +02:00
parent 3bc7622977
commit f5552218bc
535 changed files with 95242 additions and 8791 deletions
+19 -9
View File
@@ -1,14 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/activity")]
public class ActivityController(IActivityRepository activityRepo) : ControllerBase
public sealed class ActivityController(IActivityRepository activityRepo) : ControllerBase
{
[HttpGet]
public async Task<IResult> Get(
[ProducesResponseType(typeof(ActivityPageDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<ActivityPageDto>> Get(
[FromQuery] string? type,
[FromQuery] string? sort,
[FromQuery] int? page,
@@ -20,13 +25,18 @@ public class ActivityController(IActivityRepository activityRepo) : ControllerBa
var (items, totalCount) = await activityRepo.GetPagedAsync(type, sort, pageNum, take, ct);
return Results.Ok(new
{
items = items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }),
return Ok(new ActivityPageDto(
items.Select(item => new ActivityItemDto(
item.Id,
item.Type,
item.Message,
item.CreatedAt,
item.TaskId is Guid taskId
? new EntityRefDto("task", taskId.ToString(), null)
: null)).ToArray(),
totalCount,
page = pageNum,
pageSize = take,
totalPages = (int)Math.Ceiling((double)totalCount / take)
});
pageNum,
take,
(int)Math.Ceiling((double)totalCount / take)));
}
}
+104 -42
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
@@ -13,13 +14,14 @@ namespace Nexus.Api.Controllers;
[Route("api/v1/agents")]
public class AgentsController(
IAgentService agentService,
IAgentRuntime runtime,
IOpenClawChatService chat,
IActivityRepository activityRepo,
IAgentConfigService agentConfigService,
IOpenClawAgentConfigurationService agentConfiguration,
IDashboardService dashboardService,
ILogger<AgentsController> logger) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<AgentListResponse>), StatusCodes.Status200OK)]
public async Task<IResult> GetAgents(CancellationToken ct)
{
var agents = await agentService.GetAgentsAsync(ct);
@@ -28,6 +30,8 @@ public class AgentsController(
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(AgentDetailResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IResult> GetAgent(string id, CancellationToken ct)
{
var agent = await agentService.GetAgentAsync(id, ct);
@@ -39,6 +43,7 @@ public class AgentsController(
}
[HttpGet("{id}/activity")]
[ProducesResponseType(typeof(IReadOnlyList<AgentActivityResponse>), StatusCodes.Status200OK)]
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
{
var items = await activityRepo.GetByAgentAsync(id, 50, ct);
@@ -56,6 +61,7 @@ public class AgentsController(
}
[HttpGet("{id}/summary")]
[ProducesResponseType(typeof(AgentSummaryResponse), StatusCodes.Status200OK)]
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
{
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
@@ -64,7 +70,11 @@ public class AgentsController(
}
[HttpPost("{id}/command")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(AgentCommandResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> SendCommand(string id, [FromBody] AgentCommandRequest request, CancellationToken ct)
{
var message = request.Message?.Trim();
@@ -75,8 +85,30 @@ public class AgentsController(
try
{
var result = await runtime.ChatAsync(message, conversationId, id, ct);
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
var context = OpenClawInvocationContextFactory.Create(
User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.Identity?.Name,
Request.Headers["Idempotency-Key"].FirstOrDefault(),
Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? HttpContext.TraceIdentifier,
Request.Headers["traceparent"].FirstOrDefault()
?? Activity.Current?.Id);
var result = await chat.SendAsync(
message,
conversationId,
id,
new Models.OpenClawInvocationMetadata(
context.IdempotencyKey,
context.CorrelationId,
context.Actor,
context.TraceParent),
ct);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "agent",
Message = $"Command dispatched to agent {id} as durable run {result.RunId}"
}, ct);
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
}
catch (Exception exception)
@@ -92,16 +124,35 @@ public class AgentsController(
// ── Config Editor ──
[HttpGet("{id}/config")]
public IResult GetConfig(string id)
=> Results.Ok(agentConfigService.GetConfigFiles(id));
[Authorize(Roles = "owner")]
public async Task<IResult> GetConfig(string id, CancellationToken ct)
{
var files = await agentConfiguration.GetAgentFilesAsync(id, ct);
return Results.Ok(files.Files.Select(file => new
{
FileName = file.Name,
file.Size,
ModifiedAt = file.UpdatedAt,
file.Missing,
file.ContentHash
}));
}
[HttpGet("{id}/config/{fileName}")]
[Authorize(Roles = "owner")]
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
{
var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
return file is null
var file = await agentConfiguration.GetAgentFileAsync(id, fileName, ct);
return file.Missing
? Results.NotFound()
: Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
: Results.Ok(new
{
FileName = file.Name,
file.Content,
file.Size,
ModifiedAt = file.UpdatedAt,
file.ContentHash
});
}
[HttpPut("{id}/config/{fileName}")]
@@ -110,59 +161,70 @@ public class AgentsController(
{
if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." });
if (string.IsNullOrWhiteSpace(request.ExpectedHash))
return Results.BadRequest(new { error = "ExpectedHash is required." });
try
{
var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
var caller = DescribeCaller(HttpContext.User);
if (attempt.Failure is not null)
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = attempt.Failure.Validation.Errors.ToArray()
});
return Results.BadRequest(new { error = "Idempotency-Key header is required." });
}
var result = attempt.SaveResult!;
var invocation = OpenClawInvocationContext.Create(
caller,
idempotencyKey,
Request.Headers["X-Correlation-ID"].FirstOrDefault(),
Request.Headers["traceparent"].FirstOrDefault());
var result = await agentConfiguration.SetAgentFileAsync(
id,
fileName,
new Nexus.Api.Models.UpdateOpenClawAgentFileRequest(request.Content, request.ExpectedHash),
invocation,
ct);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
Message = $"Config save agent={id} file={fileName} caller={caller} verified={result.Verified} state={result.State}",
}, ct);
return Results.Ok(new
{
result.FileName,
result.Size,
result.ModifiedAt,
result.Validation,
result.Backup,
ReloadCheck = result.ReloadCheck
FileName = result.File.Name,
result.File.Size,
ModifiedAt = result.File.UpdatedAt,
result.File.ContentHash,
result.Verified,
result.State,
result.Message
});
}
catch (UnauthorizedAccessException ex)
catch (OpenClawAgentConfigurationConflictException ex)
{
logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "Permission denied",
detail: $"Cannot write config file '{fileName}' for agent '{id}'. The target path may be owned by a different user.",
statusCode: StatusCodes.Status500InternalServerError);
return Results.Json(
new
{
code = ex.Code,
message = ex.Message,
ex.ExpectedHash,
ex.CurrentHash
},
statusCode: StatusCodes.Status409Conflict);
}
catch (IOException ex)
catch (OpenClawAgentConfigurationValidationException ex)
{
logger.LogError(ex, "I/O error saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "File write error",
detail: $"Failed to write config file '{fileName}' for agent '{id}': {ex.Message}",
statusCode: StatusCodes.Status500InternalServerError);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={DescribeCaller(HttpContext.User)} validation=failed code=validation_failed",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[ex.Field] = [ex.Message]
});
}
}
+6
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
@@ -19,6 +20,7 @@ public class AuthController(
LoginAttemptTracker attemptTracker) : ControllerBase
{
[HttpGet("csrf")]
[AllowAnonymous]
public IActionResult GetCsrfToken()
{
var tokens = antiforgery.GetAndStoreTokens(HttpContext);
@@ -26,6 +28,7 @@ public class AuthController(
}
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("auth")]
public async Task<IResult> Login([FromBody] LoginRequest request, CancellationToken ct)
{
@@ -68,6 +71,7 @@ public class AuthController(
}
[HttpPost("refresh")]
[AllowAnonymous]
[EnableRateLimiting("auth")]
public async Task<IResult> Refresh(CancellationToken ct)
{
@@ -89,6 +93,7 @@ public class AuthController(
}
[HttpPost("logout")]
[AllowAnonymous]
public async Task<IResult> Logout(CancellationToken ct)
{
if (Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
@@ -123,6 +128,7 @@ public class AuthController(
}
[HttpPost("admin-reset-password")]
[AllowAnonymous]
[EnableRateLimiting("agents")]
public async Task<IResult> AdminResetPassword([FromBody] AdminResetPasswordRequest request, CancellationToken ct)
{
@@ -0,0 +1,85 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Models;
using Nexus.Api.Observability;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/telemetry/browser")]
[EnableRateLimiting("agents")]
public sealed class BrowserTelemetryController : ControllerBase
{
private static readonly HashSet<string> AllowedNames =
[
"CLS",
"FCP",
"INP",
"LCP",
"TTFB",
"board_content_visible",
"board_delta_painted",
"mutation_confirmed",
"agent_proposal_readback"
];
private static readonly HashSet<string> AllowedRatings =
["good", "needs-improvement", "poor", "custom"];
[HttpPost]
public IResult Record([FromBody] BrowserMetricRequest request)
{
if (!AllowedNames.Contains(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["name"] = ["Unsupported browser metric."]
});
if (!double.IsFinite(request.Value) || request.Value < 0 || request.Value > 86_400_000)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["value"] = ["Metric value must be finite and within the accepted range."]
});
if (!AllowedRatings.Contains(request.Rating))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["rating"] = ["Unsupported metric rating."]
});
var route = NormalizeBoundedDimension(request.RouteName, "unknown", 80);
var liveMode = request.LiveMode is "live" or "polling" ? request.LiveMode : "unknown";
var navigationType = NormalizeBoundedDimension(request.NavigationType, "unknown", 40);
var tags = new TagList
{
{ "metric.name", request.Name },
{ "metric.rating", request.Rating },
{ "route.name", route },
{ "nexus.live_mode", liveMode },
{ "navigation.type", navigationType }
};
if (request.Name == "CLS")
NexusTelemetry.BrowserScore.Record(request.Value, tags);
else
NexusTelemetry.BrowserDuration.Record(request.Value, tags);
return Results.NoContent();
}
private static string NormalizeBoundedDimension(string? value, string fallback, int maxLength)
{
if (string.IsNullOrWhiteSpace(value))
return fallback;
var normalized = new string(value
.Where(character => char.IsAsciiLetterOrDigit(character) || character is ' ' or '_' or '-')
.Take(maxLength)
.ToArray());
return string.IsNullOrWhiteSpace(normalized) ? fallback : normalized;
}
}
+37 -4
View File
@@ -1,15 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[Authorize(Roles = "owner")]
[ApiController]
[Route("api/v1/chat")]
public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase
public class ChatController(
IOpenClawChatService chat,
ILogger<ChatController> logger) : ControllerBase
{
[HttpPost]
[EnableRateLimiting("agents")]
@@ -31,7 +35,35 @@ public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logge
try
{
return Results.Ok(await runtime.ChatAsync(message, conversationId, agentId, ct));
var contextualMessage = MissionControlContextFormatter.Format(message, request.Context);
var invocationContext = OpenClawInvocationContextFactory.Create(
User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.Identity?.Name,
Request.Headers["Idempotency-Key"].FirstOrDefault(),
Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? HttpContext.TraceIdentifier,
Request.Headers["traceparent"].FirstOrDefault()
?? Activity.Current?.Id);
var invocation = new Nexus.Api.Models.OpenClawInvocationMetadata(
invocationContext.IdempotencyKey,
invocationContext.CorrelationId,
invocationContext.Actor,
invocationContext.TraceParent);
Response.Headers["X-Correlation-ID"] = invocation.CorrelationId;
return Results.Ok(await chat.SendAsync(
contextualMessage,
conversationId,
agentId,
invocation,
ct));
}
catch (ArgumentException exception)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["requestMetadata"] = [exception.Message]
});
}
catch (Exception exception)
{
@@ -42,4 +74,5 @@ public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logge
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
}
+125 -44
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
@@ -36,6 +37,9 @@ public class DashboardController(
=> await dashboardService.GetOperationsAsync(limit, agent);
[HttpPost("chat/send")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[Obsolete("Legacy adapter. Use POST /api/v1/chat.")]
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
@@ -61,6 +65,8 @@ public class DashboardController(
=> await dashboardService.GetGatewayInfoAsync(ct);
[HttpDelete("queue/{id}")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
@@ -71,6 +77,14 @@ public class DashboardController(
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
QueueDeleteOutcome.Ignored when string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)
=> StatusCode(
StatusCodes.Status410Gone,
new
{
error = "Legacy cron deletion has been removed",
recovery = $"Use DELETE /api/v1/openclaw/cron/{Uri.EscapeDataString(id)} with Idempotency-Key and a current resource hash."
}),
_ => StatusCode(500, new { error = "Internal error" })
};
}
@@ -112,8 +126,8 @@ public class DashboardController(
=> await dashboardService.GetAgentActivityAsync(id, limit);
[HttpGet("models")]
public ActionResult<List<ModelOption>> GetAvailableModels()
=> Ok(dashboardService.GetAvailableModels());
public async Task<ActionResult<List<ModelOption>>> GetAvailableModels(CancellationToken ct)
=> Ok(await dashboardService.GetAvailableModelsAsync(ct));
// ── Task Endpoints ──
@@ -121,7 +135,7 @@ public class DashboardController(
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
{
var tasks = await taskService.GetOpenAsync(ct);
return tasks.Select(MapToDto).ToList();
return tasks.Select(task => MapToDto(task)).ToList();
}
[HttpPost("tasks")]
@@ -135,7 +149,9 @@ public class DashboardController(
{
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
return Created(
$"/api/dashboard/tasks/{task.Id}",
MapToDto(task, TaskOperation(task, "created")));
}
catch (ArgumentException ex)
{
@@ -152,7 +168,9 @@ public class DashboardController(
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
@@ -164,7 +182,9 @@ public class DashboardController(
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
_ => NoContent()
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "deleted")))
};
}
@@ -178,7 +198,7 @@ public class DashboardController(
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
var callerAgent = await ResolveCallerAgentAsync(ct);
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
@@ -191,13 +211,14 @@ public class DashboardController(
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
// ── Task Board Endpoints ──
[AllowAnonymous]
[HttpGet("tasks/board")]
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
{
@@ -227,24 +248,45 @@ public class DashboardController(
}
var currentSequence = liveUpdateService.CurrentSequence;
// Subscribe before loading the snapshot so updates published while the
// snapshot is assembled are queued and delivered afterwards.
var subscription = await liveUpdateService.SubscribeAsync(currentSequence, ct);
var initial = new DashboardLiveSnapshotDto(
await taskService.GetBoardAsync(ct),
await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct),
new LiveCursorDto(currentSequence, DateTimeOffset.UtcNow, "live"));
await WriteEventAsync("snapshot", initial);
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
// A fresh snapshot is the authoritative baseline for this legacy
// adapter. Only advance this cursor after an update has actually been
// written; CurrentSequence may include events still queued below.
var lastSent = currentSequence;
while (!ct.IsCancellationRequested)
{
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
using var iteration = CancellationTokenSource.CreateLinkedTokenSource(ct);
var readTask = subscription.Reader.WaitToReadAsync(iteration.Token).AsTask();
var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), iteration.Token);
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == readTask)
if (completed == heartbeatTask)
{
var envelope = await readTask;
iteration.Cancel();
await WriteEventAsync(
"heartbeat",
new LiveCursorDto(lastSent, DateTimeOffset.UtcNow, "live"));
continue;
}
iteration.Cancel();
if (!await readTask)
break;
while (subscription.Reader.TryRead(out var envelope))
{
if (envelope.Sequence <= lastSent)
continue;
if (envelope.Type == "notifications.snapshot")
{
var snapshot = envelope.Payload as NotificationSnapshotDto
@@ -262,10 +304,7 @@ public class DashboardController(
await WriteEventAsync("update", new DashboardLiveEventDto(
envelope,
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
}
else if (await heartbeatTask)
{
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
lastSent = envelope.Sequence;
}
}
}
@@ -283,7 +322,7 @@ public class DashboardController(
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
var callerAgent = await ResolveCallerAgentAsync(ct);
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
@@ -296,24 +335,36 @@ public class DashboardController(
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
/// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly).
/// Resolves the caller identity from a verified principal. X-Agent-Id is
/// treated only as an authenticated, allow-listed actor hint.
/// </summary>
private string ResolveCallerAgent()
private async Task<string> ResolveCallerAgentAsync(CancellationToken ct)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(
httpContext,
agentService,
configuration,
ct);
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
return agentHeader;
var user = httpContext.User;
if (user?.Identity?.IsAuthenticated != true)
return "";
if (user.IsInRole("owner") || user.IsInRole("admin"))
return "bao";
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
@@ -326,7 +377,11 @@ public class DashboardController(
{
var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours));
var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct);
return Ok(new ResetStaleResponse(count));
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
count > 0 ? "completed" : "noop",
new EntityRefDto("task-board", "active", "Task Board"));
return Ok(new ResetStaleResponse(count, operation));
}
[HttpGet("tasks/{id:guid}/children")]
@@ -361,7 +416,7 @@ public class DashboardController(
}
[HttpPost("tasks/{id:guid}/activity")]
public async Task<ActionResult<ActivityEvent>> PostTaskActivity(
public async Task<ActionResult<ActivityItemDto>> PostTaskActivity(
Guid id, [FromBody] PostActivityRequest request, CancellationToken ct)
{
var task = await taskService.GetByIdAsync(id, ct);
@@ -378,7 +433,21 @@ public class DashboardController(
};
await activityService.AddAsync(ev, ct);
return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev);
var entity = new EntityRefDto("task", task.Id.ToString(), task.Title);
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
"created",
new EntityRefDto("activity", ev.Id.ToString(), ev.Type),
affectedRefs: [entity]);
return Created(
$"/api/dashboard/tasks/{id}/activity/{ev.Id}",
new ActivityItemDto(
ev.Id,
ev.Type,
ev.Message,
ev.CreatedAt,
entity,
operation));
}
// ── Agent Workflow Endpoints (Iris Overview) ──
@@ -391,7 +460,7 @@ public class DashboardController(
public async Task<ActionResult<List<DashboardTaskDto>>> GetAgentWaitingTasks(CancellationToken ct)
{
var waiting = await taskService.GetWaitingTasksAsync(ct);
return Ok(waiting.Select(MapToDto).ToList());
return Ok(waiting.Select(task => MapToDto(task)).ToList());
}
/// <summary>
@@ -424,7 +493,9 @@ public class DashboardController(
request.Priority, request.AssignedTo, request.ExpectedFrom,
request.ParentTaskId, request.StartsInProgress, request.InitialState, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
return Created(
$"/api/dashboard/tasks/{task.Id}",
MapToDto(task, TaskOperation(task, "created")));
}
catch (ArgumentException ex)
{
@@ -432,20 +503,30 @@ public class DashboardController(
}
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
private OperationResultDto TaskOperation(WorkTask task, string status)
{
var affected = new List<EntityRefDto>();
if (task.ProjectId is { } projectId)
affected.Add(new EntityRefDto("project", projectId.ToString()));
if (task.ParentTaskId is { } parentTaskId)
affected.Add(new EntityRefDto("task", parentTaskId.ToString(), "Parent task"));
return OperationResultFactory.FromHttpContext(
HttpContext,
status,
new EntityRefDto("task", task.Id.ToString(), task.Title),
affectedRefs: affected);
}
private static DashboardTaskDto MapToDto(
WorkTask t,
OperationResultDto? operation = null) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
t.IsAgentTask, t.ExpectedFrom,
ProjectId: t.ProjectId,
Operation: operation);
private async Task<bool> CanReadBoardAsync(CancellationToken ct)
{
var allowedAgent = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
if (!string.IsNullOrWhiteSpace(allowedAgent))
return true;
if (RequestAuthorizationHelper.HasValidServiceKey(HttpContext, configuration))
return true;
return User.Identity?.IsAuthenticated == true;
}
private Task<bool> CanReadBoardAsync(CancellationToken _)
=> Task.FromResult(RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration));
}
+48 -6
View File
@@ -1,23 +1,65 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize(Roles = "owner")]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status403Forbidden)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status409Conflict)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status502BadGateway)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status503ServiceUnavailable)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status504GatewayTimeout)]
[ApiController]
[Route("api/v1/docs")]
public class DocsController(IDocService docService) : ControllerBase
{
[HttpGet]
public IResult GetAll()
=> Results.Ok(docService.GetAll());
[ProducesResponseType(typeof(IReadOnlyList<DocFileInfo>), StatusCodes.Status200OK)]
public Task<IResult> GetAll(
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
Results.Ok(await docService.GetAllAsync(
agentId,
cancellationToken)));
[HttpGet("{**path}")]
public async Task<IResult> GetFile(string path)
[ProducesResponseType(typeof(DocFileContent), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public Task<IResult> GetFile(
string path,
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path))
return Results.BadRequest("Path required.");
{
return Task.FromResult<IResult>(
Results.ValidationProblem(new Dictionary<string, string[]>
{
["path"] = ["Path is required."]
}));
}
var file = await docService.GetFileAsync(path);
return file is null ? Results.NotFound() : Results.Ok(file);
return OpenClawContentReadEndpoint.ExecuteAsync(async () =>
{
var file = await docService.GetFileAsync(
path,
agentId,
cancellationToken);
return file is null ? Results.NotFound() : Results.Ok(file);
});
}
}
@@ -0,0 +1,233 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Observability;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/events")]
public sealed class DomainEventsController(
NexusDbContext db,
IDomainEventStreamService eventStream,
ILogger<DomainEventsController> logger) : ControllerBase
{
private const int MaximumReplay = 512;
[HttpGet]
public async Task Get(
[FromQuery] string? channels,
[FromQuery] long? afterSequence,
CancellationToken cancellationToken)
{
Response.Headers.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache, no-store, must-revalidate";
Response.Headers.Connection = "keep-alive";
Response.Headers["X-Accel-Buffering"] = "no";
var requestedChannels = ParseChannels(channels);
var cursor = ResolveCursor(afterSequence);
await using var subscription = eventStream.Subscribe(requestedChannels);
var lastSent = cursor ?? subscription.StartingSequence;
if (cursor.HasValue)
{
var earliest = await db.OutboxEvents
.AsNoTracking()
.Where(item => item.PublishedAt != null)
.OrderBy(item => item.Sequence)
.Select(item => (long?)item.Sequence)
.FirstOrDefaultAsync(cancellationToken);
if (earliest.HasValue && cursor.Value < earliest.Value - 1)
{
await WriteResyncRequiredAsync(
cursor.Value,
"retention_gap",
cancellationToken);
return;
}
var replay = await db.OutboxEvents
.AsNoTracking()
.Where(item =>
item.PublishedAt != null &&
item.Sequence > cursor.Value &&
item.Sequence <= subscription.StartingSequence)
.OrderBy(item => item.Sequence)
.Take(MaximumReplay + 1)
.ToListAsync(cancellationToken);
if (replay.Count > MaximumReplay)
{
await WriteResyncRequiredAsync(
cursor.Value,
"replay_limit",
cancellationToken);
return;
}
foreach (var item in replay)
{
var domainEvent = DomainEventStreamService.Map(item);
if (!MatchesChannel(domainEvent, requestedChannels))
continue;
await WriteEventAsync("domain", domainEvent.Sequence, domainEvent, cancellationToken);
lastSent = domainEvent.Sequence;
}
}
try
{
while (!cancellationToken.IsCancellationRequested)
{
using var iteration = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
var readTask = subscription.Reader
.WaitToReadAsync(iteration.Token)
.AsTask();
var heartbeatTask = Task.Delay(
TimeSpan.FromSeconds(20),
iteration.Token);
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == heartbeatTask)
{
iteration.Cancel();
await WriteEventAsync(
"heartbeat",
lastSent,
new
{
sequence = eventStream.CurrentSequence,
timestamp = DateTimeOffset.UtcNow
},
cancellationToken);
continue;
}
iteration.Cancel();
if (!await readTask)
break;
while (subscription.Reader.TryRead(out var domainEvent))
{
if (domainEvent.Sequence <= lastSent)
continue;
await WriteEventAsync(
"domain",
domainEvent.Sequence,
domainEvent,
cancellationToken);
lastSent = domainEvent.Sequence;
}
}
}
catch (DomainEventSubscriberOverflowException)
{
await WriteResyncRequiredAsync(lastSent, "subscriber_overflow", cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Normal browser disconnect.
}
catch (Exception exception)
{
logger.LogWarning(exception, "Domain event stream ended unexpectedly");
}
}
private long? ResolveCursor(long? queryCursor)
{
if (queryCursor.HasValue)
return queryCursor.Value >= 0 ? queryCursor : null;
if (!Request.Headers.TryGetValue("Last-Event-ID", out var header))
return null;
return long.TryParse(header.FirstOrDefault(), out var parsed) && parsed >= 0
? parsed
: null;
}
private async Task WriteResyncRequiredAsync(
long staleSequence,
string reason,
CancellationToken cancellationToken)
{
NexusTelemetry.SseResyncs.Add(1);
// Resume after the latest sequence that was visible when the resync
// decision was made. Re-emitting the stale cursor would make a client
// reconnect into the same retention/replay gap forever.
var resumeSequence = eventStream.CurrentSequence;
var payload = JsonSerializer.SerializeToElement(new
{
reason,
staleSequence,
resumeSequence
});
var domainEvent = new DomainEventDto(
resumeSequence,
DomainEventTypes.ResyncRequired,
new EntityRefDto("event-stream", "*"),
0,
DateTimeOffset.UtcNow,
payload);
await WriteEventAsync(
DomainEventTypes.ResyncRequired,
resumeSequence,
domainEvent,
cancellationToken);
}
private async Task WriteEventAsync(
string eventName,
long sequence,
object payload,
CancellationToken cancellationToken)
{
await Response.WriteAsync($"id: {sequence}\n", cancellationToken);
await Response.WriteAsync($"event: {eventName}\n", cancellationToken);
await Response.WriteAsync(
$"data: {JsonSerializer.Serialize(payload, JsonOptions)}\n\n",
cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
}
private static readonly JsonSerializerOptions JsonOptions =
new(JsonSerializerDefaults.Web);
private static IReadOnlySet<string> ParseChannels(string? channels)
{
if (string.IsNullOrWhiteSpace(channels))
return new HashSet<string>(["*"], StringComparer.OrdinalIgnoreCase);
var parsed = channels
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(channel => channel.ToLowerInvariant())
.Where(channel => channel.All(character =>
char.IsLetterOrDigit(character) || character is '-' or '_'))
.Take(16)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return parsed.Count == 0
? new HashSet<string>(["*"], StringComparer.OrdinalIgnoreCase)
: parsed;
}
private static bool MatchesChannel(
DomainEventDto domainEvent,
IReadOnlySet<string> channels)
{
if (channels.Contains("*"))
return true;
var channel = domainEvent.Entity.Type switch
{
"agent-proposal" => "agents",
"task" => "tasks",
"run" => "runs",
"cron" => "cron",
_ => $"{domainEvent.Entity.Type}s"
};
return channels.Contains(channel);
}
}
+65 -18
View File
@@ -14,7 +14,8 @@ namespace Nexus.Api.Controllers;
/// This is the SINGLE entrypoint for agents (Iris + sub-agents) to interact with
/// the Nexus task board, activity log, and delegation workflow.
///
/// AUTHENTICATION: Requires X-Nexus-Api-Key or a known allowed X-Agent-Id.
/// AUTHENTICATION: Requires a verified JWT or X-Nexus-Api-Key. X-Agent-Id is
/// accepted only as an actor hint for a service or privileged user principal.
/// The browser NEVER uses this controller — only backend-to-backend and gateway-to-backend.
///
/// DESIGN PRINCIPLE: No MCP protocol between Nexus and Gateway — instead, the Gateway
@@ -34,6 +35,7 @@ namespace Nexus.Api.Controllers;
/// </summary>
[ApiController]
[Route("api/bridge")]
[Authorize]
[EnableRateLimiting("agents")]
public class GatewayBridgeController(
ITaskBridgeService bridge,
@@ -42,7 +44,7 @@ public class GatewayBridgeController(
ILogger<GatewayBridgeController> logger) : ControllerBase
{
private const string ApikeyErrorMessage =
"Bridge endpoints require X-Nexus-Api-Key or X-Agent-Id header with a recognized agent identity.";
"Bridge endpoints require a verified JWT or X-Nexus-Api-Key.";
[HttpGet("health")]
public IResult Health()
@@ -236,18 +238,26 @@ public class GatewayBridgeController(
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
if (!RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedActorIds.Contains(normalizedHeader))
return (true, normalizedHeader, null);
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
normalizedHeader,
HttpContext.Connection.RemoteIpAddress);
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, unauthorized);
}
var agentHeader = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
HttpContext,
agentService,
configuration,
ct);
if (agentHeader.AgentId is not null)
return (true, agentHeader.AgentId, null);
if (agentHeader.HeaderProvided && !agentHeader.IsRecognized)
logger.LogWarning(
"Bridge: ignoring unknown X-Agent-Id from authenticated caller at {Ip} and continuing identity fallback",
HttpContext.Connection.RemoteIpAddress);
if (User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
@@ -264,9 +274,9 @@ public class GatewayBridgeController(
allowedActorIds.Contains("nexus-system"))
return (true, "nexus-system", null);
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, unauthorized);
var forbidden = StatusCode(StatusCodes.Status403Forbidden, new { error = "Authenticated caller has no permitted bridge identity." });
logger.LogWarning("Bridge: authenticated caller has no permitted identity from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, forbidden);
}
private static string ResolveSource(string agentId) => agentId switch
@@ -275,14 +285,15 @@ public class GatewayBridgeController(
_ => agentId
};
private static ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
private ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<T>
{
Ok = true,
Command = command,
Data = result.Data
Data = result.Data,
Operation = BuildBridgeOperation(command, result.Data)
});
var statusCode = result.Outcome switch
@@ -302,7 +313,7 @@ public class GatewayBridgeController(
}) { StatusCode = statusCode };
}
private static ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
private ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
@@ -310,7 +321,19 @@ public class GatewayBridgeController(
Ok = true,
Command = command,
Data = result.Data is null ? null : new ActivityEntryDto(
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt)
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
Operation = result.Data is null
? null
: OperationResultFactory.FromHttpContext(
HttpContext,
"completed",
new EntityRefDto(
"activity",
result.Data.Id.ToString(),
result.Data.Type),
affectedRefs: result.Data.TaskId is { } taskId
? [new EntityRefDto("task", taskId.ToString())]
: [])
});
var statusCode = result.Outcome switch
@@ -327,6 +350,29 @@ public class GatewayBridgeController(
Error = result.Error ?? "Unknown error"
}) { StatusCode = statusCode };
}
private OperationResultDto? BuildBridgeOperation<T>(string command, T? data)
where T : class
{
if (command.StartsWith("get_", StringComparison.Ordinal) || data is null)
return null;
if (data is DashboardTaskDto task)
{
var affected = new List<EntityRefDto>();
if (task.ProjectId is { } projectId)
affected.Add(new EntityRefDto("project", projectId.ToString()));
if (task.ParentTaskId is { } parentTaskId)
affected.Add(new EntityRefDto("task", parentTaskId.ToString(), "Parent task"));
return OperationResultFactory.FromHttpContext(
HttpContext,
"completed",
new EntityRefDto("task", task.Id.ToString(), task.Title),
affectedRefs: affected);
}
return null;
}
}
public sealed class TaskBridgeCommandResponse<T>
@@ -335,6 +381,7 @@ public sealed class TaskBridgeCommandResponse<T>
public string Command { get; init; } = string.Empty;
public T? Data { get; init; }
public string? Error { get; init; }
public OperationResultDto? Operation { get; init; }
public string Timestamp { get; init; } = DateTimeOffset.UtcNow.ToString("o");
}
@@ -24,7 +24,10 @@ public class GatewayHealthController(IGatewayConnector connector) : ControllerBa
gatewayVersion = connector.GatewayVersion ?? "unknown",
requiredVersion = connector.RequiredVersion,
versionPinned = connector.RequiredVersion is not null,
protocolVersion = connector.ProtocolVersion,
advertisedMethodCount = connector.AdvertisedMethods.Count,
lastConnectedAt = connector.LastConnectedAt?.ToString("o"),
lastEventAt = connector.LastEventAt?.ToString("o"),
reconnectAttempts = connector.ReconnectAttempts,
message = connector.StatusMessage,
timestamp = DateTimeOffset.UtcNow.ToString("o")
+6 -21
View File
@@ -11,29 +11,14 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
[AllowAnonymous]
[HttpGet("/health/live")]
public IResult Live()
{
var agentCount = 0;
try
=> Results.Ok(new
{
var path = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? "/app",
"..");
var configPath = "/home/node/.openclaw/agents-sanitized.json";
if (System.IO.File.Exists(configPath))
{
var json = System.IO.File.ReadAllText(configPath);
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("agents", out var agentsEl)
&& agentsEl.TryGetProperty("list", out var listEl))
agentCount = listEl.GetArrayLength();
}
}
catch { }
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow, agentCount });
}
status = "Healthy",
timestamp = DateTimeOffset.UtcNow,
agentSource = "openclaw-rpc"
});
[AllowAnonymous]
[HttpGet("/health")]
public async Task<IResult> Get(CancellationToken ct)
{
+42 -7
View File
@@ -1,20 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize(Roles = "owner")]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status403Forbidden)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status409Conflict)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status502BadGateway)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status503ServiceUnavailable)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status504GatewayTimeout)]
[ApiController]
[Route("api/v1/incidents")]
public class IncidentsController(IIncidentService incidentService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll()
=> Results.Ok(await incidentService.GetAllAsync());
[ProducesResponseType(typeof(IReadOnlyList<IncidentSummary>), StatusCodes.Status200OK)]
public Task<IResult> GetAll(
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
Results.Ok(await incidentService.GetAllAsync(
agentId,
cancellationToken)));
[HttpGet("{name}")]
public async Task<IResult> GetOne(string name)
{
var incident = await incidentService.GetByNameAsync(name);
return incident is null ? Results.NotFound() : Results.Ok(incident);
}
[ProducesResponseType(typeof(IncidentDetail), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public Task<IResult> GetOne(
string name,
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
{
var incident = await incidentService.GetByNameAsync(
name,
agentId,
cancellationToken);
return incident is null
? Results.NotFound()
: Results.Ok(incident);
});
}
+58 -10
View File
@@ -1,29 +1,77 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize(Roles = "owner")]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status403Forbidden)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status409Conflict)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status502BadGateway)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status503ServiceUnavailable)]
[ProducesResponseType(
typeof(OpenClawAgentConfigurationErrorDto),
StatusCodes.Status504GatewayTimeout)]
[ApiController]
[Route("api/v1/memory")]
public class MemoryController(IMemoryService memoryService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll()
=> Results.Ok(await memoryService.GetAllAsync());
[ProducesResponseType(typeof(IReadOnlyList<MemoryFileInfo>), StatusCodes.Status200OK)]
public Task<IResult> GetAll(
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
Results.Ok(await memoryService.GetAllAsync(
agentId,
cancellationToken)));
[HttpGet("search")]
public async Task<IResult> Search([FromQuery] string q)
[ProducesResponseType(typeof(IReadOnlyList<MemorySearchResult>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public Task<IResult> Search(
[FromQuery] string q,
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Results.BadRequest("Query must be at least 2 characters.");
{
return Task.FromResult<IResult>(
Results.ValidationProblem(new Dictionary<string, string[]>
{
["q"] = ["Query must be at least 2 characters."]
}));
}
return Results.Ok(await memoryService.SearchAsync(q));
return OpenClawContentReadEndpoint.ExecuteAsync(async () =>
Results.Ok(await memoryService.SearchAsync(
q,
agentId,
cancellationToken)));
}
[HttpGet("{name}")]
public async Task<IResult> GetFile(string name)
{
var file = await memoryService.GetFileAsync(name);
return file is null ? Results.NotFound() : Results.Ok(file);
}
[ProducesResponseType(typeof(MemoryFileContent), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public Task<IResult> GetFile(
string name,
[FromQuery] string agentId = "iris",
CancellationToken cancellationToken = default)
=> OpenClawContentReadEndpoint.ExecuteAsync(async () =>
{
var file = await memoryService.GetFileAsync(
name,
agentId,
cancellationToken);
return file is null ? Results.NotFound() : Results.Ok(file);
});
}
+41 -8
View File
@@ -19,7 +19,7 @@ public class NotificationsController(INotificationService notificationService) :
CancellationToken ct = default)
{
var notifications = await notificationService.GetForUserAsync(forUser, limit, unreadOnly, ct);
return Ok(notifications.Select(MapToDto).ToList());
return Ok(notifications.Select(notification => MapToDto(notification)).ToList());
}
[HttpGet("unread-count")]
@@ -42,22 +42,55 @@ public class NotificationsController(INotificationService notificationService) :
}
[HttpPatch("{id:guid}/read")]
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
[ProducesResponseType(typeof(NotificationDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<NotificationDto>> MarkAsRead(
Guid id,
CancellationToken ct = default)
{
var ok = await notificationService.MarkAsReadAsync(id, ct);
return ok ? NoContent() : NotFound(new { error = "Notification not found." });
var readResult = await notificationService.MarkAsReadAsync(id, ct);
var notification = readResult.Notification;
if (notification is null)
return NotFound(new ProblemDetails
{
Title = "Notification not found",
Detail = $"Notification '{id}' does not exist.",
Status = StatusCodes.Status404NotFound
});
var primary = new EntityRefDto(
"notification",
notification.Id.ToString(),
notification.Title);
var affected = notification.TaskId is { } taskId
? new[] { new EntityRefDto("task", taskId.ToString()) }
: [];
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
readResult.Changed ? "completed" : "noop",
primary,
revision: readResult.Changed ? 1 : 0,
affectedRefs: affected);
return Ok(MapToDto(notification, operation));
}
[HttpPatch("read-all")]
public async Task<ActionResult> MarkAllAsRead(
[ProducesResponseType(typeof(NotificationReadAllResultDto), StatusCodes.Status200OK)]
public async Task<ActionResult<NotificationReadAllResultDto>> MarkAllAsRead(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.MarkAllAsReadAsync(forUser, ct);
return Ok(new { marked = count });
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
count > 0 ? "completed" : "noop",
new EntityRefDto("notification", "*", "Benachrichtigungen"));
return Ok(new NotificationReadAllResultDto(count, operation));
}
private static NotificationDto MapToDto(Notification n) => new(
private static NotificationDto MapToDto(
Notification n,
OperationResultDto? operation = null) => new(
n.Id, n.Type, n.Title, n.Message,
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt, operation);
}
@@ -0,0 +1,256 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// Owner-only proxy for sensitive OpenClaw agent files, workspace previews,
/// and schema-validated configuration changes.
/// </summary>
[Authorize(Roles = "owner")]
[ApiController]
[Route("api/v1/openclaw")]
public sealed class OpenClawAgentConfigurationController(
IOpenClawAgentConfigurationService configuration) : ControllerBase
{
[HttpGet("agents/{agentId}/files")]
public Task<ActionResult<OpenClawAgentFileCollectionDto>> GetAgentFiles(
string agentId,
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetAgentFilesAsync(agentId, cancellationToken));
[HttpGet("agents/{agentId}/files/{fileName}")]
public Task<ActionResult<OpenClawAgentFileDto>> GetAgentFile(
string agentId,
string fileName,
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetAgentFileAsync(
agentId,
fileName,
cancellationToken));
[HttpPut("agents/{agentId}/files/{fileName}")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawAgentFileWriteDto>> SetAgentFile(
string agentId,
string fileName,
[FromBody] UpdateOpenClawAgentFileRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return await ExecuteAsync(() => configuration.SetAgentFileAsync(
agentId,
fileName,
request,
invocation!,
cancellationToken));
}
[HttpGet("agents/{agentId}/workspace")]
public Task<ActionResult<OpenClawWorkspaceCollectionDto>> GetWorkspace(
string agentId,
[FromQuery] string? path = null,
[FromQuery] int offset = 0,
[FromQuery] int limit = 250,
CancellationToken cancellationToken = default)
=> ExecuteAsync(() => configuration.GetWorkspaceAsync(
agentId,
path,
offset,
limit,
cancellationToken));
[HttpGet("agents/{agentId}/workspace/file")]
public Task<ActionResult<OpenClawWorkspaceFileDto>> GetWorkspaceFile(
string agentId,
[FromQuery] string path,
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetWorkspaceFileAsync(
agentId,
path,
cancellationToken));
[HttpGet("config/schema")]
public Task<ActionResult<OpenClawConfigSchemaLookupDto>> GetConfigSchema(
[FromQuery] string path,
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetConfigSchemaAsync(path, cancellationToken));
[HttpGet("config")]
public Task<ActionResult<OpenClawConfigSnapshotDto>> GetConfig(
CancellationToken cancellationToken)
=> ExecuteAsync(() => configuration.GetConfigAsync(cancellationToken));
[HttpPatch("config")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawConfigPatchDto>> PatchConfig(
[FromBody] PatchOpenClawConfigRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return await ExecuteAsync(() => configuration.PatchConfigAsync(
request,
invocation!,
cancellationToken));
}
private async Task<ActionResult<T>> ExecuteAsync<T>(Func<Task<T>> action)
{
try
{
return Ok(await action());
}
catch (OpenClawAgentConfigurationValidationException exception)
{
return new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
[exception.Field] = [exception.Message]
}));
}
catch (OpenClawAgentConfigurationConflictException exception)
{
return StatusCode(
StatusCodes.Status409Conflict,
new
{
code = exception.Code,
message = exception.Message,
expectedHash = exception.ExpectedHash,
currentHash = exception.CurrentHash
});
}
catch (OpenClawAgentConfigurationUnavailableException exception)
{
var status = exception.State switch
{
"forbidden" or "management_disabled" => StatusCodes.Status403Forbidden,
"disconnected" => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status409Conflict
};
return StatusCode(
status,
new OpenClawAgentConfigurationErrorDto(
exception.State,
exception.Message,
exception.Method,
exception.RequiredScope));
}
catch (OpenClawAgentConfigurationVerificationException)
{
return StatusCode(
StatusCodes.Status502BadGateway,
new OpenClawAgentConfigurationErrorDto(
"verification_failed",
"OpenClaw-Antwort konnte nicht sicher verifiziert werden."));
}
catch (OpenClawGatewayRpcException exception)
{
var code = exception.Code.ToUpperInvariant();
var status = code switch
{
"FORBIDDEN" or "AUTH_SCOPE_MISMATCH" => StatusCodes.Status403Forbidden,
"INVALID_REQUEST" or "BAD_REQUEST" => StatusCodes.Status400BadRequest,
"METHOD_UNAVAILABLE" or "METHOD_NOT_FOUND" or "NOT_IMPLEMENTED" =>
StatusCodes.Status409Conflict,
"GATEWAY_DISCONNECTED" or "UNAVAILABLE" => StatusCodes.Status503ServiceUnavailable,
"GATEWAY_TIMEOUT" or "TIMEOUT" => StatusCodes.Status504GatewayTimeout,
_ when code.StartsWith("AUTH_", StringComparison.Ordinal) ||
code.StartsWith("DEVICE_AUTH_", StringComparison.Ordinal) =>
StatusCodes.Status403Forbidden,
_ => StatusCodes.Status502BadGateway
};
return StatusCode(
status,
new OpenClawAgentConfigurationErrorDto(
code.ToLowerInvariant(),
SafeGatewayMessage(status)));
}
}
private bool TryBuildInvocation(
out OpenClawInvocationContext? invocation,
out BadRequestObjectResult? validationError)
{
invocation = null;
validationError = null;
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey) ||
idempotencyKey.Length > 128 ||
idempotencyKey.Any(char.IsControl))
{
validationError = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["Idempotency-Key"] =
[
"A non-empty Idempotency-Key header with at most 128 non-control characters is required."
]
}));
return false;
}
var correlationId = Request.Headers["X-Correlation-ID"].FirstOrDefault()?.Trim();
if (!string.IsNullOrEmpty(correlationId) &&
(correlationId.Length > 128 || correlationId.Any(char.IsControl)))
{
validationError = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["X-Correlation-ID"] =
[
"X-Correlation-ID must contain at most 128 non-control characters."
]
}));
return false;
}
var actor = User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst(ClaimTypes.Email)?.Value
?? User.Identity?.Name
?? "authenticated-owner";
var traceParent = Request.Headers["traceparent"].FirstOrDefault()?.Trim();
try
{
invocation = OpenClawInvocationContext.Create(
actor,
idempotencyKey,
correlationId,
traceParent,
includeIdempotencyParameter: false);
}
catch (ArgumentException exception)
{
validationError = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["traceparent"] = [exception.Message]
}));
return false;
}
Response.Headers["Idempotency-Key"] = invocation.IdempotencyKey;
Response.Headers["X-Correlation-ID"] = invocation.CorrelationId;
return true;
}
private static string SafeGatewayMessage(int status)
=> status switch
{
StatusCodes.Status400BadRequest => "OpenClaw hat die Anfrage als ungültig abgelehnt.",
StatusCodes.Status403Forbidden => "OpenClaw hat Nexus nicht die erforderliche Berechtigung gewährt.",
StatusCodes.Status409Conflict => "Die verbundene OpenClaw-Version unterstützt diese Aktion nicht.",
StatusCodes.Status503ServiceUnavailable => "OpenClaw Gateway ist nicht verfügbar.",
StatusCodes.Status504GatewayTimeout => "OpenClaw hat nicht rechtzeitig geantwortet.",
_ => "OpenClaw-Anfrage ist fehlgeschlagen."
};
}
@@ -0,0 +1,272 @@
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// Owner-only approval boundary for OpenClaw agent creation. Proposal creation
/// never mutates OpenClaw; approval queues a durable request only after every
/// live management gate passes.
/// </summary>
[Authorize(Roles = "owner")]
[ApiController]
[Route("api/v1/openclaw/agent-proposals")]
public sealed class OpenClawAgentProposalsController(
IAgentProposalService proposals) : ControllerBase
{
[HttpGet("~/api/v1/openclaw/agents/create-options")]
public async Task<ActionResult<AgentCreateOptionsDto>> GetCreateOptions(
CancellationToken cancellationToken)
=> Ok(await proposals.GetCreateOptionsAsync(cancellationToken));
[HttpGet]
public async Task<ActionResult<AgentProposalCollectionDto>> Get(
[FromQuery] int limit = 50,
[FromQuery] string? cursor = null,
[FromQuery] string? status = null,
CancellationToken cancellationToken = default)
{
try
{
return Ok(await proposals.GetAsync(
limit,
cursor,
status,
cancellationToken));
}
catch (AgentProposalValidationException exception)
{
return Validation(exception);
}
}
[HttpGet("{id:guid}", Name = "GetAgentProposal")]
public async Task<ActionResult<AgentProposalDto>> GetById(
Guid id,
CancellationToken cancellationToken)
{
var proposal = await proposals.GetByIdAsync(
id,
includeFileContent: true,
cancellationToken);
return proposal is null ? NotFound() : Ok(proposal);
}
[HttpPost]
[EnableRateLimiting("agents")]
public async Task<ActionResult<AgentProposalOperationDto>> Create(
[FromBody] CreateAgentProposalRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var error))
return error!;
try
{
var result = await proposals.CreateAsync(
request,
"manual",
invocation!,
cancellationToken);
return result.Ok
? CreatedAtRoute(
"GetAgentProposal",
new { id = result.Proposal!.Id },
result)
: StatusCode(StatusFor(result.State), result);
}
catch (AgentProposalValidationException exception)
{
return Validation(exception);
}
}
[HttpPost("{id:guid}/approve")]
[EnableRateLimiting("agents")]
public Task<ActionResult<AgentProposalOperationDto>> Approve(
Guid id,
[FromBody] AgentProposalActionRequest request,
CancellationToken cancellationToken)
=> Mutate(
id,
request,
proposals.ApproveAsync,
acceptedWhenProvisioning: true,
cancellationToken);
[HttpPost("{id:guid}/reject")]
[EnableRateLimiting("agents")]
public Task<ActionResult<AgentProposalOperationDto>> Reject(
Guid id,
[FromBody] AgentProposalActionRequest request,
CancellationToken cancellationToken)
=> Mutate(
id,
request,
proposals.RejectAsync,
acceptedWhenProvisioning: false,
cancellationToken);
[HttpPost("{id:guid}/retry")]
[EnableRateLimiting("agents")]
public Task<ActionResult<AgentProposalOperationDto>> Retry(
Guid id,
[FromBody] AgentProposalActionRequest request,
CancellationToken cancellationToken)
=> Mutate(
id,
request,
proposals.RetryAsync,
acceptedWhenProvisioning: true,
cancellationToken);
private async Task<ActionResult<AgentProposalOperationDto>> Mutate(
Guid id,
AgentProposalActionRequest request,
Func<
Guid,
AgentProposalActionRequest,
OpenClawInvocationMetadata,
CancellationToken,
Task<AgentProposalOperationDto>> operation,
bool acceptedWhenProvisioning,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var error))
return error!;
try
{
var result = await operation(
id,
request,
invocation!,
cancellationToken);
if (!result.Ok)
return StatusCode(StatusFor(result.State), result);
if (acceptedWhenProvisioning && result.State == "provisioning")
{
return AcceptedAtRoute(
"GetAgentProposal",
new { id = result.Proposal!.Id },
result);
}
return Ok(result);
}
catch (AgentProposalValidationException exception)
{
return Validation(exception);
}
}
private bool TryBuildInvocation(
out OpenClawInvocationMetadata? invocation,
out ActionResult<AgentProposalOperationDto>? error)
{
invocation = null;
error = null;
var idempotencyKey = Request.Headers["Idempotency-Key"]
.FirstOrDefault()
?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey)
|| idempotencyKey.Length > 200)
{
error = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["Idempotency-Key"] =
[
"A non-empty Idempotency-Key header with at most 200 characters is required."
]
}));
return false;
}
var correlationId = Request.Headers["X-Correlation-ID"]
.FirstOrDefault()
?.Trim();
if (string.IsNullOrWhiteSpace(correlationId))
correlationId = HttpContext.TraceIdentifier;
if (correlationId.Length > 200)
{
error = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["X-Correlation-ID"] =
[
"X-Correlation-ID must contain at most 200 characters."
]
}));
return false;
}
var traceParent = Request.Headers["traceparent"].FirstOrDefault()?.Trim()
?? Activity.Current?.Id;
if (traceParent?.Length > 128
|| (!string.IsNullOrWhiteSpace(traceParent)
&& !ActivityContext.TryParse(traceParent, null, out _)))
{
error = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["traceparent"] =
[
"traceparent must be a valid W3C trace context with at most 128 characters."
]
}));
return false;
}
var actor = User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst(ClaimTypes.Email)?.Value
?? User.Identity?.Name
?? "authenticated-owner";
Response.Headers["X-Correlation-ID"] = correlationId;
invocation = new OpenClawInvocationMetadata(
idempotencyKey,
correlationId,
actor,
traceParent);
return true;
}
private BadRequestObjectResult Validation(
AgentProposalValidationException exception)
=> new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
[exception.Field] = [exception.Message]
}));
private static int StatusFor(string state)
=> state switch
{
"invalid"
or "invalid_state"
or "invalid_stage"
=> StatusCodes.Status400BadRequest,
"not_found"
=> StatusCodes.Status404NotFound,
"concurrency_conflict"
or "idempotency_conflict"
or "agent_exists"
=> StatusCodes.Status409Conflict,
"experimental_blocked"
or "management_disabled"
or "scope_upgrade_required"
or "capability_missing"
or "capability_drift"
or "version_mismatch"
or "workspace_root_invalid"
=> StatusCodes.Status403Forbidden,
"gateway_unavailable"
or "inventory_unavailable"
=> StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
}
@@ -0,0 +1,81 @@
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
internal static class OpenClawContentReadEndpoint
{
public static async Task<IResult> ExecuteAsync(Func<Task<IResult>> action)
{
try
{
return await action();
}
catch (OpenClawAgentConfigurationValidationException exception)
{
return Results.ValidationProblem(
new Dictionary<string, string[]>
{
[exception.Field] = [exception.Message]
});
}
catch (OpenClawAgentConfigurationUnavailableException exception)
{
var status = exception.State switch
{
"forbidden" or "management_disabled" =>
StatusCodes.Status403Forbidden,
"disconnected" => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status409Conflict
};
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
exception.State,
exception.Message,
exception.Method,
exception.RequiredScope),
statusCode: status);
}
catch (OpenClawAgentConfigurationVerificationException)
{
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
"verification_failed",
"OpenClaw-Antwort konnte nicht sicher verifiziert werden."),
statusCode: StatusCodes.Status502BadGateway);
}
catch (OpenClawGatewayRpcException exception)
{
var code = exception.Code.ToUpperInvariant();
var status = code switch
{
"FORBIDDEN" or "AUTH_SCOPE_MISMATCH" =>
StatusCodes.Status403Forbidden,
"METHOD_UNAVAILABLE" or "METHOD_NOT_FOUND"
or "NOT_IMPLEMENTED" =>
StatusCodes.Status409Conflict,
"GATEWAY_DISCONNECTED" or "UNAVAILABLE" =>
StatusCodes.Status503ServiceUnavailable,
"GATEWAY_TIMEOUT" or "TIMEOUT" =>
StatusCodes.Status504GatewayTimeout,
_ => StatusCodes.Status502BadGateway
};
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
code.ToLowerInvariant(),
status switch
{
StatusCodes.Status403Forbidden =>
"OpenClaw hat Nexus nicht die erforderliche Leseberechtigung gewährt.",
StatusCodes.Status409Conflict =>
"Die verbundene OpenClaw-Version unterstützt diese Leseoperation nicht.",
StatusCodes.Status503ServiceUnavailable =>
"OpenClaw Gateway ist nicht verfügbar.",
StatusCodes.Status504GatewayTimeout =>
"OpenClaw hat nicht rechtzeitig geantwortet.",
_ => "OpenClaw-Leseoperation ist fehlgeschlagen."
}),
statusCode: status);
}
}
}
+390
View File
@@ -0,0 +1,390 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// Authenticated, browser-safe OpenClaw control-plane facade.
/// Gateway credentials and raw protocol payloads remain inside the backend.
/// </summary>
[Authorize]
[ApiController]
[Route("api/v1/openclaw")]
public sealed class OpenClawController(IOpenClawControlService openClaw) : ControllerBase
{
[HttpGet("connection")]
[ProducesResponseType(typeof(OpenClawConnectionDto), StatusCodes.Status200OK)]
public IResult GetConnection()
=> Results.Ok(openClaw.GetConnection());
[HttpGet("capabilities")]
[ProducesResponseType(typeof(IReadOnlyList<OpenClawCapabilityDto>), StatusCodes.Status200OK)]
public IResult GetCapabilities()
=> Results.Ok(openClaw.GetCapabilities());
[HttpGet("overview")]
[ProducesResponseType(typeof(OpenClawOverviewDto), StatusCodes.Status200OK)]
public async Task<IResult> GetOverview(CancellationToken cancellationToken)
=> Results.Ok(await openClaw.GetOverviewAsync(cancellationToken));
[HttpGet("tasks")]
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawTaskDto>), StatusCodes.Status200OK)]
public async Task<IResult> GetTasks(
[FromQuery] int limit = 100,
[FromQuery] string? cursor = null,
CancellationToken cancellationToken = default)
=> Results.Ok(await openClaw.GetTasksAsync(limit, cursor, cancellationToken));
[HttpPost("tasks/{taskId}/cancel")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawTaskDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IResult> CancelTask(
string taskId,
[FromBody] CancelOpenClawTaskRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(taskId))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["taskId"] = ["Task id is required."]
});
return Results.Ok(await openClaw.CancelTaskAsync(
taskId,
request.Reason,
cancellationToken));
}
[HttpGet("sessions")]
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawSessionDto>), StatusCodes.Status200OK)]
public async Task<IResult> GetSessions(
[FromQuery] int limit = 100,
CancellationToken cancellationToken = default)
=> Results.Ok(await openClaw.GetSessionsAsync(limit, cancellationToken));
[HttpPost("sessions/abort")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IResult> AbortSession(
[FromBody] AbortOpenClawSessionRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.SessionKey))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["sessionKey"] = ["Session key is required."]
});
return Results.Ok(await openClaw.AbortSessionAsync(
request.SessionKey,
request.RunId,
request.ClearQueued,
cancellationToken));
}
[HttpPost("sessions/model")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IResult> PatchSessionModel(
[FromBody] PatchOpenClawSessionModelRequest request,
CancellationToken cancellationToken)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.SessionKey))
errors["sessionKey"] = ["Session key is required."];
if (string.IsNullOrWhiteSpace(request.Model))
errors["model"] = ["Model is required."];
if (errors.Count > 0)
return Results.ValidationProblem(errors);
return Results.Ok(await openClaw.PatchSessionModelAsync(
request.SessionKey,
request.Model,
cancellationToken));
}
[HttpGet("cron")]
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawCronJobDto>), StatusCodes.Status200OK)]
public async Task<IResult> GetCronJobs(
[FromQuery] bool includeDisabled = true,
[FromQuery] int limit = 100,
[FromQuery] string? cursor = null,
CancellationToken cancellationToken = default)
=> Results.Ok(await openClaw.GetCronJobsAsync(
includeDisabled,
limit,
cursor,
cancellationToken));
[HttpGet("cron/{jobId}")]
[Authorize(Roles = "owner")]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IResult> GetCronJob(
string jobId,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(jobId))
return MissingCronJobId();
var result = await openClaw.GetCronJobAsync(jobId, cancellationToken);
return result.State == "not_found"
? Results.NotFound(result)
: Results.Ok(result);
}
[HttpGet("cron/{jobId}/runs")]
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawCronRunDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IResult> GetCronRuns(
string jobId,
[FromQuery] int limit = 100,
[FromQuery] string? cursor = null,
[FromQuery] string? runId = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(jobId))
return MissingCronJobId();
return Results.Ok(await openClaw.GetCronRunsAsync(
jobId,
limit,
cursor,
runId,
cancellationToken));
}
[HttpPost("cron")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status409Conflict)]
public async Task<IResult> CreateCronJob(
[FromBody] CreateOpenClawCronJobRequest request,
CancellationToken cancellationToken)
{
var headerError = RequireIdempotencyKey();
if (headerError is not null)
return headerError;
return CronMutationResult(await openClaw.CreateCronJobAsync(
request,
cancellationToken));
}
[HttpPatch("cron/{jobId}")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawCronJobDetailDto>), StatusCodes.Status409Conflict)]
public async Task<IResult> PatchCronJob(
string jobId,
[FromBody] PatchOpenClawCronJobRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(jobId))
return MissingCronJobId();
if (request.Patch is null)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["patch"] = ["Cron patch is required."]
});
var headerError = RequireIdempotencyKey();
if (headerError is not null)
return headerError;
var expectedHash = ResolveExpectedHash(request.ExpectedHash);
if (string.IsNullOrWhiteSpace(expectedHash))
return MissingExpectedHash();
return CronMutationResult(await openClaw.PatchCronJobAsync(
jobId,
request.Patch,
expectedHash,
cancellationToken));
}
[HttpDelete("cron/{jobId}")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status409Conflict)]
public async Task<IResult> DeleteCronJob(
string jobId,
[FromQuery] string? expectedHash = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(jobId))
return MissingCronJobId();
var headerError = RequireIdempotencyKey();
if (headerError is not null)
return headerError;
expectedHash = ResolveExpectedHash(expectedHash);
if (string.IsNullOrWhiteSpace(expectedHash))
return MissingExpectedHash();
return CronMutationResult(await openClaw.DeleteCronJobAsync(
jobId,
expectedHash,
cancellationToken));
}
[HttpPost("cron/{jobId}/run")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status403Forbidden)]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(OpenClawOperationDto<object>), StatusCodes.Status409Conflict)]
public async Task<IResult> RunCronJob(
string jobId,
[FromQuery] string? expectedHash = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(jobId))
return MissingCronJobId();
var headerError = RequireIdempotencyKey();
if (headerError is not null)
return headerError;
expectedHash = ResolveExpectedHash(expectedHash);
if (string.IsNullOrWhiteSpace(expectedHash))
return MissingExpectedHash();
return CronMutationResult(await openClaw.RunCronJobAsync(
jobId,
expectedHash,
cancellationToken));
}
[HttpGet("approvals")]
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawApprovalDto>), StatusCodes.Status200OK)]
public async Task<IResult> GetApprovals(
[FromQuery] int limit = 100,
CancellationToken cancellationToken = default)
=> Results.Ok(await openClaw.GetApprovalsAsync(limit, cancellationToken));
[HttpPost("approvals/{approvalId}/resolve")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(OpenClawOperationDto<OpenClawApprovalDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IResult> ResolveApproval(
string approvalId,
[FromBody] ResolveOpenClawApprovalRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(approvalId))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["approvalId"] = ["Approval id is required."]
});
if (string.IsNullOrWhiteSpace(request.Kind))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["kind"] = ["Approval kind is required."]
});
return Results.Ok(await openClaw.ResolveApprovalAsync(
approvalId,
request.Kind,
request.Decision,
cancellationToken));
}
[HttpGet("activity")]
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawActivityDto>), StatusCodes.Status200OK)]
public async Task<IResult> GetActivity(
[FromQuery] int limit = 100,
[FromQuery] string? cursor = null,
CancellationToken cancellationToken = default)
=> Results.Ok(await openClaw.GetActivityAsync(limit, cursor, cancellationToken));
[HttpGet("models")]
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawModelDto>), StatusCodes.Status200OK)]
public async Task<IResult> GetModels(CancellationToken cancellationToken)
=> Results.Ok(await openClaw.GetModelsAsync(cancellationToken));
[HttpGet("models/auth-status")]
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawModelAuthProviderDto>), StatusCodes.Status200OK)]
public async Task<IResult> GetModelAuthStatus(
[FromQuery] bool refresh = false,
CancellationToken cancellationToken = default)
=> Results.Ok(await openClaw.GetModelAuthStatusAsync(refresh, cancellationToken));
[HttpGet("agents")]
[ProducesResponseType(typeof(OpenClawCollectionDto<OpenClawAgentDto>), StatusCodes.Status200OK)]
public async Task<IResult> GetAgents(CancellationToken cancellationToken)
=> Results.Ok(await openClaw.GetAgentsAsync(cancellationToken));
private string? ResolveExpectedHash(string? value)
{
if (!string.IsNullOrWhiteSpace(value))
return value;
return Request.Headers.IfMatch.FirstOrDefault();
}
private static IResult MissingCronJobId()
=> Results.ValidationProblem(new Dictionary<string, string[]>
{
["jobId"] = ["Cron job id is required."]
});
private static IResult MissingExpectedHash()
=> Results.ValidationProblem(new Dictionary<string, string[]>
{
["expectedHash"] =
[
"A current OpenClaw resource hash is required for this cron mutation."
]
});
private IResult? RequireIdempotencyKey()
{
var value = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (!string.IsNullOrWhiteSpace(value) &&
value.Length <= 128 &&
!value.Any(char.IsControl))
{
return null;
}
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Idempotency-Key"] =
[
"A non-empty Idempotency-Key header with at most 128 non-control characters is required."
]
});
}
private static IResult CronMutationResult<T>(OpenClawOperationDto<T> result)
{
return result.State switch
{
"conflict" => Results.Json(result, statusCode: StatusCodes.Status409Conflict),
"not_found" => Results.Json(result, statusCode: StatusCodes.Status404NotFound),
"invalid" => Results.Json(result, statusCode: StatusCodes.Status400BadRequest),
"restricted" or "management_disabled" or "forbidden" =>
Results.Json(result, statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result)
};
}
}
@@ -0,0 +1,153 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// Authenticated browser-safe Server-Sent Events projection over the bounded
/// Gateway event buffer. The browser never receives Gateway credentials.
/// </summary>
[Authorize]
[ApiController]
[Route("api/v1/openclaw/events")]
public sealed class OpenClawEventsController(IOpenClawEventProjectionService events) : ControllerBase
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(750);
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15);
[HttpGet]
public async Task Stream(
[FromQuery] string? lastEventId = null,
[FromQuery] bool follow = true,
CancellationToken cancellationToken = default)
{
Response.StatusCode = StatusCodes.Status200OK;
Response.ContentType = "text/event-stream";
Response.Headers.CacheControl = "no-cache, no-store";
Response.Headers.Connection = "keep-alive";
Response.Headers["X-Accel-Buffering"] = "no";
var headerCursor = Request.Headers["Last-Event-ID"].FirstOrDefault();
var cursor = string.IsNullOrWhiteSpace(headerCursor) ? lastEventId : headerCursor;
var lastHeartbeatAt = DateTimeOffset.UtcNow;
var connectionEvent = events.CreateConnectionEvent(cursor);
var connectionSignature = GetConnectionSignature(connectionEvent);
await Response.WriteAsync("retry: 2000\n\n", cancellationToken);
await WriteEventAsync(connectionEvent, cancellationToken);
await Response.Body.FlushAsync(cancellationToken);
try
{
do
{
connectionEvent = events.CreateConnectionEvent(cursor);
var currentConnectionSignature = GetConnectionSignature(connectionEvent);
if (!string.Equals(
connectionSignature,
currentConnectionSignature,
StringComparison.Ordinal))
{
await WriteEventAsync(connectionEvent, cancellationToken);
connectionSignature = currentConnectionSignature;
}
var batch = events.Project(cursor);
if (batch.ReplayBoundaryMissed)
{
await WriteEventAsync(CreateGapEvent(cursor, batch), cancellationToken);
}
foreach (var item in batch.Events)
{
await WriteEventAsync(item, cancellationToken);
}
cursor = batch.Cursor ?? cursor;
var now = DateTimeOffset.UtcNow;
if (!follow || now - lastHeartbeatAt >= HeartbeatInterval)
{
await WriteEventAsync(events.CreateHeartbeatEvent(cursor), cancellationToken);
lastHeartbeatAt = now;
}
await Response.Body.FlushAsync(cancellationToken);
if (!follow)
break;
await Task.Delay(PollInterval, cancellationToken);
} while (!cancellationToken.IsCancellationRequested);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Expected when EventSource disconnects or the request is aborted.
}
}
private async Task WriteEventAsync(
OpenClawStreamEventDto item,
CancellationToken cancellationToken)
{
var id = StripSseControlCharacters(item.Id);
var eventType = StripSseControlCharacters(item.Type);
var data = JsonSerializer.Serialize(item, JsonOptions);
await Response.WriteAsync(
$"id: {id}\nevent: {eventType}\ndata: {data}\n\n",
cancellationToken);
}
private static OpenClawStreamEventDto CreateGapEvent(
string? requestedCursor,
OpenClawEventBatch batch)
{
var occurredAt = DateTimeOffset.UtcNow;
var gapCursor = batch.Events.Count == 0
? batch.Cursor ?? "origin"
: string.IsNullOrWhiteSpace(requestedCursor) ? "origin" : requestedCursor;
return new OpenClawStreamEventDto(
gapCursor,
"openclaw.gap",
"gap",
"gateway",
null,
null,
null,
true,
false,
null,
null,
occurredAt,
new JsonObject
{
["reason"] = "last-event-id-outside-buffer",
["requestedLastEventId"] = requestedCursor,
["oldestAvailableId"] = batch.OldestAvailableId,
["latestAvailableId"] = batch.LatestAvailableId,
["requiresAuthoritativeRefresh"] = true
});
}
private static string StripSseControlCharacters(string value)
=> value.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal);
private static string GetConnectionSignature(OpenClawStreamEventDto item)
=> string.Join(
"\u001f",
item.Payload?["state"]?.ToJsonString() ?? string.Empty,
item.Payload?["connected"]?.ToJsonString() ?? string.Empty,
item.Payload?["gatewayVersion"]?.ToJsonString() ?? string.Empty,
item.Payload?["protocolVersion"]?.ToJsonString() ?? string.Empty,
item.Payload?["deviceId"]?.ToJsonString() ?? string.Empty,
item.Payload?["deviceTokenConfigured"]?.ToJsonString() ?? string.Empty,
item.Payload?["pairingRequired"]?.ToJsonString() ?? string.Empty,
item.Payload?["pairingRequestId"]?.ToJsonString() ?? string.Empty,
item.Payload?["lastConnectedAt"]?.ToJsonString() ?? string.Empty,
item.Payload?["reconnectAttempts"]?.ToJsonString() ?? string.Empty,
item.Payload?["message"]?.ToJsonString() ?? string.Empty);
}
@@ -0,0 +1,287 @@
using System.Diagnostics;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/openclaw/runs")]
public sealed class OpenClawRunsController(IOpenClawRunService runs) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<OpenClawRunCollectionDto>> Get(
[FromQuery] int limit = 50,
[FromQuery] string? cursor = null,
[FromQuery] string? status = null,
[FromQuery] Guid? taskId = null,
[FromQuery] Guid? projectId = null,
[FromQuery] string? sessionKey = null,
CancellationToken cancellationToken = default)
=> Ok(await runs.GetAsync(
new OpenClawRunQuery(limit, cursor, status, taskId, projectId, sessionKey),
cancellationToken));
[HttpGet("{id:guid}", Name = "GetOpenClawRun")]
public async Task<ActionResult<OpenClawRunDto>> GetById(
Guid id,
CancellationToken cancellationToken)
{
var run = await runs.GetByIdAsync(id, cancellationToken);
return run is null ? NotFound() : Ok(run);
}
[HttpGet("{id:guid}/history")]
public async Task<ActionResult<OpenClawRunHistoryResponse>> GetHistory(
Guid id,
[FromQuery] int gatewayLimit = 200,
CancellationToken cancellationToken = default)
{
var result = await runs.GetHistoryAsync(
id,
Math.Clamp(gatewayLimit, 1, 1000),
cancellationToken);
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawRunOperationDto>> Start(
[FromBody] StartOpenClawRunRequest request,
CancellationToken cancellationToken)
{
var validation = ValidateStart(request);
if (validation is not null)
return validation;
if (!TryBuildInvocation(out var invocation, out var metadataError))
return metadataError!;
try
{
var result = await runs.StartAsync(request, invocation!, cancellationToken);
if (result.Ok)
{
return CreatedAtRoute(
"GetOpenClawRun",
new { id = result.Run.Id },
result);
}
if (result.State != "idempotency_conflict")
{
return AcceptedAtRoute(
"GetOpenClawRun",
new { id = result.Run.Id },
result);
}
return StatusCode(StatusFor(result.State), result);
}
catch (OpenClawRunValidationException exception)
{
return new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
[exception.Field] = [exception.Message]
}));
}
}
[HttpPost("{id:guid}/stop")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawRunOperationDto>> Stop(
Guid id,
[FromBody] OpenClawRunActionRequest? request,
CancellationToken cancellationToken)
{
var reasonError = ValidateReason(request?.Reason);
if (reasonError is not null)
return reasonError;
if (!TryBuildInvocation(out var invocation, out var metadataError))
return metadataError!;
var result = await runs.StopAsync(
id,
request?.Reason,
invocation!,
cancellationToken);
return MapOperation(result);
}
[HttpPost("{id:guid}/resume")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawRunOperationDto>> Resume(
Guid id,
[FromBody] OpenClawRunActionRequest? request,
CancellationToken cancellationToken)
{
var reasonError = ValidateReason(request?.Reason);
if (reasonError is not null)
return reasonError;
if (!TryBuildInvocation(out var invocation, out var metadataError))
return metadataError!;
var result = await runs.ResumeAsync(
id,
request?.Reason,
invocation!,
cancellationToken);
return MapOperation(result);
}
[HttpPost("{id:guid}/retry")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawRunOperationDto>> Retry(
Guid id,
[FromBody] OpenClawRunActionRequest? request,
CancellationToken cancellationToken)
{
var reasonError = ValidateReason(request?.Reason);
if (reasonError is not null)
return reasonError;
if (!TryBuildInvocation(out var invocation, out var metadataError))
return metadataError!;
var result = await runs.RetryAsync(
id,
request?.Reason,
invocation!,
cancellationToken);
if (result is null)
return NotFound();
if (result.ResultRun is not null)
{
return result.Ok
? CreatedAtRoute(
"GetOpenClawRun",
new { id = result.ResultRun.Id },
result)
: AcceptedAtRoute(
"GetOpenClawRun",
new { id = result.ResultRun.Id },
result);
}
return StatusCode(StatusFor(result.State), result);
}
private ActionResult<OpenClawRunOperationDto> MapOperation(OpenClawRunOperationDto? result)
{
if (result is null)
return NotFound();
return result.Ok
? Ok(result)
: StatusCode(StatusFor(result.State), result);
}
private bool TryBuildInvocation(
out OpenClawInvocationMetadata? invocation,
out ActionResult<OpenClawRunOperationDto>? error)
{
invocation = null;
error = null;
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey) || idempotencyKey.Length > 200)
{
error = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["Idempotency-Key"] = ["A non-empty Idempotency-Key header with at most 200 characters is required."]
}));
return false;
}
var correlationId = Request.Headers["X-Correlation-ID"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(correlationId))
correlationId = HttpContext.TraceIdentifier;
if (correlationId.Length > 200)
{
error = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["X-Correlation-ID"] = ["X-Correlation-ID must contain at most 200 characters."]
}));
return false;
}
var traceParent = Request.Headers["traceparent"].FirstOrDefault()?.Trim()
?? Activity.Current?.Id;
if (traceParent?.Length > 128
|| (!string.IsNullOrWhiteSpace(traceParent)
&& !ActivityContext.TryParse(traceParent, null, out _)))
{
error = new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["traceparent"] = ["traceparent must be a valid W3C trace context with at most 128 characters."]
}));
return false;
}
var actor = User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst(ClaimTypes.Email)?.Value
?? User.Identity?.Name
?? "authenticated-user";
Response.Headers["X-Correlation-ID"] = correlationId;
invocation = new OpenClawInvocationMetadata(
idempotencyKey,
correlationId,
actor,
traceParent);
return true;
}
private static ActionResult<OpenClawRunOperationDto>? ValidateStart(
StartOpenClawRunRequest request)
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(request.Prompt))
errors["prompt"] = ["Prompt is required."];
else if (request.Prompt.Length > 50_000)
errors["prompt"] = ["Prompt must contain at most 50000 characters."];
if (string.IsNullOrWhiteSpace(request.AgentId))
errors["agentId"] = ["Agent id is required."];
else if (request.AgentId.Length > 200)
errors["agentId"] = ["Agent id must contain at most 200 characters."];
if (string.IsNullOrWhiteSpace(request.SessionKey))
errors["sessionKey"] = ["Session key is required."];
else if (request.SessionKey.Length > 500)
errors["sessionKey"] = ["Session key must contain at most 500 characters."];
if (request.Title?.Length > 160)
errors["title"] = ["Title must contain at most 160 characters."];
if (errors.Count == 0)
return null;
return new ActionResult<OpenClawRunOperationDto>(
new BadRequestObjectResult(new ValidationProblemDetails(errors)));
}
private static ActionResult<OpenClawRunOperationDto>? ValidateReason(string? reason)
{
if (reason?.Length is not > 1000)
return null;
return new ActionResult<OpenClawRunOperationDto>(
new BadRequestObjectResult(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["reason"] = ["Reason must contain at most 1000 characters."]
})));
}
private static int StatusFor(string state)
=> state switch
{
"idempotency_conflict" or "invalid_state" or "unsupported" => StatusCodes.Status409Conflict,
"blocked" => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
}
@@ -0,0 +1,228 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Models;
using Nexus.Api.Services;
using System.Security.Claims;
namespace Nexus.Api.Controllers;
/// <summary>
/// Owner-only orchestration for the single OpenClaw Attach &amp; Adopt profile.
/// The browser never receives Gateway, bootstrap or provider credentials.
/// </summary>
[Authorize(Roles = "owner")]
[ApiController]
[Route("api/v1/openclaw/setup")]
public sealed class OpenClawSetupController(
IOpenClawSetupService setup,
IOpenClawWizardService wizard) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<OpenClawSetupStatusDto>> GetStatus(
CancellationToken cancellationToken)
=> Ok(await setup.GetStatusAsync(cancellationToken));
[HttpPost("discover")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawDiscoveryDto>> Discover(
[FromBody] OpenClawDiscoveryRequest? request,
CancellationToken cancellationToken)
=> Ok(await setup.DiscoverAsync(
request ?? new OpenClawDiscoveryRequest(),
cancellationToken));
[HttpPost("probe")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawProbeDto>>> Probe(
[FromBody] ProbeOpenClawRequest request,
CancellationToken cancellationToken)
=> Map(await setup.ProbeAsync(request, cancellationToken));
[HttpPost("attach")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> Attach(
[FromBody] AttachOpenClawRequest request,
CancellationToken cancellationToken)
=> Map(await setup.AttachAsync(request, cancellationToken));
[HttpPost("verify")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> Verify(
[FromBody] VerifyOpenClawRequest request,
CancellationToken cancellationToken)
=> Map(await setup.VerifyAsync(request, cancellationToken));
[HttpPost("adopt")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawAdoptionInventoryDto>>> Adopt(
[FromBody] AdoptOpenClawRequest request,
CancellationToken cancellationToken)
=> Map(await setup.AdoptAsync(request, cancellationToken));
[HttpPost("management")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> SetManagement(
[FromBody] SetOpenClawManagementRequest request,
CancellationToken cancellationToken)
=> Map(await setup.SetManagementAsync(request, cancellationToken));
[HttpDelete("connection")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawSetupOperationDto<OpenClawSetupStatusDto>>> DeleteConnection(
[FromBody] DeleteOpenClawConnectionRequest request,
CancellationToken cancellationToken)
=> Map(await setup.DeleteAsync(request, cancellationToken));
[HttpPost("wizard/start")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawWizardResultDto>> StartWizard(
[FromBody] StartOpenClawWizardRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return MapWizard(await wizard.StartAsync(
request,
invocation!,
cancellationToken));
}
[HttpPost("wizard/next")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawWizardResultDto>> AdvanceWizard(
[FromBody] AdvanceOpenClawWizardRequest request,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return MapWizard(await wizard.NextAsync(
request,
invocation!,
cancellationToken));
}
[HttpGet("wizard/{sessionId}")]
public async Task<ActionResult<OpenClawWizardResultDto>> GetWizardStatus(
string sessionId,
CancellationToken cancellationToken)
=> MapWizard(await wizard.GetStatusAsync(sessionId, cancellationToken));
[HttpPost("wizard/{sessionId}/cancel")]
[EnableRateLimiting("agents")]
public async Task<ActionResult<OpenClawWizardResultDto>> CancelWizard(
string sessionId,
CancellationToken cancellationToken)
{
if (!TryBuildInvocation(out var invocation, out var validationError))
return validationError!;
return MapWizard(await wizard.CancelAsync(
sessionId,
invocation!,
cancellationToken));
}
private ActionResult<OpenClawSetupOperationDto<T>> Map<T>(
OpenClawSetupOperationDto<T> result)
{
if (result.Ok)
return Ok(result);
return StatusCode(StatusFor(result.State), result);
}
private ActionResult<OpenClawWizardResultDto> MapWizard(
OpenClawWizardResultDto result)
{
if (result.Ok)
return Ok(result);
var status = result.State switch
{
"invalid" or "confirmation_required"
=> StatusCodes.Status400BadRequest,
"not_found"
=> StatusCodes.Status404NotFound,
"management_disabled" or "scope_upgrade_required"
=> StatusCodes.Status403Forbidden,
"conflict" or "server_secret_required" or "unsupported"
=> StatusCodes.Status409Conflict,
"disconnected" or "gateway_error"
=> StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
return StatusCode(status, result);
}
private bool TryBuildInvocation(
out OpenClawInvocationContext? invocation,
out BadRequestObjectResult? validationError)
{
invocation = null;
validationError = null;
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey) ||
idempotencyKey.Length > 128 ||
idempotencyKey.Any(char.IsControl))
{
validationError = BadRequest(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["Idempotency-Key"] =
[
"A non-empty Idempotency-Key header with at most 128 non-control characters is required."
]
}));
return false;
}
var actor = User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.FindFirst(ClaimTypes.Email)?.Value
?? User.Identity?.Name
?? "authenticated-owner";
try
{
invocation = OpenClawInvocationContext.Create(
actor,
idempotencyKey,
Request.Headers["X-Correlation-ID"].FirstOrDefault(),
Request.Headers["traceparent"].FirstOrDefault(),
includeIdempotencyParameter: false);
}
catch (ArgumentException exception)
{
validationError = BadRequest(new ValidationProblemDetails(
new Dictionary<string, string[]>
{
["request"] = [exception.Message]
}));
return false;
}
Response.Headers["Idempotency-Key"] = invocation.IdempotencyKey;
Response.Headers["X-Correlation-ID"] = invocation.CorrelationId;
return true;
}
private static int StatusFor(string state)
=> state switch
{
OpenClawSetupStates.InvalidEndpoint
or OpenClawSetupStates.InvalidRequest
=> StatusCodes.Status400BadRequest,
OpenClawSetupStates.NotFound
=> StatusCodes.Status404NotFound,
OpenClawSetupStates.ConcurrencyConflict
or OpenClawSetupStates.DynamicEndpointUnsupported
or OpenClawSetupStates.ExperimentalBlocked
or OpenClawSetupStates.ExcessiveScope
or OpenClawSetupStates.PairingRequired
or OpenClawSetupStates.ScopeUpgradeRequired
=> StatusCodes.Status409Conflict,
OpenClawSetupStates.Disconnected
or OpenClawSetupStates.GatewayUnavailable
=> StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status502BadGateway
};
}
+82 -13
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
@@ -11,42 +12,110 @@ namespace Nexus.Api.Controllers;
public class ProjectsController(IProjectService projectService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await projectService.GetAllAsync(ct));
[ProducesResponseType(typeof(IReadOnlyList<ProjectDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<IReadOnlyList<ProjectDto>>> GetAll(CancellationToken ct)
=> Ok((await projectService.GetAllAsync(ct)).Select(project => Map(project)).ToArray());
[HttpGet("{id:guid}")]
public async Task<IResult> GetById(Guid id, CancellationToken ct)
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProjectDto>> GetById(Guid id, CancellationToken ct)
{
var project = await projectService.GetByIdAsync(id, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
return project is null ? NotFound() : Ok(Map(project));
}
[HttpGet("{id:guid}/tasks")]
[ProducesResponseType(typeof(IReadOnlyList<ProjectTaskDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<ProjectTaskDto>>> GetTasks(
Guid id,
CancellationToken ct)
{
if (await projectService.GetByIdAsync(id, ct) is null)
return NotFound();
var tasks = await projectService.GetTasksAsync(id, ct);
return Ok(tasks.Select(task => new ProjectTaskDto(
task.Id,
task.Title,
task.State,
task.Priority,
id,
task.AssignedTo,
task.ExpectedFrom,
task.IsAgentTask,
task.UpdatedAt)).ToArray());
}
[HttpPost]
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ProjectDto>> Create(
[FromBody] CreateProjectRequest request,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
{
ModelState.AddModelError("name", "Name is required.");
return ValidationProblem(ModelState);
}
var project = await projectService.CreateAsync(request, ct);
return Results.Created($"/api/v1/projects/{project.Id}", project);
return Created(
$"/api/v1/projects/{project.Id}",
Map(project, ProjectOperation(project, "created")));
}
[HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProjectDto>> Update(
Guid id,
[FromBody] UpdateProjectRequest request,
CancellationToken ct)
{
var project = await projectService.UpdateAsync(id, request, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
return project is null
? NotFound()
: Ok(Map(project, ProjectOperation(project, "updated")));
}
[HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct)
[ProducesResponseType(typeof(ProjectDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var result = await projectService.DeleteAsync(id, ct);
return result.Outcome switch
{
ProjectDeleteOutcome.NotFound => Results.NotFound(),
ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
_ => Results.NoContent()
ProjectDeleteOutcome.NotFound => NotFound(),
ProjectDeleteOutcome.Archived => Ok(Map(
result.Project!,
ProjectOperation(result.Project!, "archived"))),
_ => Ok(Map(
result.Project!,
ProjectOperation(result.Project!, "deleted")))
};
}
private OperationResultDto ProjectOperation(
Nexus.Api.Data.Project project,
string status)
=> OperationResultFactory.FromHttpContext(
HttpContext,
status,
new EntityRefDto("project", project.Id.ToString(), project.Name));
private static ProjectDto Map(
Nexus.Api.Data.Project project,
OperationResultDto? operation = null)
=> new(
project.Id,
project.Name,
project.Description,
project.Status.ToString(),
project.Progress,
project.UpdatedAt,
operation);
}
+20 -11
View File
@@ -1,12 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
namespace Nexus.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/v1/security")]
public class SecurityController(IConfiguration config) : ControllerBase
{
[HttpGet("status")]
[ProducesResponseType(typeof(SecurityStatusDto), StatusCodes.Status200OK)]
public IResult GetStatus()
{
var jwtIssuer = config["Jwt:Issuer"] ?? "nexus";
@@ -14,16 +18,21 @@ public class SecurityController(IConfiguration config) : ControllerBase
var refreshDays = config.GetValue<int>("Jwt:RefreshTokenExpirationDays", 7);
var accessTokenMinutes = config.GetValue<int>("Jwt:AccessTokenExpirationMinutes", 30);
return Results.Ok(new
{
authMethod = "JWT + PBKDF2",
tokenConfig = new { refreshTokenDays = refreshDays, accessTokenMinutes },
rateLimit = "5 login attempts per minute per IP",
passwordPolicy = "Minimum 10 characters",
cookieConfig = new { httpOnly = true, secure = true, sameSite = "Strict" },
twoFactorEnabled = false,
passkeyEnabled = false,
checkedAt = DateTimeOffset.UtcNow
});
return Results.Ok(new SecurityStatusDto(
"JWT + PBKDF2",
new SecurityTokenConfigDto(
jwtIssuer,
jwtAudience,
refreshDays,
accessTokenMinutes),
"5 login attempts per minute per IP",
"Minimum 10 characters",
new SecurityCookieConfigDto(
HttpOnly: true,
Secure: true,
SameSite: "Strict"),
TwoFactorEnabled: false,
PasskeyEnabled: false,
DateTimeOffset.UtcNow));
}
}
+155 -31
View File
@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Observability;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
@@ -29,7 +31,9 @@ public class TasksController(
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
var task = await taskService.CreateAsync(request, ct);
return Results.Created($"/api/v1/tasks/{task.Id}", task);
return Results.Created(
$"/api/v1/tasks/{task.Id}",
MapTask(task, TaskOperation(task, "created")));
}
[HttpGet("pending-approval")]
@@ -53,7 +57,9 @@ public class TasksController(
title: "Approval denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "completed")))
};
}
@@ -70,7 +76,9 @@ public class TasksController(
title: "Rejection denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "completed")))
};
}
@@ -88,7 +96,9 @@ public class TasksController(
title: "Action denied",
detail: "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
@@ -99,7 +109,9 @@ public class TasksController(
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
_ => Results.Ok(result.Task)
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
@@ -114,64 +126,176 @@ public class TasksController(
title: "Task deletion denied",
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.NoContent()
_ => Results.Ok(MapTask(
result.Task!,
TaskOperation(result.Task!, "deleted")))
};
}
// ── Board & Stale-Reset (für Iris Autonomous Worker) ──
/// <summary>
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
/// Wird vom Iris Autonomous Worker genutzt.
/// Gibt alle aktiven Task-Spalten und eine keyset-paginierte Done-History
/// zurück. Der Done-Cursor ist opak und darf vom Client nicht verändert
/// oder interpretiert werden.
///
/// SICHERHEIT: Erfordert X-Agent-Id Header (bel. erkannter Agent) ODER
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
/// SICHERHEIT: Erfordert eine verifizierte JWT- oder
/// X-Nexus-Api-Key-Authentisierung.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
/// </summary>
[AllowAnonymous]
[HttpGet("board")]
public async Task<IResult> GetBoard(CancellationToken ct)
[ProducesResponseType(typeof(TaskBoardPageDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IResult> GetBoard(
CancellationToken ct,
[FromQuery] int doneLimit = 50,
[FromQuery] string? doneCursor = null)
{
var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
var isApiKey = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
if (!RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration))
return Results.Unauthorized();
return Results.Ok(await taskService.GetBoardAsync(ct));
if (doneLimit is < 1 or > 100)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["doneLimit"] = ["Done limit must be between 1 and 100."]
});
}
try
{
using var activity = NexusTelemetry.ActivitySource.StartActivity(
"nexus.task.board.query",
ActivityKind.Internal);
var stopwatch = Stopwatch.StartNew();
var board = await taskService.GetBoardPageAsync(doneLimit, doneCursor, ct);
stopwatch.Stop();
var cardCount = board.Offen.Count
+ board.InProgress.Count
+ board.Review.Count
+ board.Blocked.Count
+ board.Done.Count;
NexusTelemetry.TaskBoardDuration.Record(
stopwatch.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("result", "success"));
activity?.SetTag("nexus.task.count", cardCount);
activity?.SetTag("nexus.task.done_page_size", board.Done.Count);
activity?.SetTag("nexus.task.has_more_done", board.HasMoreDone);
if (NexusTelemetry.TaskBoardPayload.Enabled)
{
var payloadBytes = System.Text.Json.JsonSerializer
.SerializeToUtf8Bytes(
board,
new System.Text.Json.JsonSerializerOptions(
System.Text.Json.JsonSerializerDefaults.Web))
.LongLength;
NexusTelemetry.TaskBoardPayload.Record(
payloadBytes,
new KeyValuePair<string, object?>(
"page",
string.IsNullOrWhiteSpace(doneCursor) ? "initial" : "done"));
}
Response.Headers["Server-Timing"] =
$"board;dur={stopwatch.Elapsed.TotalMilliseconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture)}";
return Results.Ok(board);
}
catch (InvalidTaskBoardCursorException)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["doneCursor"] = ["Done cursor is invalid or no longer supported."]
});
}
}
/// <summary>
/// Returns one compact board projection for applying a persisted
/// domain-event delta without reloading every active and Done card.
/// </summary>
[HttpGet("{id:guid}/board-card")]
[ProducesResponseType(typeof(TaskBoardCardDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<TaskBoardCardDto>> GetBoardCard(
Guid id,
CancellationToken ct)
{
var card = await taskService.GetBoardCardAsync(id, ct);
return card is null ? NotFound() : Ok(card);
}
/// <summary>
/// Setzt stale Tasks (InProgress, älter als N Stunden) zurück auf Backlog.
/// Wird vom Iris Autonomous Worker genutzt.
///
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
/// X-Nexus-Api-Key / Service-Principal ODER owner/admin JWT.
/// SICHERHEIT: Erfordert eine verifizierte Authentisierung. Ein
/// X-Agent-Id-Hinweis für Iris wird nur für Service-Principals oder
/// owner/admin JWT ausgewertet.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
/// </summary>
[AllowAnonymous]
[HttpPost("reset-stale")]
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
{
var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(HttpContext, agentService, ct);
if (!RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration))
return Results.Unauthorized();
var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
HttpContext,
agentService,
configuration,
ct);
var isService = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isPrivilegedUser = RequestAuthorizationHelper.IsPrivilegedUser(HttpContext);
var isIris = string.Equals(agentHeaderResolution.AgentId, "iris", StringComparison.OrdinalIgnoreCase);
if (!isIris && !isService && !isPrivilegedUser)
{
// A presented but unrecognized agent header is an invalid credential, not a missing one.
if (HttpContext.User.Identity?.IsAuthenticated == true || agentHeaderResolution.HeaderProvided)
return Results.Forbid();
return Results.Unauthorized();
}
return Results.Forbid();
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
return Results.Ok(new ResetStaleResponse(count));
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
count > 0 ? "completed" : "noop",
new EntityRefDto("task-board", "active", "Task Board"));
return Results.Ok(new ResetStaleResponse(count, operation));
}
private OperationResultDto TaskOperation(WorkTask task, string status)
{
var affected = new List<EntityRefDto>();
if (task.ProjectId is { } projectId)
affected.Add(new EntityRefDto("project", projectId.ToString()));
if (task.ParentTaskId is { } parentTaskId)
affected.Add(new EntityRefDto("task", parentTaskId.ToString(), "Parent task"));
return OperationResultFactory.FromHttpContext(
HttpContext,
status,
new EntityRefDto("task", task.Id.ToString(), task.Title),
affectedRefs: affected);
}
private static DashboardTaskDto MapTask(
WorkTask task,
OperationResultDto? operation = null)
=> new(
task.Id,
task.Title,
task.Detail,
task.Source,
task.State,
task.Priority,
task.AssignedTo,
task.ParentTaskId,
task.DueDate,
task.CreatedAt,
task.UpdatedAt,
task.IsAgentTask,
task.ExpectedFrom,
ProjectId: task.ProjectId,
Operation: operation);
private async Task WriteApprovalAuditAsync(
Guid taskId,
string action,
@@ -179,7 +303,7 @@ public class TasksController(
string? state,
CancellationToken ct)
{
await activityRepository.AddAsync(new ActivityEvent
await activityRepository.AddAsync(new Nexus.Api.Data.ActivityEvent
{
Type = "task_approval_audit",
Message = $"Task approval task={taskId} action={action} caller={DescribeCaller(HttpContext.User)} outcome={outcome} checkpoint={(state ?? "none")}",
+13 -2
View File
@@ -3,14 +3,25 @@ namespace Nexus.Api.DTOs;
public sealed record CreateProjectRequest(string Name, string? Description);
public sealed record CreateTaskRequest(string Title, string? Priority, Guid? ProjectId);
public sealed record UpdateTaskStateRequest(string State);
public sealed record ChatRequest(string Message, string? ConversationId, string? AgentId);
public sealed record ChatRequest(
string Message,
string? ConversationId,
string? AgentId,
MissionControlContextRequest? Context = null);
public sealed record MissionControlContextRequest(
string? RouteName,
string? Path,
string? Surface,
string? EntityType,
string? EntityId);
public sealed record UpdateProjectRequest(string? Name, string? Description, string? Status);
public sealed record UpdateTaskRequest(string? Title, string? Priority, Guid? ProjectId);
public sealed record AgentCommandRequest(string Message);
public sealed record SaveConfigRequest(string Content);
public sealed record SaveConfigRequest(string Content, string? ExpectedHash = null);
public sealed record AgentListResponse(
string Id,
+135
View File
@@ -0,0 +1,135 @@
namespace Nexus.Api.Data;
/// <summary>
/// Durable, secret-free proposal for creating one OpenClaw-owned agent.
/// Nexus owns the approval workflow only; the resulting agent remains owned by
/// OpenClaw.
/// </summary>
public sealed class AgentProposal
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Source { get; set; }
public required string RequestedName { get; set; }
public required string RequestedAgentId { get; set; }
public string? Role { get; set; }
public string? Description { get; set; }
public string? Model { get; set; }
public string? Emoji { get; set; }
public string? Avatar { get; set; }
public required string Workspace { get; set; }
public required string StandardFilesJson { get; set; }
public required string StandardFilesHash { get; set; }
public string Status { get; set; } = AgentProposalStates.AwaitingApproval;
public required string RequestedBy { get; set; }
public string? ApprovedBy { get; set; }
public string? RejectedBy { get; set; }
public string? RejectionReason { get; set; }
public string? OpenClawAgentId { get; set; }
public string? OpenClawWorkspace { get; set; }
public string? LastErrorCode { get; set; }
public string? LastErrorMessage { get; set; }
public int Revision { get; set; } = 1;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? ApprovedAt { get; set; }
public DateTimeOffset? RejectedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public ICollection<AgentProvisionRequest> ProvisionRequests { get; set; } =
new List<AgentProvisionRequest>();
}
/// <summary>
/// One explicitly authorized provisioning attempt. A request that reached the
/// dispatch boundary is never silently re-queued after a restart.
/// </summary>
public sealed class AgentProvisionRequest
{
public Guid Id { get; init; } = Guid.NewGuid();
public Guid ProposalId { get; set; }
public AgentProposal Proposal { get; set; } = null!;
public int Attempt { get; set; }
public required string Stage { get; set; }
public string Status { get; set; } = AgentProvisionRequestStates.Queued;
public required string IdempotencyKeyHash { get; set; }
public required string Actor { get; set; }
public required string CorrelationId { get; set; }
public string? TraceParent { get; set; }
public string? OpenClawAgentId { get; set; }
public string? LastErrorCode { get; set; }
public string? LastErrorMessage { get; set; }
public string? LeaseOwner { get; set; }
public DateTimeOffset? LeaseUntil { get; set; }
public int Revision { get; set; } = 1;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DispatchStartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
}
/// <summary>
/// Hash-only idempotency claim. Raw idempotency keys and request content are
/// deliberately not persisted.
/// </summary>
public sealed class OperationClaim
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Operation { get; set; }
public required string IdempotencyKeyHash { get; set; }
public required string RequestHash { get; set; }
public Guid? ResourceId { get; set; }
public required string State { get; set; }
public string? ResultCode { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? CompletedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; } =
DateTimeOffset.UtcNow.AddDays(7);
}
/// <summary>
/// Transactional, content-minimized domain event. The payload may contain
/// identifiers and states, but never proposal markdown or credentials.
/// </summary>
public sealed class OutboxEvent
{
public long Sequence { get; init; }
public Guid EventId { get; init; } = Guid.NewGuid();
public required string Type { get; set; }
public required string AggregateType { get; set; }
public required string AggregateId { get; set; }
public int AggregateRevision { get; set; }
public required string PayloadJson { get; set; }
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? PublishedAt { get; set; }
public int PublishAttempts { get; set; }
public string? LastErrorCode { get; set; }
}
public static class AgentProposalStates
{
public const string Draft = "draft";
public const string AwaitingApproval = "awaiting_approval";
public const string Provisioning = "provisioning";
public const string Ready = "ready";
public const string Partial = "partial";
public const string Failed = "failed";
public const string InDoubt = "in_doubt";
public const string Rejected = "rejected";
}
public static class AgentProvisionStages
{
public const string CreateAgent = "create_agent";
public const string ReconcileAgent = "reconcile_agent";
public const string FinalizeFiles = "finalize_files";
}
public static class AgentProvisionRequestStates
{
public const string Queued = "queued";
public const string Dispatching = "dispatching";
public const string Completed = "completed";
public const string Failed = "failed";
public const string Partial = "partial";
public const string InDoubt = "in_doubt";
public const string Blocked = "blocked";
}
@@ -0,0 +1,552 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260730130442_AddOpenClawRunProjection")]
partial class AddOpenClawRunProjection
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("AgentId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("FinishedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<long?>("LastGatewaySequence")
.HasColumnType("bigint");
b.Property<string>("OpenClawRunId")
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("RetriedFromRunId")
.HasColumnType("uuid");
b.Property<int>("Revision")
.IsConcurrencyToken()
.HasColumnType("integer");
b.Property<bool>("SequenceGapDetected")
.HasColumnType("boolean");
b.Property<string>("SessionKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("StartIdempotencyKey")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("TraceParent")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OpenClawRunId")
.IsUnique();
b.HasIndex("ProjectId");
b.HasIndex("RetriedFromRunId");
b.HasIndex("SessionKey");
b.HasIndex("StartIdempotencyKey")
.IsUnique();
b.HasIndex("TaskId");
b.HasIndex("Status", "UpdatedAt");
b.ToTable("OpenClawRuns");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRunHistory", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FromStatus")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("GatewayEventId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<long?>("GatewaySequence")
.HasColumnType("bigint");
b.Property<string>("IdempotencyKey")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ResultRunId")
.HasColumnType("uuid");
b.Property<Guid>("RunId")
.HasColumnType("uuid");
b.Property<bool>("SequenceGapDetected")
.HasColumnType("boolean");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("TraceParent")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("GatewayEventId")
.IsUnique();
b.HasIndex("RunId", "OccurredAt");
b.HasIndex("RunId", "Action", "IdempotencyKey")
.IsUnique();
b.ToTable("OpenClawRunHistory");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRun", b =>
{
b.HasOne("Nexus.Api.Data.Project", null)
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Nexus.Api.Data.WorkTask", null)
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRunHistory", b =>
{
b.HasOne("Nexus.Api.Data.OpenClawRun", "Run")
.WithMany("History")
.HasForeignKey("RunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Run");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRun", b =>
{
b.Navigation("History");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,156 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddOpenClawRunProjection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "OpenClawRuns",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Title = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: false),
Prompt = table.Column<string>(type: "text", nullable: false),
AgentId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
SessionKey = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Status = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
TaskId = table.Column<Guid>(type: "uuid", nullable: true),
ProjectId = table.Column<Guid>(type: "uuid", nullable: true),
OpenClawRunId = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: true),
RetriedFromRunId = table.Column<Guid>(type: "uuid", nullable: true),
StartIdempotencyKey = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
CorrelationId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Actor = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
TraceParent = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
LastError = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
LastGatewaySequence = table.Column<long>(type: "bigint", nullable: true),
SequenceGapDetected = table.Column<bool>(type: "boolean", nullable: false),
Revision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
StartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
FinishedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenClawRuns", x => x.Id);
table.ForeignKey(
name: "FK_OpenClawRuns_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_OpenClawRuns_Tasks_TaskId",
column: x => x.TaskId,
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "OpenClawRunHistory",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RunId = table.Column<Guid>(type: "uuid", nullable: false),
Action = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
FromStatus = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
ToStatus = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Message = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: false),
Actor = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
CorrelationId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
IdempotencyKey = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
TraceParent = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
GatewayEventId = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
GatewaySequence = table.Column<long>(type: "bigint", nullable: true),
SequenceGapDetected = table.Column<bool>(type: "boolean", nullable: false),
ResultRunId = table.Column<Guid>(type: "uuid", nullable: true),
OccurredAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenClawRunHistory", x => x.Id);
table.ForeignKey(
name: "FK_OpenClawRunHistory_OpenClawRuns_RunId",
column: x => x.RunId,
principalTable: "OpenClawRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OpenClawRunHistory_GatewayEventId",
table: "OpenClawRunHistory",
column: "GatewayEventId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenClawRunHistory_RunId_Action_IdempotencyKey",
table: "OpenClawRunHistory",
columns: new[] { "RunId", "Action", "IdempotencyKey" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenClawRunHistory_RunId_OccurredAt",
table: "OpenClawRunHistory",
columns: new[] { "RunId", "OccurredAt" });
migrationBuilder.CreateIndex(
name: "IX_OpenClawRuns_OpenClawRunId",
table: "OpenClawRuns",
column: "OpenClawRunId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenClawRuns_ProjectId",
table: "OpenClawRuns",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_OpenClawRuns_RetriedFromRunId",
table: "OpenClawRuns",
column: "RetriedFromRunId");
migrationBuilder.CreateIndex(
name: "IX_OpenClawRuns_SessionKey",
table: "OpenClawRuns",
column: "SessionKey");
migrationBuilder.CreateIndex(
name: "IX_OpenClawRuns_StartIdempotencyKey",
table: "OpenClawRuns",
column: "StartIdempotencyKey",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenClawRuns_Status_UpdatedAt",
table: "OpenClawRuns",
columns: new[] { "Status", "UpdatedAt" });
migrationBuilder.CreateIndex(
name: "IX_OpenClawRuns_TaskId",
table: "OpenClawRuns",
column: "TaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OpenClawRunHistory");
migrationBuilder.DropTable(
name: "OpenClawRuns");
}
}
}
@@ -0,0 +1,619 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Data.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260730191613_AddOpenClawConnectionProfile")]
partial class AddOpenClawConnectionProfile
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawConnectionProfile", b =>
{
b.Property<string>("ProfileId")
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<DateTimeOffset?>("AdoptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("AdoptionState")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("CapabilityHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeviceId")
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("DiscoverySource")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Endpoint")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("LastProbedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastVerifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("ManagementEnabled")
.HasColumnType("boolean");
b.Property<string>("RequiredVersion")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("Revision")
.IsConcurrencyToken()
.HasColumnType("integer");
b.Property<string>("TlsCertificateFingerprint")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ProfileId");
b.ToTable("OpenClawConnectionProfiles", null, t =>
{
t.HasCheckConstraint("CK_OpenClawConnectionProfiles_Primary", "\"ProfileId\" = 'primary'");
});
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("AgentId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("FinishedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<long?>("LastGatewaySequence")
.HasColumnType("bigint");
b.Property<string>("OpenClawRunId")
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("RetriedFromRunId")
.HasColumnType("uuid");
b.Property<int>("Revision")
.IsConcurrencyToken()
.HasColumnType("integer");
b.Property<bool>("SequenceGapDetected")
.HasColumnType("boolean");
b.Property<string>("SessionKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("StartIdempotencyKey")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("TraceParent")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OpenClawRunId")
.IsUnique();
b.HasIndex("ProjectId");
b.HasIndex("RetriedFromRunId");
b.HasIndex("SessionKey");
b.HasIndex("StartIdempotencyKey")
.IsUnique();
b.HasIndex("TaskId");
b.HasIndex("Status", "UpdatedAt");
b.ToTable("OpenClawRuns");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRunHistory", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FromStatus")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("GatewayEventId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<long?>("GatewaySequence")
.HasColumnType("bigint");
b.Property<string>("IdempotencyKey")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ResultRunId")
.HasColumnType("uuid");
b.Property<Guid>("RunId")
.HasColumnType("uuid");
b.Property<bool>("SequenceGapDetected")
.HasColumnType("boolean");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("TraceParent")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("GatewayEventId")
.IsUnique();
b.HasIndex("RunId", "OccurredAt");
b.HasIndex("RunId", "Action", "IdempotencyKey")
.IsUnique();
b.ToTable("OpenClawRunHistory");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRun", b =>
{
b.HasOne("Nexus.Api.Data.Project", null)
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Nexus.Api.Data.WorkTask", null)
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRunHistory", b =>
{
b.HasOne("Nexus.Api.Data.OpenClawRun", "Run")
.WithMany("History")
.HasForeignKey("RunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Run");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRun", b =>
{
b.Navigation("History");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddOpenClawConnectionProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "OpenClawConnectionProfiles",
columns: table => new
{
ProfileId = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Endpoint = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
DiscoverySource = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
RequiredVersion = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
TlsCertificateFingerprint = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
AdoptionState = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
ManagementEnabled = table.Column<bool>(type: "boolean", nullable: false),
CapabilityHash = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
DeviceId = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: true),
Revision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
LastProbedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LastVerifiedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
AdoptedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenClawConnectionProfiles", x => x.ProfileId);
table.CheckConstraint("CK_OpenClawConnectionProfiles_Primary", "\"ProfileId\" = 'primary'");
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OpenClawConnectionProfiles");
}
}
}
@@ -0,0 +1,261 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
[DbContext(typeof(NexusDbContext))]
[Migration("20260730224500_AddAgentProvisioningAndBoardIndexes")]
public partial class AddAgentProvisioningAndBoardIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AgentProposals",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Source = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
RequestedName = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
RequestedAgentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Role = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
Description = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: true),
Model = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: true),
Emoji = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
Avatar = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
Workspace = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: false),
StandardFilesJson = table.Column<string>(type: "jsonb", nullable: false),
StandardFilesHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
RequestedBy = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
ApprovedBy = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: true),
RejectedBy = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: true),
RejectionReason = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
OpenClawAgentId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
OpenClawWorkspace = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
LastErrorCode = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
LastErrorMessage = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
Revision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ApprovedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
RejectedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AgentProposals", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OperationClaims",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Operation = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
IdempotencyKeyHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
RequestHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ResourceId = table.Column<Guid>(type: "uuid", nullable: true),
State = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
ResultCode = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OperationClaims", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OutboxEvents",
columns: table => new
{
Sequence = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EventId = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
AggregateType = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
AggregateId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
AggregateRevision = table.Column<int>(type: "integer", nullable: false),
PayloadJson = table.Column<string>(type: "jsonb", nullable: false),
OccurredAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
PublishedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
PublishAttempts = table.Column<int>(type: "integer", nullable: false),
LastErrorCode = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OutboxEvents", x => x.Sequence);
});
migrationBuilder.CreateTable(
name: "AgentProvisionRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProposalId = table.Column<Guid>(type: "uuid", nullable: false),
Attempt = table.Column<int>(type: "integer", nullable: false),
Stage = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Status = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
IdempotencyKeyHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Actor = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
CorrelationId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
TraceParent = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
OpenClawAgentId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
LastErrorCode = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
LastErrorMessage = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
LeaseOwner = table.Column<string>(type: "character varying(160)", maxLength: 160, nullable: true),
LeaseUntil = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
Revision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
DispatchStartedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AgentProvisionRequests", x => x.Id);
table.ForeignKey(
name: "FK_AgentProvisionRequests_AgentProposals_ProposalId",
column: x => x.ProposalId,
principalTable: "AgentProposals",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Activity_TaskId_CreatedAt_Id",
table: "Activity",
columns: new[] { "TaskId", "CreatedAt", "Id" },
descending: new[] { false, true, true },
filter: "\"TaskId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_AgentProposals_OpenClawAgentId",
table: "AgentProposals",
column: "OpenClawAgentId");
migrationBuilder.CreateIndex(
name: "IX_AgentProposals_RequestedAgentId",
table: "AgentProposals",
column: "RequestedAgentId");
migrationBuilder.CreateIndex(
name: "IX_AgentProposals_Status_UpdatedAt",
table: "AgentProposals",
columns: new[] { "Status", "UpdatedAt" });
migrationBuilder.CreateIndex(
name: "IX_AgentProvisionRequests_LeaseUntil",
table: "AgentProvisionRequests",
column: "LeaseUntil");
migrationBuilder.CreateIndex(
name: "IX_AgentProvisionRequests_ProposalId_Attempt",
table: "AgentProvisionRequests",
columns: new[] { "ProposalId", "Attempt" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AgentProvisionRequests_Status_CreatedAt",
table: "AgentProvisionRequests",
columns: new[] { "Status", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_OperationClaims_ExpiresAt",
table: "OperationClaims",
column: "ExpiresAt");
migrationBuilder.CreateIndex(
name: "IX_OperationClaims_Operation_IdempotencyKeyHash",
table: "OperationClaims",
columns: new[] { "Operation", "IdempotencyKeyHash" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OutboxEvents_EventId",
table: "OutboxEvents",
column: "EventId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OutboxEvents_OccurredAt",
table: "OutboxEvents",
column: "OccurredAt");
migrationBuilder.CreateIndex(
name: "IX_OutboxEvents_PublishedAt_Sequence",
table: "OutboxEvents",
columns: new[] { "PublishedAt", "Sequence" });
migrationBuilder.CreateIndex(
name: "IX_Tasks_Done_UpdatedAt_Id",
table: "Tasks",
columns: new[] { "UpdatedAt", "Id" },
descending: new[] { true, true },
filter: "\"State\" = 'Done'");
migrationBuilder.CreateIndex(
name: "IX_Tasks_AgentWorkflow",
table: "Tasks",
columns: new[] { "ExpectedFrom", "State", "UpdatedAt", "Id" },
descending: new[] { false, false, true, false },
filter: "\"IsAgentTask\" = TRUE");
migrationBuilder.CreateIndex(
name: "IX_Tasks_ParentTaskId_State",
table: "Tasks",
columns: new[] { "ParentTaskId", "State" },
filter: "\"ParentTaskId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Tasks_State_UpdatedAt_Id_Board",
table: "Tasks",
columns: new[] { "State", "UpdatedAt", "Id" },
descending: new[] { false, true, false });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Activity_TaskId_CreatedAt_Id",
table: "Activity");
migrationBuilder.DropIndex(
name: "IX_Tasks_Done_UpdatedAt_Id",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_AgentWorkflow",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ParentTaskId_State",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_State_UpdatedAt_Id_Board",
table: "Tasks");
migrationBuilder.DropTable(
name: "AgentProvisionRequests");
migrationBuilder.DropTable(
name: "OperationClaims");
migrationBuilder.DropTable(
name: "OutboxEvents");
migrationBuilder.DropTable(
name: "AgentProposals");
}
}
}
@@ -51,9 +51,225 @@ namespace Nexus.Api.Migrations
b.HasIndex("TaskId");
b.HasIndex("TaskId", "CreatedAt", "Id")
.IsDescending(false, true, true)
.HasDatabaseName("IX_Activity_TaskId_CreatedAt_Id")
.HasFilter("\"TaskId\" IS NOT NULL");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.AgentProposal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ApprovedBy")
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset?>("ApprovedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Avatar")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("Emoji")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("LastErrorCode")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("LastErrorMessage")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Model")
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("OpenClawAgentId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("OpenClawWorkspace")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("RejectedBy")
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset?>("RejectedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RejectionReason")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("RequestedAgentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("RequestedBy")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("RequestedName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<int>("Revision")
.IsConcurrencyToken()
.HasColumnType("integer");
b.Property<string>("Role")
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("StandardFilesHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("StandardFilesJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Workspace")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.HasIndex("OpenClawAgentId");
b.HasIndex("RequestedAgentId");
b.HasIndex("Status", "UpdatedAt");
b.ToTable("AgentProposals");
});
modelBuilder.Entity("Nexus.Api.Data.AgentProvisionRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<int>("Attempt")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DispatchStartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IdempotencyKeyHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("LastErrorCode")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("LastErrorMessage")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("LeaseOwner")
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<DateTimeOffset?>("LeaseUntil")
.HasColumnType("timestamp with time zone");
b.Property<string>("OpenClawAgentId")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("ProposalId")
.HasColumnType("uuid");
b.Property<int>("Revision")
.IsConcurrencyToken()
.HasColumnType("integer");
b.Property<string>("Stage")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("TraceParent")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("LeaseUntil");
b.HasIndex("ProposalId", "Attempt")
.IsUnique();
b.HasIndex("Status", "CreatedAt");
b.ToTable("AgentProvisionRequests");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
@@ -141,6 +357,370 @@ namespace Nexus.Api.Migrations
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawConnectionProfile", b =>
{
b.Property<string>("ProfileId")
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<DateTimeOffset?>("AdoptedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("AdoptionState")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("CapabilityHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeviceId")
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("DiscoverySource")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Endpoint")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("LastProbedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastVerifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("ManagementEnabled")
.HasColumnType("boolean");
b.Property<string>("RequiredVersion")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<int>("Revision")
.IsConcurrencyToken()
.HasColumnType("integer");
b.Property<string>("TlsCertificateFingerprint")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ProfileId");
b.ToTable("OpenClawConnectionProfiles", null, t =>
{
t.HasCheckConstraint("CK_OpenClawConnectionProfiles_Primary", "\"ProfileId\" = 'primary'");
});
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("AgentId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("FinishedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<long?>("LastGatewaySequence")
.HasColumnType("bigint");
b.Property<string>("OpenClawRunId")
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("RetriedFromRunId")
.HasColumnType("uuid");
b.Property<int>("Revision")
.IsConcurrencyToken()
.HasColumnType("integer");
b.Property<bool>("SequenceGapDetected")
.HasColumnType("boolean");
b.Property<string>("SessionKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("StartIdempotencyKey")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset?>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<string>("TraceParent")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OpenClawRunId")
.IsUnique();
b.HasIndex("ProjectId");
b.HasIndex("RetriedFromRunId");
b.HasIndex("SessionKey");
b.HasIndex("StartIdempotencyKey")
.IsUnique();
b.HasIndex("TaskId");
b.HasIndex("Status", "UpdatedAt");
b.ToTable("OpenClawRuns");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRunHistory", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Actor")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("CorrelationId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FromStatus")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("GatewayEventId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<long?>("GatewaySequence")
.HasColumnType("bigint");
b.Property<string>("IdempotencyKey")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ResultRunId")
.HasColumnType("uuid");
b.Property<Guid>("RunId")
.HasColumnType("uuid");
b.Property<bool>("SequenceGapDetected")
.HasColumnType("boolean");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("TraceParent")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("GatewayEventId")
.IsUnique();
b.HasIndex("RunId", "OccurredAt");
b.HasIndex("RunId", "Action", "IdempotencyKey")
.IsUnique();
b.ToTable("OpenClawRunHistory");
});
modelBuilder.Entity("Nexus.Api.Data.OperationClaim", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IdempotencyKeyHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Operation")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("RequestHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid?>("ResourceId")
.HasColumnType("uuid");
b.Property<string>("ResultCode")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.HasKey("Id");
b.HasIndex("ExpiresAt");
b.HasIndex("Operation", "IdempotencyKeyHash")
.IsUnique();
b.ToTable("OperationClaims");
});
modelBuilder.Entity("Nexus.Api.Data.OutboxEvent", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Sequence"));
b.Property<string>("AggregateId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<int>("AggregateRevision")
.HasColumnType("integer");
b.Property<string>("AggregateType")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<Guid>("EventId")
.HasColumnType("uuid");
b.Property<string>("LastErrorCode")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("PayloadJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<DateTimeOffset?>("PublishedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("PublishAttempts")
.HasColumnType("integer");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Sequence");
b.HasIndex("EventId")
.IsUnique();
b.HasIndex("OccurredAt");
b.HasIndex("PublishedAt", "Sequence");
b.ToTable("OutboxEvents");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
@@ -294,9 +874,51 @@ namespace Nexus.Api.Migrations
b.HasIndex("Source");
b.HasIndex("ExpectedFrom", "State", "UpdatedAt", "Id")
.IsDescending(false, false, true, false)
.HasDatabaseName("IX_Tasks_AgentWorkflow")
.HasFilter("\"IsAgentTask\" = TRUE");
b.HasIndex("ParentTaskId", "State")
.HasDatabaseName("IX_Tasks_ParentTaskId_State")
.HasFilter("\"ParentTaskId\" IS NOT NULL");
b.HasIndex("State", "UpdatedAt", "Id")
.IsDescending(false, true, false)
.HasDatabaseName("IX_Tasks_State_UpdatedAt_Id_Board");
b.HasIndex("UpdatedAt", "Id")
.IsDescending(true, true)
.HasDatabaseName("IX_Tasks_Done_UpdatedAt_Id")
.HasFilter("\"State\" = 'Done'");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRun", b =>
{
b.HasOne("Nexus.Api.Data.Project", null)
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Nexus.Api.Data.WorkTask", null)
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRunHistory", b =>
{
b.HasOne("Nexus.Api.Data.OpenClawRun", "Run")
.WithMany("History")
.HasForeignKey("RunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Run");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
@@ -323,6 +945,27 @@ namespace Nexus.Api.Migrations
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.AgentProposal", b =>
{
b.Navigation("ProvisionRequests");
});
modelBuilder.Entity("Nexus.Api.Data.AgentProvisionRequest", b =>
{
b.HasOne("Nexus.Api.Data.AgentProposal", "Proposal")
.WithMany("ProvisionRequests")
.HasForeignKey("ProposalId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Proposal");
});
modelBuilder.Entity("Nexus.Api.Data.OpenClawRun", b =>
{
b.Navigation("History");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
+158
View File
@@ -11,9 +11,20 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
public DbSet<NexusUser> Users => Set<NexusUser>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<SeedAudit> SeedAudits => Set<SeedAudit>();
public DbSet<OpenClawRun> OpenClawRuns => Set<OpenClawRun>();
public DbSet<OpenClawRunHistory> OpenClawRunHistory => Set<OpenClawRunHistory>();
public DbSet<OpenClawConnectionProfile> OpenClawConnectionProfiles =>
Set<OpenClawConnectionProfile>();
public DbSet<AgentProposal> AgentProposals => Set<AgentProposal>();
public DbSet<AgentProvisionRequest> AgentProvisionRequests =>
Set<AgentProvisionRequest>();
public DbSet<OperationClaim> OperationClaims => Set<OperationClaim>();
public DbSet<OutboxEvent> OutboxEvents => Set<OutboxEvent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new OpenClawConnectionProfileConfiguration());
ConfigureAgentProvisioning(modelBuilder);
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
modelBuilder.Entity<WorkTask>(entity =>
{
@@ -26,6 +37,20 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
entity.HasIndex(x => x.AssignedTo);
entity.HasIndex(x => x.IsAgentTask);
entity.HasIndex(x => x.ExpectedFrom);
entity.HasIndex(x => new { x.State, x.UpdatedAt, x.Id })
.IsDescending(false, true, false)
.HasDatabaseName("IX_Tasks_State_UpdatedAt_Id_Board");
entity.HasIndex(x => new { x.UpdatedAt, x.Id })
.IsDescending(true, true)
.HasFilter("\"State\" = 'Done'")
.HasDatabaseName("IX_Tasks_Done_UpdatedAt_Id");
entity.HasIndex(x => new { x.ParentTaskId, x.State })
.HasFilter("\"ParentTaskId\" IS NOT NULL")
.HasDatabaseName("IX_Tasks_ParentTaskId_State");
entity.HasIndex(x => new { x.ExpectedFrom, x.State, x.UpdatedAt, x.Id })
.IsDescending(false, false, true, false)
.HasFilter("\"IsAgentTask\" = TRUE")
.HasDatabaseName("IX_Tasks_AgentWorkflow");
entity.HasOne(x => x.ParentTask)
.WithMany(x => x.ChildTasks)
.HasForeignKey(x => x.ParentTaskId)
@@ -44,6 +69,10 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
{
entity.Property(x => x.Message).HasMaxLength(1000);
entity.HasIndex(x => x.TaskId);
entity.HasIndex(x => new { x.TaskId, x.CreatedAt, x.Id })
.IsDescending(false, true, true)
.HasFilter("\"TaskId\" IS NOT NULL")
.HasDatabaseName("IX_Activity_TaskId_CreatedAt_Id");
});
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
@@ -56,5 +85,134 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ActivityEvent>().HasIndex(x => x.CreatedAt);
modelBuilder.Entity<OpenClawRun>(entity =>
{
entity.Property(x => x.Title).HasMaxLength(160);
entity.Property(x => x.Prompt).HasColumnType("text");
entity.Property(x => x.AgentId).HasMaxLength(200);
entity.Property(x => x.SessionKey).HasMaxLength(500);
entity.Property(x => x.Status).HasMaxLength(40);
entity.Property(x => x.OpenClawRunId).HasMaxLength(240);
entity.Property(x => x.StartIdempotencyKey).HasMaxLength(200);
entity.Property(x => x.CorrelationId).HasMaxLength(200);
entity.Property(x => x.Actor).HasMaxLength(240);
entity.Property(x => x.TraceParent).HasMaxLength(128);
entity.Property(x => x.LastError).HasMaxLength(2000);
entity.Property(x => x.Revision).IsConcurrencyToken();
entity.HasIndex(x => x.StartIdempotencyKey).IsUnique();
entity.HasIndex(x => x.OpenClawRunId).IsUnique();
entity.HasIndex(x => new { x.Status, x.UpdatedAt });
entity.HasIndex(x => x.TaskId);
entity.HasIndex(x => x.ProjectId);
entity.HasIndex(x => x.SessionKey);
entity.HasIndex(x => x.RetriedFromRunId);
entity.HasOne<WorkTask>()
.WithMany()
.HasForeignKey(x => x.TaskId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne<Project>()
.WithMany()
.HasForeignKey(x => x.ProjectId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<OpenClawRunHistory>(entity =>
{
entity.Property(x => x.Action).HasMaxLength(80);
entity.Property(x => x.FromStatus).HasMaxLength(40);
entity.Property(x => x.ToStatus).HasMaxLength(40);
entity.Property(x => x.Message).HasMaxLength(2000);
entity.Property(x => x.Actor).HasMaxLength(240);
entity.Property(x => x.CorrelationId).HasMaxLength(200);
entity.Property(x => x.IdempotencyKey).HasMaxLength(200);
entity.Property(x => x.TraceParent).HasMaxLength(128);
entity.Property(x => x.GatewayEventId).HasMaxLength(120);
entity.HasIndex(x => new { x.RunId, x.OccurredAt });
entity.HasIndex(x => new { x.RunId, x.Action, x.IdempotencyKey }).IsUnique();
entity.HasIndex(x => x.GatewayEventId).IsUnique();
entity.HasOne(x => x.Run)
.WithMany(x => x.History)
.HasForeignKey(x => x.RunId)
.OnDelete(DeleteBehavior.Cascade);
});
}
private static void ConfigureAgentProvisioning(ModelBuilder modelBuilder)
{
modelBuilder.Entity<AgentProposal>(entity =>
{
entity.Property(x => x.Source).HasMaxLength(40);
entity.Property(x => x.RequestedName).HasMaxLength(80);
entity.Property(x => x.RequestedAgentId).HasMaxLength(64);
entity.Property(x => x.Role).HasMaxLength(160);
entity.Property(x => x.Description).HasMaxLength(4000);
entity.Property(x => x.Model).HasMaxLength(240);
entity.Property(x => x.Emoji).HasMaxLength(32);
entity.Property(x => x.Avatar).HasMaxLength(2048);
entity.Property(x => x.Workspace).HasMaxLength(2048);
entity.Property(x => x.StandardFilesJson).HasColumnType("jsonb");
entity.Property(x => x.StandardFilesHash).HasMaxLength(64);
entity.Property(x => x.Status).HasMaxLength(40);
entity.Property(x => x.RequestedBy).HasMaxLength(240);
entity.Property(x => x.ApprovedBy).HasMaxLength(240);
entity.Property(x => x.RejectedBy).HasMaxLength(240);
entity.Property(x => x.RejectionReason).HasMaxLength(1000);
entity.Property(x => x.OpenClawAgentId).HasMaxLength(128);
entity.Property(x => x.OpenClawWorkspace).HasMaxLength(2048);
entity.Property(x => x.LastErrorCode).HasMaxLength(120);
entity.Property(x => x.LastErrorMessage).HasMaxLength(2000);
entity.Property(x => x.Revision).IsConcurrencyToken();
entity.HasIndex(x => new { x.Status, x.UpdatedAt });
entity.HasIndex(x => x.RequestedAgentId);
entity.HasIndex(x => x.OpenClawAgentId);
});
modelBuilder.Entity<AgentProvisionRequest>(entity =>
{
entity.Property(x => x.Stage).HasMaxLength(40);
entity.Property(x => x.Status).HasMaxLength(40);
entity.Property(x => x.IdempotencyKeyHash).HasMaxLength(64);
entity.Property(x => x.Actor).HasMaxLength(240);
entity.Property(x => x.CorrelationId).HasMaxLength(200);
entity.Property(x => x.TraceParent).HasMaxLength(128);
entity.Property(x => x.OpenClawAgentId).HasMaxLength(128);
entity.Property(x => x.LastErrorCode).HasMaxLength(120);
entity.Property(x => x.LastErrorMessage).HasMaxLength(2000);
entity.Property(x => x.LeaseOwner).HasMaxLength(160);
entity.Property(x => x.Revision).IsConcurrencyToken();
entity.HasIndex(x => new { x.ProposalId, x.Attempt }).IsUnique();
entity.HasIndex(x => new { x.Status, x.CreatedAt });
entity.HasIndex(x => x.LeaseUntil);
entity.HasOne(x => x.Proposal)
.WithMany(x => x.ProvisionRequests)
.HasForeignKey(x => x.ProposalId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<OperationClaim>(entity =>
{
entity.Property(x => x.Operation).HasMaxLength(100);
entity.Property(x => x.IdempotencyKeyHash).HasMaxLength(64);
entity.Property(x => x.RequestHash).HasMaxLength(64);
entity.Property(x => x.State).HasMaxLength(40);
entity.Property(x => x.ResultCode).HasMaxLength(120);
entity.HasIndex(x => new { x.Operation, x.IdempotencyKeyHash }).IsUnique();
entity.HasIndex(x => x.ExpiresAt);
});
modelBuilder.Entity<OutboxEvent>(entity =>
{
entity.HasKey(x => x.Sequence);
entity.Property(x => x.Sequence).ValueGeneratedOnAdd();
entity.Property(x => x.Type).HasMaxLength(120);
entity.Property(x => x.AggregateType).HasMaxLength(80);
entity.Property(x => x.AggregateId).HasMaxLength(128);
entity.Property(x => x.PayloadJson).HasColumnType("jsonb");
entity.Property(x => x.LastErrorCode).HasMaxLength(120);
entity.HasIndex(x => x.EventId).IsUnique();
entity.HasIndex(x => new { x.PublishedAt, x.Sequence });
entity.HasIndex(x => x.OccurredAt);
});
}
}
+76
View File
@@ -0,0 +1,76 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Nexus.Api.Models;
namespace Nexus.Api.Data;
/// <summary>
/// Secret-free metadata for Nexus' one active OpenClaw connection.
/// Device private keys, device tokens, bootstrap tokens and provider credentials
/// are intentionally outside this entity.
/// </summary>
[Table("OpenClawConnectionProfiles")]
public sealed class OpenClawConnectionProfile
{
public const string PrimaryProfileId = "primary";
[Key]
[MaxLength(40)]
public string ProfileId { get; set; } = PrimaryProfileId;
[MaxLength(2048)]
public required string Endpoint { get; set; }
[MaxLength(80)]
public required string DiscoverySource { get; set; }
[MaxLength(120)]
public string? RequiredVersion { get; set; }
[MaxLength(128)]
public string? TlsCertificateFingerprint { get; set; }
[MaxLength(40)]
public string AdoptionState { get; set; } = OpenClawAdoptionStates.None;
public bool ManagementEnabled { get; set; }
[MaxLength(128)]
public string? CapabilityHash { get; set; }
[MaxLength(240)]
public string? DeviceId { get; set; }
public int Revision { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastProbedAt { get; set; }
public DateTimeOffset? LastVerifiedAt { get; set; }
public DateTimeOffset? AdoptedAt { get; set; }
}
/// <summary>
/// Kept next to the entity so the root integration only has to apply this
/// configuration from NexusDbContext.OnModelCreating.
/// </summary>
public sealed class OpenClawConnectionProfileConfiguration
: IEntityTypeConfiguration<OpenClawConnectionProfile>
{
public void Configure(EntityTypeBuilder<OpenClawConnectionProfile> entity)
{
entity.ToTable(
"OpenClawConnectionProfiles",
table => table.HasCheckConstraint(
"CK_OpenClawConnectionProfiles_Primary",
"\"ProfileId\" = 'primary'"));
entity.HasKey(profile => profile.ProfileId);
entity.Property(profile => profile.Revision).IsConcurrencyToken();
}
}
+70
View File
@@ -0,0 +1,70 @@
namespace Nexus.Api.Data;
/// <summary>
/// Durable Nexus projection of one OpenClaw chat run. Nexus owns the
/// correlation and audit metadata; OpenClaw remains the execution authority.
/// </summary>
public sealed class OpenClawRun
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Title { get; set; }
public required string Prompt { get; set; }
public required string AgentId { get; set; }
public required string SessionKey { get; set; }
public string Status { get; set; } = OpenClawRunStates.Dispatching;
public Guid? TaskId { get; set; }
public Guid? ProjectId { get; set; }
public string? OpenClawRunId { get; set; }
public Guid? RetriedFromRunId { get; set; }
public required string StartIdempotencyKey { get; set; }
public required string CorrelationId { get; set; }
public required string Actor { get; set; }
public string? TraceParent { get; set; }
public string? LastError { get; set; }
public long? LastGatewaySequence { get; set; }
public bool SequenceGapDetected { get; set; }
public int Revision { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset? FinishedAt { get; set; }
public ICollection<OpenClawRunHistory> History { get; set; } = new List<OpenClawRunHistory>();
}
/// <summary>
/// Append-only action and state-transition ledger for an OpenClaw run.
/// </summary>
public sealed class OpenClawRunHistory
{
public long Id { get; init; }
public Guid RunId { get; set; }
public OpenClawRun Run { get; set; } = null!;
public required string Action { get; set; }
public required string FromStatus { get; set; }
public required string ToStatus { get; set; }
public required string Message { get; set; }
public required string Actor { get; set; }
public required string CorrelationId { get; set; }
public string? IdempotencyKey { get; set; }
public string? TraceParent { get; set; }
public string? GatewayEventId { get; set; }
public long? GatewaySequence { get; set; }
public bool SequenceGapDetected { get; set; }
public Guid? ResultRunId { get; set; }
public DateTimeOffset OccurredAt { get; set; } = DateTimeOffset.UtcNow;
}
public static class OpenClawRunStates
{
public const string Dispatching = "dispatching";
public const string Running = "running";
public const string Stopping = "stopping";
public const string Stopped = "stopped";
public const string Completed = "completed";
public const string Failed = "failed";
public const string Blocked = "blocked";
public const string Unsupported = "unsupported";
public static bool IsTerminal(string state)
=> state is Stopped or Completed or Failed or Blocked or Unsupported;
}
+1
View File
@@ -15,6 +15,7 @@ LABEL org.opencontainers.image.title="Nexus API" \
WORKDIR /app
COPY --from=build /app/publish .
RUN apk add --no-cache curl
RUN mkdir -p /var/lib/nexus/openclaw && chown -R "$APP_UID":"$APP_UID" /var/lib/nexus/openclaw
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
@@ -35,6 +35,7 @@ public static class ApplicationBuilderExtensions
return;
var ownerEmail = configuration["Bootstrap:OwnerEmail"]?.Trim().ToLowerInvariant();
var ownerPassword = configuration["Bootstrap:OwnerPassword"];
var hasUsers = await db.Users.AnyAsync();
// ── Double-check SeedAudit after the migration — if another pod wrote it
@@ -55,20 +56,20 @@ public static class ApplicationBuilderExtensions
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Bootstrap:OwnerEmail is required for initial setup.");
if (string.IsNullOrWhiteSpace(ownerPassword) || ownerPassword.Length < 10)
throw new InvalidOperationException(
"Bootstrap:OwnerPassword is required for initial setup and must contain at least 10 characters.");
var initialDisplayName = PasswordHelper.BuildOwnerDisplayName(ownerEmail);
var initialPassword = PasswordHelper.GenerateTemporaryPassword();
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
PasswordHash = PasswordSecurity.Hash(ownerPassword),
Role = "owner"
});
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
// Record the seed attempt regardless of whether users already existed.
@@ -86,6 +87,8 @@ public static class ApplicationBuilderExtensions
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseForwardedHeaders();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRateLimiter();
app.UseApiKeyAuthentication();
app.UseAuthentication();
@@ -0,0 +1,68 @@
using System.Diagnostics;
using Nexus.Api.Observability;
using Npgsql;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Nexus.Api.Extensions;
public static class PlatformServiceCollectionExtensions
{
/// <summary>
/// Registers the canonical OpenAPI document and privacy-safe telemetry.
/// OTLP export is opt-in; without an endpoint Nexus keeps only in-process
/// instrumentation and does not add a production telemetry service.
/// </summary>
public static IServiceCollection AddNexusPlatform(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOpenApi("v1");
services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
};
});
var telemetry = services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(
serviceName: "nexus-api",
serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString()))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter(NexusTelemetry.SourceName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
{
// Exception messages and stack traces may contain prompts,
// paths or other operator content.
options.RecordException = false;
options.Filter = context =>
!context.Request.Path.StartsWithSegments("/health");
})
.AddHttpClientInstrumentation(options =>
{
options.RecordException = false;
})
.AddNpgsql()
.AddSource(NexusTelemetry.SourceName)
.AddProcessor(new NexusTelemetryRedactionProcessor()));
var endpointValue = configuration["OpenTelemetry:OtlpEndpoint"]
?? Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT");
if (Uri.TryCreate(endpointValue, UriKind.Absolute, out var endpoint))
{
telemetry.UseOtlpExporter(OtlpExportProtocol.Grpc, endpoint);
}
return services;
}
}
+127 -23
View File
@@ -1,8 +1,11 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Http.Resilience;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore;
using Nexus.Api.Data;
@@ -12,6 +15,7 @@ using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
@@ -53,7 +57,12 @@ public static class ServiceCollectionExtensions
};
});
services.AddAuthorization();
services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
@@ -77,7 +86,7 @@ public static class ServiceCollectionExtensions
options.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers.ContentType = "application/json";
context.HttpContext.Response.Headers.ContentType = "application/problem+json";
var retryAfterSeconds = 60;
@@ -93,13 +102,19 @@ public static class ServiceCollectionExtensions
context.HttpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
var body = new
var body = new ProblemDetails
{
error = "rate_limit_exceeded",
message = $"Too many attempts. Try again in {retryAfterSeconds} second(s).",
remaining = 0,
retryAfterSeconds
Type = "https://httpstatuses.com/429",
Title = "Rate limit exceeded",
Status = StatusCodes.Status429TooManyRequests,
Detail = $"Too many attempts. Try again in {retryAfterSeconds} second(s)."
};
body.Extensions["code"] = "rate_limit_exceeded";
body.Extensions["remaining"] = 0;
body.Extensions["retryAfterSeconds"] = retryAfterSeconds;
body.Extensions["traceId"] =
System.Diagnostics.Activity.Current?.Id
?? context.HttpContext.TraceIdentifier;
await context.HttpContext.Response.WriteAsJsonAsync(body, ct);
};
@@ -131,13 +146,45 @@ public static class ServiceCollectionExtensions
/// <summary>
/// Configures forwarded headers for reverse proxy scenarios.
/// </summary>
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
public static IServiceCollection AddNexusForwardedHeaders(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
var forwardLimit = configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 1;
if (forwardLimit is < 1 or > 5)
throw new InvalidOperationException("ForwardedHeaders:ForwardLimit must be between 1 and 5.");
options.ForwardLimit = forwardLimit;
foreach (var configuredProxy in configuration
.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>() ?? [])
{
if (string.IsNullOrWhiteSpace(configuredProxy))
continue;
if (!IPAddress.TryParse(configuredProxy, out var proxy))
throw new InvalidOperationException(
$"ForwardedHeaders:KnownProxies contains invalid IP address '{configuredProxy}'.");
options.KnownProxies.Add(proxy);
}
foreach (var configuredNetwork in configuration
.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>() ?? [])
{
if (string.IsNullOrWhiteSpace(configuredNetwork))
continue;
if (!System.Net.IPNetwork.TryParse(configuredNetwork, out var network))
throw new InvalidOperationException(
$"ForwardedHeaders:KnownNetworks contains invalid CIDR '{configuredNetwork}'.");
options.KnownIPNetworks.Add(network);
}
});
return services;
@@ -174,34 +221,58 @@ public static class ServiceCollectionExtensions
/// </summary>
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
var runtimeReadClient = services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
client.Timeout = Timeout.InfiniteTimeSpan;
});
AddOpenClawReadResilience(runtimeReadClient);
services.AddHttpClient("gateway", client =>
var gatewayReadClient = services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
client.Timeout = Timeout.InfiniteTimeSpan;
});
AddOpenClawReadResilience(gatewayReadClient);
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
var historyReadClient = services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
client.Timeout = Timeout.InfiniteTimeSpan;
});
AddOpenClawReadResilience(historyReadClient);
return services;
}
private static void AddOpenClawReadResilience(IHttpClientBuilder client)
{
client.AddStandardResilienceHandler(options =>
{
options.RateLimiter.DefaultRateLimiterOptions.PermitLimit = 4;
options.RateLimiter.DefaultRateLimiterOptions.QueueLimit = 0;
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(30);
options.Retry.MaxRetryAttempts = 2;
options.Retry.Delay = TimeSpan.FromMilliseconds(250);
options.Retry.UseJitter = true;
options.Retry.DisableForUnsafeHttpMethods();
options.CircuitBreaker.FailureRatio = 0.5;
options.CircuitBreaker.MinimumThroughput = 4;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(30);
});
}
/// <summary>
/// Registers application domain services (transient, scoped, singleton).
/// </summary>
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
public static IServiceCollection AddNexusApplicationServices(
this IServiceCollection services,
bool includeHostedServices = true)
{
services.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true)
@@ -209,6 +280,8 @@ public static class ServiceCollectionExtensions
services.AddOptions<StaleTaskRecoveryOptions>()
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
services.AddOptions<AgentProvisioningOptions>()
.BindConfiguration(AgentProvisioningOptions.SectionName);
services.AddHttpContextAccessor();
services.AddSingleton<LoginAttemptTracker>();
services.AddTransient<ModelRoutingService>();
@@ -219,21 +292,50 @@ public static class ServiceCollectionExtensions
services.AddScoped<ITaskService, TaskService>();
services.AddScoped<IOperationsService, OperationsService>();
services.AddScoped<ITeamService, TeamService>();
services.AddSingleton<IAgentConfigService, AgentConfigService>();
services.AddSingleton<IMemoryService, MemoryService>();
services.AddSingleton<IIncidentService, IncidentService>();
services.AddSingleton<IDocService, DocService>();
services.AddScoped<IMemoryService, MemoryService>();
services.AddScoped<IIncidentService, IncidentService>();
services.AddScoped<IDocService, DocService>();
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
services.AddSingleton<DomainEventStreamService>();
services.AddSingleton<IDomainEventStreamService>(serviceProvider =>
serviceProvider.GetRequiredService<DomainEventStreamService>());
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ICalendarService, CalendarService>();
services.AddScoped<IOpenClawControlService, OpenClawControlService>();
services.AddScoped<IOpenClawAgentConfigurationService, OpenClawAgentConfigurationService>();
services.AddScoped<IOpenClawSetupService, OpenClawSetupService>();
services.AddSingleton<IOpenClawWizardService, OpenClawWizardService>();
services.AddSingleton<IOpenClawManagementState, OpenClawManagementState>();
services.AddSingleton<IOpenClawWriteGate, OpenClawWriteGate>();
services.AddSingleton<IOpenClawEventProjectionService, OpenClawEventProjectionService>();
services.AddSingleton<IOpenClawRunGateway, OpenClawRunGateway>();
services.AddScoped<IOpenClawRunService, OpenClawRunService>();
services.AddScoped<IOpenClawChatService, OpenClawChatService>();
services.AddScoped<IAgentProposalService, AgentProposalService>();
services.AddSingleton<AgentProvisioningSignal>();
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
// ── Gateway WebSocket Connector ──
services.AddOptions<GatewayConnectorOptions>()
.BindConfiguration(GatewayConnectorOptions.SectionName);
services.AddSingleton<IOpenClawDeviceIdentityStore, OpenClawDeviceIdentityStore>();
services.AddSingleton<
IOpenClawOperationAuditStore,
PostgresOpenClawOperationAuditStore>();
services.AddSingleton<IGatewayConnector, GatewayConnector>();
services.AddHostedService(sp => (GatewayConnector)sp.GetRequiredService<IGatewayConnector>());
if (includeHostedServices)
{
services.AddHostedService<OpenClawManagementStateInitializer>();
services.AddHostedService(serviceProvider =>
serviceProvider.GetRequiredService<DomainEventStreamService>());
services.AddHostedService<AgentProvisioningWorker>();
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
services.AddHostedService(serviceProvider =>
(GatewayConnector)serviceProvider.GetRequiredService<IGatewayConnector>());
services.AddHostedService<OpenClawEventSubscriptionCoordinator>();
services.AddHostedService<OpenClawRunEventReconciler>();
}
// ── Backend Bridge (Agent-Command-Service) ──
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
@@ -250,6 +352,8 @@ public static class ServiceCollectionExtensions
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<IActivityRepository, ActivityRepository>();
services.AddScoped<IOpenClawRunRepository, OpenClawRunRepository>();
services.AddScoped<IOpenClawConnectionProfileRepository, OpenClawConnectionProfileRepository>();
return services;
}
+5 -6
View File
@@ -1,4 +1,5 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Integrations;
@@ -19,17 +20,15 @@ public sealed record AgentChatResult(
string Runtime,
string AgentId,
string ConversationId,
string Content);
string Content,
Guid? RunId = null,
string State = "unknown",
OperationResultDto? Operation = null);
public interface IAgentRuntime
{
string Name { get; }
Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken);
Task<AgentChatResult> ChatAsync(
string message,
string conversationId,
string agentId,
CancellationToken cancellationToken);
}
public interface IModelProvider
-35
View File
@@ -1,7 +1,5 @@
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Nexus.Api.Data;
namespace Nexus.Api.Integrations;
@@ -32,39 +30,6 @@ public sealed class OpenClawRuntime(HttpClient client, IConfiguration configurat
}
}
public async Task<AgentChatResult> ChatAsync(
string message,
string conversationId,
string agentId,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/chat/completions");
ApplyAuthorization(request);
request.Content = JsonContent.Create(new
{
model = $"openclaw/{agentId}",
messages = new[] { new { role = "user", content = message } },
user = conversationId,
stream = false
});
using var response = await client.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"OpenClaw chat returned HTTP {(int)response.StatusCode}: {body}");
using var document = JsonDocument.Parse(body);
var content = document.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString();
if (string.IsNullOrWhiteSpace(content))
throw new InvalidOperationException("OpenClaw returned an empty assistant response.");
return new(Name, agentId, conversationId, content);
}
private void ApplyAuthorization(HttpRequestMessage request)
{
var credential = configuration["Integrations:OpenClaw:Password"]
-12
View File
@@ -6,23 +6,11 @@ namespace Nexus.Api.Middleware;
/// Middleware that authenticates requests via the X-Nexus-Api-Key header.
/// On match, sets a ClaimsPrincipal with role "Service".
/// On mismatch or absent header, passes through to next middleware (JWT auth).
///
/// The MCP endpoint (/mcp) is intentionally skipped — the MCP SDK handles its own
/// authentication via X-Agent-Id + X-Nexus-Api-Key headers through NexusMcpTools.
/// </summary>
public sealed class ApiKeyMiddleware(RequestDelegate next)
{
private static readonly PathString McpPath = new("/mcp");
public async Task InvokeAsync(HttpContext context)
{
// MCP endpoint handles its own auth — skip ApiKey interference
if (context.Request.Path.StartsWithSegments(McpPath))
{
await next(context);
return;
}
var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
var apiKey = configuration["NexusApiKey"];
+16
View File
@@ -0,0 +1,16 @@
namespace Nexus.Api.Models;
public sealed record ActivityItemDto(
long Id,
string Type,
string Message,
DateTimeOffset At,
EntityRefDto? Entity,
OperationResultDto? Operation = null);
public sealed record ActivityPageDto(
IReadOnlyList<ActivityItemDto> Items,
int TotalCount,
int Page,
int PageSize,
int TotalPages);
+95
View File
@@ -0,0 +1,95 @@
namespace Nexus.Api.Models;
public sealed record CreateAgentProposalRequest(
string Name,
string? Role = null,
string? Description = null,
string? Model = null,
string? Emoji = null,
string? Avatar = null,
IReadOnlyDictionary<string, string>? Files = null,
string? ClientRequestId = null);
public sealed record AgentProposalActionRequest(
int ExpectedRevision,
string? Reason = null);
public sealed record AgentProposalFileDto(
string Name,
string ContentHash,
int Size,
string? Content = null);
public sealed record AgentProposalErrorDto(
string Code,
string Message,
string? Recovery = null);
public sealed record AgentProposalDto(
Guid Id,
string Source,
string RequestedName,
string RequestedAgentId,
string? Role,
string? Description,
string? Model,
string? Emoji,
string? Avatar,
string Workspace,
IReadOnlyList<AgentProposalFileDto> Files,
string Status,
string RequestedBy,
string? ApprovedBy,
string? RejectedBy,
string? RejectionReason,
string? OpenClawAgentId,
string? OpenClawWorkspace,
AgentProposalErrorDto? Error,
int Revision,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? ApprovedAt,
DateTimeOffset? RejectedAt,
DateTimeOffset? CompletedAt);
public sealed record AgentProposalCollectionDto(
IReadOnlyList<AgentProposalDto> Items,
string? NextCursor,
DateTimeOffset CheckedAt);
public sealed record AgentProposalOperationDto(
bool Ok,
string State,
string Message,
AgentProposalDto? Proposal,
string? Recovery,
string CorrelationId,
DateTimeOffset CompletedAt,
OperationResultDto? Operation = null);
public sealed record AgentCreateModelOptionDto(
string Id,
string Name,
string Provider,
bool Available);
public sealed record AgentCreateOptionsDto(
bool CanSubmitProposal,
bool CanProvision,
string State,
string? Reason,
string WorkspaceRoot,
IReadOnlyList<string> ExistingAgentIds,
IReadOnlyList<AgentCreateModelOptionDto> Models,
IReadOnlyList<string> StandardFiles,
DateTimeOffset CheckedAt);
/// <summary>
/// Structured MCP result. Proposal markdown is intentionally omitted.
/// </summary>
public sealed record AgentProposalToolResult(
bool Ok,
string State,
string Message,
AgentProposalDto? Proposal,
string? Recovery);
+11
View File
@@ -0,0 +1,11 @@
namespace Nexus.Api.Models;
public sealed record BrowserMetricRequest(
string Name,
double Value,
string Rating,
string RouteName,
string BuildVersion,
string NavigationType,
string LiveMode,
string? CorrelationId);
+16 -5
View File
@@ -9,7 +9,7 @@ public sealed record DashboardAgentInfo(
string? CurrentTask,
string? Description,
string[] Tags,
int Progress = 0,
double? Progress = null,
int Workload = 0,
string? Goal = null,
string RoleBadge = "badge-slate",
@@ -18,7 +18,10 @@ public sealed record DashboardAgentInfo(
string? StatusDetail = null,
string? Elapsed = null,
string? Think = null,
string? Next = null
string? Next = null,
long? TotalTokens = null,
decimal? CostUsd = null,
DateTimeOffset? TelemetryAt = null
);
public sealed record MessageEntry(
@@ -99,7 +102,9 @@ public sealed record DashboardTaskDto(
List<DashboardTaskDto>? ChildTasks = null,
int ChildTaskCount = 0,
int OpenChildTaskCount = 0,
bool HasVisibleDelegation = false
bool HasVisibleDelegation = false,
Guid? ProjectId = null,
OperationResultDto? Operation = null
);
public sealed record CreateDashboardTaskRequest(
@@ -175,7 +180,8 @@ public sealed record ResetStaleRequest(
);
public sealed record ResetStaleResponse(
int ResetCount
int ResetCount,
OperationResultDto? Operation = null
);
public sealed record PostActivityRequest(
@@ -201,9 +207,14 @@ public sealed record AgentWorkflowOverview(
public sealed record NotificationDto(
Guid Id, string Type, string Title, string? Message,
string ForUser, Guid? TaskId, bool IsRead, DateTimeOffset CreatedAt
string ForUser, Guid? TaskId, bool IsRead, DateTimeOffset CreatedAt,
OperationResultDto? Operation = null
);
public sealed record NotificationReadAllResultDto(
int Marked,
OperationResultDto Operation);
public sealed record UnreadCountDto(int Count);
public sealed record LiveUpdateEnvelope(
+38
View File
@@ -0,0 +1,38 @@
using System.Text.Json;
namespace Nexus.Api.Models;
/// <summary>
/// Transport-safe reference to a Nexus or OpenClaw-owned entity. Routing stays
/// a frontend concern, so this contract deliberately contains no URL.
/// </summary>
public sealed record EntityRefDto(
string Type,
string Id,
string? Label = null);
public sealed record OperationResultDto(
string OperationId,
string Status,
int Revision,
EntityRefDto? PrimaryRef,
IReadOnlyList<EntityRefDto> AffectedRefs,
string? TraceId);
/// <summary>
/// Content-minimized event sent to authenticated Mission Control clients.
/// Payloads may carry state and correlation metadata, but never prompts,
/// credentials, markdown content, tool arguments, or entity names.
/// </summary>
public sealed record DomainEventDto(
long Sequence,
string EventType,
EntityRefDto Entity,
int EntityRevision,
DateTimeOffset OccurredAt,
JsonElement Payload);
public static class DomainEventTypes
{
public const string ResyncRequired = "resync_required";
}
@@ -0,0 +1,119 @@
using System.Text.Json.Nodes;
namespace Nexus.Api.Models;
public sealed record OpenClawAgentFileSummaryDto(
string Name,
bool Missing,
long? Size,
DateTimeOffset? UpdatedAt,
string? ContentHash);
public sealed record OpenClawAgentFileCollectionDto(
string AgentId,
IReadOnlyList<OpenClawAgentFileSummaryDto> Files,
DateTimeOffset CheckedAt);
public sealed record OpenClawAgentFileDto(
string AgentId,
string Name,
bool Missing,
long? Size,
DateTimeOffset? UpdatedAt,
string? Content,
string ContentHash,
DateTimeOffset CheckedAt);
public sealed record UpdateOpenClawAgentFileRequest(
string Content,
string ExpectedHash);
public sealed record OpenClawAgentFileWriteDto(
bool Ok,
string State,
string Message,
OpenClawAgentFileDto File,
bool Verified,
string IdempotencyKey,
string CorrelationId,
DateTimeOffset CompletedAt,
OperationResultDto? Operation = null);
public sealed record OpenClawWorkspaceEntryDto(
string Path,
string Name,
string Kind,
long? Size,
DateTimeOffset? UpdatedAt);
public sealed record OpenClawWorkspaceCollectionDto(
string AgentId,
string Path,
string? ParentPath,
IReadOnlyList<OpenClawWorkspaceEntryDto> Entries,
int TotalEntries,
int Offset,
DateTimeOffset CheckedAt);
public sealed record OpenClawWorkspaceFileDto(
string AgentId,
string Path,
string Name,
long Size,
DateTimeOffset? UpdatedAt,
string MimeType,
string Encoding,
string Content,
string ContentHash,
DateTimeOffset CheckedAt);
public sealed record OpenClawConfigSchemaChildDto(
string Key,
string Path,
JsonNode? Type,
bool Required,
bool HasChildren,
string? ReloadKind,
JsonNode? Hint);
public sealed record OpenClawConfigSchemaLookupDto(
string Path,
JsonNode? Schema,
string? ReloadKind,
JsonNode? Hint,
IReadOnlyList<OpenClawConfigSchemaChildDto> Children,
DateTimeOffset CheckedAt);
public sealed record OpenClawConfigSnapshotDto(
bool Exists,
bool Valid,
string? Hash,
JsonNode? Config,
JsonNode? Issues,
JsonNode? Warnings,
DateTimeOffset CheckedAt);
public sealed record PatchOpenClawConfigRequest(
JsonNode Patch,
string BaseHash,
IReadOnlyList<string>? ReplacePaths = null,
string? Note = null,
int? RestartDelayMs = null);
public sealed record OpenClawConfigPatchDto(
bool Ok,
string State,
string Message,
OpenClawConfigSnapshotDto Snapshot,
JsonNode? Restart,
bool Verified,
string IdempotencyKey,
string CorrelationId,
DateTimeOffset CompletedAt,
OperationResultDto? Operation = null);
public sealed record OpenClawAgentConfigurationErrorDto(
string Code,
string Message,
string? RequiredMethod = null,
string? RequiredScope = null);
+362
View File
@@ -0,0 +1,362 @@
using System.Text.Json.Nodes;
namespace Nexus.Api.Models;
public sealed record OpenClawConnectionDto(
string State,
bool Configured,
bool CredentialConfigured,
bool Connected,
string Endpoint,
string? GatewayVersion,
string? RequiredVersion,
bool VersionPinned,
bool VersionMatches,
int? ProtocolVersion,
IReadOnlyList<string> GrantedScopes,
IReadOnlyList<string> AdvertisedEvents,
DateTimeOffset? LastConnectedAt,
DateTimeOffset? LastEventAt,
int ReconnectAttempts,
string? Message,
string? Recovery,
DateTimeOffset CheckedAt,
string? DeviceId = null,
bool PairingRequired = false,
string? PairingRequestId = null);
public sealed record OpenClawCapabilityDto(
string Id,
string Label,
string Method,
string RequiredScope,
bool Available,
string State,
string? Reason);
public sealed record OpenClawCollectionDto<T>(
string State,
IReadOnlyList<T> Items,
string? NextCursor,
string? Message,
string? Recovery,
DateTimeOffset CheckedAt);
public sealed record OpenClawOperationDto<T>(
bool Ok,
string State,
string Message,
T? Data,
string? Recovery,
DateTimeOffset CompletedAt,
string? OperationId = null,
string? CorrelationId = null,
string? IdempotencyKey = null,
string? TraceParent = null,
string? Actor = null,
OperationResultDto? Operation = null);
public sealed record OpenClawTaskDto(
string Id,
string Title,
string Status,
string? Kind,
string? Runtime,
string? AgentId,
string? SessionKey,
string? RunId,
string? FlowId,
string? ParentTaskId,
DateTimeOffset? CreatedAt,
DateTimeOffset? StartedAt,
DateTimeOffset? UpdatedAt,
DateTimeOffset? FinishedAt,
double? Progress,
string? Summary,
string? Error,
bool CanCancel);
public sealed record OpenClawSessionDto(
string Key,
string? SessionId,
string AgentId,
string Title,
string Status,
string? Kind,
string? Channel,
string? Model,
string? Provider,
string? RunId,
DateTimeOffset? UpdatedAt,
long? InputTokens,
long? OutputTokens,
long? TotalTokens,
bool CanAbort);
public sealed record OpenClawCronJobDto(
string Id,
string Name,
string? Description,
string Schedule,
string? TimeZone,
bool Enabled,
string Status,
string? AgentId,
string? SessionKey,
DateTimeOffset? NextRunAt,
DateTimeOffset? LastRunAt,
string? LastRunStatus,
string? LastError,
bool CanRun,
string? ResourceHash = null);
public sealed record OpenClawCronScheduleDto(
string Kind,
string? Expression,
string? TimeZone,
string? At,
long? EveryMs,
long? AnchorMs,
long? StaggerMs,
string? Command,
string? WorkingDirectory);
public sealed record OpenClawCronPayloadDto(
string Kind,
string? Text,
string? Message,
string? Model,
IReadOnlyList<string> Fallbacks,
string? Thinking,
double? TimeoutSeconds,
bool? AllowUnsafeExternalContent,
bool? LightContext,
IReadOnlyList<string> ToolsAllow,
IReadOnlyList<string> Arguments,
string? WorkingDirectory,
IReadOnlyList<string> EnvironmentKeys,
bool InputConfigured,
double? NoOutputTimeoutSeconds,
int? OutputMaxBytes);
public sealed record OpenClawCronDestinationDto(
string? Channel,
string? Target,
string? AccountId,
string? Mode);
public sealed record OpenClawCronDeliveryDto(
string Mode,
string? Channel,
string? Target,
string? ThreadId,
string? AccountId,
bool? BestEffort,
OpenClawCronDestinationDto? CompletionDestination,
OpenClawCronDestinationDto? FailureDestination);
public sealed record OpenClawCronTriggerDto(
string Script,
bool Once);
public sealed record OpenClawCronFailureAlertDto(
int? After,
string? Channel,
string? Target,
long? CooldownMs,
bool? IncludeSkipped,
string? Mode,
string? AccountId);
public sealed record OpenClawCronJobDetailDto(
string Id,
string Name,
string? DisplayName,
string? Description,
bool Enabled,
bool DeleteAfterRun,
string? AgentId,
string? SessionKey,
string SessionTarget,
string WakeMode,
OpenClawCronScheduleDto Schedule,
OpenClawCronPayloadDto Payload,
OpenClawCronDeliveryDto? Delivery,
OpenClawCronTriggerDto? Trigger,
OpenClawCronFailureAlertDto? FailureAlert,
DateTimeOffset? CreatedAt,
DateTimeOffset? UpdatedAt,
DateTimeOffset? NextRunAt,
DateTimeOffset? LastRunAt,
string? LastRunStatus,
string? LastError,
string ResourceHash,
bool CanUpdate,
bool CanDelete,
bool CanRun);
public sealed record OpenClawCronRunDto(
string Id,
string JobId,
string? JobName,
string? RunId,
string Status,
string Action,
string? Summary,
string? Error,
string? ErrorReason,
string? DeliveryStatus,
string? DeliveryError,
bool? Delivered,
bool? TriggerFired,
string? DiagnosticsSummary,
IReadOnlyList<OpenClawCronRunDiagnosticDto> Diagnostics,
string? SessionId,
string? SessionKey,
DateTimeOffset? OccurredAt,
DateTimeOffset? RunAt,
long? DurationMs,
DateTimeOffset? NextRunAt,
string? Model,
string? Provider,
long? InputTokens,
long? OutputTokens,
long? TotalTokens);
public sealed record OpenClawCronRunDiagnosticDto(
DateTimeOffset? OccurredAt,
string Source,
string Severity,
string Message,
string? ToolName,
double? ExitCode,
bool Truncated);
public sealed record CreateOpenClawCronJobRequest(
string Name,
JsonObject Schedule,
string SessionTarget,
string WakeMode,
JsonObject Payload,
string? Description = null,
bool Enabled = true,
string? AgentId = null,
string? SessionKey = null,
bool? DeleteAfterRun = null,
JsonObject? Delivery = null,
JsonObject? Trigger = null,
JsonNode? FailureAlert = null,
string? DeclarationKey = null,
string? DisplayName = null);
public sealed record PatchOpenClawCronJobRequest(
JsonObject Patch,
string? ExpectedHash = null);
public sealed record OpenClawActivityDto(
string Id,
string EventType,
string Kind,
string Action,
string Status,
string Message,
string? Severity,
string? Actor,
string? AgentId,
string? SessionKey,
string? RunId,
DateTimeOffset? OccurredAt,
string Source);
public sealed record OpenClawApprovalDto(
string Id,
string Kind,
string Title,
string? Description,
string Status,
string Severity,
string? Command,
string? WorkingDirectory,
string? AgentId,
string? SessionKey,
DateTimeOffset? RequestedAt,
DateTimeOffset? ExpiresAt,
IReadOnlyList<string> AllowedDecisions,
bool CanResolve);
public sealed record OpenClawModelDto(
string Id,
string Name,
string Provider,
bool Configured,
bool Available,
int? ContextWindow,
string? Reason);
public sealed record OpenClawModelAuthExpiryDto(
DateTimeOffset At,
long RemainingMs,
string Label);
public sealed record OpenClawModelAuthProfileSummaryDto(
string Type,
string Status,
int Count);
public sealed record OpenClawModelAuthApiKeyDto(
string Source,
string? EnvVar);
public sealed record OpenClawModelAuthUsageDto(
string? Summary,
string? Plan);
/// <summary>
/// Browser-safe projection of OpenClaw models.authStatus.
/// Profile identifiers, account identities, billing details and credentials are
/// deliberately absent from this public contract.
/// </summary>
public sealed record OpenClawModelAuthProviderDto(
string Provider,
string DisplayName,
string Status,
OpenClawModelAuthExpiryDto? Expiry,
IReadOnlyList<OpenClawModelAuthProfileSummaryDto> Profiles,
OpenClawModelAuthApiKeyDto? ApiKey,
OpenClawModelAuthUsageDto? Usage);
public sealed record OpenClawAgentDto(
string Id,
string Name,
string? Description,
string? Model,
string? Provider,
string? Workspace,
string Status);
public sealed record OpenClawOverviewDto(
OpenClawConnectionDto Connection,
IReadOnlyList<OpenClawCapabilityDto> Capabilities,
OpenClawCollectionDto<OpenClawTaskDto> Tasks,
OpenClawCollectionDto<OpenClawSessionDto> Sessions,
OpenClawCollectionDto<OpenClawCronJobDto> CronJobs,
OpenClawCollectionDto<OpenClawApprovalDto> Approvals,
OpenClawCollectionDto<OpenClawActivityDto> Activity,
OpenClawCollectionDto<OpenClawModelDto> Models,
OpenClawCollectionDto<OpenClawAgentDto> Agents,
DateTimeOffset GeneratedAt);
public sealed record CancelOpenClawTaskRequest(string? Reason);
public sealed record AbortOpenClawSessionRequest(
string SessionKey,
string? RunId = null,
bool ClearQueued = true);
public sealed record ResolveOpenClawApprovalRequest(
string Kind,
string Decision);
public sealed record PatchOpenClawSessionModelRequest(
string SessionKey,
string Model);
+26
View File
@@ -0,0 +1,26 @@
using System.Text.Json.Nodes;
namespace Nexus.Api.Models;
public sealed record OpenClawStreamEventDto(
string Id,
string Type,
string EventName,
string Category,
long? Sequence,
long? StateVersion,
long? PreviousSequence,
bool SequenceGapDetected,
bool SequenceResetDetected,
long? MissingSequenceFrom,
long? MissingSequenceTo,
DateTimeOffset OccurredAt,
JsonNode? Payload);
public sealed record OpenClawEventBatch(
IReadOnlyList<OpenClawStreamEventDto> Events,
string? Cursor,
bool ReplayBoundaryMissed,
string? OldestAvailableId,
string? LatestAvailableId,
DateTimeOffset ProjectedAt);
+95
View File
@@ -0,0 +1,95 @@
using System.Text.Json.Nodes;
namespace Nexus.Api.Models;
public sealed record StartOpenClawRunRequest(
string Prompt,
string AgentId,
string SessionKey,
string? Title = null,
Guid? TaskId = null,
Guid? ProjectId = null);
public sealed record OpenClawRunActionRequest(string? Reason = null);
/// <summary>
/// Trusted invocation context assembled by the Nexus HTTP boundary. Actor is
/// derived from the authenticated principal and is never accepted from JSON.
/// </summary>
public sealed record OpenClawInvocationMetadata(
string IdempotencyKey,
string CorrelationId,
string Actor,
string? TraceParent);
public sealed record OpenClawRunDto(
Guid Id,
string Title,
string Prompt,
string AgentId,
string SessionKey,
string Status,
Guid? TaskId,
Guid? ProjectId,
string? OpenClawRunId,
Guid? RetriedFromRunId,
string CorrelationId,
string Actor,
string? LastError,
long? LastGatewaySequence,
bool SequenceGapDetected,
bool CanStop,
bool CanRetry,
bool CanResume,
string? ResumeCapabilityMessage,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
DateTimeOffset? StartedAt,
DateTimeOffset? FinishedAt);
public sealed record OpenClawRunHistoryDto(
long Id,
Guid RunId,
string Action,
string FromStatus,
string ToStatus,
string Message,
string Actor,
string CorrelationId,
string? IdempotencyKey,
string? TraceParent,
string? GatewayEventId,
long? GatewaySequence,
bool SequenceGapDetected,
Guid? ResultRunId,
DateTimeOffset OccurredAt);
public sealed record OpenClawRunCollectionDto(
IReadOnlyList<OpenClawRunDto> Items,
string? NextCursor,
DateTimeOffset CheckedAt);
public sealed record OpenClawRunOperationDto(
bool Ok,
string State,
string Message,
OpenClawRunDto Run,
OpenClawRunDto? ResultRun,
DateTimeOffset CompletedAt,
OperationResultDto? Operation = null);
public sealed record OpenClawRunHistoryResponse(
OpenClawRunDto Run,
IReadOnlyList<OpenClawRunHistoryDto> Transitions,
string GatewayHistoryState,
JsonNode? GatewayHistory,
string? Message,
DateTimeOffset CheckedAt);
public sealed record OpenClawRunQuery(
int Limit = 50,
string? Cursor = null,
string? Status = null,
Guid? TaskId = null,
Guid? ProjectId = null,
string? SessionKey = null);
+154
View File
@@ -0,0 +1,154 @@
namespace Nexus.Api.Models;
/// <summary>
/// Public, secret-free representation of Nexus' single OpenClaw connection profile.
/// OpenClaw remains the authority for agents, configuration and scheduled jobs.
/// </summary>
public sealed record OpenClawSetupStatusDto(
string ProfileId,
string State,
bool ExperimentalBlocked,
bool HasProfile,
string? Endpoint,
string? DiscoverySource,
string AdoptionState,
bool ManagementEnabled,
string? RequiredVersion,
string? GatewayVersion,
int? ProtocolVersion,
string? DeviceId,
bool DeviceTokenConfigured,
bool PairingRequired,
string? PairingRequestId,
IReadOnlyList<string> GrantedScopes,
IReadOnlyList<string> AdvertisedMethods,
string? CapabilityHash,
int? Revision,
DateTimeOffset? LastProbedAt,
DateTimeOffset? LastVerifiedAt,
DateTimeOffset? AdoptedAt,
DateTimeOffset? UpdatedAt,
string? Message,
string? Recovery,
DateTimeOffset CheckedAt);
public sealed record OpenClawDiscoveryRequest(bool IncludeMdns = false);
public sealed record OpenClawDiscoveryCandidateDto(
string Endpoint,
string Source,
bool IsCurrentConnectorEndpoint,
bool RequiresTlsFingerprint,
bool IsValid,
string? Reason);
public sealed record OpenClawDiscoveryDto(
IReadOnlyList<OpenClawDiscoveryCandidateDto> Candidates,
string MdnsState,
string? Message,
DateTimeOffset CheckedAt);
public sealed record ProbeOpenClawRequest(
string Endpoint,
string? TlsCertificateFingerprint = null)
{
public override string ToString()
=> $"{nameof(ProbeOpenClawRequest)} {{ EndpointSupplied = {!string.IsNullOrWhiteSpace(Endpoint)}, TlsFingerprintSupplied = {!string.IsNullOrWhiteSpace(TlsCertificateFingerprint)} }}";
}
public sealed record OpenClawProbeDto(
string Endpoint,
string Source,
bool IsCurrentConnectorEndpoint,
bool Connected,
string? GatewayVersion,
string? RequiredVersion,
bool VersionMatches,
int? ProtocolVersion,
string? DeviceId,
bool PairingRequired,
string? PairingRequestId,
IReadOnlyList<string> GrantedScopes,
IReadOnlyList<string> AdvertisedMethods,
string CapabilityHash,
bool CanAttach,
bool LeastPrivilegeSatisfied,
DateTimeOffset CheckedAt);
/// <summary>
/// Bootstrap credentials are deliberately request-only. The setup profile and all
/// response contracts contain no corresponding field, so they cannot be persisted
/// or returned to the browser.
/// </summary>
public sealed record AttachOpenClawRequest(
string Endpoint,
string DiscoverySource,
string? TlsCertificateFingerprint = null,
string? BootstrapToken = null,
string? BootstrapSecretReference = null)
{
public override string ToString()
=> $"{nameof(AttachOpenClawRequest)} {{ EndpointSupplied = {!string.IsNullOrWhiteSpace(Endpoint)}, DiscoverySourceSupplied = {!string.IsNullOrWhiteSpace(DiscoverySource)}, TlsFingerprintSupplied = {!string.IsNullOrWhiteSpace(TlsCertificateFingerprint)}, BootstrapCredentialSupplied = {!string.IsNullOrWhiteSpace(BootstrapToken) || !string.IsNullOrWhiteSpace(BootstrapSecretReference)} }}";
}
public sealed record VerifyOpenClawRequest(int ExpectedRevision);
public sealed record AdoptOpenClawRequest(int ExpectedRevision);
public sealed record SetOpenClawManagementRequest(
bool Enabled,
bool Confirmed,
int ExpectedRevision);
public sealed record DeleteOpenClawConnectionRequest(
string Endpoint,
string? DeviceId,
int ExpectedRevision);
public sealed record OpenClawAdoptionInventoryDto(
int? AgentCount,
int? AgentFileCount,
int? CronJobCount,
int? ModelCount,
int? ChannelCount,
int? NodeCount,
IReadOnlyList<string> Diagnostics,
DateTimeOffset CapturedAt);
public sealed record OpenClawSetupOperationDto<T>(
bool Ok,
string State,
string Message,
T? Data,
string? Recovery,
DateTimeOffset CompletedAt);
public static class OpenClawSetupStates
{
public const string NotConfigured = "not_configured";
public const string Discovered = "discovered";
public const string Probed = "probed";
public const string Attached = "attached";
public const string Verified = "verified";
public const string Adopted = "adopted";
public const string Disconnected = "disconnected";
public const string PairingRequired = "pairing_required";
public const string ScopeUpgradeRequired = "scope_upgrade_required";
public const string ExcessiveScope = "excessive_scope";
public const string ExperimentalBlocked = "experimental_blocked";
public const string DynamicEndpointUnsupported = "dynamic_endpoint_unsupported";
public const string InvalidEndpoint = "invalid_endpoint";
public const string InvalidRequest = "invalid_request";
public const string ConcurrencyConflict = "concurrency_conflict";
public const string NotFound = "not_found";
public const string GatewayUnavailable = "gateway_unavailable";
public const string Removed = "removed";
}
public static class OpenClawAdoptionStates
{
public const string None = "none";
public const string Attached = "attached";
public const string Verified = "verified";
public const string Adopted = "adopted";
}
+50
View File
@@ -0,0 +1,50 @@
using System.Text.Json.Nodes;
namespace Nexus.Api.Models;
public sealed record StartOpenClawWizardRequest(
string Mode = "local",
bool Confirmed = false);
public sealed record AdvanceOpenClawWizardRequest(
string SessionId,
string? StepId = null,
JsonNode? Value = null,
bool HasAnswer = true);
public sealed record OpenClawWizardOptionDto(
JsonNode? Value,
string Label,
string? Hint);
public sealed record OpenClawWizardDeviceCodeDto(
string Code,
int? ExpiresInMinutes,
string? Message);
public sealed record OpenClawWizardStepDto(
string Id,
string Type,
string? Title,
string? Message,
IReadOnlyList<OpenClawWizardOptionDto> Options,
JsonNode? InitialValue,
string? Placeholder,
bool Sensitive,
string? Executor,
string? ExternalUrl,
OpenClawWizardDeviceCodeDto? DeviceCode,
bool CanAnswer,
string? BlockedReason);
public sealed record OpenClawWizardResultDto(
bool Ok,
string State,
string Message,
string? SessionId,
bool Done,
string? Status,
string? Error,
OpenClawWizardStepDto? Step,
string? Recovery,
DateTimeOffset CompletedAt);
+25
View File
@@ -0,0 +1,25 @@
namespace Nexus.Api.Models;
/// <summary>
/// Transport contract for a Nexus-owned mission scope. Persistence entities
/// stay behind the API boundary so OpenAPI remains the frontend authority.
/// </summary>
public sealed record ProjectDto(
Guid Id,
string Name,
string Description,
string Status,
int Progress,
DateTimeOffset UpdatedAt,
OperationResultDto? Operation = null);
public sealed record ProjectTaskDto(
Guid Id,
string Title,
string State,
string Priority,
Guid ProjectId,
string? AssignedTo,
string? ExpectedFrom,
bool IsAgentTask,
DateTimeOffset UpdatedAt);
+22
View File
@@ -0,0 +1,22 @@
namespace Nexus.Api.Models;
public sealed record SecurityTokenConfigDto(
string Issuer,
string Audience,
int RefreshTokenDays,
int AccessTokenMinutes);
public sealed record SecurityCookieConfigDto(
bool HttpOnly,
bool Secure,
string SameSite);
public sealed record SecurityStatusDto(
string AuthMethod,
SecurityTokenConfigDto TokenConfig,
string RateLimit,
string PasswordPolicy,
SecurityCookieConfigDto CookieConfig,
bool TwoFactorEnabled,
bool PasskeyEnabled,
DateTimeOffset CheckedAt);
+41
View File
@@ -0,0 +1,41 @@
namespace Nexus.Api.Models;
/// <summary>
/// Compact task representation used by the paged mission-control board.
/// Child tasks are represented by their own cards and linked through
/// <see cref="ParentTaskId"/> so the client can build the hierarchy in O(n).
/// </summary>
public sealed record TaskBoardCardDto(
Guid Id,
string Title,
string? Detail,
string Source,
string State,
string Priority,
string? AssignedTo,
Guid? ParentTaskId,
Guid? ProjectId,
DateTimeOffset? DueDate,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
bool IsAgentTask,
string? ExpectedFrom,
string? LastActivityMessage,
DateTimeOffset? LastActivityAt,
int ChildTaskCount,
int OpenChildTaskCount,
bool HasVisibleDelegation);
/// <summary>
/// Initial task-board page. Active columns are complete; the Done column is
/// keyset-paginated by <c>(UpdatedAt DESC, Id DESC)</c>.
/// </summary>
public sealed record TaskBoardPageDto(
string Revision,
IReadOnlyList<TaskBoardCardDto> Offen,
IReadOnlyList<TaskBoardCardDto> InProgress,
IReadOnlyList<TaskBoardCardDto> Review,
IReadOnlyList<TaskBoardCardDto> Blocked,
IReadOnlyList<TaskBoardCardDto> Done,
string? NextDoneCursor,
bool HasMoreDone);
+16 -1
View File
@@ -3,15 +3,30 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiGenerateDocumentsOnBuild>true</OpenApiGenerateDocumentsOnBuild>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)\openapi</OpenApiDocumentsDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.8">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Npgsql.OpenTelemetry" Version="10.0.3" />
<PackageReference Include="NSec.Cryptography" Version="26.4.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
</ItemGroup>
+37
View File
@@ -0,0 +1,37 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
namespace Nexus.Api.Observability;
/// <summary>
/// Low-cardinality diagnostics for the Nexus control plane. Content, prompts,
/// tool arguments, entity names, credentials and caller-provided identifiers
/// must never be recorded here.
/// </summary>
public static class NexusTelemetry
{
public const string SourceName = "Nexus.Api";
public static readonly ActivitySource ActivitySource = new(SourceName);
public static readonly Meter Meter = new(SourceName);
public static readonly Histogram<double> BrowserDuration =
Meter.CreateHistogram<double>("nexus.browser.duration", unit: "ms");
public static readonly Histogram<double> BrowserScore =
Meter.CreateHistogram<double>("nexus.browser.score", unit: "1");
public static readonly Histogram<double> TaskBoardDuration =
Meter.CreateHistogram<double>("nexus.task.board.duration", unit: "ms");
public static readonly Histogram<long> TaskBoardPayload =
Meter.CreateHistogram<long>("nexus.task.board.payload", unit: "By");
public static readonly Counter<long> SseResyncs =
Meter.CreateCounter<long>("nexus.sse.resyncs");
public static readonly UpDownCounter<long> SseSubscribers =
Meter.CreateUpDownCounter<long>("nexus.sse.subscribers");
public static readonly Histogram<double> GatewayRpcDuration =
Meter.CreateHistogram<double>("nexus.gateway.rpc.duration", unit: "ms");
public static readonly UpDownCounter<long> OutboxBacklog =
Meter.CreateUpDownCounter<long>("nexus.outbox.backlog");
public static readonly Counter<long> OutboxPublished =
Meter.CreateCounter<long>("nexus.outbox.published");
public static readonly Histogram<double> AgentProvisionDuration =
Meter.CreateHistogram<double>("nexus.agent.provision.duration", unit: "ms");
}
@@ -0,0 +1,35 @@
using System.Diagnostics;
using OpenTelemetry;
namespace Nexus.Api.Observability;
/// <summary>
/// Removes content-bearing attributes before an activity reaches an exporter.
/// Standard HTTP exception recording is disabled separately so exception
/// events cannot contain messages or stack traces.
/// </summary>
public sealed class NexusTelemetryRedactionProcessor : BaseProcessor<Activity>
{
private static readonly HashSet<string> RedactedTagNames = new(
[
"url.query",
"url.full",
"url.path",
"http.url",
"http.target",
"exception.message",
"exception.stacktrace",
"db.statement",
"db.query.text"
],
StringComparer.OrdinalIgnoreCase);
public override void OnEnd(Activity activity)
{
foreach (var tag in activity.TagObjects.ToArray())
{
if (RedactedTagNames.Contains(tag.Key))
activity.SetTag(tag.Key, null);
}
}
}
+23 -4
View File
@@ -1,27 +1,46 @@
using Nexus.Api.Extensions;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
var isOpenApiGeneration =
Assembly.GetEntryAssembly()?.GetName().Name == "GetDocument.Insider";
if (isOpenApiGeneration)
{
// The build-time mock host never accepts traffic. It still constructs the
// auth services, so use an ephemeral contract-only signing value.
builder.Configuration["Jwt:Key"] =
"openapi-contract-generation-only-000000000000000000";
}
// --- Service Registration ---
builder.Services.AddNexusAuth(builder.Configuration);
builder.Services.AddNexusRateLimiting();
builder.Services.AddNexusForwardedHeaders();
builder.Services.AddNexusForwardedHeaders(builder.Configuration);
builder.Services.AddNexusSwagger();
builder.Services.AddNexusDatabase(builder.Configuration);
builder.Services.AddNexusHttpClients(builder.Configuration);
builder.Services.AddNexusApplicationServices();
builder.Services.AddNexusApplicationServices(
includeHostedServices: !isOpenApiGeneration);
builder.Services.AddNexusRepositories();
builder.Services.AddNexusHealthChecks(builder.Configuration);
builder.Services.AddNexusPlatform(builder.Configuration);
builder.Services.AddControllers();
var app = builder.Build();
// --- Database Migration & Seeding ---
await app.EnsureDatabaseAsync();
// Build-time OpenAPI extraction constructs the host without a database. The
// flag is supplied only by the project target; normal application starts keep
// the migration and seed gate mandatory.
if (!isOpenApiGeneration)
{
// --- Database Migration & Seeding ---
await app.EnsureDatabaseAsync();
}
// --- Middleware Pipeline ---
app.UseNexusPipeline(app.Environment);
app.MapMcp("/mcp");
app.MapOpenApi("/openapi/{documentName}.json");
app.MapControllers();
app.Run();
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Nexus.Api.Tests")]
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using System.Text.Json;
namespace Nexus.Api.Repositories;
@@ -58,6 +59,19 @@ public sealed class ActivityRepository(NexusDbContext db, Nexus.Api.Services.ILi
var agentIds = Nexus.Api.Services.AgentActivityText.ExtractAgentIds(activity.Message);
activity.Message = Nexus.Api.Services.AgentActivityText.RedactForDisplay(activity.Message);
db.Activity.Add(activity);
db.OutboxEvents.Add(new OutboxEvent
{
Type = "activity.created",
AggregateType = "activity",
AggregateId = activity.TaskId?.ToString() ?? Guid.NewGuid().ToString(),
AggregateRevision = 0,
PayloadJson = JsonSerializer.Serialize(new
{
type = activity.Type,
taskId = activity.TaskId,
agentIds
})
});
await db.SaveChangesAsync(ct);
liveUpdates.Publish("activity.created", new
{
@@ -0,0 +1,38 @@
using Nexus.Api.Data;
namespace Nexus.Api.Repositories;
public interface IOpenClawConnectionProfileRepository
{
Task<OpenClawConnectionProfile?> GetPrimaryAsync(
CancellationToken cancellationToken = default);
Task<OpenClawConnectionProfile> SavePrimaryAsync(
OpenClawConnectionProfile profile,
int? expectedRevision,
CancellationToken cancellationToken = default);
Task<bool> DeletePrimaryAsync(
int expectedRevision,
CancellationToken cancellationToken = default);
}
public sealed class OpenClawConnectionProfileConcurrencyException : Exception
{
public OpenClawConnectionProfileConcurrencyException(
string message,
int? currentRevision = null)
: base(message)
{
CurrentRevision = currentRevision;
}
public OpenClawConnectionProfileConcurrencyException(
string message,
Exception innerException)
: base(message, innerException)
{
}
public int? CurrentRevision { get; }
}
@@ -0,0 +1,43 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Repositories;
public interface IOpenClawRunRepository
{
Task<IReadOnlyList<OpenClawRun>> GetAsync(OpenClawRunQuery query, CancellationToken cancellationToken = default);
Task<OpenClawRun?> GetByIdAsync(Guid id, bool tracking = false, CancellationToken cancellationToken = default);
Task<OpenClawRun?> GetByStartIdempotencyKeyAsync(string idempotencyKey, CancellationToken cancellationToken = default);
Task<OpenClawRun?> GetByOpenClawRunIdAsync(string openClawRunId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OpenClawRunHistory>> GetHistoryAsync(Guid runId, CancellationToken cancellationToken = default);
Task<OpenClawRunHistory?> GetInvocationAsync(
Guid runId,
string action,
string idempotencyKey,
CancellationToken cancellationToken = default);
Task<bool> HasGatewayEventAsync(string gatewayEventId, CancellationToken cancellationToken = default);
Task<bool> TaskExistsAsync(Guid taskId, CancellationToken cancellationToken = default);
Task<bool> ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OpenClawRunSubscription>> GetActiveSubscriptionsAsync(
CancellationToken cancellationToken = default);
Task AddAsync(
OpenClawRun run,
OpenClawRunHistory history,
CancellationToken cancellationToken = default);
Task AddRetryAsync(
OpenClawRun source,
OpenClawRun retry,
OpenClawRunHistory sourceHistory,
OpenClawRunHistory retryHistory,
CancellationToken cancellationToken = default);
Task UpdateAsync(
OpenClawRun run,
OpenClawRunHistory history,
CancellationToken cancellationToken = default);
Task UpdateProjectionAsync(
OpenClawRun run,
OpenClawRunHistory? history = null,
CancellationToken cancellationToken = default);
}
public sealed record OpenClawRunSubscription(string SessionKey, string AgentId);
@@ -10,4 +10,7 @@ public interface IProjectRepository
Task UpdateAsync(Project project, CancellationToken ct = default);
Task DeleteAsync(Project project, CancellationToken ct = default);
Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default);
Task<List<WorkTask>> GetTasksAsync(
Guid projectId,
CancellationToken ct = default);
}
+19
View File
@@ -1,4 +1,5 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Repositories;
@@ -14,4 +15,22 @@ public interface ITaskRepository
Task<int> CountAsync(CancellationToken ct = default);
Task<int> CountByStateAsync(string state, CancellationToken ct = default);
Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default);
Task<TaskBoardQueryPage> GetBoardPageAsync(
int doneLimit,
DateTimeOffset? doneBeforeUpdatedAt,
Guid? doneBeforeId,
CancellationToken ct = default);
Task<TaskBoardCardDto?> GetBoardCardAsync(
Guid id,
CancellationToken ct = default);
}
public sealed record TaskBoardQueryPage(
IReadOnlyList<TaskBoardCardDto> ActiveTasks,
IReadOnlyList<TaskBoardCardDto> DoneTasks,
bool HasMoreDone,
TaskBoardRevisionPoint? Revision);
public sealed record TaskBoardRevisionPoint(
DateTimeOffset UpdatedAt,
Guid Id);
@@ -0,0 +1,134 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
namespace Nexus.Api.Repositories;
public sealed class OpenClawConnectionProfileRepository(NexusDbContext db)
: IOpenClawConnectionProfileRepository
{
public Task<OpenClawConnectionProfile?> GetPrimaryAsync(
CancellationToken cancellationToken = default)
=> db.Set<OpenClawConnectionProfile>()
.AsNoTracking()
.SingleOrDefaultAsync(
profile => profile.ProfileId == OpenClawConnectionProfile.PrimaryProfileId,
cancellationToken);
public async Task<OpenClawConnectionProfile> SavePrimaryAsync(
OpenClawConnectionProfile profile,
int? expectedRevision,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
if (!string.Equals(
profile.ProfileId,
OpenClawConnectionProfile.PrimaryProfileId,
StringComparison.Ordinal))
{
throw new ArgumentException("Only the primary OpenClaw profile is supported.", nameof(profile));
}
var profiles = db.Set<OpenClawConnectionProfile>();
var current = await profiles.SingleOrDefaultAsync(
item => item.ProfileId == OpenClawConnectionProfile.PrimaryProfileId,
cancellationToken);
if (current is null)
{
if (expectedRevision is not null and not 0)
{
throw new OpenClawConnectionProfileConcurrencyException(
"The OpenClaw connection profile no longer exists.");
}
profile.Revision = 1;
profile.CreatedAt = profile.CreatedAt == default
? DateTimeOffset.UtcNow
: profile.CreatedAt;
profile.UpdatedAt = DateTimeOffset.UtcNow;
profiles.Add(profile);
await SaveChangesAsync(cancellationToken);
return Clone(profile);
}
if (expectedRevision is null || expectedRevision.Value != current.Revision)
{
throw new OpenClawConnectionProfileConcurrencyException(
"The OpenClaw connection profile changed since it was loaded.",
current.Revision);
}
current.Endpoint = profile.Endpoint;
current.DiscoverySource = profile.DiscoverySource;
current.RequiredVersion = profile.RequiredVersion;
current.TlsCertificateFingerprint = profile.TlsCertificateFingerprint;
current.AdoptionState = profile.AdoptionState;
current.ManagementEnabled = profile.ManagementEnabled;
current.CapabilityHash = profile.CapabilityHash;
current.DeviceId = profile.DeviceId;
current.LastProbedAt = profile.LastProbedAt;
current.LastVerifiedAt = profile.LastVerifiedAt;
current.AdoptedAt = profile.AdoptedAt;
current.UpdatedAt = DateTimeOffset.UtcNow;
current.Revision++;
await SaveChangesAsync(cancellationToken);
return Clone(current);
}
public async Task<bool> DeletePrimaryAsync(
int expectedRevision,
CancellationToken cancellationToken = default)
{
var profiles = db.Set<OpenClawConnectionProfile>();
var current = await profiles.SingleOrDefaultAsync(
item => item.ProfileId == OpenClawConnectionProfile.PrimaryProfileId,
cancellationToken);
if (current is null)
return false;
if (current.Revision != expectedRevision)
{
throw new OpenClawConnectionProfileConcurrencyException(
"The OpenClaw connection profile changed since it was loaded.",
current.Revision);
}
profiles.Remove(current);
await SaveChangesAsync(cancellationToken);
return true;
}
private async Task SaveChangesAsync(CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException exception)
{
throw new OpenClawConnectionProfileConcurrencyException(
"The OpenClaw connection profile changed concurrently.",
innerException: exception);
}
}
private static OpenClawConnectionProfile Clone(OpenClawConnectionProfile profile)
=> new()
{
ProfileId = profile.ProfileId,
Endpoint = profile.Endpoint,
DiscoverySource = profile.DiscoverySource,
RequiredVersion = profile.RequiredVersion,
TlsCertificateFingerprint = profile.TlsCertificateFingerprint,
AdoptionState = profile.AdoptionState,
ManagementEnabled = profile.ManagementEnabled,
CapabilityHash = profile.CapabilityHash,
DeviceId = profile.DeviceId,
Revision = profile.Revision,
CreatedAt = profile.CreatedAt,
UpdatedAt = profile.UpdatedAt,
LastProbedAt = profile.LastProbedAt,
LastVerifiedAt = profile.LastVerifiedAt,
AdoptedAt = profile.AdoptedAt
};
}
@@ -0,0 +1,105 @@
using System.Globalization;
using System.Text;
namespace Nexus.Api.Repositories;
internal readonly record struct OpenClawRunCursorPosition(
DateTimeOffset CreatedAt,
Guid? Id);
internal static class OpenClawRunCursorCodec
{
private const string Version = "v1";
private const int MaximumEncodedLength = 128;
public static string Encode(DateTimeOffset createdAt, Guid id)
{
var payload = string.Create(
CultureInfo.InvariantCulture,
$"{Version}|{createdAt.UtcTicks}|{id:N}");
return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
public static bool TryDecode(
string? cursor,
out OpenClawRunCursorPosition position)
{
position = default;
if (string.IsNullOrWhiteSpace(cursor) || cursor.Length > MaximumEncodedLength)
return false;
// Compatibility with the original run cursor, which was an unversioned
// UTC tick value and therefore cannot express the Id tie-breaker.
if (long.TryParse(
cursor,
NumberStyles.None,
CultureInfo.InvariantCulture,
out var legacyTicks))
{
return TryCreatePosition(legacyTicks, null, out position);
}
try
{
var normalized = cursor
.Replace('-', '+')
.Replace('_', '/');
normalized = (normalized.Length % 4) switch
{
0 => normalized,
2 => normalized + "==",
3 => normalized + "=",
_ => throw new FormatException("Invalid Base64Url length.")
};
var payload = new UTF8Encoding(
encoderShouldEmitUTF8Identifier: false,
throwOnInvalidBytes: true)
.GetString(Convert.FromBase64String(normalized));
var parts = payload.Split('|');
if (parts.Length != 3
|| !string.Equals(parts[0], Version, StringComparison.Ordinal)
|| !long.TryParse(
parts[1],
NumberStyles.None,
CultureInfo.InvariantCulture,
out var utcTicks)
|| !Guid.TryParseExact(parts[2], "N", out var id))
{
return false;
}
return TryCreatePosition(utcTicks, id, out position);
}
catch (Exception exception) when (
exception is FormatException
or DecoderFallbackException)
{
return false;
}
}
private static bool TryCreatePosition(
long utcTicks,
Guid? id,
out OpenClawRunCursorPosition position)
{
position = default;
try
{
position = new OpenClawRunCursorPosition(
new DateTimeOffset(utcTicks, TimeSpan.Zero),
id);
return true;
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}
}
@@ -0,0 +1,202 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
using System.Text.Json;
namespace Nexus.Api.Repositories;
public sealed class OpenClawRunRepository(NexusDbContext db) : IOpenClawRunRepository
{
public async Task<IReadOnlyList<OpenClawRun>> GetAsync(
OpenClawRunQuery query,
CancellationToken cancellationToken = default)
{
var runs = db.OpenClawRuns.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(query.Status))
runs = runs.Where(run => run.Status == query.Status);
if (query.TaskId.HasValue)
runs = runs.Where(run => run.TaskId == query.TaskId);
if (query.ProjectId.HasValue)
runs = runs.Where(run => run.ProjectId == query.ProjectId);
if (!string.IsNullOrWhiteSpace(query.SessionKey))
runs = runs.Where(run => run.SessionKey == query.SessionKey);
if (OpenClawRunCursorCodec.TryDecode(query.Cursor, out var cursor))
{
var createdBefore = cursor.CreatedAt;
if (cursor.Id.HasValue)
{
var idBefore = cursor.Id.Value;
runs = runs.Where(run =>
run.CreatedAt < createdBefore
|| (run.CreatedAt == createdBefore
&& run.Id.CompareTo(idBefore) < 0));
}
else
{
runs = runs.Where(run => run.CreatedAt < createdBefore);
}
}
return await runs
.OrderByDescending(run => run.CreatedAt)
.ThenByDescending(run => run.Id)
.Take(Math.Clamp(query.Limit, 1, 201))
.ToListAsync(cancellationToken);
}
public Task<OpenClawRun?> GetByIdAsync(
Guid id,
bool tracking = false,
CancellationToken cancellationToken = default)
{
var query = tracking
? db.OpenClawRuns.AsQueryable()
: db.OpenClawRuns.AsNoTracking();
return query.FirstOrDefaultAsync(run => run.Id == id, cancellationToken);
}
public Task<OpenClawRun?> GetByStartIdempotencyKeyAsync(
string idempotencyKey,
CancellationToken cancellationToken = default)
=> db.OpenClawRuns
.AsNoTracking()
.FirstOrDefaultAsync(run => run.StartIdempotencyKey == idempotencyKey, cancellationToken);
public Task<OpenClawRun?> GetByOpenClawRunIdAsync(
string openClawRunId,
CancellationToken cancellationToken = default)
=> db.OpenClawRuns
.FirstOrDefaultAsync(run => run.OpenClawRunId == openClawRunId, cancellationToken);
public async Task<IReadOnlyList<OpenClawRunHistory>> GetHistoryAsync(
Guid runId,
CancellationToken cancellationToken = default)
=> await db.OpenClawRunHistory
.AsNoTracking()
.Where(item => item.RunId == runId)
.OrderBy(item => item.OccurredAt)
.ThenBy(item => item.Id)
.ToListAsync(cancellationToken);
public Task<OpenClawRunHistory?> GetInvocationAsync(
Guid runId,
string action,
string idempotencyKey,
CancellationToken cancellationToken = default)
=> db.OpenClawRunHistory
.AsNoTracking()
.FirstOrDefaultAsync(
item => item.RunId == runId
&& item.Action == action
&& item.IdempotencyKey == idempotencyKey,
cancellationToken);
public Task<bool> HasGatewayEventAsync(
string gatewayEventId,
CancellationToken cancellationToken = default)
=> db.OpenClawRunHistory
.AsNoTracking()
.AnyAsync(item => item.GatewayEventId == gatewayEventId, cancellationToken);
public Task<bool> TaskExistsAsync(Guid taskId, CancellationToken cancellationToken = default)
=> db.Tasks.AsNoTracking().AnyAsync(task => task.Id == taskId, cancellationToken);
public Task<bool> ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken = default)
=> db.Projects.AsNoTracking().AnyAsync(project => project.Id == projectId, cancellationToken);
public async Task<IReadOnlyList<OpenClawRunSubscription>> GetActiveSubscriptionsAsync(
CancellationToken cancellationToken = default)
{
var subscriptions = await db.OpenClawRuns
.AsNoTracking()
.Where(run => run.Status == OpenClawRunStates.Dispatching
|| run.Status == OpenClawRunStates.Running
|| run.Status == OpenClawRunStates.Stopping)
.Select(run => new { run.SessionKey, run.AgentId })
.Distinct()
.ToListAsync(cancellationToken);
return subscriptions
.Select(item => new OpenClawRunSubscription(item.SessionKey, item.AgentId))
.ToList();
}
public async Task AddAsync(
OpenClawRun run,
OpenClawRunHistory history,
CancellationToken cancellationToken = default)
{
db.OpenClawRuns.Add(run);
db.OpenClawRunHistory.Add(history);
db.OutboxEvents.Add(CreateRunEvent("run.created", run));
await db.SaveChangesAsync(cancellationToken);
}
public async Task AddRetryAsync(
OpenClawRun source,
OpenClawRun retry,
OpenClawRunHistory sourceHistory,
OpenClawRunHistory retryHistory,
CancellationToken cancellationToken = default)
{
source.UpdatedAt = DateTimeOffset.UtcNow;
source.Revision++;
db.OpenClawRuns.Update(source);
db.OpenClawRuns.Add(retry);
db.OpenClawRunHistory.AddRange(sourceHistory, retryHistory);
db.OutboxEvents.Add(CreateRunEvent("run.updated", source));
db.OutboxEvents.Add(CreateRunEvent(
"run.created",
retry,
source.Id));
await db.SaveChangesAsync(cancellationToken);
}
public async Task UpdateAsync(
OpenClawRun run,
OpenClawRunHistory history,
CancellationToken cancellationToken = default)
{
run.UpdatedAt = DateTimeOffset.UtcNow;
run.Revision++;
db.OpenClawRuns.Update(run);
db.OpenClawRunHistory.Add(history);
db.OutboxEvents.Add(CreateRunEvent("run.updated", run));
await db.SaveChangesAsync(cancellationToken);
}
public async Task UpdateProjectionAsync(
OpenClawRun run,
OpenClawRunHistory? history = null,
CancellationToken cancellationToken = default)
{
run.UpdatedAt = DateTimeOffset.UtcNow;
run.Revision++;
db.OpenClawRuns.Update(run);
if (history is not null)
db.OpenClawRunHistory.Add(history);
db.OutboxEvents.Add(CreateRunEvent("run.updated", run));
await db.SaveChangesAsync(cancellationToken);
}
private static OutboxEvent CreateRunEvent(
string type,
OpenClawRun run,
Guid? sourceRunId = null)
=> new()
{
Type = type,
AggregateType = "run",
AggregateId = run.Id.ToString(),
AggregateRevision = run.Revision,
PayloadJson = JsonSerializer.Serialize(new
{
status = run.Status,
taskId = run.TaskId,
projectId = run.ProjectId,
agentId = run.AgentId,
sourceRunId
})
};
}
+29
View File
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using System.Text.Json;
namespace Nexus.Api.Repositories;
@@ -14,6 +15,7 @@ public sealed class ProjectRepository(NexusDbContext db) : IProjectRepository
public async Task<Project> AddAsync(Project project, CancellationToken ct = default)
{
db.Projects.Add(project);
db.OutboxEvents.Add(CreateProjectEvent("project.created", project));
await db.SaveChangesAsync(ct);
return project;
}
@@ -21,15 +23,42 @@ public sealed class ProjectRepository(NexusDbContext db) : IProjectRepository
public async Task UpdateAsync(Project project, CancellationToken ct = default)
{
project.UpdatedAt = DateTimeOffset.UtcNow;
db.OutboxEvents.Add(CreateProjectEvent("project.updated", project));
await db.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Project project, CancellationToken ct = default)
{
db.OutboxEvents.Add(CreateProjectEvent("project.deleted", project));
db.Projects.Remove(project);
await db.SaveChangesAsync(ct);
}
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default)
=> db.Tasks.AnyAsync(t => t.ProjectId == projectId, ct);
public Task<List<WorkTask>> GetTasksAsync(
Guid projectId,
CancellationToken ct = default)
=> db.Tasks
.AsNoTracking()
.Where(task => task.ProjectId == projectId)
.OrderBy(task => task.State == "Done" ? 1 : 0)
.ThenByDescending(task => task.UpdatedAt)
.ThenBy(task => task.Id)
.ToListAsync(ct);
private static OutboxEvent CreateProjectEvent(string type, Project project)
=> new()
{
Type = type,
AggregateType = "project",
AggregateId = project.Id.ToString(),
AggregateRevision = 0,
PayloadJson = JsonSerializer.Serialize(new
{
status = project.Status,
progress = project.Progress
})
};
}
+145
View File
@@ -1,5 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
using System.Text.Json;
namespace Nexus.Api.Repositories;
@@ -23,6 +25,7 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
public async Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default)
{
db.Tasks.Add(task);
db.OutboxEvents.Add(CreateTaskEvent("task.created", task));
await db.SaveChangesAsync(ct);
return task;
}
@@ -47,10 +50,12 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
task.UpdatedAt = updatedAt;
db.OutboxEvents.Add(CreateTaskEvent("task.updated", task));
await db.SaveChangesAsync(ct);
return true;
}
await using var transaction = await db.Database.BeginTransactionAsync(ct);
var affectedRows = await db.Tasks
.Where(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
@@ -59,6 +64,17 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
.SetProperty(task => task.State, TaskStateHelper.ToStateString(TaskState.Backlog))
.SetProperty(task => task.UpdatedAt, updatedAt), ct);
if (affectedRows > 0)
{
var updatedTask = await db.Tasks
.AsNoTracking()
.SingleAsync(task => task.Id == id, ct);
db.OutboxEvents.Add(CreateTaskEvent("task.updated", updatedTask));
await db.SaveChangesAsync(ct);
}
await transaction.CommitAsync(ct);
return affectedRows > 0;
}
@@ -66,11 +82,13 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
{
task.UpdatedAt = DateTimeOffset.UtcNow;
db.Tasks.Update(task);
db.OutboxEvents.Add(CreateTaskEvent("task.updated", task));
await db.SaveChangesAsync(ct);
}
public async Task DeleteAsync(WorkTask task, CancellationToken ct = default)
{
db.OutboxEvents.Add(CreateTaskEvent("task.deleted", task));
db.Tasks.Remove(task);
await db.SaveChangesAsync(ct);
}
@@ -86,4 +104,131 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
.OrderByDescending(x => x.UpdatedAt)
.FirstOrDefaultAsync(ct);
public async Task<TaskBoardQueryPage> GetBoardPageAsync(
int doneLimit,
DateTimeOffset? doneBeforeUpdatedAt,
Guid? doneBeforeId,
CancellationToken ct = default)
{
var doneState = TaskStateHelper.ToStateString(TaskState.Done);
var activeQuery = db.Tasks
.AsNoTracking()
.Where(task => task.State != doneState)
.OrderBy(task => task.State == "Backlog" ? 0
: task.State == "In progress" ? 1
: task.State == "Review" ? 2
: task.State == "Blocked" ? 3
: 4)
.ThenByDescending(task => task.Priority == "High" ? 3
: task.Priority == "Medium" || task.Priority == "Normal" ? 2
: task.Priority == "Low" ? 1
: 2)
.ThenBy(task => task.CreatedAt)
.ThenBy(task => task.Id);
var doneQuery = db.Tasks
.AsNoTracking()
.Where(task => task.State == doneState);
if (doneBeforeUpdatedAt.HasValue && doneBeforeId.HasValue)
{
var cursorUpdatedAt = doneBeforeUpdatedAt.Value;
var cursorId = doneBeforeId.Value;
doneQuery = doneQuery.Where(task =>
task.UpdatedAt < cursorUpdatedAt
|| (task.UpdatedAt == cursorUpdatedAt && task.Id.CompareTo(cursorId) < 0));
}
doneQuery = doneQuery
.OrderByDescending(task => task.UpdatedAt)
.ThenByDescending(task => task.Id);
// Three SQL statements total. Child counts and latest activity are
// correlated scalar subqueries inside each projected statement rather
// than per-card round trips.
var isDoneContinuation =
doneBeforeUpdatedAt.HasValue && doneBeforeId.HasValue;
var activeTasks = isDoneContinuation
? []
: await ProjectBoardCards(activeQuery).ToListAsync(ct);
var doneTasks = await ProjectBoardCards(doneQuery)
.Take(doneLimit + 1)
.ToListAsync(ct);
var revision = await db.Tasks
.AsNoTracking()
.OrderByDescending(task => task.UpdatedAt)
.ThenByDescending(task => task.Id)
.Select(task => new TaskBoardRevisionPoint(task.UpdatedAt, task.Id))
.FirstOrDefaultAsync(ct);
var hasMoreDone = doneTasks.Count > doneLimit;
if (hasMoreDone)
doneTasks.RemoveAt(doneLimit);
return new TaskBoardQueryPage(activeTasks, doneTasks, hasMoreDone, revision);
}
public Task<TaskBoardCardDto?> GetBoardCardAsync(
Guid id,
CancellationToken ct = default)
=> ProjectBoardCards(
db.Tasks
.AsNoTracking()
.Where(task => task.Id == id))
.SingleOrDefaultAsync(ct);
private IQueryable<TaskBoardCardDto> ProjectBoardCards(IQueryable<WorkTask> query)
=> query.Select(task => new TaskBoardCardDto(
task.Id,
task.Title,
task.Detail,
task.Source,
task.State,
task.Priority,
task.AssignedTo,
task.ParentTaskId,
task.ProjectId,
task.DueDate,
task.CreatedAt,
task.UpdatedAt,
task.IsAgentTask,
task.ExpectedFrom,
db.Activity
.Where(activity => activity.TaskId == task.Id)
.OrderByDescending(activity => activity.CreatedAt)
.ThenByDescending(activity => activity.Id)
.Select(activity => activity.Message)
.FirstOrDefault(),
db.Activity
.Where(activity => activity.TaskId == task.Id)
.OrderByDescending(activity => activity.CreatedAt)
.ThenByDescending(activity => activity.Id)
.Select(activity => (DateTimeOffset?)activity.CreatedAt)
.FirstOrDefault(),
db.Tasks.Count(child => child.ParentTaskId == task.Id),
db.Tasks.Count(child =>
child.ParentTaskId == task.Id
&& child.State != "Done"),
task.ParentTaskId.HasValue
|| task.IsAgentTask
|| db.Tasks.Any(child => child.ParentTaskId == task.Id)));
private static OutboxEvent CreateTaskEvent(string type, WorkTask task)
=> new()
{
Type = type,
AggregateType = "task",
AggregateId = task.Id.ToString(),
AggregateRevision = 0,
PayloadJson = JsonSerializer.Serialize(new
{
entityType = "task",
id = task.Id,
state = task.State,
projectId = task.ProjectId,
assignedAgentId = task.AssignedTo
})
};
}
+21 -15
View File
@@ -1,5 +1,5 @@
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Services;
namespace Nexus.Api.Routing;
@@ -12,24 +12,30 @@ public sealed record RoutingTarget(
string Detail);
public sealed class ModelRoutingService(
IAgentRuntime runtime)
IOpenClawControlService openClaw)
{
public async Task<IReadOnlyCollection<RoutingTarget>> GetStatusAsync(
CancellationToken cancellationToken)
{
var runtimeStatus = await runtime.GetStatusAsync(cancellationToken);
var response = await openClaw.GetModelsAsync(cancellationToken);
if (response.Items.Count == 0)
return Array.Empty<RoutingTarget>();
return
[
new(1, "OpenClaw", "deepseek/deepseek-v4-flash", "Programmer agent",
runtimeStatus.Status,
"Routed through OpenClaw policy"),
new(2, "OpenClaw", "deepseek/deepseek-v4-pro", "Reviewer agent",
runtimeStatus.Status,
"Routed through OpenClaw policy"),
new(3, "OpenClaw", "openai/gpt-5.3-chat-latest", "Iris orchestrator",
runtimeStatus.Status,
"Routed through OpenClaw policy")
];
return response.Items
.OrderByDescending(model =>
string.Equals(model.Provider, "openai", StringComparison.OrdinalIgnoreCase))
.ThenBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
.Select((model, index) => new RoutingTarget(
index + 1,
$"OpenClaw / {model.Provider}",
model.Id,
string.Equals(model.Provider, "openai", StringComparison.OrdinalIgnoreCase)
? "Primary agent runtime"
: "Configured Gateway fallback",
model.Available ? OperationalStatus.Online : OperationalStatus.Degraded,
model.Available
? "Configured and resolved by OpenClaw"
: model.Reason ?? "Configured in OpenClaw, currently unavailable"))
.ToArray();
}
}
-147
View File
@@ -1,147 +0,0 @@
using System.Text.Json;
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class AgentConfigService : IAgentConfigService
{
private static readonly HashSet<string> AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
{
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
};
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId)
{
var workspacePath = $"/mnt/workspace-{agentId}";
if (!Directory.Exists(workspacePath))
return Array.Empty<AgentConfigFileInfo>();
return Directory.GetFiles(workspacePath, "*.md")
.Select(f => new FileInfo(f))
.Where(f => AllowedFiles.Contains(f.Name))
.OrderBy(f => f.Name)
.Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc))
.ToList();
}
public async Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
if (!AllowedFiles.Contains(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
return null;
var content = await File.ReadAllTextAsync(safePath!, ct);
var fi = new FileInfo(safePath!);
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
}
public async Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
{
var fileKind = DetermineFileKind(fileName);
var validation = Validate(fileName, content, fileKind);
var backup = new AgentConfigBackupResult("not_applicable", BackupCreated: false);
var reload = CreateReloadCheck();
if (validation.Errors.Count > 0)
return new AgentConfigSaveAttempt(null, new AgentConfigSaveFailure("validation_failed", validation, backup, reload));
var workspacePath = $"/mnt/workspace-{agentId}";
if (!Directory.Exists(workspacePath))
return new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"workspace_not_found",
new AgentConfigValidationResult("failed", fileKind, ["Agent workspace is not available on this node."]),
backup,
reload));
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"invalid_path",
new AgentConfigValidationResult("failed", fileKind, ["Invalid filename or path."]),
backup,
reload));
var tempPath = safePath + ".tmp";
var backupPath = safePath + ".bak";
var backupCreated = false;
try
{
if (File.Exists(safePath))
{
File.Copy(safePath, backupPath, overwrite: true);
backupCreated = true;
}
await File.WriteAllTextAsync(tempPath, content, ct);
File.Move(tempPath, safePath!, overwrite: true);
}
catch
{
if (File.Exists(tempPath)) File.Delete(tempPath);
throw;
}
var fi = new FileInfo(safePath!);
return new AgentConfigSaveAttempt(
new AgentConfigFileSaveResult(
fileName,
fi.Length,
fi.LastWriteTimeUtc,
new AgentConfigValidationResult("passed", fileKind, []),
new AgentConfigBackupResult(backupCreated ? "created" : "not_applicable", backupCreated),
CreateReloadCheck()),
null);
}
private static AgentConfigValidationResult Validate(string fileName, string content, string fileKind)
{
var errors = new List<string>();
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
errors.Add("Filename is invalid.");
else if (!AllowedFiles.Contains(fileName))
errors.Add("File is not allowed for Mission Control editing.");
if (content.IndexOf('\0') >= 0)
errors.Add("Content contains null bytes.");
if (content.Length > IAgentConfigService.MaxConfigFileBytes)
errors.Add($"Content exceeds maximum size of {IAgentConfigService.MaxConfigFileBytes / 1024}KB.");
if (string.Equals(fileKind, "json", StringComparison.OrdinalIgnoreCase))
{
try
{
JsonDocument.Parse(content);
}
catch (JsonException ex)
{
errors.Add($"JSON validation failed: {ex.Message}");
}
}
return new AgentConfigValidationResult(errors.Count == 0 ? "passed" : "failed", fileKind, errors);
}
private static string DetermineFileKind(string fileName)
{
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return "json";
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
return "markdown";
return "text";
}
private static AgentConfigReloadCheckResult CreateReloadCheck()
=> new(
"not_supported",
"Mission Control verified the file write locally, but agent hot reload is not available for workspace config files.");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
namespace Nexus.Api.Services;
public sealed class AgentProvisioningOptions
{
public const string SectionName = "OpenClawAgentProvisioning";
/// <summary>
/// OpenClaw-host path, not a Nexus container mount. The Gateway resolves
/// the leading tilde when agents.create is executed.
/// </summary>
public string WorkspaceRoot { get; set; } = "~/.openclaw";
public int PollIntervalSeconds { get; set; } = 5;
public int LeaseSeconds { get; set; } = 60;
public int MaxProposalFileBytes { get; set; } = 262_144;
public int MaxProposalFilesBytes { get; set; } = 524_288;
}
/// <summary>
/// Bounded wake-up signal. PostgreSQL remains authoritative; losing this
/// process-local signal merely falls back to polling.
/// </summary>
public sealed class AgentProvisioningSignal
{
private readonly System.Threading.Channels.Channel<bool> channel =
System.Threading.Channels.Channel.CreateBounded<bool>(
new System.Threading.Channels.BoundedChannelOptions(1)
{
FullMode = System.Threading.Channels.BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false
});
public void Notify() => channel.Writer.TryWrite(true);
public async Task WaitAsync(TimeSpan maximumDelay, CancellationToken cancellationToken)
{
using var delayCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
var signal = channel.Reader.WaitToReadAsync(cancellationToken).AsTask();
var delay = Task.Delay(maximumDelay, delayCancellation.Token);
var completed = await Task.WhenAny(signal, delay);
if (completed == signal && await signal)
{
while (channel.Reader.TryRead(out _))
{
}
}
delayCancellation.Cancel();
}
}
public sealed class AgentProvisioningWorker(
IServiceScopeFactory scopeFactory,
AgentProvisioningSignal signal,
Microsoft.Extensions.Options.IOptions<AgentProvisioningOptions> options,
ILogger<AgentProvisioningWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await using (var startupScope = scopeFactory.CreateAsyncScope())
{
try
{
await startupScope.ServiceProvider
.GetRequiredService<IAgentProposalService>()
.RecoverInterruptedRequestsAsync(stoppingToken);
}
catch (Exception exception) when (!stoppingToken.IsCancellationRequested)
{
logger.LogError(
exception,
"Agent provisioning recovery failed; live mutations remain paused until the next poll");
}
}
var delay = TimeSpan.FromSeconds(Math.Clamp(
options.Value.PollIntervalSeconds,
1,
60));
while (!stoppingToken.IsCancellationRequested)
{
var processed = false;
try
{
await using var scope = scopeFactory.CreateAsyncScope();
processed = await scope.ServiceProvider
.GetRequiredService<IAgentProposalService>()
.ProcessNextAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Agent provisioning worker iteration failed");
}
if (!processed)
await signal.WaitAsync(delay, stoppingToken);
}
}
}
+84 -224
View File
@@ -1,104 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
namespace Nexus.Api.Services;
public sealed record AgentConfig
{
[JsonPropertyName("id")]
public string Id { get; init; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("workspace")]
public string? Workspace { get; init; }
[JsonPropertyName("agentDir")]
public string? AgentDir { get; init; }
[JsonPropertyName("model")]
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
[JsonPropertyName("identity")]
public AgentIdentityConfig? Identity { get; init; }
[JsonPropertyName("subagents")]
public SubAgentConfig? Subagents { get; init; }
}
public sealed record SubAgentConfig
{
[JsonPropertyName("allowAgents")]
public IReadOnlyList<string>? AllowAgents { get; init; }
}
public sealed record AgentIdentityConfig
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("theme")]
public string Theme { get; init; } = string.Empty;
}
public sealed record AgentModelConfig
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
}
public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
{
public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
{
var primaryModel = reader.GetString();
return string.IsNullOrWhiteSpace(primaryModel) ? null : new AgentModelConfig { Primary = primaryModel };
}
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Agent model must be either a string or an object.");
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
string? primary = null;
foreach (var property in root.EnumerateObject())
{
if (!string.Equals(property.Name, "primary", StringComparison.OrdinalIgnoreCase))
continue;
primary = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Null => null,
_ => throw new JsonException("Agent model primary must be a string.")
};
break;
}
return new AgentModelConfig { Primary = primary };
}
public override void Write(Utf8JsonWriter writer, AgentModelConfig value, JsonSerializerOptions options)
{
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(value.Primary))
writer.WriteString("primary", value.Primary);
else
writer.WriteNull("primary");
writer.WriteEndObject();
}
}
public sealed record AgentInfo(
string Id,
string Name,
@@ -131,92 +34,116 @@ public interface IAgentService
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
}
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
/// <summary>
/// Projects OpenClaw's live agent inventory into Nexus' application contract.
/// OpenClaw remains the sole source of truth: no host config or workspace path
/// is consulted by this service.
/// </summary>
public sealed class AgentService(IOpenClawControlService openClaw) : IAgentService
{
private static readonly JsonSerializerOptions JsonOptions = new()
public async Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(
CancellationToken cancellationToken)
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var liveAgents = await openClaw.GetAgentsAsync(cancellationToken);
var sessions = await openClaw.GetSessionsAsync(500, cancellationToken);
var connection = openClaw.GetConnection();
var agents = new List<AgentInfo>(liveAgents.Items.Count);
public async Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
{
var configs = await LoadAgentConfigsAsync(cancellationToken);
var runtimeStatus = await runtime.GetStatusAsync(cancellationToken);
var overallOperational = runtimeStatus.Status;
var now = DateTimeOffset.UtcNow;
var agents = new List<AgentInfo>(configs.Count);
foreach (var config in configs)
foreach (var live in liveAgents.Items
.Where(item => !string.IsNullOrWhiteSpace(item.Id))
.DistinctBy(item => item.Id, StringComparer.OrdinalIgnoreCase))
{
var model = ResolveModel(config);
var role = DeriveRole(config.Id);
var description = config.Identity?.Theme ?? string.Empty;
if (string.IsNullOrEmpty(description))
var session = FindLatestSession(sessions.Items, live.Id);
var description = live.Description;
if (string.IsNullOrWhiteSpace(description) &&
string.Equals(live.Id, "main", StringComparison.OrdinalIgnoreCase))
{
description = config.Id switch
{
"main" => "Primary conversational agent — routing and general-purpose chat",
_ => description
};
description = "Primary conversational agent — routing and general-purpose chat";
}
agents.Add(new AgentInfo(
Id: config.Id,
Name: config.Identity?.Name ?? config.Name ?? config.Id,
Role: role,
Model: model,
Status: overallOperational,
LastSeen: now,
Workspace: config.Workspace,
Description: description
));
Id: live.Id,
Name: string.IsNullOrWhiteSpace(live.Name) ? live.Id : live.Name,
Role: DeriveRole(live.Id),
Model: session?.Model ?? live.Model ?? "openclaw/default",
Status: ResolveStatus(connection.Connected, live.Status),
LastSeen: session?.UpdatedAt ?? connection.LastEventAt,
Workspace: live.Workspace,
Description: description));
}
return agents.AsReadOnly();
}
public async Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
public async Task<AgentDetail?> GetAgentAsync(
string id,
CancellationToken cancellationToken)
{
var configs = await LoadAgentConfigsAsync(cancellationToken);
var config = configs.FirstOrDefault(a =>
a.Id.Equals(id, StringComparison.OrdinalIgnoreCase));
if (config is null) return null;
if (string.IsNullOrWhiteSpace(id))
return null;
var runtimeStatus = await runtime.GetStatusAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
var role = DeriveRole(config.Id);
var description = config.Identity?.Theme ?? string.Empty;
var liveAgents = await openClaw.GetAgentsAsync(cancellationToken);
var live = liveAgents.Items.FirstOrDefault(item =>
string.Equals(item.Id, id, StringComparison.OrdinalIgnoreCase));
if (live is null)
return null;
if (string.IsNullOrEmpty(description) && config.Id == "main")
var sessions = await openClaw.GetSessionsAsync(500, cancellationToken);
var session = FindLatestSession(sessions.Items, live.Id);
var connection = openClaw.GetConnection();
var description = live.Description;
if (string.IsNullOrWhiteSpace(description) &&
string.Equals(live.Id, "main", StringComparison.OrdinalIgnoreCase))
{
description = "Primary conversational agent — routing and general-purpose chat";
}
return new AgentDetail(
Id: config.Id,
Name: config.Identity?.Name ?? config.Name ?? config.Id,
Role: role,
Model: ResolveModel(config),
Status: runtimeStatus.Status,
LastSeen: now,
Workspace: config.Workspace,
AgentDir: config.AgentDir,
Id: live.Id,
Name: string.IsNullOrWhiteSpace(live.Name) ? live.Id : live.Name,
Role: DeriveRole(live.Id),
Model: session?.Model ?? live.Model ?? "openclaw/default",
Status: ResolveStatus(connection.Connected, live.Status),
LastSeen: session?.UpdatedAt ?? connection.LastEventAt,
Workspace: live.Workspace,
AgentDir: null,
Description: description,
SubAgents: config.Subagents?.AllowAgents,
IdentityName: config.Identity?.Name
);
SubAgents: null,
IdentityName: string.IsNullOrWhiteSpace(live.Name) ? live.Id : live.Name);
}
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(
CancellationToken cancellationToken)
{
var configs = await LoadAgentConfigsAsync(cancellationToken);
return configs
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
.Select(config => config.Id.Trim().ToLowerInvariant())
var liveAgents = await openClaw.GetAgentsAsync(cancellationToken);
return liveAgents.Items
.Where(agent => !string.IsNullOrWhiteSpace(agent.Id))
.Select(agent => agent.Id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static Nexus.Api.Models.OpenClawSessionDto? FindLatestSession(
IReadOnlyList<Nexus.Api.Models.OpenClawSessionDto> sessions,
string agentId)
=> sessions
.Where(item => string.Equals(item.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.UpdatedAt)
.FirstOrDefault();
private static OperationalStatus ResolveStatus(bool connected, string? liveStatus)
{
if (!connected)
return OperationalStatus.Offline;
return liveStatus?.Trim().ToLowerInvariant() switch
{
"degraded" or "stale" or "warning" => OperationalStatus.Degraded,
"offline" or "failed" or "error" => OperationalStatus.Offline,
"unknown" or "unsupported" => OperationalStatus.Unknown,
_ => OperationalStatus.Online
};
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
@@ -228,71 +155,4 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
"main" => "Assistant",
_ => "Custom"
};
private static string ResolveModel(AgentConfig config)
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{
var path = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/agents-sanitized.json";
if (!File.Exists(path))
return BuildFallbackConfigs();
var json = await File.ReadAllTextAsync(path, cancellationToken);
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
var root = document.RootElement;
if (!root.TryGetProperty("agents", out var agentsElement))
return BuildFallbackConfigs();
if (!agentsElement.TryGetProperty("list", out var listElement))
return BuildFallbackConfigs();
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
: null;
var configs = new List<AgentConfig>();
foreach (var agentElement in listElement.EnumerateArray())
{
var config = JsonSerializer.Deserialize<AgentConfig>(agentElement.GetRawText(), JsonOptions);
if (config is null || string.IsNullOrWhiteSpace(config.Id))
continue;
// Inherit defaults for missing fields
if (string.IsNullOrWhiteSpace(config.Name))
config = config with { Name = config.Id };
if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
config = config with { Workspace = defaults.Workspace };
configs.Add(config);
}
return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs();
}
private static IReadOnlyList<AgentConfig> BuildFallbackConfigs()
=> AgentIdentityCatalog.DefaultConfiguredAgentIds
.Select(id => new AgentConfig
{
Id = id,
Name = id,
Model = new AgentModelConfig { Primary = "deepseek/deepseek-v4-flash" }
})
.ToList()
.AsReadOnly();
private sealed record AgentDefaults
{
[JsonPropertyName("workspace")]
public string? Workspace { get; init; }
[JsonPropertyName("model")]
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
}
}
+30 -74
View File
@@ -1,86 +1,42 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
/// <summary>
/// Compatibility adapter for the existing calendar API. Its data now comes
/// exclusively from the real OpenClaw cron control plane; disconnected or
/// unsupported Gateways return an empty list instead of fabricated jobs.
/// </summary>
public sealed class CalendarService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<CalendarService> logger) : ICalendarService
IOpenClawControlService openClaw) : ICalendarService
{
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default)
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(
CancellationToken ct = default)
{
try
{
var client = CreateGatewayClient();
var response = await client.GetAsync("/api/cron", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
return data ?? new List<CronJobEntry>();
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data");
}
return BuildFallbackCronJobs();
var response = await openClaw.GetCronJobsAsync(200, ct);
return response.Items
.Select(job => new CronJobEntry(
job.Id,
job.Name,
job.Schedule,
job.LastRunAt?.ToString("O") ?? string.Empty,
job.NextRunAt?.ToString("O") ?? string.Empty,
job.Status))
.ToArray();
}
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default)
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(
CancellationToken ct = default)
{
try
{
var client = CreateGatewayClient();
var response = await client.GetAsync("/api/cron/upcoming", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
return data ?? new List<UpcomingCronEntry>();
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data");
}
return BuildFallbackUpcomingJobs();
}
private HttpClient CreateGatewayClient()
{
var client = httpClientFactory.CreateClient("gateway");
var token = configuration["Integrations:OpenClaw:Token"];
if (!string.IsNullOrWhiteSpace(token))
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static IReadOnlyList<CronJobEntry> BuildFallbackCronJobs()
{
var now = DateTimeOffset.UtcNow;
return
[
new("health-check", "Health Check", "*/5 * * * *", now.AddMinutes(-3).ToString("O"), now.AddMinutes(2).ToString("O"), "completed"),
new("memory-sync", "Memory Sync", "0 */6 * * *", now.AddHours(-2).ToString("O"), now.AddHours(4).ToString("O"), "completed"),
new("task-cleanup", "Task Cleanup", "0 3 * * *", now.AddDays(-1).ToString("O"), now.AddDays(1).AddHours(3).ToString("O"), "completed"),
new("backup", "Database Backup", "0 4 * * *", now.AddDays(-1).AddHours(-1).ToString("O"), now.AddDays(1).AddHours(4).ToString("O"), "completed"),
new("model-routing-refresh", "Model Routing Refresh", "*/30 * * * *", now.AddMinutes(-12).ToString("O"), now.AddMinutes(18).ToString("O"), "running")
];
}
private static IReadOnlyList<UpcomingCronEntry> BuildFallbackUpcomingJobs()
{
var now = DateTimeOffset.UtcNow;
return
[
new("health-check", "Health Check", now.AddMinutes(2).ToString("O"), "*/5 * * * *"),
new("model-routing-refresh", "Model Routing Refresh", now.AddMinutes(18).ToString("O"), "*/30 * * * *"),
new("memory-sync", "Memory Sync", now.AddHours(4).ToString("O"), "0 */6 * * *"),
new("task-cleanup", "Task Cleanup", now.AddDays(1).AddHours(3).ToString("O"), "0 3 * * *"),
new("backup", "Database Backup", now.AddDays(1).AddHours(4).ToString("O"), "0 4 * * *")
];
var response = await openClaw.GetCronJobsAsync(200, ct);
return response.Items
.Where(job => job.Enabled && job.NextRunAt is not null)
.OrderBy(job => job.NextRunAt)
.Select(job => new UpcomingCronEntry(
job.Id,
job.Name,
job.NextRunAt!.Value.ToString("O"),
job.Schedule))
.ToArray();
}
}
+207 -82
View File
@@ -4,68 +4,118 @@ namespace Nexus.Api.Services;
public sealed class DashboardService(
IOpenClawGatewayClient gateway,
IOpenClawControlService openClaw,
IOpenClawChatService chat,
ITaskService taskService,
ILogger<DashboardService> logger) : IDashboardService
{
public async Task<DashboardStatus> GetStatusAsync()
{
try
{
return await gateway.GetStatusAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard status check failed");
return new DashboardStatus(false, "Offline", 0, 0);
}
var connection = openClaw.GetConnection();
var tasks = await openClaw.GetTasksAsync(200);
var sessions = await openClaw.GetSessionsAsync(200);
return new DashboardStatus(
connection.Connected,
connection.Connected ? "Online" : "Offline",
sessions.Items.Count(session => session.Status is "running" or "active"),
tasks.Items.Count(task => task.Status is "queued" or "running"));
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
try
var agentsTask = openClaw.GetAgentsAsync();
var sessionsTask = openClaw.GetSessionsAsync(500);
var tasksTask = openClaw.GetTasksAsync(500);
await Task.WhenAll(agentsTask, sessionsTask, tasksTask);
var agents = agentsTask.Result;
var sessions = sessionsTask.Result;
var tasks = tasksTask.Result;
return agents.Items.Select(agent =>
{
return await gateway.GetAgentsAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard agents fetch failed");
return [];
}
var session = sessions.Items
.Where(item => string.Equals(item.AgentId, agent.Id, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.UpdatedAt)
.FirstOrDefault();
var task = tasks.Items
.Where(item =>
string.Equals(item.AgentId, agent.Id, StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrWhiteSpace(item.SessionKey) &&
string.Equals(item.SessionKey, session?.Key, StringComparison.Ordinal)))
.Where(item => item.Status is "queued" or "running" or "active")
.OrderByDescending(item => item.UpdatedAt ?? item.StartedAt ?? item.CreatedAt)
.FirstOrDefault();
var active = session?.Status is "running" or "active";
return new DashboardAgentInfo(
Id: agent.Id,
Name: agent.Name,
Role: DeriveRole(agent.Id),
Model: session?.Model ?? agent.Model ?? "openclaw/default",
IsActive: active,
CurrentTask: task?.Title ?? (active ? session?.Title : null),
Description: agent.Description,
Tags: BuildAgentTags(agent.Id),
Progress: task?.Progress,
Workload: sessions.Items.Count(item =>
string.Equals(item.AgentId, agent.Id, StringComparison.OrdinalIgnoreCase) &&
item.Status is "running" or "active"),
Goal: null,
RoleBadge: DeriveRoleBadge(agent.Id),
StatusLabel: active ? "Working" : "Ready",
StatusKind: active ? "working" : "ready",
StatusDetail: session is null ? "No Gateway session reported." : session.Title,
Elapsed: null,
Think: null,
Next: null,
TotalTokens: session?.TotalTokens,
CostUsd: null,
TelemetryAt: session?.UpdatedAt);
}).ToList();
}
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
{
try
{
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
var activity = await openClaw.GetActivityAsync(Math.Clamp(limit, 1, 100));
var entries = activity.Items;
if (!string.IsNullOrWhiteSpace(agentFilter))
entries = entries
.Where(item => string.Equals(item.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (!string.IsNullOrWhiteSpace(agentFilter))
{
entries = entries
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
.ToList();
}
return entries;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard operations fetch failed");
return [];
}
return entries.Select(item => new FeedEntry(
item.AgentId ?? item.Actor ?? "OpenClaw",
item.Message,
item.OccurredAt?.ToString("O") ?? activity.CheckedAt.ToString("O"),
item.OccurredAt?.ToLocalTime().ToString("HH:mm") ?? "--:--",
item.AgentId,
item.EventType)).ToList();
}
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
{
try
{
return await gateway.SendChatMessageAsync(agentId, message);
var context = OpenClawInvocationContextFactory.Create(
actor: "nexus-dashboard",
idempotencyKey: null,
correlationId: null,
traceParent: null);
var result = await chat.SendAsync(
message,
$"nexus-dashboard-{agentId.ToLowerInvariant()}",
agentId,
new OpenClawInvocationMetadata(
context.IdempotencyKey,
context.CorrelationId,
context.Actor,
context.TraceParent),
CancellationToken.None);
return new ChatResponse(true, result.Content, null);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard chat send failed");
return new ChatResponse(false, null, "Gateway nicht erreichbar");
return new ChatResponse(false, null, "OpenClaw chat endpoint is not enabled or reachable.");
}
}
@@ -91,11 +141,17 @@ public sealed class DashboardService(
{
try
{
var cronTask = gateway.GetQueueAsync();
var cronTask = openClaw.GetCronJobsAsync(200, ct);
var tasksTask = taskService.GetOpenAsync(ct);
await Task.WhenAll(cronTask, tasksTask);
var merged = new List<QueueItem>(cronTask.Result);
var merged = cronTask.Result.Items.Select(job => new QueueItem(
job.Id,
job.Name,
job.Status,
"medium",
"cron",
FormatWaitTime(job.NextRunAt))).ToList();
foreach (var t in tasksTask.Result)
{
merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--"));
@@ -114,24 +170,32 @@ public sealed class DashboardService(
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
{
try
{
return await gateway.GetGatewayInfoAsync(ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Gateway info fetch failed");
return new GatewayRuntimeInfo(false, "unknown", null, null, false, false, "error", DateTimeOffset.UtcNow, "Gateway nicht erreichbar", "Gateway nicht erreichbar");
}
await Task.CompletedTask;
var connection = openClaw.GetConnection();
var versionStatus = !connection.Connected
? "error"
: !connection.VersionPinned
? "unpinned"
: connection.GatewayVersion is null
? "missing"
: connection.VersionMatches ? "matched" : "drift";
return new GatewayRuntimeInfo(
connection.Connected,
connection.Endpoint,
connection.GatewayVersion,
connection.RequiredVersion,
connection.VersionPinned,
connection.VersionMatches,
versionStatus,
connection.CheckedAt,
connection.Message,
connection.Recovery);
}
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
{
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
{
var ok = await gateway.DeleteCronJobAsync(id);
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
}
return new QueueDeleteResult(QueueDeleteOutcome.Ignored);
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
{
@@ -147,8 +211,7 @@ public sealed class DashboardService(
};
}
var deleted = await gateway.DeleteCronJobAsync(id);
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
return new QueueDeleteResult(QueueDeleteOutcome.NotFound);
}
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
@@ -169,44 +232,52 @@ public sealed class DashboardService(
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
{
try
{
return await gateway.GetAgentModelAsync(agentId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
return null;
}
var sessions = await openClaw.GetSessionsAsync(500);
var session = sessions.Items
.Where(item => string.Equals(item.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(item => item.UpdatedAt)
.FirstOrDefault();
return session?.Model is null
? null
: new AgentModelInfo(session.Model, session.Provider ?? "unknown");
}
public async Task<bool> SetAgentModelAsync(string agentId, string model)
{
try
{
return await gateway.SetAgentModelAsync(agentId, model);
}
catch (Exception ex)
{
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
return false;
}
var result = await openClaw.PatchSessionModelAsync(
$"agent:{agentId}:main",
model);
return result.Ok;
}
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
{
try
{
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
return [];
}
var response = await openClaw.GetActivityAsync(Math.Clamp(limit * 5, 1, 100));
return response.Items
.Where(item => string.Equals(item.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
.Take(Math.Clamp(limit, 1, 20))
.Select(item => new AgentActivityEntry(
FormatTimeAgo(item.OccurredAt),
item.Message,
item.OccurredAt ?? response.CheckedAt,
item.Source))
.ToList();
}
public List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
public async Task<List<ModelOption>> GetAvailableModelsAsync(CancellationToken ct)
{
var models = await openClaw.GetModelsAsync(ct);
return models.Items
.Where(model => !string.IsNullOrWhiteSpace(model.Id))
.Select(model => new ModelOption(
model.Id,
string.IsNullOrWhiteSpace(model.Name) ? model.Id : model.Name,
model.Provider))
.DistinctBy(model => model.Id, StringComparer.OrdinalIgnoreCase)
.OrderBy(model => model.Provider, StringComparer.OrdinalIgnoreCase)
.ThenBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch
{
@@ -219,4 +290,58 @@ public sealed class DashboardService(
{
["high"] = 0, ["medium"] = 1, ["low"] = 2
};
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
"programmer" => "Developer",
"reviewer" => "Reviewer",
"architekt" => "Architect",
"researcher" => "Researcher",
"executor" => "Executor",
"main" => "Assistant",
_ => "Agent"
};
private static string DeriveRoleBadge(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "badge-violet",
"reviewer" => "badge-amber",
"executor" => "badge-green",
_ => "badge-blue"
};
private static string[] BuildAgentTags(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => ["orchestration", "delegation", "approvals"],
"programmer" => ["code", "build", "test"],
"reviewer" => ["review", "quality", "security"],
"architekt" => ["architecture", "infrastructure"],
"researcher" => ["research", "analysis"],
"executor" => ["execution", "operations"],
_ => ["openclaw"]
};
private static string FormatWaitTime(DateTimeOffset? nextRunAt)
{
if (nextRunAt is null)
return "--";
var remaining = nextRunAt.Value - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero) return "now";
if (remaining.TotalMinutes < 1) return "<1m";
if (remaining.TotalHours < 1) return $"{(int)remaining.TotalMinutes}m";
if (remaining.TotalDays < 1) return $"{(int)remaining.TotalHours}h";
return $"{(int)remaining.TotalDays}d";
}
private static string FormatTimeAgo(DateTimeOffset? timestamp)
{
if (timestamp is null)
return "unknown";
var elapsed = DateTimeOffset.UtcNow - timestamp.Value;
if (elapsed.TotalMinutes < 1) return "now";
if (elapsed.TotalHours < 1) return $"{(int)elapsed.TotalMinutes}m";
if (elapsed.TotalDays < 1) return $"{(int)elapsed.TotalHours}h";
return $"{(int)elapsed.TotalDays}d";
}
}
+110 -56
View File
@@ -1,75 +1,129 @@
using Nexus.Api.Helpers;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class DocService : IDocService
public sealed class DocService(
IOpenClawAgentConfigurationService configuration) : IDocService
{
private static readonly string[] AllowedExtensions = [".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"];
private static readonly string[] SearchRoots =
private static readonly HashSet<string> AllowedExtensions =
new(
[".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"],
StringComparer.OrdinalIgnoreCase);
private static readonly (string Path, string Category)[] ScanDirectories =
[
"/mnt/workspace-iris",
"/home/node/.openclaw/workspace/nexus"
("", "workspace"),
("nexus-phases", "phases"),
("skills", "skills"),
("nexus", "nexus"),
("nexus/phases", "nexus-phases")
];
private static readonly (string Dir, string Category)[] ScanDirectories =
[
("/mnt/workspace-iris/nexus-phases", "phases"),
("/mnt/workspace-iris/skills", "skills"),
("/mnt/workspace-iris", "workspace"),
("/home/node/.openclaw/workspace/nexus", "nexus"),
("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases")
];
public IReadOnlyList<DocFileInfo> GetAll()
public async Task<IReadOnlyList<DocFileInfo>> GetAllAsync(
string agentId,
CancellationToken cancellationToken = default)
{
var results = new List<DocFileInfo>();
foreach (var (dir, category) in ScanDirectories)
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.GetFiles(dir, "*.*"))
var normalizedAgentId = NormalizeAgentId(agentId);
var directories = await OpenClawContentReadHelpers.SelectBoundedAsync<
(string Path, string Category),
DirectoryResult>(
ScanDirectories,
async (directory, token) =>
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext)) continue;
try
{
var listing = await configuration.GetWorkspaceAsync(
normalizedAgentId,
directory.Path,
0,
100,
token);
return new DirectoryResult(
directory.Category,
listing);
}
catch (OpenClawGatewayRpcException exception)
when (OpenClawContentReadHelpers.IsNotFound(exception))
{
return null;
}
},
cancellationToken);
var fi = new FileInfo(file);
results.Add(new DocFileInfo(
fi.Name,
file.Replace("/mnt/workspace-iris", "").TrimStart('/'),
category,
ext.Replace(".", ""),
fi.Length,
fi.LastWriteTimeUtc));
}
}
return results.OrderByDescending(x => x.ModifiedAt).Take(100).ToList();
return directories
.SelectMany(directory => directory.Listing.Entries
.Where(entry =>
string.Equals(entry.Kind, "file", StringComparison.Ordinal)
&& !(string.IsNullOrEmpty(directory.Listing.Path)
&& string.Equals(
entry.Name,
"MEMORY.md",
StringComparison.OrdinalIgnoreCase))
&& AllowedExtensions.Contains(
Path.GetExtension(entry.Name)))
.Select(entry => new DocFileInfo(
entry.Name,
entry.Path,
directory.Category,
Path.GetExtension(entry.Name).TrimStart('.')
.ToLowerInvariant(),
entry.Size ?? 0,
(entry.UpdatedAt
?? directory.Listing.CheckedAt).UtcDateTime,
normalizedAgentId,
entry.Path)))
.OrderByDescending(item => item.ModifiedAt)
.Take(100)
.ToArray();
}
public async Task<DocFileContent?> GetFileAsync(string path)
public async Task<DocFileContent?> GetFileAsync(
string path,
string agentId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(path))
return null;
string? resolvedPath = null;
foreach (var root in SearchRoots)
if (string.IsNullOrWhiteSpace(path)
|| !AllowedExtensions.Contains(Path.GetExtension(path)))
{
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate))
{
resolvedPath = candidate;
break;
}
return null;
}
if (resolvedPath is null)
var normalizedAgentId = NormalizeAgentId(agentId);
try
{
var file = await configuration.GetWorkspaceFileAsync(
normalizedAgentId,
path,
cancellationToken);
var content = OpenClawContentReadHelpers.ReadText(file);
if (content is null
|| !AllowedExtensions.Contains(Path.GetExtension(file.Name)))
{
return null;
}
return new DocFileContent(
file.Name,
file.Path,
content,
file.Size,
(file.UpdatedAt ?? file.CheckedAt).UtcDateTime,
normalizedAgentId,
file.Path);
}
catch (OpenClawGatewayRpcException exception)
when (OpenClawContentReadHelpers.IsNotFound(exception))
{
return null;
var content = await File.ReadAllTextAsync(resolvedPath);
var fi = new FileInfo(resolvedPath);
var relativePath = resolvedPath
.Replace("/mnt/workspace-iris/", "")
.Replace("/home/node/.openclaw/workspace/nexus/", "");
return new DocFileContent(fi.Name, relativePath, content, fi.Length, fi.LastWriteTimeUtc);
}
}
private static string NormalizeAgentId(string? agentId)
=> string.IsNullOrWhiteSpace(agentId)
? "iris"
: agentId.Trim().ToLowerInvariant();
private sealed record DirectoryResult(
string Category,
OpenClawWorkspaceCollectionDto Listing);
}
@@ -0,0 +1,288 @@
using System.Collections.Concurrent;
using System.Data;
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Observability;
namespace Nexus.Api.Services;
/// <summary>
/// Publishes the PostgreSQL transactional outbox to bounded in-process SSE
/// subscribers. PostgreSQL remains authoritative: the in-process channel only
/// reduces latency, while reconnect replay is always read from the database.
/// </summary>
public sealed class DomainEventStreamService(
IServiceScopeFactory scopeFactory,
ILogger<DomainEventStreamService> logger) :
BackgroundService,
IDomainEventStreamService
{
private const int BatchSize = 128;
private const int SubscriberCapacity = 64;
private const int MinimumRetainedSequences = 10_000;
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
private static readonly TimeSpan Retention = TimeSpan.FromHours(24);
private readonly ConcurrentDictionary<Guid, Subscriber> subscribers = new();
private long currentSequence;
private long reportedBacklog;
private DateTimeOffset nextRetentionSweep = DateTimeOffset.UtcNow.AddMinutes(10);
public long CurrentSequence => Interlocked.Read(ref currentSequence);
public DomainEventSubscription Subscribe(IReadOnlySet<string> channels)
{
var id = Guid.NewGuid();
var queue = Channel.CreateBounded<DomainEventDto>(
new BoundedChannelOptions(SubscriberCapacity)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = true,
AllowSynchronousContinuations = false
});
subscribers[id] = new Subscriber(queue, channels);
NexusTelemetry.SseSubscribers.Add(1);
return new DomainEventSubscription(
queue.Reader,
CurrentSequence,
() =>
{
if (subscribers.TryRemove(id, out var removed))
{
removed.Queue.Writer.TryComplete();
NexusTelemetry.SseSubscribers.Add(-1);
}
return ValueTask.CompletedTask;
});
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await InitializeSequenceAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var published = await PublishNextBatchAsync(stoppingToken);
if (DateTimeOffset.UtcNow >= nextRetentionSweep)
{
await PruneRetainedEventsAsync(stoppingToken);
nextRetentionSweep = DateTimeOffset.UtcNow.AddHours(1);
}
if (!published)
await Task.Delay(PollInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogError(exception, "Domain outbox iteration failed");
await Task.Delay(PollInterval, stoppingToken);
}
}
foreach (var (id, subscriber) in subscribers)
{
if (subscribers.TryRemove(id, out _))
{
subscriber.Queue.Writer.TryComplete();
NexusTelemetry.SseSubscribers.Add(-1);
}
}
var backlog = Interlocked.Exchange(ref reportedBacklog, 0);
if (backlog != 0)
NexusTelemetry.OutboxBacklog.Add(-backlog);
}
internal static DomainEventDto Map(OutboxEvent item)
{
using var document = JsonDocument.Parse(item.PayloadJson);
var entityType = NormalizeEntityType(item.AggregateType);
return new DomainEventDto(
item.Sequence,
item.Type,
new EntityRefDto(entityType, item.AggregateId),
item.AggregateRevision,
item.OccurredAt,
document.RootElement.Clone());
}
private async Task InitializeSequenceAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
var latest = await db.OutboxEvents
.AsNoTracking()
.Where(item => item.PublishedAt != null)
.Select(item => (long?)item.Sequence)
.MaxAsync(cancellationToken) ?? 0;
Interlocked.Exchange(ref currentSequence, latest);
}
private async Task<bool> PublishNextBatchAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
List<OutboxEvent> pending;
if (db.Database.IsRelational())
{
await using var transaction = await db.Database.BeginTransactionAsync(
IsolationLevel.ReadCommitted,
cancellationToken);
pending = await db.OutboxEvents
.FromSqlInterpolated($"""
SELECT * FROM "OutboxEvents"
WHERE "PublishedAt" IS NULL
ORDER BY "Sequence"
LIMIT {BatchSize}
FOR UPDATE SKIP LOCKED
""")
.ToListAsync(cancellationToken);
var publishedAt = DateTimeOffset.UtcNow;
foreach (var item in pending)
{
item.PublishedAt = publishedAt;
item.PublishAttempts++;
item.LastErrorCode = null;
}
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
else
{
pending = await db.OutboxEvents
.Where(item => item.PublishedAt == null)
.OrderBy(item => item.Sequence)
.Take(BatchSize)
.ToListAsync(cancellationToken);
var publishedAt = DateTimeOffset.UtcNow;
foreach (var item in pending)
{
item.PublishedAt = publishedAt;
item.PublishAttempts++;
item.LastErrorCode = null;
}
await db.SaveChangesAsync(cancellationToken);
}
foreach (var item in pending)
{
var domainEvent = Map(item);
Interlocked.Exchange(ref currentSequence, domainEvent.Sequence);
Publish(domainEvent);
NexusTelemetry.OutboxPublished.Add(1);
}
var nextBacklogEstimate = pending.Count == BatchSize ? BatchSize : 0;
var previousBacklog = Interlocked.Exchange(
ref reportedBacklog,
nextBacklogEstimate);
NexusTelemetry.OutboxBacklog.Add(nextBacklogEstimate - previousBacklog);
return pending.Count > 0;
}
private void Publish(DomainEventDto domainEvent)
{
var channel = ChannelFor(domainEvent);
foreach (var (id, subscriber) in subscribers)
{
if (!subscriber.Channels.Contains("*") &&
!subscriber.Channels.Contains(channel))
{
continue;
}
if (subscriber.Queue.Writer.TryWrite(domainEvent))
continue;
if (subscribers.TryRemove(id, out var removed))
{
removed.Queue.Writer.TryComplete(
new DomainEventSubscriberOverflowException());
NexusTelemetry.SseSubscribers.Add(-1);
NexusTelemetry.SseResyncs.Add(1);
}
}
}
private async Task PruneRetainedEventsAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
var maximum = await db.OutboxEvents
.Where(item => item.PublishedAt != null)
.Select(item => (long?)item.Sequence)
.MaxAsync(cancellationToken) ?? 0;
var sequenceCutoff = Math.Max(0, maximum - MinimumRetainedSequences);
var timeCutoff = DateTimeOffset.UtcNow - Retention;
if (sequenceCutoff == 0)
return;
if (db.Database.IsRelational())
{
await db.OutboxEvents
.Where(item =>
item.PublishedAt != null &&
item.Sequence < sequenceCutoff &&
item.OccurredAt < timeCutoff)
.ExecuteDeleteAsync(cancellationToken);
return;
}
var expired = await db.OutboxEvents
.Where(item =>
item.PublishedAt != null &&
item.Sequence < sequenceCutoff &&
item.OccurredAt < timeCutoff)
.ToListAsync(cancellationToken);
db.OutboxEvents.RemoveRange(expired);
await db.SaveChangesAsync(cancellationToken);
}
private static string ChannelFor(DomainEventDto domainEvent) =>
domainEvent.Entity.Type switch
{
"agent-proposal" => "agents",
"task" => "tasks",
"run" => "runs",
"cron" => "cron",
"notification" => "notifications",
"incident" => "incidents",
_ => $"{domainEvent.Entity.Type}s"
};
private static string NormalizeEntityType(string aggregateType)
{
var normalized = aggregateType
.Trim()
.Replace("_", "-", StringComparison.Ordinal)
.ToLowerInvariant();
return normalized switch
{
"agentproposal" or "agent-proposal" => "agent-proposal",
"worktask" or "task" => "task",
"openclawrun" or "run" => "run",
"cronjob" or "cron" => "cron",
_ => normalized
};
}
private sealed record Subscriber(
Channel<DomainEventDto> Queue,
IReadOnlySet<string> Channels);
}
File diff suppressed because it is too large Load Diff
-41
View File
@@ -1,41 +0,0 @@
namespace Nexus.Api.Services;
public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
public sealed record AgentConfigValidationResult(string Status, string FileKind, IReadOnlyList<string> Errors);
public sealed record AgentConfigBackupResult(string Status, bool BackupCreated);
public sealed record AgentConfigReloadCheckResult(string Status, string Message);
public sealed record AgentConfigFileSaveResult(
string FileName,
long Size,
DateTime ModifiedAt,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveFailure(
string Code,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveAttempt(
AgentConfigFileSaveResult? SaveResult,
AgentConfigSaveFailure? Failure
);
public interface IAgentConfigService
{
const int MaxConfigFileBytes = 500 * 1024;
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
}
+64
View File
@@ -0,0 +1,64 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IAgentProposalService
{
Task<AgentCreateOptionsDto> GetCreateOptionsAsync(
CancellationToken cancellationToken = default);
Task<AgentProposalCollectionDto> GetAsync(
int limit = 50,
string? cursor = null,
string? status = null,
CancellationToken cancellationToken = default);
Task<AgentProposalDto?> GetByIdAsync(
Guid id,
bool includeFileContent = true,
CancellationToken cancellationToken = default);
Task<AgentProposalOperationDto> CreateAsync(
CreateAgentProposalRequest request,
string source,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
Task<AgentProposalOperationDto> ApproveAsync(
Guid id,
AgentProposalActionRequest request,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
Task<AgentProposalOperationDto> RejectAsync(
Guid id,
AgentProposalActionRequest request,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
Task<AgentProposalOperationDto> RetryAsync(
Guid id,
AgentProposalActionRequest request,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
/// <summary>
/// Converts requests left at a dispatch boundary by a prior process into a
/// conservative state. It never calls agents.create.
/// </summary>
Task RecoverInterruptedRequestsAsync(
CancellationToken cancellationToken = default);
/// <summary>
/// Processes at most one explicitly queued request. Returns false when no
/// work was available.
/// </summary>
Task<bool> ProcessNextAsync(CancellationToken cancellationToken = default);
}
public sealed class AgentProposalValidationException(
string field,
string message) : Exception(message)
{
public string Field { get; } = field;
}
+1 -1
View File
@@ -22,5 +22,5 @@ public interface IDashboardService
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit);
List<ModelOption> GetAvailableModels();
Task<List<ModelOption>> GetAvailableModelsAsync(CancellationToken ct);
}
+13 -4
View File
@@ -6,17 +6,26 @@ public sealed record DocFileInfo(
string Category,
string Type,
long Size,
DateTime ModifiedAt);
DateTime ModifiedAt,
string SourceAgentId,
string WorkspacePath);
public sealed record DocFileContent(
string Name,
string Path,
string Content,
long Size,
DateTime ModifiedAt);
DateTime ModifiedAt,
string SourceAgentId,
string WorkspacePath);
public interface IDocService
{
IReadOnlyList<DocFileInfo> GetAll();
Task<DocFileContent?> GetFileAsync(string path);
Task<IReadOnlyList<DocFileInfo>> GetAllAsync(
string agentId = "iris",
CancellationToken cancellationToken = default);
Task<DocFileContent?> GetFileAsync(
string path,
string agentId = "iris",
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,31 @@
using System.Threading.Channels;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IDomainEventStreamService
{
long CurrentSequence { get; }
DomainEventSubscription Subscribe(IReadOnlySet<string> channels);
}
public sealed class DomainEventSubscription(
ChannelReader<DomainEventDto> reader,
long startingSequence,
Func<ValueTask> disposeAsync) : IAsyncDisposable
{
public ChannelReader<DomainEventDto> Reader { get; } = reader;
public long StartingSequence { get; } = startingSequence;
public ValueTask DisposeAsync() => disposeAsync();
}
public sealed class DomainEventSubscriberOverflowException :
InvalidOperationException
{
public DomainEventSubscriberOverflowException()
: base("The domain-event subscriber queue overflowed and requires a REST resync.")
{
}
}
+166 -1
View File
@@ -1,3 +1,4 @@
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
@@ -19,7 +20,8 @@ public interface IGatewayConnector
string? GatewayVersion { get; }
/// <summary>
/// Required version from configuration, or null when unpinned.
/// Required version from configuration, falling back to Nexus' verified
/// OpenClaw release pin when the setting is absent or blank.
/// </summary>
string? RequiredVersion { get; }
@@ -37,6 +39,169 @@ public interface IGatewayConnector
/// Detailed status message (e.g. error or version info).
/// </summary>
string? StatusMessage { get; }
/// <summary>
/// Stable Nexus backend device id used for non-loopback Gateway pairing.
/// The private key and device token are never exposed through this contract.
/// </summary>
string? DeviceId { get; }
/// <summary>
/// Whether a paired backend device token is available in protected server-side storage.
/// </summary>
bool DeviceTokenConfigured { get; }
/// <summary>
/// Whether the Gateway is waiting for an operator to approve the current device request.
/// </summary>
bool PairingRequired { get; }
/// <summary>
/// Exact pending OpenClaw pairing request id, when supplied by the Gateway.
/// </summary>
string? PairingRequestId { get; }
/// <summary>
/// Negotiated Gateway protocol version from the latest hello-ok frame.
/// </summary>
int? ProtocolVersion { get; }
/// <summary>
/// RPC methods advertised by the connected Gateway.
/// </summary>
IReadOnlySet<string> AdvertisedMethods { get; }
/// <summary>
/// Event families advertised by the connected Gateway.
/// </summary>
IReadOnlySet<string> AdvertisedEvents { get; }
/// <summary>
/// Operator scopes granted by the Gateway during the handshake.
/// </summary>
IReadOnlySet<string> GrantedScopes { get; }
/// <summary>
/// Normalized endpoint and TLS pin currently used by the running connector.
/// These values never include credentials.
/// </summary>
string? ActiveEndpoint => null;
string? ActiveTlsFingerprint => null;
/// <summary>
/// Timestamp of the latest event frame received from the Gateway.
/// </summary>
DateTimeOffset? LastEventAt { get; }
/// <summary>
/// Returns whether the current Gateway explicitly advertises an RPC method.
/// </summary>
bool Supports(string method);
/// <summary>
/// Invokes a Gateway RPC over the authenticated protocol-v4 connection.
/// </summary>
Task<JsonNode?> InvokeAsync(
string method,
object? parameters = null,
TimeSpan? timeout = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
/// <summary>
/// Returns a bounded newest-first snapshot of recently received Gateway events.
/// </summary>
IReadOnlyList<GatewayEventEnvelope> GetRecentEvents(int limit = 100);
/// <summary>
/// Requests a new explicit operator-scope set for the next Gateway
/// handshake. The production connector closes the current socket so
/// OpenClaw can start its normal pairing or scope-upgrade flow.
/// Test connectors may keep the default no-op implementation.
/// </summary>
Task RequestOperatorScopesAsync(
IReadOnlyCollection<string> scopes,
CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <summary>
/// Re-targets the single connector after Setup has validated the endpoint.
/// The optional bootstrap token remains process-memory-only until OpenClaw
/// issues a bound device token.
/// </summary>
Task ConfigureEndpointAsync(
string endpoint,
string? tlsFingerprint,
string? bootstrapToken,
CancellationToken cancellationToken = default)
=> Task.CompletedTask;
/// <summary>
/// Closes the active socket after a local detach. The background connector
/// may return to an unauthenticated discovery/pairing state, but the
/// adopted profile and protected device token are no longer active.
/// </summary>
Task DisconnectAsync(CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
public sealed record GatewayEventEnvelope(
string Event,
JsonNode? Payload,
long? Sequence,
long? StateVersion,
DateTimeOffset ReceivedAt);
/// <summary>
/// Correlation metadata for one logical Nexus-to-OpenClaw invocation.
/// Only fields defined by the OpenClaw wire schema are sent to the Gateway:
/// correlation is reflected in the request id, W3C traceparent is attached to
/// the request frame, and idempotencyKey is added to params only when
/// <see cref="IncludeIdempotencyParameter"/> is explicitly enabled for a
/// schema-confirmed method. Actor and the complete context remain in Nexus'
/// local audit boundary.
/// </summary>
public sealed record OpenClawInvocationContext(
string IdempotencyKey,
string CorrelationId,
string Actor,
string TraceParent,
bool IncludeIdempotencyParameter = false)
{
public static OpenClawInvocationContext Create(
string? actor = null,
string? idempotencyKey = null,
string? correlationId = null,
string? traceParent = null,
bool includeIdempotencyParameter = false)
=> OpenClawInvocationContextFactory.Create(
actor,
idempotencyKey,
correlationId,
traceParent,
includeIdempotencyParameter);
}
public sealed class OpenClawGatewayRpcException : Exception
{
public OpenClawGatewayRpcException(
string code,
string message,
JsonNode? details = null,
bool retryable = false,
int? retryAfterMs = null)
: base(message)
{
Code = code;
Details = details;
Retryable = retryable;
RetryAfterMs = retryAfterMs;
}
public string Code { get; }
public JsonNode? Details { get; }
public bool Retryable { get; }
public int? RetryAfterMs { get; }
}
/// <summary>
+13 -4
View File
@@ -6,17 +6,26 @@ public sealed record IncidentSummary(
string? Date,
string Severity,
string Excerpt,
long Size);
long Size,
string SourceAgentId,
string WorkspacePath);
public sealed record IncidentDetail(
string Name,
string Title,
string? Date,
string Content,
long Size);
long Size,
string SourceAgentId,
string WorkspacePath);
public interface IIncidentService
{
Task<IReadOnlyList<IncidentSummary>> GetAllAsync();
Task<IncidentDetail?> GetByNameAsync(string name);
Task<IReadOnlyList<IncidentSummary>> GetAllAsync(
string agentId = "iris",
CancellationToken cancellationToken = default);
Task<IncidentDetail?> GetByNameAsync(
string name,
string agentId = "iris",
CancellationToken cancellationToken = default);
}
+33 -6
View File
@@ -1,14 +1,41 @@
namespace Nexus.Api.Services;
public sealed record MemoryFileInfo(string Name, string Path, long Size, DateTime ModifiedAt);
public sealed record MemoryFileInfo(
string Name,
string Path,
long Size,
DateTime ModifiedAt,
string SourceAgentId,
string WorkspacePath);
public sealed record MemoryFileContent(string Name, string Path, string Content, long Size, DateTime ModifiedAt);
public sealed record MemoryFileContent(
string Name,
string Path,
string Content,
long Size,
DateTime ModifiedAt,
string SourceAgentId,
string WorkspacePath);
public sealed record MemorySearchResult(string Name, string Path, string Excerpt, long Size);
public sealed record MemorySearchResult(
string Name,
string Path,
string Excerpt,
long Size,
string SourceAgentId,
string WorkspacePath);
public interface IMemoryService
{
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync();
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query);
Task<MemoryFileContent?> GetFileAsync(string name);
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync(
string agentId = "iris",
CancellationToken cancellationToken = default);
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(
string query,
string agentId = "iris",
CancellationToken cancellationToken = default);
Task<MemoryFileContent?> GetFileAsync(
string name,
string agentId = "iris",
CancellationToken cancellationToken = default);
}
+3 -1
View File
@@ -3,11 +3,13 @@ using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed record NotificationReadResult(Notification? Notification, bool Changed);
public interface INotificationService
{
Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default);
Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
Task<NotificationReadResult> MarkAsReadAsync(Guid id, CancellationToken ct = default);
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
@@ -0,0 +1,78 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawAgentConfigurationService
{
Task<OpenClawAgentFileCollectionDto> GetAgentFilesAsync(
string agentId,
CancellationToken cancellationToken = default);
Task<OpenClawAgentFileDto> GetAgentFileAsync(
string agentId,
string fileName,
CancellationToken cancellationToken = default);
Task<OpenClawAgentFileWriteDto> SetAgentFileAsync(
string agentId,
string fileName,
UpdateOpenClawAgentFileRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default);
Task<OpenClawWorkspaceCollectionDto> GetWorkspaceAsync(
string agentId,
string? path,
int offset,
int limit,
CancellationToken cancellationToken = default);
Task<OpenClawWorkspaceFileDto> GetWorkspaceFileAsync(
string agentId,
string path,
CancellationToken cancellationToken = default);
Task<OpenClawConfigSchemaLookupDto> GetConfigSchemaAsync(
string path,
CancellationToken cancellationToken = default);
Task<OpenClawConfigSnapshotDto> GetConfigAsync(
CancellationToken cancellationToken = default);
Task<OpenClawConfigPatchDto> PatchConfigAsync(
PatchOpenClawConfigRequest request,
OpenClawInvocationContext invocationContext,
CancellationToken cancellationToken = default);
}
public sealed class OpenClawAgentConfigurationValidationException(
string field,
string message) : Exception(message)
{
public string Field { get; } = field;
}
public sealed class OpenClawAgentConfigurationConflictException(
string code,
string message,
string? expectedHash = null,
string? currentHash = null) : Exception(message)
{
public string Code { get; } = code;
public string? ExpectedHash { get; } = expectedHash;
public string? CurrentHash { get; } = currentHash;
}
public sealed class OpenClawAgentConfigurationUnavailableException(
string state,
string method,
string requiredScope,
string message) : Exception(message)
{
public string State { get; } = state;
public string Method { get; } = method;
public string RequiredScope { get; } = requiredScope;
}
public sealed class OpenClawAgentConfigurationVerificationException(
string message) : Exception(message);
+67
View File
@@ -0,0 +1,67 @@
using Nexus.Api.Integrations;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawChatService
{
Task<AgentChatResult> SendAsync(
string message,
string conversationId,
string agentId,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
}
/// <summary>
/// The single Iris/agent chat dispatch path. Every message becomes a durable
/// Nexus run and crosses the Protocol-v4 chat.send boundary; Nexus never calls
/// OpenClaw's OpenAI-compatible /v1/chat/completions endpoint.
/// </summary>
public sealed class OpenClawChatService(IOpenClawRunService runs) :
IOpenClawChatService
{
public async Task<AgentChatResult> SendAsync(
string message,
string conversationId,
string agentId,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default)
{
var normalizedAgent = agentId.Trim().ToLowerInvariant();
var operation = await runs.StartAsync(
new StartOpenClawRunRequest(
message,
normalizedAgent,
$"agent:{normalizedAgent}:main",
Title: $"Chat with {normalizedAgent}"),
invocation,
cancellationToken);
if (!operation.Ok)
{
throw new OpenClawChatDispatchException(
operation.State,
operation.Message,
operation.Run.Id);
}
return new AgentChatResult(
"OpenClaw Protocol v4",
normalizedAgent,
conversationId,
operation.Message,
operation.Run.Id,
operation.State,
operation.Operation);
}
}
public sealed class OpenClawChatDispatchException(
string state,
string message,
Guid runId) : InvalidOperationException(message)
{
public string State { get; } = state;
public Guid RunId { get; } = runId;
}
@@ -0,0 +1,95 @@
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawControlService
{
OpenClawConnectionDto GetConnection();
IReadOnlyList<OpenClawCapabilityDto> GetCapabilities();
Task<OpenClawOverviewDto> GetOverviewAsync(CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawTaskDto>> GetTasksAsync(
int limit = 100,
string? cursor = null,
CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawSessionDto>> GetSessionsAsync(
int limit = 100,
CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawCronJobDto>> GetCronJobsAsync(
int limit = 100,
CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawCronJobDto>> GetCronJobsAsync(
bool includeDisabled,
int limit = 100,
string? cursor = null,
CancellationToken cancellationToken = default);
Task<OpenClawOperationDto<OpenClawCronJobDetailDto>> GetCronJobAsync(
string jobId,
CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawCronRunDto>> GetCronRunsAsync(
string jobId,
int limit = 100,
string? cursor = null,
string? runId = null,
CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawApprovalDto>> GetApprovalsAsync(
int limit = 100,
CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawActivityDto>> GetActivityAsync(
int limit = 100,
string? cursor = null,
CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawModelDto>> GetModelsAsync(
CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawModelAuthProviderDto>> GetModelAuthStatusAsync(
bool refresh = false,
CancellationToken cancellationToken = default);
Task<OpenClawCollectionDto<OpenClawAgentDto>> GetAgentsAsync(
CancellationToken cancellationToken = default);
Task<OpenClawOperationDto<OpenClawTaskDto>> CancelTaskAsync(
string taskId,
string? reason,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
Task<OpenClawOperationDto<object>> AbortSessionAsync(
string sessionKey,
string? runId,
bool clearQueued,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
Task<OpenClawOperationDto<object>> PatchSessionModelAsync(
string sessionKey,
string model,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
Task<OpenClawOperationDto<object>> RunCronJobAsync(
string jobId,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
Task<OpenClawOperationDto<object>> RunCronJobAsync(
string jobId,
string? expectedHash,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
Task<OpenClawOperationDto<OpenClawCronJobDetailDto>> CreateCronJobAsync(
CreateOpenClawCronJobRequest request,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
Task<OpenClawOperationDto<OpenClawCronJobDetailDto>> PatchCronJobAsync(
string jobId,
JsonObject patch,
string? expectedHash,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
Task<OpenClawOperationDto<object>> DeleteCronJobAsync(
string jobId,
string? expectedHash = null,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
Task<OpenClawOperationDto<OpenClawApprovalDto>> ResolveApprovalAsync(
string approvalId,
string kind,
string decision,
CancellationToken cancellationToken = default,
OpenClawInvocationContext? invocationContext = null);
}
@@ -0,0 +1,10 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawEventProjectionService
{
OpenClawEventBatch Project(string? lastEventId, int limit = 500);
OpenClawStreamEventDto CreateConnectionEvent(string? cursor);
OpenClawStreamEventDto CreateHeartbeatEvent(string? cursor);
}
+10 -14
View File
@@ -1,21 +1,17 @@
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
/// <summary>
/// Bounded compatibility client for session history, which is not yet exposed
/// through the typed OpenClaw control projection used by the dashboard.
/// Agent, model, cron, status and mutation discovery belong to
/// <see cref="IOpenClawControlService"/>.
/// </summary>
public interface IOpenClawGatewayClient
{
Task<JsonNode?> InvokeToolAsync(string tool, object? args = null);
Task<DashboardStatus> GetStatusAsync();
Task<List<DashboardAgentInfo>> GetAgentsAsync();
Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0);
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
Task<List<QueueItem>> GetQueueAsync();
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default);
Task<bool> DeleteCronJobAsync(string id);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5);
List<ModelOption> GetAvailableModels();
Task<List<MessageEntry>> GetSessionHistoryAsync(
string sessionKey,
int limit = 50,
int offset = 0);
}
+29
View File
@@ -0,0 +1,29 @@
using System.Text.Json.Nodes;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawRunGateway
{
Task<OpenClawRunGatewayResult> StartAsync(
OpenClawRun run,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
Task<OpenClawRunGatewayResult> StopAsync(
OpenClawRun run,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
Task<OpenClawRunGatewayResult> GetHistoryAsync(
OpenClawRun run,
int limit,
CancellationToken cancellationToken = default);
}
public sealed record OpenClawRunGatewayResult(
bool Supported,
bool Ok,
string State,
string Message,
string? OpenClawRunId = null,
JsonNode? Data = null);
+37
View File
@@ -0,0 +1,37 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawRunService
{
Task<OpenClawRunCollectionDto> GetAsync(
OpenClawRunQuery query,
CancellationToken cancellationToken = default);
Task<OpenClawRunDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<OpenClawRunOperationDto> StartAsync(
StartOpenClawRunRequest request,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
Task<OpenClawRunOperationDto?> StopAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
Task<OpenClawRunOperationDto?> ResumeAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
Task<OpenClawRunOperationDto?> RetryAsync(
Guid id,
string? reason,
OpenClawInvocationMetadata invocation,
CancellationToken cancellationToken = default);
Task<OpenClawRunHistoryResponse?> GetHistoryAsync(
Guid id,
int gatewayLimit = 200,
CancellationToken cancellationToken = default);
Task ReconcileAsync(
GatewayEventEnvelope gatewayEvent,
CancellationToken cancellationToken = default);
}
+37
View File
@@ -0,0 +1,37 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawSetupService
{
Task<OpenClawSetupStatusDto> GetStatusAsync(
CancellationToken cancellationToken = default);
Task<OpenClawDiscoveryDto> DiscoverAsync(
OpenClawDiscoveryRequest request,
CancellationToken cancellationToken = default);
Task<OpenClawSetupOperationDto<OpenClawProbeDto>> ProbeAsync(
ProbeOpenClawRequest request,
CancellationToken cancellationToken = default);
Task<OpenClawSetupOperationDto<OpenClawSetupStatusDto>> AttachAsync(
AttachOpenClawRequest request,
CancellationToken cancellationToken = default);
Task<OpenClawSetupOperationDto<OpenClawSetupStatusDto>> VerifyAsync(
VerifyOpenClawRequest request,
CancellationToken cancellationToken = default);
Task<OpenClawSetupOperationDto<OpenClawAdoptionInventoryDto>> AdoptAsync(
AdoptOpenClawRequest request,
CancellationToken cancellationToken = default);
Task<OpenClawSetupOperationDto<OpenClawSetupStatusDto>> SetManagementAsync(
SetOpenClawManagementRequest request,
CancellationToken cancellationToken = default);
Task<OpenClawSetupOperationDto<OpenClawSetupStatusDto>> DeleteAsync(
DeleteOpenClawConnectionRequest request,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,25 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawWizardService
{
Task<OpenClawWizardResultDto> StartAsync(
StartOpenClawWizardRequest request,
OpenClawInvocationContext invocation,
CancellationToken cancellationToken = default);
Task<OpenClawWizardResultDto> NextAsync(
AdvanceOpenClawWizardRequest request,
OpenClawInvocationContext invocation,
CancellationToken cancellationToken = default);
Task<OpenClawWizardResultDto> GetStatusAsync(
string sessionId,
CancellationToken cancellationToken = default);
Task<OpenClawWizardResultDto> CancelAsync(
string sessionId,
OpenClawInvocationContext invocation,
CancellationToken cancellationToken = default);
}
+3
View File
@@ -11,6 +11,9 @@ public interface IProjectService
{
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default);
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetTasksAsync(
Guid id,
CancellationToken ct = default);
Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default);
Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default);
Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default);

Some files were not shown because too many files have changed in this diff Show More