- Register RunService and IRunStore (InMemoryRunStore) in DI - Disambiguate IGuidGenerator namespaces (Chat vs Runs) - Mount RunEndpoints at canonical /v1/advisory-ai/runs path - Make RunService public for WebService composition - Add integration tests for runs authorization and CRUD Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
945 lines
36 KiB
C#
945 lines
36 KiB
C#
// <copyright file="RunEndpoints.cs" company="StellaOps">
|
|
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
|
// </copyright>
|
|
|
|
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using StellaOps.AdvisoryAI.Runs;
|
|
using StellaOps.AdvisoryAI.WebService.Security;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
using StellaOps.Determinism;
|
|
using System.Collections.Immutable;
|
|
using static StellaOps.Localization.T;
|
|
|
|
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// API endpoints for AI investigation runs.
|
|
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-006
|
|
/// </summary>
|
|
public static class RunEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps run endpoints to the route builder.
|
|
/// </summary>
|
|
/// <param name="builder">The endpoint route builder.</param>
|
|
/// <returns>The route group builder.</returns>
|
|
public static RouteGroupBuilder MapRunEndpoints(this IEndpointRouteBuilder builder)
|
|
{
|
|
var group = builder.MapGroup("/v1/advisory-ai/runs")
|
|
.WithTags("Runs")
|
|
.RequireAuthorization(AdvisoryAIPolicies.ViewPolicy)
|
|
.RequireTenant();
|
|
|
|
group.MapPost("/", CreateRunAsync)
|
|
.WithName("CreateRun")
|
|
.WithSummary("Creates a new AI investigation run")
|
|
.WithDescription("Creates a new AI investigation run scoped to the authenticated tenant, capturing the title, objective, and optional CVE/component/SBOM context. The run begins in the Created state and accumulates events as the investigation progresses. Returns 201 with the initial run state and a Location header.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunDto>(StatusCodes.Status201Created)
|
|
.ProducesValidationProblem();
|
|
|
|
group.MapGet("/{runId}", GetRunAsync)
|
|
.WithName("GetRun")
|
|
.WithSummary("Gets a run by ID")
|
|
.WithDescription("Returns the current state of an AI investigation run, including status, event count, artifact count, content digest, attestation flag, context, and approval info. Returns 404 if the run does not exist or belongs to a different tenant.")
|
|
.Produces<RunDto>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/", QueryRunsAsync)
|
|
.WithName("QueryRuns")
|
|
.WithSummary("Queries runs with filters")
|
|
.WithDescription("Returns a paginated list of AI investigation runs for the current tenant, optionally filtered by initiator, CVE ID, component, and status. Supports skip/take pagination. Results are ordered by creation time descending.")
|
|
.Produces<RunQueryResultDto>();
|
|
|
|
group.MapGet("/{runId}/timeline", GetTimelineAsync)
|
|
.WithName("GetRunTimeline")
|
|
.WithSummary("Gets the event timeline for a run")
|
|
.WithDescription("Returns the ordered event timeline for an AI investigation run, including user turns, assistant turns, proposed actions, approvals, and artifact additions. Supports skip/take pagination over the event sequence. Returns 404 if the run does not exist.")
|
|
.Produces<ImmutableArray<RunEventDto>>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{runId}/events", AddEventAsync)
|
|
.WithName("AddRunEvent")
|
|
.WithSummary("Adds an event to a run")
|
|
.WithDescription("Appends a typed event to an active AI investigation run, supporting arbitrary event types with optional content payload, evidence links, and parent event reference for threading. Returns 201 with the created event. Returns 404 if the run does not exist or is in a terminal state.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{runId}/turns/user", AddUserTurnAsync)
|
|
.WithName("AddUserTurn")
|
|
.WithSummary("Adds a user turn to the run")
|
|
.WithDescription("Appends a user conversational turn to an active AI investigation run, recording the message text, actor ID, and optional evidence links. User turns drive the investigation dialogue and are included in the run content digest for attestation purposes.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{runId}/turns/assistant", AddAssistantTurnAsync)
|
|
.WithName("AddAssistantTurn")
|
|
.WithSummary("Adds an assistant turn to the run")
|
|
.WithDescription("Appends an AI assistant conversational turn to an active run, recording the generated message and optional evidence links. Assistant turns are included in the run content digest and contribute to the attestable evidence chain for the investigation.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{runId}/actions", ProposeActionAsync)
|
|
.WithName("ProposeAction")
|
|
.WithSummary("Proposes an action in the run")
|
|
.WithDescription("Records an AI-proposed action in a run, including the action type, subject, rationale, parameters, and whether human approval is required before execution. Actions flagged as requiring approval transition the run to PendingApproval once approval is requested. Returns 404 if the run is not active.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunEventDto>(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{runId}/approval/request", RequestApprovalAsync)
|
|
.WithName("RequestApproval")
|
|
.WithSummary("Requests approval for pending actions")
|
|
.WithDescription("Transitions a run to the PendingApproval state and notifies the designated approvers. The request body specifies the approver IDs and an optional reason. Returns the updated run state. Returns 404 if the run does not exist or is not in a state that allows approval requests.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunDto>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{runId}/approval/decide", ApproveAsync)
|
|
.WithName("ApproveRun")
|
|
.WithSummary("Approves or rejects a run")
|
|
.WithDescription("Records an approval or rejection decision for a run in PendingApproval state. On approval, the run transitions back to Active so approved actions can be executed. On rejection, the run is cancelled. Returns 400 if the run is not in an approvable state.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunDto>()
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/{runId}/actions/{actionEventId}/execute", ExecuteActionAsync)
|
|
.WithName("ExecuteAction")
|
|
.WithSummary("Executes an approved action")
|
|
.WithDescription("Marks a previously proposed and approved action as executed, recording the execution result in the run timeline. Only actions that have been approved may be executed; attempting to execute a pending or rejected action returns 400. Returns 404 if the run or action event does not exist.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunEventDto>()
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/{runId}/artifacts", AddArtifactAsync)
|
|
.WithName("AddArtifact")
|
|
.WithSummary("Adds an artifact to the run")
|
|
.WithDescription("Attaches an artifact (evidence pack, report, SBOM snippet, or other typed asset) to an active run. The artifact is recorded with its content digest, media type, size, and optional inline content. Adding an artifact updates the run's content digest, contributing to its attestation chain.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunDto>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{runId}/complete", CompleteRunAsync)
|
|
.WithName("CompleteRun")
|
|
.WithSummary("Completes a run")
|
|
.WithDescription("Transitions an active AI investigation run to the Completed terminal state, optionally recording a summary of findings. Once completed, the run is immutable and ready for attestation. Returns 400 if the run is already in a terminal state.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunDto>()
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/{runId}/cancel", CancelRunAsync)
|
|
.WithName("CancelRun")
|
|
.WithSummary("Cancels a run")
|
|
.WithDescription("Transitions an active or pending-approval AI investigation run to the Cancelled terminal state, optionally recording a cancellation reason. Cancelled runs are immutable and excluded from active and pending-approval queries. Returns 400 if the run is already in a terminal state.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunDto>()
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapPost("/{runId}/handoff", HandOffRunAsync)
|
|
.WithName("HandOffRun")
|
|
.WithSummary("Hands off a run to another user")
|
|
.WithDescription("Transfers ownership of an active AI investigation run to another user within the same tenant. A hand-off event is recorded in the run timeline with the target user ID and an optional message. Returns 404 if the run does not exist or the target user is not valid.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunDto>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapPost("/{runId}/attest", AttestRunAsync)
|
|
.WithName("AttestRun")
|
|
.WithSummary("Creates an attestation for a completed run")
|
|
.WithDescription("Generates and persists a cryptographic attestation for a completed AI investigation run, recording the content digest, model metadata, and claim hashes. The attestation can optionally be signed via the attestation sign endpoint. Returns 400 if the run is not in a terminal state or has already been attested.")
|
|
.RequireAuthorization(AdvisoryAIPolicies.OperatePolicy)
|
|
.Produces<RunDto>()
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status400BadRequest);
|
|
|
|
group.MapGet("/active", GetActiveRunsAsync)
|
|
.WithName("GetActiveRuns")
|
|
.WithSummary("Gets active runs for the current user")
|
|
.WithDescription("Returns up to 50 AI investigation runs in Created, Active, or PendingApproval state that were initiated by the current user within the authenticated tenant. Use this endpoint to resume in-progress investigations or surface runs awaiting user input.")
|
|
.Produces<ImmutableArray<RunDto>>();
|
|
|
|
group.MapGet("/pending-approval", GetPendingApprovalAsync)
|
|
.WithName("GetPendingApproval")
|
|
.WithSummary("Gets runs pending approval")
|
|
.WithDescription("Returns up to 50 AI investigation runs in the PendingApproval state for the authenticated tenant. Use this endpoint to surface runs that are blocked on a human approval decision before their proposed actions can be executed.")
|
|
.Produces<ImmutableArray<RunDto>>();
|
|
|
|
return group;
|
|
}
|
|
|
|
private static async Task<IResult> CreateRunAsync(
|
|
[FromBody] CreateRunRequestDto request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
var run = await runService.CreateAsync(new CreateRunRequest
|
|
{
|
|
TenantId = tenantId,
|
|
InitiatedBy = userId,
|
|
Title = request.Title,
|
|
Objective = request.Objective,
|
|
Context = request.Context is not null ? MapToContext(request.Context) : null,
|
|
Metadata = request.Metadata?.ToImmutableDictionary()
|
|
}, ct);
|
|
|
|
return Results.Created($"/api/v1/runs/{run.RunId}", MapToDto(run));
|
|
}
|
|
|
|
private static async Task<IResult> GetRunAsync(
|
|
string runId,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
var run = await runService.GetAsync(tenantId, runId, ct);
|
|
if (run is null)
|
|
{
|
|
return Results.NotFound(new { message = _t("advisoryai.error.run_not_found", runId) });
|
|
}
|
|
|
|
return Results.Ok(MapToDto(run));
|
|
}
|
|
|
|
private static async Task<IResult> QueryRunsAsync(
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromQuery] string? initiatedBy,
|
|
[FromQuery] string? cveId,
|
|
[FromQuery] string? component,
|
|
[FromQuery] string? status,
|
|
[FromQuery] int skip = 0,
|
|
[FromQuery] int take = 20,
|
|
CancellationToken ct = default)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
ImmutableArray<RunStatus>? statuses = null;
|
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<RunStatus>(status, true, out var parsedStatus))
|
|
{
|
|
statuses = [parsedStatus];
|
|
}
|
|
|
|
var result = await runService.QueryAsync(new RunQuery
|
|
{
|
|
TenantId = tenantId,
|
|
InitiatedBy = initiatedBy,
|
|
CveId = cveId,
|
|
Component = component,
|
|
Statuses = statuses,
|
|
Skip = skip,
|
|
Take = take
|
|
}, ct);
|
|
|
|
return Results.Ok(new RunQueryResultDto
|
|
{
|
|
Runs = result.Runs.Select(MapToDto).ToImmutableArray(),
|
|
TotalCount = result.TotalCount,
|
|
HasMore = result.HasMore
|
|
});
|
|
}
|
|
|
|
private static async Task<IResult> GetTimelineAsync(
|
|
string runId,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromQuery] int skip = 0,
|
|
[FromQuery] int take = 100,
|
|
CancellationToken ct = default)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
var events = await runService.GetTimelineAsync(tenantId, runId, skip, take, ct);
|
|
return Results.Ok(events.Select(MapEventToDto).ToImmutableArray());
|
|
}
|
|
|
|
private static async Task<IResult> AddEventAsync(
|
|
string runId,
|
|
[FromBody] AddEventRequestDto request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var evt = await runService.AddEventAsync(tenantId, runId, new AddRunEventRequest
|
|
{
|
|
Type = request.Type,
|
|
ActorId = userId,
|
|
Content = request.Content,
|
|
EvidenceLinks = request.EvidenceLinks,
|
|
ParentEventId = request.ParentEventId,
|
|
Metadata = request.Metadata?.ToImmutableDictionary()
|
|
}, ct);
|
|
|
|
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> AddUserTurnAsync(
|
|
string runId,
|
|
[FromBody] AddTurnRequestDto request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
try
|
|
{
|
|
var evt = await runService.AddUserTurnAsync(
|
|
tenantId, runId, request.Message, userId, request.EvidenceLinks, ct);
|
|
|
|
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> AddAssistantTurnAsync(
|
|
string runId,
|
|
[FromBody] AddTurnRequestDto request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var evt = await runService.AddAssistantTurnAsync(
|
|
tenantId, runId, request.Message, request.EvidenceLinks, ct);
|
|
|
|
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> ProposeActionAsync(
|
|
string runId,
|
|
[FromBody] ProposeActionRequestDto request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var evt = await runService.ProposeActionAsync(tenantId, runId, new ProposeActionRequest
|
|
{
|
|
ActionType = request.ActionType,
|
|
Subject = request.Subject,
|
|
Rationale = request.Rationale,
|
|
RequiresApproval = request.RequiresApproval,
|
|
Parameters = request.Parameters?.ToImmutableDictionary(),
|
|
EvidenceLinks = request.EvidenceLinks
|
|
}, ct);
|
|
|
|
return Results.Created($"/api/v1/runs/{runId}/events/{evt.EventId}", MapEventToDto(evt));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> RequestApprovalAsync(
|
|
string runId,
|
|
[FromBody] RequestApprovalDto request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var run = await runService.RequestApprovalAsync(
|
|
tenantId, runId, [.. request.Approvers], request.Reason, ct);
|
|
|
|
return Results.Ok(MapToDto(run));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> ApproveAsync(
|
|
string runId,
|
|
[FromBody] ApprovalDecisionDto request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
try
|
|
{
|
|
var run = await runService.ApproveAsync(
|
|
tenantId, runId, request.Approved, userId, request.Reason, ct);
|
|
|
|
return Results.Ok(MapToDto(run));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> ExecuteActionAsync(
|
|
string runId,
|
|
string actionEventId,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var evt = await runService.ExecuteActionAsync(tenantId, runId, actionEventId, ct);
|
|
return Results.Ok(MapEventToDto(evt));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> AddArtifactAsync(
|
|
string runId,
|
|
[FromBody] AddArtifactRequestDto request,
|
|
[FromServices] IRunService runService,
|
|
[FromServices] TimeProvider timeProvider,
|
|
[FromServices] IGuidProvider guidProvider,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var run = await runService.AddArtifactAsync(tenantId, runId, new RunArtifact
|
|
{
|
|
ArtifactId = request.ArtifactId ?? guidProvider.NewGuid().ToString("N"),
|
|
Type = request.Type,
|
|
Name = request.Name,
|
|
Description = request.Description,
|
|
CreatedAt = timeProvider.GetUtcNow(),
|
|
ContentDigest = request.ContentDigest,
|
|
ContentSize = request.ContentSize,
|
|
MediaType = request.MediaType,
|
|
StorageUri = request.StorageUri,
|
|
IsInline = request.IsInline,
|
|
InlineContent = request.InlineContent,
|
|
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty
|
|
}, ct);
|
|
|
|
return Results.Ok(MapToDto(run));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> CompleteRunAsync(
|
|
string runId,
|
|
[FromBody] CompleteRunRequestDto? request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var run = await runService.CompleteAsync(tenantId, runId, request?.Summary, ct);
|
|
return Results.Ok(MapToDto(run));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> CancelRunAsync(
|
|
string runId,
|
|
[FromBody] CancelRunRequestDto? request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var run = await runService.CancelAsync(tenantId, runId, request?.Reason, ct);
|
|
return Results.Ok(MapToDto(run));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> HandOffRunAsync(
|
|
string runId,
|
|
[FromBody] HandOffRequestDto request,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var run = await runService.HandOffAsync(tenantId, runId, request.ToUserId, request.Message, ct);
|
|
return Results.Ok(MapToDto(run));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.NotFound(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> AttestRunAsync(
|
|
string runId,
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
try
|
|
{
|
|
var run = await runService.AttestAsync(tenantId, runId, ct);
|
|
return Results.Ok(MapToDto(run));
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(new { message = ex.Message });
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> GetActiveRunsAsync(
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
[FromHeader(Name = "X-User-Id")] string? userId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
userId ??= "anonymous";
|
|
|
|
var result = await runService.QueryAsync(new RunQuery
|
|
{
|
|
TenantId = tenantId,
|
|
InitiatedBy = userId,
|
|
Statuses = [RunStatus.Created, RunStatus.Active, RunStatus.PendingApproval],
|
|
Take = 50
|
|
}, ct);
|
|
|
|
return Results.Ok(result.Runs.Select(MapToDto).ToImmutableArray());
|
|
}
|
|
|
|
private static async Task<IResult> GetPendingApprovalAsync(
|
|
[FromServices] IRunService runService,
|
|
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
|
CancellationToken ct)
|
|
{
|
|
tenantId ??= "default";
|
|
|
|
var result = await runService.QueryAsync(new RunQuery
|
|
{
|
|
TenantId = tenantId,
|
|
Statuses = [RunStatus.PendingApproval],
|
|
Take = 50
|
|
}, ct);
|
|
|
|
return Results.Ok(result.Runs.Select(MapToDto).ToImmutableArray());
|
|
}
|
|
|
|
private static RunDto MapToDto(Run run) => new()
|
|
{
|
|
RunId = run.RunId,
|
|
TenantId = run.TenantId,
|
|
InitiatedBy = run.InitiatedBy,
|
|
Title = run.Title,
|
|
Objective = run.Objective,
|
|
Status = run.Status.ToString(),
|
|
CreatedAt = run.CreatedAt,
|
|
UpdatedAt = run.UpdatedAt,
|
|
CompletedAt = run.CompletedAt,
|
|
EventCount = run.Events.Length,
|
|
ArtifactCount = run.Artifacts.Length,
|
|
ContentDigest = run.ContentDigest,
|
|
IsAttested = run.Attestation is not null,
|
|
Context = MapContextToDto(run.Context),
|
|
Approval = run.Approval is not null ? MapApprovalToDto(run.Approval) : null,
|
|
Metadata = run.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
|
};
|
|
|
|
private static RunEventDto MapEventToDto(RunEvent evt) => new()
|
|
{
|
|
EventId = evt.EventId,
|
|
Type = evt.Type.ToString(),
|
|
Timestamp = evt.Timestamp,
|
|
ActorId = evt.ActorId,
|
|
SequenceNumber = evt.SequenceNumber,
|
|
ParentEventId = evt.ParentEventId,
|
|
EvidenceLinkCount = evt.EvidenceLinks.Length,
|
|
Metadata = evt.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
|
};
|
|
|
|
private static RunContextDto MapContextToDto(RunContext context) => new()
|
|
{
|
|
FocusedCveId = context.FocusedCveId,
|
|
FocusedComponent = context.FocusedComponent,
|
|
SbomDigest = context.SbomDigest,
|
|
ImageReference = context.ImageReference,
|
|
Tags = [.. context.Tags],
|
|
IsOpsMemoryEnriched = context.OpsMemory?.IsEnriched ?? false
|
|
};
|
|
|
|
private static ApprovalInfoDto MapApprovalToDto(ApprovalInfo approval) => new()
|
|
{
|
|
Required = approval.Required,
|
|
Approvers = [.. approval.Approvers],
|
|
Approved = approval.Approved,
|
|
ApprovedBy = approval.ApprovedBy,
|
|
ApprovedAt = approval.ApprovedAt,
|
|
Reason = approval.Reason
|
|
};
|
|
|
|
private static RunContext MapToContext(RunContextDto dto) => new()
|
|
{
|
|
FocusedCveId = dto.FocusedCveId,
|
|
FocusedComponent = dto.FocusedComponent,
|
|
SbomDigest = dto.SbomDigest,
|
|
ImageReference = dto.ImageReference,
|
|
Tags = [.. dto.Tags ?? []]
|
|
};
|
|
}
|
|
|
|
// DTOs
|
|
|
|
/// <summary>DTO for creating a run.</summary>
|
|
public sealed record CreateRunRequestDto
|
|
{
|
|
/// <summary>Gets the run title.</summary>
|
|
public required string Title { get; init; }
|
|
|
|
/// <summary>Gets the run objective.</summary>
|
|
public string? Objective { get; init; }
|
|
|
|
/// <summary>Gets the context.</summary>
|
|
public RunContextDto? Context { get; init; }
|
|
|
|
/// <summary>Gets metadata.</summary>
|
|
public Dictionary<string, string>? Metadata { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for run context.</summary>
|
|
public sealed record RunContextDto
|
|
{
|
|
/// <summary>Gets the focused CVE ID.</summary>
|
|
public string? FocusedCveId { get; init; }
|
|
|
|
/// <summary>Gets the focused component.</summary>
|
|
public string? FocusedComponent { get; init; }
|
|
|
|
/// <summary>Gets the SBOM digest.</summary>
|
|
public string? SbomDigest { get; init; }
|
|
|
|
/// <summary>Gets the image reference.</summary>
|
|
public string? ImageReference { get; init; }
|
|
|
|
/// <summary>Gets the tags.</summary>
|
|
public List<string>? Tags { get; init; }
|
|
|
|
/// <summary>Gets whether OpsMemory enrichment was applied.</summary>
|
|
public bool IsOpsMemoryEnriched { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for a run.</summary>
|
|
public sealed record RunDto
|
|
{
|
|
/// <summary>Gets the run ID.</summary>
|
|
public required string RunId { get; init; }
|
|
|
|
/// <summary>Gets the tenant ID.</summary>
|
|
public required string TenantId { get; init; }
|
|
|
|
/// <summary>Gets the initiator.</summary>
|
|
public required string InitiatedBy { get; init; }
|
|
|
|
/// <summary>Gets the title.</summary>
|
|
public required string Title { get; init; }
|
|
|
|
/// <summary>Gets the objective.</summary>
|
|
public string? Objective { get; init; }
|
|
|
|
/// <summary>Gets the status.</summary>
|
|
public required string Status { get; init; }
|
|
|
|
/// <summary>Gets the created timestamp.</summary>
|
|
public required DateTimeOffset CreatedAt { get; init; }
|
|
|
|
/// <summary>Gets the updated timestamp.</summary>
|
|
public DateTimeOffset UpdatedAt { get; init; }
|
|
|
|
/// <summary>Gets the completed timestamp.</summary>
|
|
public DateTimeOffset? CompletedAt { get; init; }
|
|
|
|
/// <summary>Gets the event count.</summary>
|
|
public int EventCount { get; init; }
|
|
|
|
/// <summary>Gets the artifact count.</summary>
|
|
public int ArtifactCount { get; init; }
|
|
|
|
/// <summary>Gets the content digest.</summary>
|
|
public string? ContentDigest { get; init; }
|
|
|
|
/// <summary>Gets whether the run is attested.</summary>
|
|
public bool IsAttested { get; init; }
|
|
|
|
/// <summary>Gets the context.</summary>
|
|
public RunContextDto? Context { get; init; }
|
|
|
|
/// <summary>Gets the approval info.</summary>
|
|
public ApprovalInfoDto? Approval { get; init; }
|
|
|
|
/// <summary>Gets metadata.</summary>
|
|
public Dictionary<string, string>? Metadata { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for run event.</summary>
|
|
public sealed record RunEventDto
|
|
{
|
|
/// <summary>Gets the event ID.</summary>
|
|
public required string EventId { get; init; }
|
|
|
|
/// <summary>Gets the event type.</summary>
|
|
public required string Type { get; init; }
|
|
|
|
/// <summary>Gets the timestamp.</summary>
|
|
public required DateTimeOffset Timestamp { get; init; }
|
|
|
|
/// <summary>Gets the actor ID.</summary>
|
|
public string? ActorId { get; init; }
|
|
|
|
/// <summary>Gets the sequence number.</summary>
|
|
public int SequenceNumber { get; init; }
|
|
|
|
/// <summary>Gets the parent event ID.</summary>
|
|
public string? ParentEventId { get; init; }
|
|
|
|
/// <summary>Gets the evidence link count.</summary>
|
|
public int EvidenceLinkCount { get; init; }
|
|
|
|
/// <summary>Gets metadata.</summary>
|
|
public Dictionary<string, string>? Metadata { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for approval info.</summary>
|
|
public sealed record ApprovalInfoDto
|
|
{
|
|
/// <summary>Gets whether approval is required.</summary>
|
|
public bool Required { get; init; }
|
|
|
|
/// <summary>Gets the approvers.</summary>
|
|
public List<string> Approvers { get; init; } = [];
|
|
|
|
/// <summary>Gets whether approved.</summary>
|
|
public bool? Approved { get; init; }
|
|
|
|
/// <summary>Gets who approved.</summary>
|
|
public string? ApprovedBy { get; init; }
|
|
|
|
/// <summary>Gets when approved.</summary>
|
|
public DateTimeOffset? ApprovedAt { get; init; }
|
|
|
|
/// <summary>Gets the reason.</summary>
|
|
public string? Reason { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for query results.</summary>
|
|
public sealed record RunQueryResultDto
|
|
{
|
|
/// <summary>Gets the runs.</summary>
|
|
public required ImmutableArray<RunDto> Runs { get; init; }
|
|
|
|
/// <summary>Gets the total count.</summary>
|
|
public required int TotalCount { get; init; }
|
|
|
|
/// <summary>Gets whether there are more results.</summary>
|
|
public bool HasMore { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for adding an event.</summary>
|
|
public sealed record AddEventRequestDto
|
|
{
|
|
/// <summary>Gets the event type.</summary>
|
|
public required RunEventType Type { get; init; }
|
|
|
|
/// <summary>Gets the content.</summary>
|
|
public RunEventContent? Content { get; init; }
|
|
|
|
/// <summary>Gets evidence links.</summary>
|
|
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
|
|
|
/// <summary>Gets the parent event ID.</summary>
|
|
public string? ParentEventId { get; init; }
|
|
|
|
/// <summary>Gets metadata.</summary>
|
|
public Dictionary<string, string>? Metadata { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for adding a turn.</summary>
|
|
public sealed record AddTurnRequestDto
|
|
{
|
|
/// <summary>Gets the message.</summary>
|
|
public required string Message { get; init; }
|
|
|
|
/// <summary>Gets evidence links.</summary>
|
|
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for proposing an action.</summary>
|
|
public sealed record ProposeActionRequestDto
|
|
{
|
|
/// <summary>Gets the action type.</summary>
|
|
public required string ActionType { get; init; }
|
|
|
|
/// <summary>Gets the subject.</summary>
|
|
public string? Subject { get; init; }
|
|
|
|
/// <summary>Gets the rationale.</summary>
|
|
public string? Rationale { get; init; }
|
|
|
|
/// <summary>Gets whether approval is required.</summary>
|
|
public bool RequiresApproval { get; init; } = true;
|
|
|
|
/// <summary>Gets the parameters.</summary>
|
|
public Dictionary<string, string>? Parameters { get; init; }
|
|
|
|
/// <summary>Gets evidence links.</summary>
|
|
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for requesting approval.</summary>
|
|
public sealed record RequestApprovalDto
|
|
{
|
|
/// <summary>Gets the approvers.</summary>
|
|
public required List<string> Approvers { get; init; }
|
|
|
|
/// <summary>Gets the reason.</summary>
|
|
public string? Reason { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for approval decision.</summary>
|
|
public sealed record ApprovalDecisionDto
|
|
{
|
|
/// <summary>Gets whether approved.</summary>
|
|
public required bool Approved { get; init; }
|
|
|
|
/// <summary>Gets the reason.</summary>
|
|
public string? Reason { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for adding an artifact.</summary>
|
|
public sealed record AddArtifactRequestDto
|
|
{
|
|
/// <summary>Gets the artifact ID.</summary>
|
|
public string? ArtifactId { get; init; }
|
|
|
|
/// <summary>Gets the artifact type.</summary>
|
|
public required ArtifactType Type { get; init; }
|
|
|
|
/// <summary>Gets the name.</summary>
|
|
public required string Name { get; init; }
|
|
|
|
/// <summary>Gets the description.</summary>
|
|
public string? Description { get; init; }
|
|
|
|
/// <summary>Gets the content digest.</summary>
|
|
public required string ContentDigest { get; init; }
|
|
|
|
/// <summary>Gets the content size.</summary>
|
|
public long ContentSize { get; init; }
|
|
|
|
/// <summary>Gets the media type.</summary>
|
|
public required string MediaType { get; init; }
|
|
|
|
/// <summary>Gets the storage URI.</summary>
|
|
public string? StorageUri { get; init; }
|
|
|
|
/// <summary>Gets whether inline.</summary>
|
|
public bool IsInline { get; init; }
|
|
|
|
/// <summary>Gets inline content.</summary>
|
|
public string? InlineContent { get; init; }
|
|
|
|
/// <summary>Gets metadata.</summary>
|
|
public Dictionary<string, string>? Metadata { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for completing a run.</summary>
|
|
public sealed record CompleteRunRequestDto
|
|
{
|
|
/// <summary>Gets the summary.</summary>
|
|
public string? Summary { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for canceling a run.</summary>
|
|
public sealed record CancelRunRequestDto
|
|
{
|
|
/// <summary>Gets the reason.</summary>
|
|
public string? Reason { get; init; }
|
|
}
|
|
|
|
/// <summary>DTO for hand off.</summary>
|
|
public sealed record HandOffRequestDto
|
|
{
|
|
/// <summary>Gets the target user ID.</summary>
|
|
public required string ToUserId { get; init; }
|
|
|
|
/// <summary>Gets the message.</summary>
|
|
public string? Message { get; init; }
|
|
}
|