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
+125 -44
View File
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
@@ -36,6 +37,9 @@ public class DashboardController(
=> await dashboardService.GetOperationsAsync(limit, agent);
[HttpPost("chat/send")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
[Obsolete("Legacy adapter. Use POST /api/v1/chat.")]
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
@@ -61,6 +65,8 @@ public class DashboardController(
=> await dashboardService.GetGatewayInfoAsync(ct);
[HttpDelete("queue/{id}")]
[Authorize(Roles = "owner")]
[EnableRateLimiting("agents")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
@@ -71,6 +77,14 @@ public class DashboardController(
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
QueueDeleteOutcome.Ignored when string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)
=> StatusCode(
StatusCodes.Status410Gone,
new
{
error = "Legacy cron deletion has been removed",
recovery = $"Use DELETE /api/v1/openclaw/cron/{Uri.EscapeDataString(id)} with Idempotency-Key and a current resource hash."
}),
_ => StatusCode(500, new { error = "Internal error" })
};
}
@@ -112,8 +126,8 @@ public class DashboardController(
=> await dashboardService.GetAgentActivityAsync(id, limit);
[HttpGet("models")]
public ActionResult<List<ModelOption>> GetAvailableModels()
=> Ok(dashboardService.GetAvailableModels());
public async Task<ActionResult<List<ModelOption>>> GetAvailableModels(CancellationToken ct)
=> Ok(await dashboardService.GetAvailableModelsAsync(ct));
// ── Task Endpoints ──
@@ -121,7 +135,7 @@ public class DashboardController(
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
{
var tasks = await taskService.GetOpenAsync(ct);
return tasks.Select(MapToDto).ToList();
return tasks.Select(task => MapToDto(task)).ToList();
}
[HttpPost("tasks")]
@@ -135,7 +149,9 @@ public class DashboardController(
{
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
return Created(
$"/api/dashboard/tasks/{task.Id}",
MapToDto(task, TaskOperation(task, "created")));
}
catch (ArgumentException ex)
{
@@ -152,7 +168,9 @@ public class DashboardController(
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
@@ -164,7 +182,9 @@ public class DashboardController(
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
_ => NoContent()
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "deleted")))
};
}
@@ -178,7 +198,7 @@ public class DashboardController(
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
var callerAgent = await ResolveCallerAgentAsync(ct);
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
@@ -191,13 +211,14 @@ public class DashboardController(
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
// ── Task Board Endpoints ──
[AllowAnonymous]
[HttpGet("tasks/board")]
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
{
@@ -227,24 +248,45 @@ public class DashboardController(
}
var currentSequence = liveUpdateService.CurrentSequence;
// Subscribe before loading the snapshot so updates published while the
// snapshot is assembled are queued and delivered afterwards.
var subscription = await liveUpdateService.SubscribeAsync(currentSequence, ct);
var initial = new DashboardLiveSnapshotDto(
await taskService.GetBoardAsync(ct),
await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct),
new LiveCursorDto(currentSequence, DateTimeOffset.UtcNow, "live"));
await WriteEventAsync("snapshot", initial);
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
// A fresh snapshot is the authoritative baseline for this legacy
// adapter. Only advance this cursor after an update has actually been
// written; CurrentSequence may include events still queued below.
var lastSent = currentSequence;
while (!ct.IsCancellationRequested)
{
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
using var iteration = CancellationTokenSource.CreateLinkedTokenSource(ct);
var readTask = subscription.Reader.WaitToReadAsync(iteration.Token).AsTask();
var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), iteration.Token);
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == readTask)
if (completed == heartbeatTask)
{
var envelope = await readTask;
iteration.Cancel();
await WriteEventAsync(
"heartbeat",
new LiveCursorDto(lastSent, DateTimeOffset.UtcNow, "live"));
continue;
}
iteration.Cancel();
if (!await readTask)
break;
while (subscription.Reader.TryRead(out var envelope))
{
if (envelope.Sequence <= lastSent)
continue;
if (envelope.Type == "notifications.snapshot")
{
var snapshot = envelope.Payload as NotificationSnapshotDto
@@ -262,10 +304,7 @@ public class DashboardController(
await WriteEventAsync("update", new DashboardLiveEventDto(
envelope,
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
}
else if (await heartbeatTask)
{
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
lastSent = envelope.Sequence;
}
}
}
@@ -283,7 +322,7 @@ public class DashboardController(
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
var callerAgent = await ResolveCallerAgentAsync(ct);
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
@@ -296,24 +335,36 @@ public class DashboardController(
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
_ => Ok(MapToDto(
result.Task!,
TaskOperation(result.Task!, "updated")))
};
}
/// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly).
/// Resolves the caller identity from a verified principal. X-Agent-Id is
/// treated only as an authenticated, allow-listed actor hint.
/// </summary>
private string ResolveCallerAgent()
private async Task<string> ResolveCallerAgentAsync(CancellationToken ct)
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(
httpContext,
agentService,
configuration,
ct);
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
return agentHeader;
var user = httpContext.User;
if (user?.Identity?.IsAuthenticated != true)
return "";
if (user.IsInRole("owner") || user.IsInRole("admin"))
return "bao";
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
@@ -326,7 +377,11 @@ public class DashboardController(
{
var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours));
var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct);
return Ok(new ResetStaleResponse(count));
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
count > 0 ? "completed" : "noop",
new EntityRefDto("task-board", "active", "Task Board"));
return Ok(new ResetStaleResponse(count, operation));
}
[HttpGet("tasks/{id:guid}/children")]
@@ -361,7 +416,7 @@ public class DashboardController(
}
[HttpPost("tasks/{id:guid}/activity")]
public async Task<ActionResult<ActivityEvent>> PostTaskActivity(
public async Task<ActionResult<ActivityItemDto>> PostTaskActivity(
Guid id, [FromBody] PostActivityRequest request, CancellationToken ct)
{
var task = await taskService.GetByIdAsync(id, ct);
@@ -378,7 +433,21 @@ public class DashboardController(
};
await activityService.AddAsync(ev, ct);
return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev);
var entity = new EntityRefDto("task", task.Id.ToString(), task.Title);
var operation = OperationResultFactory.FromHttpContext(
HttpContext,
"created",
new EntityRefDto("activity", ev.Id.ToString(), ev.Type),
affectedRefs: [entity]);
return Created(
$"/api/dashboard/tasks/{id}/activity/{ev.Id}",
new ActivityItemDto(
ev.Id,
ev.Type,
ev.Message,
ev.CreatedAt,
entity,
operation));
}
// ── Agent Workflow Endpoints (Iris Overview) ──
@@ -391,7 +460,7 @@ public class DashboardController(
public async Task<ActionResult<List<DashboardTaskDto>>> GetAgentWaitingTasks(CancellationToken ct)
{
var waiting = await taskService.GetWaitingTasksAsync(ct);
return Ok(waiting.Select(MapToDto).ToList());
return Ok(waiting.Select(task => MapToDto(task)).ToList());
}
/// <summary>
@@ -424,7 +493,9 @@ public class DashboardController(
request.Priority, request.AssignedTo, request.ExpectedFrom,
request.ParentTaskId, request.StartsInProgress, request.InitialState, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
return Created(
$"/api/dashboard/tasks/{task.Id}",
MapToDto(task, TaskOperation(task, "created")));
}
catch (ArgumentException ex)
{
@@ -432,20 +503,30 @@ public class DashboardController(
}
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
private OperationResultDto TaskOperation(WorkTask task, string status)
{
var affected = new List<EntityRefDto>();
if (task.ProjectId is { } projectId)
affected.Add(new EntityRefDto("project", projectId.ToString()));
if (task.ParentTaskId is { } parentTaskId)
affected.Add(new EntityRefDto("task", parentTaskId.ToString(), "Parent task"));
return OperationResultFactory.FromHttpContext(
HttpContext,
status,
new EntityRefDto("task", task.Id.ToString(), task.Title),
affectedRefs: affected);
}
private static DashboardTaskDto MapToDto(
WorkTask t,
OperationResultDto? operation = null) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
t.IsAgentTask, t.ExpectedFrom,
ProjectId: t.ProjectId,
Operation: operation);
private async Task<bool> CanReadBoardAsync(CancellationToken ct)
{
var allowedAgent = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
if (!string.IsNullOrWhiteSpace(allowedAgent))
return true;
if (RequestAuthorizationHelper.HasValidServiceKey(HttpContext, configuration))
return true;
return User.Identity?.IsAuthenticated == true;
}
private Task<bool> CanReadBoardAsync(CancellationToken _)
=> Task.FromResult(RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration));
}