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

This commit is contained in:
AzuTear
2026-07-31 22:39:47 +02:00
parent 3bc7622977
commit f5552218bc
535 changed files with 95242 additions and 8791 deletions
+104 -42
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
@@ -13,13 +14,14 @@ namespace Nexus.Api.Controllers;
[Route("api/v1/agents")]
public class AgentsController(
IAgentService agentService,
IAgentRuntime runtime,
IOpenClawChatService chat,
IActivityRepository activityRepo,
IAgentConfigService agentConfigService,
IOpenClawAgentConfigurationService agentConfiguration,
IDashboardService dashboardService,
ILogger<AgentsController> logger) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<AgentListResponse>), StatusCodes.Status200OK)]
public async Task<IResult> GetAgents(CancellationToken ct)
{
var agents = await agentService.GetAgentsAsync(ct);
@@ -28,6 +30,8 @@ public class AgentsController(
}
[HttpGet("{id}")]
[ProducesResponseType(typeof(AgentDetailResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IResult> GetAgent(string id, CancellationToken ct)
{
var agent = await agentService.GetAgentAsync(id, ct);
@@ -39,6 +43,7 @@ public class AgentsController(
}
[HttpGet("{id}/activity")]
[ProducesResponseType(typeof(IReadOnlyList<AgentActivityResponse>), StatusCodes.Status200OK)]
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
{
var items = await activityRepo.GetByAgentAsync(id, 50, ct);
@@ -56,6 +61,7 @@ public class AgentsController(
}
[HttpGet("{id}/summary")]
[ProducesResponseType(typeof(AgentSummaryResponse), StatusCodes.Status200OK)]
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
{
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
@@ -64,7 +70,11 @@ public class AgentsController(
}
[HttpPost("{id}/command")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[ProducesResponseType(typeof(AgentCommandResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> SendCommand(string id, [FromBody] AgentCommandRequest request, CancellationToken ct)
{
var message = request.Message?.Trim();
@@ -75,8 +85,30 @@ public class AgentsController(
try
{
var result = await runtime.ChatAsync(message, conversationId, id, ct);
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
var context = OpenClawInvocationContextFactory.Create(
User.FindFirst("sub")?.Value
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? User.Identity?.Name,
Request.Headers["Idempotency-Key"].FirstOrDefault(),
Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? HttpContext.TraceIdentifier,
Request.Headers["traceparent"].FirstOrDefault()
?? Activity.Current?.Id);
var result = await chat.SendAsync(
message,
conversationId,
id,
new Models.OpenClawInvocationMetadata(
context.IdempotencyKey,
context.CorrelationId,
context.Actor,
context.TraceParent),
ct);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "agent",
Message = $"Command dispatched to agent {id} as durable run {result.RunId}"
}, ct);
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
}
catch (Exception exception)
@@ -92,16 +124,35 @@ public class AgentsController(
// ── Config Editor ──
[HttpGet("{id}/config")]
public IResult GetConfig(string id)
=> Results.Ok(agentConfigService.GetConfigFiles(id));
[Authorize(Roles = "owner")]
public async Task<IResult> GetConfig(string id, CancellationToken ct)
{
var files = await agentConfiguration.GetAgentFilesAsync(id, ct);
return Results.Ok(files.Files.Select(file => new
{
FileName = file.Name,
file.Size,
ModifiedAt = file.UpdatedAt,
file.Missing,
file.ContentHash
}));
}
[HttpGet("{id}/config/{fileName}")]
[Authorize(Roles = "owner")]
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
{
var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
return file is null
var file = await agentConfiguration.GetAgentFileAsync(id, fileName, ct);
return file.Missing
? Results.NotFound()
: Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
: Results.Ok(new
{
FileName = file.Name,
file.Content,
file.Size,
ModifiedAt = file.UpdatedAt,
file.ContentHash
});
}
[HttpPut("{id}/config/{fileName}")]
@@ -110,59 +161,70 @@ public class AgentsController(
{
if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." });
if (string.IsNullOrWhiteSpace(request.ExpectedHash))
return Results.BadRequest(new { error = "ExpectedHash is required." });
try
{
var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
var caller = DescribeCaller(HttpContext.User);
if (attempt.Failure is not null)
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = attempt.Failure.Validation.Errors.ToArray()
});
return Results.BadRequest(new { error = "Idempotency-Key header is required." });
}
var result = attempt.SaveResult!;
var invocation = OpenClawInvocationContext.Create(
caller,
idempotencyKey,
Request.Headers["X-Correlation-ID"].FirstOrDefault(),
Request.Headers["traceparent"].FirstOrDefault());
var result = await agentConfiguration.SetAgentFileAsync(
id,
fileName,
new Nexus.Api.Models.UpdateOpenClawAgentFileRequest(request.Content, request.ExpectedHash),
invocation,
ct);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
Message = $"Config save agent={id} file={fileName} caller={caller} verified={result.Verified} state={result.State}",
}, ct);
return Results.Ok(new
{
result.FileName,
result.Size,
result.ModifiedAt,
result.Validation,
result.Backup,
ReloadCheck = result.ReloadCheck
FileName = result.File.Name,
result.File.Size,
ModifiedAt = result.File.UpdatedAt,
result.File.ContentHash,
result.Verified,
result.State,
result.Message
});
}
catch (UnauthorizedAccessException ex)
catch (OpenClawAgentConfigurationConflictException ex)
{
logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "Permission denied",
detail: $"Cannot write config file '{fileName}' for agent '{id}'. The target path may be owned by a different user.",
statusCode: StatusCodes.Status500InternalServerError);
return Results.Json(
new
{
code = ex.Code,
message = ex.Message,
ex.ExpectedHash,
ex.CurrentHash
},
statusCode: StatusCodes.Status409Conflict);
}
catch (IOException ex)
catch (OpenClawAgentConfigurationValidationException ex)
{
logger.LogError(ex, "I/O error saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "File write error",
detail: $"Failed to write config file '{fileName}' for agent '{id}': {ex.Message}",
statusCode: StatusCodes.Status500InternalServerError);
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={DescribeCaller(HttpContext.User)} validation=failed code=validation_failed",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[ex.Field] = [ex.Message]
});
}
}