sprints work

This commit is contained in:
master
2026-01-10 20:32:13 +02:00
parent 0d5eda86fc
commit 17d0631b8e
189 changed files with 40667 additions and 497 deletions

View File

@@ -0,0 +1,890 @@
// <copyright file="EvidencePackEndpoints.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Evidence.Pack;
using StellaOps.Evidence.Pack.Models;
namespace StellaOps.AdvisoryAI.WebService.Endpoints;
/// <summary>
/// API endpoints for Evidence Packs.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-010
/// </summary>
public static class EvidencePackEndpoints
{
/// <summary>
/// Maps all Evidence Pack endpoints.
/// </summary>
public static void MapEvidencePackEndpoints(this WebApplication app)
{
// POST /v1/evidence-packs - Create Evidence Pack
app.MapPost("/v1/evidence-packs", HandleCreateEvidencePack)
.WithName("evidence-packs.create")
.WithTags("EvidencePacks")
.Produces<EvidencePackResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/evidence-packs/{packId} - Get Evidence Pack
app.MapGet("/v1/evidence-packs/{packId}", HandleGetEvidencePack)
.WithName("evidence-packs.get")
.WithTags("EvidencePacks")
.Produces<EvidencePackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// POST /v1/evidence-packs/{packId}/sign - Sign Evidence Pack
app.MapPost("/v1/evidence-packs/{packId}/sign", HandleSignEvidencePack)
.WithName("evidence-packs.sign")
.WithTags("EvidencePacks")
.Produces<SignedEvidencePackResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// POST /v1/evidence-packs/{packId}/verify - Verify Evidence Pack
app.MapPost("/v1/evidence-packs/{packId}/verify", HandleVerifyEvidencePack)
.WithName("evidence-packs.verify")
.WithTags("EvidencePacks")
.Produces<EvidencePackVerificationResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/evidence-packs/{packId}/export - Export Evidence Pack
app.MapGet("/v1/evidence-packs/{packId}/export", HandleExportEvidencePack)
.WithName("evidence-packs.export")
.WithTags("EvidencePacks")
.Produces<byte[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/runs/{runId}/evidence-packs - List Evidence Packs for Run
app.MapGet("/v1/runs/{runId}/evidence-packs", HandleListRunEvidencePacks)
.WithName("evidence-packs.list-by-run")
.WithTags("EvidencePacks")
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
// GET /v1/evidence-packs - List Evidence Packs
app.MapGet("/v1/evidence-packs", HandleListEvidencePacks)
.WithName("evidence-packs.list")
.WithTags("EvidencePacks")
.Produces<EvidencePackListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.RequireRateLimiting("advisory-ai");
}
private static async Task<IResult> HandleCreateEvidencePack(
CreateEvidencePackRequest request,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
if (request.Claims is null || request.Claims.Count == 0)
{
return Results.BadRequest(new { error = "At least one claim is required" });
}
if (request.Evidence is null || request.Evidence.Count == 0)
{
return Results.BadRequest(new { error = "At least one evidence item is required" });
}
var claims = request.Claims.Select(c => new EvidenceClaim
{
ClaimId = c.ClaimId ?? $"claim-{Guid.NewGuid():N}"[..16],
Text = c.Text,
Type = Enum.TryParse<ClaimType>(c.Type, true, out var ct) ? ct : ClaimType.Custom,
Status = c.Status,
Confidence = c.Confidence,
EvidenceIds = c.EvidenceIds?.ToImmutableArray() ?? [],
Source = c.Source
}).ToArray();
var evidence = request.Evidence.Select(e => new EvidenceItem
{
EvidenceId = e.EvidenceId ?? $"ev-{Guid.NewGuid():N}"[..12],
Type = Enum.TryParse<EvidenceType>(e.Type, true, out var et) ? et : EvidenceType.Custom,
Uri = e.Uri,
Digest = e.Digest ?? "sha256:unknown",
CollectedAt = e.CollectedAt ?? DateTimeOffset.UtcNow,
Snapshot = EvidenceSnapshot.Custom(e.SnapshotType ?? "custom", (e.SnapshotData ?? new Dictionary<string, object>()).ToImmutableDictionary(x => x.Key, x => (object?)x.Value))
}).ToArray();
var subject = new EvidenceSubject
{
Type = Enum.TryParse<EvidenceSubjectType>(request.Subject?.Type, true, out var st)
? st
: EvidenceSubjectType.Custom,
FindingId = request.Subject?.FindingId,
CveId = request.Subject?.CveId,
Component = request.Subject?.Component,
ImageDigest = request.Subject?.ImageDigest
};
var context = new EvidencePackContext
{
TenantId = tenantId,
RunId = request.RunId,
ConversationId = request.ConversationId,
UserId = GetUserId(httpContext),
GeneratedBy = "API"
};
var pack = await evidencePackService.CreateAsync(claims, evidence, subject, context, cancellationToken)
.ConfigureAwait(false);
var response = EvidencePackResponse.FromPack(pack);
return Results.Created($"/v1/evidence-packs/{pack.PackId}", response);
}
private static async Task<IResult> HandleGetEvidencePack(
string packId,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
}
return Results.Ok(EvidencePackResponse.FromPack(pack));
}
private static async Task<IResult> HandleSignEvidencePack(
string packId,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
}
var signedPack = await evidencePackService.SignAsync(pack, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(SignedEvidencePackResponse.FromSignedPack(signedPack));
}
private static async Task<IResult> HandleVerifyEvidencePack(
string packId,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
}
// Get signed version from store
var store = httpContext.RequestServices.GetService<IEvidencePackStore>();
var signedPack = store is not null
? await store.GetSignedByIdAsync(tenantId, packId, cancellationToken).ConfigureAwait(false)
: null;
if (signedPack is null)
{
return Results.BadRequest(new { error = "Pack is not signed", packId });
}
var result = await evidencePackService.VerifyAsync(signedPack, cancellationToken)
.ConfigureAwait(false);
return Results.Ok(new EvidencePackVerificationResponse
{
PackId = packId,
Valid = result.Valid,
PackDigest = result.PackDigest,
SignatureKeyId = result.SignatureKeyId,
Issues = result.Issues.ToList(),
EvidenceResolutions = result.EvidenceResolutions.Select(r => new EvidenceResolutionApiResponse
{
EvidenceId = r.EvidenceId,
Uri = r.Uri,
Resolved = r.Resolved,
DigestMatches = r.DigestMatches,
Error = r.Error
}).ToList()
});
}
private static async Task<IResult> HandleExportEvidencePack(
string packId,
string? format,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var pack = await evidencePackService.GetAsync(tenantId, packId, cancellationToken)
.ConfigureAwait(false);
if (pack is null)
{
return Results.NotFound(new { error = "Evidence pack not found", packId });
}
var exportFormat = format?.ToLowerInvariant() switch
{
"markdown" or "md" => EvidencePackExportFormat.Markdown,
"html" => EvidencePackExportFormat.Html,
"pdf" => EvidencePackExportFormat.Pdf,
"signedjson" => EvidencePackExportFormat.SignedJson,
_ => EvidencePackExportFormat.Json
};
var export = await evidencePackService.ExportAsync(packId, exportFormat, cancellationToken)
.ConfigureAwait(false);
return Results.File(export.Content, export.ContentType, export.FileName);
}
private static async Task<IResult> HandleListRunEvidencePacks(
string runId,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var store = httpContext.RequestServices.GetService<IEvidencePackStore>();
if (store is null)
{
return Results.Ok(new EvidencePackListResponse { Count = 0, Packs = [] });
}
var packs = await store.GetByRunIdAsync(runId, cancellationToken).ConfigureAwait(false);
// Filter by tenant
var filtered = packs.Where(p => p.TenantId == tenantId).ToList();
return Results.Ok(new EvidencePackListResponse
{
Count = filtered.Count,
Packs = filtered.Select(EvidencePackSummary.FromPack).ToList()
});
}
private static async Task<IResult> HandleListEvidencePacks(
string? cveId,
string? runId,
int? limit,
IEvidencePackService evidencePackService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var tenantId = GetTenantId(httpContext);
if (string.IsNullOrEmpty(tenantId))
{
return Results.Unauthorized();
}
var store = httpContext.RequestServices.GetService<IEvidencePackStore>();
if (store is null)
{
return Results.Ok(new EvidencePackListResponse { Count = 0, Packs = [] });
}
var query = new EvidencePackQuery
{
CveId = cveId,
RunId = runId,
Limit = Math.Min(limit ?? 50, 100)
};
var packs = await store.ListAsync(tenantId, query, cancellationToken).ConfigureAwait(false);
return Results.Ok(new EvidencePackListResponse
{
Count = packs.Count,
Packs = packs.Select(EvidencePackSummary.FromPack).ToList()
});
}
private static string? GetTenantId(HttpContext context)
{
if (context.Request.Headers.TryGetValue("X-StellaOps-Tenant", out var tenant))
{
return tenant.ToString();
}
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
return tenantClaim;
}
private static string GetUserId(HttpContext context)
{
if (context.Request.Headers.TryGetValue("X-StellaOps-User", out var user))
{
return user.ToString();
}
return context.User?.FindFirst("sub")?.Value ?? "anonymous";
}
}
#region Request/Response Models
/// <summary>
/// Request to create an Evidence Pack.
/// </summary>
public sealed record CreateEvidencePackRequest
{
/// <summary>Subject of the evidence pack.</summary>
public EvidenceSubjectRequest? Subject { get; init; }
/// <summary>Claims in the pack.</summary>
public IReadOnlyList<EvidenceClaimRequest>? Claims { get; init; }
/// <summary>Evidence items.</summary>
public IReadOnlyList<EvidenceItemRequest>? Evidence { get; init; }
/// <summary>Optional Run ID to link to.</summary>
public string? RunId { get; init; }
/// <summary>Optional conversation ID.</summary>
public string? ConversationId { get; init; }
}
/// <summary>
/// Evidence subject in request.
/// </summary>
public sealed record EvidenceSubjectRequest
{
/// <summary>Subject type.</summary>
public string? Type { get; init; }
/// <summary>Finding ID if applicable.</summary>
public string? FindingId { get; init; }
/// <summary>CVE ID if applicable.</summary>
public string? CveId { get; init; }
/// <summary>Component if applicable.</summary>
public string? Component { get; init; }
/// <summary>Image digest if applicable.</summary>
public string? ImageDigest { get; init; }
}
/// <summary>
/// Evidence claim in request.
/// </summary>
public sealed record EvidenceClaimRequest
{
/// <summary>Optional claim ID (auto-generated if not provided).</summary>
public string? ClaimId { get; init; }
/// <summary>Claim text.</summary>
public required string Text { get; init; }
/// <summary>Claim type.</summary>
public required string Type { get; init; }
/// <summary>Status.</summary>
public required string Status { get; init; }
/// <summary>Confidence score 0-1.</summary>
public double Confidence { get; init; }
/// <summary>Evidence IDs supporting this claim.</summary>
public IReadOnlyList<string>? EvidenceIds { get; init; }
/// <summary>Source of the claim.</summary>
public string? Source { get; init; }
}
/// <summary>
/// Evidence item in request.
/// </summary>
public sealed record EvidenceItemRequest
{
/// <summary>Optional evidence ID (auto-generated if not provided).</summary>
public string? EvidenceId { get; init; }
/// <summary>Evidence type.</summary>
public required string Type { get; init; }
/// <summary>URI to the evidence.</summary>
public required string Uri { get; init; }
/// <summary>Content digest.</summary>
public string? Digest { get; init; }
/// <summary>When the evidence was collected.</summary>
public DateTimeOffset? CollectedAt { get; init; }
/// <summary>Snapshot type.</summary>
public string? SnapshotType { get; init; }
/// <summary>Snapshot data.</summary>
public Dictionary<string, object>? SnapshotData { get; init; }
}
/// <summary>
/// Evidence Pack response.
/// </summary>
public sealed record EvidencePackResponse
{
/// <summary>Pack ID.</summary>
public required string PackId { get; init; }
/// <summary>Version.</summary>
public required string Version { get; init; }
/// <summary>Tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>Created timestamp.</summary>
public required string CreatedAt { get; init; }
/// <summary>Content digest.</summary>
public required string ContentDigest { get; init; }
/// <summary>Subject.</summary>
public required EvidenceSubjectResponse Subject { get; init; }
/// <summary>Claims.</summary>
public required IReadOnlyList<EvidenceClaimResponse> Claims { get; init; }
/// <summary>Evidence items.</summary>
public required IReadOnlyList<EvidenceItemResponse> Evidence { get; init; }
/// <summary>Context.</summary>
public EvidencePackContextResponse? Context { get; init; }
/// <summary>Related links.</summary>
public EvidencePackLinks? Links { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidencePackResponse FromPack(EvidencePack pack) => new()
{
PackId = pack.PackId,
Version = pack.Version,
TenantId = pack.TenantId,
CreatedAt = pack.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
ContentDigest = pack.ComputeContentDigest(),
Subject = EvidenceSubjectResponse.FromSubject(pack.Subject),
Claims = pack.Claims.Select(EvidenceClaimResponse.FromClaim).ToList(),
Evidence = pack.Evidence.Select(EvidenceItemResponse.FromItem).ToList(),
Context = pack.Context is not null ? EvidencePackContextResponse.FromContext(pack.Context) : null,
Links = new EvidencePackLinks
{
Self = $"/v1/evidence-packs/{pack.PackId}",
Sign = $"/v1/evidence-packs/{pack.PackId}/sign",
Verify = $"/v1/evidence-packs/{pack.PackId}/verify",
Export = $"/v1/evidence-packs/{pack.PackId}/export"
}
};
}
/// <summary>
/// Evidence subject response.
/// </summary>
public sealed record EvidenceSubjectResponse
{
/// <summary>Subject type.</summary>
public required string Type { get; init; }
/// <summary>Finding ID.</summary>
public string? FindingId { get; init; }
/// <summary>CVE ID.</summary>
public string? CveId { get; init; }
/// <summary>Component.</summary>
public string? Component { get; init; }
/// <summary>Image digest.</summary>
public string? ImageDigest { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidenceSubjectResponse FromSubject(EvidenceSubject subject) => new()
{
Type = subject.Type.ToString(),
FindingId = subject.FindingId,
CveId = subject.CveId,
Component = subject.Component,
ImageDigest = subject.ImageDigest
};
}
/// <summary>
/// Evidence claim response.
/// </summary>
public sealed record EvidenceClaimResponse
{
/// <summary>Claim ID.</summary>
public required string ClaimId { get; init; }
/// <summary>Claim text.</summary>
public required string Text { get; init; }
/// <summary>Claim type.</summary>
public required string Type { get; init; }
/// <summary>Status.</summary>
public required string Status { get; init; }
/// <summary>Confidence score.</summary>
public double Confidence { get; init; }
/// <summary>Evidence IDs.</summary>
public required IReadOnlyList<string> EvidenceIds { get; init; }
/// <summary>Source.</summary>
public string? Source { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidenceClaimResponse FromClaim(EvidenceClaim claim) => new()
{
ClaimId = claim.ClaimId,
Text = claim.Text,
Type = claim.Type.ToString(),
Status = claim.Status,
Confidence = claim.Confidence,
EvidenceIds = claim.EvidenceIds.ToList(),
Source = claim.Source
};
}
/// <summary>
/// Evidence item response.
/// </summary>
public sealed record EvidenceItemResponse
{
/// <summary>Evidence ID.</summary>
public required string EvidenceId { get; init; }
/// <summary>Evidence type.</summary>
public required string Type { get; init; }
/// <summary>URI.</summary>
public required string Uri { get; init; }
/// <summary>Digest.</summary>
public required string Digest { get; init; }
/// <summary>Collection timestamp.</summary>
public required string CollectedAt { get; init; }
/// <summary>Snapshot.</summary>
public required EvidenceSnapshotResponse Snapshot { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidenceItemResponse FromItem(EvidenceItem item) => new()
{
EvidenceId = item.EvidenceId,
Type = item.Type.ToString(),
Uri = item.Uri,
Digest = item.Digest,
CollectedAt = item.CollectedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
Snapshot = EvidenceSnapshotResponse.FromSnapshot(item.Snapshot)
};
}
/// <summary>
/// Evidence snapshot response.
/// </summary>
public sealed record EvidenceSnapshotResponse
{
/// <summary>Snapshot type.</summary>
public required string Type { get; init; }
/// <summary>Snapshot data.</summary>
public required IReadOnlyDictionary<string, object?> Data { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidenceSnapshotResponse FromSnapshot(EvidenceSnapshot snapshot) => new()
{
Type = snapshot.Type,
Data = snapshot.Data
};
}
/// <summary>
/// Evidence pack context response.
/// </summary>
public sealed record EvidencePackContextResponse
{
/// <summary>Tenant ID.</summary>
public string? TenantId { get; init; }
/// <summary>Run ID.</summary>
public string? RunId { get; init; }
/// <summary>Conversation ID.</summary>
public string? ConversationId { get; init; }
/// <summary>User ID.</summary>
public string? UserId { get; init; }
/// <summary>Generator.</summary>
public string? GeneratedBy { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static EvidencePackContextResponse FromContext(EvidencePackContext context) => new()
{
TenantId = context.TenantId,
RunId = context.RunId,
ConversationId = context.ConversationId,
UserId = context.UserId,
GeneratedBy = context.GeneratedBy
};
}
/// <summary>
/// Evidence pack links.
/// </summary>
public sealed record EvidencePackLinks
{
/// <summary>Self link.</summary>
public string? Self { get; init; }
/// <summary>Sign link.</summary>
public string? Sign { get; init; }
/// <summary>Verify link.</summary>
public string? Verify { get; init; }
/// <summary>Export link.</summary>
public string? Export { get; init; }
}
/// <summary>
/// Signed evidence pack response.
/// </summary>
public sealed record SignedEvidencePackResponse
{
/// <summary>Pack ID.</summary>
public required string PackId { get; init; }
/// <summary>Signed timestamp.</summary>
public required string SignedAt { get; init; }
/// <summary>Pack content.</summary>
public required EvidencePackResponse Pack { get; init; }
/// <summary>DSSE envelope.</summary>
public required DsseEnvelopeResponse Envelope { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static SignedEvidencePackResponse FromSignedPack(SignedEvidencePack signedPack) => new()
{
PackId = signedPack.Pack.PackId,
SignedAt = signedPack.SignedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
Pack = EvidencePackResponse.FromPack(signedPack.Pack),
Envelope = DsseEnvelopeResponse.FromEnvelope(signedPack.Envelope)
};
}
/// <summary>
/// DSSE envelope response.
/// </summary>
public sealed record DsseEnvelopeResponse
{
/// <summary>Payload type.</summary>
public required string PayloadType { get; init; }
/// <summary>Payload digest.</summary>
public required string PayloadDigest { get; init; }
/// <summary>Signatures.</summary>
public required IReadOnlyList<DsseSignatureResponse> Signatures { get; init; }
/// <summary>
/// Creates response from domain model.
/// </summary>
public static DsseEnvelopeResponse FromEnvelope(DsseEnvelope envelope) => new()
{
PayloadType = envelope.PayloadType,
PayloadDigest = envelope.PayloadDigest,
Signatures = envelope.Signatures.Select(s => new DsseSignatureResponse
{
KeyId = s.KeyId,
Sig = s.Sig
}).ToList()
};
}
/// <summary>
/// DSSE signature response.
/// </summary>
public sealed record DsseSignatureResponse
{
/// <summary>Key ID.</summary>
public required string KeyId { get; init; }
/// <summary>Signature.</summary>
public required string Sig { get; init; }
}
/// <summary>
/// Evidence pack verification response.
/// </summary>
public sealed record EvidencePackVerificationResponse
{
/// <summary>Pack ID.</summary>
public required string PackId { get; init; }
/// <summary>Whether verification passed.</summary>
public bool Valid { get; init; }
/// <summary>Pack digest.</summary>
public string? PackDigest { get; init; }
/// <summary>Signing key ID.</summary>
public string? SignatureKeyId { get; init; }
/// <summary>Issues found.</summary>
public IReadOnlyList<string>? Issues { get; init; }
/// <summary>Evidence resolution results.</summary>
public IReadOnlyList<EvidenceResolutionApiResponse>? EvidenceResolutions { get; init; }
}
/// <summary>
/// Evidence resolution result in API response.
/// </summary>
public sealed record EvidenceResolutionApiResponse
{
/// <summary>Evidence ID.</summary>
public required string EvidenceId { get; init; }
/// <summary>URI.</summary>
public required string Uri { get; init; }
/// <summary>Whether resolved.</summary>
public bool Resolved { get; init; }
/// <summary>Whether digest matches.</summary>
public bool DigestMatches { get; init; }
/// <summary>Error message.</summary>
public string? Error { get; init; }
}
/// <summary>
/// Evidence pack list response.
/// </summary>
public sealed record EvidencePackListResponse
{
/// <summary>Total count.</summary>
public int Count { get; init; }
/// <summary>Pack summaries.</summary>
public required IReadOnlyList<EvidencePackSummary> Packs { get; init; }
}
/// <summary>
/// Evidence pack summary.
/// </summary>
public sealed record EvidencePackSummary
{
/// <summary>Pack ID.</summary>
public required string PackId { get; init; }
/// <summary>Tenant ID.</summary>
public required string TenantId { get; init; }
/// <summary>Created timestamp.</summary>
public required string CreatedAt { get; init; }
/// <summary>Subject type.</summary>
public required string SubjectType { get; init; }
/// <summary>CVE ID if applicable.</summary>
public string? CveId { get; init; }
/// <summary>Number of claims.</summary>
public int ClaimCount { get; init; }
/// <summary>Number of evidence items.</summary>
public int EvidenceCount { get; init; }
/// <summary>
/// Creates summary from domain model.
/// </summary>
public static EvidencePackSummary FromPack(EvidencePack pack) => new()
{
PackId = pack.PackId,
TenantId = pack.TenantId,
CreatedAt = pack.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
SubjectType = pack.Subject.Type.ToString(),
CveId = pack.Subject.CveId,
ClaimCount = pack.Claims.Length,
EvidenceCount = pack.Evidence.Length
};
}
#endregion

View File

@@ -0,0 +1,904 @@
// <copyright file="RunEndpoints.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System.Collections.Immutable;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.AdvisoryAI.Runs;
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("/api/v1/runs")
.WithTags("Runs");
group.MapPost("/", CreateRunAsync)
.WithName("CreateRun")
.WithSummary("Creates a new AI investigation run")
.Produces<RunDto>(StatusCodes.Status201Created)
.ProducesValidationProblem();
group.MapGet("/{runId}", GetRunAsync)
.WithName("GetRun")
.WithSummary("Gets a run by ID")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/", QueryRunsAsync)
.WithName("QueryRuns")
.WithSummary("Queries runs with filters")
.Produces<RunQueryResultDto>();
group.MapGet("/{runId}/timeline", GetTimelineAsync)
.WithName("GetRunTimeline")
.WithSummary("Gets the event timeline for a run")
.Produces<ImmutableArray<RunEventDto>>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/events", AddEventAsync)
.WithName("AddRunEvent")
.WithSummary("Adds an event to a run")
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/turns/user", AddUserTurnAsync)
.WithName("AddUserTurn")
.WithSummary("Adds a user turn to the run")
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/turns/assistant", AddAssistantTurnAsync)
.WithName("AddAssistantTurn")
.WithSummary("Adds an assistant turn to the run")
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/actions", ProposeActionAsync)
.WithName("ProposeAction")
.WithSummary("Proposes an action in the run")
.Produces<RunEventDto>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/approval/request", RequestApprovalAsync)
.WithName("RequestApproval")
.WithSummary("Requests approval for pending actions")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/approval/decide", ApproveAsync)
.WithName("ApproveRun")
.WithSummary("Approves or rejects a run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/{runId}/actions/{actionEventId}/execute", ExecuteActionAsync)
.WithName("ExecuteAction")
.WithSummary("Executes an approved action")
.Produces<RunEventDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/{runId}/artifacts", AddArtifactAsync)
.WithName("AddArtifact")
.WithSummary("Adds an artifact to the run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/complete", CompleteRunAsync)
.WithName("CompleteRun")
.WithSummary("Completes a run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/{runId}/cancel", CancelRunAsync)
.WithName("CancelRun")
.WithSummary("Cancels a run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapPost("/{runId}/handoff", HandOffRunAsync)
.WithName("HandOffRun")
.WithSummary("Hands off a run to another user")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/{runId}/attest", AttestRunAsync)
.WithName("AttestRun")
.WithSummary("Creates an attestation for a completed run")
.Produces<RunDto>()
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
group.MapGet("/active", GetActiveRunsAsync)
.WithName("GetActiveRuns")
.WithSummary("Gets active runs for the current user")
.Produces<ImmutableArray<RunDto>>();
group.MapGet("/pending-approval", GetPendingApprovalAsync)
.WithName("GetPendingApproval")
.WithSummary("Gets runs pending approval")
.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 = $"Run {runId} not found" });
}
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,
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
CancellationToken ct)
{
tenantId ??= "default";
try
{
var run = await runService.AddArtifactAsync(tenantId, runId, new RunArtifact
{
ArtifactId = request.ArtifactId ?? Guid.NewGuid().ToString("N"),
Type = request.Type,
Name = request.Name,
Description = request.Description,
CreatedAt = DateTimeOffset.UtcNow,
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; }
}