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.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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user