sprints and audit work
This commit is contained in:
266
src/__Libraries/StellaOps.Facet/FacetDriftVexWorkflow.cs
Normal file
266
src/__Libraries/StellaOps.Facet/FacetDriftVexWorkflow.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
// <copyright file="FacetDriftVexWorkflow.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
|
||||
// </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;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FacetDriftVexWorkflow"/> class.
|
||||
/// </summary>
|
||||
public FacetDriftVexWorkflow(
|
||||
FacetDriftVexEmitter emitter,
|
||||
IFacetDriftVexDraftStore draftStore,
|
||||
ILogger<FacetDriftVexWorkflow>? logger = null)
|
||||
{
|
||||
_emitter = emitter ?? throw new ArgumentNullException(nameof(emitter));
|
||||
_draftStore = draftStore ?? throw new ArgumentNullException(nameof(draftStore));
|
||||
_logger = logger ?? NullLogger<FacetDriftVexWorkflow>.Instance;
|
||||
}
|
||||
|
||||
/// <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(DateTimeOffset.UtcNow, ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user