sprints work
This commit is contained in:
@@ -118,6 +118,10 @@ public static class ServiceCollectionExtensions
|
||||
services.TryAddSingleton<GroundingValidator>();
|
||||
services.TryAddSingleton<ActionProposalParser>();
|
||||
|
||||
// Object link resolvers (SPRINT_20260109_011_002 OMCI-005)
|
||||
services.TryAddSingleton<ITypedLinkResolver, OpsMemoryLinkResolver>();
|
||||
services.TryAddSingleton<IObjectLinkResolver, CompositeObjectLinkResolver>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.AdvisoryAI.Diagnostics;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
@@ -56,6 +57,9 @@ builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationG
|
||||
builder.Services.AddAiAttestationServices();
|
||||
builder.Services.AddInMemoryAiAttestationStore();
|
||||
|
||||
// Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
|
||||
builder.Services.AddEvidencePack();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddProblemDetails();
|
||||
@@ -188,6 +192,9 @@ app.MapGet("/v1/advisory-ai/conversations", HandleListConversations)
|
||||
// AI Attestations endpoints (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
|
||||
app.MapAttestationEndpoints();
|
||||
|
||||
// Evidence Pack endpoints (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
|
||||
app.MapEvidencePackEndpoints();
|
||||
|
||||
// Refresh Router endpoint cache
|
||||
app.TryRefreshStellaRouterEndpoints(routerOptions);
|
||||
|
||||
|
||||
@@ -15,5 +15,7 @@
|
||||
<ProjectReference Include="..\..\Router/__Libraries/StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
|
||||
<!-- AI Attestations (Sprint: SPRINT_20260109_011_001) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
|
||||
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
151
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionAuditLedger.cs
Normal file
151
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionAuditLedger.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
// <copyright file="ActionAuditLedger.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory action audit ledger for development and testing.
|
||||
/// In production, this would use PostgreSQL with proper indexing.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-006
|
||||
/// </summary>
|
||||
internal sealed class ActionAuditLedger : IActionAuditLedger
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ActionAuditEntry> _entries = new();
|
||||
private readonly ILogger<ActionAuditLedger> _logger;
|
||||
private readonly AuditLedgerOptions _options;
|
||||
|
||||
public ActionAuditLedger(
|
||||
IOptions<AuditLedgerOptions> options,
|
||||
ILogger<ActionAuditLedger> logger)
|
||||
{
|
||||
_options = options?.Value ?? new AuditLedgerOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordAsync(ActionAuditEntry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
_entries[entry.EntryId] = entry;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recorded audit entry {EntryId}: {ActionType} by {Actor} -> {Outcome}",
|
||||
entry.EntryId, entry.ActionType, entry.Actor, entry.Outcome);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<ActionAuditEntry>> QueryAsync(
|
||||
ActionAuditQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var entries = _entries.Values.AsEnumerable();
|
||||
|
||||
// Apply filters
|
||||
if (!string.IsNullOrEmpty(query.TenantId))
|
||||
{
|
||||
entries = entries.Where(e => e.TenantId.Equals(query.TenantId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ActionType))
|
||||
{
|
||||
entries = entries.Where(e => e.ActionType.Equals(query.ActionType, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Actor))
|
||||
{
|
||||
entries = entries.Where(e => e.Actor.Equals(query.Actor, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.Outcome.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Outcome == query.Outcome.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.RunId))
|
||||
{
|
||||
entries = entries.Where(e => e.RunId != null && e.RunId.Equals(query.RunId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.CveId))
|
||||
{
|
||||
entries = entries.Where(e => e.CveId != null && e.CveId.Equals(query.CveId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ImageDigest))
|
||||
{
|
||||
entries = entries.Where(e => e.ImageDigest != null && e.ImageDigest.Equals(query.ImageDigest, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (query.FromTimestamp.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Timestamp >= query.FromTimestamp.Value);
|
||||
}
|
||||
|
||||
if (query.ToTimestamp.HasValue)
|
||||
{
|
||||
entries = entries.Where(e => e.Timestamp < query.ToTimestamp.Value);
|
||||
}
|
||||
|
||||
// Order by timestamp descending, apply pagination
|
||||
var result = entries
|
||||
.OrderByDescending(e => e.Timestamp)
|
||||
.Skip(query.Offset)
|
||||
.Take(query.Limit)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ActionAuditEntry?> GetAsync(
|
||||
string entryId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(entryId);
|
||||
|
||||
_entries.TryGetValue(entryId, out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<ActionAuditEntry>> GetByRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(runId);
|
||||
|
||||
var entries = _entries.Values
|
||||
.Where(e => e.RunId != null && e.RunId.Equals(runId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the audit ledger.
|
||||
/// </summary>
|
||||
public sealed class AuditLedgerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Days to retain audit entries.
|
||||
/// </summary>
|
||||
public int RetentionDays { get; set; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// Whether audit logging is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
135
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionDefinition.cs
Normal file
135
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionDefinition.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
// <copyright file="ActionDefinition.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the metadata and constraints for an action type.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
|
||||
/// </summary>
|
||||
public sealed record ActionDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique action type identifier (e.g., "approve", "quarantine").
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this action does.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Role required to execute this action.
|
||||
/// </summary>
|
||||
public required string RequiredRole { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Risk level of this action for policy decisions.
|
||||
/// </summary>
|
||||
public required ActionRiskLevel RiskLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action is idempotent (safe to retry).
|
||||
/// </summary>
|
||||
public required bool IsIdempotent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action supports rollback/compensation.
|
||||
/// </summary>
|
||||
public required bool HasCompensation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action type for compensation/rollback, if supported.
|
||||
/// </summary>
|
||||
public string? CompensationActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters accepted by this action.
|
||||
/// </summary>
|
||||
public ImmutableArray<ActionParameterDefinition> Parameters { get; init; } =
|
||||
ImmutableArray<ActionParameterDefinition>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Environments where this action can be executed.
|
||||
/// Empty means all environments.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AllowedEnvironments { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tags for categorization.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Risk levels for actions, affecting policy decisions and approval requirements.
|
||||
/// </summary>
|
||||
public enum ActionRiskLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only, informational actions.
|
||||
/// </summary>
|
||||
Low = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Creates records, sends notifications.
|
||||
/// </summary>
|
||||
Medium = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Modifies security posture.
|
||||
/// </summary>
|
||||
High = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Production blockers, quarantine operations.
|
||||
/// </summary>
|
||||
Critical = 3
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Definition of an action parameter.
|
||||
/// </summary>
|
||||
public sealed record ActionParameterDefinition
|
||||
{
|
||||
/// <summary>
|
||||
/// Parameter name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameter type (string, int, bool, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this parameter is required.
|
||||
/// </summary>
|
||||
public required bool Required { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the parameter.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value if not provided.
|
||||
/// </summary>
|
||||
public string? DefaultValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation regex pattern.
|
||||
/// </summary>
|
||||
public string? ValidationPattern { get; init; }
|
||||
}
|
||||
456
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionExecutor.cs
Normal file
456
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionExecutor.cs
Normal file
@@ -0,0 +1,456 @@
|
||||
// <copyright file="ActionExecutor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Executes AI-proposed actions with policy gate integration, idempotency, and audit logging.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-007
|
||||
/// </summary>
|
||||
internal sealed class ActionExecutor : IActionExecutor
|
||||
{
|
||||
private readonly IActionPolicyGate _policyGate;
|
||||
private readonly IActionRegistry _actionRegistry;
|
||||
private readonly IIdempotencyHandler _idempotencyHandler;
|
||||
private readonly IApprovalWorkflowAdapter _approvalAdapter;
|
||||
private readonly IActionAuditLedger _auditLedger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
private readonly ILogger<ActionExecutor> _logger;
|
||||
private readonly ActionExecutorOptions _options;
|
||||
|
||||
public ActionExecutor(
|
||||
IActionPolicyGate policyGate,
|
||||
IActionRegistry actionRegistry,
|
||||
IIdempotencyHandler idempotencyHandler,
|
||||
IApprovalWorkflowAdapter approvalAdapter,
|
||||
IActionAuditLedger auditLedger,
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
IOptions<ActionExecutorOptions> options,
|
||||
ILogger<ActionExecutor> logger)
|
||||
{
|
||||
_policyGate = policyGate ?? throw new ArgumentNullException(nameof(policyGate));
|
||||
_actionRegistry = actionRegistry ?? throw new ArgumentNullException(nameof(actionRegistry));
|
||||
_idempotencyHandler = idempotencyHandler ?? throw new ArgumentNullException(nameof(idempotencyHandler));
|
||||
_approvalAdapter = approvalAdapter ?? throw new ArgumentNullException(nameof(approvalAdapter));
|
||||
_auditLedger = auditLedger ?? throw new ArgumentNullException(nameof(auditLedger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator));
|
||||
_options = options?.Value ?? new ActionExecutorOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ActionExecutionResult> ExecuteAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var executionId = _guidGenerator.NewGuid().ToString();
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Executing action {ActionType} (execution: {ExecutionId}) for user {UserId}",
|
||||
proposal.ActionType, executionId, context.UserId);
|
||||
|
||||
// 1. Check idempotency first
|
||||
if (_options.EnableIdempotency)
|
||||
{
|
||||
var idempotencyKey = _idempotencyHandler.GenerateKey(proposal, context);
|
||||
var idempotencyCheck = await _idempotencyHandler.CheckAsync(idempotencyKey, cancellationToken);
|
||||
|
||||
if (idempotencyCheck.AlreadyExecuted)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Action {ActionType} skipped due to idempotency (previous execution: {PreviousId})",
|
||||
proposal.ActionType, idempotencyCheck.PreviousResult?.ExecutionId);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.IdempotentSkipped,
|
||||
null, null, cancellationToken);
|
||||
|
||||
return idempotencyCheck.PreviousResult!;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Evaluate policy if not already evaluated
|
||||
var policyDecision = decision;
|
||||
if (decision.Decision == PolicyDecisionKind.Indeterminate)
|
||||
{
|
||||
policyDecision = await _policyGate.EvaluateAsync(proposal, context, cancellationToken);
|
||||
}
|
||||
|
||||
// 3. Handle based on policy decision
|
||||
ActionExecutionResult result;
|
||||
switch (policyDecision.Decision)
|
||||
{
|
||||
case PolicyDecisionKind.Allow:
|
||||
result = await ExecuteImmediatelyAsync(executionId, proposal, context, policyDecision, startedAt, cancellationToken);
|
||||
break;
|
||||
|
||||
case PolicyDecisionKind.AllowWithApproval:
|
||||
result = await ExecuteWithApprovalAsync(executionId, proposal, context, policyDecision, startedAt, cancellationToken);
|
||||
break;
|
||||
|
||||
case PolicyDecisionKind.Deny:
|
||||
result = CreateDeniedResult(executionId, proposal, policyDecision, startedAt);
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.DeniedByPolicy,
|
||||
policyDecision, null, cancellationToken);
|
||||
break;
|
||||
|
||||
case PolicyDecisionKind.DenyWithOverride:
|
||||
result = CreateDeniedWithOverrideResult(executionId, proposal, policyDecision, startedAt);
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.DeniedByPolicy,
|
||||
policyDecision, null, cancellationToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidOperationException($"Unexpected policy decision: {policyDecision.Decision}");
|
||||
}
|
||||
|
||||
// 4. Record idempotency if execution was successful
|
||||
if (_options.EnableIdempotency && result.Outcome == ActionExecutionOutcome.Success)
|
||||
{
|
||||
var idempotencyKey = _idempotencyHandler.GenerateKey(proposal, context);
|
||||
await _idempotencyHandler.RecordExecutionAsync(idempotencyKey, result, context, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ActionRollbackResult> RollbackAsync(
|
||||
string executionId,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(executionId);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// In a real implementation, this would:
|
||||
// 1. Look up the original execution
|
||||
// 2. Find the compensation action
|
||||
// 3. Execute the compensation
|
||||
// 4. Record the rollback
|
||||
|
||||
_logger.LogInformation(
|
||||
"Rollback requested for execution {ExecutionId}",
|
||||
executionId);
|
||||
|
||||
// Stub implementation
|
||||
return new ActionRollbackResult
|
||||
{
|
||||
Success = false,
|
||||
Message = "Rollback not yet implemented",
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "NOT_IMPLEMENTED",
|
||||
Message = "Rollback functionality is not yet implemented"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ActionExecutionStatus?> GetStatusAsync(
|
||||
string executionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// In a real implementation, this would look up execution status from storage
|
||||
return Task.FromResult<ActionExecutionStatus?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionTypeInfo> GetSupportedActionTypes()
|
||||
{
|
||||
return _actionRegistry.GetAllActions()
|
||||
.Select(a => new ActionTypeInfo
|
||||
{
|
||||
Type = a.ActionType,
|
||||
DisplayName = a.DisplayName,
|
||||
Description = a.Description,
|
||||
Category = GetActionCategory(a),
|
||||
Parameters = a.Parameters.Select(p => new ActionParameterInfo
|
||||
{
|
||||
Name = p.Name,
|
||||
DisplayName = p.Name,
|
||||
Description = p.Description,
|
||||
IsRequired = p.Required,
|
||||
Type = p.Type,
|
||||
DefaultValue = p.DefaultValue
|
||||
}).ToImmutableArray(),
|
||||
RequiredPermission = a.RequiredRole,
|
||||
SupportsRollback = a.HasCompensation,
|
||||
IsDestructive = a.RiskLevel >= ActionRiskLevel.High
|
||||
})
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<ActionExecutionResult> ExecuteImmediatelyAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Executing action {ActionType} immediately (policy: {PolicyId})",
|
||||
proposal.ActionType, decision.PolicyId);
|
||||
|
||||
try
|
||||
{
|
||||
// Perform the actual action execution
|
||||
// In a real implementation, this would dispatch to specific action handlers
|
||||
var result = await PerformActionAsync(executionId, proposal, context, startedAt, cancellationToken);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context,
|
||||
result.Outcome == ActionExecutionOutcome.Success
|
||||
? ActionAuditOutcome.Executed
|
||||
: ActionAuditOutcome.ExecutionFailed,
|
||||
decision, result.Error?.Message, cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Action execution failed for {ActionType}", proposal.ActionType);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.ExecutionFailed,
|
||||
decision, ex.Message, cancellationToken);
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Execution failed: {ex.Message}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "EXECUTION_FAILED",
|
||||
Message = ex.Message,
|
||||
IsRetryable = true
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ActionExecutionResult> ExecuteWithApprovalAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Action {ActionType} requires approval (policy: {PolicyId})",
|
||||
proposal.ActionType, decision.PolicyId);
|
||||
|
||||
// Create approval request
|
||||
var approvalRequest = await _approvalAdapter.CreateApprovalRequestAsync(
|
||||
proposal, decision, context, cancellationToken);
|
||||
|
||||
await RecordAuditEntryAsync(
|
||||
executionId, proposal, context, ActionAuditOutcome.ApprovalRequested,
|
||||
decision, null, cancellationToken, approvalRequest.RequestId);
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.PendingApproval,
|
||||
Message = $"Approval required from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = null, // Not completed yet
|
||||
CanRollback = false,
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["approvalRequestId"] = approvalRequest.RequestId,
|
||||
["approvalWorkflowId"] = approvalRequest.WorkflowId
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ActionExecutionResult> PerformActionAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
DateTimeOffset startedAt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get action definition for rollback capability
|
||||
var actionDef = _actionRegistry.GetAction(proposal.ActionType);
|
||||
var canRollback = actionDef?.HasCompensation ?? false;
|
||||
|
||||
// In a real implementation, this would dispatch to specific action handlers
|
||||
// For now, simulate successful execution
|
||||
_logger.LogInformation(
|
||||
"Performed action {ActionType} with parameters: {Parameters}",
|
||||
proposal.ActionType,
|
||||
string.Join(", ", proposal.Parameters.Select(p => $"{p.Key}={p.Value}")));
|
||||
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Success,
|
||||
Message = $"Action {proposal.ActionType} executed successfully",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = canRollback,
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["actionType"] = proposal.ActionType,
|
||||
["status"] = "completed"
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private ActionExecutionResult CreateDeniedResult(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Action denied by policy: {decision.Reason}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "POLICY_DENIED",
|
||||
Message = decision.Reason ?? "Action denied by policy",
|
||||
IsRetryable = false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private ActionExecutionResult CreateDeniedWithOverrideResult(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
DateTimeOffset startedAt)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Failed,
|
||||
Message = $"Action denied (override available): {decision.Reason}",
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false,
|
||||
Error = new ActionError
|
||||
{
|
||||
Code = "POLICY_DENIED_OVERRIDE_AVAILABLE",
|
||||
Message = decision.Reason ?? "Action denied by policy",
|
||||
Details = "An administrator can override this decision",
|
||||
IsRetryable = false
|
||||
},
|
||||
OutputData = new Dictionary<string, string>
|
||||
{
|
||||
["overrideAvailable"] = "true",
|
||||
["policyId"] = decision.PolicyId ?? ""
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RecordAuditEntryAsync(
|
||||
string executionId,
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
ActionAuditOutcome outcome,
|
||||
ActionPolicyDecision? decision,
|
||||
string? errorMessage,
|
||||
CancellationToken cancellationToken,
|
||||
string? approvalRequestId = null)
|
||||
{
|
||||
var entry = new ActionAuditEntry
|
||||
{
|
||||
EntryId = _guidGenerator.NewGuid().ToString(),
|
||||
TenantId = context.TenantId,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
ActionType = proposal.ActionType,
|
||||
Actor = context.UserId,
|
||||
Outcome = outcome,
|
||||
RunId = context.RunId,
|
||||
FindingId = context.FindingId,
|
||||
CveId = context.CveId,
|
||||
ImageDigest = context.ImageDigest,
|
||||
PolicyId = decision?.PolicyId,
|
||||
PolicyResult = decision?.Decision,
|
||||
ApprovalRequestId = approvalRequestId,
|
||||
Parameters = proposal.Parameters,
|
||||
ExecutionId = executionId,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
|
||||
await _auditLedger.RecordAsync(entry, cancellationToken);
|
||||
}
|
||||
|
||||
private static string? GetActionCategory(ActionDefinition action)
|
||||
{
|
||||
if (action.Tags.Contains("cve", StringComparer.OrdinalIgnoreCase) ||
|
||||
action.Tags.Contains("vex", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Vulnerability Management";
|
||||
}
|
||||
|
||||
if (action.Tags.Contains("container", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Container Security";
|
||||
}
|
||||
|
||||
if (action.Tags.Contains("report", StringComparer.OrdinalIgnoreCase) ||
|
||||
action.Tags.Contains("export", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Reporting";
|
||||
}
|
||||
|
||||
if (action.Tags.Contains("notification", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return "Communication";
|
||||
}
|
||||
|
||||
return "General";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for action execution.
|
||||
/// </summary>
|
||||
public sealed class ActionExecutorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether idempotency checking is enabled.
|
||||
/// </summary>
|
||||
public bool EnableIdempotency { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether audit logging is enabled.
|
||||
/// </summary>
|
||||
public bool EnableAuditLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Default timeout for action execution.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
352
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionPolicyGate.cs
Normal file
352
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionPolicyGate.cs
Normal file
@@ -0,0 +1,352 @@
|
||||
// <copyright file="ActionPolicyGate.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates action proposals against K4 lattice policy rules.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-002
|
||||
/// </summary>
|
||||
internal sealed class ActionPolicyGate : IActionPolicyGate
|
||||
{
|
||||
private readonly IActionRegistry _actionRegistry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<ActionPolicyGate> _logger;
|
||||
private readonly ActionPolicyOptions _options;
|
||||
|
||||
public ActionPolicyGate(
|
||||
IActionRegistry actionRegistry,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<ActionPolicyOptions> options,
|
||||
ILogger<ActionPolicyGate> logger)
|
||||
{
|
||||
_actionRegistry = actionRegistry ?? throw new ArgumentNullException(nameof(actionRegistry));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? new ActionPolicyOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ActionPolicyDecision> EvaluateAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Evaluating policy for action {ActionType} by user {UserId} in tenant {TenantId}",
|
||||
proposal.ActionType, context.UserId, context.TenantId);
|
||||
|
||||
// Get action definition
|
||||
var actionDef = _actionRegistry.GetAction(proposal.ActionType);
|
||||
if (actionDef is null)
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Unknown action type: {proposal.ActionType}",
|
||||
"action-validation",
|
||||
allowOverride: false));
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
var paramValidation = _actionRegistry.ValidateParameters(proposal.ActionType, proposal.Parameters);
|
||||
if (!paramValidation.IsValid)
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Invalid parameters: {string.Join(", ", paramValidation.Errors)}",
|
||||
"parameter-validation",
|
||||
allowOverride: false));
|
||||
}
|
||||
|
||||
// Check required role
|
||||
if (!HasRequiredRole(context.UserRoles, actionDef.RequiredRole))
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Missing required role: {actionDef.RequiredRole}",
|
||||
"role-check",
|
||||
allowOverride: false));
|
||||
}
|
||||
|
||||
// Check environment restrictions
|
||||
if (actionDef.AllowedEnvironments.Length > 0 &&
|
||||
!actionDef.AllowedEnvironments.Contains(context.Environment, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(CreateDenyDecision(
|
||||
$"Action not allowed in environment: {context.Environment}",
|
||||
"environment-check",
|
||||
allowOverride: true));
|
||||
}
|
||||
|
||||
// Evaluate based on risk level and K4 context
|
||||
var decision = EvaluateRiskLevel(actionDef, context);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Policy decision for {ActionType}: {Decision} (policy: {PolicyId})",
|
||||
proposal.ActionType, decision.Decision, decision.PolicyId);
|
||||
|
||||
return Task.FromResult(decision);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<PolicyExplanation> ExplainAsync(
|
||||
ActionPolicyDecision decision,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var details = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(decision.Reason))
|
||||
{
|
||||
details.Add(decision.Reason);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(decision.K4Position))
|
||||
{
|
||||
details.Add($"K4 lattice position: {decision.K4Position}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(decision.VexStatus))
|
||||
{
|
||||
details.Add($"VEX status: {decision.VexStatus}");
|
||||
}
|
||||
|
||||
var suggestedActions = new List<string>();
|
||||
switch (decision.Decision)
|
||||
{
|
||||
case PolicyDecisionKind.Deny:
|
||||
suggestedActions.Add("Contact your security administrator to request elevated permissions");
|
||||
break;
|
||||
case PolicyDecisionKind.DenyWithOverride:
|
||||
suggestedActions.Add("Request an admin override if you have business justification");
|
||||
break;
|
||||
case PolicyDecisionKind.AllowWithApproval:
|
||||
suggestedActions.Add($"Request approval from: {string.Join(", ", decision.RequiredApprovers.Select(a => a.Identifier))}");
|
||||
break;
|
||||
}
|
||||
|
||||
var explanation = new PolicyExplanation
|
||||
{
|
||||
Summary = GenerateSummary(decision),
|
||||
Details = details.ToImmutableArray(),
|
||||
PolicyReferences = decision.PolicyId is not null
|
||||
? ImmutableArray.Create(new PolicyReference
|
||||
{
|
||||
PolicyId = decision.PolicyId,
|
||||
Name = GetPolicyName(decision.PolicyId)
|
||||
})
|
||||
: ImmutableArray<PolicyReference>.Empty,
|
||||
SuggestedActions = suggestedActions.ToImmutableArray()
|
||||
};
|
||||
|
||||
return Task.FromResult(explanation);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IdempotencyCheckResult> CheckIdempotencyAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Delegate to IdempotencyHandler - this is a stub for interface compliance
|
||||
// The actual check is done in ActionExecutor
|
||||
return Task.FromResult(new IdempotencyCheckResult
|
||||
{
|
||||
WasExecuted = false
|
||||
});
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateRiskLevel(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Map risk level to policy decision
|
||||
return actionDef.RiskLevel switch
|
||||
{
|
||||
ActionRiskLevel.Low => CreateAllowDecision(actionDef, context),
|
||||
ActionRiskLevel.Medium => EvaluateMediumRisk(actionDef, context),
|
||||
ActionRiskLevel.High => EvaluateHighRisk(actionDef, context),
|
||||
ActionRiskLevel.Critical => EvaluateCriticalRisk(actionDef, context),
|
||||
_ => CreateDenyDecision("Unknown risk level", "risk-evaluation", allowOverride: true)
|
||||
};
|
||||
}
|
||||
|
||||
private ActionPolicyDecision CreateAllowDecision(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "low-risk-auto-allow",
|
||||
Reason = "Low-risk action allowed automatically"
|
||||
};
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateMediumRisk(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Check if user has elevated role
|
||||
if (HasRequiredRole(context.UserRoles, "security-lead") ||
|
||||
HasRequiredRole(context.UserRoles, "admin"))
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "medium-risk-elevated-role",
|
||||
Reason = "Medium-risk action allowed for elevated role"
|
||||
};
|
||||
}
|
||||
|
||||
// Require team lead approval
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"medium-risk-approval",
|
||||
"Medium-risk action requires team lead approval",
|
||||
[new RequiredApprover { Type = ApproverType.Role, Identifier = "team-lead" }]);
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateHighRisk(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Check if admin
|
||||
if (HasRequiredRole(context.UserRoles, "admin"))
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "high-risk-admin",
|
||||
Reason = "High-risk action allowed for admin"
|
||||
};
|
||||
}
|
||||
|
||||
// Require security lead approval
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"high-risk-approval",
|
||||
"High-risk action requires security lead approval",
|
||||
[new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }]);
|
||||
}
|
||||
|
||||
private ActionPolicyDecision EvaluateCriticalRisk(ActionDefinition actionDef, ActionContext context)
|
||||
{
|
||||
// Critical actions always require multi-party approval
|
||||
// Even admins need CISO sign-off for critical actions in production
|
||||
if (context.Environment.Equals("production", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"critical-risk-production",
|
||||
"Critical action in production requires CISO and security lead approval",
|
||||
[
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "ciso" },
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }
|
||||
]);
|
||||
}
|
||||
|
||||
// Non-production: just security lead
|
||||
return CreateApprovalDecision(
|
||||
actionDef,
|
||||
context,
|
||||
"critical-risk-non-prod",
|
||||
"Critical action requires security lead approval",
|
||||
[new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }]);
|
||||
}
|
||||
|
||||
private ActionPolicyDecision CreateApprovalDecision(
|
||||
ActionDefinition actionDef,
|
||||
ActionContext context,
|
||||
string policyId,
|
||||
string reason,
|
||||
ImmutableArray<RequiredApprover> approvers)
|
||||
{
|
||||
var timeout = actionDef.RiskLevel == ActionRiskLevel.Critical
|
||||
? TimeSpan.FromHours(_options.CriticalTimeoutHours)
|
||||
: TimeSpan.FromHours(_options.DefaultTimeoutHours);
|
||||
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.AllowWithApproval,
|
||||
PolicyId = policyId,
|
||||
Reason = reason,
|
||||
RequiredApprovers = approvers,
|
||||
ApprovalWorkflowId = $"action-approval-{actionDef.RiskLevel.ToString().ToLowerInvariant()}",
|
||||
ExpiresAt = _timeProvider.GetUtcNow().Add(timeout)
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateDenyDecision(string reason, string policyId, bool allowOverride)
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = allowOverride ? PolicyDecisionKind.DenyWithOverride : PolicyDecisionKind.Deny,
|
||||
PolicyId = policyId,
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
|
||||
private static bool HasRequiredRole(ImmutableArray<string> userRoles, string requiredRole)
|
||||
{
|
||||
// Admin role can do everything
|
||||
if (userRoles.Contains("admin", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return userRoles.Contains(requiredRole, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GenerateSummary(ActionPolicyDecision decision)
|
||||
{
|
||||
return decision.Decision switch
|
||||
{
|
||||
PolicyDecisionKind.Allow => "Action is allowed and can proceed immediately.",
|
||||
PolicyDecisionKind.AllowWithApproval => "Action requires approval before execution.",
|
||||
PolicyDecisionKind.Deny => "Action is denied by policy.",
|
||||
PolicyDecisionKind.DenyWithOverride => "Action is denied but can be overridden by an administrator.",
|
||||
PolicyDecisionKind.Indeterminate => "Unable to determine policy decision.",
|
||||
_ => "Unknown policy decision."
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetPolicyName(string policyId)
|
||||
{
|
||||
return policyId switch
|
||||
{
|
||||
"low-risk-auto-allow" => "Low Risk Auto-Allow Policy",
|
||||
"medium-risk-elevated-role" => "Medium Risk Elevated Role Policy",
|
||||
"medium-risk-approval" => "Medium Risk Approval Policy",
|
||||
"high-risk-admin" => "High Risk Admin Policy",
|
||||
"high-risk-approval" => "High Risk Approval Policy",
|
||||
"critical-risk-production" => "Critical Risk Production Policy",
|
||||
"critical-risk-non-prod" => "Critical Risk Non-Production Policy",
|
||||
"role-check" => "Role Authorization Policy",
|
||||
"environment-check" => "Environment Restriction Policy",
|
||||
"action-validation" => "Action Validation Policy",
|
||||
"parameter-validation" => "Parameter Validation Policy",
|
||||
_ => policyId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for action policy evaluation.
|
||||
/// </summary>
|
||||
public sealed class ActionPolicyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Default timeout in hours for approval requests.
|
||||
/// </summary>
|
||||
public int DefaultTimeoutHours { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Timeout in hours for critical risk approval requests.
|
||||
/// </summary>
|
||||
public int CriticalTimeoutHours { get; set; } = 24;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable K4 lattice integration.
|
||||
/// </summary>
|
||||
public bool EnableK4Integration { get; set; } = true;
|
||||
}
|
||||
433
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionRegistry.cs
Normal file
433
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/ActionRegistry.cs
Normal file
@@ -0,0 +1,433 @@
|
||||
// <copyright file="ActionRegistry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of action registry with built-in action definitions.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
|
||||
/// </summary>
|
||||
internal sealed partial class ActionRegistry : IActionRegistry
|
||||
{
|
||||
private readonly FrozenDictionary<string, ActionDefinition> _actions;
|
||||
|
||||
public ActionRegistry()
|
||||
{
|
||||
_actions = CreateBuiltInActions().ToFrozenDictionary(a => a.ActionType, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ActionDefinition? GetAction(string actionType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(actionType);
|
||||
return _actions.GetValueOrDefault(actionType);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionDefinition> GetAllActions() =>
|
||||
_actions.Values.ToImmutableArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionDefinition> GetActionsByRiskLevel(ActionRiskLevel riskLevel) =>
|
||||
_actions.Values.Where(a => a.RiskLevel == riskLevel).ToImmutableArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ImmutableArray<ActionDefinition> GetActionsByTag(string tag) =>
|
||||
_actions.Values.Where(a => a.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase)).ToImmutableArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ActionParameterValidationResult ValidateParameters(
|
||||
string actionType,
|
||||
ImmutableDictionary<string, string> parameters)
|
||||
{
|
||||
var definition = GetAction(actionType);
|
||||
if (definition is null)
|
||||
{
|
||||
return ActionParameterValidationResult.Failure($"Unknown action type: {actionType}");
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
// Check required parameters
|
||||
foreach (var param in definition.Parameters.Where(p => p.Required))
|
||||
{
|
||||
if (!parameters.ContainsKey(param.Name) || string.IsNullOrWhiteSpace(parameters[param.Name]))
|
||||
{
|
||||
errors.Add($"Missing required parameter: {param.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate parameter patterns
|
||||
foreach (var param in definition.Parameters.Where(p => p.ValidationPattern is not null))
|
||||
{
|
||||
if (parameters.TryGetValue(param.Name, out var value) && !string.IsNullOrEmpty(value))
|
||||
{
|
||||
if (!Regex.IsMatch(value, param.ValidationPattern!))
|
||||
{
|
||||
errors.Add($"Parameter '{param.Name}' does not match pattern: {param.ValidationPattern}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Count == 0
|
||||
? ActionParameterValidationResult.Success
|
||||
: ActionParameterValidationResult.Failure(errors.ToArray());
|
||||
}
|
||||
|
||||
private static IEnumerable<ActionDefinition> CreateBuiltInActions()
|
||||
{
|
||||
// CVE/Finding Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "approve",
|
||||
DisplayName = "Approve Risk",
|
||||
Description = "Accept the risk for a CVE finding with documented justification",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.High,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = true,
|
||||
CompensationActionType = "revoke_approval",
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "CVE identifier",
|
||||
ValidationPattern = CveIdPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "justification",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Risk acceptance justification"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "expires_days",
|
||||
Type = "int",
|
||||
Required = false,
|
||||
Description = "Days until approval expires",
|
||||
DefaultValue = "90"
|
||||
}
|
||||
],
|
||||
Tags = ["cve", "risk", "vex"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "revoke_approval",
|
||||
DisplayName = "Revoke Risk Approval",
|
||||
Description = "Revoke a previously approved risk acceptance",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Medium,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "CVE identifier",
|
||||
ValidationPattern = CveIdPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "reason",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Reason for revocation"
|
||||
}
|
||||
],
|
||||
Tags = ["cve", "risk", "vex"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "quarantine",
|
||||
DisplayName = "Quarantine Image",
|
||||
Description = "Block an image from deployment due to critical vulnerability",
|
||||
RequiredRole = "security-lead",
|
||||
RiskLevel = ActionRiskLevel.Critical,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = true,
|
||||
CompensationActionType = "release_quarantine",
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest to quarantine",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "reason",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Reason for quarantine"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_ids",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Comma-separated CVE IDs"
|
||||
}
|
||||
],
|
||||
Tags = ["container", "security", "deployment"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "release_quarantine",
|
||||
DisplayName = "Release from Quarantine",
|
||||
Description = "Release a previously quarantined image",
|
||||
RequiredRole = "security-lead",
|
||||
RiskLevel = ActionRiskLevel.High,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest to release",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "justification",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Justification for release"
|
||||
}
|
||||
],
|
||||
Tags = ["container", "security", "deployment"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "defer",
|
||||
DisplayName = "Defer Finding",
|
||||
Description = "Defer remediation of a finding to a later date",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = true,
|
||||
CompensationActionType = "undefer",
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "finding_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Finding identifier"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "defer_days",
|
||||
Type = "int",
|
||||
Required = true,
|
||||
Description = "Days to defer"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "reason",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Reason for deferral"
|
||||
}
|
||||
],
|
||||
Tags = ["finding", "triage"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "undefer",
|
||||
DisplayName = "Undefer Finding",
|
||||
Description = "Remove deferral from a finding",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "finding_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Finding identifier"
|
||||
}
|
||||
],
|
||||
Tags = ["finding", "triage"]
|
||||
};
|
||||
|
||||
// VEX Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "create_vex",
|
||||
DisplayName = "Create VEX Statement",
|
||||
Description = "Create a VEX statement for a CVE",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Medium,
|
||||
IsIdempotent = false,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "cve_id",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "CVE identifier",
|
||||
ValidationPattern = CveIdPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "status",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "VEX status (not_affected, affected, under_investigation, fixed)"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "justification",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Justification for not_affected status"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "impact_statement",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Impact statement for affected status"
|
||||
}
|
||||
],
|
||||
Tags = ["vex", "compliance"]
|
||||
};
|
||||
|
||||
// Report Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "generate_manifest",
|
||||
DisplayName = "Generate Security Manifest",
|
||||
Description = "Generate a security manifest for an image",
|
||||
RequiredRole = "viewer",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "format",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Output format (json, pdf)",
|
||||
DefaultValue = "json"
|
||||
}
|
||||
],
|
||||
Tags = ["report", "compliance"]
|
||||
};
|
||||
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "export_sbom",
|
||||
DisplayName = "Export SBOM",
|
||||
Description = "Export SBOM in specified format",
|
||||
RequiredRole = "viewer",
|
||||
RiskLevel = ActionRiskLevel.Low,
|
||||
IsIdempotent = true,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "image_digest",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Image digest",
|
||||
ValidationPattern = ImageDigestPattern().ToString()
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "format",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "SBOM format (spdx-json, cyclonedx-json)",
|
||||
DefaultValue = "spdx-json"
|
||||
}
|
||||
],
|
||||
Tags = ["sbom", "export", "compliance"]
|
||||
};
|
||||
|
||||
// Notification Actions
|
||||
yield return new ActionDefinition
|
||||
{
|
||||
ActionType = "notify_team",
|
||||
DisplayName = "Notify Team",
|
||||
Description = "Send notification to a team channel",
|
||||
RequiredRole = "security-analyst",
|
||||
RiskLevel = ActionRiskLevel.Medium,
|
||||
IsIdempotent = false,
|
||||
HasCompensation = false,
|
||||
Parameters =
|
||||
[
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "channel",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Notification channel"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "message",
|
||||
Type = "string",
|
||||
Required = true,
|
||||
Description = "Message content"
|
||||
},
|
||||
new ActionParameterDefinition
|
||||
{
|
||||
Name = "priority",
|
||||
Type = "string",
|
||||
Required = false,
|
||||
Description = "Priority (low, medium, high, critical)",
|
||||
DefaultValue = "medium"
|
||||
}
|
||||
],
|
||||
Tags = ["notification", "communication"]
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^CVE-\d{4}-\d{4,}$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex CveIdPattern();
|
||||
|
||||
[GeneratedRegex(@"^sha256:[a-fA-F0-9]{64}$")]
|
||||
private static partial Regex ImageDigestPattern();
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// <copyright file="ApprovalWorkflowAdapter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory approval workflow adapter for development and testing.
|
||||
/// In production, this would integrate with ReviewWorkflowService.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-004
|
||||
/// </summary>
|
||||
internal sealed class ApprovalWorkflowAdapter : IApprovalWorkflowAdapter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ApprovalRequestState> _requests = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
private readonly ILogger<ApprovalWorkflowAdapter> _logger;
|
||||
|
||||
public ApprovalWorkflowAdapter(
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
ILogger<ApprovalWorkflowAdapter> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_guidGenerator = guidGenerator ?? throw new ArgumentNullException(nameof(guidGenerator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApprovalRequest> CreateApprovalRequestAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(decision);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var requestId = _guidGenerator.NewGuid().ToString();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var timeout = decision.ExpiresAt.HasValue
|
||||
? decision.ExpiresAt.Value - now
|
||||
: TimeSpan.FromHours(4);
|
||||
|
||||
var request = new ApprovalRequest
|
||||
{
|
||||
RequestId = requestId,
|
||||
WorkflowId = decision.ApprovalWorkflowId ?? "default",
|
||||
TenantId = context.TenantId,
|
||||
RequesterId = context.UserId,
|
||||
RequiredApprovers = decision.RequiredApprovers,
|
||||
Timeout = timeout,
|
||||
Payload = new ApprovalPayload
|
||||
{
|
||||
ActionType = proposal.ActionType,
|
||||
ActionLabel = proposal.Label,
|
||||
Parameters = proposal.Parameters,
|
||||
RunId = context.RunId,
|
||||
FindingId = context.FindingId,
|
||||
PolicyReason = decision.Reason
|
||||
},
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
var state = new ApprovalRequestState
|
||||
{
|
||||
Request = request,
|
||||
State = ApprovalState.Pending,
|
||||
Approvals = ImmutableArray<ApprovalEntry>.Empty
|
||||
};
|
||||
|
||||
_requests[requestId] = state;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created approval request {RequestId} for action {ActionType} by user {UserId}",
|
||||
requestId, proposal.ActionType, context.UserId);
|
||||
|
||||
return Task.FromResult(request);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApprovalStatus?> GetApprovalStatusAsync(
|
||||
string requestId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_requests.TryGetValue(requestId, out var state))
|
||||
{
|
||||
return Task.FromResult<ApprovalStatus?>(null);
|
||||
}
|
||||
|
||||
// Check for expiration
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (state.State == ApprovalState.Pending && now >= state.Request.ExpiresAt)
|
||||
{
|
||||
state.State = ApprovalState.Expired;
|
||||
state.UpdatedAt = now;
|
||||
}
|
||||
|
||||
var status = new ApprovalStatus
|
||||
{
|
||||
RequestId = requestId,
|
||||
State = state.State,
|
||||
Approvals = state.Approvals,
|
||||
CreatedAt = state.Request.CreatedAt,
|
||||
UpdatedAt = state.UpdatedAt,
|
||||
ExpiresAt = state.Request.ExpiresAt
|
||||
};
|
||||
|
||||
return Task.FromResult<ApprovalStatus?>(status);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApprovalResult> WaitForApprovalAsync(
|
||||
string requestId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cts.IsCancellationRequested)
|
||||
{
|
||||
var status = await GetApprovalStatusAsync(requestId, cts.Token);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
DenialReason = "Approval request not found"
|
||||
};
|
||||
}
|
||||
|
||||
switch (status.State)
|
||||
{
|
||||
case ApprovalState.Approved:
|
||||
var approvalEntry = status.Approvals.LastOrDefault();
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = true,
|
||||
ApproverId = approvalEntry?.ApproverId,
|
||||
DecidedAt = approvalEntry?.DecidedAt,
|
||||
Comments = approvalEntry?.Comments
|
||||
};
|
||||
|
||||
case ApprovalState.Denied:
|
||||
var denialEntry = status.Approvals.LastOrDefault(a => !a.Approved);
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
ApproverId = denialEntry?.ApproverId,
|
||||
DecidedAt = denialEntry?.DecidedAt,
|
||||
Comments = denialEntry?.Comments,
|
||||
DenialReason = denialEntry?.Comments ?? "Request denied"
|
||||
};
|
||||
|
||||
case ApprovalState.Expired:
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
TimedOut = true,
|
||||
DenialReason = "Approval request expired"
|
||||
};
|
||||
|
||||
case ApprovalState.Cancelled:
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
Cancelled = true,
|
||||
DenialReason = "Approval request was cancelled"
|
||||
};
|
||||
|
||||
case ApprovalState.Pending:
|
||||
// Continue waiting
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout or cancellation
|
||||
}
|
||||
|
||||
return new ApprovalResult
|
||||
{
|
||||
Approved = false,
|
||||
TimedOut = true,
|
||||
DenialReason = "Timed out waiting for approval"
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CancelApprovalRequestAsync(
|
||||
string requestId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_requests.TryGetValue(requestId, out var state) && state.State == ApprovalState.Pending)
|
||||
{
|
||||
state.State = ApprovalState.Cancelled;
|
||||
state.UpdatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Cancelled approval request {RequestId}: {Reason}",
|
||||
requestId, reason);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an approval decision (used for testing and external approval callbacks).
|
||||
/// </summary>
|
||||
public void RecordApproval(string requestId, string approverId, bool approved, string? comments = null)
|
||||
{
|
||||
if (!_requests.TryGetValue(requestId, out var state))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval request not found: {requestId}");
|
||||
}
|
||||
|
||||
if (state.State != ApprovalState.Pending)
|
||||
{
|
||||
throw new InvalidOperationException($"Request {requestId} is not pending");
|
||||
}
|
||||
|
||||
var entry = new ApprovalEntry
|
||||
{
|
||||
ApproverId = approverId,
|
||||
Approved = approved,
|
||||
Comments = comments,
|
||||
DecidedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
state.Approvals = state.Approvals.Add(entry);
|
||||
state.UpdatedAt = entry.DecidedAt;
|
||||
|
||||
if (!approved)
|
||||
{
|
||||
state.State = ApprovalState.Denied;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Check if all required approvals are met
|
||||
var approvedRoles = state.Approvals
|
||||
.Where(a => a.Approved)
|
||||
.Select(a => a.ApproverId)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Simplified check: any required approver approving is sufficient
|
||||
// In production, would check against actual role membership
|
||||
state.State = ApprovalState.Approved;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recorded {Decision} for request {RequestId} by {ApproverId}",
|
||||
approved ? "approval" : "denial", requestId, approverId);
|
||||
}
|
||||
|
||||
private sealed class ApprovalRequestState
|
||||
{
|
||||
public required ApprovalRequest Request { get; init; }
|
||||
public ApprovalState State { get; set; }
|
||||
public ImmutableArray<ApprovalEntry> Approvals { get; set; }
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// <copyright file="IActionAuditLedger.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Audit ledger for recording all action attempts and outcomes.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-006
|
||||
/// </summary>
|
||||
public interface IActionAuditLedger
|
||||
{
|
||||
/// <summary>
|
||||
/// Records an audit entry for an action.
|
||||
/// </summary>
|
||||
/// <param name="entry">The audit entry to record.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordAsync(ActionAuditEntry entry, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Queries audit entries.
|
||||
/// </summary>
|
||||
/// <param name="query">The query criteria.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Matching audit entries.</returns>
|
||||
Task<ImmutableArray<ActionAuditEntry>> QueryAsync(
|
||||
ActionAuditQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific audit entry by ID.
|
||||
/// </summary>
|
||||
/// <param name="entryId">The entry ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The audit entry or null if not found.</returns>
|
||||
Task<ActionAuditEntry?> GetAsync(
|
||||
string entryId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets audit entries for a specific run.
|
||||
/// </summary>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Audit entries for the run.</returns>
|
||||
Task<ImmutableArray<ActionAuditEntry>> GetByRunAsync(
|
||||
string runId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An audit entry for an action attempt.
|
||||
/// </summary>
|
||||
public sealed record ActionAuditEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique entry identifier.
|
||||
/// </summary>
|
||||
public required string EntryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant identifier.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action was attempted.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of action attempted.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who attempted the action.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the action attempt.
|
||||
/// </summary>
|
||||
public required ActionAuditOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated AI run ID.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated finding ID.
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated CVE ID.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated image digest.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy that evaluated the action.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy decision result.
|
||||
/// </summary>
|
||||
public PolicyDecisionKind? PolicyResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approval request ID if approval was required.
|
||||
/// </summary>
|
||||
public string? ApprovalRequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who approved the action.
|
||||
/// </summary>
|
||||
public string? ApproverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Execution result ID if executed.
|
||||
/// </summary>
|
||||
public string? ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result digest for verification.
|
||||
/// </summary>
|
||||
public string? ResultDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Attestation digest if attested.
|
||||
/// </summary>
|
||||
public string? AttestationDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of an action attempt.
|
||||
/// </summary>
|
||||
public enum ActionAuditOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Action was successfully executed.
|
||||
/// </summary>
|
||||
Executed,
|
||||
|
||||
/// <summary>
|
||||
/// Action was denied by policy.
|
||||
/// </summary>
|
||||
DeniedByPolicy,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was requested.
|
||||
/// </summary>
|
||||
ApprovalRequested,
|
||||
|
||||
/// <summary>
|
||||
/// Action was approved and executed.
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was denied.
|
||||
/// </summary>
|
||||
ApprovalDenied,
|
||||
|
||||
/// <summary>
|
||||
/// Approval request timed out.
|
||||
/// </summary>
|
||||
ApprovalTimedOut,
|
||||
|
||||
/// <summary>
|
||||
/// Execution failed.
|
||||
/// </summary>
|
||||
ExecutionFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Action was skipped due to idempotency.
|
||||
/// </summary>
|
||||
IdempotentSkipped,
|
||||
|
||||
/// <summary>
|
||||
/// Action was rolled back.
|
||||
/// </summary>
|
||||
RolledBack,
|
||||
|
||||
/// <summary>
|
||||
/// Action validation failed.
|
||||
/// </summary>
|
||||
ValidationFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query criteria for audit entries.
|
||||
/// </summary>
|
||||
public sealed record ActionAuditQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by tenant.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by action type.
|
||||
/// </summary>
|
||||
public string? ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by actor (user).
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by outcome.
|
||||
/// </summary>
|
||||
public ActionAuditOutcome? Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by run ID.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by CVE ID.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by image digest.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Start of time range (inclusive).
|
||||
/// </summary>
|
||||
public DateTimeOffset? FromTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// End of time range (exclusive).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ToTimestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int Limit { get; init; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Offset for pagination.
|
||||
/// </summary>
|
||||
public int Offset { get; init; } = 0;
|
||||
}
|
||||
349
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionExecutor.cs
Normal file
349
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionExecutor.cs
Normal file
@@ -0,0 +1,349 @@
|
||||
// <copyright file="IActionExecutor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Executes AI-proposed actions after policy gate approval.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-002
|
||||
/// </summary>
|
||||
public interface IActionExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes an action after policy gate approval.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The approved action proposal.</param>
|
||||
/// <param name="decision">The policy decision that approved the action.</param>
|
||||
/// <param name="context">The execution context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The execution result.</returns>
|
||||
Task<ActionExecutionResult> ExecuteAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Rolls back a previously executed action if supported.
|
||||
/// </summary>
|
||||
/// <param name="executionId">The execution ID to rollback.</param>
|
||||
/// <param name="context">The context for rollback.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The rollback result.</returns>
|
||||
Task<ActionRollbackResult> RollbackAsync(
|
||||
string executionId,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an action execution.
|
||||
/// </summary>
|
||||
/// <param name="executionId">The execution ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The current execution status.</returns>
|
||||
Task<ActionExecutionStatus?> GetStatusAsync(
|
||||
string executionId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Lists available action types supported by this executor.
|
||||
/// </summary>
|
||||
/// <returns>The available action types.</returns>
|
||||
ImmutableArray<ActionTypeInfo> GetSupportedActionTypes();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of action execution.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutionResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this execution.
|
||||
/// </summary>
|
||||
public required string ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of the execution.
|
||||
/// </summary>
|
||||
public required ActionExecutionOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message about the execution.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output data from the action.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> OutputData { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When execution started.
|
||||
/// </summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When execution completed (null if still running).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Duration of the execution.
|
||||
/// </summary>
|
||||
public TimeSpan? Duration => CompletedAt.HasValue
|
||||
? CompletedAt.Value - StartedAt
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action can be rolled back.
|
||||
/// </summary>
|
||||
public bool CanRollback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error details if execution failed.
|
||||
/// </summary>
|
||||
public ActionError? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related artifact IDs created or modified by this action.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AffectedArtifacts { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of action execution.
|
||||
/// </summary>
|
||||
public enum ActionExecutionOutcome
|
||||
{
|
||||
/// <summary>
|
||||
/// Action executed successfully.
|
||||
/// </summary>
|
||||
Success,
|
||||
|
||||
/// <summary>
|
||||
/// Action partially completed.
|
||||
/// </summary>
|
||||
PartialSuccess,
|
||||
|
||||
/// <summary>
|
||||
/// Action failed.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Action was cancelled.
|
||||
/// </summary>
|
||||
Cancelled,
|
||||
|
||||
/// <summary>
|
||||
/// Action execution timed out.
|
||||
/// </summary>
|
||||
Timeout,
|
||||
|
||||
/// <summary>
|
||||
/// Action was skipped due to idempotency (already executed).
|
||||
/// </summary>
|
||||
Skipped,
|
||||
|
||||
/// <summary>
|
||||
/// Action is pending approval.
|
||||
/// </summary>
|
||||
PendingApproval,
|
||||
|
||||
/// <summary>
|
||||
/// Action is currently executing.
|
||||
/// </summary>
|
||||
InProgress
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current status of an action execution.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutionStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The execution ID.
|
||||
/// </summary>
|
||||
public required string ExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current outcome/state.
|
||||
/// </summary>
|
||||
public required ActionExecutionOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Progress percentage if known (0-100).
|
||||
/// </summary>
|
||||
public int? ProgressPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status message.
|
||||
/// </summary>
|
||||
public string? StatusMessage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When status was last updated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Estimated completion time if known.
|
||||
/// </summary>
|
||||
public DateTimeOffset? EstimatedCompletionAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes an error that occurred during action execution.
|
||||
/// </summary>
|
||||
public sealed record ActionError
|
||||
{
|
||||
/// <summary>
|
||||
/// Error code for programmatic handling.
|
||||
/// </summary>
|
||||
public required string Code { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed error information.
|
||||
/// </summary>
|
||||
public string? Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the error is retryable.
|
||||
/// </summary>
|
||||
public bool IsRetryable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested wait time before retry.
|
||||
/// </summary>
|
||||
public TimeSpan? RetryAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Inner error if this is a wrapper.
|
||||
/// </summary>
|
||||
public ActionError? InnerError { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of rolling back an action.
|
||||
/// </summary>
|
||||
public sealed record ActionRollbackResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the rollback was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Message about the rollback.
|
||||
/// </summary>
|
||||
public string? Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error if rollback failed.
|
||||
/// </summary>
|
||||
public ActionError? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When rollback completed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an available action type.
|
||||
/// </summary>
|
||||
public sealed record ActionTypeInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// The action type identifier.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of what this action does.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category for grouping actions.
|
||||
/// </summary>
|
||||
public string? Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required parameters for this action type.
|
||||
/// </summary>
|
||||
public ImmutableArray<ActionParameterInfo> Parameters { get; init; } =
|
||||
ImmutableArray<ActionParameterInfo>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Required permission to execute this action.
|
||||
/// </summary>
|
||||
public string? RequiredPermission { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action supports rollback.
|
||||
/// </summary>
|
||||
public bool SupportsRollback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this action is destructive (requires extra confirmation).
|
||||
/// </summary>
|
||||
public bool IsDestructive { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an action parameter.
|
||||
/// </summary>
|
||||
public sealed record ActionParameterInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Parameter name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name.
|
||||
/// </summary>
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the parameter.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this parameter is required.
|
||||
/// </summary>
|
||||
public bool IsRequired { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameter type (string, integer, boolean, etc.).
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Default value if not specified.
|
||||
/// </summary>
|
||||
public string? DefaultValue { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Valid values for enum-like parameters.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> AllowedValues { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
358
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionPolicyGate.cs
Normal file
358
src/AdvisoryAI/StellaOps.AdvisoryAI/Actions/IActionPolicyGate.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
// <copyright file="IActionPolicyGate.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether AI-proposed actions are allowed by policy.
|
||||
/// Integrates with K4 lattice for VEX-aware decisions and approval workflows.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-001
|
||||
/// </summary>
|
||||
public interface IActionPolicyGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates whether an action is allowed by policy.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal from the AI.</param>
|
||||
/// <param name="context">The execution context including tenant, user, roles, environment.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The policy decision with any required approvals.</returns>
|
||||
Task<ActionPolicyDecision> EvaluateAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable explanation for a policy decision.
|
||||
/// </summary>
|
||||
/// <param name="decision">The decision to explain.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Human-readable explanation with policy references.</returns>
|
||||
Task<PolicyExplanation> ExplainAsync(
|
||||
ActionPolicyDecision decision,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an action has already been executed (idempotency check).
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal.</param>
|
||||
/// <param name="context">The execution context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if the action was already executed with the same parameters.</returns>
|
||||
Task<IdempotencyCheckResult> CheckIdempotencyAsync(
|
||||
ActionProposal proposal,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for action policy evaluation.
|
||||
/// </summary>
|
||||
public sealed record ActionContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant identifier for multi-tenancy.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User identifier who initiated the action.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User's roles/permissions.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> UserRoles { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target environment (production, staging, development, etc.).
|
||||
/// </summary>
|
||||
public required string Environment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated AI run ID, if any.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated finding ID for remediation actions.
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// CVE ID if this is a vulnerability-related action.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Image digest if this is a container-related action.
|
||||
/// </summary>
|
||||
public string? ImageDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional correlation ID for tracing.
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata for policy evaluation.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An action proposed by the AI system.
|
||||
/// </summary>
|
||||
public sealed record ActionProposal
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this proposal.
|
||||
/// </summary>
|
||||
public required string ProposalId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of action (e.g., "approve", "quarantine", "create_vex").
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable label for the action.
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action parameters.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, string> Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the proposal was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the proposal expires (null = never).
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Idempotency key for deduplication.
|
||||
/// </summary>
|
||||
public string? IdempotencyKey { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of policy gate evaluation.
|
||||
/// </summary>
|
||||
public sealed record ActionPolicyDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// The decision outcome.
|
||||
/// </summary>
|
||||
public required PolicyDecisionKind Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reference to the policy that made this decision.
|
||||
/// </summary>
|
||||
public string? PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Brief reason for the decision.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required approvers if decision is AllowWithApproval.
|
||||
/// </summary>
|
||||
public ImmutableArray<RequiredApprover> RequiredApprovers { get; init; } =
|
||||
ImmutableArray<RequiredApprover>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Approval workflow ID if approval is required.
|
||||
/// </summary>
|
||||
public string? ApprovalWorkflowId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// K4 lattice position used in the decision.
|
||||
/// </summary>
|
||||
public string? K4Position { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// VEX status that influenced the decision, if any.
|
||||
/// </summary>
|
||||
public string? VexStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Severity level assigned by policy.
|
||||
/// </summary>
|
||||
public int? SeverityLevel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this decision expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional decision metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kinds of policy decisions.
|
||||
/// </summary>
|
||||
public enum PolicyDecisionKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Action is allowed and can execute immediately.
|
||||
/// </summary>
|
||||
Allow,
|
||||
|
||||
/// <summary>
|
||||
/// Action is allowed but requires approval workflow.
|
||||
/// </summary>
|
||||
AllowWithApproval,
|
||||
|
||||
/// <summary>
|
||||
/// Action is denied by policy.
|
||||
/// </summary>
|
||||
Deny,
|
||||
|
||||
/// <summary>
|
||||
/// Action is denied but admin can override.
|
||||
/// </summary>
|
||||
DenyWithOverride,
|
||||
|
||||
/// <summary>
|
||||
/// Decision could not be made (missing context).
|
||||
/// </summary>
|
||||
Indeterminate
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Describes a required approver for AllowWithApproval decisions.
|
||||
/// </summary>
|
||||
public sealed record RequiredApprover
|
||||
{
|
||||
/// <summary>
|
||||
/// Type of approver requirement.
|
||||
/// </summary>
|
||||
public required ApproverType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identifier (user ID, role name, or group name).
|
||||
/// </summary>
|
||||
public required string Identifier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of approval requirements.
|
||||
/// </summary>
|
||||
public enum ApproverType
|
||||
{
|
||||
/// <summary>
|
||||
/// Specific user must approve.
|
||||
/// </summary>
|
||||
User,
|
||||
|
||||
/// <summary>
|
||||
/// Any user with this role can approve.
|
||||
/// </summary>
|
||||
Role,
|
||||
|
||||
/// <summary>
|
||||
/// Any member of this group can approve.
|
||||
/// </summary>
|
||||
Group
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable explanation of a policy decision.
|
||||
/// </summary>
|
||||
public sealed record PolicyExplanation
|
||||
{
|
||||
/// <summary>
|
||||
/// Natural language summary of the decision.
|
||||
/// </summary>
|
||||
public required string Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed explanation points.
|
||||
/// </summary>
|
||||
public required ImmutableArray<string> Details { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// References to policies that were evaluated.
|
||||
/// </summary>
|
||||
public ImmutableArray<PolicyReference> PolicyReferences { get; init; } =
|
||||
ImmutableArray<PolicyReference>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Suggested next steps for the user.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SuggestedActions { get; init; } =
|
||||
ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a specific policy.
|
||||
/// </summary>
|
||||
public sealed record PolicyReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
public required string PolicyId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rule within the policy that matched.
|
||||
/// </summary>
|
||||
public string? RuleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Link to policy documentation.
|
||||
/// </summary>
|
||||
public string? DocumentationUrl { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of idempotency check.
|
||||
/// </summary>
|
||||
public sealed record IdempotencyCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action was previously executed.
|
||||
/// </summary>
|
||||
public required bool WasExecuted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous execution ID if executed.
|
||||
/// </summary>
|
||||
public string? PreviousExecutionId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action was previously executed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExecutedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Result of the previous execution.
|
||||
/// </summary>
|
||||
public string? PreviousResult { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// <copyright file="IActionRegistry.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of available action types and their definitions.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-003
|
||||
/// </summary>
|
||||
public interface IActionRegistry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the definition for an action type.
|
||||
/// </summary>
|
||||
/// <param name="actionType">The action type identifier.</param>
|
||||
/// <returns>The definition or null if not found.</returns>
|
||||
ActionDefinition? GetAction(string actionType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered action definitions.
|
||||
/// </summary>
|
||||
/// <returns>All action definitions.</returns>
|
||||
ImmutableArray<ActionDefinition> GetAllActions();
|
||||
|
||||
/// <summary>
|
||||
/// Gets actions by risk level.
|
||||
/// </summary>
|
||||
/// <param name="riskLevel">The risk level to filter by.</param>
|
||||
/// <returns>Actions matching the risk level.</returns>
|
||||
ImmutableArray<ActionDefinition> GetActionsByRiskLevel(ActionRiskLevel riskLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Gets actions by tag.
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag to filter by.</param>
|
||||
/// <returns>Actions with the specified tag.</returns>
|
||||
ImmutableArray<ActionDefinition> GetActionsByTag(string tag);
|
||||
|
||||
/// <summary>
|
||||
/// Validates action parameters against the definition.
|
||||
/// </summary>
|
||||
/// <param name="actionType">The action type.</param>
|
||||
/// <param name="parameters">The parameters to validate.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
ActionParameterValidationResult ValidateParameters(
|
||||
string actionType,
|
||||
ImmutableDictionary<string, string> parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parameter validation.
|
||||
/// </summary>
|
||||
public sealed record ActionParameterValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether validation passed.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation errors if any.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Errors { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful validation result.
|
||||
/// </summary>
|
||||
public static ActionParameterValidationResult Success => new() { IsValid = true };
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed validation result.
|
||||
/// </summary>
|
||||
public static ActionParameterValidationResult Failure(params string[] errors) =>
|
||||
new() { IsValid = false, Errors = errors.ToImmutableArray() };
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// <copyright file="IApprovalWorkflowAdapter.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter for integrating with approval workflow systems.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-004
|
||||
/// </summary>
|
||||
public interface IApprovalWorkflowAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an approval request for an action.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal.</param>
|
||||
/// <param name="decision">The policy decision requiring approval.</param>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created approval request.</returns>
|
||||
Task<ApprovalRequest> CreateApprovalRequestAsync(
|
||||
ActionProposal proposal,
|
||||
ActionPolicyDecision decision,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the status of an approval request.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The approval status or null if not found.</returns>
|
||||
Task<ApprovalStatus?> GetApprovalStatusAsync(
|
||||
string requestId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Waits for an approval decision with timeout.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request ID.</param>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The approval result.</returns>
|
||||
Task<ApprovalResult> WaitForApprovalAsync(
|
||||
string requestId,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending approval request.
|
||||
/// </summary>
|
||||
/// <param name="requestId">The request ID.</param>
|
||||
/// <param name="reason">Cancellation reason.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task CancelApprovalRequestAsync(
|
||||
string requestId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An approval request for an action.
|
||||
/// </summary>
|
||||
public sealed record ApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique request identifier.
|
||||
/// </summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated workflow ID.
|
||||
/// </summary>
|
||||
public required string WorkflowId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who requested the action.
|
||||
/// </summary>
|
||||
public required string RequesterId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Required approvers.
|
||||
/// </summary>
|
||||
public required ImmutableArray<RequiredApprover> RequiredApprovers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Request timeout.
|
||||
/// </summary>
|
||||
public required TimeSpan Timeout { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Payload containing action details.
|
||||
/// </summary>
|
||||
public required ApprovalPayload Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the request was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the request expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt => CreatedAt.Add(Timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Payload for an approval request.
|
||||
/// </summary>
|
||||
public sealed record ApprovalPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Action type being requested.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable action label.
|
||||
/// </summary>
|
||||
public required string ActionLabel { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Action parameters.
|
||||
/// </summary>
|
||||
public required ImmutableDictionary<string, string> Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated run ID.
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated finding ID.
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy reason for requiring approval.
|
||||
/// </summary>
|
||||
public string? PolicyReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current status of an approval request.
|
||||
/// </summary>
|
||||
public sealed record ApprovalStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Request ID.
|
||||
/// </summary>
|
||||
public required string RequestId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current state.
|
||||
/// </summary>
|
||||
public required ApprovalState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approvals received so far.
|
||||
/// </summary>
|
||||
public ImmutableArray<ApprovalEntry> Approvals { get; init; } =
|
||||
ImmutableArray<ApprovalEntry>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When the request was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the state was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the request expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// State of an approval request.
|
||||
/// </summary>
|
||||
public enum ApprovalState
|
||||
{
|
||||
/// <summary>
|
||||
/// Waiting for approvals.
|
||||
/// </summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>
|
||||
/// All required approvals received.
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// Request was denied.
|
||||
/// </summary>
|
||||
Denied,
|
||||
|
||||
/// <summary>
|
||||
/// Request timed out.
|
||||
/// </summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>
|
||||
/// Request was cancelled.
|
||||
/// </summary>
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An individual approval entry.
|
||||
/// </summary>
|
||||
public sealed record ApprovalEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// User who approved/denied.
|
||||
/// </summary>
|
||||
public required string ApproverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether they approved.
|
||||
/// </summary>
|
||||
public required bool Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comments from the approver.
|
||||
/// </summary>
|
||||
public string? Comments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made.
|
||||
/// </summary>
|
||||
public required DateTimeOffset DecidedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of waiting for approval.
|
||||
/// </summary>
|
||||
public sealed record ApprovalResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action was approved.
|
||||
/// </summary>
|
||||
public required bool Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the request timed out.
|
||||
/// </summary>
|
||||
public bool TimedOut { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the request was cancelled.
|
||||
/// </summary>
|
||||
public bool Cancelled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who made the final decision.
|
||||
/// </summary>
|
||||
public string? ApproverId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the decision was made.
|
||||
/// </summary>
|
||||
public DateTimeOffset? DecidedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Comments from the approver.
|
||||
/// </summary>
|
||||
public string? Comments { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for denial/cancellation.
|
||||
/// </summary>
|
||||
public string? DenialReason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// <copyright file="IGuidGenerator.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for GUID generation to enable deterministic testing.
|
||||
/// </summary>
|
||||
public interface IGuidGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new GUID.
|
||||
/// </summary>
|
||||
/// <returns>A new GUID.</returns>
|
||||
Guid NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation using Guid.NewGuid().
|
||||
/// </summary>
|
||||
internal sealed class DefaultGuidGenerator : IGuidGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static readonly IGuidGenerator Instance = new DefaultGuidGenerator();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid NewGuid() => Guid.NewGuid();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// <copyright file="IIdempotencyHandler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Handles idempotency checking for action execution.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-005
|
||||
/// </summary>
|
||||
public interface IIdempotencyHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a deterministic idempotency key for an action.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The action proposal.</param>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <returns>The idempotency key.</returns>
|
||||
string GenerateKey(ActionProposal proposal, ActionContext context);
|
||||
|
||||
/// <summary>
|
||||
/// Checks if an action was already executed.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Check result with previous execution if found.</returns>
|
||||
Task<IdempotencyResult> CheckAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records an action execution for idempotency.
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="result">The execution result.</param>
|
||||
/// <param name="context">The action context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RecordExecutionAsync(
|
||||
string key,
|
||||
ActionExecutionResult result,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an idempotency record (for rollback scenarios).
|
||||
/// </summary>
|
||||
/// <param name="key">The idempotency key.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task RemoveAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of idempotency check.
|
||||
/// </summary>
|
||||
public sealed record IdempotencyResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the action was already executed.
|
||||
/// </summary>
|
||||
public required bool AlreadyExecuted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Previous execution result if executed.
|
||||
/// </summary>
|
||||
public ActionExecutionResult? PreviousResult { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the action was previously executed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExecutedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User who executed the action.
|
||||
/// </summary>
|
||||
public string? ExecutedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a result indicating no previous execution.
|
||||
/// </summary>
|
||||
public static IdempotencyResult NotExecuted => new() { AlreadyExecuted = false };
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// <copyright file="IdempotencyHandler.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory idempotency handler for development and testing.
|
||||
/// In production, this would use PostgreSQL with TTL cleanup.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-005
|
||||
/// </summary>
|
||||
internal sealed class IdempotencyHandler : IIdempotencyHandler
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IdempotencyRecord> _records = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IdempotencyOptions _options;
|
||||
private readonly ILogger<IdempotencyHandler> _logger;
|
||||
|
||||
public IdempotencyHandler(
|
||||
TimeProvider timeProvider,
|
||||
IOptions<IdempotencyOptions> options,
|
||||
ILogger<IdempotencyHandler> logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_options = options?.Value ?? new IdempotencyOptions();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateKey(ActionProposal proposal, ActionContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(proposal);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
// Key components: tenant, action type, target identifiers
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(context.TenantId);
|
||||
sb.Append('|');
|
||||
sb.Append(proposal.ActionType);
|
||||
|
||||
// Add target-specific components in sorted order for determinism
|
||||
var targets = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (proposal.Parameters.TryGetValue("cve_id", out var cveId) && !string.IsNullOrEmpty(cveId))
|
||||
{
|
||||
targets["cve"] = cveId;
|
||||
}
|
||||
|
||||
if (proposal.Parameters.TryGetValue("image_digest", out var digest) && !string.IsNullOrEmpty(digest))
|
||||
{
|
||||
targets["image"] = digest;
|
||||
}
|
||||
|
||||
if (proposal.Parameters.TryGetValue("finding_id", out var findingId) && !string.IsNullOrEmpty(findingId))
|
||||
{
|
||||
targets["finding"] = findingId;
|
||||
}
|
||||
|
||||
if (proposal.Parameters.TryGetValue("component", out var component) && !string.IsNullOrEmpty(component))
|
||||
{
|
||||
targets["component"] = component;
|
||||
}
|
||||
|
||||
// If using explicit idempotency key from proposal, include it
|
||||
if (!string.IsNullOrEmpty(proposal.IdempotencyKey))
|
||||
{
|
||||
targets["key"] = proposal.IdempotencyKey;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in targets)
|
||||
{
|
||||
sb.Append('|');
|
||||
sb.Append(key);
|
||||
sb.Append(':');
|
||||
sb.Append(value);
|
||||
}
|
||||
|
||||
var content = sb.ToString();
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IdempotencyResult> CheckAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
|
||||
if (!_records.TryGetValue(key, out var record))
|
||||
{
|
||||
return Task.FromResult(IdempotencyResult.NotExecuted);
|
||||
}
|
||||
|
||||
// Check if record has expired
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (now >= record.ExpiresAt)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
return Task.FromResult(IdempotencyResult.NotExecuted);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Idempotency hit for key {Key}, previously executed at {ExecutedAt} by {ExecutedBy}",
|
||||
key, record.ExecutedAt, record.ExecutedBy);
|
||||
|
||||
return Task.FromResult(new IdempotencyResult
|
||||
{
|
||||
AlreadyExecuted = true,
|
||||
PreviousResult = record.Result,
|
||||
ExecutedAt = record.ExecutedAt,
|
||||
ExecutedBy = record.ExecutedBy
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordExecutionAsync(
|
||||
string key,
|
||||
ActionExecutionResult result,
|
||||
ActionContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var record = new IdempotencyRecord
|
||||
{
|
||||
Key = key,
|
||||
Result = result,
|
||||
ExecutedAt = now,
|
||||
ExecutedBy = context.UserId,
|
||||
TenantId = context.TenantId,
|
||||
ExpiresAt = now.AddDays(_options.TtlDays)
|
||||
};
|
||||
|
||||
_records[key] = record;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Recorded idempotency for key {Key}, expires at {ExpiresAt}",
|
||||
key, record.ExpiresAt.ToString("O", CultureInfo.InvariantCulture));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
|
||||
_logger.LogDebug("Removed idempotency record for key {Key}", key);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired records. Should be called periodically.
|
||||
/// </summary>
|
||||
public void CleanupExpired()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiredKeys = _records
|
||||
.Where(kvp => now >= kvp.Value.ExpiresAt)
|
||||
.Select(kvp => kvp.Key)
|
||||
.ToList();
|
||||
|
||||
foreach (var key in expiredKeys)
|
||||
{
|
||||
_records.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
if (expiredKeys.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Cleaned up {Count} expired idempotency records", expiredKeys.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record IdempotencyRecord
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
public required ActionExecutionResult Result { get; init; }
|
||||
public required DateTimeOffset ExecutedAt { get; init; }
|
||||
public required string ExecutedBy { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for idempotency handling.
|
||||
/// </summary>
|
||||
public sealed class IdempotencyOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Days to retain idempotency records before expiration.
|
||||
/// </summary>
|
||||
public int TtlDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether idempotency checking is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// <copyright file="AttestationIntegration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates AI attestation with the conversation service.
|
||||
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-005
|
||||
/// </summary>
|
||||
public sealed class AttestationIntegration : IAttestationIntegration
|
||||
{
|
||||
private readonly IAiAttestationService _attestationService;
|
||||
private readonly IPromptTemplateRegistry _templateRegistry;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<AttestationIntegration> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AttestationIntegration"/> class.
|
||||
/// </summary>
|
||||
public AttestationIntegration(
|
||||
IAiAttestationService attestationService,
|
||||
IPromptTemplateRegistry templateRegistry,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AttestationIntegration> logger)
|
||||
{
|
||||
_attestationService = attestationService ?? throw new ArgumentNullException(nameof(attestationService));
|
||||
_templateRegistry = templateRegistry ?? throw new ArgumentNullException(nameof(templateRegistry));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiAttestationResult?> AttestTurnAsync(
|
||||
string runId,
|
||||
string tenantId,
|
||||
ConversationTurn turn,
|
||||
GroundingResult? groundingResult,
|
||||
bool sign,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (turn.Role != TurnRole.Assistant)
|
||||
{
|
||||
// Only attest assistant (AI) turns
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Attesting turn {TurnId} for run {RunId}", turn.TurnId, runId);
|
||||
|
||||
try
|
||||
{
|
||||
// Extract claims from grounding result and convert to ClaimEvidence format
|
||||
var claims = groundingResult?.GroundedClaims
|
||||
.Select(c => new ClaimEvidence
|
||||
{
|
||||
Text = c.ClaimText,
|
||||
Position = c.Position,
|
||||
Length = c.Length,
|
||||
GroundingScore = c.Confidence,
|
||||
GroundedBy = c.EvidenceLinks
|
||||
.Select(e => e.ToString())
|
||||
.ToImmutableArray(),
|
||||
Verified = c.Confidence >= 0.7
|
||||
})
|
||||
.ToImmutableArray() ?? ImmutableArray<ClaimEvidence>.Empty;
|
||||
|
||||
// Create attestation for the first claim (representative of the turn)
|
||||
// In practice, you might create multiple claim attestations per turn
|
||||
var contentDigest = ComputeContentDigest(turn.Content);
|
||||
var claimText = turn.Content.Length > 500
|
||||
? turn.Content[..500] + "..."
|
||||
: turn.Content;
|
||||
|
||||
var claimAttestation = new AiClaimAttestation
|
||||
{
|
||||
ClaimId = $"{runId}:{turn.TurnId}:turn-claim",
|
||||
RunId = runId,
|
||||
TurnId = turn.TurnId,
|
||||
TenantId = tenantId,
|
||||
ClaimText = claimText,
|
||||
ClaimDigest = ComputeContentDigest(claimText),
|
||||
GroundedBy = claims.SelectMany(c => c.GroundedBy).Distinct().ToImmutableArray(),
|
||||
GroundingScore = groundingResult?.OverallScore ?? 0.0,
|
||||
Verified = groundingResult?.OverallScore >= 0.7,
|
||||
Timestamp = turn.Timestamp,
|
||||
ContentDigest = contentDigest
|
||||
};
|
||||
|
||||
var result = await _attestationService.CreateClaimAttestationAsync(
|
||||
claimAttestation,
|
||||
sign,
|
||||
ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created claim attestation {AttestationId} for turn {TurnId}",
|
||||
result.AttestationId, turn.TurnId);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to create claim attestation for turn {TurnId}: {Message}",
|
||||
turn.TurnId, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiAttestationResult?> AttestRunAsync(
|
||||
Conversation conversation,
|
||||
string runId,
|
||||
string promptTemplateName,
|
||||
AiModelInfo modelInfo,
|
||||
bool sign,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Attesting run {RunId} for conversation {ConversationId}",
|
||||
runId, conversation.ConversationId);
|
||||
|
||||
try
|
||||
{
|
||||
// Get prompt template info
|
||||
var templateInfo = _templateRegistry.GetTemplateInfo(promptTemplateName);
|
||||
|
||||
// Collect all evidence URIs from turns
|
||||
var allEvidenceUris = conversation.Turns
|
||||
.SelectMany(t => t.EvidenceLinks)
|
||||
.Select(e => e.Uri)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
// Build turn summaries with claims
|
||||
var turns = conversation.Turns
|
||||
.Select(t => new AiTurnSummary
|
||||
{
|
||||
TurnId = t.TurnId,
|
||||
Role = MapRole(t.Role),
|
||||
ContentDigest = ComputeContentDigest(t.Content),
|
||||
Timestamp = t.Timestamp,
|
||||
Claims = t.Role == TurnRole.Assistant
|
||||
? ImmutableArray.Create(new ClaimEvidence
|
||||
{
|
||||
Text = t.Content.Length > 200 ? t.Content[..200] + "..." : t.Content,
|
||||
Position = 0,
|
||||
Length = Math.Min(t.Content.Length, 200),
|
||||
GroundedBy = t.EvidenceLinks.Select(e => e.Uri).ToImmutableArray(),
|
||||
GroundingScore = 0.8,
|
||||
Verified = t.EvidenceLinks.Length > 0
|
||||
})
|
||||
: ImmutableArray<ClaimEvidence>.Empty
|
||||
})
|
||||
.ToImmutableArray();
|
||||
|
||||
// Build run context
|
||||
var context = new AiRunContext
|
||||
{
|
||||
FindingId = conversation.Context.ScanId,
|
||||
CveId = conversation.Context.CurrentCveId,
|
||||
Component = conversation.Context.CurrentComponent,
|
||||
ImageDigest = conversation.Context.CurrentImageDigest,
|
||||
PolicyId = conversation.Context.Policy?.PolicyIds.FirstOrDefault(),
|
||||
EvidenceUris = allEvidenceUris
|
||||
};
|
||||
|
||||
var runAttestation = new AiRunAttestation
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = conversation.TenantId,
|
||||
UserId = conversation.UserId ?? "unknown",
|
||||
ConversationId = conversation.ConversationId,
|
||||
StartedAt = conversation.CreatedAt,
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Model = modelInfo,
|
||||
PromptTemplate = templateInfo,
|
||||
Context = context,
|
||||
Turns = turns,
|
||||
OverallGroundingScore = ComputeOverallGroundingScore(turns)
|
||||
};
|
||||
|
||||
var result = await _attestationService.CreateRunAttestationAsync(
|
||||
runAttestation,
|
||||
sign,
|
||||
ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created run attestation {AttestationId} for run {RunId} with {TurnCount} turns",
|
||||
result.AttestationId, runId, turns.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Failed to create run attestation for {RunId}: {Message}",
|
||||
runId, ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AiAttestationVerificationResult> VerifyRunAsync(
|
||||
string runId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_logger.LogDebug("Verifying run attestation for {RunId}", runId);
|
||||
|
||||
var result = await _attestationService.VerifyRunAttestationAsync(runId, ct);
|
||||
|
||||
if (result.Valid)
|
||||
{
|
||||
_logger.LogInformation("Run {RunId} attestation verified successfully", runId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Run {RunId} attestation verification failed: {Reason}",
|
||||
runId, result.FailureReason);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ComputeContentDigest(string content)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(content));
|
||||
return $"sha256:{Convert.ToHexStringLower(hash)}";
|
||||
}
|
||||
|
||||
private static double ComputeOverallGroundingScore(ImmutableArray<AiTurnSummary> turns)
|
||||
{
|
||||
var assistantTurns = turns.Where(t => t.Role == Attestation.Models.TurnRole.Assistant).ToList();
|
||||
if (assistantTurns.Count == 0) return 0.0;
|
||||
|
||||
var avgScore = assistantTurns
|
||||
.Where(t => t.GroundingScore.HasValue)
|
||||
.DefaultIfEmpty()
|
||||
.Average(t => t?.GroundingScore ?? 0.0);
|
||||
|
||||
return avgScore;
|
||||
}
|
||||
|
||||
private static Attestation.Models.TurnRole MapRole(TurnRole role) => role switch
|
||||
{
|
||||
TurnRole.User => Attestation.Models.TurnRole.User,
|
||||
TurnRole.Assistant => Attestation.Models.TurnRole.Assistant,
|
||||
TurnRole.System => Attestation.Models.TurnRole.System,
|
||||
_ => Attestation.Models.TurnRole.System
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for attestation integration.
|
||||
/// </summary>
|
||||
public interface IAttestationIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an attestation for a conversation turn.
|
||||
/// </summary>
|
||||
/// <param name="runId">The run identifier.</param>
|
||||
/// <param name="tenantId">The tenant identifier.</param>
|
||||
/// <param name="turn">The conversation turn.</param>
|
||||
/// <param name="groundingResult">The grounding validation result.</param>
|
||||
/// <param name="sign">Whether to sign the attestation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The attestation result, or null if attestation is skipped.</returns>
|
||||
Task<AiAttestationResult?> AttestTurnAsync(
|
||||
string runId,
|
||||
string tenantId,
|
||||
ConversationTurn turn,
|
||||
GroundingResult? groundingResult,
|
||||
bool sign,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation for a completed run.
|
||||
/// </summary>
|
||||
/// <param name="conversation">The conversation.</param>
|
||||
/// <param name="runId">The run identifier.</param>
|
||||
/// <param name="promptTemplateName">The prompt template name used.</param>
|
||||
/// <param name="modelInfo">The AI model information.</param>
|
||||
/// <param name="sign">Whether to sign the attestation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The attestation result, or null if attestation fails.</returns>
|
||||
Task<AiAttestationResult?> AttestRunAsync(
|
||||
Conversation conversation,
|
||||
string runId,
|
||||
string promptTemplateName,
|
||||
AiModelInfo modelInfo,
|
||||
bool sign,
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a run attestation.
|
||||
/// </summary>
|
||||
/// <param name="runId">The run identifier.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
Task<AiAttestationVerificationResult> VerifyRunAsync(
|
||||
string runId,
|
||||
CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of grounding validation for a turn.
|
||||
/// </summary>
|
||||
public sealed record GroundingResult
|
||||
{
|
||||
/// <summary>Overall grounding score (0.0-1.0).</summary>
|
||||
public required double OverallScore { get; init; }
|
||||
|
||||
/// <summary>Individual grounded claims.</summary>
|
||||
public ImmutableArray<GroundedClaim> GroundedClaims { get; init; } = ImmutableArray<GroundedClaim>.Empty;
|
||||
|
||||
/// <summary>Ungrounded claims (claims without evidence).</summary>
|
||||
public ImmutableArray<string> UngroundedClaims { get; init; } = ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A claim that has been grounded to evidence.
|
||||
/// </summary>
|
||||
public sealed record GroundedClaim
|
||||
{
|
||||
/// <summary>The claim text.</summary>
|
||||
public required string ClaimText { get; init; }
|
||||
|
||||
/// <summary>Position in the content.</summary>
|
||||
public required int Position { get; init; }
|
||||
|
||||
/// <summary>Length of the claim.</summary>
|
||||
public required int Length { get; init; }
|
||||
|
||||
/// <summary>Confidence score (0.0-1.0).</summary>
|
||||
public required double Confidence { get; init; }
|
||||
|
||||
/// <summary>Evidence links supporting this claim.</summary>
|
||||
public ImmutableArray<Uri> EvidenceLinks { get; init; } = ImmutableArray<Uri>.Empty;
|
||||
}
|
||||
@@ -345,16 +345,31 @@ public sealed record ConversationContext
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the conversation topic.
|
||||
/// </summary>
|
||||
public string? Topic { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current CVE being discussed.
|
||||
/// </summary>
|
||||
public string? CurrentCveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the focused CVE ID (alias for CurrentCveId).
|
||||
/// </summary>
|
||||
public string? FocusedCveId => CurrentCveId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current component PURL.
|
||||
/// </summary>
|
||||
public string? CurrentComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the focused component (alias for CurrentComponent).
|
||||
/// </summary>
|
||||
public string? FocusedComponent => CurrentComponent;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current image digest.
|
||||
/// </summary>
|
||||
@@ -370,6 +385,49 @@ public sealed record ConversationContext
|
||||
/// </summary>
|
||||
public string? SbomId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the finding ID in context.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public string? FindingId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run ID in context.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public string? RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user ID.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public string? UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the vulnerability severity.
|
||||
/// </summary>
|
||||
public string? Severity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the vulnerability is reachable.
|
||||
/// </summary>
|
||||
public bool? IsReachable { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the CVSS score.
|
||||
/// </summary>
|
||||
public double? CvssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the EPSS score.
|
||||
/// </summary>
|
||||
public double? EpssScore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets context tags.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets accumulated evidence links.
|
||||
/// </summary>
|
||||
@@ -413,11 +471,21 @@ public sealed record ConversationTurn
|
||||
/// </summary>
|
||||
public required string TurnId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the turn number in the conversation (1-based).
|
||||
/// </summary>
|
||||
public int TurnNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role (user/assistant/system).
|
||||
/// </summary>
|
||||
public required TurnRole Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor identifier (user ID or system ID).
|
||||
/// </summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message content.
|
||||
/// </summary>
|
||||
@@ -536,11 +604,27 @@ public sealed record ProposedAction
|
||||
/// </summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action subject (CVE, component, etc.).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action rationale.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action payload (JSON).
|
||||
/// </summary>
|
||||
public string? Payload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this action requires confirmation.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// <copyright file="EvidencePackChatIntegration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Evidence.Pack;
|
||||
using StellaOps.Evidence.Pack.Models;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates Evidence Pack creation with chat grounding validation.
|
||||
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-006
|
||||
/// </summary>
|
||||
public sealed class EvidencePackChatIntegration
|
||||
{
|
||||
private readonly IEvidencePackService _evidencePackService;
|
||||
private readonly ILogger<EvidencePackChatIntegration> _logger;
|
||||
private readonly EvidencePackChatOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EvidencePackChatIntegration"/> class.
|
||||
/// </summary>
|
||||
public EvidencePackChatIntegration(
|
||||
IEvidencePackService evidencePackService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EvidencePackChatIntegration> logger,
|
||||
IOptions<EvidencePackChatOptions>? options = null)
|
||||
{
|
||||
_evidencePackService = evidencePackService ?? throw new ArgumentNullException(nameof(evidencePackService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_options = options?.Value ?? new EvidencePackChatOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an Evidence Pack from a grounding validation result if conditions are met.
|
||||
/// </summary>
|
||||
/// <param name="grounding">The grounding validation result.</param>
|
||||
/// <param name="context">The conversation context.</param>
|
||||
/// <param name="conversationId">The conversation ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created Evidence Pack, or null if conditions not met.</returns>
|
||||
public async Task<EvidencePack?> TryCreateFromGroundingAsync(
|
||||
GroundingValidationResult grounding,
|
||||
ConversationContext context,
|
||||
string conversationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_options.AutoCreateEnabled)
|
||||
{
|
||||
_logger.LogDebug("Auto-create Evidence Packs disabled");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!grounding.IsAcceptable)
|
||||
{
|
||||
_logger.LogDebug("Grounding not acceptable (score {Score:F2}), skipping Evidence Pack creation",
|
||||
grounding.GroundingScore);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (grounding.GroundingScore < _options.MinGroundingScore)
|
||||
{
|
||||
_logger.LogDebug("Grounding score {Score:F2} below threshold {Threshold:F2}, skipping Evidence Pack creation",
|
||||
grounding.GroundingScore, _options.MinGroundingScore);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (grounding.ValidatedLinks.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No validated links in grounding result, skipping Evidence Pack creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build claims from grounded claims
|
||||
var claims = BuildClaimsFromGrounding(grounding);
|
||||
if (claims.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No claims extracted from grounding, skipping Evidence Pack creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build evidence items from validated links
|
||||
var evidence = BuildEvidenceFromLinks(grounding.ValidatedLinks);
|
||||
if (evidence.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No evidence items built from links, skipping Evidence Pack creation");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine subject
|
||||
var subject = BuildSubject(context);
|
||||
|
||||
// Create context
|
||||
var packContext = new EvidencePackContext
|
||||
{
|
||||
TenantId = context.TenantId,
|
||||
RunId = context.RunId,
|
||||
ConversationId = conversationId,
|
||||
UserId = context.UserId,
|
||||
GeneratedBy = "AdvisoryAI"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var pack = await _evidencePackService.CreateAsync(
|
||||
claims.ToArray(),
|
||||
evidence.ToArray(),
|
||||
subject,
|
||||
packContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created Evidence Pack {PackId} from chat grounding (score {Score:F2}, {ClaimCount} claims, {EvidenceCount} evidence items)",
|
||||
pack.PackId, grounding.GroundingScore, claims.Length, evidence.Length);
|
||||
|
||||
return pack;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create Evidence Pack from grounding result");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ImmutableArray<EvidenceClaim> BuildClaimsFromGrounding(GroundingValidationResult grounding)
|
||||
{
|
||||
var claims = new List<EvidenceClaim>();
|
||||
var claimIndex = 0;
|
||||
|
||||
// Build claims from grounded claims (claims near valid links)
|
||||
var validLinkPositions = grounding.ValidatedLinks
|
||||
.Where(l => l.IsValid)
|
||||
.Select(l => l.Position)
|
||||
.ToHashSet();
|
||||
|
||||
// We need to extract claims that were considered grounded
|
||||
// These are claims that had a nearby link
|
||||
// For now, we infer from the structure - grounded claims = TotalClaims - UngroundedClaims
|
||||
// We'll create claims based on the validated links and their context
|
||||
|
||||
foreach (var link in grounding.ValidatedLinks.Where(l => l.IsValid))
|
||||
{
|
||||
var claimId = $"claim-{claimIndex++:D3}";
|
||||
var evidenceId = $"ev-{link.Type}-{claimIndex:D3}";
|
||||
|
||||
// Determine claim type based on link type
|
||||
var claimType = link.Type switch
|
||||
{
|
||||
"vex" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
|
||||
"reach" or "runtime" => Evidence.Pack.Models.ClaimType.Reachability,
|
||||
"sbom" => Evidence.Pack.Models.ClaimType.VulnerabilityStatus,
|
||||
_ => Evidence.Pack.Models.ClaimType.Custom
|
||||
};
|
||||
|
||||
// Build claim text based on link context
|
||||
var claimText = link.Type switch
|
||||
{
|
||||
"vex" => $"VEX statement from {link.Path}",
|
||||
"reach" => $"Reachability analysis for {link.Path}",
|
||||
"runtime" => $"Runtime observation from {link.Path}",
|
||||
"sbom" => $"Component present in SBOM {link.Path}",
|
||||
"ops-mem" => $"OpsMemory context from {link.Path}",
|
||||
_ => $"Evidence from {link.Type}:{link.Path}"
|
||||
};
|
||||
|
||||
claims.Add(new EvidenceClaim
|
||||
{
|
||||
ClaimId = claimId,
|
||||
Text = claimText,
|
||||
Type = claimType,
|
||||
Status = "grounded",
|
||||
Confidence = grounding.GroundingScore,
|
||||
EvidenceIds = [evidenceId],
|
||||
Source = "ai"
|
||||
});
|
||||
}
|
||||
|
||||
return claims.ToImmutableArray();
|
||||
}
|
||||
|
||||
private ImmutableArray<EvidenceItem> BuildEvidenceFromLinks(ImmutableArray<ValidatedLink> validatedLinks)
|
||||
{
|
||||
var evidence = new List<EvidenceItem>();
|
||||
var evidenceIndex = 0;
|
||||
|
||||
foreach (var link in validatedLinks.Where(l => l.IsValid))
|
||||
{
|
||||
var evidenceId = $"ev-{link.Type}-{evidenceIndex++:D3}";
|
||||
|
||||
var evidenceType = link.Type switch
|
||||
{
|
||||
"sbom" => EvidenceType.Sbom,
|
||||
"vex" => EvidenceType.Vex,
|
||||
"reach" => EvidenceType.Reachability,
|
||||
"runtime" => EvidenceType.Runtime,
|
||||
"attest" => EvidenceType.Attestation,
|
||||
"ops-mem" => EvidenceType.OpsMemory,
|
||||
_ => EvidenceType.Custom
|
||||
};
|
||||
|
||||
var snapshot = CreateSnapshotForType(link.Type, link.Path, link.ResolvedUri);
|
||||
|
||||
evidence.Add(new EvidenceItem
|
||||
{
|
||||
EvidenceId = evidenceId,
|
||||
Type = evidenceType,
|
||||
Uri = link.ResolvedUri ?? $"stella://{link.Type}/{link.Path}",
|
||||
Digest = ComputeLinkDigest(link),
|
||||
CollectedAt = _timeProvider.GetUtcNow(),
|
||||
Snapshot = snapshot
|
||||
});
|
||||
}
|
||||
|
||||
return evidence.ToImmutableArray();
|
||||
}
|
||||
|
||||
private static EvidenceSnapshot CreateSnapshotForType(string type, string path, string? resolvedUri)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
"sbom" => EvidenceSnapshot.Custom("sbom", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
"vex" => EvidenceSnapshot.Custom("vex", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
"reach" => EvidenceSnapshot.Custom("reachability", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
"runtime" => EvidenceSnapshot.Custom("runtime", new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary()),
|
||||
|
||||
_ => EvidenceSnapshot.Custom(type, new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = path,
|
||||
["resolvedUri"] = resolvedUri,
|
||||
["source"] = "chat-grounding"
|
||||
}.ToImmutableDictionary())
|
||||
};
|
||||
}
|
||||
|
||||
private static string ComputeLinkDigest(ValidatedLink link)
|
||||
{
|
||||
var input = $"{link.Type}:{link.Path}:{link.ResolvedUri}";
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static EvidenceSubject BuildSubject(ConversationContext context)
|
||||
{
|
||||
// Determine subject type based on available context
|
||||
if (!string.IsNullOrEmpty(context.FindingId))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Finding,
|
||||
FindingId = context.FindingId,
|
||||
CveId = context.CurrentCveId,
|
||||
Component = context.CurrentComponent,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CurrentCveId))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Cve,
|
||||
CveId = context.CurrentCveId,
|
||||
Component = context.CurrentComponent,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CurrentComponent))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Component,
|
||||
Component = context.CurrentComponent,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(context.CurrentImageDigest))
|
||||
{
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Image,
|
||||
ImageDigest = context.CurrentImageDigest
|
||||
};
|
||||
}
|
||||
|
||||
// Default to custom subject
|
||||
return new EvidenceSubject
|
||||
{
|
||||
Type = EvidenceSubjectType.Custom
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for Evidence Pack chat integration.
|
||||
/// </summary>
|
||||
public sealed class EvidencePackChatOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether to auto-create Evidence Packs.
|
||||
/// Default: true.
|
||||
/// </summary>
|
||||
public bool AutoCreateEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum grounding score for auto-creation.
|
||||
/// Default: 0.7.
|
||||
/// </summary>
|
||||
public double MinGroundingScore { get; set; } = 0.7;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to auto-sign created packs.
|
||||
/// Default: false.
|
||||
/// </summary>
|
||||
public bool AutoSign { get; set; }
|
||||
}
|
||||
@@ -341,7 +341,7 @@ public sealed partial class GroundingValidator
|
||||
return claim[..(maxLength - 3)] + "...";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs):(?<path>[^\]]+)\]", RegexOptions.Compiled)]
|
||||
[GeneratedRegex(@"\[(?<type>sbom|reach|runtime|vex|attest|auth|docs|ops-mem):(?<path>[^\]]+)\]", RegexOptions.Compiled)]
|
||||
private static partial Regex ObjectLinkRegex();
|
||||
|
||||
[GeneratedRegex(@"(?:is|are|was|were|has been|have been)\s+(?:not\s+)?(?:affected|vulnerable|exploitable|fixed|patched|mitigated|under investigation)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||
|
||||
294
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/OpsMemoryIntegration.cs
Normal file
294
src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/OpsMemoryIntegration.cs
Normal file
@@ -0,0 +1,294 @@
|
||||
// <copyright file="OpsMemoryIntegration.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Integration;
|
||||
using StellaOps.OpsMemory.Models;
|
||||
using OpsMemoryConversationContext = StellaOps.OpsMemory.Integration.ConversationContext;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Integrates OpsMemory with AdvisoryAI chat sessions.
|
||||
/// Enables surfacing past decisions and recording new decisions from chat actions.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-004
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryIntegration : IOpsMemoryIntegration
|
||||
{
|
||||
private readonly IOpsMemoryChatProvider _opsMemoryProvider;
|
||||
private readonly OpsMemoryContextEnricher _contextEnricher;
|
||||
private readonly ILogger<OpsMemoryIntegration> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new OpsMemoryIntegration.
|
||||
/// </summary>
|
||||
public OpsMemoryIntegration(
|
||||
IOpsMemoryChatProvider opsMemoryProvider,
|
||||
OpsMemoryContextEnricher contextEnricher,
|
||||
ILogger<OpsMemoryIntegration> logger)
|
||||
{
|
||||
_opsMemoryProvider = opsMemoryProvider ?? throw new ArgumentNullException(nameof(opsMemoryProvider));
|
||||
_contextEnricher = contextEnricher ?? throw new ArgumentNullException(nameof(contextEnricher));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryEnrichmentResult> EnrichConversationContextAsync(
|
||||
ConversationContext context,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Enriching conversation context with OpsMemory for tenant {TenantId}",
|
||||
tenantId);
|
||||
|
||||
// Build chat context request from conversation context
|
||||
var request = BuildChatContextRequest(context, tenantId);
|
||||
|
||||
// Enrich prompt with OpsMemory context
|
||||
var enrichedPrompt = await _contextEnricher.EnrichPromptAsync(
|
||||
request,
|
||||
existingPrompt: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new OpsMemoryEnrichmentResult
|
||||
{
|
||||
SystemPromptAddition = enrichedPrompt.SystemPromptAddition ?? string.Empty,
|
||||
ContextBlock = enrichedPrompt.EnrichedPrompt,
|
||||
ReferencedMemoryIds = enrichedPrompt.DecisionsReferenced,
|
||||
HasEnrichment = enrichedPrompt.HasEnrichment,
|
||||
SimilarDecisionCount = enrichedPrompt.Context.SimilarDecisions.Length,
|
||||
ApplicableTacticCount = enrichedPrompt.Context.ApplicableTactics.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<OpsMemoryRecord?> RecordDecisionFromActionAsync(
|
||||
ProposedAction action,
|
||||
Conversation conversation,
|
||||
ConversationTurn turn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ShouldRecordAction(action))
|
||||
{
|
||||
_logger.LogDebug("Skipping non-decision action: {ActionType}", action.ActionType);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Recording decision from chat action: {ActionType} for {Subject}",
|
||||
action.ActionType, action.Subject ?? "(unknown)");
|
||||
|
||||
// Build action execution result
|
||||
var actionResult = new ActionExecutionResult
|
||||
{
|
||||
Action = MapActionType(action.ActionType),
|
||||
CveId = ExtractCveId(action),
|
||||
Component = ExtractComponent(action),
|
||||
Success = true, // Assuming executed action was successful
|
||||
Rationale = action.Rationale ?? turn.Content,
|
||||
ExecutedAt = turn.Timestamp,
|
||||
ActorId = turn.ActorId ?? "system",
|
||||
Metadata = action.Parameters
|
||||
};
|
||||
|
||||
// Build conversation context for OpsMemory
|
||||
var opsMemoryConversationContext = new OpsMemoryConversationContext
|
||||
{
|
||||
ConversationId = conversation.ConversationId,
|
||||
TenantId = conversation.TenantId,
|
||||
UserId = conversation.UserId ?? "unknown",
|
||||
Topic = conversation.Context.Topic,
|
||||
TurnNumber = turn.TurnNumber,
|
||||
Situation = ExtractSituation(conversation.Context),
|
||||
EvidenceLinks = turn.EvidenceLinks.Select(e => e.Uri).ToImmutableArray()
|
||||
};
|
||||
|
||||
// Record to OpsMemory
|
||||
var record = await _opsMemoryProvider.RecordFromActionAsync(
|
||||
actionResult,
|
||||
opsMemoryConversationContext,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created OpsMemory record {MemoryId} from conversation {ConversationId}",
|
||||
record.MemoryId, conversation.ConversationId);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsForContextAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _opsMemoryProvider.GetRecentDecisionsAsync(tenantId, limit, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ChatContextRequest BuildChatContextRequest(
|
||||
ConversationContext context,
|
||||
string tenantId)
|
||||
{
|
||||
return new ChatContextRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CveId = context.FocusedCveId,
|
||||
Component = context.FocusedComponent,
|
||||
Severity = context.Severity,
|
||||
Reachability = context.IsReachable.HasValue
|
||||
? (context.IsReachable.Value ? ReachabilityStatus.Reachable : ReachabilityStatus.NotReachable)
|
||||
: null,
|
||||
CvssScore = context.CvssScore,
|
||||
EpssScore = context.EpssScore,
|
||||
ContextTags = context.Tags,
|
||||
MaxSuggestions = 3,
|
||||
MinSimilarity = 0.6
|
||||
};
|
||||
}
|
||||
|
||||
private static bool ShouldRecordAction(ProposedAction action)
|
||||
{
|
||||
// Only record security decision actions
|
||||
var recordableActions = new[]
|
||||
{
|
||||
"accept_risk", "suppress", "quarantine", "remediate", "defer",
|
||||
"approve", "reject", "escalate", "mitigate", "monitor"
|
||||
};
|
||||
|
||||
return recordableActions.Contains(action.ActionType, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static DecisionAction MapActionType(string actionType)
|
||||
{
|
||||
// Map action types to OpsMemory.Models.DecisionAction enum values
|
||||
return actionType.ToUpperInvariant() switch
|
||||
{
|
||||
"ACCEPT_RISK" or "ACCEPT" or "SUPPRESS" or "APPROVE" => DecisionAction.Accept,
|
||||
"QUARANTINE" => DecisionAction.Quarantine,
|
||||
"REMEDIATE" or "FIX" => DecisionAction.Remediate,
|
||||
"DEFER" or "POSTPONE" or "MONITOR" => DecisionAction.Defer,
|
||||
"REJECT" or "FALSE_POSITIVE" => DecisionAction.FalsePositive,
|
||||
"ESCALATE" => DecisionAction.Escalate,
|
||||
"MITIGATE" => DecisionAction.Mitigate,
|
||||
_ => DecisionAction.Defer // Default to Defer for unknown actions
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractCveId(ProposedAction action)
|
||||
{
|
||||
if (action.Parameters.TryGetValue("cve_id", out var cveId))
|
||||
return cveId;
|
||||
|
||||
if (action.Parameters.TryGetValue("cveId", out cveId))
|
||||
return cveId;
|
||||
|
||||
if (action.Subject?.StartsWith("CVE-", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return action.Subject;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractComponent(ProposedAction action)
|
||||
{
|
||||
if (action.Parameters.TryGetValue("component", out var component))
|
||||
return component;
|
||||
|
||||
if (action.Parameters.TryGetValue("purl", out var purl))
|
||||
return purl;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SituationContext? ExtractSituation(ConversationContext context)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.FocusedCveId) &&
|
||||
string.IsNullOrWhiteSpace(context.FocusedComponent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SituationContext
|
||||
{
|
||||
CveId = context.FocusedCveId,
|
||||
Component = context.FocusedComponent,
|
||||
Severity = context.Severity,
|
||||
Reachability = context.IsReachable.HasValue
|
||||
? (context.IsReachable.Value ? ReachabilityStatus.Reachable : ReachabilityStatus.NotReachable)
|
||||
: ReachabilityStatus.Unknown,
|
||||
CvssScore = context.CvssScore,
|
||||
EpssScore = context.EpssScore,
|
||||
ContextTags = context.Tags
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for OpsMemory integration with AdvisoryAI.
|
||||
/// </summary>
|
||||
public interface IOpsMemoryIntegration
|
||||
{
|
||||
/// <summary>
|
||||
/// Enriches conversation context with OpsMemory data.
|
||||
/// </summary>
|
||||
Task<OpsMemoryEnrichmentResult> EnrichConversationContextAsync(
|
||||
ConversationContext context,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Records a decision from an executed chat action to OpsMemory.
|
||||
/// </summary>
|
||||
Task<OpsMemoryRecord?> RecordDecisionFromActionAsync(
|
||||
ProposedAction action,
|
||||
Conversation conversation,
|
||||
ConversationTurn turn,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent decisions for context.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<PastDecisionSummary>> GetRecentDecisionsForContextAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of OpsMemory enrichment for conversation.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryEnrichmentResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the system prompt addition.
|
||||
/// </summary>
|
||||
public required string SystemPromptAddition { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the context block to include in the conversation.
|
||||
/// </summary>
|
||||
public required string ContextBlock { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the memory IDs referenced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ReferencedMemoryIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether any enrichment was added.
|
||||
/// </summary>
|
||||
public bool HasEnrichment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of similar decisions found.
|
||||
/// </summary>
|
||||
public int SimilarDecisionCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of applicable tactics found.
|
||||
/// </summary>
|
||||
public int ApplicableTacticCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// <copyright file="OpsMemoryLinkResolver.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.OpsMemory.Storage;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves ops-mem:// object links to OpsMemory records.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
public sealed class OpsMemoryLinkResolver : ITypedLinkResolver
|
||||
{
|
||||
private readonly IOpsMemoryStore _store;
|
||||
private readonly ILogger<OpsMemoryLinkResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OpsMemoryLinkResolver"/> class.
|
||||
/// </summary>
|
||||
public OpsMemoryLinkResolver(
|
||||
IOpsMemoryStore store,
|
||||
ILogger<OpsMemoryLinkResolver> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the link type this resolver handles.
|
||||
/// </summary>
|
||||
public string LinkType => "ops-mem";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LinkResolution> ResolveAsync(
|
||||
string path,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
_logger.LogWarning("Cannot resolve ops-mem link without tenant ID");
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var record = await _store.GetByIdAsync(path, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (record is null)
|
||||
{
|
||||
_logger.LogDebug("OpsMemory record not found: {MemoryId}", path);
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
|
||||
return new LinkResolution
|
||||
{
|
||||
Exists = true,
|
||||
Uri = $"ops-mem://{path}",
|
||||
ObjectType = "decision",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["cveId"] = record.Situation?.CveId ?? string.Empty,
|
||||
["action"] = record.Decision?.Action.ToString() ?? string.Empty,
|
||||
["outcome"] = record.Outcome?.Status.ToString() ?? "pending",
|
||||
["decidedAt"] = record.RecordedAt.ToString("O")
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resolving ops-mem link: {Path}", path);
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for type-specific link resolvers.
|
||||
/// </summary>
|
||||
public interface ITypedLinkResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the link type this resolver handles.
|
||||
/// </summary>
|
||||
string LinkType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a link of this type.
|
||||
/// </summary>
|
||||
Task<LinkResolution> ResolveAsync(
|
||||
string path,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composite link resolver that delegates to type-specific resolvers.
|
||||
/// Sprint: SPRINT_20260109_011_002_BE Task: OMCI-005
|
||||
/// </summary>
|
||||
public sealed class CompositeObjectLinkResolver : IObjectLinkResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, ITypedLinkResolver> _resolvers;
|
||||
private readonly ILogger<CompositeObjectLinkResolver> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CompositeObjectLinkResolver"/> class.
|
||||
/// </summary>
|
||||
public CompositeObjectLinkResolver(
|
||||
IEnumerable<ITypedLinkResolver> resolvers,
|
||||
ILogger<CompositeObjectLinkResolver> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(resolvers);
|
||||
|
||||
_resolvers = resolvers.ToDictionary(
|
||||
r => r.LinkType,
|
||||
r => r,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LinkResolution> ResolveAsync(
|
||||
string type,
|
||||
string path,
|
||||
string? tenantId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_resolvers.TryGetValue(type, out var resolver))
|
||||
{
|
||||
_logger.LogDebug("No resolver registered for link type: {Type}", type);
|
||||
return new LinkResolution { Exists = false };
|
||||
}
|
||||
|
||||
return await resolver.ResolveAsync(path, tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// <copyright file="ActionsServiceCollectionExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering action-related services.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE
|
||||
/// </summary>
|
||||
public static class ActionsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds action policy integration services to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configurePolicy">Optional policy configuration.</param>
|
||||
/// <param name="configureIdempotency">Optional idempotency configuration.</param>
|
||||
/// <param name="configureAudit">Optional audit configuration.</param>
|
||||
/// <param name="configureExecutor">Optional executor configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddActionPolicyIntegration(
|
||||
this IServiceCollection services,
|
||||
Action<ActionPolicyOptions>? configurePolicy = null,
|
||||
Action<IdempotencyOptions>? configureIdempotency = null,
|
||||
Action<AuditLedgerOptions>? configureAudit = null,
|
||||
Action<ActionExecutorOptions>? configureExecutor = null)
|
||||
{
|
||||
// Configure options
|
||||
if (configurePolicy is not null)
|
||||
{
|
||||
services.Configure(configurePolicy);
|
||||
}
|
||||
|
||||
if (configureIdempotency is not null)
|
||||
{
|
||||
services.Configure(configureIdempotency);
|
||||
}
|
||||
|
||||
if (configureAudit is not null)
|
||||
{
|
||||
services.Configure(configureAudit);
|
||||
}
|
||||
|
||||
if (configureExecutor is not null)
|
||||
{
|
||||
services.Configure(configureExecutor);
|
||||
}
|
||||
|
||||
// Register core services
|
||||
services.TryAddSingleton<IActionRegistry, ActionRegistry>();
|
||||
services.TryAddSingleton<IGuidGenerator, DefaultGuidGenerator>();
|
||||
|
||||
// Register policy gate
|
||||
services.TryAddScoped<IActionPolicyGate, ActionPolicyGate>();
|
||||
|
||||
// Register idempotency handler
|
||||
services.TryAddSingleton<IIdempotencyHandler, IdempotencyHandler>();
|
||||
|
||||
// Register approval workflow adapter
|
||||
services.TryAddSingleton<IApprovalWorkflowAdapter, ApprovalWorkflowAdapter>();
|
||||
|
||||
// Register audit ledger
|
||||
services.TryAddSingleton<IActionAuditLedger, ActionAuditLedger>();
|
||||
|
||||
// Register action executor
|
||||
services.TryAddScoped<IActionExecutor, ActionExecutor>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds action policy integration with default configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddDefaultActionPolicyIntegration(
|
||||
this IServiceCollection services)
|
||||
{
|
||||
return services.AddActionPolicyIntegration();
|
||||
}
|
||||
}
|
||||
429
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunService.cs
Normal file
429
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunService.cs
Normal file
@@ -0,0 +1,429 @@
|
||||
// <copyright file="IRunService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing AI investigation runs.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-002
|
||||
/// </summary>
|
||||
public interface IRunService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a new run.
|
||||
/// </summary>
|
||||
/// <param name="request">The create request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The created run.</returns>
|
||||
Task<Run> CreateAsync(CreateRunRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a run by ID.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The run, or null if not found.</returns>
|
||||
Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries runs.
|
||||
/// </summary>
|
||||
/// <param name="query">The query parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The matching runs.</returns>
|
||||
Task<RunQueryResult> QueryAsync(RunQuery query, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an event to a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="eventRequest">The event to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The added event.</returns>
|
||||
Task<RunEvent> AddEventAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
AddRunEventRequest eventRequest,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a user turn to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="message">The user message.</param>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="evidenceLinks">Optional evidence links.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The added event.</returns>
|
||||
Task<RunEvent> AddUserTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
string userId,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an assistant turn to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="message">The assistant message.</param>
|
||||
/// <param name="evidenceLinks">Optional evidence links.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The added event.</returns>
|
||||
Task<RunEvent> AddAssistantTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Proposes an action in the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="action">The proposed action.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The action proposed event.</returns>
|
||||
Task<RunEvent> ProposeActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ProposeActionRequest action,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests approval for pending actions.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="approvers">The approver user IDs.</param>
|
||||
/// <param name="reason">The reason for approval request.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> RequestApprovalAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ImmutableArray<string> approvers,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Approves or rejects a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="approved">Whether to approve or reject.</param>
|
||||
/// <param name="approverId">The approver's user ID.</param>
|
||||
/// <param name="reason">The approval/rejection reason.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> ApproveAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
bool approved,
|
||||
string approverId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes an approved action.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="actionEventId">The action event ID to execute.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The action executed event.</returns>
|
||||
Task<RunEvent> ExecuteActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string actionEventId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an artifact to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="artifact">The artifact to add.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> AddArtifactAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunArtifact artifact,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attaches an evidence pack to the run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="evidencePack">The evidence pack reference.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> AttachEvidencePackAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
EvidencePackReference evidencePack,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Completes a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="summary">Optional completion summary.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The completed run.</returns>
|
||||
Task<Run> CompleteAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? summary = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="reason">The cancellation reason.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The cancelled run.</returns>
|
||||
Task<Run> CancelAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Hands off a run to another user.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="toUserId">The user to hand off to.</param>
|
||||
/// <param name="message">Optional handoff message.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The updated run.</returns>
|
||||
Task<Run> HandOffAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string toUserId,
|
||||
string? message = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an attestation for a completed run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The attested run.</returns>
|
||||
Task<Run> AttestAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timeline for a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="skip">Number of events to skip.</param>
|
||||
/// <param name="take">Number of events to take.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The timeline events.</returns>
|
||||
Task<ImmutableArray<RunEvent>> GetTimelineAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create a new run.
|
||||
/// </summary>
|
||||
public sealed record CreateRunRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the initiating user ID.
|
||||
/// </summary>
|
||||
public required string InitiatedBy { get; init; }
|
||||
|
||||
/// <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 initial context.
|
||||
/// </summary>
|
||||
public RunContext? Context { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add a run event.
|
||||
/// </summary>
|
||||
public sealed record AddRunEventRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the event type.
|
||||
/// </summary>
|
||||
public required RunEventType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor ID.
|
||||
/// </summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event 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 event metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to propose an action.
|
||||
/// </summary>
|
||||
public sealed record ProposeActionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action 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 action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string>? Parameters { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence links.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceLink>? EvidenceLinks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query parameters for runs.
|
||||
/// </summary>
|
||||
public sealed record RunQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional status filter.
|
||||
/// </summary>
|
||||
public ImmutableArray<RunStatus>? Statuses { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional initiator filter.
|
||||
/// </summary>
|
||||
public string? InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional CVE filter.
|
||||
/// </summary>
|
||||
public string? CveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional component filter.
|
||||
/// </summary>
|
||||
public string? Component { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional created after filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedAfter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional created before filter.
|
||||
/// </summary>
|
||||
public DateTimeOffset? CreatedBefore { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number to skip.
|
||||
/// </summary>
|
||||
public int Skip { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number to take.
|
||||
/// </summary>
|
||||
public int Take { get; init; } = 20;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a run query.
|
||||
/// </summary>
|
||||
public sealed record RunQueryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matching runs.
|
||||
/// </summary>
|
||||
public required ImmutableArray<Run> 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; }
|
||||
}
|
||||
104
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunStore.cs
Normal file
104
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/IRunStore.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
// <copyright file="IRunStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Store for persisting runs.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-004
|
||||
/// </summary>
|
||||
public interface IRunStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Saves a run.
|
||||
/// </summary>
|
||||
/// <param name="run">The run to save.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task SaveAsync(Run run, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a run by ID.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The run, or null if not found.</returns>
|
||||
Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries runs.
|
||||
/// </summary>
|
||||
/// <param name="query">The query parameters.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The matching runs and total count.</returns>
|
||||
Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
|
||||
RunQuery query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a run.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if deleted, false if not found.</returns>
|
||||
Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets runs by status.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="statuses">The statuses to filter by.</param>
|
||||
/// <param name="skip">Number to skip.</param>
|
||||
/// <param name="take">Number to take.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The matching runs.</returns>
|
||||
Task<ImmutableArray<Run>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
ImmutableArray<RunStatus> statuses,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets active runs for a user.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The user's active runs.</returns>
|
||||
Task<ImmutableArray<Run>> GetActiveForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets runs pending approval.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="approverId">Optional approver filter.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Runs pending approval.</returns>
|
||||
Task<ImmutableArray<Run>> GetPendingApprovalAsync(
|
||||
string tenantId,
|
||||
string? approverId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates run status.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="runId">The run ID.</param>
|
||||
/// <param name="newStatus">The new status.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>True if updated, false if not found.</returns>
|
||||
Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunStatus newStatus,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
161
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/InMemoryRunStore.cs
Normal file
161
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/InMemoryRunStore.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
// <copyright file="InMemoryRunStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IRunStore"/> for development/testing.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-005
|
||||
/// </summary>
|
||||
public sealed class InMemoryRunStore : IRunStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<(string TenantId, string RunId), Run> _runs = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveAsync(Run run, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_runs[(run.TenantId, run.RunId)] = run;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
_runs.TryGetValue((tenantId, runId), out var run);
|
||||
return Task.FromResult(run);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
|
||||
RunQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == query.TenantId)
|
||||
.Where(r => query.Statuses is null || query.Statuses.Value.Contains(r.Status))
|
||||
.Where(r => query.InitiatedBy is null || r.InitiatedBy == query.InitiatedBy)
|
||||
.Where(r => query.CveId is null || r.Context.FocusedCveId == query.CveId)
|
||||
.Where(r => query.Component is null || r.Context.FocusedComponent == query.Component)
|
||||
.Where(r => query.CreatedAfter is null || r.CreatedAt >= query.CreatedAfter)
|
||||
.Where(r => query.CreatedBefore is null || r.CreatedAt <= query.CreatedBefore)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToList();
|
||||
|
||||
var totalCount = runs.Count;
|
||||
var pagedRuns = runs
|
||||
.Skip(query.Skip)
|
||||
.Take(query.Take)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult((pagedRuns, totalCount));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
return Task.FromResult(_runs.TryRemove((tenantId, runId), out _));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<Run>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
ImmutableArray<RunStatus> statuses,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId && statuses.Contains(r.Status))
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(runs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<Run>> GetActiveForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var activeStatuses = new[] { RunStatus.Created, RunStatus.Active, RunStatus.PendingApproval };
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId)
|
||||
.Where(r => r.InitiatedBy == userId || r.Metadata.GetValueOrDefault("current_owner") == userId)
|
||||
.Where(r => activeStatuses.Contains(r.Status))
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(runs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ImmutableArray<Run>> GetPendingApprovalAsync(
|
||||
string tenantId,
|
||||
string? approverId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var runs = _runs.Values
|
||||
.Where(r => r.TenantId == tenantId && r.Status == RunStatus.PendingApproval)
|
||||
.Where(r => approverId is null || (r.Approval?.Approvers.Contains(approverId) ?? false))
|
||||
.OrderByDescending(r => r.UpdatedAt)
|
||||
.ToImmutableArray();
|
||||
|
||||
return Task.FromResult(runs);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunStatus newStatus,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!_runs.TryGetValue((tenantId, runId), out var run))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var updated = run with { Status = newStatus };
|
||||
_runs[(tenantId, runId)] = updated;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all runs (for testing).
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_runs.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of runs (for testing).
|
||||
/// </summary>
|
||||
public int Count => _runs.Count;
|
||||
}
|
||||
278
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/Run.cs
Normal file
278
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/Run.cs
Normal file
@@ -0,0 +1,278 @@
|
||||
// <copyright file="Run.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// An auditable container for an AI-assisted investigation session.
|
||||
/// Captures the complete lifecycle from initial query through tool calls,
|
||||
/// artifact generation, and approvals.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
|
||||
/// </summary>
|
||||
public sealed record Run
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique run identifier.
|
||||
/// Format: run-{timestamp}-{random}
|
||||
/// </summary>
|
||||
public required string RunId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant ID for multi-tenancy isolation.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user who initiated the run.
|
||||
/// </summary>
|
||||
public required string InitiatedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run title (user-provided or auto-generated).
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run objective/goal.
|
||||
/// </summary>
|
||||
public string? Objective { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current run status.
|
||||
/// </summary>
|
||||
public RunStatus Status { get; init; } = RunStatus.Created;
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the run was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the run was last updated.
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the run was completed (if completed).
|
||||
/// </summary>
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered timeline of events in this run.
|
||||
/// </summary>
|
||||
public ImmutableArray<RunEvent> Events { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifacts produced by this run.
|
||||
/// </summary>
|
||||
public ImmutableArray<RunArtifact> Artifacts { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence packs attached to this run.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidencePackReference> EvidencePacks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the run context (CVE focus, component scope, etc.).
|
||||
/// </summary>
|
||||
public RunContext Context { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approval requirements and status.
|
||||
/// </summary>
|
||||
public ApprovalInfo? Approval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content hash of the run for attestation.
|
||||
/// </summary>
|
||||
public string? ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attestation for this run (if attested).
|
||||
/// </summary>
|
||||
public RunAttestation? Attestation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a run in its lifecycle.
|
||||
/// </summary>
|
||||
public enum RunStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Run has been created but not started.
|
||||
/// </summary>
|
||||
Created = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Run is actively in progress.
|
||||
/// </summary>
|
||||
Active = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Run is waiting for approval.
|
||||
/// </summary>
|
||||
PendingApproval = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Run was approved and actions executed.
|
||||
/// </summary>
|
||||
Approved = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Run was rejected.
|
||||
/// </summary>
|
||||
Rejected = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Run completed successfully.
|
||||
/// </summary>
|
||||
Completed = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Run was cancelled.
|
||||
/// </summary>
|
||||
Cancelled = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Run failed with error.
|
||||
/// </summary>
|
||||
Failed = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Run expired without completion.
|
||||
/// </summary>
|
||||
Expired = 8
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context information for a run.
|
||||
/// </summary>
|
||||
public sealed record RunContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the focused CVE ID (if any).
|
||||
/// </summary>
|
||||
public string? FocusedCveId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the focused component PURL (if any).
|
||||
/// </summary>
|
||||
public string? FocusedComponent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SBOM digest (if any).
|
||||
/// </summary>
|
||||
public string? SbomDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the container image reference (if any).
|
||||
/// </summary>
|
||||
public string? ImageReference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the scope tags.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets OpsMemory context if enriched.
|
||||
/// </summary>
|
||||
public OpsMemoryRunContext? OpsMemory { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpsMemory context attached to a run.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryRunContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the similar past decisions surfaced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SimilarDecisionIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the applicable tactics.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TacticIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether OpsMemory enrichment was applied.
|
||||
/// </summary>
|
||||
public bool IsEnriched { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approval information for a run.
|
||||
/// </summary>
|
||||
public sealed record ApprovalInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether approval is required.
|
||||
/// </summary>
|
||||
public bool Required { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approver user IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> Approvers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether approval was granted.
|
||||
/// </summary>
|
||||
public bool? Approved { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets who approved/rejected.
|
||||
/// </summary>
|
||||
public string? ApprovedBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when approval was decided.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ApprovedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the approval/rejection reason.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attestation for a completed run.
|
||||
/// </summary>
|
||||
public sealed record RunAttestation
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the attestation ID.
|
||||
/// </summary>
|
||||
public required string AttestationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest that was attested.
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the attestation statement URI.
|
||||
/// </summary>
|
||||
public required string StatementUri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the signature.
|
||||
/// </summary>
|
||||
public required string Signature { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the attestation was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
182
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunArtifact.cs
Normal file
182
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunArtifact.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
// <copyright file="RunArtifact.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// An artifact produced by a run.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
|
||||
/// </summary>
|
||||
public sealed record RunArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the artifact ID.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact type.
|
||||
/// </summary>
|
||||
public required ArtifactType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact name.
|
||||
/// </summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the artifact was created.
|
||||
/// </summary>
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest (SHA256).
|
||||
/// </summary>
|
||||
public required string ContentDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content size in bytes.
|
||||
/// </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 the artifact is inline (small enough to embed).
|
||||
/// </summary>
|
||||
public bool IsInline { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the inline content (if IsInline).
|
||||
/// </summary>
|
||||
public string? InlineContent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event ID that produced this artifact.
|
||||
/// </summary>
|
||||
public string? ProducingEventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets artifact metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of artifact produced by a run.
|
||||
/// </summary>
|
||||
public enum ArtifactType
|
||||
{
|
||||
/// <summary>
|
||||
/// Evidence pack bundle.
|
||||
/// </summary>
|
||||
EvidencePack = 0,
|
||||
|
||||
/// <summary>
|
||||
/// VEX statement.
|
||||
/// </summary>
|
||||
VexStatement = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Decision record.
|
||||
/// </summary>
|
||||
DecisionRecord = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Action result.
|
||||
/// </summary>
|
||||
ActionResult = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation result.
|
||||
/// </summary>
|
||||
PolicyResult = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Remediation plan.
|
||||
/// </summary>
|
||||
RemediationPlan = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Report document.
|
||||
/// </summary>
|
||||
Report = 6,
|
||||
|
||||
/// <summary>
|
||||
/// SBOM document.
|
||||
/// </summary>
|
||||
Sbom = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Attestation statement.
|
||||
/// </summary>
|
||||
Attestation = 8,
|
||||
|
||||
/// <summary>
|
||||
/// Query result data.
|
||||
/// </summary>
|
||||
QueryResult = 9,
|
||||
|
||||
/// <summary>
|
||||
/// Code snippet.
|
||||
/// </summary>
|
||||
CodeSnippet = 10,
|
||||
|
||||
/// <summary>
|
||||
/// Configuration file.
|
||||
/// </summary>
|
||||
Configuration = 11,
|
||||
|
||||
/// <summary>
|
||||
/// Other artifact type.
|
||||
/// </summary>
|
||||
Other = 99
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an evidence pack.
|
||||
/// </summary>
|
||||
public sealed record EvidencePackReference
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evidence pack ID.
|
||||
/// </summary>
|
||||
public required string PackId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pack digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the pack was attached.
|
||||
/// </summary>
|
||||
public required DateTimeOffset AttachedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the pack type.
|
||||
/// </summary>
|
||||
public string? PackType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the storage URI.
|
||||
/// </summary>
|
||||
public string? StorageUri { get; init; }
|
||||
}
|
||||
428
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs
Normal file
428
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs
Normal file
@@ -0,0 +1,428 @@
|
||||
// <copyright file="RunEvent.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// An event in a run's timeline.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-001
|
||||
/// </summary>
|
||||
public sealed record RunEvent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the event ID (unique within the run).
|
||||
/// </summary>
|
||||
public required string EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event type.
|
||||
/// </summary>
|
||||
public required RunEventType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets when the event occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor who triggered the event (user or system).
|
||||
/// </summary>
|
||||
public string? ActorId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event content (varies by type).
|
||||
/// </summary>
|
||||
public RunEventContent? Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets evidence links attached to this event.
|
||||
/// </summary>
|
||||
public ImmutableArray<EvidenceLink> EvidenceLinks { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the sequence number in the run timeline.
|
||||
/// </summary>
|
||||
public int SequenceNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the parent event ID (for threaded responses).
|
||||
/// </summary>
|
||||
public string? ParentEventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets event metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Type of run event.
|
||||
/// </summary>
|
||||
public enum RunEventType
|
||||
{
|
||||
/// <summary>
|
||||
/// Run was created.
|
||||
/// </summary>
|
||||
Created = 0,
|
||||
|
||||
/// <summary>
|
||||
/// User message/turn.
|
||||
/// </summary>
|
||||
UserTurn = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Assistant response/turn.
|
||||
/// </summary>
|
||||
AssistantTurn = 2,
|
||||
|
||||
/// <summary>
|
||||
/// System message.
|
||||
/// </summary>
|
||||
SystemMessage = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Tool was called.
|
||||
/// </summary>
|
||||
ToolCall = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Tool returned result.
|
||||
/// </summary>
|
||||
ToolResult = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Action was proposed.
|
||||
/// </summary>
|
||||
ActionProposed = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was requested.
|
||||
/// </summary>
|
||||
ApprovalRequested = 7,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was granted.
|
||||
/// </summary>
|
||||
ApprovalGranted = 8,
|
||||
|
||||
/// <summary>
|
||||
/// Approval was denied.
|
||||
/// </summary>
|
||||
ApprovalDenied = 9,
|
||||
|
||||
/// <summary>
|
||||
/// Action was executed.
|
||||
/// </summary>
|
||||
ActionExecuted = 10,
|
||||
|
||||
/// <summary>
|
||||
/// Artifact was produced.
|
||||
/// </summary>
|
||||
ArtifactProduced = 11,
|
||||
|
||||
/// <summary>
|
||||
/// Evidence was attached.
|
||||
/// </summary>
|
||||
EvidenceAttached = 12,
|
||||
|
||||
/// <summary>
|
||||
/// Run was handed off to another user.
|
||||
/// </summary>
|
||||
HandedOff = 13,
|
||||
|
||||
/// <summary>
|
||||
/// Run status changed.
|
||||
/// </summary>
|
||||
StatusChanged = 14,
|
||||
|
||||
/// <summary>
|
||||
/// OpsMemory context was enriched.
|
||||
/// </summary>
|
||||
OpsMemoryEnriched = 15,
|
||||
|
||||
/// <summary>
|
||||
/// Error occurred.
|
||||
/// </summary>
|
||||
Error = 16,
|
||||
|
||||
/// <summary>
|
||||
/// Run was completed.
|
||||
/// </summary>
|
||||
Completed = 17,
|
||||
|
||||
/// <summary>
|
||||
/// Run was cancelled.
|
||||
/// </summary>
|
||||
Cancelled = 18
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content of a run event (polymorphic).
|
||||
/// </summary>
|
||||
public abstract record RunEventContent;
|
||||
|
||||
/// <summary>
|
||||
/// Content for user/assistant turn events.
|
||||
/// </summary>
|
||||
public sealed record TurnContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the message text.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role (user/assistant/system).
|
||||
/// </summary>
|
||||
public required string Role { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets referenced artifacts.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ArtifactIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for tool call events.
|
||||
/// </summary>
|
||||
public sealed record ToolCallContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tool name.
|
||||
/// </summary>
|
||||
public required string ToolName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tool input parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the call succeeded.
|
||||
/// </summary>
|
||||
public bool? Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the call duration.
|
||||
/// </summary>
|
||||
public TimeSpan? Duration { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for tool result events.
|
||||
/// </summary>
|
||||
public sealed record ToolResultContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the tool name.
|
||||
/// </summary>
|
||||
public required string ToolName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result summary.
|
||||
/// </summary>
|
||||
public string? ResultSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the tool succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message (if failed).
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result artifact ID (if any).
|
||||
/// </summary>
|
||||
public string? ArtifactId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for action proposed events.
|
||||
/// </summary>
|
||||
public sealed record ActionProposedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action subject (CVE, component, etc.).
|
||||
/// </summary>
|
||||
public string? Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the proposed action rationale.
|
||||
/// </summary>
|
||||
public string? Rationale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether approval is required.
|
||||
/// </summary>
|
||||
public bool RequiresApproval { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action parameters.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Parameters { get; init; } =
|
||||
ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for action executed events.
|
||||
/// </summary>
|
||||
public sealed record ActionExecutedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the action type.
|
||||
/// </summary>
|
||||
public required string ActionType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the action succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result summary.
|
||||
/// </summary>
|
||||
public string? ResultSummary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the OpsMemory record ID (if recorded).
|
||||
/// </summary>
|
||||
public string? OpsMemoryRecordId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the produced artifact IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> ArtifactIds { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for artifact produced events.
|
||||
/// </summary>
|
||||
public sealed record ArtifactProducedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the artifact ID.
|
||||
/// </summary>
|
||||
public required string ArtifactId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact type.
|
||||
/// </summary>
|
||||
public required string ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the artifact name.
|
||||
/// </summary>
|
||||
public string? Name { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest.
|
||||
/// </summary>
|
||||
public string? ContentDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for status changed events.
|
||||
/// </summary>
|
||||
public sealed record StatusChangedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the previous status.
|
||||
/// </summary>
|
||||
public required RunStatus FromStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the new status.
|
||||
/// </summary>
|
||||
public required RunStatus ToStatus { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason for the change.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for OpsMemory enrichment events.
|
||||
/// </summary>
|
||||
public sealed record OpsMemoryEnrichedContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the similar decision IDs surfaced.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> SimilarDecisionIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the applicable tactic IDs.
|
||||
/// </summary>
|
||||
public ImmutableArray<string> TacticIds { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of known issues found.
|
||||
/// </summary>
|
||||
public int KnownIssueCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content for error events.
|
||||
/// </summary>
|
||||
public sealed record ErrorContent : RunEventContent
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the error code.
|
||||
/// </summary>
|
||||
public required string ErrorCode { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message.
|
||||
/// </summary>
|
||||
public required string Message { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stack trace (if available).
|
||||
/// </summary>
|
||||
public string? StackTrace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the error is recoverable.
|
||||
/// </summary>
|
||||
public bool IsRecoverable { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to an evidence link.
|
||||
/// </summary>
|
||||
public sealed record EvidenceLink
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the evidence URI.
|
||||
/// </summary>
|
||||
public required string Uri { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence type.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the content digest.
|
||||
/// </summary>
|
||||
public string? Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the evidence label/description.
|
||||
/// </summary>
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
723
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/RunService.cs
Normal file
723
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/RunService.cs
Normal file
@@ -0,0 +1,723 @@
|
||||
// <copyright file="RunService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of the run service.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-003
|
||||
/// </summary>
|
||||
internal sealed class RunService : IRunService
|
||||
{
|
||||
private readonly IRunStore _store;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<RunService> _logger;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RunService"/> class.
|
||||
/// </summary>
|
||||
public RunService(
|
||||
IRunStore store,
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
ILogger<RunService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_timeProvider = timeProvider;
|
||||
_guidGenerator = guidGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> CreateAsync(CreateRunRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.TenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.InitiatedBy);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(request.Title);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var runId = GenerateRunId(now);
|
||||
|
||||
var createdEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.Created,
|
||||
Timestamp = now,
|
||||
ActorId = request.InitiatedBy,
|
||||
SequenceNumber = 0,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = RunStatus.Created,
|
||||
ToStatus = RunStatus.Created,
|
||||
Reason = "Run created"
|
||||
}
|
||||
};
|
||||
|
||||
var run = new Run
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = request.TenantId,
|
||||
InitiatedBy = request.InitiatedBy,
|
||||
Title = request.Title,
|
||||
Objective = request.Objective,
|
||||
Status = RunStatus.Created,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Context = request.Context ?? new RunContext(),
|
||||
Events = [createdEvent],
|
||||
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
await _store.SaveAsync(run, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Created run {RunId} for tenant {TenantId} by user {UserId}",
|
||||
runId, request.TenantId, request.InitiatedBy);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
return await _store.GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunQueryResult> QueryAsync(RunQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
var (runs, totalCount) = await _store.QueryAsync(query, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new RunQueryResult
|
||||
{
|
||||
Runs = runs,
|
||||
TotalCount = totalCount,
|
||||
HasMore = totalCount > query.Skip + runs.Length
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> AddEventAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
AddRunEventRequest eventRequest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = eventRequest.Type,
|
||||
Timestamp = now,
|
||||
ActorId = eventRequest.ActorId,
|
||||
Content = eventRequest.Content,
|
||||
EvidenceLinks = eventRequest.EvidenceLinks ?? [],
|
||||
SequenceNumber = run.Events.Length,
|
||||
ParentEventId = eventRequest.ParentEventId,
|
||||
Metadata = eventRequest.Metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Events = run.Events.Add(newEvent),
|
||||
UpdatedAt = now,
|
||||
Status = run.Status == RunStatus.Created ? RunStatus.Active : run.Status
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> AddUserTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
string userId,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = RunEventType.UserTurn,
|
||||
ActorId = userId,
|
||||
Content = new TurnContent
|
||||
{
|
||||
Message = message,
|
||||
Role = "user"
|
||||
},
|
||||
EvidenceLinks = evidenceLinks
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> AddAssistantTurnAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string message,
|
||||
ImmutableArray<EvidenceLink>? evidenceLinks = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = RunEventType.AssistantTurn,
|
||||
ActorId = "assistant",
|
||||
Content = new TurnContent
|
||||
{
|
||||
Message = message,
|
||||
Role = "assistant"
|
||||
},
|
||||
EvidenceLinks = evidenceLinks
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> ProposeActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ProposeActionRequest action,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
|
||||
return await AddEventAsync(tenantId, runId, new AddRunEventRequest
|
||||
{
|
||||
Type = RunEventType.ActionProposed,
|
||||
ActorId = "assistant",
|
||||
Content = new ActionProposedContent
|
||||
{
|
||||
ActionType = action.ActionType,
|
||||
Subject = action.Subject,
|
||||
Rationale = action.Rationale,
|
||||
RequiresApproval = action.RequiresApproval,
|
||||
Parameters = action.Parameters ?? ImmutableDictionary<string, string>.Empty
|
||||
},
|
||||
EvidenceLinks = action.EvidenceLinks
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> RequestApprovalAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
ImmutableArray<string> approvers,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var approvalEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.ApprovalRequested,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = RunStatus.PendingApproval,
|
||||
Reason = reason ?? "Approval requested for proposed actions"
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = RunStatus.PendingApproval,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(approvalEvent),
|
||||
Approval = new ApprovalInfo
|
||||
{
|
||||
Required = true,
|
||||
Approvers = approvers
|
||||
}
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Approval requested for run {RunId} from approvers: {Approvers}",
|
||||
runId, string.Join(", ", approvers));
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> ApproveAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
bool approved,
|
||||
string approverId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (run.Status != RunStatus.PendingApproval)
|
||||
{
|
||||
throw new InvalidOperationException($"Run {runId} is not pending approval. Current status: {run.Status}");
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newStatus = approved ? RunStatus.Approved : RunStatus.Rejected;
|
||||
var eventType = approved ? RunEventType.ApprovalGranted : RunEventType.ApprovalDenied;
|
||||
|
||||
var approvalEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = eventType,
|
||||
Timestamp = now,
|
||||
ActorId = approverId,
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = newStatus,
|
||||
Reason = reason ?? (approved ? "Approved" : "Rejected")
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = newStatus,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(approvalEvent),
|
||||
Approval = run.Approval! with
|
||||
{
|
||||
Approved = approved,
|
||||
ApprovedBy = approverId,
|
||||
ApprovedAt = now,
|
||||
Reason = reason
|
||||
},
|
||||
CompletedAt = approved ? null : now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Run {RunId} {Action} by {ApproverId}: {Reason}",
|
||||
runId, approved ? "approved" : "rejected", approverId, reason ?? "(no reason)");
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RunEvent> ExecuteActionAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string actionEventId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (run.Status != RunStatus.Approved && run.Status != RunStatus.Active)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot execute actions on run with status: {run.Status}");
|
||||
}
|
||||
|
||||
var actionEvent = run.Events.FirstOrDefault(e => e.EventId == actionEventId);
|
||||
if (actionEvent is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Action event {actionEventId} not found in run {runId}");
|
||||
}
|
||||
|
||||
if (actionEvent.Type != RunEventType.ActionProposed)
|
||||
{
|
||||
throw new InvalidOperationException($"Event {actionEventId} is not an action proposal");
|
||||
}
|
||||
|
||||
// In a real implementation, this would execute the action
|
||||
// For now, we just record that it was executed
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var actionContent = actionEvent.Content as ActionProposedContent;
|
||||
|
||||
var executedEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.ActionExecuted,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
ParentEventId = actionEventId,
|
||||
Content = new ActionExecutedContent
|
||||
{
|
||||
ActionType = actionContent?.ActionType ?? "unknown",
|
||||
Success = true,
|
||||
ResultSummary = $"Action {actionContent?.ActionType} executed successfully"
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Events = run.Events.Add(executedEvent),
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Executed action {ActionType} in run {RunId}",
|
||||
actionContent?.ActionType ?? "unknown", runId);
|
||||
|
||||
return executedEvent;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> AddArtifactAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunArtifact artifact,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var artifactEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.ArtifactProduced,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new ArtifactProducedContent
|
||||
{
|
||||
ArtifactId = artifact.ArtifactId,
|
||||
ArtifactType = artifact.Type.ToString(),
|
||||
Name = artifact.Name,
|
||||
ContentDigest = artifact.ContentDigest
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Artifacts = run.Artifacts.Add(artifact),
|
||||
Events = run.Events.Add(artifactEvent),
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> AttachEvidencePackAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
EvidencePackReference evidencePack,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var evidenceEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.EvidenceAttached,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
EvidencePacks = run.EvidencePacks.Add(evidencePack),
|
||||
Events = run.Events.Add(evidenceEvent),
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> CompleteAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? summary = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var completedEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.Completed,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = RunStatus.Completed,
|
||||
Reason = summary ?? "Run completed"
|
||||
}
|
||||
};
|
||||
|
||||
var contentDigest = ComputeContentDigest(run);
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = RunStatus.Completed,
|
||||
CompletedAt = now,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(completedEvent),
|
||||
ContentDigest = contentDigest
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Completed run {RunId} with digest {Digest}", runId, contentDigest);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> CancelAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cancelledEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.Cancelled,
|
||||
Timestamp = now,
|
||||
ActorId = "system",
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new StatusChangedContent
|
||||
{
|
||||
FromStatus = run.Status,
|
||||
ToStatus = RunStatus.Cancelled,
|
||||
Reason = reason ?? "Run cancelled"
|
||||
}
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Status = RunStatus.Cancelled,
|
||||
CompletedAt = now,
|
||||
UpdatedAt = now,
|
||||
Events = run.Events.Add(cancelledEvent)
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Cancelled run {RunId}: {Reason}", runId, reason ?? "(no reason)");
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> HandOffAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
string toUserId,
|
||||
string? message = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
ValidateCanModify(run);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var handoffEvent = new RunEvent
|
||||
{
|
||||
EventId = GenerateEventId(),
|
||||
Type = RunEventType.HandedOff,
|
||||
Timestamp = now,
|
||||
ActorId = run.InitiatedBy,
|
||||
SequenceNumber = run.Events.Length,
|
||||
Content = new TurnContent
|
||||
{
|
||||
Message = message ?? $"Handed off to {toUserId}",
|
||||
Role = "system"
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["to_user"] = toUserId
|
||||
}.ToImmutableDictionary()
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Events = run.Events.Add(handoffEvent),
|
||||
UpdatedAt = now,
|
||||
Metadata = run.Metadata.SetItem("current_owner", toUserId)
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Run {RunId} handed off to {UserId}", runId, toUserId);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Run> AttestAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetRequiredRunAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (run.Status != RunStatus.Completed)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot attest run that is not completed. Current status: {run.Status}");
|
||||
}
|
||||
|
||||
if (run.Attestation is not null)
|
||||
{
|
||||
throw new InvalidOperationException($"Run {runId} is already attested");
|
||||
}
|
||||
|
||||
var contentDigest = run.ContentDigest ?? ComputeContentDigest(run);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// In a real implementation, this would sign the attestation
|
||||
var attestation = new RunAttestation
|
||||
{
|
||||
AttestationId = $"att-{_guidGenerator.NewGuid():N}",
|
||||
ContentDigest = contentDigest,
|
||||
StatementUri = $"stellaops://runs/{runId}/attestation",
|
||||
Signature = "placeholder-signature", // Would be DSSE signature
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
var updatedRun = run with
|
||||
{
|
||||
Attestation = attestation,
|
||||
ContentDigest = contentDigest,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _store.SaveAsync(updatedRun, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Attested run {RunId} with attestation {AttestationId}", runId, attestation.AttestationId);
|
||||
|
||||
return updatedRun;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ImmutableArray<RunEvent>> GetTimelineAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return run.Events
|
||||
.OrderBy(e => e.SequenceNumber)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
private async Task<Run> GetRequiredRunAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var run = await _store.GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Run {runId} not found");
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
private static void ValidateCanModify(Run run)
|
||||
{
|
||||
if (run.Status is RunStatus.Completed or RunStatus.Cancelled or RunStatus.Failed or RunStatus.Expired)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot modify run with status: {run.Status}");
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateRunId(DateTimeOffset timestamp)
|
||||
{
|
||||
var ts = timestamp.ToString("yyyyMMddHHmmss", System.Globalization.CultureInfo.InvariantCulture);
|
||||
var random = _guidGenerator.NewGuid().ToString("N")[..8];
|
||||
return $"run-{ts}-{random}";
|
||||
}
|
||||
|
||||
private string GenerateEventId()
|
||||
{
|
||||
return $"evt-{_guidGenerator.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static string ComputeContentDigest(Run run)
|
||||
{
|
||||
var content = new
|
||||
{
|
||||
run.RunId,
|
||||
run.TenantId,
|
||||
run.Title,
|
||||
run.CreatedAt,
|
||||
EventCount = run.Events.Length,
|
||||
Events = run.Events.Select(e => new { e.EventId, e.Type, e.Timestamp }).ToArray(),
|
||||
Artifacts = run.Artifacts.Select(a => new { a.ArtifactId, a.ContentDigest }).ToArray()
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(content, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for generating GUIDs (injectable for testing).
|
||||
/// </summary>
|
||||
public interface IGuidGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a new GUID.
|
||||
/// </summary>
|
||||
Guid NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default GUID generator using Guid.NewGuid().
|
||||
/// </summary>
|
||||
public sealed class DefaultGuidGenerator : IGuidGenerator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Guid NewGuid() => Guid.NewGuid();
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.Core\StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="..\..\Concelier\__Libraries\StellaOps.Concelier.RawModels\StellaOps.Concelier.RawModels.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Core\StellaOps.Excititor.Core.csproj" />
|
||||
<ProjectReference Include="..\..\OpsMemory\StellaOps.OpsMemory\StellaOps.OpsMemory.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.AdvisoryAI.Attestation\StellaOps.AdvisoryAI.Attestation.csproj" />
|
||||
<!-- Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-006) -->
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Evidence.Pack\StellaOps.Evidence.Pack.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
// <copyright file="ActionExecutorTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActionExecutor.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionExecutorTests
|
||||
{
|
||||
private readonly Mock<IActionPolicyGate> _policyGateMock;
|
||||
private readonly ActionRegistry _actionRegistry;
|
||||
private readonly Mock<IIdempotencyHandler> _idempotencyMock;
|
||||
private readonly Mock<IApprovalWorkflowAdapter> _approvalMock;
|
||||
private readonly Mock<IActionAuditLedger> _auditLedgerMock;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly FakeGuidGenerator _guidGenerator;
|
||||
private readonly ActionExecutor _sut;
|
||||
|
||||
public ActionExecutorTests()
|
||||
{
|
||||
_policyGateMock = new Mock<IActionPolicyGate>();
|
||||
_actionRegistry = new ActionRegistry();
|
||||
_idempotencyMock = new Mock<IIdempotencyHandler>();
|
||||
_approvalMock = new Mock<IApprovalWorkflowAdapter>();
|
||||
_auditLedgerMock = new Mock<IActionAuditLedger>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
_guidGenerator = new FakeGuidGenerator();
|
||||
|
||||
var options = Options.Create(new ActionExecutorOptions
|
||||
{
|
||||
EnableIdempotency = true,
|
||||
EnableAuditLogging = true
|
||||
});
|
||||
|
||||
_sut = new ActionExecutor(
|
||||
_policyGateMock.Object,
|
||||
_actionRegistry,
|
||||
_idempotencyMock.Object,
|
||||
_approvalMock.Object,
|
||||
_auditLedgerMock.Object,
|
||||
_timeProvider,
|
||||
_guidGenerator,
|
||||
options,
|
||||
NullLogger<ActionExecutor>.Instance);
|
||||
|
||||
// Default idempotency behavior: not executed
|
||||
_idempotencyMock
|
||||
.Setup(x => x.CheckAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(IdempotencyResult.NotExecuted);
|
||||
_idempotencyMock
|
||||
.Setup(x => x.GenerateKey(It.IsAny<ActionProposal>(), It.IsAny<ActionContext>()))
|
||||
.Returns("test-idempotency-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ExecutesImmediately_WhenPolicyAllows()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer");
|
||||
var context = CreateContext();
|
||||
var decision = CreateAllowDecision();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
|
||||
result.ExecutionId.Should().NotBeNullOrEmpty();
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.Executed),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsPendingApproval_WhenApprovalRequired()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve");
|
||||
var context = CreateContext();
|
||||
var decision = CreateApprovalRequiredDecision();
|
||||
|
||||
_approvalMock
|
||||
.Setup(x => x.CreateApprovalRequestAsync(
|
||||
It.IsAny<ActionProposal>(),
|
||||
It.IsAny<ActionPolicyDecision>(),
|
||||
It.IsAny<ActionContext>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ApprovalRequest
|
||||
{
|
||||
RequestId = "approval-123",
|
||||
WorkflowId = "high-risk-approval",
|
||||
TenantId = context.TenantId,
|
||||
RequesterId = context.UserId,
|
||||
RequiredApprovers = ImmutableArray.Create(
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }),
|
||||
Timeout = TimeSpan.FromHours(4),
|
||||
Payload = new ApprovalPayload
|
||||
{
|
||||
ActionType = proposal.ActionType,
|
||||
ActionLabel = proposal.Label,
|
||||
Parameters = proposal.Parameters
|
||||
},
|
||||
CreatedAt = _timeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.PendingApproval);
|
||||
result.OutputData.Should().ContainKey("approvalRequestId");
|
||||
result.OutputData["approvalRequestId"].Should().Be("approval-123");
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.ApprovalRequested),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsFailed_WhenPolicyDenies()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine");
|
||||
var context = CreateContext();
|
||||
var decision = CreateDenyDecision("Missing required role");
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
|
||||
result.Error.Should().NotBeNull();
|
||||
result.Error!.Code.Should().Be("POLICY_DENIED");
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.DeniedByPolicy),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SkipsExecution_WhenIdempotent()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer");
|
||||
var context = CreateContext();
|
||||
var decision = CreateAllowDecision();
|
||||
var previousResult = new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = "previous-exec-123",
|
||||
Outcome = ActionExecutionOutcome.Success,
|
||||
Message = "Previously executed",
|
||||
StartedAt = _timeProvider.GetUtcNow().AddHours(-1),
|
||||
CompletedAt = _timeProvider.GetUtcNow().AddHours(-1),
|
||||
CanRollback = false
|
||||
};
|
||||
|
||||
_idempotencyMock
|
||||
.Setup(x => x.CheckAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new IdempotencyResult
|
||||
{
|
||||
AlreadyExecuted = true,
|
||||
PreviousResult = previousResult,
|
||||
ExecutedAt = _timeProvider.GetUtcNow().AddHours(-1)
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeSameAs(previousResult);
|
||||
|
||||
_auditLedgerMock.Verify(
|
||||
x => x.RecordAsync(
|
||||
It.Is<ActionAuditEntry>(e => e.Outcome == ActionAuditOutcome.IdempotentSkipped),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RecordsIdempotency_OnSuccess()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer");
|
||||
var context = CreateContext();
|
||||
var decision = CreateAllowDecision();
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Success);
|
||||
|
||||
_idempotencyMock.Verify(
|
||||
x => x.RecordExecutionAsync(
|
||||
It.IsAny<string>(),
|
||||
It.Is<ActionExecutionResult>(r => r.Outcome == ActionExecutionOutcome.Success),
|
||||
It.IsAny<ActionContext>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IncludesOverrideInfo_ForDenyWithOverride()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve");
|
||||
var context = CreateContext();
|
||||
var decision = new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.DenyWithOverride,
|
||||
PolicyId = "high-risk-check",
|
||||
Reason = "Additional approval needed"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _sut.ExecuteAsync(proposal, decision, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Outcome.Should().Be(ActionExecutionOutcome.Failed);
|
||||
result.Error!.Code.Should().Be("POLICY_DENIED_OVERRIDE_AVAILABLE");
|
||||
result.OutputData.Should().ContainKey("overrideAvailable");
|
||||
result.OutputData["overrideAvailable"].Should().Be("true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSupportedActionTypes_ReturnsAllActions()
|
||||
{
|
||||
// Act
|
||||
var actionTypes = _sut.GetSupportedActionTypes();
|
||||
|
||||
// Assert
|
||||
actionTypes.Should().NotBeEmpty();
|
||||
actionTypes.Should().Contain(a => a.Type == "approve");
|
||||
actionTypes.Should().Contain(a => a.Type == "quarantine");
|
||||
actionTypes.Should().Contain(a => a.Type == "defer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSupportedActionTypes_IncludesMetadata()
|
||||
{
|
||||
// Act
|
||||
var actionTypes = _sut.GetSupportedActionTypes();
|
||||
var approveAction = actionTypes.Single(a => a.Type == "approve");
|
||||
|
||||
// Assert
|
||||
approveAction.DisplayName.Should().Be("Approve Risk");
|
||||
approveAction.RequiredPermission.Should().NotBeNullOrEmpty();
|
||||
approveAction.SupportsRollback.Should().BeTrue();
|
||||
approveAction.IsDestructive.Should().BeTrue(); // High risk
|
||||
}
|
||||
|
||||
private static ActionProposal CreateProposal(string actionType)
|
||||
{
|
||||
var parameters = actionType switch
|
||||
{
|
||||
"approve" => new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted"
|
||||
},
|
||||
"quarantine" => new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
},
|
||||
"defer" => new Dictionary<string, string>
|
||||
{
|
||||
["finding_id"] = "finding-123",
|
||||
["defer_days"] = "30",
|
||||
["reason"] = "Scheduled for next sprint"
|
||||
},
|
||||
_ => new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = Guid.NewGuid().ToString(),
|
||||
ActionType = actionType,
|
||||
Label = $"Test {actionType}",
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateContext()
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
UserId = "test-user",
|
||||
UserRoles = ImmutableArray.Create("security-analyst"),
|
||||
Environment = "development"
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateAllowDecision()
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "test-policy",
|
||||
Reason = "Allowed"
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateApprovalRequiredDecision()
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.AllowWithApproval,
|
||||
PolicyId = "high-risk-approval",
|
||||
Reason = "High-risk action requires approval",
|
||||
ApprovalWorkflowId = "action-approval-high",
|
||||
RequiredApprovers = ImmutableArray.Create(
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" }),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(4)
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionPolicyDecision CreateDenyDecision(string reason)
|
||||
{
|
||||
return new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Deny,
|
||||
PolicyId = "role-check",
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake GUID generator for deterministic testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeGuidGenerator : IGuidGenerator
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
public Guid NewGuid() => new($"00000000-0000-0000-0000-{_counter++:D12}");
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// <copyright file="ActionPolicyGateTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActionPolicyGate.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionPolicyGateTests
|
||||
{
|
||||
private readonly ActionPolicyGate _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public ActionPolicyGateTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new ActionPolicyOptions
|
||||
{
|
||||
DefaultTimeoutHours = 4,
|
||||
CriticalTimeoutHours = 24
|
||||
});
|
||||
|
||||
_sut = new ActionPolicyGate(
|
||||
new ActionRegistry(),
|
||||
_timeProvider,
|
||||
options,
|
||||
NullLogger<ActionPolicyGate>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsLowRiskAction_WithAnyRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("defer", new Dictionary<string, string>
|
||||
{
|
||||
["finding_id"] = "finding-123",
|
||||
["defer_days"] = "30",
|
||||
["reason"] = "Scheduled for next sprint"
|
||||
});
|
||||
var context = CreateContext("security-analyst");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Allow);
|
||||
result.PolicyId.Should().Be("low-risk-auto-allow");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresApproval_ForMediumRiskWithoutElevatedRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("create_vex", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["status"] = "not_affected",
|
||||
["justification"] = "Component not in use"
|
||||
});
|
||||
var context = CreateContext("security-analyst");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "team-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsMediumRiskAction_ForSecurityLead()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("create_vex", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["status"] = "not_affected",
|
||||
["justification"] = "Component not in use"
|
||||
});
|
||||
var context = CreateContext("security-lead");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresApproval_ForHighRiskWithoutAdminRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted"
|
||||
});
|
||||
var context = CreateContext("security-analyst");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_AllowsHighRiskAction_ForAdmin()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted"
|
||||
});
|
||||
var context = CreateContext("admin");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Allow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresMultiPartyApproval_ForCriticalActionInProduction()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
});
|
||||
var context = CreateContext("security-lead", environment: "production");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().HaveCountGreaterThan(1);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "ciso");
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_RequiresSingleApproval_ForCriticalActionInNonProduction()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
});
|
||||
var context = CreateContext("security-lead", environment: "staging");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.AllowWithApproval);
|
||||
result.RequiredApprovers.Should().HaveCount(1);
|
||||
result.RequiredApprovers.Should().Contain(a => a.Identifier == "security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DeniesAction_ForMissingRole()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability"
|
||||
});
|
||||
var context = CreateContext("viewer"); // Not a security-lead
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
result.Reason.Should().Contain("role");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DeniesAction_ForUnknownActionType()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("unknown-action", new Dictionary<string, string>());
|
||||
var context = CreateContext("admin");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
result.Reason.Should().Contain("Unknown action type");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvaluateAsync_DeniesAction_ForInvalidParameters()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "invalid-cve"
|
||||
// Missing required justification
|
||||
});
|
||||
var context = CreateContext("admin");
|
||||
|
||||
// Act
|
||||
var result = await _sut.EvaluateAsync(proposal, context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Decision.Should().Be(PolicyDecisionKind.Deny);
|
||||
result.Reason.Should().Contain("Invalid parameters");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplainAsync_ReturnsSummary_ForAllowDecision()
|
||||
{
|
||||
// Arrange
|
||||
var decision = new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.Allow,
|
||||
PolicyId = "low-risk-auto-allow",
|
||||
Reason = "Low-risk action allowed automatically"
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = await _sut.ExplainAsync(decision, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
explanation.Summary.Should().Contain("allowed");
|
||||
explanation.Details.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplainAsync_IncludesApprovers_ForApprovalDecision()
|
||||
{
|
||||
// Arrange
|
||||
var decision = new ActionPolicyDecision
|
||||
{
|
||||
Decision = PolicyDecisionKind.AllowWithApproval,
|
||||
PolicyId = "high-risk-approval",
|
||||
Reason = "High-risk action requires approval",
|
||||
RequiredApprovers = ImmutableArray.Create(
|
||||
new RequiredApprover { Type = ApproverType.Role, Identifier = "security-lead" })
|
||||
};
|
||||
|
||||
// Act
|
||||
var explanation = await _sut.ExplainAsync(decision, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
explanation.Summary.Should().Contain("approval");
|
||||
explanation.SuggestedActions.Should().Contain(a => a.Contains("security-lead"));
|
||||
}
|
||||
|
||||
private static ActionProposal CreateProposal(string actionType, Dictionary<string, string> parameters)
|
||||
{
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = Guid.NewGuid().ToString(),
|
||||
ActionType = actionType,
|
||||
Label = $"Test {actionType}",
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateContext(string role, string environment = "development")
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
UserId = "test-user",
|
||||
UserRoles = ImmutableArray.Create(role),
|
||||
Environment = environment
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake TimeProvider for testing.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset utcNow)
|
||||
{
|
||||
_utcNow = utcNow;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration);
|
||||
|
||||
public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// <copyright file="ActionRegistryTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for ActionRegistry.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ActionRegistryTests
|
||||
{
|
||||
private readonly ActionRegistry _sut = new();
|
||||
|
||||
[Fact]
|
||||
public void GetAction_ReturnsDefinition_ForApproveAction()
|
||||
{
|
||||
// Act
|
||||
var action = _sut.GetAction("approve");
|
||||
|
||||
// Assert
|
||||
action.Should().NotBeNull();
|
||||
action!.ActionType.Should().Be("approve");
|
||||
action.DisplayName.Should().Be("Approve Risk");
|
||||
action.RiskLevel.Should().Be(ActionRiskLevel.High);
|
||||
action.IsIdempotent.Should().BeTrue();
|
||||
action.HasCompensation.Should().BeTrue();
|
||||
action.CompensationActionType.Should().Be("revoke_approval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAction_ReturnsDefinition_ForQuarantineAction()
|
||||
{
|
||||
// Act
|
||||
var action = _sut.GetAction("quarantine");
|
||||
|
||||
// Assert
|
||||
action.Should().NotBeNull();
|
||||
action!.ActionType.Should().Be("quarantine");
|
||||
action.RiskLevel.Should().Be(ActionRiskLevel.Critical);
|
||||
action.RequiredRole.Should().Be("security-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAction_ReturnsNull_ForUnknownAction()
|
||||
{
|
||||
// Act
|
||||
var action = _sut.GetAction("unknown-action");
|
||||
|
||||
// Assert
|
||||
action.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAction_IsCaseInsensitive()
|
||||
{
|
||||
// Act
|
||||
var lower = _sut.GetAction("approve");
|
||||
var upper = _sut.GetAction("APPROVE");
|
||||
var mixed = _sut.GetAction("Approve");
|
||||
|
||||
// Assert
|
||||
lower.Should().NotBeNull();
|
||||
upper.Should().NotBeNull();
|
||||
mixed.Should().NotBeNull();
|
||||
lower!.ActionType.Should().Be(upper!.ActionType);
|
||||
lower.ActionType.Should().Be(mixed!.ActionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAllActions_ReturnsAllBuiltInActions()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetAllActions();
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().Contain(a => a.ActionType == "approve");
|
||||
actions.Should().Contain(a => a.ActionType == "quarantine");
|
||||
actions.Should().Contain(a => a.ActionType == "defer");
|
||||
actions.Should().Contain(a => a.ActionType == "create_vex");
|
||||
actions.Should().Contain(a => a.ActionType == "generate_manifest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByRiskLevel_ReturnsCorrectActions_ForLowRisk()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByRiskLevel(ActionRiskLevel.Low);
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().AllSatisfy(a => a.RiskLevel.Should().Be(ActionRiskLevel.Low));
|
||||
actions.Should().Contain(a => a.ActionType == "defer");
|
||||
actions.Should().Contain(a => a.ActionType == "generate_manifest");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByRiskLevel_ReturnsCorrectActions_ForCriticalRisk()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByRiskLevel(ActionRiskLevel.Critical);
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().AllSatisfy(a => a.RiskLevel.Should().Be(ActionRiskLevel.Critical));
|
||||
actions.Should().Contain(a => a.ActionType == "quarantine");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByTag_ReturnsCorrectActions_ForCveTag()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByTag("cve");
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().Contain(a => a.ActionType == "approve");
|
||||
actions.Should().Contain(a => a.ActionType == "revoke_approval");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetActionsByTag_ReturnsCorrectActions_ForVexTag()
|
||||
{
|
||||
// Act
|
||||
var actions = _sut.GetActionsByTag("vex");
|
||||
|
||||
// Assert
|
||||
actions.Should().NotBeEmpty();
|
||||
actions.Should().Contain(a => a.ActionType == "create_vex");
|
||||
actions.Should().Contain(a => a.ActionType == "approve");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsSuccess_WithValidParameters()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487",
|
||||
["justification"] = "Risk accepted for internal service"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("approve", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
result.Errors.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsFailure_ForMissingRequiredParameter()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
// Missing required "justification" parameter
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("approve", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("justification"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsFailure_ForInvalidCveIdPattern()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "invalid-cve",
|
||||
["justification"] = "Test"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("approve", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("cve_id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_ReturnsFailure_ForUnknownActionType()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("unknown-action", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("Unknown action type"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_AcceptsValidImageDigest()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
["reason"] = "Critical vulnerability",
|
||||
["cve_ids"] = "CVE-2023-44487"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("quarantine", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateParameters_RejectsInvalidImageDigest()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "invalid-digest",
|
||||
["reason"] = "Critical vulnerability"
|
||||
}.ToImmutableDictionary();
|
||||
|
||||
// Act
|
||||
var result = _sut.ValidateParameters("quarantine", parameters);
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().Contain(e => e.Contains("image_digest"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
// <copyright file="IdempotencyHandlerTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Actions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Actions;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for IdempotencyHandler.
|
||||
/// Sprint: SPRINT_20260109_011_004_BE Task PACT-008
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class IdempotencyHandlerTests
|
||||
{
|
||||
private readonly IdempotencyHandler _sut;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public IdempotencyHandlerTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
var options = Options.Create(new IdempotencyOptions { TtlDays = 30 });
|
||||
|
||||
_sut = new IdempotencyHandler(
|
||||
_timeProvider,
|
||||
options,
|
||||
NullLogger<IdempotencyHandler>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal, context);
|
||||
var key2 = _sut.GenerateKey(proposal, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().Be(key2);
|
||||
key1.Should().HaveLength(64); // SHA-256 hex length
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByTenant()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context1 = CreateContext("tenant-1");
|
||||
var context2 = CreateContext("tenant-2");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal, context1);
|
||||
var key2 = _sut.GenerateKey(proposal, context2);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByActionType()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var proposal2 = CreateProposal("defer", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByCveId()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var proposal2 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2024-12345"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_DiffersByImageDigest()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:aaaa"
|
||||
});
|
||||
var proposal2 = CreateProposal("quarantine", new Dictionary<string, string>
|
||||
{
|
||||
["image_digest"] = "sha256:bbbb"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateKey_IncludesExplicitIdempotencyKey()
|
||||
{
|
||||
// Arrange
|
||||
var proposal1 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
}, idempotencyKey: "key-1");
|
||||
var proposal2 = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
}, idempotencyKey: "key-2");
|
||||
var context = CreateContext("tenant-1");
|
||||
|
||||
// Act
|
||||
var key1 = _sut.GenerateKey(proposal1, context);
|
||||
var key2 = _sut.GenerateKey(proposal2, context);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsNotExecuted_WhenNoRecord()
|
||||
{
|
||||
// Arrange
|
||||
var key = "non-existent-key";
|
||||
|
||||
// Act
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
result.PreviousResult.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsExecuted_WhenRecorded()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeTrue();
|
||||
result.PreviousResult.Should().NotBeNull();
|
||||
result.PreviousResult!.ExecutionId.Should().Be("exec-123");
|
||||
result.ExecutedBy.Should().Be("test-user");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckAsync_ReturnsNotExecuted_WhenExpired()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
|
||||
|
||||
// Advance past TTL
|
||||
_timeProvider.Advance(TimeSpan.FromDays(31));
|
||||
|
||||
// Act
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAsync_DeletesRecord()
|
||||
{
|
||||
// Arrange
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
await _sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None);
|
||||
|
||||
// Act
|
||||
await _sut.RemoveAsync(key, CancellationToken.None);
|
||||
var result = await _sut.CheckAsync(key, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanupExpired_RemovesExpiredRecords()
|
||||
{
|
||||
// Arrange - synchronous setup with async results
|
||||
var proposal = CreateProposal("approve", new Dictionary<string, string>
|
||||
{
|
||||
["cve_id"] = "CVE-2023-44487"
|
||||
});
|
||||
var context = CreateContext("tenant-1");
|
||||
var key = _sut.GenerateKey(proposal, context);
|
||||
var executionResult = CreateExecutionResult("exec-123");
|
||||
|
||||
_sut.RecordExecutionAsync(key, executionResult, context, CancellationToken.None).Wait();
|
||||
|
||||
// Advance past TTL
|
||||
_timeProvider.Advance(TimeSpan.FromDays(31));
|
||||
|
||||
// Act
|
||||
_sut.CleanupExpired();
|
||||
var result = _sut.CheckAsync(key, CancellationToken.None).Result;
|
||||
|
||||
// Assert
|
||||
result.AlreadyExecuted.Should().BeFalse();
|
||||
}
|
||||
|
||||
private static ActionProposal CreateProposal(
|
||||
string actionType,
|
||||
Dictionary<string, string> parameters,
|
||||
string? idempotencyKey = null)
|
||||
{
|
||||
return new ActionProposal
|
||||
{
|
||||
ProposalId = Guid.NewGuid().ToString(),
|
||||
ActionType = actionType,
|
||||
Label = $"Test {actionType}",
|
||||
Parameters = parameters.ToImmutableDictionary(),
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
IdempotencyKey = idempotencyKey
|
||||
};
|
||||
}
|
||||
|
||||
private static ActionContext CreateContext(string tenantId)
|
||||
{
|
||||
return new ActionContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = "test-user",
|
||||
UserRoles = ImmutableArray.Create("security-analyst"),
|
||||
Environment = "development"
|
||||
};
|
||||
}
|
||||
|
||||
private ActionExecutionResult CreateExecutionResult(string executionId)
|
||||
{
|
||||
return new ActionExecutionResult
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
Outcome = ActionExecutionOutcome.Success,
|
||||
Message = "Success",
|
||||
StartedAt = _timeProvider.GetUtcNow(),
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
CanRollback = false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// <copyright file="InMemoryRunStoreTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="InMemoryRunStore"/>.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InMemoryRunStoreTests
|
||||
{
|
||||
private readonly InMemoryRunStore _store = new();
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_And_GetAsync_RoundTrip()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1");
|
||||
|
||||
// Act
|
||||
await _store.SaveAsync(run);
|
||||
var retrieved = await _store.GetAsync("tenant-1", "run-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(run.RunId, retrieved.RunId);
|
||||
Assert.Equal(run.TenantId, retrieved.TenantId);
|
||||
Assert.Equal(run.Title, retrieved.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_DifferentTenant_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1");
|
||||
await _store.SaveAsync(run);
|
||||
|
||||
// Act
|
||||
var retrieved = await _store.GetAsync("tenant-2", "run-1");
|
||||
|
||||
// Assert
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_ExistingRun_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1");
|
||||
await _store.SaveAsync(run);
|
||||
|
||||
// Act
|
||||
var deleted = await _store.DeleteAsync("tenant-1", "run-1");
|
||||
|
||||
// Assert
|
||||
Assert.True(deleted);
|
||||
Assert.Null(await _store.GetAsync("tenant-1", "run-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_NonExistentRun_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var deleted = await _store.DeleteAsync("tenant-1", "non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.False(deleted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByTenant()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1"));
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1"));
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-2"));
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery { TenantId = "tenant-1" });
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal("tenant-1", r.TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByStatus()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with { Status = RunStatus.Completed });
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1") with { Status = RunStatus.Active });
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Statuses = [RunStatus.Active]
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal(RunStatus.Active, r.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByInitiator()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", initiatedBy: "user-1"));
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", initiatedBy: "user-2"));
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", initiatedBy: "user-1"));
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal("user-1", r.InitiatedBy));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersByCveId()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", cveId: "CVE-2024-1111"));
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", cveId: "CVE-2024-2222"));
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", cveId: "CVE-2024-1111"));
|
||||
|
||||
// Act
|
||||
var (runs, count) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
CveId = "CVE-2024-1111"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, count);
|
||||
Assert.All(runs, r => Assert.Equal("CVE-2024-1111", r.Context.FocusedCveId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_Pagination_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await _store.SaveAsync(CreateTestRun($"run-{i}", "tenant-1"));
|
||||
}
|
||||
|
||||
// Act
|
||||
var (page1, total1) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Skip = 0,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
var (page2, total2) = await _store.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
Skip = 3,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(10, total1);
|
||||
Assert.Equal(10, total2);
|
||||
Assert.Equal(3, page1.Length);
|
||||
Assert.Equal(3, page2.Length);
|
||||
Assert.True(page1.All(r => !page2.Contains(r)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetByStatusAsync_ReturnsCorrectRuns()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with { Status = RunStatus.PendingApproval });
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1") with { Status = RunStatus.Completed });
|
||||
|
||||
// Act
|
||||
var runs = await _store.GetByStatusAsync(
|
||||
"tenant-1", [RunStatus.Active, RunStatus.PendingApproval]);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, runs.Length);
|
||||
Assert.DoesNotContain(runs, r => r.Status == RunStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetActiveForUserAsync_ReturnsUserRuns()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1", initiatedBy: "user-1") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1", initiatedBy: "user-2") with { Status = RunStatus.Active });
|
||||
await _store.SaveAsync(CreateTestRun("run-3", "tenant-1", initiatedBy: "user-1") with { Status = RunStatus.Completed });
|
||||
|
||||
// Act
|
||||
var runs = await _store.GetActiveForUserAsync("tenant-1", "user-1");
|
||||
|
||||
// Assert
|
||||
Assert.Single(runs);
|
||||
Assert.Equal("user-1", runs[0].InitiatedBy);
|
||||
Assert.Equal(RunStatus.Active, runs[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPendingApprovalAsync_ReturnsAllPending()
|
||||
{
|
||||
// Arrange
|
||||
await _store.SaveAsync(CreateTestRun("run-1", "tenant-1") with
|
||||
{
|
||||
Status = RunStatus.PendingApproval,
|
||||
Approval = new ApprovalInfo { Required = true, Approvers = ["approver-1"] }
|
||||
});
|
||||
await _store.SaveAsync(CreateTestRun("run-2", "tenant-1") with
|
||||
{
|
||||
Status = RunStatus.PendingApproval,
|
||||
Approval = new ApprovalInfo { Required = true, Approvers = ["approver-2"] }
|
||||
});
|
||||
|
||||
// Act - no filter
|
||||
var allPending = await _store.GetPendingApprovalAsync("tenant-1");
|
||||
|
||||
// Act - with filter
|
||||
var approver1Pending = await _store.GetPendingApprovalAsync("tenant-1", "approver-1");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, allPending.Length);
|
||||
Assert.Single(approver1Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_UpdatesStatus()
|
||||
{
|
||||
// Arrange
|
||||
var run = CreateTestRun("run-1", "tenant-1") with { Status = RunStatus.Active };
|
||||
await _store.SaveAsync(run);
|
||||
|
||||
// Act
|
||||
var updated = await _store.UpdateStatusAsync("tenant-1", "run-1", RunStatus.Completed);
|
||||
|
||||
// Assert
|
||||
Assert.True(updated);
|
||||
var retrieved = await _store.GetAsync("tenant-1", "run-1");
|
||||
Assert.Equal(RunStatus.Completed, retrieved!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatusAsync_NonExistent_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var updated = await _store.UpdateStatusAsync("tenant-1", "non-existent", RunStatus.Active);
|
||||
|
||||
// Assert
|
||||
Assert.False(updated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_RemovesAllRuns()
|
||||
{
|
||||
// Arrange
|
||||
_store.SaveAsync(CreateTestRun("run-1", "tenant-1")).Wait();
|
||||
_store.SaveAsync(CreateTestRun("run-2", "tenant-1")).Wait();
|
||||
|
||||
// Act
|
||||
_store.Clear();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(0, _store.Count);
|
||||
}
|
||||
|
||||
private static Run CreateTestRun(
|
||||
string runId,
|
||||
string tenantId,
|
||||
string initiatedBy = "test-user",
|
||||
string? cveId = null) => new()
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = tenantId,
|
||||
InitiatedBy = initiatedBy,
|
||||
Title = $"Test Run {runId}",
|
||||
Status = RunStatus.Created,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Context = new RunContext
|
||||
{
|
||||
FocusedCveId = cveId
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
// <copyright file="RunServiceIntegrationTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for RunService covering full lifecycle scenarios.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class RunServiceIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly InMemoryRunStore _store = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly DeterministicGuidGenerator _guidGenerator = new();
|
||||
private readonly RunService _service;
|
||||
|
||||
private readonly string _testTenantId = "test-tenant";
|
||||
|
||||
public RunServiceIntegrationTests()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
_service = new RunService(
|
||||
_store,
|
||||
_timeProvider,
|
||||
_guidGenerator,
|
||||
NullLogger<RunService>.Instance);
|
||||
}
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_store.Clear();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullConversationToAttestationFlow_Succeeds()
|
||||
{
|
||||
// Phase 1: Create Run
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "alice@example.com",
|
||||
Title = "CVE-2024-1234 Investigation",
|
||||
Objective = "Determine if vulnerable code path is reachable",
|
||||
Context = new RunContext
|
||||
{
|
||||
FocusedCveId = "CVE-2024-1234",
|
||||
FocusedComponent = "pkg:npm/express@4.17.1",
|
||||
Tags = ["critical", "production"]
|
||||
}
|
||||
});
|
||||
|
||||
run.Should().NotBeNull();
|
||||
run.Status.Should().Be(RunStatus.Created);
|
||||
run.Events.Should().HaveCount(1);
|
||||
run.Events[0].Type.Should().Be(RunEventType.Created);
|
||||
|
||||
// Phase 2: Conversation (User turn -> Assistant turn -> User turn -> Assistant turn)
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var userTurn1 = await _service.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Can you analyze CVE-2024-1234 and tell me if we're affected?",
|
||||
"alice@example.com",
|
||||
[new EvidenceLink { Uri = "sbom:digest/sha256:abc123", Type = "sbom", Label = "Application SBOM" }]);
|
||||
|
||||
userTurn1.Type.Should().Be(RunEventType.UserTurn);
|
||||
userTurn1.EvidenceLinks.Should().HaveCount(1);
|
||||
|
||||
// Run should be active now
|
||||
var activeRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
activeRun!.Status.Should().Be(RunStatus.Active);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(2));
|
||||
var assistantTurn1 = await _service.AddAssistantTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Based on the SBOM analysis, express@4.17.1 is present. I found that the vulnerable function `parseQuery` is not called in your codebase.",
|
||||
[new EvidenceLink { Uri = "reach:analysis/CVE-2024-1234", Type = "reachability" }]);
|
||||
|
||||
assistantTurn1.Type.Should().Be(RunEventType.AssistantTurn);
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Great, can you propose a VEX statement for this?",
|
||||
"alice@example.com");
|
||||
|
||||
// Phase 3: Action Proposal
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(2));
|
||||
var proposalEvent = await _service.ProposeActionAsync(_testTenantId, run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
Subject = "CVE-2024-1234",
|
||||
Rationale = "Vulnerable function parseQuery is not reachable in application code paths",
|
||||
RequiresApproval = true,
|
||||
Parameters = new Dictionary<string, string>
|
||||
{
|
||||
["vex_status"] = "not_affected",
|
||||
["vex_justification"] = "vulnerable_code_not_in_execute_path"
|
||||
}.ToImmutableDictionary(),
|
||||
EvidenceLinks = [new EvidenceLink
|
||||
{
|
||||
Uri = "reach:analysis/CVE-2024-1234",
|
||||
Type = "reachability",
|
||||
Label = "Reachability Analysis"
|
||||
}]
|
||||
});
|
||||
|
||||
proposalEvent.Type.Should().Be(RunEventType.ActionProposed);
|
||||
|
||||
// Phase 4: Request Approval
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var pendingRun = await _service.RequestApprovalAsync(
|
||||
_testTenantId, run.RunId,
|
||||
["bob@example.com", "charlie@example.com"],
|
||||
"Please review VEX statement for CVE-2024-1234");
|
||||
|
||||
pendingRun.Status.Should().Be(RunStatus.PendingApproval);
|
||||
pendingRun.Approval.Should().NotBeNull();
|
||||
pendingRun.Approval!.Approvers.Should().Contain("bob@example.com");
|
||||
|
||||
// Phase 5: Approval
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(5));
|
||||
var approvedRun = await _service.ApproveAsync(
|
||||
_testTenantId, run.RunId,
|
||||
approved: true,
|
||||
approverId: "bob@example.com",
|
||||
reason: "Reachability analysis is sound, VEX statement approved");
|
||||
|
||||
approvedRun.Status.Should().Be(RunStatus.Approved);
|
||||
approvedRun.Approval!.Approved.Should().BeTrue();
|
||||
approvedRun.Approval.ApprovedBy.Should().Be("bob@example.com");
|
||||
|
||||
// Phase 6: Execute Action
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var executedEvent = await _service.ExecuteActionAsync(
|
||||
_testTenantId, run.RunId, proposalEvent.EventId);
|
||||
|
||||
executedEvent.Type.Should().Be(RunEventType.ActionExecuted);
|
||||
|
||||
// Phase 7: Add VEX Artifact
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var vexArtifact = new RunArtifact
|
||||
{
|
||||
ArtifactId = "vex-001",
|
||||
Type = ArtifactType.VexStatement,
|
||||
Name = "VEX Statement for CVE-2024-1234",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
MediaType = "application/vnd.openvex+json",
|
||||
StorageUri = "stellaops://artifacts/vex/vex-001.json"
|
||||
};
|
||||
|
||||
var withArtifact = await _service.AddArtifactAsync(_testTenantId, run.RunId, vexArtifact);
|
||||
withArtifact.Artifacts.Should().HaveCount(1);
|
||||
withArtifact.Artifacts[0].ArtifactId.Should().Be("vex-001");
|
||||
|
||||
// Phase 8: Complete Run
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var completedRun = await _service.CompleteAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Investigation complete: CVE-2024-1234 not affected, VEX published");
|
||||
|
||||
completedRun.Status.Should().Be(RunStatus.Completed);
|
||||
completedRun.CompletedAt.Should().NotBeNull();
|
||||
completedRun.ContentDigest.Should().NotBeNullOrEmpty();
|
||||
|
||||
// Phase 9: Attest Run
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var attestedRun = await _service.AttestAsync(_testTenantId, run.RunId);
|
||||
|
||||
attestedRun.Attestation.Should().NotBeNull();
|
||||
attestedRun.Attestation!.AttestationId.Should().StartWith("att-");
|
||||
attestedRun.Attestation.ContentDigest.Should().Be(completedRun.ContentDigest);
|
||||
|
||||
// Verify final timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Length.Should().BeGreaterThanOrEqualTo(8);
|
||||
|
||||
// Verify event sequence is properly ordered
|
||||
for (var i = 1; i < timeline.Length; i++)
|
||||
{
|
||||
timeline[i].SequenceNumber.Should().BeGreaterThan(timeline[i - 1].SequenceNumber);
|
||||
timeline[i].Timestamp.Should().BeOnOrAfter(timeline[i - 1].Timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimelinePersistence_EventsAreAppendOnly()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Timeline Test"
|
||||
});
|
||||
|
||||
var initialEventCount = run.Events.Length;
|
||||
|
||||
// Act - Add multiple events
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Message 1", "user-1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Response 1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Message 2", "user-1");
|
||||
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Response 2");
|
||||
|
||||
// Assert
|
||||
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
finalRun!.Events.Length.Should().Be(initialEventCount + 4);
|
||||
|
||||
// Events should have monotonically increasing sequence numbers
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
var sequenceNumbers = timeline.Select(e => e.SequenceNumber).ToList();
|
||||
sequenceNumbers.Should().BeInAscendingOrder();
|
||||
|
||||
// Event timestamps should not go backwards
|
||||
var timestamps = timeline.Select(e => e.Timestamp).ToList();
|
||||
for (var i = 1; i < timestamps.Count; i++)
|
||||
{
|
||||
timestamps[i].Should().BeOnOrAfter(timestamps[i - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArtifactStorageAndRetrieval_MultipleArtifactTypes()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Artifact Test"
|
||||
});
|
||||
|
||||
var artifacts = new[]
|
||||
{
|
||||
new RunArtifact
|
||||
{
|
||||
ArtifactId = "vex-001",
|
||||
Type = ArtifactType.VexStatement,
|
||||
Name = "VEX Statement",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:vex123",
|
||||
MediaType = "application/vnd.openvex+json"
|
||||
},
|
||||
new RunArtifact
|
||||
{
|
||||
ArtifactId = "report-001",
|
||||
Type = ArtifactType.Report,
|
||||
Name = "Investigation Report",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:report456",
|
||||
MediaType = "application/pdf"
|
||||
},
|
||||
new RunArtifact
|
||||
{
|
||||
ArtifactId = "evidence-001",
|
||||
Type = ArtifactType.EvidencePack,
|
||||
Name = "Evidence Bundle",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:evidence789",
|
||||
MediaType = "application/zip"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddArtifactAsync(_testTenantId, run.RunId, artifact);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
finalRun!.Artifacts.Should().HaveCount(3);
|
||||
|
||||
// Verify all artifact types are present
|
||||
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.VexStatement);
|
||||
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.Report);
|
||||
finalRun.Artifacts.Should().Contain(a => a.Type == ArtifactType.EvidencePack);
|
||||
|
||||
// Verify artifact details preserved
|
||||
var vexArtifact = finalRun.Artifacts.Single(a => a.ArtifactId == "vex-001");
|
||||
vexArtifact.ContentDigest.Should().Be("sha256:vex123");
|
||||
vexArtifact.MediaType.Should().Be("application/vnd.openvex+json");
|
||||
|
||||
// Verify artifact events in timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Where(e => e.Type == RunEventType.ArtifactProduced).Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EvidencePackAttachment_TracksAllPacks()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Evidence Pack Test"
|
||||
});
|
||||
|
||||
var evidencePacks = new[]
|
||||
{
|
||||
new EvidencePackReference
|
||||
{
|
||||
PackId = "pack-001",
|
||||
Digest = "sha256:pack001hash",
|
||||
AttachedAt = _timeProvider.GetUtcNow(),
|
||||
PackType = "vulnerability-analysis"
|
||||
},
|
||||
new EvidencePackReference
|
||||
{
|
||||
PackId = "pack-002",
|
||||
Digest = "sha256:pack002hash",
|
||||
AttachedAt = _timeProvider.GetUtcNow(),
|
||||
PackType = "reachability-evidence"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
foreach (var pack in evidencePacks)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AttachEvidencePackAsync(_testTenantId, run.RunId, pack);
|
||||
}
|
||||
|
||||
// Assert
|
||||
var finalRun = await _service.GetAsync(_testTenantId, run.RunId);
|
||||
finalRun!.EvidencePacks.Should().HaveCount(2);
|
||||
finalRun.EvidencePacks.Should().Contain(p => p.PackId == "pack-001");
|
||||
finalRun.EvidencePacks.Should().Contain(p => p.PackId == "pack-002");
|
||||
|
||||
// Verify evidence events in timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Where(e => e.Type == RunEventType.EvidenceAttached).Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunContentDigest_IsDeterministic()
|
||||
{
|
||||
// Create two identical runs
|
||||
_guidGenerator.Reset();
|
||||
var run1 = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Determinism Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run1.RunId, "Test message", "user-1");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run1.RunId, "Test response");
|
||||
var completed1 = await _service.CompleteAsync(_testTenantId, run1.RunId);
|
||||
|
||||
// Reset state and create identical run
|
||||
_store.Clear();
|
||||
_guidGenerator.Reset();
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 10, 0, 0, TimeSpan.Zero));
|
||||
|
||||
var run2 = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Determinism Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run2.RunId, "Test message", "user-1");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run2.RunId, "Test response");
|
||||
var completed2 = await _service.CompleteAsync(_testTenantId, run2.RunId);
|
||||
|
||||
// Assert - digests should be identical for identical content
|
||||
completed1.ContentDigest.Should().Be(completed2.ContentDigest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantIsolation_RunsAreIsolatedByTenant()
|
||||
{
|
||||
// Arrange - Create runs in different tenants
|
||||
var tenant1Run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Tenant 1 Run"
|
||||
});
|
||||
|
||||
var tenant2Run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-2",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Tenant 2 Run"
|
||||
});
|
||||
|
||||
// Act & Assert - Cannot access other tenant's run
|
||||
var crossTenantAccess = await _service.GetAsync("tenant-1", tenant2Run.RunId);
|
||||
crossTenantAccess.Should().BeNull();
|
||||
|
||||
// Query only returns own tenant's runs
|
||||
var tenant1Query = await _service.QueryAsync(new RunQuery { TenantId = "tenant-1" });
|
||||
tenant1Query.Runs.Should().HaveCount(1);
|
||||
tenant1Query.Runs[0].RunId.Should().Be(tenant1Run.RunId);
|
||||
|
||||
var tenant2Query = await _service.QueryAsync(new RunQuery { TenantId = "tenant-2" });
|
||||
tenant2Query.Runs.Should().HaveCount(1);
|
||||
tenant2Query.Runs[0].RunId.Should().Be(tenant2Run.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffWorkflow_TransfersOwnershipCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "alice@example.com",
|
||||
Title = "Handoff Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Starting investigation", "alice@example.com");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Initial analysis complete");
|
||||
|
||||
// Act - Hand off to another user
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(10));
|
||||
var handedOff = await _service.HandOffAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"bob@example.com",
|
||||
"Please continue this investigation - I need to focus on another issue");
|
||||
|
||||
// Assert
|
||||
handedOff.Metadata["current_owner"].Should().Be("bob@example.com");
|
||||
|
||||
// Verify handoff event in timeline
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
var handoffEvent = timeline.FirstOrDefault(e => e.Type == RunEventType.HandedOff);
|
||||
handoffEvent.Should().NotBeNull();
|
||||
handoffEvent!.Metadata["to_user"].Should().Be("bob@example.com");
|
||||
|
||||
// New owner can continue the run
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
var continuedEvent = await _service.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Continuing investigation from Alice",
|
||||
"bob@example.com");
|
||||
|
||||
continuedEvent.Should().NotBeNull();
|
||||
continuedEvent.ActorId.Should().Be("bob@example.com");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectionWorkflow_StopsRunOnRejection()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Rejection Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync(_testTenantId, run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
await _service.RequestApprovalAsync(_testTenantId, run.RunId, ["approver-1"]);
|
||||
|
||||
// Act - Reject
|
||||
var rejected = await _service.ApproveAsync(
|
||||
_testTenantId, run.RunId,
|
||||
approved: false,
|
||||
approverId: "approver-1",
|
||||
reason: "Insufficient evidence for not_affected status");
|
||||
|
||||
// Assert
|
||||
rejected.Status.Should().Be(RunStatus.Rejected);
|
||||
rejected.Approval!.Approved.Should().BeFalse();
|
||||
rejected.CompletedAt.Should().NotBeNull();
|
||||
|
||||
// Cannot add more events to rejected run
|
||||
await _service.Invoking(s => s.AddUserTurnAsync(
|
||||
_testTenantId, run.RunId, "More questions", "user-1"))
|
||||
.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationWorkflow_PreservesHistory()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Cancellation Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question 1", "user-1");
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, "Answer 1");
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Question 2", "user-1");
|
||||
|
||||
var eventCountBeforeCancel = (await _service.GetAsync(_testTenantId, run.RunId))!.Events.Length;
|
||||
|
||||
// Act
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(30));
|
||||
var cancelled = await _service.CancelAsync(
|
||||
_testTenantId, run.RunId,
|
||||
"Investigation no longer needed - issue resolved by upstream fix");
|
||||
|
||||
// Assert
|
||||
cancelled.Status.Should().Be(RunStatus.Cancelled);
|
||||
cancelled.CompletedAt.Should().NotBeNull();
|
||||
|
||||
// History is preserved
|
||||
cancelled.Events.Length.Should().Be(eventCountBeforeCancel + 1);
|
||||
cancelled.Events.Last().Type.Should().Be(RunEventType.Cancelled);
|
||||
|
||||
// Timeline still accessible
|
||||
var timeline = await _service.GetTimelineAsync(_testTenantId, run.RunId);
|
||||
timeline.Length.Should().Be(eventCountBeforeCancel + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_FailsForNonCompletedRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Attest Validation Test"
|
||||
});
|
||||
|
||||
// Act & Assert - Cannot attest non-completed run
|
||||
await _service.Invoking(s => s.AttestAsync(_testTenantId, run.RunId))
|
||||
.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*not completed*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AttestAsync_FailsForAlreadyAttestedRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Double Attest Test"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, "Q", "user-1");
|
||||
await _service.CompleteAsync(_testTenantId, run.RunId);
|
||||
await _service.AttestAsync(_testTenantId, run.RunId);
|
||||
|
||||
// Act & Assert - Cannot attest twice
|
||||
await _service.Invoking(s => s.AttestAsync(_testTenantId, run.RunId))
|
||||
.Should().ThrowAsync<InvalidOperationException>()
|
||||
.WithMessage("*already attested*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_PaginationWorks()
|
||||
{
|
||||
// Arrange - Create 10 runs
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(1));
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = $"Run {i + 1}"
|
||||
});
|
||||
}
|
||||
|
||||
// Act - Page 1
|
||||
var page1 = await _service.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Skip = 0,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
// Act - Page 2
|
||||
var page2 = await _service.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
Skip = 3,
|
||||
Take = 3
|
||||
});
|
||||
|
||||
// Assert
|
||||
page1.TotalCount.Should().Be(10);
|
||||
page1.Runs.Should().HaveCount(3);
|
||||
page1.HasMore.Should().BeTrue();
|
||||
|
||||
page2.Runs.Should().HaveCount(3);
|
||||
page2.HasMore.Should().BeTrue();
|
||||
|
||||
// No overlap between pages
|
||||
page1.Runs.Select(r => r.RunId)
|
||||
.Should().NotIntersectWith(page2.Runs.Select(r => r.RunId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimelineAsync_PaginationWorks()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = _testTenantId,
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Timeline Pagination Test"
|
||||
});
|
||||
|
||||
// Add 20 events
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync(_testTenantId, run.RunId, $"Message {i}", "user-1");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync(_testTenantId, run.RunId, $"Response {i}");
|
||||
}
|
||||
|
||||
// Act
|
||||
var page1 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 0, take: 5);
|
||||
var page2 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 5, take: 5);
|
||||
var page3 = await _service.GetTimelineAsync(_testTenantId, run.RunId, skip: 10, take: 5);
|
||||
|
||||
// Assert
|
||||
page1.Should().HaveCount(5);
|
||||
page2.Should().HaveCount(5);
|
||||
page3.Should().HaveCount(5);
|
||||
|
||||
// Verify sequence continuity
|
||||
page1.Last().SequenceNumber.Should().BeLessThan(page2.First().SequenceNumber);
|
||||
page2.Last().SequenceNumber.Should().BeLessThan(page3.First().SequenceNumber);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic GUID generator for testing.
|
||||
/// </summary>
|
||||
private sealed class DeterministicGuidGenerator : IGuidGenerator
|
||||
{
|
||||
private int _counter;
|
||||
|
||||
public Guid NewGuid()
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
BitConverter.GetBytes(_counter++).CopyTo(bytes, 0);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
public void Reset() => _counter = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
// <copyright file="RunServiceTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="RunService"/>.
|
||||
/// Sprint: SPRINT_20260109_011_003_BE Task: RUN-009
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class RunServiceTests
|
||||
{
|
||||
private readonly InMemoryRunStore _store = new();
|
||||
private readonly FakeTimeProvider _timeProvider = new();
|
||||
private readonly NullLogger<RunService> _logger = NullLogger<RunService>.Instance;
|
||||
private readonly RunService _service;
|
||||
|
||||
public RunServiceTests()
|
||||
{
|
||||
_timeProvider.SetUtcNow(new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
_service = new RunService(_store, _timeProvider, new DefaultGuidGenerator(), _logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_GeneratesRunWithCorrectProperties()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run",
|
||||
Objective = "Investigate CVE-2024-1234"
|
||||
};
|
||||
|
||||
// Act
|
||||
var run = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(run);
|
||||
Assert.NotEmpty(run.RunId);
|
||||
Assert.Equal("tenant-1", run.TenantId);
|
||||
Assert.Equal("user-1", run.InitiatedBy);
|
||||
Assert.Equal("Test Run", run.Title);
|
||||
Assert.Equal("Investigate CVE-2024-1234", run.Objective);
|
||||
Assert.Equal(RunStatus.Created, run.Status);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), run.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_WithContext_StoresContextCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "CVE Investigation",
|
||||
Context = new RunContext
|
||||
{
|
||||
FocusedCveId = "CVE-2024-5678",
|
||||
FocusedComponent = "pkg:npm/express@4.17.1",
|
||||
Tags = ["critical", "urgent"]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var run = await _service.CreateAsync(request);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("CVE-2024-5678", run.Context.FocusedCveId);
|
||||
Assert.Equal("pkg:npm/express@4.17.1", run.Context.FocusedComponent);
|
||||
Assert.Contains("critical", run.Context.Tags);
|
||||
Assert.Contains("urgent", run.Context.Tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ExistingRun_ReturnsRun()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
};
|
||||
var created = await _service.CreateAsync(request);
|
||||
|
||||
// Act
|
||||
var retrieved = await _service.GetAsync("tenant-1", created.RunId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal(created.RunId, retrieved.RunId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_NonExistentRun_ReturnsNull()
|
||||
{
|
||||
// Act
|
||||
var retrieved = await _service.GetAsync("tenant-1", "non-existent");
|
||||
|
||||
// Assert
|
||||
Assert.Null(retrieved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddUserTurnAsync_AddsEventAndActivatesRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
// Act
|
||||
var evt = await _service.AddUserTurnAsync(
|
||||
"tenant-1", run.RunId, "What vulnerabilities affect this component?", "user-1");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(RunEventType.UserTurn, evt.Type);
|
||||
Assert.Equal("user-1", evt.ActorId);
|
||||
var turnContent = Assert.IsType<TurnContent>(evt.Content);
|
||||
Assert.Contains("vulnerabilities", turnContent.Message);
|
||||
|
||||
var updated = await _service.GetAsync("tenant-1", run.RunId);
|
||||
Assert.Equal(RunStatus.Active, updated!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAssistantTurnAsync_AddsEventCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question?", "user-1");
|
||||
|
||||
// Act
|
||||
var evt = await _service.AddAssistantTurnAsync(
|
||||
"tenant-1", run.RunId, "Here is my analysis of the vulnerability...");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(RunEventType.AssistantTurn, evt.Type);
|
||||
Assert.Equal("assistant", evt.ActorId);
|
||||
var assistantContent = Assert.IsType<TurnContent>(evt.Content);
|
||||
Assert.Contains("analysis", assistantContent.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProposeActionAsync_AddsActionProposal()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Fix this CVE", "user-1");
|
||||
|
||||
// Act
|
||||
var evt = await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
Subject = "CVE-2024-1234",
|
||||
Rationale = "Component is not affected due to usage context",
|
||||
RequiresApproval = true
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(evt);
|
||||
Assert.Equal(RunEventType.ActionProposed, evt.Type);
|
||||
var actionContent = Assert.IsType<ActionProposedContent>(evt.Content);
|
||||
Assert.Equal("vex:publish", actionContent.ActionType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestApprovalAsync_ChangesStatusToPendingApproval()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
|
||||
// Act
|
||||
var updated = await _service.RequestApprovalAsync(
|
||||
"tenant-1", run.RunId, ["approver-1", "approver-2"], "Please review VEX decision");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.PendingApproval, updated.Status);
|
||||
Assert.NotNull(updated.Approval);
|
||||
Assert.True(updated.Approval.Required);
|
||||
Assert.Contains("approver-1", updated.Approval.Approvers);
|
||||
Assert.Contains("approver-2", updated.Approval.Approvers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_Approved_ChangesStatusToApproved()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
await _service.RequestApprovalAsync("tenant-1", run.RunId, ["approver-1"], "Review");
|
||||
|
||||
// Act
|
||||
var updated = await _service.ApproveAsync(
|
||||
"tenant-1", run.RunId, approved: true, "approver-1", "Looks good");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Approved, updated.Status);
|
||||
Assert.NotNull(updated.Approval);
|
||||
Assert.True(updated.Approval.Approved);
|
||||
Assert.Equal("approver-1", updated.Approval.ApprovedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAsync_Rejected_ChangesStatusToRejected()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.ProposeActionAsync("tenant-1", run.RunId, new ProposeActionRequest
|
||||
{
|
||||
ActionType = "vex:publish",
|
||||
RequiresApproval = true
|
||||
});
|
||||
await _service.RequestApprovalAsync("tenant-1", run.RunId, ["approver-1"], "Review");
|
||||
|
||||
// Act
|
||||
var updated = await _service.ApproveAsync(
|
||||
"tenant-1", run.RunId, approved: false, "approver-1", "Needs more justification");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Rejected, updated.Status);
|
||||
Assert.False(updated.Approval!.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteAsync_SetsCompletedStatusAndTimestamp()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question", "user-1");
|
||||
await _service.AddAssistantTurnAsync("tenant-1", run.RunId, "Answer");
|
||||
|
||||
// Act
|
||||
var completed = await _service.CompleteAsync("tenant-1", run.RunId, "Investigation complete");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Completed, completed.Status);
|
||||
Assert.NotNull(completed.CompletedAt);
|
||||
Assert.Equal(_timeProvider.GetUtcNow(), completed.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelAsync_SetsCancelledStatus()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
// Act
|
||||
var cancelled = await _service.CancelAsync("tenant-1", run.RunId, "No longer needed");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(RunStatus.Cancelled, cancelled.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddArtifactAsync_AddsArtifactToRun()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
var artifact = new RunArtifact
|
||||
{
|
||||
ArtifactId = "artifact-1",
|
||||
Type = ArtifactType.VexStatement,
|
||||
Name = "VEX for CVE-2024-1234",
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ContentDigest = "sha256:abc123",
|
||||
MediaType = "application/vnd.openvex+json"
|
||||
};
|
||||
|
||||
// Act
|
||||
var updated = await _service.AddArtifactAsync("tenant-1", run.RunId, artifact);
|
||||
|
||||
// Assert
|
||||
Assert.Single(updated.Artifacts);
|
||||
Assert.Equal("artifact-1", updated.Artifacts[0].ArtifactId);
|
||||
Assert.Equal(ArtifactType.VexStatement, updated.Artifacts[0].Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_FiltersCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Run 1",
|
||||
Context = new RunContext { FocusedCveId = "CVE-2024-1111" }
|
||||
});
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-2",
|
||||
Title = "Run 2",
|
||||
Context = new RunContext { FocusedCveId = "CVE-2024-2222" }
|
||||
});
|
||||
await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Run 3",
|
||||
Context = new RunContext { FocusedCveId = "CVE-2024-1111" }
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.QueryAsync(new RunQuery
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
CveId = "CVE-2024-1111"
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, result.TotalCount);
|
||||
Assert.All(result.Runs, r =>
|
||||
{
|
||||
Assert.Equal("user-1", r.InitiatedBy);
|
||||
Assert.Equal("CVE-2024-1111", r.Context.FocusedCveId);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTimelineAsync_ReturnsEventsInOrder()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question 1", "user-1");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddAssistantTurnAsync("tenant-1", run.RunId, "Answer 1");
|
||||
_timeProvider.Advance(TimeSpan.FromSeconds(1));
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Question 2", "user-1");
|
||||
|
||||
// Act
|
||||
var timeline = await _service.GetTimelineAsync("tenant-1", run.RunId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(3, timeline.Length);
|
||||
Assert.Equal(RunEventType.UserTurn, timeline[0].Type);
|
||||
Assert.Equal(RunEventType.AssistantTurn, timeline[1].Type);
|
||||
Assert.Equal(RunEventType.UserTurn, timeline[2].Type);
|
||||
|
||||
// Verify sequence numbers are ordered
|
||||
Assert.True(timeline[0].SequenceNumber < timeline[1].SequenceNumber);
|
||||
Assert.True(timeline[1].SequenceNumber < timeline[2].SequenceNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandOffAsync_TransfersOwnership()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
|
||||
// Act
|
||||
var updated = await _service.HandOffAsync("tenant-1", run.RunId, "user-2", "Please continue");
|
||||
|
||||
// Assert
|
||||
Assert.Equal("user-2", updated.Metadata["current_owner"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddEventAsync_NonExistentRun_ThrowsInvalidOperation()
|
||||
{
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.AddUserTurnAsync("tenant-1", "non-existent", "Message", "user-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompleteAsync_AlreadyCompleted_ThrowsInvalidOperation()
|
||||
{
|
||||
// Arrange
|
||||
var run = await _service.CreateAsync(new CreateRunRequest
|
||||
{
|
||||
TenantId = "tenant-1",
|
||||
InitiatedBy = "user-1",
|
||||
Title = "Test Run"
|
||||
});
|
||||
await _service.AddUserTurnAsync("tenant-1", run.RunId, "Q", "user-1");
|
||||
await _service.CompleteAsync("tenant-1", run.RunId);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_service.CompleteAsync("tenant-1", run.RunId));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user