Files
git.stella-ops.org/src/__Libraries/StellaOps.Facet/FacetDriftVexWorkflow.cs

270 lines
8.9 KiB
C#

// <copyright file="FacetDriftVexWorkflow.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
// </copyright>
// Sprint: SPRINT_20260105_002_003_FACET (QTA-019)
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace StellaOps.Facet;
/// <summary>
/// Result of a facet drift VEX workflow execution.
/// </summary>
public sealed record FacetDriftVexWorkflowResult
{
/// <summary>
/// Emission result from the emitter.
/// </summary>
public required FacetDriftVexEmissionResult EmissionResult { get; init; }
/// <summary>
/// Number of drafts that were newly created.
/// </summary>
public int NewDraftsCreated { get; init; }
/// <summary>
/// Number of drafts that already existed (skipped).
/// </summary>
public int ExistingDraftsSkipped { get; init; }
/// <summary>
/// IDs of newly created drafts.
/// </summary>
public ImmutableArray<string> CreatedDraftIds { get; init; } = [];
/// <summary>
/// Any errors that occurred during storage.
/// </summary>
public ImmutableArray<string> Errors { get; init; } = [];
/// <summary>
/// Whether all operations completed successfully.
/// </summary>
public bool Success => Errors.Length == 0;
}
/// <summary>
/// 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.
/// </summary>
public sealed class FacetDriftVexWorkflow
{
private readonly FacetDriftVexEmitter _emitter;
private readonly IFacetDriftVexDraftStore _draftStore;
private readonly ILogger<FacetDriftVexWorkflow> _logger;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="FacetDriftVexWorkflow"/> class.
/// </summary>
public FacetDriftVexWorkflow(
FacetDriftVexEmitter emitter,
IFacetDriftVexDraftStore draftStore,
ILogger<FacetDriftVexWorkflow>? logger = null,
TimeProvider? timeProvider = null)
{
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
_draftStore = draftStore ?? throw new ArgumentNullException(nameof(draftStore));
_logger = logger ?? NullLogger<FacetDriftVexWorkflow>.Instance;
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Executes the full workflow: emit drafts from drift report and store them.
/// </summary>
/// <param name="driftReport">The drift report to process.</param>
/// <param name="skipExisting">If true, skip creating drafts that already exist.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Workflow result with draft IDs and status.</returns>
public async Task<FacetDriftVexWorkflowResult> 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<string>();
var skippedCount = 0;
var errors = new List<string>();
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]
};
}
/// <summary>
/// Approves a draft and converts it to a VEX statement.
/// </summary>
/// <param name="draftId">ID of the draft to approve.</param>
/// <param name="reviewedBy">Who approved the draft.</param>
/// <param name="notes">Optional review notes.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if approval succeeded.</returns>
public async Task<bool> 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;
}
}
/// <summary>
/// Rejects a draft.
/// </summary>
/// <param name="draftId">ID of the draft to reject.</param>
/// <param name="reviewedBy">Who rejected the draft.</param>
/// <param name="reason">Reason for rejection.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>True if rejection succeeded.</returns>
public async Task<bool> 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;
}
}
/// <summary>
/// Gets drafts pending review.
/// </summary>
public Task<ImmutableArray<FacetDriftVexDraft>> GetPendingDraftsAsync(
string? imageDigest = null,
CancellationToken ct = default)
{
var query = new FacetDriftVexDraftQuery
{
ImageDigest = imageDigest,
ReviewStatus = FacetDriftVexReviewStatus.Pending
};
return _draftStore.QueryAsync(query, ct);
}
/// <summary>
/// Gets drafts that have exceeded their review deadline.
/// </summary>
public Task<ImmutableArray<FacetDriftVexDraft>> GetOverdueDraftsAsync(CancellationToken ct = default)
{
return _draftStore.GetOverdueAsync(_timeProvider.GetUtcNow(), ct);
}
}