//
// 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);
}
}