release orchestrator v1 draft and build fixes

This commit is contained in:
master
2026-01-12 12:24:17 +02:00
parent f3de858c59
commit 9873f80830
1598 changed files with 240385 additions and 5944 deletions

View File

@@ -0,0 +1,651 @@
// <copyright file="EvidenceThreadEndpoints.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the AGPL-3.0-or-later.
// </copyright>
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Platform.WebService.Services;
using StellaOps.ReleaseOrchestrator.EvidenceThread.Export;
using StellaOps.ReleaseOrchestrator.EvidenceThread.Models;
using StellaOps.ReleaseOrchestrator.EvidenceThread.Services;
using StellaOps.ReleaseOrchestrator.EvidenceThread.Transcript;
namespace StellaOps.Platform.WebService.Endpoints;
/// <summary>
/// REST endpoints for Evidence Thread operations.
/// </summary>
public static class EvidenceThreadEndpoints
{
/// <summary>
/// Maps Evidence Thread API endpoints.
/// </summary>
public static IEndpointRouteBuilder MapEvidenceThreadEndpoints(this IEndpointRouteBuilder app)
{
var evidence = app.MapGroup("/api/v1/evidence")
.WithTags("Evidence Thread");
// GET /api/v1/evidence/{artifactDigest} - Get evidence thread for artifact
evidence.MapGet("/{artifactDigest}", GetEvidenceThread)
.WithName("GetEvidenceThread")
.WithSummary("Get evidence thread for an artifact")
.WithDescription("Retrieves the full evidence thread graph for an artifact by its digest.")
.Produces<EvidenceThreadResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/evidence/{artifactDigest}/export - Export thread as DSSE bundle
evidence.MapPost("/{artifactDigest}/export", ExportEvidenceThread)
.WithName("ExportEvidenceThread")
.WithSummary("Export evidence thread as DSSE bundle")
.WithDescription("Exports the evidence thread as a signed DSSE envelope for offline verification.")
.Produces<EvidenceExportResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/evidence/{artifactDigest}/transcript - Generate transcript
evidence.MapPost("/{artifactDigest}/transcript", GenerateTranscript)
.WithName("GenerateEvidenceTranscript")
.WithSummary("Generate natural language transcript")
.WithDescription("Generates a natural language transcript explaining the evidence thread.")
.Produces<EvidenceTranscriptResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
// GET /api/v1/evidence/{artifactDigest}/nodes - Get evidence nodes
evidence.MapGet("/{artifactDigest}/nodes", GetEvidenceNodes)
.WithName("GetEvidenceNodes")
.WithSummary("Get evidence nodes for an artifact")
.WithDescription("Retrieves all evidence nodes in the thread.")
.Produces<EvidenceNodeListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
// GET /api/v1/evidence/{artifactDigest}/links - Get evidence links
evidence.MapGet("/{artifactDigest}/links", GetEvidenceLinks)
.WithName("GetEvidenceLinks")
.WithSummary("Get evidence links for an artifact")
.WithDescription("Retrieves all evidence links in the thread.")
.Produces<EvidenceLinkListResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status400BadRequest);
// POST /api/v1/evidence/{artifactDigest}/collect - Trigger evidence collection
evidence.MapPost("/{artifactDigest}/collect", CollectEvidence)
.WithName("CollectEvidence")
.WithSummary("Collect evidence for an artifact")
.WithDescription("Triggers collection of all available evidence for an artifact.")
.Produces<EvidenceCollectionResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest);
return app;
}
private static async Task<IResult> GetEvidenceThread(
HttpContext context,
PlatformRequestContextResolver resolver,
IEvidenceThreadService service,
string artifactDigest,
[FromQuery] bool? includeContent,
CancellationToken ct)
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifact_digest_required" });
}
var options = new EvidenceThreadOptions
{
IncludeContent = includeContent ?? true
};
var graph = await service.GetThreadGraphAsync(
requestContext!.TenantId,
artifactDigest,
options,
ct).ConfigureAwait(false);
if (graph is null)
{
return Results.NotFound(new { error = "thread_not_found", artifactDigest });
}
return Results.Ok(new EvidenceThreadResponse
{
ThreadId = graph.Thread.Id,
TenantId = graph.Thread.TenantId,
ArtifactDigest = graph.Thread.ArtifactDigest,
ArtifactName = graph.Thread.ArtifactName,
Status = graph.Thread.Status.ToString(),
Verdict = graph.Thread.Verdict?.ToString(),
RiskScore = graph.Thread.RiskScore,
ReachabilityMode = graph.Thread.ReachabilityMode?.ToString(),
NodeCount = graph.Nodes.Length,
LinkCount = graph.Links.Length,
CreatedAt = graph.Thread.CreatedAt,
UpdatedAt = graph.Thread.UpdatedAt
});
}
private static async Task<IResult> ExportEvidenceThread(
HttpContext context,
PlatformRequestContextResolver resolver,
IEvidenceThreadService threadService,
IDsseThreadExporter exporter,
string artifactDigest,
[FromBody] EvidenceExportRequest? request,
CancellationToken ct)
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifact_digest_required" });
}
var graph = await threadService.GetThreadGraphAsync(
requestContext!.TenantId,
artifactDigest,
null,
ct).ConfigureAwait(false);
if (graph is null)
{
return Results.NotFound(new { error = "thread_not_found", artifactDigest });
}
var options = new ThreadExportOptions
{
Format = ParseExportFormat(request?.Format),
IncludeTranscript = request?.IncludeTranscript ?? true,
Sign = request?.Sign ?? true,
SigningKeyId = request?.SigningKeyId
};
var result = await exporter.ExportAsync(graph, options, ct).ConfigureAwait(false);
if (!result.Success)
{
return Results.BadRequest(new
{
error = result.ErrorCode,
message = result.ErrorMessage
});
}
return Results.Ok(new EvidenceExportResponse
{
ThreadId = result.ThreadId,
Format = result.Format.ToString(),
ContentDigest = result.ContentDigest,
SizeBytes = result.SizeBytes,
SigningKeyId = result.SigningKeyId,
ExportedAt = result.ExportedAt,
DurationMs = (long)result.Duration.TotalMilliseconds,
Envelope = result.Envelope is not null
? new DsseEnvelopeResponse
{
PayloadType = result.Envelope.PayloadType,
Payload = result.Envelope.Payload,
PayloadDigest = result.Envelope.PayloadDigest,
Signatures = result.Envelope.Signatures.Select(s => new DsseSignatureResponse
{
KeyId = s.KeyId,
Sig = s.Sig,
Algorithm = s.Algorithm
}).ToList()
}
: null
});
}
private static async Task<IResult> GenerateTranscript(
HttpContext context,
PlatformRequestContextResolver resolver,
IEvidenceThreadService threadService,
ITranscriptGenerator transcriptGenerator,
string artifactDigest,
[FromBody] EvidenceTranscriptRequest? request,
CancellationToken ct)
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifact_digest_required" });
}
var graph = await threadService.GetThreadGraphAsync(
requestContext!.TenantId,
artifactDigest,
null,
ct).ConfigureAwait(false);
if (graph is null)
{
return Results.NotFound(new { error = "thread_not_found", artifactDigest });
}
var options = new TranscriptOptions
{
Type = ParseTranscriptType(request?.Type),
IncludeLlmRationale = request?.IncludeLlmRationale ?? true,
RationalePromptHint = request?.RationalePromptHint,
MaxLength = request?.MaxLength
};
var transcript = await transcriptGenerator.GenerateTranscriptAsync(graph, options, ct).ConfigureAwait(false);
return Results.Ok(new EvidenceTranscriptResponse
{
TranscriptId = transcript.Id,
ThreadId = transcript.ThreadId,
Type = transcript.TranscriptType.ToString(),
TemplateVersion = transcript.TemplateVersion,
LlmModel = transcript.LlmModel,
Content = transcript.Content,
AnchorCount = transcript.Anchors.Length,
GeneratedAt = transcript.GeneratedAt
});
}
private static async Task<IResult> GetEvidenceNodes(
HttpContext context,
PlatformRequestContextResolver resolver,
IEvidenceThreadService service,
string artifactDigest,
[FromQuery] string? kind,
CancellationToken ct)
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifact_digest_required" });
}
var filterKinds = string.IsNullOrWhiteSpace(kind)
? null
: kind.Split(',')
.Select(k => Enum.TryParse<EvidenceNodeKind>(k.Trim(), true, out var nk) ? nk : (EvidenceNodeKind?)null)
.Where(k => k.HasValue)
.Select(k => k!.Value)
.ToList();
var options = new EvidenceThreadOptions
{
IncludeContent = true,
NodeKinds = filterKinds
};
var graph = await service.GetThreadGraphAsync(
requestContext!.TenantId,
artifactDigest,
options,
ct).ConfigureAwait(false);
if (graph is null)
{
return Results.NotFound(new { error = "thread_not_found", artifactDigest });
}
var nodes = graph.Nodes.Select(n => new EvidenceNodeResponse
{
Id = n.Id,
Kind = n.Kind.ToString(),
RefId = n.RefId,
RefDigest = n.RefDigest,
Title = n.Title,
Summary = n.Summary,
Confidence = n.Confidence,
AnchorCount = n.Anchors.Length,
CreatedAt = n.CreatedAt
}).ToList();
return Results.Ok(new EvidenceNodeListResponse
{
ThreadId = graph.Thread.Id,
ArtifactDigest = artifactDigest,
Nodes = nodes,
TotalCount = nodes.Count
});
}
private static async Task<IResult> GetEvidenceLinks(
HttpContext context,
PlatformRequestContextResolver resolver,
IEvidenceThreadService service,
string artifactDigest,
CancellationToken ct)
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifact_digest_required" });
}
var graph = await service.GetThreadGraphAsync(
requestContext!.TenantId,
artifactDigest,
null,
ct).ConfigureAwait(false);
if (graph is null)
{
return Results.NotFound(new { error = "thread_not_found", artifactDigest });
}
var links = graph.Links.Select(l => new EvidenceLinkResponse
{
Id = l.Id,
SrcNodeId = l.SrcNodeId,
DstNodeId = l.DstNodeId,
Relation = l.Relation.ToString(),
Weight = l.Weight,
CreatedAt = l.CreatedAt
}).ToList();
return Results.Ok(new EvidenceLinkListResponse
{
ThreadId = graph.Thread.Id,
ArtifactDigest = artifactDigest,
Links = links,
TotalCount = links.Count
});
}
private static async Task<IResult> CollectEvidence(
HttpContext context,
PlatformRequestContextResolver resolver,
IEvidenceThreadService threadService,
IEvidenceNodeCollector collector,
string artifactDigest,
[FromBody] EvidenceCollectionRequest? request,
CancellationToken ct)
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
if (string.IsNullOrWhiteSpace(artifactDigest))
{
return Results.BadRequest(new { error = "artifact_digest_required" });
}
// Get or create the thread
var thread = await threadService.GetOrCreateThreadAsync(
requestContext!.TenantId,
artifactDigest,
artifactName: null,
ct).ConfigureAwait(false);
var options = new EvidenceCollectionOptions
{
BaseArtifactDigest = request?.BaseArtifactDigest,
CollectSbomDiff = request?.CollectSbomDiff ?? true,
CollectReachability = request?.CollectReachability ?? true,
CollectVex = request?.CollectVex ?? true,
CollectAttestations = request?.CollectAttestations ?? true
};
var result = await collector.CollectAllAsync(thread.Id, artifactDigest, options, ct).ConfigureAwait(false);
return Results.Ok(new EvidenceCollectionResponse
{
ThreadId = thread.Id,
ArtifactDigest = artifactDigest,
NodesCollected = result.Nodes.Count,
LinksCreated = result.Links.Count,
ErrorCount = result.Errors.Count,
Errors = result.Errors.Select(e => new EvidenceCollectionErrorResponse
{
Source = e.Source,
Message = e.Message,
ExceptionType = e.ExceptionType
}).ToList(),
DurationMs = result.DurationMs
});
}
private static ThreadExportFormat ParseExportFormat(string? format) => format?.ToLowerInvariant() switch
{
"dsse" => ThreadExportFormat.Dsse,
"json" => ThreadExportFormat.Json,
"markdown" => ThreadExportFormat.Markdown,
"pdf" => ThreadExportFormat.Pdf,
_ => ThreadExportFormat.Dsse
};
private static TranscriptType ParseTranscriptType(string? type) => type?.ToLowerInvariant() switch
{
"summary" => TranscriptType.Summary,
"detailed" => TranscriptType.Detailed,
"audit" => TranscriptType.Audit,
_ => TranscriptType.Detailed
};
private static bool TryResolveContext(
HttpContext context,
PlatformRequestContextResolver resolver,
out PlatformRequestContext? requestContext,
out IResult? failure)
{
if (resolver.TryResolve(context, out requestContext, out var error))
{
failure = null;
return true;
}
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
return false;
}
}
#region Request/Response DTOs
/// <summary>
/// Response for evidence thread query.
/// </summary>
public sealed record EvidenceThreadResponse
{
public Guid ThreadId { get; init; }
public string? TenantId { get; init; }
public string? ArtifactDigest { get; init; }
public string? ArtifactName { get; init; }
public string? Status { get; init; }
public string? Verdict { get; init; }
public decimal? RiskScore { get; init; }
public string? ReachabilityMode { get; init; }
public int NodeCount { get; init; }
public int LinkCount { get; init; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
}
/// <summary>
/// Request for evidence export.
/// </summary>
public sealed record EvidenceExportRequest
{
public string? Format { get; init; }
public bool? IncludeTranscript { get; init; }
public bool? Sign { get; init; }
public string? SigningKeyId { get; init; }
}
/// <summary>
/// Response for evidence export.
/// </summary>
public sealed record EvidenceExportResponse
{
public Guid ThreadId { get; init; }
public string? Format { get; init; }
public string? ContentDigest { get; init; }
public long SizeBytes { get; init; }
public string? SigningKeyId { get; init; }
public DateTimeOffset ExportedAt { get; init; }
public long DurationMs { get; init; }
public DsseEnvelopeResponse? Envelope { get; init; }
}
/// <summary>
/// DSSE envelope response DTO.
/// </summary>
public sealed record DsseEnvelopeResponse
{
public string? PayloadType { get; init; }
public string? Payload { get; init; }
public string? PayloadDigest { get; init; }
public List<DsseSignatureResponse>? Signatures { get; init; }
}
/// <summary>
/// DSSE signature response DTO.
/// </summary>
public sealed record DsseSignatureResponse
{
public string? KeyId { get; init; }
public string? Sig { get; init; }
public string? Algorithm { get; init; }
}
/// <summary>
/// Request for transcript generation.
/// </summary>
public sealed record EvidenceTranscriptRequest
{
public string? Type { get; init; }
public bool? IncludeLlmRationale { get; init; }
public string? RationalePromptHint { get; init; }
public int? MaxLength { get; init; }
}
/// <summary>
/// Response for transcript generation.
/// </summary>
public sealed record EvidenceTranscriptResponse
{
public Guid TranscriptId { get; init; }
public Guid ThreadId { get; init; }
public string? Type { get; init; }
public string? TemplateVersion { get; init; }
public string? LlmModel { get; init; }
public string? Content { get; init; }
public int AnchorCount { get; init; }
public DateTimeOffset GeneratedAt { get; init; }
}
/// <summary>
/// Response for evidence node query.
/// </summary>
public sealed record EvidenceNodeResponse
{
public Guid Id { get; init; }
public string? Kind { get; init; }
public string? RefId { get; init; }
public string? RefDigest { get; init; }
public string? Title { get; init; }
public string? Summary { get; init; }
public decimal? Confidence { get; init; }
public int AnchorCount { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Response for evidence node list.
/// </summary>
public sealed record EvidenceNodeListResponse
{
public Guid ThreadId { get; init; }
public string? ArtifactDigest { get; init; }
public List<EvidenceNodeResponse>? Nodes { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Response for evidence link query.
/// </summary>
public sealed record EvidenceLinkResponse
{
public Guid Id { get; init; }
public Guid SrcNodeId { get; init; }
public Guid DstNodeId { get; init; }
public string? Relation { get; init; }
public decimal? Weight { get; init; }
public DateTimeOffset CreatedAt { get; init; }
}
/// <summary>
/// Response for evidence link list.
/// </summary>
public sealed record EvidenceLinkListResponse
{
public Guid ThreadId { get; init; }
public string? ArtifactDigest { get; init; }
public List<EvidenceLinkResponse>? Links { get; init; }
public int TotalCount { get; init; }
}
/// <summary>
/// Request for evidence collection.
/// </summary>
public sealed record EvidenceCollectionRequest
{
public string? BaseArtifactDigest { get; init; }
public bool? CollectSbomDiff { get; init; }
public bool? CollectReachability { get; init; }
public bool? CollectVex { get; init; }
public bool? CollectAttestations { get; init; }
}
/// <summary>
/// Response for evidence collection.
/// </summary>
public sealed record EvidenceCollectionResponse
{
public Guid ThreadId { get; init; }
public string? ArtifactDigest { get; init; }
public int NodesCollected { get; init; }
public int LinksCreated { get; init; }
public int ErrorCount { get; init; }
public List<EvidenceCollectionErrorResponse>? Errors { get; init; }
public long DurationMs { get; init; }
}
/// <summary>
/// Error response for evidence collection.
/// </summary>
public sealed record EvidenceCollectionErrorResponse
{
public string? Source { get; init; }
public string? Message { get; init; }
public string? ExceptionType { get; init; }
}
#endregion