270 lines
8.9 KiB
C#
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);
|
|
}
|
|
}
|