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