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