325 lines
13 KiB
C#
325 lines
13 KiB
C#
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;
|
|
|
|
namespace Nexus.Api.Controllers;
|
|
|
|
[Authorize]
|
|
[ApiController]
|
|
[Route("api/v1/tasks")]
|
|
public class TasksController(
|
|
ITaskService taskService,
|
|
IAgentService agentService,
|
|
IConfiguration configuration,
|
|
IActivityRepository activityRepository) : ControllerBase
|
|
{
|
|
[HttpGet]
|
|
public async Task<IResult> GetAll(CancellationToken ct)
|
|
=> Results.Ok(await taskService.GetAllAsync(ct));
|
|
|
|
[HttpPost]
|
|
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.Title))
|
|
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}",
|
|
MapTask(task, TaskOperation(task, "created")));
|
|
}
|
|
|
|
[HttpGet("pending-approval")]
|
|
[Authorize(Roles = "owner")]
|
|
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
|
{
|
|
var pending = await taskService.GetPendingApprovalAsync(ct);
|
|
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
|
|
}
|
|
|
|
[HttpPost("{id:guid}/approve")]
|
|
[Authorize(Roles = "owner")]
|
|
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
|
{
|
|
var result = await taskService.ApproveAsync(id, ct);
|
|
await WriteApprovalAuditAsync(id, "approve", result.Outcome, result.Task?.State, ct);
|
|
return result.Outcome switch
|
|
{
|
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
|
title: "Approval denied",
|
|
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
|
|
statusCode: StatusCodes.Status403Forbidden),
|
|
_ => Results.Ok(MapTask(
|
|
result.Task!,
|
|
TaskOperation(result.Task!, "completed")))
|
|
};
|
|
}
|
|
|
|
[HttpPost("{id:guid}/reject")]
|
|
[Authorize(Roles = "owner")]
|
|
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
|
{
|
|
var result = await taskService.RejectAsync(id, ct);
|
|
await WriteApprovalAuditAsync(id, "reject", result.Outcome, result.Task?.State, ct);
|
|
return result.Outcome switch
|
|
{
|
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
|
title: "Rejection denied",
|
|
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
|
|
statusCode: StatusCodes.Status403Forbidden),
|
|
_ => Results.Ok(MapTask(
|
|
result.Task!,
|
|
TaskOperation(result.Task!, "completed")))
|
|
};
|
|
}
|
|
|
|
[HttpPatch("{id:guid}/state")]
|
|
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
|
|
{
|
|
if (!TaskStateHelper.IsValidState(request.State))
|
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
|
|
|
|
var result = await taskService.UpdateStateAsync(id, request.State, ct);
|
|
return result.Outcome switch
|
|
{
|
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
|
title: "Action denied",
|
|
detail: "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben.",
|
|
statusCode: StatusCodes.Status403Forbidden),
|
|
_ => Results.Ok(MapTask(
|
|
result.Task!,
|
|
TaskOperation(result.Task!, "updated")))
|
|
};
|
|
}
|
|
|
|
[HttpPatch("{id:guid}")]
|
|
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
|
{
|
|
var result = await taskService.UpdateAsync(id, request, ct);
|
|
return result.Outcome switch
|
|
{
|
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
|
_ => Results.Ok(MapTask(
|
|
result.Task!,
|
|
TaskOperation(result.Task!, "updated")))
|
|
};
|
|
}
|
|
|
|
[HttpDelete("{id:guid}")]
|
|
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
|
{
|
|
var result = await taskService.DeleteAsync(id, ct);
|
|
return result.Outcome switch
|
|
{
|
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
|
title: "Task deletion denied",
|
|
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
|
statusCode: StatusCodes.Status403Forbidden),
|
|
_ => Results.Ok(MapTask(
|
|
result.Task!,
|
|
TaskOperation(result.Task!, "deleted")))
|
|
};
|
|
}
|
|
|
|
// ── Board & Stale-Reset (für Iris Autonomous Worker) ──
|
|
|
|
/// <summary>
|
|
/// 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 eine verifizierte JWT- oder
|
|
/// X-Nexus-Api-Key-Authentisierung.
|
|
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
|
|
/// </summary>
|
|
[HttpGet("board")]
|
|
[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)
|
|
{
|
|
if (!RequestAuthorizationHelper.HasVerifiedAuthentication(HttpContext, configuration))
|
|
return Results.Unauthorized();
|
|
|
|
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 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>
|
|
[HttpPost("reset-stale")]
|
|
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken 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)
|
|
return Results.Forbid();
|
|
|
|
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
|
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,
|
|
TaskOperationOutcome outcome,
|
|
string? state,
|
|
CancellationToken ct)
|
|
{
|
|
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")}",
|
|
TaskId = taskId
|
|
}, ct);
|
|
}
|
|
|
|
private static string DescribeCaller(ClaimsPrincipal user)
|
|
{
|
|
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
|
?? user.FindFirst(ClaimTypes.Email)?.Value
|
|
?? user.Identity?.Name
|
|
?? "unknown";
|
|
|
|
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
|
|
return $"{role}:{subject}".ToLowerInvariant();
|
|
}
|
|
}
|