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