// // Copyright (c) StellaOps. Licensed under BUSL-1.1. // // Sprint: SPRINT_20260105_002_003_FACET (QTA-019) using System.Collections.Immutable; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace StellaOps.Facet; /// /// Result of a facet drift VEX workflow execution. /// public sealed record FacetDriftVexWorkflowResult { /// /// Emission result from the emitter. /// public required FacetDriftVexEmissionResult EmissionResult { get; init; } /// /// Number of drafts that were newly created. /// public int NewDraftsCreated { get; init; } /// /// Number of drafts that already existed (skipped). /// public int ExistingDraftsSkipped { get; init; } /// /// IDs of newly created drafts. /// public ImmutableArray CreatedDraftIds { get; init; } = []; /// /// Any errors that occurred during storage. /// public ImmutableArray Errors { get; init; } = []; /// /// Whether all operations completed successfully. /// public bool Success => Errors.Length == 0; } /// /// Orchestrates the facet drift VEX workflow: emit drafts + store. /// This integrates with the Excititor VEX workflow by providing /// drafts that can be picked up for human review. /// public sealed class FacetDriftVexWorkflow { private readonly FacetDriftVexEmitter _emitter; private readonly IFacetDriftVexDraftStore _draftStore; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; /// /// Initializes a new instance of the class. /// public FacetDriftVexWorkflow( FacetDriftVexEmitter emitter, IFacetDriftVexDraftStore draftStore, ILogger? logger = null, TimeProvider? timeProvider = null) { _emitter = emitter ?? throw new ArgumentNullException(nameof(emitter)); _draftStore = draftStore ?? throw new ArgumentNullException(nameof(draftStore)); _logger = logger ?? NullLogger.Instance; _timeProvider = timeProvider ?? TimeProvider.System; } /// /// Executes the full workflow: emit drafts from drift report and store them. /// /// The drift report to process. /// If true, skip creating drafts that already exist. /// Cancellation token. /// Workflow result with draft IDs and status. public async Task ExecuteAsync( FacetDriftReport driftReport, bool skipExisting = true, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(driftReport); // Emit drafts from drift report var context = new FacetDriftVexEmissionContext(driftReport); var emissionResult = _emitter.EmitDrafts(context); if (emissionResult.DraftsEmitted == 0) { _logger.LogDebug("No drafts to emit for image {ImageDigest}", driftReport.ImageDigest); return new FacetDriftVexWorkflowResult { EmissionResult = emissionResult, NewDraftsCreated = 0, ExistingDraftsSkipped = 0 }; } // Store drafts var createdIds = new List(); var skippedCount = 0; var errors = new List(); foreach (var draft in emissionResult.Drafts) { ct.ThrowIfCancellationRequested(); try { if (skipExisting) { var exists = await _draftStore.ExistsAsync( draft.ImageDigest, draft.FacetId, ct).ConfigureAwait(false); if (exists) { _logger.LogDebug( "Skipping existing draft for {ImageDigest}/{FacetId}", draft.ImageDigest, draft.FacetId); skippedCount++; continue; } } await _draftStore.SaveAsync(draft, ct).ConfigureAwait(false); createdIds.Add(draft.DraftId); _logger.LogInformation( "Created VEX draft {DraftId} for {ImageDigest}/{FacetId} with churn {ChurnPercent:F1}%", draft.DraftId, draft.ImageDigest, draft.FacetId, draft.DriftSummary.ChurnPercent); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError( ex, "Failed to store draft for {ImageDigest}/{FacetId}", draft.ImageDigest, draft.FacetId); errors.Add($"Failed to store draft for {draft.FacetId}: {ex.Message}"); } } return new FacetDriftVexWorkflowResult { EmissionResult = emissionResult, NewDraftsCreated = createdIds.Count, ExistingDraftsSkipped = skippedCount, CreatedDraftIds = [.. createdIds], Errors = [.. errors] }; } /// /// Approves a draft and converts it to a VEX statement. /// /// ID of the draft to approve. /// Who approved the draft. /// Optional review notes. /// Cancellation token. /// True if approval succeeded. public async Task ApproveAsync( string draftId, string reviewedBy, string? notes = null, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(draftId); ArgumentException.ThrowIfNullOrWhiteSpace(reviewedBy); try { await _draftStore.UpdateReviewStatusAsync( draftId, FacetDriftVexReviewStatus.Approved, reviewedBy, notes, ct).ConfigureAwait(false); _logger.LogInformation( "Draft {DraftId} approved by {ReviewedBy}", draftId, reviewedBy); return true; } catch (KeyNotFoundException) { _logger.LogWarning("Draft {DraftId} not found for approval", draftId); return false; } } /// /// Rejects a draft. /// /// ID of the draft to reject. /// Who rejected the draft. /// Reason for rejection. /// Cancellation token. /// True if rejection succeeded. public async Task RejectAsync( string draftId, string reviewedBy, string reason, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(draftId); ArgumentException.ThrowIfNullOrWhiteSpace(reviewedBy); ArgumentException.ThrowIfNullOrWhiteSpace(reason); try { await _draftStore.UpdateReviewStatusAsync( draftId, FacetDriftVexReviewStatus.Rejected, reviewedBy, reason, ct).ConfigureAwait(false); _logger.LogInformation( "Draft {DraftId} rejected by {ReviewedBy}: {Reason}", draftId, reviewedBy, reason); return true; } catch (KeyNotFoundException) { _logger.LogWarning("Draft {DraftId} not found for rejection", draftId); return false; } } /// /// Gets drafts pending review. /// public Task> GetPendingDraftsAsync( string? imageDigest = null, CancellationToken ct = default) { var query = new FacetDriftVexDraftQuery { ImageDigest = imageDigest, ReviewStatus = FacetDriftVexReviewStatus.Pending }; return _draftStore.QueryAsync(query, ct); } /// /// Gets drafts that have exceeded their review deadline. /// public Task> GetOverdueDraftsAsync(CancellationToken ct = default) { return _draftStore.GetOverdueAsync(_timeProvider.GetUtcNow(), ct); } }