using Microsoft.Extensions.Logging; using StellaOps.TaskRunner.Core.Execution; using StellaOps.TaskRunner.Core.Planning; using System.Text.RegularExpressions; namespace StellaOps.TaskRunner.Infrastructure.Execution; public sealed class PackRunApprovalDecisionService { private readonly IPackRunApprovalStore _approvalStore; private readonly IPackRunStateStore _stateStore; private readonly IPackRunJobScheduler _scheduler; private readonly ILogger _logger; public PackRunApprovalDecisionService( IPackRunApprovalStore approvalStore, IPackRunStateStore stateStore, IPackRunJobScheduler scheduler, ILogger logger) { _approvalStore = approvalStore ?? throw new ArgumentNullException(nameof(approvalStore)); _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); _scheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task ApplyAsync( PackRunApprovalDecisionRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentException.ThrowIfNullOrWhiteSpace(request.RunId); ArgumentException.ThrowIfNullOrWhiteSpace(request.ApprovalId); var runId = request.RunId.Trim(); var approvalId = request.ApprovalId.Trim(); if (!IsSha256Digest(request.PlanHash)) { _logger.LogWarning( "Approval decision for run {RunId} rejected – plan hash format invalid (expected sha256:<64-hex>).", runId); return PackRunApprovalDecisionResult.PlanHashMismatch; } var state = await _stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); if (state is null) { _logger.LogWarning("Approval decision for run {RunId} rejected – run state not found.", runId); return PackRunApprovalDecisionResult.NotFound; } var approvals = await _approvalStore.GetAsync(runId, cancellationToken).ConfigureAwait(false); if (approvals.Count == 0) { _logger.LogWarning("Approval decision for run {RunId} rejected – approval state not found.", runId); return PackRunApprovalDecisionResult.NotFound; } if (!string.Equals(state.PlanHash, request.PlanHash, StringComparison.Ordinal)) { _logger.LogWarning( "Approval decision for run {RunId} rejected – plan hash mismatch (expected {Expected}, got {Actual}).", runId, state.PlanHash, request.PlanHash); return PackRunApprovalDecisionResult.PlanHashMismatch; } var requestedAt = state.RequestedAt != default ? state.RequestedAt : state.CreatedAt; var coordinator = PackRunApprovalCoordinator.Restore(state.Plan, approvals, requestedAt); ApprovalActionResult actionResult; var now = DateTimeOffset.UtcNow; switch (request.Decision) { case PackRunApprovalDecisionType.Approved: actionResult = coordinator.Approve(approvalId, request.ActorId ?? "system", now, request.Summary); break; case PackRunApprovalDecisionType.Rejected: actionResult = coordinator.Reject(approvalId, request.ActorId ?? "system", now, request.Summary); break; case PackRunApprovalDecisionType.Expired: actionResult = coordinator.Expire(approvalId, now, request.Summary); break; default: throw new ArgumentOutOfRangeException(nameof(request.Decision), request.Decision, "Unsupported approval decision."); } await _approvalStore.UpdateAsync(runId, actionResult.State, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Applied approval decision {Decision} for run {RunId} (approval {ApprovalId}, actor={ActorId}).", request.Decision, runId, approvalId, request.ActorId ?? "(system)"); if (actionResult.ShouldResumeRun && request.Decision == PackRunApprovalDecisionType.Approved) { var context = new PackRunExecutionContext(runId, state.Plan, requestedAt); await _scheduler.ScheduleAsync(context, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Scheduled run {RunId} for resume after approvals completed.", runId); return PackRunApprovalDecisionResult.Resumed; } return PackRunApprovalDecisionResult.Applied; } private static bool IsSha256Digest(string value) => !string.IsNullOrWhiteSpace(value) && Sha256Pattern.IsMatch(value); private static readonly Regex Sha256Pattern = new( "^sha256:[0-9a-f]{64}$", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); } public sealed record PackRunApprovalDecisionRequest( string RunId, string ApprovalId, string PlanHash, PackRunApprovalDecisionType Decision, string? ActorId, string? Summary); public enum PackRunApprovalDecisionType { Approved, Rejected, Expired } public sealed record PackRunApprovalDecisionResult(string Status) { public static PackRunApprovalDecisionResult NotFound { get; } = new("not_found"); public static PackRunApprovalDecisionResult PlanHashMismatch { get; } = new("plan_hash_mismatch"); public static PackRunApprovalDecisionResult Applied { get; } = new("applied"); public static PackRunApprovalDecisionResult Resumed { get; } = new("resumed"); public bool ShouldResume => ReferenceEquals(this, Resumed); }