sprints work
This commit is contained in:
@@ -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
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user