up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,10 +1,10 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunApprovalStore
{
Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken);
Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken);
Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken);
}
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunApprovalStore
{
Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken);
Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken);
Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken);
}

View File

@@ -1,6 +1,6 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunJobDispatcher
{
Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken);
}
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunJobDispatcher
{
Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken);
}

View File

@@ -1,8 +1,8 @@
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunNotificationPublisher
{
Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken);
Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken);
}
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunNotificationPublisher
{
Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken);
Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken);
}

View File

@@ -1,7 +1,7 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunStepExecutor
{
Task<PackRunStepExecutionResult> ExecuteAsync(

View File

@@ -1,177 +1,177 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunApprovalCoordinator
{
private readonly ConcurrentDictionary<string, PackRunApprovalState> approvals;
private readonly IReadOnlyDictionary<string, PackRunApprovalRequirement> requirements;
private PackRunApprovalCoordinator(
IReadOnlyDictionary<string, PackRunApprovalState> approvals,
IReadOnlyDictionary<string, PackRunApprovalRequirement> requirements)
{
this.approvals = new ConcurrentDictionary<string, PackRunApprovalState>(approvals);
this.requirements = requirements;
}
public static PackRunApprovalCoordinator Create(TaskPackPlan plan, DateTimeOffset requestTimestamp)
{
ArgumentNullException.ThrowIfNull(plan);
var requirements = TaskPackPlanInsights
.CollectApprovalRequirements(plan)
.ToDictionary(
requirement => requirement.ApprovalId,
requirement => new PackRunApprovalRequirement(
requirement.ApprovalId,
requirement.Grants.ToImmutableArray(),
requirement.StepIds.ToImmutableArray(),
requirement.Messages.ToImmutableArray(),
requirement.ReasonTemplate),
StringComparer.Ordinal);
var states = requirements.Values
.ToDictionary(
requirement => requirement.ApprovalId,
requirement => new PackRunApprovalState(
requirement.ApprovalId,
requirement.RequiredGrants,
requirement.StepIds,
requirement.Messages,
requirement.ReasonTemplate,
requestTimestamp,
PackRunApprovalStatus.Pending),
StringComparer.Ordinal);
return new PackRunApprovalCoordinator(states, requirements);
}
public static PackRunApprovalCoordinator Restore(TaskPackPlan plan, IReadOnlyList<PackRunApprovalState> existingStates, DateTimeOffset requestedAt)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(existingStates);
var coordinator = Create(plan, requestedAt);
foreach (var state in existingStates)
{
coordinator.approvals[state.ApprovalId] = state;
}
return coordinator;
}
public IReadOnlyList<PackRunApprovalState> GetApprovals()
=> approvals.Values
.OrderBy(state => state.ApprovalId, StringComparer.Ordinal)
.ToImmutableArray();
public bool HasPendingApprovals => approvals.Values.Any(state => state.Status == PackRunApprovalStatus.Pending);
public ApprovalActionResult Approve(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Approve(actorId, completedAt, summary));
var shouldResume = approvals.Values.All(state => state.Status == PackRunApprovalStatus.Approved);
return new ApprovalActionResult(updated, shouldResume);
}
public ApprovalActionResult Reject(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Reject(actorId, completedAt, summary));
return new ApprovalActionResult(updated, false);
}
public ApprovalActionResult Expire(string approvalId, DateTimeOffset expiredAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Expire(expiredAt, summary));
return new ApprovalActionResult(updated, false);
}
public IReadOnlyList<ApprovalNotification> BuildNotifications(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var hints = TaskPackPlanInsights.CollectApprovalRequirements(plan);
var notifications = new List<ApprovalNotification>(hints.Count);
foreach (var hint in hints)
{
if (!requirements.TryGetValue(hint.ApprovalId, out var requirement))
{
continue;
}
notifications.Add(new ApprovalNotification(
requirement.ApprovalId,
requirement.RequiredGrants,
requirement.Messages,
requirement.StepIds,
requirement.ReasonTemplate));
}
return notifications;
}
public IReadOnlyList<PolicyGateNotification> BuildPolicyNotifications(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var policyHints = TaskPackPlanInsights.CollectPolicyGateHints(plan);
return policyHints
.Select(hint => new PolicyGateNotification(
hint.StepId,
hint.Message,
hint.Parameters.Select(parameter => new PolicyGateNotificationParameter(
parameter.Name,
parameter.RequiresRuntimeValue,
parameter.Expression,
parameter.Error)).ToImmutableArray()))
.ToImmutableArray();
}
}
public sealed record PackRunApprovalRequirement(
string ApprovalId,
IReadOnlyList<string> RequiredGrants,
IReadOnlyList<string> StepIds,
IReadOnlyList<string> Messages,
string? ReasonTemplate);
public sealed record ApprovalActionResult(PackRunApprovalState State, bool ShouldResumeRun);
public sealed record ApprovalNotification(
string ApprovalId,
IReadOnlyList<string> RequiredGrants,
IReadOnlyList<string> Messages,
IReadOnlyList<string> StepIds,
string? ReasonTemplate);
public sealed record PolicyGateNotification(string StepId, string? Message, IReadOnlyList<PolicyGateNotificationParameter> Parameters);
public sealed record PolicyGateNotificationParameter(
string Name,
bool RequiresRuntimeValue,
string? Expression,
string? Error);
using System.Collections.Concurrent;
using System.Collections.Immutable;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunApprovalCoordinator
{
private readonly ConcurrentDictionary<string, PackRunApprovalState> approvals;
private readonly IReadOnlyDictionary<string, PackRunApprovalRequirement> requirements;
private PackRunApprovalCoordinator(
IReadOnlyDictionary<string, PackRunApprovalState> approvals,
IReadOnlyDictionary<string, PackRunApprovalRequirement> requirements)
{
this.approvals = new ConcurrentDictionary<string, PackRunApprovalState>(approvals);
this.requirements = requirements;
}
public static PackRunApprovalCoordinator Create(TaskPackPlan plan, DateTimeOffset requestTimestamp)
{
ArgumentNullException.ThrowIfNull(plan);
var requirements = TaskPackPlanInsights
.CollectApprovalRequirements(plan)
.ToDictionary(
requirement => requirement.ApprovalId,
requirement => new PackRunApprovalRequirement(
requirement.ApprovalId,
requirement.Grants.ToImmutableArray(),
requirement.StepIds.ToImmutableArray(),
requirement.Messages.ToImmutableArray(),
requirement.ReasonTemplate),
StringComparer.Ordinal);
var states = requirements.Values
.ToDictionary(
requirement => requirement.ApprovalId,
requirement => new PackRunApprovalState(
requirement.ApprovalId,
requirement.RequiredGrants,
requirement.StepIds,
requirement.Messages,
requirement.ReasonTemplate,
requestTimestamp,
PackRunApprovalStatus.Pending),
StringComparer.Ordinal);
return new PackRunApprovalCoordinator(states, requirements);
}
public static PackRunApprovalCoordinator Restore(TaskPackPlan plan, IReadOnlyList<PackRunApprovalState> existingStates, DateTimeOffset requestedAt)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(existingStates);
var coordinator = Create(plan, requestedAt);
foreach (var state in existingStates)
{
coordinator.approvals[state.ApprovalId] = state;
}
return coordinator;
}
public IReadOnlyList<PackRunApprovalState> GetApprovals()
=> approvals.Values
.OrderBy(state => state.ApprovalId, StringComparer.Ordinal)
.ToImmutableArray();
public bool HasPendingApprovals => approvals.Values.Any(state => state.Status == PackRunApprovalStatus.Pending);
public ApprovalActionResult Approve(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Approve(actorId, completedAt, summary));
var shouldResume = approvals.Values.All(state => state.Status == PackRunApprovalStatus.Approved);
return new ApprovalActionResult(updated, shouldResume);
}
public ApprovalActionResult Reject(string approvalId, string actorId, DateTimeOffset completedAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
ArgumentException.ThrowIfNullOrWhiteSpace(actorId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Reject(actorId, completedAt, summary));
return new ApprovalActionResult(updated, false);
}
public ApprovalActionResult Expire(string approvalId, DateTimeOffset expiredAt, string? summary = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(approvalId);
var updated = approvals.AddOrUpdate(
approvalId,
static _ => throw new KeyNotFoundException("Unknown approval."),
(_, current) => current.Expire(expiredAt, summary));
return new ApprovalActionResult(updated, false);
}
public IReadOnlyList<ApprovalNotification> BuildNotifications(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var hints = TaskPackPlanInsights.CollectApprovalRequirements(plan);
var notifications = new List<ApprovalNotification>(hints.Count);
foreach (var hint in hints)
{
if (!requirements.TryGetValue(hint.ApprovalId, out var requirement))
{
continue;
}
notifications.Add(new ApprovalNotification(
requirement.ApprovalId,
requirement.RequiredGrants,
requirement.Messages,
requirement.StepIds,
requirement.ReasonTemplate));
}
return notifications;
}
public IReadOnlyList<PolicyGateNotification> BuildPolicyNotifications(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var policyHints = TaskPackPlanInsights.CollectPolicyGateHints(plan);
return policyHints
.Select(hint => new PolicyGateNotification(
hint.StepId,
hint.Message,
hint.Parameters.Select(parameter => new PolicyGateNotificationParameter(
parameter.Name,
parameter.RequiresRuntimeValue,
parameter.Expression,
parameter.Error)).ToImmutableArray()))
.ToImmutableArray();
}
}
public sealed record PackRunApprovalRequirement(
string ApprovalId,
IReadOnlyList<string> RequiredGrants,
IReadOnlyList<string> StepIds,
IReadOnlyList<string> Messages,
string? ReasonTemplate);
public sealed record ApprovalActionResult(PackRunApprovalState State, bool ShouldResumeRun);
public sealed record ApprovalNotification(
string ApprovalId,
IReadOnlyList<string> RequiredGrants,
IReadOnlyList<string> Messages,
IReadOnlyList<string> StepIds,
string? ReasonTemplate);
public sealed record PolicyGateNotification(string StepId, string? Message, IReadOnlyList<PolicyGateNotificationParameter> Parameters);
public sealed record PolicyGateNotificationParameter(
string Name,
bool RequiresRuntimeValue,
string? Expression,
string? Error);

View File

@@ -1,84 +1,84 @@
using System.Collections.Immutable;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunApprovalState
{
public PackRunApprovalState(
string approvalId,
IReadOnlyList<string> requiredGrants,
IReadOnlyList<string> stepIds,
IReadOnlyList<string> messages,
string? reasonTemplate,
DateTimeOffset requestedAt,
PackRunApprovalStatus status,
string? actorId = null,
DateTimeOffset? completedAt = null,
string? summary = null)
{
if (string.IsNullOrWhiteSpace(approvalId))
{
throw new ArgumentException("Approval id must not be empty.", nameof(approvalId));
}
ApprovalId = approvalId;
RequiredGrants = requiredGrants.ToImmutableArray();
StepIds = stepIds.ToImmutableArray();
Messages = messages.ToImmutableArray();
ReasonTemplate = reasonTemplate;
RequestedAt = requestedAt;
Status = status;
ActorId = actorId;
CompletedAt = completedAt;
Summary = summary;
}
public string ApprovalId { get; }
public IReadOnlyList<string> RequiredGrants { get; }
public IReadOnlyList<string> StepIds { get; }
public IReadOnlyList<string> Messages { get; }
public string? ReasonTemplate { get; }
public DateTimeOffset RequestedAt { get; }
public PackRunApprovalStatus Status { get; }
public string? ActorId { get; }
public DateTimeOffset? CompletedAt { get; }
public string? Summary { get; }
public PackRunApprovalState Approve(string actorId, DateTimeOffset completedAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Approved, actorId, completedAt, summary);
public PackRunApprovalState Reject(string actorId, DateTimeOffset completedAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Rejected, actorId, completedAt, summary);
public PackRunApprovalState Expire(DateTimeOffset expiredAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Expired, actorId: null, expiredAt, summary);
private PackRunApprovalState Transition(PackRunApprovalStatus status, string? actorId, DateTimeOffset completedAt, string? summary)
{
if (Status != PackRunApprovalStatus.Pending)
{
throw new InvalidOperationException($"Approval '{ApprovalId}' is already {Status}.");
}
return new PackRunApprovalState(
ApprovalId,
RequiredGrants,
StepIds,
Messages,
ReasonTemplate,
RequestedAt,
status,
actorId,
completedAt,
summary);
}
}
using System.Collections.Immutable;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunApprovalState
{
public PackRunApprovalState(
string approvalId,
IReadOnlyList<string> requiredGrants,
IReadOnlyList<string> stepIds,
IReadOnlyList<string> messages,
string? reasonTemplate,
DateTimeOffset requestedAt,
PackRunApprovalStatus status,
string? actorId = null,
DateTimeOffset? completedAt = null,
string? summary = null)
{
if (string.IsNullOrWhiteSpace(approvalId))
{
throw new ArgumentException("Approval id must not be empty.", nameof(approvalId));
}
ApprovalId = approvalId;
RequiredGrants = requiredGrants.ToImmutableArray();
StepIds = stepIds.ToImmutableArray();
Messages = messages.ToImmutableArray();
ReasonTemplate = reasonTemplate;
RequestedAt = requestedAt;
Status = status;
ActorId = actorId;
CompletedAt = completedAt;
Summary = summary;
}
public string ApprovalId { get; }
public IReadOnlyList<string> RequiredGrants { get; }
public IReadOnlyList<string> StepIds { get; }
public IReadOnlyList<string> Messages { get; }
public string? ReasonTemplate { get; }
public DateTimeOffset RequestedAt { get; }
public PackRunApprovalStatus Status { get; }
public string? ActorId { get; }
public DateTimeOffset? CompletedAt { get; }
public string? Summary { get; }
public PackRunApprovalState Approve(string actorId, DateTimeOffset completedAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Approved, actorId, completedAt, summary);
public PackRunApprovalState Reject(string actorId, DateTimeOffset completedAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Rejected, actorId, completedAt, summary);
public PackRunApprovalState Expire(DateTimeOffset expiredAt, string? summary = null)
=> Transition(PackRunApprovalStatus.Expired, actorId: null, expiredAt, summary);
private PackRunApprovalState Transition(PackRunApprovalStatus status, string? actorId, DateTimeOffset completedAt, string? summary)
{
if (Status != PackRunApprovalStatus.Pending)
{
throw new InvalidOperationException($"Approval '{ApprovalId}' is already {Status}.");
}
return new PackRunApprovalState(
ApprovalId,
RequiredGrants,
StepIds,
Messages,
ReasonTemplate,
RequestedAt,
status,
actorId,
completedAt,
summary);
}
}

View File

@@ -1,9 +1,9 @@
namespace StellaOps.TaskRunner.Core.Execution;
public enum PackRunApprovalStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
Expired = 3
}
namespace StellaOps.TaskRunner.Core.Execution;
public enum PackRunApprovalStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
Expired = 3
}

View File

@@ -1,7 +1,7 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionContext
{
public PackRunExecutionContext(string runId, TaskPackPlan plan, DateTimeOffset requestedAt, string? tenantId = null)

View File

@@ -1,240 +1,240 @@
using System.Collections.ObjectModel;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionGraph
{
public static readonly TaskPackPlanFailurePolicy DefaultFailurePolicy = new(1, 0, ContinueOnError: false);
public PackRunExecutionGraph(IReadOnlyList<PackRunExecutionStep> steps, TaskPackPlanFailurePolicy? failurePolicy)
{
Steps = steps ?? throw new ArgumentNullException(nameof(steps));
FailurePolicy = failurePolicy ?? DefaultFailurePolicy;
}
public IReadOnlyList<PackRunExecutionStep> Steps { get; }
public TaskPackPlanFailurePolicy FailurePolicy { get; }
}
public enum PackRunStepKind
{
Unknown = 0,
Run,
GateApproval,
GatePolicy,
Parallel,
Map,
Loop,
Conditional
}
public sealed class PackRunExecutionStep
{
public PackRunExecutionStep(
string id,
string templateId,
PackRunStepKind kind,
bool enabled,
string? uses,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
string? approvalId,
string? gateMessage,
int? maxParallel,
bool continueOnError,
IReadOnlyList<PackRunExecutionStep> children,
PackRunLoopConfig? loopConfig = null,
PackRunConditionalConfig? conditionalConfig = null,
PackRunPolicyGateConfig? policyGateConfig = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
Kind = kind;
Enabled = enabled;
Uses = uses;
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
ApprovalId = approvalId;
GateMessage = gateMessage;
MaxParallel = maxParallel;
ContinueOnError = continueOnError;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopConfig = loopConfig;
ConditionalConfig = conditionalConfig;
PolicyGateConfig = policyGateConfig;
}
public string Id { get; }
public string TemplateId { get; }
public PackRunStepKind Kind { get; }
public bool Enabled { get; }
public string? Uses { get; }
public IReadOnlyDictionary<string, TaskPackPlanParameterValue> Parameters { get; }
public string? ApprovalId { get; }
public string? GateMessage { get; }
public int? MaxParallel { get; }
public bool ContinueOnError { get; }
public IReadOnlyList<PackRunExecutionStep> Children { get; }
/// <summary>Loop step configuration (when Kind == Loop).</summary>
public PackRunLoopConfig? LoopConfig { get; }
/// <summary>Conditional step configuration (when Kind == Conditional).</summary>
public PackRunConditionalConfig? ConditionalConfig { get; }
/// <summary>Policy gate configuration (when Kind == GatePolicy).</summary>
public PackRunPolicyGateConfig? PolicyGateConfig { get; }
public static IReadOnlyDictionary<string, TaskPackPlanParameterValue> EmptyParameters { get; } =
new ReadOnlyDictionary<string, TaskPackPlanParameterValue>(new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal));
public static IReadOnlyList<PackRunExecutionStep> EmptyChildren { get; } =
Array.Empty<PackRunExecutionStep>();
}
/// <summary>
/// Configuration for loop steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunLoopConfig(
/// <summary>Expression yielding items to iterate over.</summary>
string? ItemsExpression,
/// <summary>Static items array (alternative to expression).</summary>
IReadOnlyList<object>? StaticItems,
/// <summary>Range specification (alternative to expression).</summary>
PackRunLoopRange? Range,
/// <summary>Variable name bound to current item (default: "item").</summary>
string Iterator,
/// <summary>Variable name bound to current index (default: "index").</summary>
string Index,
/// <summary>Maximum iterations (safety limit).</summary>
int MaxIterations,
/// <summary>Aggregation mode for loop outputs.</summary>
PackRunLoopAggregationMode AggregationMode,
/// <summary>JMESPath to extract from each iteration result.</summary>
string? OutputPath)
{
public static PackRunLoopConfig Default => new(
null, null, null, "item", "index", 1000, PackRunLoopAggregationMode.Collect, null);
}
/// <summary>Range specification for loop iteration.</summary>
public sealed record PackRunLoopRange(int Start, int End, int Step = 1);
/// <summary>Loop output aggregation modes.</summary>
public enum PackRunLoopAggregationMode
{
/// <summary>Collect outputs into array.</summary>
Collect = 0,
/// <summary>Deep merge objects.</summary>
Merge,
/// <summary>Keep only last output.</summary>
Last,
/// <summary>Keep only first output.</summary>
First,
/// <summary>Discard outputs.</summary>
None
}
/// <summary>
/// Configuration for conditional steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunConditionalConfig(
/// <summary>Ordered branches (first matching executes).</summary>
IReadOnlyList<PackRunConditionalBranch> Branches,
/// <summary>Steps to execute if no branch matches.</summary>
IReadOnlyList<PackRunExecutionStep>? ElseBranch,
/// <summary>Whether to union outputs from all branches.</summary>
bool OutputUnion);
/// <summary>A conditional branch with condition and body.</summary>
public sealed record PackRunConditionalBranch(
/// <summary>Condition expression (JMESPath or operator-based).</summary>
string ConditionExpression,
/// <summary>Steps to execute if condition matches.</summary>
IReadOnlyList<PackRunExecutionStep> Body);
/// <summary>
/// Configuration for policy gate steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunPolicyGateConfig(
/// <summary>Policy identifier in the registry.</summary>
string PolicyId,
/// <summary>Specific policy version (semver).</summary>
string? PolicyVersion,
/// <summary>Policy digest for reproducibility.</summary>
string? PolicyDigest,
/// <summary>JMESPath expression to construct policy input.</summary>
string? InputExpression,
/// <summary>Timeout for policy evaluation.</summary>
TimeSpan Timeout,
/// <summary>What to do on policy failure.</summary>
PackRunPolicyFailureAction FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount,
/// <summary>Delay between retries.</summary>
TimeSpan RetryDelay,
/// <summary>Override approvers (if action is RequestOverride).</summary>
IReadOnlyList<string>? OverrideApprovers,
/// <summary>Step ID to branch to (if action is Branch).</summary>
string? BranchTo,
/// <summary>Whether to record decision in evidence locker.</summary>
bool RecordDecision,
/// <summary>Whether to record policy input.</summary>
bool RecordInput,
/// <summary>Whether to record rationale.</summary>
bool RecordRationale,
/// <summary>Whether to create DSSE attestation.</summary>
bool CreateAttestation)
{
public static PackRunPolicyGateConfig Default(string policyId) => new(
policyId, null, null, null,
TimeSpan.FromMinutes(5),
PackRunPolicyFailureAction.Abort, 0, TimeSpan.FromSeconds(10),
null, null, true, false, true, false);
}
/// <summary>Policy gate failure actions.</summary>
public enum PackRunPolicyFailureAction
{
/// <summary>Abort the run.</summary>
Abort = 0,
/// <summary>Log warning and continue.</summary>
Warn,
/// <summary>Request override approval.</summary>
RequestOverride,
/// <summary>Branch to specified step.</summary>
Branch
}
using System.Collections.ObjectModel;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionGraph
{
public static readonly TaskPackPlanFailurePolicy DefaultFailurePolicy = new(1, 0, ContinueOnError: false);
public PackRunExecutionGraph(IReadOnlyList<PackRunExecutionStep> steps, TaskPackPlanFailurePolicy? failurePolicy)
{
Steps = steps ?? throw new ArgumentNullException(nameof(steps));
FailurePolicy = failurePolicy ?? DefaultFailurePolicy;
}
public IReadOnlyList<PackRunExecutionStep> Steps { get; }
public TaskPackPlanFailurePolicy FailurePolicy { get; }
}
public enum PackRunStepKind
{
Unknown = 0,
Run,
GateApproval,
GatePolicy,
Parallel,
Map,
Loop,
Conditional
}
public sealed class PackRunExecutionStep
{
public PackRunExecutionStep(
string id,
string templateId,
PackRunStepKind kind,
bool enabled,
string? uses,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
string? approvalId,
string? gateMessage,
int? maxParallel,
bool continueOnError,
IReadOnlyList<PackRunExecutionStep> children,
PackRunLoopConfig? loopConfig = null,
PackRunConditionalConfig? conditionalConfig = null,
PackRunPolicyGateConfig? policyGateConfig = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
Kind = kind;
Enabled = enabled;
Uses = uses;
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
ApprovalId = approvalId;
GateMessage = gateMessage;
MaxParallel = maxParallel;
ContinueOnError = continueOnError;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopConfig = loopConfig;
ConditionalConfig = conditionalConfig;
PolicyGateConfig = policyGateConfig;
}
public string Id { get; }
public string TemplateId { get; }
public PackRunStepKind Kind { get; }
public bool Enabled { get; }
public string? Uses { get; }
public IReadOnlyDictionary<string, TaskPackPlanParameterValue> Parameters { get; }
public string? ApprovalId { get; }
public string? GateMessage { get; }
public int? MaxParallel { get; }
public bool ContinueOnError { get; }
public IReadOnlyList<PackRunExecutionStep> Children { get; }
/// <summary>Loop step configuration (when Kind == Loop).</summary>
public PackRunLoopConfig? LoopConfig { get; }
/// <summary>Conditional step configuration (when Kind == Conditional).</summary>
public PackRunConditionalConfig? ConditionalConfig { get; }
/// <summary>Policy gate configuration (when Kind == GatePolicy).</summary>
public PackRunPolicyGateConfig? PolicyGateConfig { get; }
public static IReadOnlyDictionary<string, TaskPackPlanParameterValue> EmptyParameters { get; } =
new ReadOnlyDictionary<string, TaskPackPlanParameterValue>(new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal));
public static IReadOnlyList<PackRunExecutionStep> EmptyChildren { get; } =
Array.Empty<PackRunExecutionStep>();
}
/// <summary>
/// Configuration for loop steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunLoopConfig(
/// <summary>Expression yielding items to iterate over.</summary>
string? ItemsExpression,
/// <summary>Static items array (alternative to expression).</summary>
IReadOnlyList<object>? StaticItems,
/// <summary>Range specification (alternative to expression).</summary>
PackRunLoopRange? Range,
/// <summary>Variable name bound to current item (default: "item").</summary>
string Iterator,
/// <summary>Variable name bound to current index (default: "index").</summary>
string Index,
/// <summary>Maximum iterations (safety limit).</summary>
int MaxIterations,
/// <summary>Aggregation mode for loop outputs.</summary>
PackRunLoopAggregationMode AggregationMode,
/// <summary>JMESPath to extract from each iteration result.</summary>
string? OutputPath)
{
public static PackRunLoopConfig Default => new(
null, null, null, "item", "index", 1000, PackRunLoopAggregationMode.Collect, null);
}
/// <summary>Range specification for loop iteration.</summary>
public sealed record PackRunLoopRange(int Start, int End, int Step = 1);
/// <summary>Loop output aggregation modes.</summary>
public enum PackRunLoopAggregationMode
{
/// <summary>Collect outputs into array.</summary>
Collect = 0,
/// <summary>Deep merge objects.</summary>
Merge,
/// <summary>Keep only last output.</summary>
Last,
/// <summary>Keep only first output.</summary>
First,
/// <summary>Discard outputs.</summary>
None
}
/// <summary>
/// Configuration for conditional steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunConditionalConfig(
/// <summary>Ordered branches (first matching executes).</summary>
IReadOnlyList<PackRunConditionalBranch> Branches,
/// <summary>Steps to execute if no branch matches.</summary>
IReadOnlyList<PackRunExecutionStep>? ElseBranch,
/// <summary>Whether to union outputs from all branches.</summary>
bool OutputUnion);
/// <summary>A conditional branch with condition and body.</summary>
public sealed record PackRunConditionalBranch(
/// <summary>Condition expression (JMESPath or operator-based).</summary>
string ConditionExpression,
/// <summary>Steps to execute if condition matches.</summary>
IReadOnlyList<PackRunExecutionStep> Body);
/// <summary>
/// Configuration for policy gate steps per taskpack-control-flow.schema.json.
/// </summary>
public sealed record PackRunPolicyGateConfig(
/// <summary>Policy identifier in the registry.</summary>
string PolicyId,
/// <summary>Specific policy version (semver).</summary>
string? PolicyVersion,
/// <summary>Policy digest for reproducibility.</summary>
string? PolicyDigest,
/// <summary>JMESPath expression to construct policy input.</summary>
string? InputExpression,
/// <summary>Timeout for policy evaluation.</summary>
TimeSpan Timeout,
/// <summary>What to do on policy failure.</summary>
PackRunPolicyFailureAction FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount,
/// <summary>Delay between retries.</summary>
TimeSpan RetryDelay,
/// <summary>Override approvers (if action is RequestOverride).</summary>
IReadOnlyList<string>? OverrideApprovers,
/// <summary>Step ID to branch to (if action is Branch).</summary>
string? BranchTo,
/// <summary>Whether to record decision in evidence locker.</summary>
bool RecordDecision,
/// <summary>Whether to record policy input.</summary>
bool RecordInput,
/// <summary>Whether to record rationale.</summary>
bool RecordRationale,
/// <summary>Whether to create DSSE attestation.</summary>
bool CreateAttestation)
{
public static PackRunPolicyGateConfig Default(string policyId) => new(
policyId, null, null, null,
TimeSpan.FromMinutes(5),
PackRunPolicyFailureAction.Abort, 0, TimeSpan.FromSeconds(10),
null, null, true, false, true, false);
}
/// <summary>Policy gate failure actions.</summary>
public enum PackRunPolicyFailureAction
{
/// <summary>Abort the run.</summary>
Abort = 0,
/// <summary>Log warning and continue.</summary>
Warn,
/// <summary>Request override approval.</summary>
RequestOverride,
/// <summary>Branch to specified step.</summary>
Branch
}

View File

@@ -1,243 +1,243 @@
using System.Collections.ObjectModel;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionGraphBuilder
{
public PackRunExecutionGraph Build(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var steps = plan.Steps.Select(ConvertStep).ToList();
var failurePolicy = plan.FailurePolicy;
return new PackRunExecutionGraph(steps, failurePolicy);
}
private static PackRunExecutionStep ConvertStep(TaskPackPlanStep step)
{
var kind = DetermineKind(step.Type);
var parameters = step.Parameters is null
? PackRunExecutionStep.EmptyParameters
: new ReadOnlyDictionary<string, TaskPackPlanParameterValue>(
new Dictionary<string, TaskPackPlanParameterValue>(step.Parameters, StringComparer.Ordinal));
var children = step.Children is null
? PackRunExecutionStep.EmptyChildren
: step.Children.Select(ConvertStep).ToList();
var maxParallel = TryGetInt(parameters, "maxParallel");
var continueOnError = TryGetBool(parameters, "continueOnError");
// Extract type-specific configurations
var loopConfig = kind == PackRunStepKind.Loop ? ExtractLoopConfig(parameters, children) : null;
var conditionalConfig = kind == PackRunStepKind.Conditional ? ExtractConditionalConfig(parameters, children) : null;
var policyGateConfig = kind == PackRunStepKind.GatePolicy ? ExtractPolicyGateConfig(parameters, step) : null;
return new PackRunExecutionStep(
step.Id,
step.TemplateId,
kind,
step.Enabled,
step.Uses,
parameters,
step.ApprovalId,
step.GateMessage,
maxParallel,
continueOnError,
children,
loopConfig,
conditionalConfig,
policyGateConfig);
}
private static PackRunStepKind DetermineKind(string? type)
=> type switch
{
"run" => PackRunStepKind.Run,
"gate.approval" => PackRunStepKind.GateApproval,
"gate.policy" => PackRunStepKind.GatePolicy,
"parallel" => PackRunStepKind.Parallel,
"map" => PackRunStepKind.Map,
"loop" => PackRunStepKind.Loop,
"conditional" => PackRunStepKind.Conditional,
_ => PackRunStepKind.Unknown
};
private static PackRunLoopConfig ExtractLoopConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var itemsExpression = TryGetString(parameters, "items");
var iterator = TryGetString(parameters, "iterator") ?? "item";
var index = TryGetString(parameters, "index") ?? "index";
var maxIterations = TryGetInt(parameters, "maxIterations") ?? 1000;
var aggregationMode = ParseAggregationMode(TryGetString(parameters, "aggregation"));
var outputPath = TryGetString(parameters, "outputPath");
// Parse range if present
PackRunLoopRange? range = null;
if (parameters.TryGetValue("range", out var rangeValue) && rangeValue.Value is JsonObject rangeObj)
{
var start = rangeObj["start"]?.GetValue<int>() ?? 0;
var end = rangeObj["end"]?.GetValue<int>() ?? 0;
var step = rangeObj["step"]?.GetValue<int>() ?? 1;
range = new PackRunLoopRange(start, end, step);
}
// Parse static items if present
IReadOnlyList<object>? staticItems = null;
if (parameters.TryGetValue("staticItems", out var staticValue) && staticValue.Value is JsonArray arr)
{
staticItems = arr.Select(n => (object)(n?.ToString() ?? "")).ToList();
}
return new PackRunLoopConfig(
itemsExpression, staticItems, range, iterator, index,
maxIterations, aggregationMode, outputPath);
}
private static PackRunConditionalConfig ExtractConditionalConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var branches = new List<PackRunConditionalBranch>();
IReadOnlyList<PackRunExecutionStep>? elseBranch = null;
var outputUnion = TryGetBool(parameters, "outputUnion");
// Parse branches from parameters
if (parameters.TryGetValue("branches", out var branchesValue) && branchesValue.Value is JsonArray branchArray)
{
foreach (var branchNode in branchArray)
{
if (branchNode is not JsonObject branchObj) continue;
var condition = branchObj["condition"]?.ToString() ?? "true";
var bodySteps = new List<PackRunExecutionStep>();
// Body would be parsed from the plan's children structure
// For now, use empty body - actual body comes from step children
branches.Add(new PackRunConditionalBranch(condition, bodySteps));
}
}
// If no explicit branches parsed, treat children as the primary branch body
if (branches.Count == 0 && children.Count > 0)
{
branches.Add(new PackRunConditionalBranch("true", children));
}
return new PackRunConditionalConfig(branches, elseBranch, outputUnion);
}
private static PackRunPolicyGateConfig? ExtractPolicyGateConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
TaskPackPlanStep step)
{
var policyId = TryGetString(parameters, "policyId") ?? TryGetString(parameters, "policy");
if (string.IsNullOrEmpty(policyId)) return null;
var policyVersion = TryGetString(parameters, "policyVersion");
var policyDigest = TryGetString(parameters, "policyDigest");
var inputExpression = TryGetString(parameters, "inputExpression");
var timeout = ParseTimeSpan(TryGetString(parameters, "timeout"), TimeSpan.FromMinutes(5));
var failureAction = ParsePolicyFailureAction(TryGetString(parameters, "failureAction"));
var retryCount = TryGetInt(parameters, "retryCount") ?? 0;
var retryDelay = ParseTimeSpan(TryGetString(parameters, "retryDelay"), TimeSpan.FromSeconds(10));
var recordDecision = TryGetBool(parameters, "recordDecision") || !parameters.ContainsKey("recordDecision");
var recordInput = TryGetBool(parameters, "recordInput");
var recordRationale = TryGetBool(parameters, "recordRationale") || !parameters.ContainsKey("recordRationale");
var createAttestation = TryGetBool(parameters, "attestation");
// Parse override approvers
IReadOnlyList<string>? overrideApprovers = null;
if (parameters.TryGetValue("overrideApprovers", out var approversValue) && approversValue.Value is JsonArray arr)
{
overrideApprovers = arr.Select(n => n?.ToString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList();
}
var branchTo = TryGetString(parameters, "branchTo");
return new PackRunPolicyGateConfig(
policyId, policyVersion, policyDigest, inputExpression,
timeout, failureAction, retryCount, retryDelay,
overrideApprovers, branchTo,
recordDecision, recordInput, recordRationale, createAttestation);
}
private static PackRunLoopAggregationMode ParseAggregationMode(string? mode)
=> mode?.ToLowerInvariant() switch
{
"collect" => PackRunLoopAggregationMode.Collect,
"merge" => PackRunLoopAggregationMode.Merge,
"last" => PackRunLoopAggregationMode.Last,
"first" => PackRunLoopAggregationMode.First,
"none" => PackRunLoopAggregationMode.None,
_ => PackRunLoopAggregationMode.Collect
};
private static PackRunPolicyFailureAction ParsePolicyFailureAction(string? action)
=> action?.ToLowerInvariant() switch
{
"abort" => PackRunPolicyFailureAction.Abort,
"warn" => PackRunPolicyFailureAction.Warn,
"requestoverride" => PackRunPolicyFailureAction.RequestOverride,
"branch" => PackRunPolicyFailureAction.Branch,
_ => PackRunPolicyFailureAction.Abort
};
private static TimeSpan ParseTimeSpan(string? value, TimeSpan defaultValue)
{
if (string.IsNullOrEmpty(value)) return defaultValue;
// Parse formats like "30s", "5m", "1h"
if (value.Length < 2) return defaultValue;
var unit = value[^1];
if (!int.TryParse(value[..^1], out var number)) return defaultValue;
return unit switch
{
's' => TimeSpan.FromSeconds(number),
'm' => TimeSpan.FromMinutes(number),
'h' => TimeSpan.FromHours(number),
'd' => TimeSpan.FromDays(number),
_ => defaultValue
};
}
private static int? TryGetInt(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
{
return null;
}
return jsonValue.TryGetValue<int>(out var result) ? result : null;
}
private static bool TryGetBool(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
{
return false;
}
return jsonValue.TryGetValue<bool>(out var result) && result;
}
private static string? TryGetString(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value))
{
return null;
}
return value.Value switch
{
JsonValue jsonValue when jsonValue.TryGetValue<string>(out var str) => str,
_ => value.Value?.ToString()
};
}
}
using System.Collections.ObjectModel;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunExecutionGraphBuilder
{
public PackRunExecutionGraph Build(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var steps = plan.Steps.Select(ConvertStep).ToList();
var failurePolicy = plan.FailurePolicy;
return new PackRunExecutionGraph(steps, failurePolicy);
}
private static PackRunExecutionStep ConvertStep(TaskPackPlanStep step)
{
var kind = DetermineKind(step.Type);
var parameters = step.Parameters is null
? PackRunExecutionStep.EmptyParameters
: new ReadOnlyDictionary<string, TaskPackPlanParameterValue>(
new Dictionary<string, TaskPackPlanParameterValue>(step.Parameters, StringComparer.Ordinal));
var children = step.Children is null
? PackRunExecutionStep.EmptyChildren
: step.Children.Select(ConvertStep).ToList();
var maxParallel = TryGetInt(parameters, "maxParallel");
var continueOnError = TryGetBool(parameters, "continueOnError");
// Extract type-specific configurations
var loopConfig = kind == PackRunStepKind.Loop ? ExtractLoopConfig(parameters, children) : null;
var conditionalConfig = kind == PackRunStepKind.Conditional ? ExtractConditionalConfig(parameters, children) : null;
var policyGateConfig = kind == PackRunStepKind.GatePolicy ? ExtractPolicyGateConfig(parameters, step) : null;
return new PackRunExecutionStep(
step.Id,
step.TemplateId,
kind,
step.Enabled,
step.Uses,
parameters,
step.ApprovalId,
step.GateMessage,
maxParallel,
continueOnError,
children,
loopConfig,
conditionalConfig,
policyGateConfig);
}
private static PackRunStepKind DetermineKind(string? type)
=> type switch
{
"run" => PackRunStepKind.Run,
"gate.approval" => PackRunStepKind.GateApproval,
"gate.policy" => PackRunStepKind.GatePolicy,
"parallel" => PackRunStepKind.Parallel,
"map" => PackRunStepKind.Map,
"loop" => PackRunStepKind.Loop,
"conditional" => PackRunStepKind.Conditional,
_ => PackRunStepKind.Unknown
};
private static PackRunLoopConfig ExtractLoopConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var itemsExpression = TryGetString(parameters, "items");
var iterator = TryGetString(parameters, "iterator") ?? "item";
var index = TryGetString(parameters, "index") ?? "index";
var maxIterations = TryGetInt(parameters, "maxIterations") ?? 1000;
var aggregationMode = ParseAggregationMode(TryGetString(parameters, "aggregation"));
var outputPath = TryGetString(parameters, "outputPath");
// Parse range if present
PackRunLoopRange? range = null;
if (parameters.TryGetValue("range", out var rangeValue) && rangeValue.Value is JsonObject rangeObj)
{
var start = rangeObj["start"]?.GetValue<int>() ?? 0;
var end = rangeObj["end"]?.GetValue<int>() ?? 0;
var step = rangeObj["step"]?.GetValue<int>() ?? 1;
range = new PackRunLoopRange(start, end, step);
}
// Parse static items if present
IReadOnlyList<object>? staticItems = null;
if (parameters.TryGetValue("staticItems", out var staticValue) && staticValue.Value is JsonArray arr)
{
staticItems = arr.Select(n => (object)(n?.ToString() ?? "")).ToList();
}
return new PackRunLoopConfig(
itemsExpression, staticItems, range, iterator, index,
maxIterations, aggregationMode, outputPath);
}
private static PackRunConditionalConfig ExtractConditionalConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
IReadOnlyList<PackRunExecutionStep> children)
{
var branches = new List<PackRunConditionalBranch>();
IReadOnlyList<PackRunExecutionStep>? elseBranch = null;
var outputUnion = TryGetBool(parameters, "outputUnion");
// Parse branches from parameters
if (parameters.TryGetValue("branches", out var branchesValue) && branchesValue.Value is JsonArray branchArray)
{
foreach (var branchNode in branchArray)
{
if (branchNode is not JsonObject branchObj) continue;
var condition = branchObj["condition"]?.ToString() ?? "true";
var bodySteps = new List<PackRunExecutionStep>();
// Body would be parsed from the plan's children structure
// For now, use empty body - actual body comes from step children
branches.Add(new PackRunConditionalBranch(condition, bodySteps));
}
}
// If no explicit branches parsed, treat children as the primary branch body
if (branches.Count == 0 && children.Count > 0)
{
branches.Add(new PackRunConditionalBranch("true", children));
}
return new PackRunConditionalConfig(branches, elseBranch, outputUnion);
}
private static PackRunPolicyGateConfig? ExtractPolicyGateConfig(
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
TaskPackPlanStep step)
{
var policyId = TryGetString(parameters, "policyId") ?? TryGetString(parameters, "policy");
if (string.IsNullOrEmpty(policyId)) return null;
var policyVersion = TryGetString(parameters, "policyVersion");
var policyDigest = TryGetString(parameters, "policyDigest");
var inputExpression = TryGetString(parameters, "inputExpression");
var timeout = ParseTimeSpan(TryGetString(parameters, "timeout"), TimeSpan.FromMinutes(5));
var failureAction = ParsePolicyFailureAction(TryGetString(parameters, "failureAction"));
var retryCount = TryGetInt(parameters, "retryCount") ?? 0;
var retryDelay = ParseTimeSpan(TryGetString(parameters, "retryDelay"), TimeSpan.FromSeconds(10));
var recordDecision = TryGetBool(parameters, "recordDecision") || !parameters.ContainsKey("recordDecision");
var recordInput = TryGetBool(parameters, "recordInput");
var recordRationale = TryGetBool(parameters, "recordRationale") || !parameters.ContainsKey("recordRationale");
var createAttestation = TryGetBool(parameters, "attestation");
// Parse override approvers
IReadOnlyList<string>? overrideApprovers = null;
if (parameters.TryGetValue("overrideApprovers", out var approversValue) && approversValue.Value is JsonArray arr)
{
overrideApprovers = arr.Select(n => n?.ToString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList();
}
var branchTo = TryGetString(parameters, "branchTo");
return new PackRunPolicyGateConfig(
policyId, policyVersion, policyDigest, inputExpression,
timeout, failureAction, retryCount, retryDelay,
overrideApprovers, branchTo,
recordDecision, recordInput, recordRationale, createAttestation);
}
private static PackRunLoopAggregationMode ParseAggregationMode(string? mode)
=> mode?.ToLowerInvariant() switch
{
"collect" => PackRunLoopAggregationMode.Collect,
"merge" => PackRunLoopAggregationMode.Merge,
"last" => PackRunLoopAggregationMode.Last,
"first" => PackRunLoopAggregationMode.First,
"none" => PackRunLoopAggregationMode.None,
_ => PackRunLoopAggregationMode.Collect
};
private static PackRunPolicyFailureAction ParsePolicyFailureAction(string? action)
=> action?.ToLowerInvariant() switch
{
"abort" => PackRunPolicyFailureAction.Abort,
"warn" => PackRunPolicyFailureAction.Warn,
"requestoverride" => PackRunPolicyFailureAction.RequestOverride,
"branch" => PackRunPolicyFailureAction.Branch,
_ => PackRunPolicyFailureAction.Abort
};
private static TimeSpan ParseTimeSpan(string? value, TimeSpan defaultValue)
{
if (string.IsNullOrEmpty(value)) return defaultValue;
// Parse formats like "30s", "5m", "1h"
if (value.Length < 2) return defaultValue;
var unit = value[^1];
if (!int.TryParse(value[..^1], out var number)) return defaultValue;
return unit switch
{
's' => TimeSpan.FromSeconds(number),
'm' => TimeSpan.FromMinutes(number),
'h' => TimeSpan.FromHours(number),
'd' => TimeSpan.FromDays(number),
_ => defaultValue
};
}
private static int? TryGetInt(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
{
return null;
}
return jsonValue.TryGetValue<int>(out var result) ? result : null;
}
private static bool TryGetBool(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value) || value.Value is not JsonValue jsonValue)
{
return false;
}
return jsonValue.TryGetValue<bool>(out var result) && result;
}
private static string? TryGetString(IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters, string key)
{
if (!parameters.TryGetValue(key, out var value))
{
return null;
}
return value.Value switch
{
JsonValue jsonValue when jsonValue.TryGetValue<string>(out var str) => str,
_ => value.Value?.ToString()
};
}
}

View File

@@ -1,159 +1,159 @@
using System.Collections.ObjectModel;
using System.Linq;
namespace StellaOps.TaskRunner.Core.Execution;
public static class PackRunGateStateUpdater
{
public static PackRunGateStateUpdateResult Apply(
PackRunState state,
PackRunExecutionGraph graph,
PackRunApprovalCoordinator coordinator,
DateTimeOffset timestamp)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(coordinator);
var approvals = coordinator.GetApprovals()
.SelectMany(approval => approval.StepIds.Select(stepId => (stepId, approval)))
.GroupBy(tuple => tuple.stepId, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.First().approval,
StringComparer.Ordinal);
var mutable = new Dictionary<string, PackRunStepStateRecord>(state.Steps, StringComparer.Ordinal);
var changed = false;
var hasBlockingFailure = false;
foreach (var step in EnumerateSteps(graph.Steps))
{
if (!mutable.TryGetValue(step.Id, out var record))
{
continue;
}
switch (step.Kind)
{
case PackRunStepKind.GateApproval:
if (!approvals.TryGetValue(step.Id, out var approvalState))
{
continue;
}
switch (approvalState.Status)
{
case PackRunApprovalStatus.Pending:
break;
case PackRunApprovalStatus.Approved:
if (record.Status != PackRunStepExecutionStatus.Succeeded || record.StatusReason is not null)
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
break;
case PackRunApprovalStatus.Rejected:
case PackRunApprovalStatus.Expired:
var failureReason = BuildFailureReason(approvalState);
if (record.Status != PackRunStepExecutionStatus.Failed ||
!string.Equals(record.StatusReason, failureReason, StringComparison.Ordinal))
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Failed,
StatusReason = failureReason,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
hasBlockingFailure = true;
break;
}
break;
case PackRunStepKind.GatePolicy:
if (record.Status == PackRunStepExecutionStatus.Pending &&
string.Equals(record.StatusReason, "requires-policy", StringComparison.Ordinal))
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
break;
}
}
if (!changed)
{
return new PackRunGateStateUpdateResult(state, hasBlockingFailure);
}
var updatedState = state with
{
UpdatedAt = timestamp,
Steps = new ReadOnlyDictionary<string, PackRunStepStateRecord>(mutable)
};
return new PackRunGateStateUpdateResult(updatedState, hasBlockingFailure);
}
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
{
if (steps.Count == 0)
{
yield break;
}
foreach (var step in steps)
{
yield return step;
if (step.Children.Count > 0)
{
foreach (var child in EnumerateSteps(step.Children))
{
yield return child;
}
}
}
}
private static string BuildFailureReason(PackRunApprovalState state)
{
var baseReason = state.Status switch
{
PackRunApprovalStatus.Rejected => "approval-rejected",
PackRunApprovalStatus.Expired => "approval-expired",
_ => "approval-invalid"
};
if (string.IsNullOrWhiteSpace(state.Summary))
{
return baseReason;
}
var summary = state.Summary.Trim();
return $"{baseReason}:{summary}";
}
}
public readonly record struct PackRunGateStateUpdateResult(PackRunState State, bool HasBlockingFailure);
using System.Collections.ObjectModel;
using System.Linq;
namespace StellaOps.TaskRunner.Core.Execution;
public static class PackRunGateStateUpdater
{
public static PackRunGateStateUpdateResult Apply(
PackRunState state,
PackRunExecutionGraph graph,
PackRunApprovalCoordinator coordinator,
DateTimeOffset timestamp)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(coordinator);
var approvals = coordinator.GetApprovals()
.SelectMany(approval => approval.StepIds.Select(stepId => (stepId, approval)))
.GroupBy(tuple => tuple.stepId, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.First().approval,
StringComparer.Ordinal);
var mutable = new Dictionary<string, PackRunStepStateRecord>(state.Steps, StringComparer.Ordinal);
var changed = false;
var hasBlockingFailure = false;
foreach (var step in EnumerateSteps(graph.Steps))
{
if (!mutable.TryGetValue(step.Id, out var record))
{
continue;
}
switch (step.Kind)
{
case PackRunStepKind.GateApproval:
if (!approvals.TryGetValue(step.Id, out var approvalState))
{
continue;
}
switch (approvalState.Status)
{
case PackRunApprovalStatus.Pending:
break;
case PackRunApprovalStatus.Approved:
if (record.Status != PackRunStepExecutionStatus.Succeeded || record.StatusReason is not null)
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
break;
case PackRunApprovalStatus.Rejected:
case PackRunApprovalStatus.Expired:
var failureReason = BuildFailureReason(approvalState);
if (record.Status != PackRunStepExecutionStatus.Failed ||
!string.Equals(record.StatusReason, failureReason, StringComparison.Ordinal))
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Failed,
StatusReason = failureReason,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
hasBlockingFailure = true;
break;
}
break;
case PackRunStepKind.GatePolicy:
if (record.Status == PackRunStepExecutionStatus.Pending &&
string.Equals(record.StatusReason, "requires-policy", StringComparison.Ordinal))
{
mutable[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = timestamp,
NextAttemptAt = null
};
changed = true;
}
break;
}
}
if (!changed)
{
return new PackRunGateStateUpdateResult(state, hasBlockingFailure);
}
var updatedState = state with
{
UpdatedAt = timestamp,
Steps = new ReadOnlyDictionary<string, PackRunStepStateRecord>(mutable)
};
return new PackRunGateStateUpdateResult(updatedState, hasBlockingFailure);
}
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
{
if (steps.Count == 0)
{
yield break;
}
foreach (var step in steps)
{
yield return step;
if (step.Children.Count > 0)
{
foreach (var child in EnumerateSteps(step.Children))
{
yield return child;
}
}
}
}
private static string BuildFailureReason(PackRunApprovalState state)
{
var baseReason = state.Status switch
{
PackRunApprovalStatus.Rejected => "approval-rejected",
PackRunApprovalStatus.Expired => "approval-expired",
_ => "approval-invalid"
};
if (string.IsNullOrWhiteSpace(state.Summary))
{
return baseReason;
}
var summary = state.Summary.Trim();
return $"{baseReason}:{summary}";
}
}
public readonly record struct PackRunGateStateUpdateResult(PackRunState State, bool HasBlockingFailure);

View File

@@ -1,84 +1,84 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunProcessor
{
private readonly IPackRunApprovalStore approvalStore;
private readonly IPackRunNotificationPublisher notificationPublisher;
private readonly ILogger<PackRunProcessor> logger;
public PackRunProcessor(
IPackRunApprovalStore approvalStore,
IPackRunNotificationPublisher notificationPublisher,
ILogger<PackRunProcessor> logger)
{
this.approvalStore = approvalStore ?? throw new ArgumentNullException(nameof(approvalStore));
this.notificationPublisher = notificationPublisher ?? throw new ArgumentNullException(nameof(notificationPublisher));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PackRunProcessorResult> ProcessNewRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var existing = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
PackRunApprovalCoordinator coordinator;
bool shouldResume;
if (existing.Count > 0)
{
coordinator = PackRunApprovalCoordinator.Restore(context.Plan, existing, context.RequestedAt);
shouldResume = !coordinator.HasPendingApprovals;
logger.LogInformation("Run {RunId} approvals restored (pending: {Pending}).", context.RunId, coordinator.HasPendingApprovals);
}
else
{
coordinator = PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
await approvalStore.SaveAsync(context.RunId, coordinator.GetApprovals(), cancellationToken).ConfigureAwait(false);
var approvalNotifications = coordinator.BuildNotifications(context.Plan);
foreach (var notification in approvalNotifications)
{
await notificationPublisher.PublishApprovalRequestedAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Approval requested for run {RunId} gate {ApprovalId} requiring grants {Grants}.",
context.RunId,
notification.ApprovalId,
string.Join(",", notification.RequiredGrants));
}
var policyNotifications = coordinator.BuildPolicyNotifications(context.Plan);
foreach (var notification in policyNotifications)
{
await notificationPublisher.PublishPolicyGatePendingAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
logger.LogDebug(
"Policy gate pending for run {RunId} step {StepId}.",
context.RunId,
notification.StepId);
}
shouldResume = !coordinator.HasPendingApprovals;
}
if (shouldResume)
{
logger.LogInformation("Run {RunId} has no approvals; proceeding immediately.", context.RunId);
}
return new PackRunProcessorResult(coordinator, shouldResume);
}
public async Task<PackRunApprovalCoordinator> RestoreAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var states = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
if (states.Count == 0)
{
return PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
}
return PackRunApprovalCoordinator.Restore(context.Plan, states, context.RequestedAt);
}
}
using Microsoft.Extensions.Logging;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed class PackRunProcessor
{
private readonly IPackRunApprovalStore approvalStore;
private readonly IPackRunNotificationPublisher notificationPublisher;
private readonly ILogger<PackRunProcessor> logger;
public PackRunProcessor(
IPackRunApprovalStore approvalStore,
IPackRunNotificationPublisher notificationPublisher,
ILogger<PackRunProcessor> logger)
{
this.approvalStore = approvalStore ?? throw new ArgumentNullException(nameof(approvalStore));
this.notificationPublisher = notificationPublisher ?? throw new ArgumentNullException(nameof(notificationPublisher));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<PackRunProcessorResult> ProcessNewRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var existing = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
PackRunApprovalCoordinator coordinator;
bool shouldResume;
if (existing.Count > 0)
{
coordinator = PackRunApprovalCoordinator.Restore(context.Plan, existing, context.RequestedAt);
shouldResume = !coordinator.HasPendingApprovals;
logger.LogInformation("Run {RunId} approvals restored (pending: {Pending}).", context.RunId, coordinator.HasPendingApprovals);
}
else
{
coordinator = PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
await approvalStore.SaveAsync(context.RunId, coordinator.GetApprovals(), cancellationToken).ConfigureAwait(false);
var approvalNotifications = coordinator.BuildNotifications(context.Plan);
foreach (var notification in approvalNotifications)
{
await notificationPublisher.PublishApprovalRequestedAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Approval requested for run {RunId} gate {ApprovalId} requiring grants {Grants}.",
context.RunId,
notification.ApprovalId,
string.Join(",", notification.RequiredGrants));
}
var policyNotifications = coordinator.BuildPolicyNotifications(context.Plan);
foreach (var notification in policyNotifications)
{
await notificationPublisher.PublishPolicyGatePendingAsync(context.RunId, notification, cancellationToken).ConfigureAwait(false);
logger.LogDebug(
"Policy gate pending for run {RunId} step {StepId}.",
context.RunId,
notification.StepId);
}
shouldResume = !coordinator.HasPendingApprovals;
}
if (shouldResume)
{
logger.LogInformation("Run {RunId} has no approvals; proceeding immediately.", context.RunId);
}
return new PackRunProcessorResult(coordinator, shouldResume);
}
public async Task<PackRunApprovalCoordinator> RestoreAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
var states = await approvalStore.GetAsync(context.RunId, cancellationToken).ConfigureAwait(false);
if (states.Count == 0)
{
return PackRunApprovalCoordinator.Create(context.Plan, context.RequestedAt);
}
return PackRunApprovalCoordinator.Restore(context.Plan, states, context.RequestedAt);
}
}

View File

@@ -1,5 +1,5 @@
namespace StellaOps.TaskRunner.Core.Execution;
public sealed record PackRunProcessorResult(
PackRunApprovalCoordinator ApprovalCoordinator,
bool ShouldResumeImmediately);
namespace StellaOps.TaskRunner.Core.Execution;
public sealed record PackRunProcessorResult(
PackRunApprovalCoordinator ApprovalCoordinator,
bool ShouldResumeImmediately);

View File

@@ -1,8 +1,8 @@
using System.Collections.ObjectModel;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
using System.Collections.ObjectModel;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed record PackRunState(
string RunId,
string PlanHash,
@@ -34,26 +34,26 @@ public sealed record PackRunState(
new ReadOnlyDictionary<string, PackRunStepStateRecord>(new Dictionary<string, PackRunStepStateRecord>(steps, StringComparer.Ordinal)),
tenantId);
}
public sealed record PackRunStepStateRecord(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
public interface IPackRunStateStore
{
Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken);
Task SaveAsync(PackRunState state, CancellationToken cancellationToken);
Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken);
}
public sealed record PackRunStepStateRecord(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
public interface IPackRunStateStore
{
Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken);
Task SaveAsync(PackRunState state, CancellationToken cancellationToken);
Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken);
}

View File

@@ -1,121 +1,121 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public static class PackRunStepStateMachine
{
public static PackRunStepState Create(DateTimeOffset? createdAt = null)
=> new(PackRunStepExecutionStatus.Pending, Attempts: 0, createdAt, NextAttemptAt: null);
public static PackRunStepState Start(PackRunStepState state, DateTimeOffset startedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Pending)
{
throw new InvalidOperationException($"Cannot start step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Running,
LastTransitionAt = startedAt,
NextAttemptAt = null
};
}
public static PackRunStepState CompleteSuccess(PackRunStepState state, DateTimeOffset completedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Running)
{
throw new InvalidOperationException($"Cannot complete step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Succeeded,
Attempts = state.Attempts + 1,
LastTransitionAt = completedAt,
NextAttemptAt = null
};
}
public static PackRunStepFailureResult RegisterFailure(
PackRunStepState state,
DateTimeOffset failedAt,
TaskPackPlanFailurePolicy failurePolicy)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(failurePolicy);
if (state.Status is not PackRunStepExecutionStatus.Running)
{
throw new InvalidOperationException($"Cannot register failure from status {state.Status}.");
}
var attempts = state.Attempts + 1;
if (attempts < failurePolicy.MaxAttempts)
{
var backoff = TimeSpan.FromSeconds(Math.Max(0, failurePolicy.BackoffSeconds));
var nextAttemptAt = failedAt + backoff;
var nextState = state with
{
Status = PackRunStepExecutionStatus.Pending,
Attempts = attempts,
LastTransitionAt = failedAt,
NextAttemptAt = nextAttemptAt
};
return new PackRunStepFailureResult(nextState, PackRunStepFailureOutcome.Retry);
}
var finalState = state with
{
Status = PackRunStepExecutionStatus.Failed,
Attempts = attempts,
LastTransitionAt = failedAt,
NextAttemptAt = null
};
return new PackRunStepFailureResult(finalState, PackRunStepFailureOutcome.Abort);
}
public static PackRunStepState Skip(PackRunStepState state, DateTimeOffset skippedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Pending)
{
throw new InvalidOperationException($"Cannot skip step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Skipped,
LastTransitionAt = skippedAt,
NextAttemptAt = null
};
}
}
public sealed record PackRunStepState(
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt);
public enum PackRunStepExecutionStatus
{
Pending = 0,
Running,
Succeeded,
Failed,
Skipped
}
public readonly record struct PackRunStepFailureResult(PackRunStepState State, PackRunStepFailureOutcome Outcome);
public enum PackRunStepFailureOutcome
{
Retry = 0,
Abort
}
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public static class PackRunStepStateMachine
{
public static PackRunStepState Create(DateTimeOffset? createdAt = null)
=> new(PackRunStepExecutionStatus.Pending, Attempts: 0, createdAt, NextAttemptAt: null);
public static PackRunStepState Start(PackRunStepState state, DateTimeOffset startedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Pending)
{
throw new InvalidOperationException($"Cannot start step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Running,
LastTransitionAt = startedAt,
NextAttemptAt = null
};
}
public static PackRunStepState CompleteSuccess(PackRunStepState state, DateTimeOffset completedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Running)
{
throw new InvalidOperationException($"Cannot complete step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Succeeded,
Attempts = state.Attempts + 1,
LastTransitionAt = completedAt,
NextAttemptAt = null
};
}
public static PackRunStepFailureResult RegisterFailure(
PackRunStepState state,
DateTimeOffset failedAt,
TaskPackPlanFailurePolicy failurePolicy)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(failurePolicy);
if (state.Status is not PackRunStepExecutionStatus.Running)
{
throw new InvalidOperationException($"Cannot register failure from status {state.Status}.");
}
var attempts = state.Attempts + 1;
if (attempts < failurePolicy.MaxAttempts)
{
var backoff = TimeSpan.FromSeconds(Math.Max(0, failurePolicy.BackoffSeconds));
var nextAttemptAt = failedAt + backoff;
var nextState = state with
{
Status = PackRunStepExecutionStatus.Pending,
Attempts = attempts,
LastTransitionAt = failedAt,
NextAttemptAt = nextAttemptAt
};
return new PackRunStepFailureResult(nextState, PackRunStepFailureOutcome.Retry);
}
var finalState = state with
{
Status = PackRunStepExecutionStatus.Failed,
Attempts = attempts,
LastTransitionAt = failedAt,
NextAttemptAt = null
};
return new PackRunStepFailureResult(finalState, PackRunStepFailureOutcome.Abort);
}
public static PackRunStepState Skip(PackRunStepState state, DateTimeOffset skippedAt)
{
ArgumentNullException.ThrowIfNull(state);
if (state.Status is not PackRunStepExecutionStatus.Pending)
{
throw new InvalidOperationException($"Cannot skip step from status {state.Status}.");
}
return state with
{
Status = PackRunStepExecutionStatus.Skipped,
LastTransitionAt = skippedAt,
NextAttemptAt = null
};
}
}
public sealed record PackRunStepState(
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt);
public enum PackRunStepExecutionStatus
{
Pending = 0,
Running,
Succeeded,
Failed,
Skipped
}
public readonly record struct PackRunStepFailureResult(PackRunStepState State, PackRunStepFailureOutcome Outcome);
public enum PackRunStepFailureOutcome
{
Retry = 0,
Abort
}

View File

@@ -1,109 +1,109 @@
using System.Collections.ObjectModel;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution.Simulation;
public sealed class PackRunSimulationEngine
{
private readonly PackRunExecutionGraphBuilder graphBuilder;
public PackRunSimulationEngine()
{
graphBuilder = new PackRunExecutionGraphBuilder();
}
public PackRunSimulationResult Simulate(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var graph = graphBuilder.Build(plan);
var steps = graph.Steps.Select(ConvertStep).ToList();
var outputs = BuildOutputs(plan.Outputs);
return new PackRunSimulationResult(steps, outputs, graph.FailurePolicy);
}
private static PackRunSimulationNode ConvertStep(PackRunExecutionStep step)
{
var status = DetermineStatus(step);
var children = step.Children.Count == 0
? PackRunSimulationNode.Empty
: new ReadOnlyCollection<PackRunSimulationNode>(step.Children.Select(ConvertStep).ToList());
// Extract loop/conditional specific details
var loopInfo = step.Kind == PackRunStepKind.Loop && step.LoopConfig is not null
? new PackRunSimulationLoopInfo(
step.LoopConfig.ItemsExpression,
step.LoopConfig.Iterator,
step.LoopConfig.Index,
step.LoopConfig.MaxIterations,
step.LoopConfig.AggregationMode.ToString().ToLowerInvariant())
: null;
var conditionalInfo = step.Kind == PackRunStepKind.Conditional && step.ConditionalConfig is not null
? new PackRunSimulationConditionalInfo(
step.ConditionalConfig.Branches.Select(b =>
new PackRunSimulationBranch(b.ConditionExpression, b.Body.Count)).ToList(),
step.ConditionalConfig.ElseBranch?.Count ?? 0,
step.ConditionalConfig.OutputUnion)
: null;
var policyInfo = step.Kind == PackRunStepKind.GatePolicy && step.PolicyGateConfig is not null
? new PackRunSimulationPolicyInfo(
step.PolicyGateConfig.PolicyId,
step.PolicyGateConfig.PolicyVersion,
step.PolicyGateConfig.FailureAction.ToString().ToLowerInvariant(),
step.PolicyGateConfig.RetryCount)
: null;
return new PackRunSimulationNode(
step.Id,
step.TemplateId,
step.Kind,
step.Enabled,
step.Uses,
step.ApprovalId,
step.GateMessage,
step.Parameters,
step.MaxParallel,
step.ContinueOnError,
status,
children,
loopInfo,
conditionalInfo,
policyInfo);
}
private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step)
{
if (!step.Enabled)
{
return PackRunSimulationStatus.Skipped;
}
return step.Kind switch
{
PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval,
PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy,
PackRunStepKind.Loop => PackRunSimulationStatus.WillIterate,
PackRunStepKind.Conditional => PackRunSimulationStatus.WillBranch,
_ => PackRunSimulationStatus.Pending
};
}
private static IReadOnlyList<PackRunSimulationOutput> BuildOutputs(IReadOnlyList<TaskPackPlanOutput> outputs)
{
if (outputs.Count == 0)
{
return PackRunSimulationOutput.Empty;
}
var list = new List<PackRunSimulationOutput>(outputs.Count);
foreach (var output in outputs)
{
list.Add(new PackRunSimulationOutput(output.Name, output.Type, output.Path, output.Expression));
}
return new ReadOnlyCollection<PackRunSimulationOutput>(list);
}
}
using System.Collections.ObjectModel;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution.Simulation;
public sealed class PackRunSimulationEngine
{
private readonly PackRunExecutionGraphBuilder graphBuilder;
public PackRunSimulationEngine()
{
graphBuilder = new PackRunExecutionGraphBuilder();
}
public PackRunSimulationResult Simulate(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var graph = graphBuilder.Build(plan);
var steps = graph.Steps.Select(ConvertStep).ToList();
var outputs = BuildOutputs(plan.Outputs);
return new PackRunSimulationResult(steps, outputs, graph.FailurePolicy);
}
private static PackRunSimulationNode ConvertStep(PackRunExecutionStep step)
{
var status = DetermineStatus(step);
var children = step.Children.Count == 0
? PackRunSimulationNode.Empty
: new ReadOnlyCollection<PackRunSimulationNode>(step.Children.Select(ConvertStep).ToList());
// Extract loop/conditional specific details
var loopInfo = step.Kind == PackRunStepKind.Loop && step.LoopConfig is not null
? new PackRunSimulationLoopInfo(
step.LoopConfig.ItemsExpression,
step.LoopConfig.Iterator,
step.LoopConfig.Index,
step.LoopConfig.MaxIterations,
step.LoopConfig.AggregationMode.ToString().ToLowerInvariant())
: null;
var conditionalInfo = step.Kind == PackRunStepKind.Conditional && step.ConditionalConfig is not null
? new PackRunSimulationConditionalInfo(
step.ConditionalConfig.Branches.Select(b =>
new PackRunSimulationBranch(b.ConditionExpression, b.Body.Count)).ToList(),
step.ConditionalConfig.ElseBranch?.Count ?? 0,
step.ConditionalConfig.OutputUnion)
: null;
var policyInfo = step.Kind == PackRunStepKind.GatePolicy && step.PolicyGateConfig is not null
? new PackRunSimulationPolicyInfo(
step.PolicyGateConfig.PolicyId,
step.PolicyGateConfig.PolicyVersion,
step.PolicyGateConfig.FailureAction.ToString().ToLowerInvariant(),
step.PolicyGateConfig.RetryCount)
: null;
return new PackRunSimulationNode(
step.Id,
step.TemplateId,
step.Kind,
step.Enabled,
step.Uses,
step.ApprovalId,
step.GateMessage,
step.Parameters,
step.MaxParallel,
step.ContinueOnError,
status,
children,
loopInfo,
conditionalInfo,
policyInfo);
}
private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step)
{
if (!step.Enabled)
{
return PackRunSimulationStatus.Skipped;
}
return step.Kind switch
{
PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval,
PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy,
PackRunStepKind.Loop => PackRunSimulationStatus.WillIterate,
PackRunStepKind.Conditional => PackRunSimulationStatus.WillBranch,
_ => PackRunSimulationStatus.Pending
};
}
private static IReadOnlyList<PackRunSimulationOutput> BuildOutputs(IReadOnlyList<TaskPackPlanOutput> outputs)
{
if (outputs.Count == 0)
{
return PackRunSimulationOutput.Empty;
}
var list = new List<PackRunSimulationOutput>(outputs.Count);
foreach (var output in outputs)
{
list.Add(new PackRunSimulationOutput(output.Name, output.Type, output.Path, output.Expression));
}
return new ReadOnlyCollection<PackRunSimulationOutput>(list);
}
}

View File

@@ -1,190 +1,190 @@
using System.Collections.ObjectModel;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution.Simulation;
public sealed class PackRunSimulationResult
{
public PackRunSimulationResult(
IReadOnlyList<PackRunSimulationNode> steps,
IReadOnlyList<PackRunSimulationOutput> outputs,
TaskPackPlanFailurePolicy failurePolicy)
{
Steps = steps ?? throw new ArgumentNullException(nameof(steps));
Outputs = outputs ?? throw new ArgumentNullException(nameof(outputs));
FailurePolicy = failurePolicy ?? throw new ArgumentNullException(nameof(failurePolicy));
}
public IReadOnlyList<PackRunSimulationNode> Steps { get; }
public IReadOnlyList<PackRunSimulationOutput> Outputs { get; }
public TaskPackPlanFailurePolicy FailurePolicy { get; }
public bool HasPendingApprovals => Steps.Any(ContainsApprovalRequirement);
private static bool ContainsApprovalRequirement(PackRunSimulationNode node)
{
if (node.Status is PackRunSimulationStatus.RequiresApproval or PackRunSimulationStatus.RequiresPolicy)
{
return true;
}
return node.Children.Any(ContainsApprovalRequirement);
}
}
public sealed class PackRunSimulationNode
{
public PackRunSimulationNode(
string id,
string templateId,
PackRunStepKind kind,
bool enabled,
string? uses,
string? approvalId,
string? gateMessage,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
int? maxParallel,
bool continueOnError,
PackRunSimulationStatus status,
IReadOnlyList<PackRunSimulationNode> children,
PackRunSimulationLoopInfo? loopInfo = null,
PackRunSimulationConditionalInfo? conditionalInfo = null,
PackRunSimulationPolicyInfo? policyInfo = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
Kind = kind;
Enabled = enabled;
Uses = uses;
ApprovalId = approvalId;
GateMessage = gateMessage;
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
MaxParallel = maxParallel;
ContinueOnError = continueOnError;
Status = status;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopInfo = loopInfo;
ConditionalInfo = conditionalInfo;
PolicyInfo = policyInfo;
}
public string Id { get; }
public string TemplateId { get; }
public PackRunStepKind Kind { get; }
public bool Enabled { get; }
public string? Uses { get; }
public string? ApprovalId { get; }
public string? GateMessage { get; }
public IReadOnlyDictionary<string, TaskPackPlanParameterValue> Parameters { get; }
public int? MaxParallel { get; }
public bool ContinueOnError { get; }
public PackRunSimulationStatus Status { get; }
public IReadOnlyList<PackRunSimulationNode> Children { get; }
/// <summary>Loop step simulation info (when Kind == Loop).</summary>
public PackRunSimulationLoopInfo? LoopInfo { get; }
/// <summary>Conditional step simulation info (when Kind == Conditional).</summary>
public PackRunSimulationConditionalInfo? ConditionalInfo { get; }
/// <summary>Policy gate simulation info (when Kind == GatePolicy).</summary>
public PackRunSimulationPolicyInfo? PolicyInfo { get; }
public static IReadOnlyList<PackRunSimulationNode> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationNode>(Array.Empty<PackRunSimulationNode>());
}
public enum PackRunSimulationStatus
{
Pending = 0,
Skipped,
RequiresApproval,
RequiresPolicy,
/// <summary>Loop step will iterate over items.</summary>
WillIterate,
/// <summary>Conditional step will branch based on conditions.</summary>
WillBranch
}
/// <summary>Loop step simulation details.</summary>
public sealed record PackRunSimulationLoopInfo(
/// <summary>Items expression to iterate over.</summary>
string? ItemsExpression,
/// <summary>Iterator variable name.</summary>
string Iterator,
/// <summary>Index variable name.</summary>
string Index,
/// <summary>Maximum iterations allowed.</summary>
int MaxIterations,
/// <summary>Aggregation mode for outputs.</summary>
string AggregationMode);
/// <summary>Conditional step simulation details.</summary>
public sealed record PackRunSimulationConditionalInfo(
/// <summary>Branch conditions and body step counts.</summary>
IReadOnlyList<PackRunSimulationBranch> Branches,
/// <summary>Number of steps in else branch.</summary>
int ElseStepCount,
/// <summary>Whether outputs are unioned.</summary>
bool OutputUnion);
/// <summary>A conditional branch summary.</summary>
public sealed record PackRunSimulationBranch(
/// <summary>Condition expression.</summary>
string Condition,
/// <summary>Number of steps in body.</summary>
int StepCount);
/// <summary>Policy gate simulation details.</summary>
public sealed record PackRunSimulationPolicyInfo(
/// <summary>Policy identifier.</summary>
string PolicyId,
/// <summary>Policy version (if specified).</summary>
string? PolicyVersion,
/// <summary>Failure action.</summary>
string FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount);
public sealed class PackRunSimulationOutput
{
public PackRunSimulationOutput(
string name,
string type,
TaskPackPlanParameterValue? path,
TaskPackPlanParameterValue? expression)
{
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)) : name;
Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(type)) : type;
Path = path;
Expression = expression;
}
public string Name { get; }
public string Type { get; }
public TaskPackPlanParameterValue? Path { get; }
public TaskPackPlanParameterValue? Expression { get; }
public bool RequiresRuntimeValue =>
(Path?.RequiresRuntimeValue ?? false) ||
(Expression?.RequiresRuntimeValue ?? false);
public static IReadOnlyList<PackRunSimulationOutput> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationOutput>(Array.Empty<PackRunSimulationOutput>());
}
using System.Collections.ObjectModel;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution.Simulation;
public sealed class PackRunSimulationResult
{
public PackRunSimulationResult(
IReadOnlyList<PackRunSimulationNode> steps,
IReadOnlyList<PackRunSimulationOutput> outputs,
TaskPackPlanFailurePolicy failurePolicy)
{
Steps = steps ?? throw new ArgumentNullException(nameof(steps));
Outputs = outputs ?? throw new ArgumentNullException(nameof(outputs));
FailurePolicy = failurePolicy ?? throw new ArgumentNullException(nameof(failurePolicy));
}
public IReadOnlyList<PackRunSimulationNode> Steps { get; }
public IReadOnlyList<PackRunSimulationOutput> Outputs { get; }
public TaskPackPlanFailurePolicy FailurePolicy { get; }
public bool HasPendingApprovals => Steps.Any(ContainsApprovalRequirement);
private static bool ContainsApprovalRequirement(PackRunSimulationNode node)
{
if (node.Status is PackRunSimulationStatus.RequiresApproval or PackRunSimulationStatus.RequiresPolicy)
{
return true;
}
return node.Children.Any(ContainsApprovalRequirement);
}
}
public sealed class PackRunSimulationNode
{
public PackRunSimulationNode(
string id,
string templateId,
PackRunStepKind kind,
bool enabled,
string? uses,
string? approvalId,
string? gateMessage,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
int? maxParallel,
bool continueOnError,
PackRunSimulationStatus status,
IReadOnlyList<PackRunSimulationNode> children,
PackRunSimulationLoopInfo? loopInfo = null,
PackRunSimulationConditionalInfo? conditionalInfo = null,
PackRunSimulationPolicyInfo? policyInfo = null)
{
Id = string.IsNullOrWhiteSpace(id) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(id)) : id;
TemplateId = string.IsNullOrWhiteSpace(templateId) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(templateId)) : templateId;
Kind = kind;
Enabled = enabled;
Uses = uses;
ApprovalId = approvalId;
GateMessage = gateMessage;
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
MaxParallel = maxParallel;
ContinueOnError = continueOnError;
Status = status;
Children = children ?? throw new ArgumentNullException(nameof(children));
LoopInfo = loopInfo;
ConditionalInfo = conditionalInfo;
PolicyInfo = policyInfo;
}
public string Id { get; }
public string TemplateId { get; }
public PackRunStepKind Kind { get; }
public bool Enabled { get; }
public string? Uses { get; }
public string? ApprovalId { get; }
public string? GateMessage { get; }
public IReadOnlyDictionary<string, TaskPackPlanParameterValue> Parameters { get; }
public int? MaxParallel { get; }
public bool ContinueOnError { get; }
public PackRunSimulationStatus Status { get; }
public IReadOnlyList<PackRunSimulationNode> Children { get; }
/// <summary>Loop step simulation info (when Kind == Loop).</summary>
public PackRunSimulationLoopInfo? LoopInfo { get; }
/// <summary>Conditional step simulation info (when Kind == Conditional).</summary>
public PackRunSimulationConditionalInfo? ConditionalInfo { get; }
/// <summary>Policy gate simulation info (when Kind == GatePolicy).</summary>
public PackRunSimulationPolicyInfo? PolicyInfo { get; }
public static IReadOnlyList<PackRunSimulationNode> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationNode>(Array.Empty<PackRunSimulationNode>());
}
public enum PackRunSimulationStatus
{
Pending = 0,
Skipped,
RequiresApproval,
RequiresPolicy,
/// <summary>Loop step will iterate over items.</summary>
WillIterate,
/// <summary>Conditional step will branch based on conditions.</summary>
WillBranch
}
/// <summary>Loop step simulation details.</summary>
public sealed record PackRunSimulationLoopInfo(
/// <summary>Items expression to iterate over.</summary>
string? ItemsExpression,
/// <summary>Iterator variable name.</summary>
string Iterator,
/// <summary>Index variable name.</summary>
string Index,
/// <summary>Maximum iterations allowed.</summary>
int MaxIterations,
/// <summary>Aggregation mode for outputs.</summary>
string AggregationMode);
/// <summary>Conditional step simulation details.</summary>
public sealed record PackRunSimulationConditionalInfo(
/// <summary>Branch conditions and body step counts.</summary>
IReadOnlyList<PackRunSimulationBranch> Branches,
/// <summary>Number of steps in else branch.</summary>
int ElseStepCount,
/// <summary>Whether outputs are unioned.</summary>
bool OutputUnion);
/// <summary>A conditional branch summary.</summary>
public sealed record PackRunSimulationBranch(
/// <summary>Condition expression.</summary>
string Condition,
/// <summary>Number of steps in body.</summary>
int StepCount);
/// <summary>Policy gate simulation details.</summary>
public sealed record PackRunSimulationPolicyInfo(
/// <summary>Policy identifier.</summary>
string PolicyId,
/// <summary>Policy version (if specified).</summary>
string? PolicyVersion,
/// <summary>Failure action.</summary>
string FailureAction,
/// <summary>Retry count on failure.</summary>
int RetryCount);
public sealed class PackRunSimulationOutput
{
public PackRunSimulationOutput(
string name,
string type,
TaskPackPlanParameterValue? path,
TaskPackPlanParameterValue? expression)
{
Name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(name)) : name;
Type = string.IsNullOrWhiteSpace(type) ? throw new ArgumentException("Value cannot be null or whitespace.", nameof(type)) : type;
Path = path;
Expression = expression;
}
public string Name { get; }
public string Type { get; }
public TaskPackPlanParameterValue? Path { get; }
public TaskPackPlanParameterValue? Expression { get; }
public bool RequiresRuntimeValue =>
(Path?.RequiresRuntimeValue ?? false) ||
(Expression?.RequiresRuntimeValue ?? false);
public static IReadOnlyList<PackRunSimulationOutput> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationOutput>(Array.Empty<PackRunSimulationOutput>());
}

View File

@@ -1,17 +1,17 @@
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Expressions;
namespace StellaOps.TaskRunner.Core.Planning;
public sealed class TaskPackPlan
{
public TaskPackPlan(
TaskPackPlanMetadata metadata,
IReadOnlyDictionary<string, JsonNode?> inputs,
IReadOnlyList<TaskPackPlanStep> steps,
string hash,
IReadOnlyList<TaskPackPlanApproval> approvals,
using System.Collections.Immutable;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Expressions;
namespace StellaOps.TaskRunner.Core.Planning;
public sealed class TaskPackPlan
{
public TaskPackPlan(
TaskPackPlanMetadata metadata,
IReadOnlyDictionary<string, JsonNode?> inputs,
IReadOnlyList<TaskPackPlanStep> steps,
string hash,
IReadOnlyList<TaskPackPlanApproval> approvals,
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs,
TaskPackPlanFailurePolicy? failurePolicy)
@@ -29,12 +29,12 @@ public sealed class TaskPackPlan
public TaskPackPlanMetadata Metadata { get; }
public IReadOnlyDictionary<string, JsonNode?> Inputs { get; }
public IReadOnlyList<TaskPackPlanStep> Steps { get; }
public string Hash { get; }
public IReadOnlyList<TaskPackPlanApproval> Approvals { get; }
public IReadOnlyList<TaskPackPlanStep> Steps { get; }
public string Hash { get; }
public IReadOnlyList<TaskPackPlanApproval> Approvals { get; }
public IReadOnlyList<TaskPackPlanSecret> Secrets { get; }
@@ -46,35 +46,35 @@ public sealed class TaskPackPlan
public sealed record TaskPackPlanMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
public sealed record TaskPackPlanStep(
string Id,
string TemplateId,
string? Name,
string Type,
bool Enabled,
string? Uses,
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? Parameters,
string? ApprovalId,
string? GateMessage,
IReadOnlyList<TaskPackPlanStep>? Children);
public sealed record TaskPackPlanParameterValue(
JsonNode? Value,
string? Expression,
string? Error,
bool RequiresRuntimeValue)
{
internal static TaskPackPlanParameterValue FromResolution(TaskPackValueResolution resolution)
=> new(resolution.Value, resolution.Expression, resolution.Error, resolution.RequiresRuntimeValue);
}
public sealed record TaskPackPlanApproval(
string Id,
IReadOnlyList<string> Grants,
string? ExpiresAfter,
string? ReasonTemplate);
public sealed record TaskPackPlanSecret(string Name, string Scope, string? Description);
string Id,
string TemplateId,
string? Name,
string Type,
bool Enabled,
string? Uses,
IReadOnlyDictionary<string, TaskPackPlanParameterValue>? Parameters,
string? ApprovalId,
string? GateMessage,
IReadOnlyList<TaskPackPlanStep>? Children);
public sealed record TaskPackPlanParameterValue(
JsonNode? Value,
string? Expression,
string? Error,
bool RequiresRuntimeValue)
{
internal static TaskPackPlanParameterValue FromResolution(TaskPackValueResolution resolution)
=> new(resolution.Value, resolution.Expression, resolution.Error, resolution.RequiresRuntimeValue);
}
public sealed record TaskPackPlanApproval(
string Id,
IReadOnlyList<string> Grants,
string? ExpiresAfter,
string? ReasonTemplate);
public sealed record TaskPackPlanSecret(string Name, string Scope, string? Description);
public sealed record TaskPackPlanOutput(
string Name,
string Type,
@@ -85,20 +85,20 @@ public sealed record TaskPackPlanFailurePolicy(
int MaxAttempts,
int BackoffSeconds,
bool ContinueOnError);
public sealed class TaskPackPlanResult
{
public TaskPackPlanResult(TaskPackPlan? plan, ImmutableArray<TaskPackPlanError> errors)
{
Plan = plan;
Errors = errors;
}
public TaskPackPlan? Plan { get; }
public ImmutableArray<TaskPackPlanError> Errors { get; }
public bool Success => Plan is not null && Errors.IsDefaultOrEmpty;
}
public sealed record TaskPackPlanError(string Path, string Message);
public sealed class TaskPackPlanResult
{
public TaskPackPlanResult(TaskPackPlan? plan, ImmutableArray<TaskPackPlanError> errors)
{
Plan = plan;
Errors = errors;
}
public TaskPackPlan? Plan { get; }
public ImmutableArray<TaskPackPlanError> Errors { get; }
public bool Success => Plan is not null && Errors.IsDefaultOrEmpty;
}
public sealed record TaskPackPlanError(string Path, string Message);

View File

@@ -1,18 +1,18 @@
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Serialization;
namespace StellaOps.TaskRunner.Core.Planning;
internal static class TaskPackPlanHasher
{
public static string ComputeHash(
TaskPackPlanMetadata metadata,
IReadOnlyDictionary<string, JsonNode?> inputs,
IReadOnlyList<TaskPackPlanStep> steps,
IReadOnlyList<TaskPackPlanApproval> approvals,
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Serialization;
namespace StellaOps.TaskRunner.Core.Planning;
internal static class TaskPackPlanHasher
{
public static string ComputeHash(
TaskPackPlanMetadata metadata,
IReadOnlyDictionary<string, JsonNode?> inputs,
IReadOnlyList<TaskPackPlanStep> steps,
IReadOnlyList<TaskPackPlanApproval> approvals,
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs,
TaskPackPlanFailurePolicy? failurePolicy)
@@ -21,13 +21,13 @@ internal static class TaskPackPlanHasher
new CanonicalMetadata(metadata.Name, metadata.Version, metadata.Description, metadata.Tags),
inputs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal),
steps.Select(ToCanonicalStep).ToList(),
approvals
.OrderBy(a => a.Id, StringComparer.Ordinal)
.Select(a => new CanonicalApproval(a.Id, a.Grants.OrderBy(g => g, StringComparer.Ordinal).ToList(), a.ExpiresAfter, a.ReasonTemplate))
.ToList(),
secrets
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => new CanonicalSecret(s.Name, s.Scope, s.Description))
approvals
.OrderBy(a => a.Id, StringComparer.Ordinal)
.Select(a => new CanonicalApproval(a.Id, a.Grants.OrderBy(g => g, StringComparer.Ordinal).ToList(), a.ExpiresAfter, a.ReasonTemplate))
.ToList(),
secrets
.OrderBy(s => s.Name, StringComparer.Ordinal)
.Select(s => new CanonicalSecret(s.Name, s.Scope, s.Description))
.ToList(),
outputs
.OrderBy(o => o.Name, StringComparer.Ordinal)
@@ -41,35 +41,35 @@ internal static class TaskPackPlanHasher
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
return $"sha256:{ConvertToHex(hashBytes)}";
}
private static string ConvertToHex(byte[] hashBytes)
{
var builder = new StringBuilder(hashBytes.Length * 2);
foreach (var b in hashBytes)
{
builder.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private static CanonicalPlanStep ToCanonicalStep(TaskPackPlanStep step)
=> new(
step.Id,
step.TemplateId,
step.Name,
step.Type,
step.Enabled,
step.Uses,
step.Parameters?.ToDictionary(
kvp => kvp.Key,
kvp => new CanonicalParameter(kvp.Value.Value, kvp.Value.Expression, kvp.Value.Error, kvp.Value.RequiresRuntimeValue),
StringComparer.Ordinal),
step.ApprovalId,
step.GateMessage,
step.Children?.Select(ToCanonicalStep).ToList());
}
private static string ConvertToHex(byte[] hashBytes)
{
var builder = new StringBuilder(hashBytes.Length * 2);
foreach (var b in hashBytes)
{
builder.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture));
}
return builder.ToString();
}
private static CanonicalPlanStep ToCanonicalStep(TaskPackPlanStep step)
=> new(
step.Id,
step.TemplateId,
step.Name,
step.Type,
step.Enabled,
step.Uses,
step.Parameters?.ToDictionary(
kvp => kvp.Key,
kvp => new CanonicalParameter(kvp.Value.Value, kvp.Value.Expression, kvp.Value.Error, kvp.Value.RequiresRuntimeValue),
StringComparer.Ordinal),
step.ApprovalId,
step.GateMessage,
step.Children?.Select(ToCanonicalStep).ToList());
private sealed record CanonicalPlan(
CanonicalMetadata Metadata,
IDictionary<string, JsonNode?> Inputs,
@@ -78,25 +78,25 @@ internal static class TaskPackPlanHasher
IReadOnlyList<CanonicalSecret> Secrets,
IReadOnlyList<CanonicalOutput> Outputs,
CanonicalFailurePolicy? FailurePolicy);
private sealed record CanonicalMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
private sealed record CanonicalPlanStep(
string Id,
string TemplateId,
string? Name,
string Type,
bool Enabled,
string? Uses,
IDictionary<string, CanonicalParameter>? Parameters,
string? ApprovalId,
string? GateMessage,
IReadOnlyList<CanonicalPlanStep>? Children);
private sealed record CanonicalApproval(string Id, IReadOnlyList<string> Grants, string? ExpiresAfter, string? ReasonTemplate);
private sealed record CanonicalSecret(string Name, string Scope, string? Description);
private sealed record CanonicalMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
private sealed record CanonicalPlanStep(
string Id,
string TemplateId,
string? Name,
string Type,
bool Enabled,
string? Uses,
IDictionary<string, CanonicalParameter>? Parameters,
string? ApprovalId,
string? GateMessage,
IReadOnlyList<CanonicalPlanStep>? Children);
private sealed record CanonicalApproval(string Id, IReadOnlyList<string> Grants, string? ExpiresAfter, string? ReasonTemplate);
private sealed record CanonicalSecret(string Name, string Scope, string? Description);
private sealed record CanonicalParameter(JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue);
private sealed record CanonicalOutput(
@@ -106,14 +106,14 @@ internal static class TaskPackPlanHasher
CanonicalParameter? Expression);
private sealed record CanonicalFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError);
private static CanonicalOutput ToCanonicalOutput(TaskPackPlanOutput output)
=> new(
output.Name,
output.Type,
ToCanonicalParameter(output.Path),
ToCanonicalParameter(output.Expression));
private static CanonicalParameter? ToCanonicalParameter(TaskPackPlanParameterValue? value)
=> value is null ? null : new CanonicalParameter(value.Value, value.Expression, value.Error, value.RequiresRuntimeValue);
}
private static CanonicalOutput ToCanonicalOutput(TaskPackPlanOutput output)
=> new(
output.Name,
output.Type,
ToCanonicalParameter(output.Path),
ToCanonicalParameter(output.Expression));
private static CanonicalParameter? ToCanonicalParameter(TaskPackPlanParameterValue? value)
=> value is null ? null : new CanonicalParameter(value.Value, value.Expression, value.Error, value.RequiresRuntimeValue);
}

View File

@@ -1,185 +1,185 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.TaskRunner.Core.Planning;
public static class TaskPackPlanInsights
{
public static IReadOnlyList<TaskPackPlanApprovalRequirement> CollectApprovalRequirements(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var approvals = plan.Approvals.ToDictionary(approval => approval.Id, StringComparer.Ordinal);
var builders = new Dictionary<string, ApprovalRequirementBuilder>(StringComparer.Ordinal);
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal) && !string.IsNullOrEmpty(step.ApprovalId))
{
if (!builders.TryGetValue(step.ApprovalId, out var builder))
{
builder = new ApprovalRequirementBuilder(step.ApprovalId);
builders[step.ApprovalId] = builder;
}
builder.AddStep(step);
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return builders.Values
.Select(builder => builder.Build(approvals))
.OrderBy(requirement => requirement.ApprovalId, StringComparer.Ordinal)
.ToList();
}
public static IReadOnlyList<TaskPackPlanNotificationHint> CollectNotificationHints(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var notifications = new List<TaskPackPlanNotificationHint>();
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal))
{
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "approval-request", step.GateMessage, step.ApprovalId));
}
else if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
{
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "policy-gate", step.GateMessage, null));
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return notifications;
}
public static IReadOnlyList<TaskPackPlanPolicyGateHint> CollectPolicyGateHints(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var hints = new List<TaskPackPlanPolicyGateHint>();
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
{
var parameters = step.Parameters?
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Select(kvp => new TaskPackPlanPolicyParameter(
kvp.Key,
kvp.Value.RequiresRuntimeValue,
kvp.Value.Expression,
kvp.Value.Error))
.ToList() ?? new List<TaskPackPlanPolicyParameter>();
hints.Add(new TaskPackPlanPolicyGateHint(step.Id, step.GateMessage, parameters));
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return hints;
}
private sealed class ApprovalRequirementBuilder
{
private readonly HashSet<string> stepIds = new(StringComparer.Ordinal);
private readonly List<string> messages = new();
public ApprovalRequirementBuilder(string approvalId)
{
ApprovalId = approvalId;
}
public string ApprovalId { get; }
public void AddStep(TaskPackPlanStep step)
{
stepIds.Add(step.Id);
if (!string.IsNullOrWhiteSpace(step.GateMessage))
{
messages.Add(step.GateMessage!);
}
}
public TaskPackPlanApprovalRequirement Build(IReadOnlyDictionary<string, TaskPackPlanApproval> knownApprovals)
{
knownApprovals.TryGetValue(ApprovalId, out var approval);
var orderedSteps = stepIds
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
var orderedMessages = messages
.Where(message => !string.IsNullOrWhiteSpace(message))
.Distinct(StringComparer.Ordinal)
.ToList();
return new TaskPackPlanApprovalRequirement(
ApprovalId,
approval?.Grants ?? Array.Empty<string>(),
approval?.ExpiresAfter,
approval?.ReasonTemplate,
orderedSteps,
orderedMessages);
}
}
}
public sealed record TaskPackPlanApprovalRequirement(
string ApprovalId,
IReadOnlyList<string> Grants,
string? ExpiresAfter,
string? ReasonTemplate,
IReadOnlyList<string> StepIds,
IReadOnlyList<string> Messages);
public sealed record TaskPackPlanNotificationHint(
string StepId,
string Type,
string? Message,
string? ApprovalId);
public sealed record TaskPackPlanPolicyGateHint(
string StepId,
string? Message,
IReadOnlyList<TaskPackPlanPolicyParameter> Parameters);
public sealed record TaskPackPlanPolicyParameter(
string Name,
bool RequiresRuntimeValue,
string? Expression,
string? Error);
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.TaskRunner.Core.Planning;
public static class TaskPackPlanInsights
{
public static IReadOnlyList<TaskPackPlanApprovalRequirement> CollectApprovalRequirements(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var approvals = plan.Approvals.ToDictionary(approval => approval.Id, StringComparer.Ordinal);
var builders = new Dictionary<string, ApprovalRequirementBuilder>(StringComparer.Ordinal);
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal) && !string.IsNullOrEmpty(step.ApprovalId))
{
if (!builders.TryGetValue(step.ApprovalId, out var builder))
{
builder = new ApprovalRequirementBuilder(step.ApprovalId);
builders[step.ApprovalId] = builder;
}
builder.AddStep(step);
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return builders.Values
.Select(builder => builder.Build(approvals))
.OrderBy(requirement => requirement.ApprovalId, StringComparer.Ordinal)
.ToList();
}
public static IReadOnlyList<TaskPackPlanNotificationHint> CollectNotificationHints(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var notifications = new List<TaskPackPlanNotificationHint>();
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.approval", StringComparison.Ordinal))
{
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "approval-request", step.GateMessage, step.ApprovalId));
}
else if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
{
notifications.Add(new TaskPackPlanNotificationHint(step.Id, "policy-gate", step.GateMessage, null));
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return notifications;
}
public static IReadOnlyList<TaskPackPlanPolicyGateHint> CollectPolicyGateHints(TaskPackPlan plan)
{
ArgumentNullException.ThrowIfNull(plan);
var hints = new List<TaskPackPlanPolicyGateHint>();
void Visit(IReadOnlyList<TaskPackPlanStep>? steps)
{
if (steps is null)
{
return;
}
foreach (var step in steps)
{
if (string.Equals(step.Type, "gate.policy", StringComparison.Ordinal))
{
var parameters = step.Parameters?
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
.Select(kvp => new TaskPackPlanPolicyParameter(
kvp.Key,
kvp.Value.RequiresRuntimeValue,
kvp.Value.Expression,
kvp.Value.Error))
.ToList() ?? new List<TaskPackPlanPolicyParameter>();
hints.Add(new TaskPackPlanPolicyGateHint(step.Id, step.GateMessage, parameters));
}
Visit(step.Children);
}
}
Visit(plan.Steps);
return hints;
}
private sealed class ApprovalRequirementBuilder
{
private readonly HashSet<string> stepIds = new(StringComparer.Ordinal);
private readonly List<string> messages = new();
public ApprovalRequirementBuilder(string approvalId)
{
ApprovalId = approvalId;
}
public string ApprovalId { get; }
public void AddStep(TaskPackPlanStep step)
{
stepIds.Add(step.Id);
if (!string.IsNullOrWhiteSpace(step.GateMessage))
{
messages.Add(step.GateMessage!);
}
}
public TaskPackPlanApprovalRequirement Build(IReadOnlyDictionary<string, TaskPackPlanApproval> knownApprovals)
{
knownApprovals.TryGetValue(ApprovalId, out var approval);
var orderedSteps = stepIds
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
var orderedMessages = messages
.Where(message => !string.IsNullOrWhiteSpace(message))
.Distinct(StringComparer.Ordinal)
.ToList();
return new TaskPackPlanApprovalRequirement(
ApprovalId,
approval?.Grants ?? Array.Empty<string>(),
approval?.ExpiresAfter,
approval?.ReasonTemplate,
orderedSteps,
orderedMessages);
}
}
}
public sealed record TaskPackPlanApprovalRequirement(
string ApprovalId,
IReadOnlyList<string> Grants,
string? ExpiresAfter,
string? ReasonTemplate,
IReadOnlyList<string> StepIds,
IReadOnlyList<string> Messages);
public sealed record TaskPackPlanNotificationHint(
string StepId,
string Type,
string? Message,
string? ApprovalId);
public sealed record TaskPackPlanPolicyGateHint(
string StepId,
string? Message,
IReadOnlyList<TaskPackPlanPolicyParameter> Parameters);
public sealed record TaskPackPlanPolicyParameter(
string Name,
bool RequiresRuntimeValue,
string? Expression,
string? Error);

View File

@@ -1,68 +1,68 @@
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Serialization;
internal static class CanonicalJson
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false
};
public static string Serialize<T>(T value)
{
var node = JsonSerializer.SerializeToNode(value, SerializerOptions);
if (node is null)
{
throw new InvalidOperationException("Unable to serialize value to JSON node.");
}
var canonical = Canonicalize(node);
return canonical.ToJsonString(SerializerOptions);
}
public static JsonNode Canonicalize(JsonNode node)
{
return node switch
{
JsonObject obj => CanonicalizeObject(obj),
JsonArray array => CanonicalizeArray(array),
_ => node.DeepClone()
};
}
private static JsonObject CanonicalizeObject(JsonObject obj)
{
var canonical = new JsonObject();
foreach (var property in obj.OrderBy(static p => p.Key, StringComparer.Ordinal))
{
if (property.Value is null)
{
canonical[property.Key] = null;
}
else
{
canonical[property.Key] = Canonicalize(property.Value);
}
}
return canonical;
}
private static JsonArray CanonicalizeArray(JsonArray array)
{
var canonical = new JsonArray();
foreach (var element in array)
{
canonical.Add(element is null ? null : Canonicalize(element));
}
return canonical;
}
}
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Core.Serialization;
internal static class CanonicalJson
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
WriteIndented = false
};
public static string Serialize<T>(T value)
{
var node = JsonSerializer.SerializeToNode(value, SerializerOptions);
if (node is null)
{
throw new InvalidOperationException("Unable to serialize value to JSON node.");
}
var canonical = Canonicalize(node);
return canonical.ToJsonString(SerializerOptions);
}
public static JsonNode Canonicalize(JsonNode node)
{
return node switch
{
JsonObject obj => CanonicalizeObject(obj),
JsonArray array => CanonicalizeArray(array),
_ => node.DeepClone()
};
}
private static JsonObject CanonicalizeObject(JsonObject obj)
{
var canonical = new JsonObject();
foreach (var property in obj.OrderBy(static p => p.Key, StringComparer.Ordinal))
{
if (property.Value is null)
{
canonical[property.Key] = null;
}
else
{
canonical[property.Key] = Canonicalize(property.Value);
}
}
return canonical;
}
private static JsonArray CanonicalizeArray(JsonArray array)
{
var canonical = new JsonArray();
foreach (var element in array)
{
canonical.Add(element is null ? null : Canonicalize(element));
}
return canonical;
}
}

View File

@@ -1,383 +1,383 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using StellaOps.TaskRunner.Core.AirGap;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifest
{
[JsonPropertyName("apiVersion")]
public required string ApiVersion { get; init; }
[JsonPropertyName("kind")]
public required string Kind { get; init; }
[JsonPropertyName("metadata")]
public required TaskPackMetadata Metadata { get; init; }
[JsonPropertyName("spec")]
public required TaskPackSpec Spec { get; init; }
}
public sealed class TaskPackMetadata
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
[JsonPropertyName("tenantVisibility")]
public IReadOnlyList<string>? TenantVisibility { get; init; }
[JsonPropertyName("maintainers")]
public IReadOnlyList<TaskPackMaintainer>? Maintainers { get; init; }
[JsonPropertyName("license")]
public string? License { get; init; }
[JsonPropertyName("annotations")]
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
public sealed class TaskPackMaintainer
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
}
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
[JsonPropertyName("secrets")]
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
[JsonPropertyName("approvals")]
public IReadOnlyList<TaskPackApproval>? Approvals { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
[JsonPropertyName("outputs")]
public IReadOnlyList<TaskPackOutput>? Outputs { get; init; }
[JsonPropertyName("success")]
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
[JsonPropertyName("sandbox")]
public TaskPackSandbox? Sandbox { get; init; }
[JsonPropertyName("slo")]
public TaskPackSlo? Slo { get; init; }
/// <summary>
/// Whether this pack requires a sealed (air-gapped) environment.
/// </summary>
[JsonPropertyName("sealedInstall")]
public bool SealedInstall { get; init; }
/// <summary>
/// Specific requirements for sealed install mode.
/// </summary>
[JsonPropertyName("sealedRequirements")]
public SealedRequirements? SealedRequirements { get; init; }
}
public sealed class TaskPackInput
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("schema")]
public string? Schema { get; init; }
[JsonPropertyName("required")]
public bool Required { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("default")]
public JsonNode? Default { get; init; }
}
public sealed class TaskPackSecret
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("scope")]
public required string Scope { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackApproval
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("grants")]
public IReadOnlyList<string> Grants { get; init; } = Array.Empty<string>();
[JsonPropertyName("expiresAfter")]
public string? ExpiresAfter { get; init; }
[JsonPropertyName("reasonTemplate")]
public string? ReasonTemplate { get; init; }
}
public sealed class TaskPackStep
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("when")]
public string? When { get; init; }
[JsonPropertyName("run")]
public TaskPackRunStep? Run { get; init; }
[JsonPropertyName("gate")]
public TaskPackGateStep? Gate { get; init; }
[JsonPropertyName("parallel")]
public TaskPackParallelStep? Parallel { get; init; }
[JsonPropertyName("map")]
public TaskPackMapStep? Map { get; init; }
[JsonPropertyName("loop")]
public TaskPackLoopStep? Loop { get; init; }
[JsonPropertyName("conditional")]
public TaskPackConditionalStep? Conditional { get; init; }
}
public sealed class TaskPackRunStep
{
[JsonPropertyName("uses")]
public required string Uses { get; init; }
[JsonPropertyName("with")]
public IDictionary<string, JsonNode?>? With { get; init; }
[JsonPropertyName("egress")]
public IReadOnlyList<TaskPackRunEgress>? Egress { get; init; }
}
public sealed class TaskPackRunEgress
{
[JsonPropertyName("url")]
public required string Url { get; init; }
[JsonPropertyName("intent")]
public string? Intent { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackGateStep
{
[JsonPropertyName("approval")]
public TaskPackApprovalGate? Approval { get; init; }
[JsonPropertyName("policy")]
public TaskPackPolicyGate? Policy { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class TaskPackApprovalGate
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("autoExpireAfter")]
public string? AutoExpireAfter { get; init; }
}
public sealed class TaskPackPolicyGate
{
[JsonPropertyName("policy")]
public required string Policy { get; init; }
[JsonPropertyName("parameters")]
public IDictionary<string, JsonNode?>? Parameters { get; init; }
}
public sealed class TaskPackParallelStep
{
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
[JsonPropertyName("maxParallel")]
public int? MaxParallel { get; init; }
[JsonPropertyName("continueOnError")]
public bool ContinueOnError { get; init; }
}
public sealed class TaskPackMapStep
{
[JsonPropertyName("items")]
public required string Items { get; init; }
[JsonPropertyName("step")]
public required TaskPackStep Step { get; init; }
}
public sealed class TaskPackLoopStep
{
[JsonPropertyName("items")]
public string? Items { get; init; }
[JsonPropertyName("range")]
public TaskPackLoopRange? Range { get; init; }
[JsonPropertyName("staticItems")]
public IReadOnlyList<object>? StaticItems { get; init; }
[JsonPropertyName("iterator")]
public string Iterator { get; init; } = "item";
[JsonPropertyName("index")]
public string Index { get; init; } = "index";
[JsonPropertyName("maxIterations")]
public int MaxIterations { get; init; } = 1000;
[JsonPropertyName("aggregation")]
public string? Aggregation { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackLoopRange
{
[JsonPropertyName("start")]
public int Start { get; init; }
[JsonPropertyName("end")]
public int End { get; init; }
[JsonPropertyName("step")]
public int Step { get; init; } = 1;
}
public sealed class TaskPackConditionalStep
{
[JsonPropertyName("branches")]
public IReadOnlyList<TaskPackConditionalBranch> Branches { get; init; } = Array.Empty<TaskPackConditionalBranch>();
[JsonPropertyName("else")]
public IReadOnlyList<TaskPackStep>? Else { get; init; }
[JsonPropertyName("outputUnion")]
public bool OutputUnion { get; init; }
}
public sealed class TaskPackConditionalBranch
{
[JsonPropertyName("condition")]
public required string Condition { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackOutput
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
[JsonPropertyName("expression")]
public string? Expression { get; init; }
}
public sealed class TaskPackSuccess
{
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class TaskPackFailure
{
[JsonPropertyName("message")]
public string? Message { get; init; }
[JsonPropertyName("retries")]
public TaskPackRetryPolicy? Retries { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackSandbox
{
[JsonPropertyName("mode")]
public string? Mode { get; init; }
[JsonPropertyName("egressAllowlist")]
public IReadOnlyList<string>? EgressAllowlist { get; init; }
[JsonPropertyName("cpuLimitMillicores")]
public int CpuLimitMillicores { get; init; }
[JsonPropertyName("memoryLimitMiB")]
public int MemoryLimitMiB { get; init; }
[JsonPropertyName("quotaSeconds")]
public int QuotaSeconds { get; init; }
}
public sealed class TaskPackSlo
{
[JsonPropertyName("runP95Seconds")]
public int RunP95Seconds { get; init; }
[JsonPropertyName("approvalP95Seconds")]
public int ApprovalP95Seconds { get; init; }
[JsonPropertyName("maxQueueDepth")]
public int MaxQueueDepth { get; init; }
}
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using StellaOps.TaskRunner.Core.AirGap;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifest
{
[JsonPropertyName("apiVersion")]
public required string ApiVersion { get; init; }
[JsonPropertyName("kind")]
public required string Kind { get; init; }
[JsonPropertyName("metadata")]
public required TaskPackMetadata Metadata { get; init; }
[JsonPropertyName("spec")]
public required TaskPackSpec Spec { get; init; }
}
public sealed class TaskPackMetadata
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("version")]
public required string Version { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("tags")]
public IReadOnlyList<string>? Tags { get; init; }
[JsonPropertyName("tenantVisibility")]
public IReadOnlyList<string>? TenantVisibility { get; init; }
[JsonPropertyName("maintainers")]
public IReadOnlyList<TaskPackMaintainer>? Maintainers { get; init; }
[JsonPropertyName("license")]
public string? License { get; init; }
[JsonPropertyName("annotations")]
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
}
public sealed class TaskPackMaintainer
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("email")]
public string? Email { get; init; }
}
public sealed class TaskPackSpec
{
[JsonPropertyName("inputs")]
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
[JsonPropertyName("secrets")]
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
[JsonPropertyName("approvals")]
public IReadOnlyList<TaskPackApproval>? Approvals { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
[JsonPropertyName("outputs")]
public IReadOnlyList<TaskPackOutput>? Outputs { get; init; }
[JsonPropertyName("success")]
public TaskPackSuccess? Success { get; init; }
[JsonPropertyName("failure")]
public TaskPackFailure? Failure { get; init; }
[JsonPropertyName("sandbox")]
public TaskPackSandbox? Sandbox { get; init; }
[JsonPropertyName("slo")]
public TaskPackSlo? Slo { get; init; }
/// <summary>
/// Whether this pack requires a sealed (air-gapped) environment.
/// </summary>
[JsonPropertyName("sealedInstall")]
public bool SealedInstall { get; init; }
/// <summary>
/// Specific requirements for sealed install mode.
/// </summary>
[JsonPropertyName("sealedRequirements")]
public SealedRequirements? SealedRequirements { get; init; }
}
public sealed class TaskPackInput
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("schema")]
public string? Schema { get; init; }
[JsonPropertyName("required")]
public bool Required { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("default")]
public JsonNode? Default { get; init; }
}
public sealed class TaskPackSecret
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("scope")]
public required string Scope { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackApproval
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("grants")]
public IReadOnlyList<string> Grants { get; init; } = Array.Empty<string>();
[JsonPropertyName("expiresAfter")]
public string? ExpiresAfter { get; init; }
[JsonPropertyName("reasonTemplate")]
public string? ReasonTemplate { get; init; }
}
public sealed class TaskPackStep
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("when")]
public string? When { get; init; }
[JsonPropertyName("run")]
public TaskPackRunStep? Run { get; init; }
[JsonPropertyName("gate")]
public TaskPackGateStep? Gate { get; init; }
[JsonPropertyName("parallel")]
public TaskPackParallelStep? Parallel { get; init; }
[JsonPropertyName("map")]
public TaskPackMapStep? Map { get; init; }
[JsonPropertyName("loop")]
public TaskPackLoopStep? Loop { get; init; }
[JsonPropertyName("conditional")]
public TaskPackConditionalStep? Conditional { get; init; }
}
public sealed class TaskPackRunStep
{
[JsonPropertyName("uses")]
public required string Uses { get; init; }
[JsonPropertyName("with")]
public IDictionary<string, JsonNode?>? With { get; init; }
[JsonPropertyName("egress")]
public IReadOnlyList<TaskPackRunEgress>? Egress { get; init; }
}
public sealed class TaskPackRunEgress
{
[JsonPropertyName("url")]
public required string Url { get; init; }
[JsonPropertyName("intent")]
public string? Intent { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
}
public sealed class TaskPackGateStep
{
[JsonPropertyName("approval")]
public TaskPackApprovalGate? Approval { get; init; }
[JsonPropertyName("policy")]
public TaskPackPolicyGate? Policy { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class TaskPackApprovalGate
{
[JsonPropertyName("id")]
public required string Id { get; init; }
[JsonPropertyName("autoExpireAfter")]
public string? AutoExpireAfter { get; init; }
}
public sealed class TaskPackPolicyGate
{
[JsonPropertyName("policy")]
public required string Policy { get; init; }
[JsonPropertyName("parameters")]
public IDictionary<string, JsonNode?>? Parameters { get; init; }
}
public sealed class TaskPackParallelStep
{
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
[JsonPropertyName("maxParallel")]
public int? MaxParallel { get; init; }
[JsonPropertyName("continueOnError")]
public bool ContinueOnError { get; init; }
}
public sealed class TaskPackMapStep
{
[JsonPropertyName("items")]
public required string Items { get; init; }
[JsonPropertyName("step")]
public required TaskPackStep Step { get; init; }
}
public sealed class TaskPackLoopStep
{
[JsonPropertyName("items")]
public string? Items { get; init; }
[JsonPropertyName("range")]
public TaskPackLoopRange? Range { get; init; }
[JsonPropertyName("staticItems")]
public IReadOnlyList<object>? StaticItems { get; init; }
[JsonPropertyName("iterator")]
public string Iterator { get; init; } = "item";
[JsonPropertyName("index")]
public string Index { get; init; } = "index";
[JsonPropertyName("maxIterations")]
public int MaxIterations { get; init; } = 1000;
[JsonPropertyName("aggregation")]
public string? Aggregation { get; init; }
[JsonPropertyName("outputPath")]
public string? OutputPath { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackLoopRange
{
[JsonPropertyName("start")]
public int Start { get; init; }
[JsonPropertyName("end")]
public int End { get; init; }
[JsonPropertyName("step")]
public int Step { get; init; } = 1;
}
public sealed class TaskPackConditionalStep
{
[JsonPropertyName("branches")]
public IReadOnlyList<TaskPackConditionalBranch> Branches { get; init; } = Array.Empty<TaskPackConditionalBranch>();
[JsonPropertyName("else")]
public IReadOnlyList<TaskPackStep>? Else { get; init; }
[JsonPropertyName("outputUnion")]
public bool OutputUnion { get; init; }
}
public sealed class TaskPackConditionalBranch
{
[JsonPropertyName("condition")]
public required string Condition { get; init; }
[JsonPropertyName("steps")]
public IReadOnlyList<TaskPackStep> Steps { get; init; } = Array.Empty<TaskPackStep>();
}
public sealed class TaskPackOutput
{
[JsonPropertyName("name")]
public required string Name { get; init; }
[JsonPropertyName("type")]
public required string Type { get; init; }
[JsonPropertyName("path")]
public string? Path { get; init; }
[JsonPropertyName("expression")]
public string? Expression { get; init; }
}
public sealed class TaskPackSuccess
{
[JsonPropertyName("message")]
public string? Message { get; init; }
}
public sealed class TaskPackFailure
{
[JsonPropertyName("message")]
public string? Message { get; init; }
[JsonPropertyName("retries")]
public TaskPackRetryPolicy? Retries { get; init; }
}
public sealed class TaskPackRetryPolicy
{
[JsonPropertyName("maxAttempts")]
public int MaxAttempts { get; init; }
[JsonPropertyName("backoffSeconds")]
public int BackoffSeconds { get; init; }
}
public sealed class TaskPackSandbox
{
[JsonPropertyName("mode")]
public string? Mode { get; init; }
[JsonPropertyName("egressAllowlist")]
public IReadOnlyList<string>? EgressAllowlist { get; init; }
[JsonPropertyName("cpuLimitMillicores")]
public int CpuLimitMillicores { get; init; }
[JsonPropertyName("memoryLimitMiB")]
public int MemoryLimitMiB { get; init; }
[JsonPropertyName("quotaSeconds")]
public int QuotaSeconds { get; init; }
}
public sealed class TaskPackSlo
{
[JsonPropertyName("runP95Seconds")]
public int RunP95Seconds { get; init; }
[JsonPropertyName("approvalP95Seconds")]
public int ApprovalP95Seconds { get; init; }
[JsonPropertyName("maxQueueDepth")]
public int MaxQueueDepth { get; init; }
}

View File

@@ -1,168 +1,168 @@
using System.Collections;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifestLoader
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public async Task<TaskPackManifest> LoadAsync(Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var yaml = await reader.ReadToEndAsync().ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return Deserialize(yaml);
}
public TaskPackManifest Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Path must not be empty.", nameof(path));
}
using var stream = File.OpenRead(path);
return LoadAsync(stream).GetAwaiter().GetResult();
}
public TaskPackManifest Deserialize(string yaml)
{
if (string.IsNullOrWhiteSpace(yaml))
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
try
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
using var reader = new StringReader(yaml);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
var node = ConvertToJsonNode(yamlObject);
if (node is null)
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
var manifest = node.Deserialize<TaskPackManifest>(SerializerOptions);
if (manifest is null)
{
throw new TaskPackManifestLoadException("Unable to deserialize manifest.");
}
return manifest;
}
catch (TaskPackManifestLoadException)
{
throw;
}
catch (Exception ex)
{
throw new TaskPackManifestLoadException(string.Format(CultureInfo.InvariantCulture, "Failed to parse manifest: {0}", ex.Message), ex);
}
}
private static JsonNode? ConvertToJsonNode(object? value)
{
switch (value)
{
case null:
return null;
case string s:
if (bool.TryParse(s, out var boolValue))
{
return JsonValue.Create(boolValue);
}
if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue))
{
return JsonValue.Create(longValue);
}
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
return JsonValue.Create(doubleValue);
}
return JsonValue.Create(s);
case bool b:
return JsonValue.Create(b);
case int i:
return JsonValue.Create(i);
case long l:
return JsonValue.Create(l);
case double d:
return JsonValue.Create(d);
case float f:
return JsonValue.Create(f);
case decimal dec:
return JsonValue.Create(dec);
case IDictionary<object, object> dictionary:
{
var obj = new JsonObject();
foreach (var kvp in dictionary)
{
var key = Convert.ToString(kvp.Key, CultureInfo.InvariantCulture);
if (string.IsNullOrEmpty(key))
{
continue;
}
obj[key] = ConvertToJsonNode(kvp.Value);
}
return obj;
}
case IEnumerable enumerable:
{
var array = new JsonArray();
foreach (var item in enumerable)
{
array.Add(ConvertToJsonNode(item));
}
return array;
}
default:
return JsonValue.Create(value.ToString());
}
}
}
public sealed class TaskPackManifestLoadException : Exception
{
public TaskPackManifestLoadException(string message)
: base(message)
{
}
public TaskPackManifestLoadException(string message, Exception innerException)
: base(message, innerException)
{
}
}
using System.Collections;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifestLoader
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public async Task<TaskPackManifest> LoadAsync(Stream stream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4096, leaveOpen: true);
var yaml = await reader.ReadToEndAsync().ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return Deserialize(yaml);
}
public TaskPackManifest Load(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("Path must not be empty.", nameof(path));
}
using var stream = File.OpenRead(path);
return LoadAsync(stream).GetAwaiter().GetResult();
}
public TaskPackManifest Deserialize(string yaml)
{
if (string.IsNullOrWhiteSpace(yaml))
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
try
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
using var reader = new StringReader(yaml);
var yamlObject = deserializer.Deserialize(reader);
if (yamlObject is null)
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
var node = ConvertToJsonNode(yamlObject);
if (node is null)
{
throw new TaskPackManifestLoadException("Manifest is empty.");
}
var manifest = node.Deserialize<TaskPackManifest>(SerializerOptions);
if (manifest is null)
{
throw new TaskPackManifestLoadException("Unable to deserialize manifest.");
}
return manifest;
}
catch (TaskPackManifestLoadException)
{
throw;
}
catch (Exception ex)
{
throw new TaskPackManifestLoadException(string.Format(CultureInfo.InvariantCulture, "Failed to parse manifest: {0}", ex.Message), ex);
}
}
private static JsonNode? ConvertToJsonNode(object? value)
{
switch (value)
{
case null:
return null;
case string s:
if (bool.TryParse(s, out var boolValue))
{
return JsonValue.Create(boolValue);
}
if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue))
{
return JsonValue.Create(longValue);
}
if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleValue))
{
return JsonValue.Create(doubleValue);
}
return JsonValue.Create(s);
case bool b:
return JsonValue.Create(b);
case int i:
return JsonValue.Create(i);
case long l:
return JsonValue.Create(l);
case double d:
return JsonValue.Create(d);
case float f:
return JsonValue.Create(f);
case decimal dec:
return JsonValue.Create(dec);
case IDictionary<object, object> dictionary:
{
var obj = new JsonObject();
foreach (var kvp in dictionary)
{
var key = Convert.ToString(kvp.Key, CultureInfo.InvariantCulture);
if (string.IsNullOrEmpty(key))
{
continue;
}
obj[key] = ConvertToJsonNode(kvp.Value);
}
return obj;
}
case IEnumerable enumerable:
{
var array = new JsonArray();
foreach (var item in enumerable)
{
array.Add(ConvertToJsonNode(item));
}
return array;
}
default:
return JsonValue.Create(value.ToString());
}
}
}
public sealed class TaskPackManifestLoadException : Exception
{
public TaskPackManifestLoadException(string message)
: base(message)
{
}
public TaskPackManifestLoadException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -1,350 +1,350 @@
using System;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using System.Linq;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifestValidator
{
private static readonly Regex NameRegex = new("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex VersionRegex = new("^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z-.]+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public TaskPackManifestValidationResult Validate(TaskPackManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = new List<TaskPackManifestValidationError>();
if (!string.Equals(manifest.ApiVersion, "stellaops.io/pack.v1", StringComparison.Ordinal))
{
errors.Add(new TaskPackManifestValidationError("apiVersion", "Only apiVersion 'stellaops.io/pack.v1' is supported."));
}
if (!string.Equals(manifest.Kind, "TaskPack", StringComparison.Ordinal))
{
errors.Add(new TaskPackManifestValidationError("kind", "Kind must be 'TaskPack'."));
}
ValidateMetadata(manifest.Metadata, errors);
ValidateSpec(manifest.Spec, errors);
return new TaskPackManifestValidationResult(errors.ToImmutableArray());
}
private static void ValidateMetadata(TaskPackMetadata metadata, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(metadata.Name))
{
errors.Add(new TaskPackManifestValidationError("metadata.name", "Name is required."));
}
else if (!NameRegex.IsMatch(metadata.Name))
{
errors.Add(new TaskPackManifestValidationError("metadata.name", "Name must follow DNS-1123 naming (lowercase alphanumeric plus '-')."));
}
if (string.IsNullOrWhiteSpace(metadata.Version))
{
errors.Add(new TaskPackManifestValidationError("metadata.version", "Version is required."));
}
else if (!VersionRegex.IsMatch(metadata.Version))
{
errors.Add(new TaskPackManifestValidationError("metadata.version", "Version must follow SemVer (major.minor.patch[+/-metadata])."));
}
}
private static void ValidateSpec(TaskPackSpec spec, ICollection<TaskPackManifestValidationError> errors)
{
if (spec.Steps is null || spec.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError("spec.steps", "At least one step is required."));
return;
}
var stepIds = new HashSet<string>(StringComparer.Ordinal);
var approvalIds = new HashSet<string>(StringComparer.Ordinal);
if (spec.Approvals is not null)
{
foreach (var approval in spec.Approvals)
{
if (!approvalIds.Add(approval.Id))
{
errors.Add(new TaskPackManifestValidationError($"spec.approvals[{approval.Id}]", "Duplicate approval id."));
}
}
}
ValidateInputs(spec, errors);
ValidateSteps(spec.Steps, "spec.steps", stepIds, approvalIds, errors);
}
private static void ValidateInputs(TaskPackSpec spec, ICollection<TaskPackManifestValidationError> errors)
{
if (spec.Inputs is null)
{
return;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var (input, index) in spec.Inputs.Select((input, index) => (input, index)))
{
var prefix = $"spec.inputs[{index}]";
if (!seen.Add(input.Name))
{
errors.Add(new TaskPackManifestValidationError($"{prefix}.name", "Duplicate input name."));
}
if (string.IsNullOrWhiteSpace(input.Type))
{
errors.Add(new TaskPackManifestValidationError($"{prefix}.type", "Input type is required."));
}
}
}
private static void ValidateSteps(
IReadOnlyList<TaskPackStep> steps,
string pathPrefix,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
foreach (var (step, index) in steps.Select((step, index) => (step, index)))
{
var path = $"{pathPrefix}[{index}]";
if (!stepIds.Add(step.Id))
{
errors.Add(new TaskPackManifestValidationError($"{path}.id", "Duplicate step id."));
}
var typeCount = (step.Run is not null ? 1 : 0)
+ (step.Gate is not null ? 1 : 0)
+ (step.Parallel is not null ? 1 : 0)
+ (step.Map is not null ? 1 : 0)
+ (step.Loop is not null ? 1 : 0)
+ (step.Conditional is not null ? 1 : 0);
if (typeCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Step must define one of run, gate, parallel, map, loop, or conditional."));
}
else if (typeCount > 1)
{
errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, map, loop, or conditional."));
}
if (step.Run is not null)
{
ValidateRunStep(step.Run, $"{path}.run", errors);
}
if (step.Gate is not null)
{
ValidateGateStep(step.Gate, approvalIds, $"{path}.gate", errors);
}
if (step.Parallel is not null)
{
ValidateParallelStep(step.Parallel, $"{path}.parallel", stepIds, approvalIds, errors);
}
if (step.Map is not null)
{
ValidateMapStep(step.Map, $"{path}.map", stepIds, approvalIds, errors);
}
if (step.Loop is not null)
{
ValidateLoopStep(step.Loop, $"{path}.loop", stepIds, approvalIds, errors);
}
if (step.Conditional is not null)
{
ValidateConditionalStep(step.Conditional, $"{path}.conditional", stepIds, approvalIds, errors);
}
}
}
private static void ValidateRunStep(TaskPackRunStep run, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(run.Uses))
{
errors.Add(new TaskPackManifestValidationError($"{path}.uses", "Run step requires 'uses'."));
}
if (run.Egress is not null)
{
for (var i = 0; i < run.Egress.Count; i++)
{
var entry = run.Egress[i];
var entryPath = $"{path}.egress[{i}]";
if (entry is null)
{
errors.Add(new TaskPackManifestValidationError(entryPath, "Egress entry must be specified."));
continue;
}
if (string.IsNullOrWhiteSpace(entry.Url))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress entry requires an absolute URL."));
}
else if (!Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) ||
(!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
}
if (entry.Intent is not null && string.IsNullOrWhiteSpace(entry.Intent))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.intent", "Intent must be omitted or non-empty."));
}
}
}
}
private static void ValidateGateStep(TaskPackGateStep gate, HashSet<string> approvalIds, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (gate.Approval is null && gate.Policy is null)
{
errors.Add(new TaskPackManifestValidationError(path, "Gate step requires 'approval' or 'policy'."));
return;
}
if (gate.Approval is not null)
{
if (!approvalIds.Contains(gate.Approval.Id))
{
errors.Add(new TaskPackManifestValidationError($"{path}.approval.id", $"Approval '{gate.Approval.Id}' is not declared under spec.approvals."));
}
}
}
private static void ValidateParallelStep(
TaskPackParallelStep parallel,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (parallel.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Parallel step requires nested steps."));
return;
}
ValidateSteps(parallel.Steps, $"{path}.steps", stepIds, approvalIds, errors);
}
private static void ValidateMapStep(
TaskPackMapStep map,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(map.Items))
{
errors.Add(new TaskPackManifestValidationError($"{path}.items", "Map step requires 'items' expression."));
}
if (map.Step is null)
{
errors.Add(new TaskPackManifestValidationError($"{path}.step", "Map step requires nested step definition."));
}
else
{
ValidateSteps(new[] { map.Step }, $"{path}.step", stepIds, approvalIds, errors);
}
}
private static void ValidateLoopStep(
TaskPackLoopStep loop,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
// Loop must have one of: items expression, range, or staticItems
var sourceCount = (string.IsNullOrWhiteSpace(loop.Items) ? 0 : 1)
+ (loop.Range is not null ? 1 : 0)
+ (loop.StaticItems is not null ? 1 : 0);
if (sourceCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Loop step requires 'items', 'range', or 'staticItems'."));
}
if (loop.MaxIterations <= 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.maxIterations", "maxIterations must be greater than 0."));
}
if (loop.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Loop step requires nested steps."));
}
else
{
ValidateSteps(loop.Steps, $"{path}.steps", stepIds, approvalIds, errors);
}
}
private static void ValidateConditionalStep(
TaskPackConditionalStep conditional,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (conditional.Branches.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.branches", "Conditional step requires at least one branch."));
return;
}
for (var i = 0; i < conditional.Branches.Count; i++)
{
var branch = conditional.Branches[i];
var branchPath = $"{path}.branches[{i}]";
if (string.IsNullOrWhiteSpace(branch.Condition))
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.condition", "Branch requires a condition expression."));
}
if (branch.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.steps", "Branch requires nested steps."));
}
else
{
ValidateSteps(branch.Steps, $"{branchPath}.steps", stepIds, approvalIds, errors);
}
}
if (conditional.Else is not null && conditional.Else.Count > 0)
{
ValidateSteps(conditional.Else, $"{path}.else", stepIds, approvalIds, errors);
}
}
}
public sealed record TaskPackManifestValidationError(string Path, string Message);
public sealed class TaskPackManifestValidationResult
{
public TaskPackManifestValidationResult(ImmutableArray<TaskPackManifestValidationError> errors)
{
Errors = errors;
}
public ImmutableArray<TaskPackManifestValidationError> Errors { get; }
public bool IsValid => Errors.IsDefaultOrEmpty;
}
using System;
using System.Collections.Immutable;
using System.Text.RegularExpressions;
using System.Linq;
namespace StellaOps.TaskRunner.Core.TaskPacks;
public sealed class TaskPackManifestValidator
{
private static readonly Regex NameRegex = new("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex VersionRegex = new("^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z-.]+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public TaskPackManifestValidationResult Validate(TaskPackManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
var errors = new List<TaskPackManifestValidationError>();
if (!string.Equals(manifest.ApiVersion, "stellaops.io/pack.v1", StringComparison.Ordinal))
{
errors.Add(new TaskPackManifestValidationError("apiVersion", "Only apiVersion 'stellaops.io/pack.v1' is supported."));
}
if (!string.Equals(manifest.Kind, "TaskPack", StringComparison.Ordinal))
{
errors.Add(new TaskPackManifestValidationError("kind", "Kind must be 'TaskPack'."));
}
ValidateMetadata(manifest.Metadata, errors);
ValidateSpec(manifest.Spec, errors);
return new TaskPackManifestValidationResult(errors.ToImmutableArray());
}
private static void ValidateMetadata(TaskPackMetadata metadata, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(metadata.Name))
{
errors.Add(new TaskPackManifestValidationError("metadata.name", "Name is required."));
}
else if (!NameRegex.IsMatch(metadata.Name))
{
errors.Add(new TaskPackManifestValidationError("metadata.name", "Name must follow DNS-1123 naming (lowercase alphanumeric plus '-')."));
}
if (string.IsNullOrWhiteSpace(metadata.Version))
{
errors.Add(new TaskPackManifestValidationError("metadata.version", "Version is required."));
}
else if (!VersionRegex.IsMatch(metadata.Version))
{
errors.Add(new TaskPackManifestValidationError("metadata.version", "Version must follow SemVer (major.minor.patch[+/-metadata])."));
}
}
private static void ValidateSpec(TaskPackSpec spec, ICollection<TaskPackManifestValidationError> errors)
{
if (spec.Steps is null || spec.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError("spec.steps", "At least one step is required."));
return;
}
var stepIds = new HashSet<string>(StringComparer.Ordinal);
var approvalIds = new HashSet<string>(StringComparer.Ordinal);
if (spec.Approvals is not null)
{
foreach (var approval in spec.Approvals)
{
if (!approvalIds.Add(approval.Id))
{
errors.Add(new TaskPackManifestValidationError($"spec.approvals[{approval.Id}]", "Duplicate approval id."));
}
}
}
ValidateInputs(spec, errors);
ValidateSteps(spec.Steps, "spec.steps", stepIds, approvalIds, errors);
}
private static void ValidateInputs(TaskPackSpec spec, ICollection<TaskPackManifestValidationError> errors)
{
if (spec.Inputs is null)
{
return;
}
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var (input, index) in spec.Inputs.Select((input, index) => (input, index)))
{
var prefix = $"spec.inputs[{index}]";
if (!seen.Add(input.Name))
{
errors.Add(new TaskPackManifestValidationError($"{prefix}.name", "Duplicate input name."));
}
if (string.IsNullOrWhiteSpace(input.Type))
{
errors.Add(new TaskPackManifestValidationError($"{prefix}.type", "Input type is required."));
}
}
}
private static void ValidateSteps(
IReadOnlyList<TaskPackStep> steps,
string pathPrefix,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
foreach (var (step, index) in steps.Select((step, index) => (step, index)))
{
var path = $"{pathPrefix}[{index}]";
if (!stepIds.Add(step.Id))
{
errors.Add(new TaskPackManifestValidationError($"{path}.id", "Duplicate step id."));
}
var typeCount = (step.Run is not null ? 1 : 0)
+ (step.Gate is not null ? 1 : 0)
+ (step.Parallel is not null ? 1 : 0)
+ (step.Map is not null ? 1 : 0)
+ (step.Loop is not null ? 1 : 0)
+ (step.Conditional is not null ? 1 : 0);
if (typeCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Step must define one of run, gate, parallel, map, loop, or conditional."));
}
else if (typeCount > 1)
{
errors.Add(new TaskPackManifestValidationError(path, "Step may define only one of run, gate, parallel, map, loop, or conditional."));
}
if (step.Run is not null)
{
ValidateRunStep(step.Run, $"{path}.run", errors);
}
if (step.Gate is not null)
{
ValidateGateStep(step.Gate, approvalIds, $"{path}.gate", errors);
}
if (step.Parallel is not null)
{
ValidateParallelStep(step.Parallel, $"{path}.parallel", stepIds, approvalIds, errors);
}
if (step.Map is not null)
{
ValidateMapStep(step.Map, $"{path}.map", stepIds, approvalIds, errors);
}
if (step.Loop is not null)
{
ValidateLoopStep(step.Loop, $"{path}.loop", stepIds, approvalIds, errors);
}
if (step.Conditional is not null)
{
ValidateConditionalStep(step.Conditional, $"{path}.conditional", stepIds, approvalIds, errors);
}
}
}
private static void ValidateRunStep(TaskPackRunStep run, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(run.Uses))
{
errors.Add(new TaskPackManifestValidationError($"{path}.uses", "Run step requires 'uses'."));
}
if (run.Egress is not null)
{
for (var i = 0; i < run.Egress.Count; i++)
{
var entry = run.Egress[i];
var entryPath = $"{path}.egress[{i}]";
if (entry is null)
{
errors.Add(new TaskPackManifestValidationError(entryPath, "Egress entry must be specified."));
continue;
}
if (string.IsNullOrWhiteSpace(entry.Url))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress entry requires an absolute URL."));
}
else if (!Uri.TryCreate(entry.Url, UriKind.Absolute, out var uri) ||
(!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.url", "Egress URL must be an absolute HTTP or HTTPS address."));
}
if (entry.Intent is not null && string.IsNullOrWhiteSpace(entry.Intent))
{
errors.Add(new TaskPackManifestValidationError($"{entryPath}.intent", "Intent must be omitted or non-empty."));
}
}
}
}
private static void ValidateGateStep(TaskPackGateStep gate, HashSet<string> approvalIds, string path, ICollection<TaskPackManifestValidationError> errors)
{
if (gate.Approval is null && gate.Policy is null)
{
errors.Add(new TaskPackManifestValidationError(path, "Gate step requires 'approval' or 'policy'."));
return;
}
if (gate.Approval is not null)
{
if (!approvalIds.Contains(gate.Approval.Id))
{
errors.Add(new TaskPackManifestValidationError($"{path}.approval.id", $"Approval '{gate.Approval.Id}' is not declared under spec.approvals."));
}
}
}
private static void ValidateParallelStep(
TaskPackParallelStep parallel,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (parallel.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Parallel step requires nested steps."));
return;
}
ValidateSteps(parallel.Steps, $"{path}.steps", stepIds, approvalIds, errors);
}
private static void ValidateMapStep(
TaskPackMapStep map,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (string.IsNullOrWhiteSpace(map.Items))
{
errors.Add(new TaskPackManifestValidationError($"{path}.items", "Map step requires 'items' expression."));
}
if (map.Step is null)
{
errors.Add(new TaskPackManifestValidationError($"{path}.step", "Map step requires nested step definition."));
}
else
{
ValidateSteps(new[] { map.Step }, $"{path}.step", stepIds, approvalIds, errors);
}
}
private static void ValidateLoopStep(
TaskPackLoopStep loop,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
// Loop must have one of: items expression, range, or staticItems
var sourceCount = (string.IsNullOrWhiteSpace(loop.Items) ? 0 : 1)
+ (loop.Range is not null ? 1 : 0)
+ (loop.StaticItems is not null ? 1 : 0);
if (sourceCount == 0)
{
errors.Add(new TaskPackManifestValidationError(path, "Loop step requires 'items', 'range', or 'staticItems'."));
}
if (loop.MaxIterations <= 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.maxIterations", "maxIterations must be greater than 0."));
}
if (loop.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.steps", "Loop step requires nested steps."));
}
else
{
ValidateSteps(loop.Steps, $"{path}.steps", stepIds, approvalIds, errors);
}
}
private static void ValidateConditionalStep(
TaskPackConditionalStep conditional,
string path,
HashSet<string> stepIds,
HashSet<string> approvalIds,
ICollection<TaskPackManifestValidationError> errors)
{
if (conditional.Branches.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{path}.branches", "Conditional step requires at least one branch."));
return;
}
for (var i = 0; i < conditional.Branches.Count; i++)
{
var branch = conditional.Branches[i];
var branchPath = $"{path}.branches[{i}]";
if (string.IsNullOrWhiteSpace(branch.Condition))
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.condition", "Branch requires a condition expression."));
}
if (branch.Steps.Count == 0)
{
errors.Add(new TaskPackManifestValidationError($"{branchPath}.steps", "Branch requires nested steps."));
}
else
{
ValidateSteps(branch.Steps, $"{branchPath}.steps", stepIds, approvalIds, errors);
}
}
if (conditional.Else is not null && conditional.Else.Count > 0)
{
ValidateSteps(conditional.Else, $"{path}.else", stepIds, approvalIds, errors);
}
}
}
public sealed record TaskPackManifestValidationError(string Path, string Message);
public sealed class TaskPackManifestValidationResult
{
public TaskPackManifestValidationResult(ImmutableArray<TaskPackManifestValidationError> errors)
{
Errors = errors;
}
public ImmutableArray<TaskPackManifestValidationError> Errors { get; }
public bool IsValid => Errors.IsDefaultOrEmpty;
}

View File

@@ -1,118 +1,118 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class FilePackRunApprovalStore : IPackRunApprovalStore
{
private readonly string rootPath;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public FilePackRunApprovalStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = rootPath;
Directory.CreateDirectory(rootPath);
}
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
var path = GetFilePath(runId);
var json = SerializeApprovals(approvals);
File.WriteAllText(path, json);
return Task.CompletedTask;
}
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
{
var path = GetFilePath(runId);
if (!File.Exists(path))
{
return Task.FromResult((IReadOnlyList<PackRunApprovalState>)Array.Empty<PackRunApprovalState>());
}
var json = File.ReadAllText(path);
var approvals = DeserializeApprovals(json);
return Task.FromResult((IReadOnlyList<PackRunApprovalState>)approvals);
}
public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
{
var approvals = (await GetAsync(runId, cancellationToken).ConfigureAwait(false)).ToList();
var index = approvals.FindIndex(existing => string.Equals(existing.ApprovalId, approval.ApprovalId, StringComparison.Ordinal));
if (index < 0)
{
throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'.");
}
approvals[index] = approval;
await SaveAsync(runId, approvals, cancellationToken).ConfigureAwait(false);
}
private string GetFilePath(string runId)
{
var safeFile = $"{runId}.json";
return Path.Combine(rootPath, safeFile);
}
private string SerializeApprovals(IReadOnlyList<PackRunApprovalState> approvals)
{
var array = new JsonArray();
foreach (var approval in approvals)
{
var node = new JsonObject
{
["approvalId"] = approval.ApprovalId,
["status"] = approval.Status.ToString(),
["requestedAt"] = approval.RequestedAt,
["actorId"] = approval.ActorId,
["completedAt"] = approval.CompletedAt,
["summary"] = approval.Summary,
["requiredGrants"] = new JsonArray(approval.RequiredGrants.Select(grant => (JsonNode)grant).ToArray()),
["stepIds"] = new JsonArray(approval.StepIds.Select(step => (JsonNode)step).ToArray()),
["messages"] = new JsonArray(approval.Messages.Select(message => (JsonNode)message).ToArray()),
["reasonTemplate"] = approval.ReasonTemplate
};
array.Add(node);
}
return array.ToJsonString(serializerOptions);
}
private static IReadOnlyList<PackRunApprovalState> DeserializeApprovals(string json)
{
var array = JsonNode.Parse(json)?.AsArray() ?? new JsonArray();
var list = new List<PackRunApprovalState>(array.Count);
foreach (var entry in array)
{
if (entry is not JsonObject obj)
{
continue;
}
var requiredGrants = obj["requiredGrants"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
var stepIds = obj["stepIds"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
var messages = obj["messages"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
Enum.TryParse(obj["status"]?.GetValue<string>(), ignoreCase: true, out PackRunApprovalStatus status);
list.Add(new PackRunApprovalState(
obj["approvalId"]?.GetValue<string>() ?? string.Empty,
requiredGrants,
stepIds,
messages,
obj["reasonTemplate"]?.GetValue<string>(),
obj["requestedAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.UtcNow,
status,
obj["actorId"]?.GetValue<string>(),
obj["completedAt"]?.GetValue<DateTimeOffset?>(),
obj["summary"]?.GetValue<string>()));
}
return list;
}
}
using System.Text.Json;
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class FilePackRunApprovalStore : IPackRunApprovalStore
{
private readonly string rootPath;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public FilePackRunApprovalStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = rootPath;
Directory.CreateDirectory(rootPath);
}
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
var path = GetFilePath(runId);
var json = SerializeApprovals(approvals);
File.WriteAllText(path, json);
return Task.CompletedTask;
}
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
{
var path = GetFilePath(runId);
if (!File.Exists(path))
{
return Task.FromResult((IReadOnlyList<PackRunApprovalState>)Array.Empty<PackRunApprovalState>());
}
var json = File.ReadAllText(path);
var approvals = DeserializeApprovals(json);
return Task.FromResult((IReadOnlyList<PackRunApprovalState>)approvals);
}
public async Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
{
var approvals = (await GetAsync(runId, cancellationToken).ConfigureAwait(false)).ToList();
var index = approvals.FindIndex(existing => string.Equals(existing.ApprovalId, approval.ApprovalId, StringComparison.Ordinal));
if (index < 0)
{
throw new InvalidOperationException($"Approval '{approval.ApprovalId}' not found for run '{runId}'.");
}
approvals[index] = approval;
await SaveAsync(runId, approvals, cancellationToken).ConfigureAwait(false);
}
private string GetFilePath(string runId)
{
var safeFile = $"{runId}.json";
return Path.Combine(rootPath, safeFile);
}
private string SerializeApprovals(IReadOnlyList<PackRunApprovalState> approvals)
{
var array = new JsonArray();
foreach (var approval in approvals)
{
var node = new JsonObject
{
["approvalId"] = approval.ApprovalId,
["status"] = approval.Status.ToString(),
["requestedAt"] = approval.RequestedAt,
["actorId"] = approval.ActorId,
["completedAt"] = approval.CompletedAt,
["summary"] = approval.Summary,
["requiredGrants"] = new JsonArray(approval.RequiredGrants.Select(grant => (JsonNode)grant).ToArray()),
["stepIds"] = new JsonArray(approval.StepIds.Select(step => (JsonNode)step).ToArray()),
["messages"] = new JsonArray(approval.Messages.Select(message => (JsonNode)message).ToArray()),
["reasonTemplate"] = approval.ReasonTemplate
};
array.Add(node);
}
return array.ToJsonString(serializerOptions);
}
private static IReadOnlyList<PackRunApprovalState> DeserializeApprovals(string json)
{
var array = JsonNode.Parse(json)?.AsArray() ?? new JsonArray();
var list = new List<PackRunApprovalState>(array.Count);
foreach (var entry in array)
{
if (entry is not JsonObject obj)
{
continue;
}
var requiredGrants = obj["requiredGrants"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
var stepIds = obj["stepIds"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
var messages = obj["messages"]?.AsArray()?.Select(node => node!.GetValue<string>()).ToList() ?? new List<string>();
Enum.TryParse(obj["status"]?.GetValue<string>(), ignoreCase: true, out PackRunApprovalStatus status);
list.Add(new PackRunApprovalState(
obj["approvalId"]?.GetValue<string>() ?? string.Empty,
requiredGrants,
stepIds,
messages,
obj["reasonTemplate"]?.GetValue<string>(),
obj["requestedAt"]?.GetValue<DateTimeOffset>() ?? DateTimeOffset.UtcNow,
status,
obj["actorId"]?.GetValue<string>(),
obj["completedAt"]?.GetValue<DateTimeOffset?>(),
obj["summary"]?.GetValue<string>()));
}
return list;
}
}

View File

@@ -1,115 +1,115 @@
using System.Text.Json;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
/// <summary>
/// File-system backed implementation of <see cref="IPackRunStateStore"/> intended for development and air-gapped smoke tests.
/// </summary>
public sealed class FilePackRunStateStore : IPackRunStateStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly string rootPath;
private readonly SemaphoreSlim mutex = new(1, 1);
public FilePackRunStateStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = Path.GetFullPath(rootPath);
Directory.CreateDirectory(this.rootPath);
}
public async Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetPath(runId);
if (!File.Exists(path))
{
return null;
}
await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return document?.ToDomain();
}
public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
var path = GetPath(state.RunId);
var document = StateDocument.FromDomain(state);
Directory.CreateDirectory(rootPath);
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
finally
{
mutex.Release();
}
}
public async Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(rootPath))
{
return Array.Empty<PackRunState>();
}
var states = new List<PackRunState>();
var files = Directory.EnumerateFiles(rootPath, "*.json", SearchOption.TopDirectoryOnly)
.OrderBy(file => file, StringComparer.Ordinal);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is not null)
{
states.Add(document.ToDomain());
}
}
return states;
}
private string GetPath(string runId)
{
var safeName = SanitizeFileName(runId);
return Path.Combine(rootPath, $"{safeName}.json");
}
private static string SanitizeFileName(string value)
{
var result = value.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
return result;
}
using System.Text.Json;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
/// <summary>
/// File-system backed implementation of <see cref="IPackRunStateStore"/> intended for development and air-gapped smoke tests.
/// </summary>
public sealed class FilePackRunStateStore : IPackRunStateStore
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly string rootPath;
private readonly SemaphoreSlim mutex = new(1, 1);
public FilePackRunStateStore(string rootPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
this.rootPath = Path.GetFullPath(rootPath);
Directory.CreateDirectory(this.rootPath);
}
public async Task<PackRunState?> GetAsync(string runId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
var path = GetPath(runId);
if (!File.Exists(path))
{
return null;
}
await using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return document?.ToDomain();
}
public async Task SaveAsync(PackRunState state, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(state);
var path = GetPath(state.RunId);
var document = StateDocument.FromDomain(state);
Directory.CreateDirectory(rootPath);
await mutex.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await using var stream = File.Open(path, FileMode.Create, FileAccess.Write, FileShare.None);
await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
finally
{
mutex.Release();
}
}
public async Task<IReadOnlyList<PackRunState>> ListAsync(CancellationToken cancellationToken)
{
if (!Directory.Exists(rootPath))
{
return Array.Empty<PackRunState>();
}
var states = new List<PackRunState>();
var files = Directory.EnumerateFiles(rootPath, "*.json", SearchOption.TopDirectoryOnly)
.OrderBy(file => file, StringComparer.Ordinal);
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
await using var stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.Read);
var document = await JsonSerializer.DeserializeAsync<StateDocument>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
if (document is not null)
{
states.Add(document.ToDomain());
}
}
return states;
}
private string GetPath(string runId)
{
var safeName = SanitizeFileName(runId);
return Path.Combine(rootPath, $"{safeName}.json");
}
private static string SanitizeFileName(string value)
{
var result = value.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
result = result.Replace(invalid, '_');
}
return result;
}
private sealed record StateDocument(
string RunId,
string PlanHash,
@@ -125,21 +125,21 @@ public sealed class FilePackRunStateStore : IPackRunStateStore
{
var steps = state.Steps.Values
.OrderBy(step => step.StepId, StringComparer.Ordinal)
.Select(step => new StepDocument(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason))
.ToList();
.Select(step => new StepDocument(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason))
.ToList();
return new StateDocument(
state.RunId,
state.PlanHash,
@@ -154,23 +154,23 @@ public sealed class FilePackRunStateStore : IPackRunStateStore
public PackRunState ToDomain()
{
var steps = Steps.ToDictionary(
step => step.StepId,
step => new PackRunStepStateRecord(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason),
StringComparer.Ordinal);
var steps = Steps.ToDictionary(
step => step.StepId,
step => new PackRunStepStateRecord(
step.StepId,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status,
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason),
StringComparer.Ordinal);
return new PackRunState(
RunId,
PlanHash,
@@ -183,18 +183,18 @@ public sealed class FilePackRunStateStore : IPackRunStateStore
TenantId);
}
}
private sealed record StepDocument(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
}
private sealed record StepDocument(
string StepId,
PackRunStepKind Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
PackRunStepExecutionStatus Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
}

View File

@@ -25,26 +25,26 @@ public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher, IPackRu
}
public string QueuePath => queuePath;
public async Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken)
{
var files = Directory.GetFiles(queuePath, "*.json", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
public async Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken)
{
var files = Directory.GetFiles(queuePath, "*.json", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
foreach (var file in files)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
{
var jobJson = await File.ReadAllTextAsync(file, cancellationToken).ConfigureAwait(false);
var job = JsonSerializer.Deserialize<JobEnvelope>(jobJson, serializerOptions);
if (job is null)
{
continue;
}
TaskPackPlan? plan = job.Plan;
if (plan is null)
{
@@ -76,12 +76,12 @@ public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher, IPackRu
}
catch (Exception ex)
{
var failedPath = file + ".failed";
File.Move(file, failedPath, overwrite: true);
Console.Error.WriteLine($"Failed to dequeue job '{file}': {ex.Message}");
}
}
var failedPath = file + ".failed";
File.Move(file, failedPath, overwrite: true);
Console.Error.WriteLine($"Failed to dequeue job '{file}': {ex.Message}");
}
}
return null;
}
@@ -108,23 +108,23 @@ public sealed class FilesystemPackRunDispatcher : IPackRunJobDispatcher, IPackRu
=> Path.IsPathRooted(relative) ? relative : Path.Combine(root, relative);
private static async Task<IDictionary<string, JsonNode?>> LoadInputsAsync(string? path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
}
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
var node = JsonNode.Parse(json) as JsonObject;
if (node is null)
{
return new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
}
return node.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.Ordinal);
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
}
var json = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
var node = JsonNode.Parse(json) as JsonObject;
if (node is null)
{
return new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
}
return node.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.Ordinal);
}
private sealed record JobEnvelope(

View File

@@ -1,73 +1,73 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class HttpPackRunNotificationPublisher : IPackRunNotificationPublisher
{
private readonly IHttpClientFactory httpClientFactory;
private readonly NotificationOptions options;
private readonly ILogger<HttpPackRunNotificationPublisher> logger;
public HttpPackRunNotificationPublisher(
IHttpClientFactory httpClientFactory,
IOptions<NotificationOptions> options,
ILogger<HttpPackRunNotificationPublisher> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
if (options.ApprovalEndpoint is null)
{
logger.LogWarning("Approval endpoint not configured; skipping approval notification for run {RunId}.", runId);
return;
}
var client = httpClientFactory.CreateClient("taskrunner-notifications");
var payload = new
{
runId,
notification.ApprovalId,
notification.RequiredGrants,
notification.Messages,
notification.StepIds,
notification.ReasonTemplate
};
var response = await client.PostAsJsonAsync(options.ApprovalEndpoint, payload, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
if (options.PolicyEndpoint is null)
{
logger.LogDebug("Policy endpoint not configured; skipping policy notification for run {RunId} step {StepId}.", runId, notification.StepId);
return;
}
var client = httpClientFactory.CreateClient("taskrunner-notifications");
var payload = new
{
runId,
notification.StepId,
notification.Message,
Parameters = notification.Parameters.Select(parameter => new
{
parameter.Name,
parameter.RequiresRuntimeValue,
parameter.Expression,
parameter.Error
})
};
var response = await client.PostAsJsonAsync(options.PolicyEndpoint, payload, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
}
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class HttpPackRunNotificationPublisher : IPackRunNotificationPublisher
{
private readonly IHttpClientFactory httpClientFactory;
private readonly NotificationOptions options;
private readonly ILogger<HttpPackRunNotificationPublisher> logger;
public HttpPackRunNotificationPublisher(
IHttpClientFactory httpClientFactory,
IOptions<NotificationOptions> options,
ILogger<HttpPackRunNotificationPublisher> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
if (options.ApprovalEndpoint is null)
{
logger.LogWarning("Approval endpoint not configured; skipping approval notification for run {RunId}.", runId);
return;
}
var client = httpClientFactory.CreateClient("taskrunner-notifications");
var payload = new
{
runId,
notification.ApprovalId,
notification.RequiredGrants,
notification.Messages,
notification.StepIds,
notification.ReasonTemplate
};
var response = await client.PostAsJsonAsync(options.ApprovalEndpoint, payload, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
if (options.PolicyEndpoint is null)
{
logger.LogDebug("Policy endpoint not configured; skipping policy notification for run {RunId} step {StepId}.", runId, notification.StepId);
return;
}
var client = httpClientFactory.CreateClient("taskrunner-notifications");
var payload = new
{
runId,
notification.StepId,
notification.Message,
Parameters = notification.Parameters.Select(parameter => new
{
parameter.Name,
parameter.RequiresRuntimeValue,
parameter.Expression,
parameter.Error
})
};
var response = await client.PostAsJsonAsync(options.PolicyEndpoint, payload, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -1,34 +1,34 @@
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class LoggingPackRunNotificationPublisher : IPackRunNotificationPublisher
{
private readonly ILogger<LoggingPackRunNotificationPublisher> logger;
public LoggingPackRunNotificationPublisher(ILogger<LoggingPackRunNotificationPublisher> logger)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
logger.LogInformation(
"Run {RunId}: approval {ApprovalId} requires grants {Grants}.",
runId,
notification.ApprovalId,
string.Join(",", notification.RequiredGrants));
return Task.CompletedTask;
}
public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
logger.LogDebug(
"Run {RunId}: policy gate {StepId} pending (parameters: {Parameters}).",
runId,
notification.StepId,
string.Join(",", notification.Parameters.Select(p => p.Name)));
return Task.CompletedTask;
}
}
using Microsoft.Extensions.Logging;
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class LoggingPackRunNotificationPublisher : IPackRunNotificationPublisher
{
private readonly ILogger<LoggingPackRunNotificationPublisher> logger;
public LoggingPackRunNotificationPublisher(ILogger<LoggingPackRunNotificationPublisher> logger)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
logger.LogInformation(
"Run {RunId}: approval {ApprovalId} requires grants {Grants}.",
runId,
notification.ApprovalId,
string.Join(",", notification.RequiredGrants));
return Task.CompletedTask;
}
public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
logger.LogDebug(
"Run {RunId}: policy gate {StepId} pending (parameters: {Parameters}).",
runId,
notification.StepId,
string.Join(",", notification.Parameters.Select(p => p.Name)));
return Task.CompletedTask;
}
}

View File

@@ -1,9 +1,9 @@
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NoopPackRunJobDispatcher : IPackRunJobDispatcher
{
public Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken)
=> Task.FromResult<PackRunExecutionContext?>(null);
}
using StellaOps.TaskRunner.Core.Execution;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NoopPackRunJobDispatcher : IPackRunJobDispatcher
{
public Task<PackRunExecutionContext?> TryDequeueAsync(CancellationToken cancellationToken)
=> Task.FromResult<PackRunExecutionContext?>(null);
}

View File

@@ -1,24 +1,24 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NoopPackRunStepExecutor : IPackRunStepExecutor
{
public Task<PackRunStepExecutionResult> ExecuteAsync(
PackRunExecutionStep step,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
CancellationToken cancellationToken)
{
if (parameters.TryGetValue("simulateFailure", out var value) &&
value.Value is JsonValue jsonValue &&
jsonValue.TryGetValue<bool>(out var failure) &&
failure)
{
return Task.FromResult(new PackRunStepExecutionResult(false, "Simulated failure requested."));
}
return Task.FromResult(new PackRunStepExecutionResult(true));
}
}
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NoopPackRunStepExecutor : IPackRunStepExecutor
{
public Task<PackRunStepExecutionResult> ExecuteAsync(
PackRunExecutionStep step,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
CancellationToken cancellationToken)
{
if (parameters.TryGetValue("simulateFailure", out var value) &&
value.Value is JsonValue jsonValue &&
jsonValue.TryGetValue<bool>(out var failure) &&
failure)
{
return Task.FromResult(new PackRunStepExecutionResult(false, "Simulated failure requested."));
}
return Task.FromResult(new PackRunStepExecutionResult(true));
}
}

View File

@@ -1,8 +1,8 @@
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NotificationOptions
{
public Uri? ApprovalEndpoint { get; set; }
public Uri? PolicyEndpoint { get; set; }
}
namespace StellaOps.TaskRunner.Infrastructure.Execution;
public sealed class NotificationOptions
{
public Uri? ApprovalEndpoint { get; set; }
public Uri? PolicyEndpoint { get; set; }
}

View File

@@ -1,66 +1,66 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Infrastructure.Execution;
namespace StellaOps.TaskRunner.Tests;
public sealed class FilePackRunStateStoreTests
{
[Fact]
public async Task SaveAndGetAsync_RoundTripsState()
{
var directory = CreateTempDirectory();
try
{
var store = new FilePackRunStateStore(directory);
var original = CreateState("run:primary");
await store.SaveAsync(original, CancellationToken.None);
var reloaded = await store.GetAsync("run:primary", CancellationToken.None);
Assert.NotNull(reloaded);
Assert.Equal(original.RunId, reloaded!.RunId);
Assert.Equal(original.PlanHash, reloaded.PlanHash);
Assert.Equal(original.FailurePolicy, reloaded.FailurePolicy);
Assert.Equal(original.Steps.Count, reloaded.Steps.Count);
var step = Assert.Single(reloaded.Steps);
Assert.Equal("step-a", step.Key);
Assert.Equal(original.Steps["step-a"], step.Value);
}
finally
{
TryDelete(directory);
}
}
[Fact]
public async Task ListAsync_ReturnsStatesInDeterministicOrder()
{
var directory = CreateTempDirectory();
try
{
var store = new FilePackRunStateStore(directory);
var stateB = CreateState("run-b");
var stateA = CreateState("run-a");
await store.SaveAsync(stateB, CancellationToken.None);
await store.SaveAsync(stateA, CancellationToken.None);
var states = await store.ListAsync(CancellationToken.None);
Assert.Collection(states,
first => Assert.Equal("run-a", first.RunId),
second => Assert.Equal("run-b", second.RunId));
}
finally
{
TryDelete(directory);
}
}
private static PackRunState CreateState(string runId)
{
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Infrastructure.Execution;
namespace StellaOps.TaskRunner.Tests;
public sealed class FilePackRunStateStoreTests
{
[Fact]
public async Task SaveAndGetAsync_RoundTripsState()
{
var directory = CreateTempDirectory();
try
{
var store = new FilePackRunStateStore(directory);
var original = CreateState("run:primary");
await store.SaveAsync(original, CancellationToken.None);
var reloaded = await store.GetAsync("run:primary", CancellationToken.None);
Assert.NotNull(reloaded);
Assert.Equal(original.RunId, reloaded!.RunId);
Assert.Equal(original.PlanHash, reloaded.PlanHash);
Assert.Equal(original.FailurePolicy, reloaded.FailurePolicy);
Assert.Equal(original.Steps.Count, reloaded.Steps.Count);
var step = Assert.Single(reloaded.Steps);
Assert.Equal("step-a", step.Key);
Assert.Equal(original.Steps["step-a"], step.Value);
}
finally
{
TryDelete(directory);
}
}
[Fact]
public async Task ListAsync_ReturnsStatesInDeterministicOrder()
{
var directory = CreateTempDirectory();
try
{
var store = new FilePackRunStateStore(directory);
var stateB = CreateState("run-b");
var stateA = CreateState("run-a");
await store.SaveAsync(stateB, CancellationToken.None);
await store.SaveAsync(stateA, CancellationToken.None);
var states = await store.ListAsync(CancellationToken.None);
Assert.Collection(states,
first => Assert.Equal("run-a", first.RunId),
second => Assert.Equal("run-b", second.RunId));
}
finally
{
TryDelete(directory);
}
}
private static PackRunState CreateState(string runId)
{
var failurePolicy = new TaskPackPlanFailurePolicy(MaxAttempts: 3, BackoffSeconds: 30, ContinueOnError: false);
var metadata = new TaskPackPlanMetadata("sample", "1.0.0", null, Array.Empty<string>());
var parameters = new Dictionary<string, TaskPackPlanParameterValue>(StringComparer.Ordinal);
@@ -89,41 +89,41 @@ public sealed class FilePackRunStateStoreTests
["step-a"] = new PackRunStepStateRecord(
StepId: "step-a",
Kind: PackRunStepKind.Run,
Enabled: true,
ContinueOnError: false,
MaxParallel: null,
ApprovalId: null,
GateMessage: null,
Status: PackRunStepExecutionStatus.Pending,
Attempts: 1,
LastTransitionAt: DateTimeOffset.UtcNow,
NextAttemptAt: null,
Enabled: true,
ContinueOnError: false,
MaxParallel: null,
ApprovalId: null,
GateMessage: null,
Status: PackRunStepExecutionStatus.Pending,
Attempts: 1,
LastTransitionAt: DateTimeOffset.UtcNow,
NextAttemptAt: null,
StatusReason: null)
};
var timestamp = DateTimeOffset.UtcNow;
return PackRunState.Create(runId, "hash-123", plan, failurePolicy, timestamp, steps, timestamp);
}
private static string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "stellaops-taskrunner-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static void TryDelete(string directory)
{
try
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
catch
{
// Swallow cleanup errors to avoid masking test assertions.
}
}
}
}
private static string CreateTempDirectory()
{
var path = Path.Combine(Path.GetTempPath(), "stellaops-taskrunner-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);
return path;
}
private static void TryDelete(string directory)
{
try
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
catch
{
// Swallow cleanup errors to avoid masking test assertions.
}
}
}

View File

@@ -1,95 +1,95 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunApprovalCoordinatorTests
{
[Fact]
public void Create_FromPlan_PopulatesApprovals()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var approvals = coordinator.GetApprovals();
Assert.Single(approvals);
Assert.Equal("security-review", approvals[0].ApprovalId);
Assert.Equal(PackRunApprovalStatus.Pending, approvals[0].Status);
}
[Fact]
public void Approve_AllowsResumeWhenLastApprovalCompletes()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var result = coordinator.Approve("security-review", "approver-1", DateTimeOffset.UtcNow);
Assert.True(result.ShouldResumeRun);
Assert.Equal(PackRunApprovalStatus.Approved, result.State.Status);
Assert.Equal("approver-1", result.State.ActorId);
}
[Fact]
public void Reject_DoesNotResumeAndMarksState()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var result = coordinator.Reject("security-review", "approver-1", DateTimeOffset.UtcNow, "Not safe");
Assert.False(result.ShouldResumeRun);
Assert.Equal(PackRunApprovalStatus.Rejected, result.State.Status);
Assert.Equal("Not safe", result.State.Summary);
}
[Fact]
public void BuildNotifications_UsesRequirements()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var notifications = coordinator.BuildNotifications(plan);
Assert.Single(notifications);
var notification = notifications[0];
Assert.Equal("security-review", notification.ApprovalId);
Assert.Contains("Packs.Approve", notification.RequiredGrants);
}
[Fact]
public void BuildPolicyNotifications_ProducesGateMetadata()
{
var plan = BuildPolicyPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var notifications = coordinator.BuildPolicyNotifications(plan);
Assert.Single(notifications);
var hint = notifications[0];
Assert.Equal("policy-check", hint.StepId);
var parameter = hint.Parameters.Single(p => p.Name == "threshold");
Assert.False(parameter.RequiresRuntimeValue);
var runtimeParam = hint.Parameters.Single(p => p.Name == "evidenceRef");
Assert.True(runtimeParam.RequiresRuntimeValue);
Assert.Equal("steps.prepare.outputs.evidence", runtimeParam.Expression);
}
private static TaskPackPlan BuildPlan()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(false)
};
return planner.Plan(manifest, inputs).Plan!;
}
private static TaskPackPlan BuildPolicyPlan()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
return planner.Plan(manifest).Plan!;
}
}
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunApprovalCoordinatorTests
{
[Fact]
public void Create_FromPlan_PopulatesApprovals()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var approvals = coordinator.GetApprovals();
Assert.Single(approvals);
Assert.Equal("security-review", approvals[0].ApprovalId);
Assert.Equal(PackRunApprovalStatus.Pending, approvals[0].Status);
}
[Fact]
public void Approve_AllowsResumeWhenLastApprovalCompletes()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var result = coordinator.Approve("security-review", "approver-1", DateTimeOffset.UtcNow);
Assert.True(result.ShouldResumeRun);
Assert.Equal(PackRunApprovalStatus.Approved, result.State.Status);
Assert.Equal("approver-1", result.State.ActorId);
}
[Fact]
public void Reject_DoesNotResumeAndMarksState()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var result = coordinator.Reject("security-review", "approver-1", DateTimeOffset.UtcNow, "Not safe");
Assert.False(result.ShouldResumeRun);
Assert.Equal(PackRunApprovalStatus.Rejected, result.State.Status);
Assert.Equal("Not safe", result.State.Summary);
}
[Fact]
public void BuildNotifications_UsesRequirements()
{
var plan = BuildPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var notifications = coordinator.BuildNotifications(plan);
Assert.Single(notifications);
var notification = notifications[0];
Assert.Equal("security-review", notification.ApprovalId);
Assert.Contains("Packs.Approve", notification.RequiredGrants);
}
[Fact]
public void BuildPolicyNotifications_ProducesGateMetadata()
{
var plan = BuildPolicyPlan();
var coordinator = PackRunApprovalCoordinator.Create(plan, DateTimeOffset.UtcNow);
var notifications = coordinator.BuildPolicyNotifications(plan);
Assert.Single(notifications);
var hint = notifications[0];
Assert.Equal("policy-check", hint.StepId);
var parameter = hint.Parameters.Single(p => p.Name == "threshold");
Assert.False(parameter.RequiresRuntimeValue);
var runtimeParam = hint.Parameters.Single(p => p.Name == "evidenceRef");
Assert.True(runtimeParam.RequiresRuntimeValue);
Assert.Equal("steps.prepare.outputs.evidence", runtimeParam.Expression);
}
private static TaskPackPlan BuildPlan()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(false)
};
return planner.Plan(manifest, inputs).Plan!;
}
private static TaskPackPlan BuildPolicyPlan()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
return planner.Plan(manifest).Plan!;
}
}

View File

@@ -1,68 +1,68 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunExecutionGraphBuilderTests
{
[Fact]
public void Build_GeneratesParallelMetadata()
{
var manifest = TestManifests.Load(TestManifests.Parallel);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var plan = result.Plan!;
var builder = new PackRunExecutionGraphBuilder();
var graph = builder.Build(plan);
Assert.Equal(2, graph.FailurePolicy.MaxAttempts);
Assert.Equal(10, graph.FailurePolicy.BackoffSeconds);
var parallel = Assert.Single(graph.Steps);
Assert.Equal(PackRunStepKind.Parallel, parallel.Kind);
Assert.True(parallel.Enabled);
Assert.Equal(2, parallel.MaxParallel);
Assert.True(parallel.ContinueOnError);
Assert.Equal(2, parallel.Children.Count);
Assert.All(parallel.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
}
[Fact]
public void Build_PreservesMapIterationsAndDisabledSteps()
{
var planner = new TaskPackPlanner();
var builder = new PackRunExecutionGraphBuilder();
// Map iterations
var mapManifest = TestManifests.Load(TestManifests.Map);
var inputs = new Dictionary<string, JsonNode?>
{
["targets"] = new JsonArray("alpha", "beta", "gamma")
};
var mapPlan = planner.Plan(mapManifest, inputs).Plan!;
var mapGraph = builder.Build(mapPlan);
var mapStep = Assert.Single(mapGraph.Steps);
Assert.Equal(PackRunStepKind.Map, mapStep.Kind);
Assert.Equal(3, mapStep.Children.Count);
Assert.All(mapStep.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
// Disabled conditional step
var conditionalManifest = TestManifests.Load(TestManifests.Sample);
var conditionalInputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(true)
};
var conditionalPlan = planner.Plan(conditionalManifest, conditionalInputs).Plan!;
var conditionalGraph = builder.Build(conditionalPlan);
var applyStep = conditionalGraph.Steps.Single(step => step.Id == "apply-step");
Assert.False(applyStep.Enabled);
}
}
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunExecutionGraphBuilderTests
{
[Fact]
public void Build_GeneratesParallelMetadata()
{
var manifest = TestManifests.Load(TestManifests.Parallel);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var plan = result.Plan!;
var builder = new PackRunExecutionGraphBuilder();
var graph = builder.Build(plan);
Assert.Equal(2, graph.FailurePolicy.MaxAttempts);
Assert.Equal(10, graph.FailurePolicy.BackoffSeconds);
var parallel = Assert.Single(graph.Steps);
Assert.Equal(PackRunStepKind.Parallel, parallel.Kind);
Assert.True(parallel.Enabled);
Assert.Equal(2, parallel.MaxParallel);
Assert.True(parallel.ContinueOnError);
Assert.Equal(2, parallel.Children.Count);
Assert.All(parallel.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
}
[Fact]
public void Build_PreservesMapIterationsAndDisabledSteps()
{
var planner = new TaskPackPlanner();
var builder = new PackRunExecutionGraphBuilder();
// Map iterations
var mapManifest = TestManifests.Load(TestManifests.Map);
var inputs = new Dictionary<string, JsonNode?>
{
["targets"] = new JsonArray("alpha", "beta", "gamma")
};
var mapPlan = planner.Plan(mapManifest, inputs).Plan!;
var mapGraph = builder.Build(mapPlan);
var mapStep = Assert.Single(mapGraph.Steps);
Assert.Equal(PackRunStepKind.Map, mapStep.Kind);
Assert.Equal(3, mapStep.Children.Count);
Assert.All(mapStep.Children, child => Assert.Equal(PackRunStepKind.Run, child.Kind));
// Disabled conditional step
var conditionalManifest = TestManifests.Load(TestManifests.Sample);
var conditionalInputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(true)
};
var conditionalPlan = planner.Plan(conditionalManifest, conditionalInputs).Plan!;
var conditionalGraph = builder.Build(conditionalPlan);
var applyStep = conditionalGraph.Steps.Single(step => step.Id == "apply-step");
Assert.False(applyStep.Enabled);
}
}

View File

@@ -1,150 +1,150 @@
using System;
using System.Collections.Generic;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunGateStateUpdaterTests
{
private static readonly DateTimeOffset RequestedAt = DateTimeOffset.UnixEpoch;
private static readonly DateTimeOffset UpdateTimestamp = DateTimeOffset.UnixEpoch.AddMinutes(5);
[Fact]
public void Apply_ApprovedGate_ClearsReasonAndSucceeds()
{
var plan = BuildApprovalPlan();
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var state = CreateInitialState(plan, graph);
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
coordinator.Approve("security-review", "approver-1", UpdateTimestamp);
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
Assert.False(result.HasBlockingFailure);
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
var gate = result.State.Steps["approval"];
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
Assert.Null(gate.StatusReason);
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
}
[Fact]
public void Apply_RejectedGate_FlagsFailure()
{
var plan = BuildApprovalPlan();
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var state = CreateInitialState(plan, graph);
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
coordinator.Reject("security-review", "approver-1", UpdateTimestamp, "not-safe");
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
Assert.True(result.HasBlockingFailure);
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
var gate = result.State.Steps["approval"];
Assert.Equal(PackRunStepExecutionStatus.Failed, gate.Status);
Assert.StartsWith("approval-rejected", gate.StatusReason, StringComparison.Ordinal);
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
}
[Fact]
public void Apply_PolicyGate_ClearsPendingReason()
{
var plan = BuildPolicyPlan();
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var state = CreateInitialState(plan, graph);
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
Assert.False(result.HasBlockingFailure);
var gate = result.State.Steps["policy-check"];
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
Assert.Null(gate.StatusReason);
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
var prepare = result.State.Steps["prepare"];
Assert.Equal(PackRunStepExecutionStatus.Pending, prepare.Status);
Assert.Null(prepare.StatusReason);
}
private static TaskPackPlan BuildApprovalPlan()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, System.Text.Json.Nodes.JsonNode?>
{
["dryRun"] = System.Text.Json.Nodes.JsonValue.Create(false)
};
return planner.Plan(manifest, inputs).Plan!;
}
private static TaskPackPlan BuildPolicyPlan()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
return planner.Plan(manifest).Plan!;
}
private static PackRunState CreateInitialState(TaskPackPlan plan, PackRunExecutionGraph graph)
{
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal);
foreach (var step in EnumerateSteps(graph.Steps))
{
var status = PackRunStepExecutionStatus.Pending;
string? reason = null;
if (!step.Enabled)
{
status = PackRunStepExecutionStatus.Skipped;
reason = "disabled";
}
else if (step.Kind == PackRunStepKind.GateApproval)
{
reason = "requires-approval";
}
else if (step.Kind == PackRunStepKind.GatePolicy)
{
reason = "requires-policy";
}
steps[step.Id] = new PackRunStepStateRecord(
step.Id,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
status,
Attempts: 0,
LastTransitionAt: null,
NextAttemptAt: null,
StatusReason: reason);
}
using System;
using System.Collections.Generic;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunGateStateUpdaterTests
{
private static readonly DateTimeOffset RequestedAt = DateTimeOffset.UnixEpoch;
private static readonly DateTimeOffset UpdateTimestamp = DateTimeOffset.UnixEpoch.AddMinutes(5);
[Fact]
public void Apply_ApprovedGate_ClearsReasonAndSucceeds()
{
var plan = BuildApprovalPlan();
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var state = CreateInitialState(plan, graph);
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
coordinator.Approve("security-review", "approver-1", UpdateTimestamp);
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
Assert.False(result.HasBlockingFailure);
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
var gate = result.State.Steps["approval"];
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
Assert.Null(gate.StatusReason);
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
}
[Fact]
public void Apply_RejectedGate_FlagsFailure()
{
var plan = BuildApprovalPlan();
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var state = CreateInitialState(plan, graph);
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
coordinator.Reject("security-review", "approver-1", UpdateTimestamp, "not-safe");
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
Assert.True(result.HasBlockingFailure);
Assert.Equal(UpdateTimestamp, result.State.UpdatedAt);
var gate = result.State.Steps["approval"];
Assert.Equal(PackRunStepExecutionStatus.Failed, gate.Status);
Assert.StartsWith("approval-rejected", gate.StatusReason, StringComparison.Ordinal);
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
}
[Fact]
public void Apply_PolicyGate_ClearsPendingReason()
{
var plan = BuildPolicyPlan();
var graph = new PackRunExecutionGraphBuilder().Build(plan);
var state = CreateInitialState(plan, graph);
var coordinator = PackRunApprovalCoordinator.Create(plan, RequestedAt);
var result = PackRunGateStateUpdater.Apply(state, graph, coordinator, UpdateTimestamp);
Assert.False(result.HasBlockingFailure);
var gate = result.State.Steps["policy-check"];
Assert.Equal(PackRunStepExecutionStatus.Succeeded, gate.Status);
Assert.Null(gate.StatusReason);
Assert.Equal(UpdateTimestamp, gate.LastTransitionAt);
var prepare = result.State.Steps["prepare"];
Assert.Equal(PackRunStepExecutionStatus.Pending, prepare.Status);
Assert.Null(prepare.StatusReason);
}
private static TaskPackPlan BuildApprovalPlan()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, System.Text.Json.Nodes.JsonNode?>
{
["dryRun"] = System.Text.Json.Nodes.JsonValue.Create(false)
};
return planner.Plan(manifest, inputs).Plan!;
}
private static TaskPackPlan BuildPolicyPlan()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
return planner.Plan(manifest).Plan!;
}
private static PackRunState CreateInitialState(TaskPackPlan plan, PackRunExecutionGraph graph)
{
var steps = new Dictionary<string, PackRunStepStateRecord>(StringComparer.Ordinal);
foreach (var step in EnumerateSteps(graph.Steps))
{
var status = PackRunStepExecutionStatus.Pending;
string? reason = null;
if (!step.Enabled)
{
status = PackRunStepExecutionStatus.Skipped;
reason = "disabled";
}
else if (step.Kind == PackRunStepKind.GateApproval)
{
reason = "requires-approval";
}
else if (step.Kind == PackRunStepKind.GatePolicy)
{
reason = "requires-policy";
}
steps[step.Id] = new PackRunStepStateRecord(
step.Id,
step.Kind,
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
status,
Attempts: 0,
LastTransitionAt: null,
NextAttemptAt: null,
StatusReason: reason);
}
return PackRunState.Create("run-1", plan.Hash, plan, graph.FailurePolicy, RequestedAt, steps, RequestedAt);
}
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
{
foreach (var step in steps)
{
yield return step;
if (step.Children.Count > 0)
{
foreach (var child in EnumerateSteps(step.Children))
{
yield return child;
}
}
}
}
}
}
private static IEnumerable<PackRunExecutionStep> EnumerateSteps(IReadOnlyList<PackRunExecutionStep> steps)
{
foreach (var step in steps)
{
yield return step;
if (step.Children.Count > 0)
{
foreach (var child in EnumerateSteps(step.Children))
{
yield return child;
}
}
}
}
}

View File

@@ -1,85 +1,85 @@
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunProcessorTests
{
[Fact]
public async Task ProcessNewRunAsync_PersistsApprovalsAndPublishesNotifications()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest, new Dictionary<string, JsonNode?> { ["dryRun"] = JsonValue.Create(false) }).Plan!;
var context = new PackRunExecutionContext("run-123", plan, DateTimeOffset.UtcNow);
var store = new TestApprovalStore();
var publisher = new TestNotificationPublisher();
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
Assert.False(result.ShouldResumeImmediately);
var saved = Assert.Single(store.Saved);
Assert.Equal("security-review", saved.ApprovalId);
Assert.Single(publisher.Approvals);
Assert.Empty(publisher.Policies);
}
[Fact]
public async Task ProcessNewRunAsync_NoApprovals_ResumesImmediately()
{
var manifest = TestManifests.Load(TestManifests.Output);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var context = new PackRunExecutionContext("run-456", plan, DateTimeOffset.UtcNow);
var store = new TestApprovalStore();
var publisher = new TestNotificationPublisher();
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
Assert.True(result.ShouldResumeImmediately);
Assert.Empty(store.Saved);
Assert.Empty(publisher.Approvals);
}
private sealed class TestApprovalStore : IPackRunApprovalStore
{
public List<PackRunApprovalState> Saved { get; } = new();
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
=> Task.FromResult((IReadOnlyList<PackRunApprovalState>)Saved);
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
Saved.Clear();
Saved.AddRange(approvals);
return Task.CompletedTask;
}
public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class TestNotificationPublisher : IPackRunNotificationPublisher
{
public List<ApprovalNotification> Approvals { get; } = new();
public List<PolicyGateNotification> Policies { get; } = new();
public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
Approvals.Add(notification);
return Task.CompletedTask;
}
public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
Policies.Add(notification);
return Task.CompletedTask;
}
}
}
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
using System.Text.Json.Nodes;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunProcessorTests
{
[Fact]
public async Task ProcessNewRunAsync_PersistsApprovalsAndPublishesNotifications()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest, new Dictionary<string, JsonNode?> { ["dryRun"] = JsonValue.Create(false) }).Plan!;
var context = new PackRunExecutionContext("run-123", plan, DateTimeOffset.UtcNow);
var store = new TestApprovalStore();
var publisher = new TestNotificationPublisher();
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
Assert.False(result.ShouldResumeImmediately);
var saved = Assert.Single(store.Saved);
Assert.Equal("security-review", saved.ApprovalId);
Assert.Single(publisher.Approvals);
Assert.Empty(publisher.Policies);
}
[Fact]
public async Task ProcessNewRunAsync_NoApprovals_ResumesImmediately()
{
var manifest = TestManifests.Load(TestManifests.Output);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var context = new PackRunExecutionContext("run-456", plan, DateTimeOffset.UtcNow);
var store = new TestApprovalStore();
var publisher = new TestNotificationPublisher();
var processor = new PackRunProcessor(store, publisher, NullLogger<PackRunProcessor>.Instance);
var result = await processor.ProcessNewRunAsync(context, CancellationToken.None);
Assert.True(result.ShouldResumeImmediately);
Assert.Empty(store.Saved);
Assert.Empty(publisher.Approvals);
}
private sealed class TestApprovalStore : IPackRunApprovalStore
{
public List<PackRunApprovalState> Saved { get; } = new();
public Task<IReadOnlyList<PackRunApprovalState>> GetAsync(string runId, CancellationToken cancellationToken)
=> Task.FromResult((IReadOnlyList<PackRunApprovalState>)Saved);
public Task SaveAsync(string runId, IReadOnlyList<PackRunApprovalState> approvals, CancellationToken cancellationToken)
{
Saved.Clear();
Saved.AddRange(approvals);
return Task.CompletedTask;
}
public Task UpdateAsync(string runId, PackRunApprovalState approval, CancellationToken cancellationToken)
=> Task.CompletedTask;
}
private sealed class TestNotificationPublisher : IPackRunNotificationPublisher
{
public List<ApprovalNotification> Approvals { get; } = new();
public List<PolicyGateNotification> Policies { get; } = new();
public Task PublishApprovalRequestedAsync(string runId, ApprovalNotification notification, CancellationToken cancellationToken)
{
Approvals.Add(notification);
return Task.CompletedTask;
}
public Task PublishPolicyGatePendingAsync(string runId, PolicyGateNotification notification, CancellationToken cancellationToken)
{
Policies.Add(notification);
return Task.CompletedTask;
}
}
}

View File

@@ -1,142 +1,142 @@
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunSimulationEngineTests
{
[Fact]
public void Simulate_IdentifiesGateStatuses()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var gate = result.Steps.Single(step => step.Kind == PackRunStepKind.GatePolicy);
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, gate.Status);
var run = result.Steps.Single(step => step.Kind == PackRunStepKind.Run);
Assert.Equal(PackRunSimulationStatus.Pending, run.Status);
}
[Fact]
public void Simulate_MarksDisabledStepsAndOutputs()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(true)
};
var plan = planner.Plan(manifest, inputs).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var applyStep = result.Steps.Single(step => step.Id == "apply-step");
Assert.Equal(PackRunSimulationStatus.Skipped, applyStep.Status);
Assert.Empty(result.Outputs);
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.MaxAttempts, result.FailurePolicy.MaxAttempts);
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.BackoffSeconds, result.FailurePolicy.BackoffSeconds);
}
[Fact]
public void Simulate_ProjectsOutputsAndRuntimeFlags()
{
var manifest = TestManifests.Load(TestManifests.Output);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var step = Assert.Single(result.Steps);
Assert.Equal(PackRunStepKind.Run, step.Kind);
Assert.Collection(result.Outputs,
bundle =>
{
Assert.Equal("bundlePath", bundle.Name);
Assert.False(bundle.RequiresRuntimeValue);
},
evidence =>
{
Assert.Equal("evidenceModel", evidence.Name);
Assert.True(evidence.RequiresRuntimeValue);
});
}
[Fact]
public void Simulate_LoopStep_SetsWillIterateStatus()
{
var manifest = TestManifests.Load(TestManifests.Loop);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["targets"] = new JsonArray { "a", "b", "c" }
};
var result = planner.Plan(manifest, inputs);
Assert.Empty(result.Errors);
Assert.NotNull(result.Plan);
var engine = new PackRunSimulationEngine();
var simResult = engine.Simulate(result.Plan);
var loopStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Loop);
Assert.Equal(PackRunSimulationStatus.WillIterate, loopStep.Status);
Assert.Equal("process-loop", loopStep.Id);
Assert.NotNull(loopStep.LoopInfo);
Assert.Equal("target", loopStep.LoopInfo.Iterator);
Assert.Equal("idx", loopStep.LoopInfo.Index);
Assert.Equal(100, loopStep.LoopInfo.MaxIterations);
Assert.Equal("collect", loopStep.LoopInfo.AggregationMode);
}
[Fact]
public void Simulate_ConditionalStep_SetsWillBranchStatus()
{
var manifest = TestManifests.Load(TestManifests.Conditional);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["environment"] = JsonValue.Create("production")
};
var result = planner.Plan(manifest, inputs);
Assert.Empty(result.Errors);
Assert.NotNull(result.Plan);
var engine = new PackRunSimulationEngine();
var simResult = engine.Simulate(result.Plan);
var conditionalStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Conditional);
Assert.Equal(PackRunSimulationStatus.WillBranch, conditionalStep.Status);
Assert.Equal("env-branch", conditionalStep.Id);
Assert.NotNull(conditionalStep.ConditionalInfo);
Assert.Equal(2, conditionalStep.ConditionalInfo.Branches.Count);
Assert.True(conditionalStep.ConditionalInfo.OutputUnion);
}
[Fact]
public void Simulate_PolicyGateStep_HasPolicyInfo()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var policyStep = result.Steps.Single(s => s.Kind == PackRunStepKind.GatePolicy);
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, policyStep.Status);
Assert.NotNull(policyStep.PolicyInfo);
Assert.Equal("security-hold", policyStep.PolicyInfo.PolicyId);
Assert.Equal("abort", policyStep.PolicyInfo.FailureAction);
}
}
using System.Text.Json.Nodes;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunSimulationEngineTests
{
[Fact]
public void Simulate_IdentifiesGateStatuses()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var gate = result.Steps.Single(step => step.Kind == PackRunStepKind.GatePolicy);
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, gate.Status);
var run = result.Steps.Single(step => step.Kind == PackRunStepKind.Run);
Assert.Equal(PackRunSimulationStatus.Pending, run.Status);
}
[Fact]
public void Simulate_MarksDisabledStepsAndOutputs()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(true)
};
var plan = planner.Plan(manifest, inputs).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var applyStep = result.Steps.Single(step => step.Id == "apply-step");
Assert.Equal(PackRunSimulationStatus.Skipped, applyStep.Status);
Assert.Empty(result.Outputs);
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.MaxAttempts, result.FailurePolicy.MaxAttempts);
Assert.Equal(PackRunExecutionGraph.DefaultFailurePolicy.BackoffSeconds, result.FailurePolicy.BackoffSeconds);
}
[Fact]
public void Simulate_ProjectsOutputsAndRuntimeFlags()
{
var manifest = TestManifests.Load(TestManifests.Output);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var step = Assert.Single(result.Steps);
Assert.Equal(PackRunStepKind.Run, step.Kind);
Assert.Collection(result.Outputs,
bundle =>
{
Assert.Equal("bundlePath", bundle.Name);
Assert.False(bundle.RequiresRuntimeValue);
},
evidence =>
{
Assert.Equal("evidenceModel", evidence.Name);
Assert.True(evidence.RequiresRuntimeValue);
});
}
[Fact]
public void Simulate_LoopStep_SetsWillIterateStatus()
{
var manifest = TestManifests.Load(TestManifests.Loop);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["targets"] = new JsonArray { "a", "b", "c" }
};
var result = planner.Plan(manifest, inputs);
Assert.Empty(result.Errors);
Assert.NotNull(result.Plan);
var engine = new PackRunSimulationEngine();
var simResult = engine.Simulate(result.Plan);
var loopStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Loop);
Assert.Equal(PackRunSimulationStatus.WillIterate, loopStep.Status);
Assert.Equal("process-loop", loopStep.Id);
Assert.NotNull(loopStep.LoopInfo);
Assert.Equal("target", loopStep.LoopInfo.Iterator);
Assert.Equal("idx", loopStep.LoopInfo.Index);
Assert.Equal(100, loopStep.LoopInfo.MaxIterations);
Assert.Equal("collect", loopStep.LoopInfo.AggregationMode);
}
[Fact]
public void Simulate_ConditionalStep_SetsWillBranchStatus()
{
var manifest = TestManifests.Load(TestManifests.Conditional);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["environment"] = JsonValue.Create("production")
};
var result = planner.Plan(manifest, inputs);
Assert.Empty(result.Errors);
Assert.NotNull(result.Plan);
var engine = new PackRunSimulationEngine();
var simResult = engine.Simulate(result.Plan);
var conditionalStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Conditional);
Assert.Equal(PackRunSimulationStatus.WillBranch, conditionalStep.Status);
Assert.Equal("env-branch", conditionalStep.Id);
Assert.NotNull(conditionalStep.ConditionalInfo);
Assert.Equal(2, conditionalStep.ConditionalInfo.Branches.Count);
Assert.True(conditionalStep.ConditionalInfo.OutputUnion);
}
[Fact]
public void Simulate_PolicyGateStep_HasPolicyInfo()
{
var manifest = TestManifests.Load(TestManifests.PolicyGate);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var engine = new PackRunSimulationEngine();
var result = engine.Simulate(plan);
var policyStep = result.Steps.Single(s => s.Kind == PackRunStepKind.GatePolicy);
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, policyStep.Status);
Assert.NotNull(policyStep.PolicyInfo);
Assert.Equal("security-hold", policyStep.PolicyInfo.PolicyId);
Assert.Equal("abort", policyStep.PolicyInfo.FailureAction);
}
}

View File

@@ -1,66 +1,66 @@
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunStepStateMachineTests
{
private static readonly TaskPackPlanFailurePolicy RetryTwicePolicy = new(MaxAttempts: 3, BackoffSeconds: 5, ContinueOnError: false);
[Fact]
public void Start_FromPending_SetsRunning()
{
var state = PackRunStepStateMachine.Create();
var started = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
Assert.Equal(PackRunStepExecutionStatus.Running, started.Status);
Assert.Equal(0, started.Attempts);
}
[Fact]
public void CompleteSuccess_IncrementsAttempts()
{
var state = PackRunStepStateMachine.Create();
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
var completed = PackRunStepStateMachine.CompleteSuccess(running, DateTimeOffset.UnixEpoch.AddSeconds(1));
Assert.Equal(PackRunStepExecutionStatus.Succeeded, completed.Status);
Assert.Equal(1, completed.Attempts);
Assert.Null(completed.NextAttemptAt);
}
[Fact]
public void RegisterFailure_SchedulesRetryUntilMaxAttempts()
{
var state = PackRunStepStateMachine.Create();
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
var firstFailure = PackRunStepStateMachine.RegisterFailure(running, DateTimeOffset.UnixEpoch.AddSeconds(2), RetryTwicePolicy);
Assert.Equal(PackRunStepFailureOutcome.Retry, firstFailure.Outcome);
Assert.Equal(PackRunStepExecutionStatus.Pending, firstFailure.State.Status);
Assert.Equal(1, firstFailure.State.Attempts);
Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(7), firstFailure.State.NextAttemptAt);
var restarted = PackRunStepStateMachine.Start(firstFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(7));
var secondFailure = PackRunStepStateMachine.RegisterFailure(restarted, DateTimeOffset.UnixEpoch.AddSeconds(9), RetryTwicePolicy);
Assert.Equal(PackRunStepFailureOutcome.Retry, secondFailure.Outcome);
Assert.Equal(2, secondFailure.State.Attempts);
var finalStart = PackRunStepStateMachine.Start(secondFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(9 + RetryTwicePolicy.BackoffSeconds));
var terminalFailure = PackRunStepStateMachine.RegisterFailure(finalStart, DateTimeOffset.UnixEpoch.AddSeconds(20), RetryTwicePolicy);
Assert.Equal(PackRunStepFailureOutcome.Abort, terminalFailure.Outcome);
Assert.Equal(PackRunStepExecutionStatus.Failed, terminalFailure.State.Status);
Assert.Equal(3, terminalFailure.State.Attempts);
Assert.Null(terminalFailure.State.NextAttemptAt);
}
[Fact]
public void Skip_FromPending_SetsSkipped()
{
var state = PackRunStepStateMachine.Create();
var skipped = PackRunStepStateMachine.Skip(state, DateTimeOffset.UnixEpoch.AddHours(1));
Assert.Equal(PackRunStepExecutionStatus.Skipped, skipped.Status);
Assert.Equal(0, skipped.Attempts);
}
}
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class PackRunStepStateMachineTests
{
private static readonly TaskPackPlanFailurePolicy RetryTwicePolicy = new(MaxAttempts: 3, BackoffSeconds: 5, ContinueOnError: false);
[Fact]
public void Start_FromPending_SetsRunning()
{
var state = PackRunStepStateMachine.Create();
var started = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
Assert.Equal(PackRunStepExecutionStatus.Running, started.Status);
Assert.Equal(0, started.Attempts);
}
[Fact]
public void CompleteSuccess_IncrementsAttempts()
{
var state = PackRunStepStateMachine.Create();
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
var completed = PackRunStepStateMachine.CompleteSuccess(running, DateTimeOffset.UnixEpoch.AddSeconds(1));
Assert.Equal(PackRunStepExecutionStatus.Succeeded, completed.Status);
Assert.Equal(1, completed.Attempts);
Assert.Null(completed.NextAttemptAt);
}
[Fact]
public void RegisterFailure_SchedulesRetryUntilMaxAttempts()
{
var state = PackRunStepStateMachine.Create();
var running = PackRunStepStateMachine.Start(state, DateTimeOffset.UnixEpoch);
var firstFailure = PackRunStepStateMachine.RegisterFailure(running, DateTimeOffset.UnixEpoch.AddSeconds(2), RetryTwicePolicy);
Assert.Equal(PackRunStepFailureOutcome.Retry, firstFailure.Outcome);
Assert.Equal(PackRunStepExecutionStatus.Pending, firstFailure.State.Status);
Assert.Equal(1, firstFailure.State.Attempts);
Assert.Equal(DateTimeOffset.UnixEpoch.AddSeconds(7), firstFailure.State.NextAttemptAt);
var restarted = PackRunStepStateMachine.Start(firstFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(7));
var secondFailure = PackRunStepStateMachine.RegisterFailure(restarted, DateTimeOffset.UnixEpoch.AddSeconds(9), RetryTwicePolicy);
Assert.Equal(PackRunStepFailureOutcome.Retry, secondFailure.Outcome);
Assert.Equal(2, secondFailure.State.Attempts);
var finalStart = PackRunStepStateMachine.Start(secondFailure.State, DateTimeOffset.UnixEpoch.AddSeconds(9 + RetryTwicePolicy.BackoffSeconds));
var terminalFailure = PackRunStepStateMachine.RegisterFailure(finalStart, DateTimeOffset.UnixEpoch.AddSeconds(20), RetryTwicePolicy);
Assert.Equal(PackRunStepFailureOutcome.Abort, terminalFailure.Outcome);
Assert.Equal(PackRunStepExecutionStatus.Failed, terminalFailure.State.Status);
Assert.Equal(3, terminalFailure.State.Attempts);
Assert.Null(terminalFailure.State.NextAttemptAt);
}
[Fact]
public void Skip_FromPending_SetsSkipped()
{
var state = PackRunStepStateMachine.Create();
var skipped = PackRunStepStateMachine.Skip(state, DateTimeOffset.UnixEpoch.AddHours(1));
Assert.Equal(PackRunStepExecutionStatus.Skipped, skipped.Status);
Assert.Equal(0, skipped.Attempts);
}
}

View File

@@ -3,39 +3,39 @@ using System.Linq;
using System.Text.Json.Nodes;
using StellaOps.AirGap.Policy;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Tests;
public sealed class TaskPackPlannerTests
{
[Fact]
public void Plan_WithSequentialSteps_ComputesDeterministicHash()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(false)
};
var resultA = planner.Plan(manifest, inputs);
Assert.True(resultA.Success);
var plan = resultA.Plan!;
Assert.Equal(3, plan.Steps.Count);
Assert.Equal("plan-step", plan.Steps[0].Id);
Assert.Equal("plan-step", plan.Steps[0].TemplateId);
Assert.Equal("run", plan.Steps[0].Type);
Assert.Equal("gate.approval", plan.Steps[1].Type);
Assert.Equal("security-review", plan.Steps[1].ApprovalId);
Assert.Equal("run", plan.Steps[2].Type);
Assert.True(plan.Steps[2].Enabled);
Assert.Single(plan.Approvals);
Assert.Equal("security-review", plan.Approvals[0].Id);
Assert.False(string.IsNullOrWhiteSpace(plan.Hash));
var resultB = planner.Plan(manifest, inputs);
Assert.True(resultB.Success);
namespace StellaOps.TaskRunner.Tests;
public sealed class TaskPackPlannerTests
{
[Fact]
public void Plan_WithSequentialSteps_ComputesDeterministicHash()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(false)
};
var resultA = planner.Plan(manifest, inputs);
Assert.True(resultA.Success);
var plan = resultA.Plan!;
Assert.Equal(3, plan.Steps.Count);
Assert.Equal("plan-step", plan.Steps[0].Id);
Assert.Equal("plan-step", plan.Steps[0].TemplateId);
Assert.Equal("run", plan.Steps[0].Type);
Assert.Equal("gate.approval", plan.Steps[1].Type);
Assert.Equal("security-review", plan.Steps[1].ApprovalId);
Assert.Equal("run", plan.Steps[2].Type);
Assert.True(plan.Steps[2].Enabled);
Assert.Single(plan.Approvals);
Assert.Equal("security-review", plan.Approvals[0].Id);
Assert.False(string.IsNullOrWhiteSpace(plan.Hash));
var resultB = planner.Plan(manifest, inputs);
Assert.True(resultB.Success);
Assert.Equal(plan.Hash, resultB.Plan!.Hash);
}
@@ -54,108 +54,108 @@ public sealed class TaskPackPlannerTests
Assert.True(hex.All(c => Uri.IsHexDigit(c)), "Hash contains non-hex characters.");
}
[Fact]
public void Plan_WhenConditionEvaluatesFalse_DisablesStep()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(true)
};
var result = planner.Plan(manifest, inputs);
Assert.True(result.Success);
Assert.False(result.Plan!.Steps[2].Enabled);
}
[Fact]
public void Plan_WithStepReferences_MarksParametersAsRuntime()
{
var manifest = TestManifests.Load(TestManifests.StepReference);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var plan = result.Plan!;
Assert.Equal(2, plan.Steps.Count);
var referenceParameters = plan.Steps[1].Parameters!;
Assert.True(referenceParameters["sourceSummary"].RequiresRuntimeValue);
Assert.Equal("steps.prepare.outputs.summary", referenceParameters["sourceSummary"].Expression);
}
[Fact]
public void Plan_WithMapStep_ExpandsIterations()
{
var manifest = TestManifests.Load(TestManifests.Map);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["targets"] = new JsonArray("alpha", "beta", "gamma")
};
var result = planner.Plan(manifest, inputs);
Assert.True(result.Success);
var plan = result.Plan!;
var mapStep = plan.Steps.Single(s => s.Type == "map");
Assert.Equal(3, mapStep.Children!.Count);
Assert.All(mapStep.Children!, child => Assert.Equal("echo-step", child.TemplateId));
Assert.Equal(3, mapStep.Parameters!["iterationCount"].Value!.GetValue<int>());
Assert.Equal("alpha", mapStep.Children![0].Parameters!["item"].Value!.GetValue<string>());
}
[Fact]
public void CollectApprovalRequirements_GroupsGates()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var requirements = TaskPackPlanInsights.CollectApprovalRequirements(plan);
Assert.Single(requirements);
var requirement = requirements[0];
Assert.Equal("security-review", requirement.ApprovalId);
Assert.Contains("Packs.Approve", requirement.Grants);
Assert.Equal(plan.Steps[1].Id, requirement.StepIds.Single());
var notifications = TaskPackPlanInsights.CollectNotificationHints(plan);
Assert.Contains(notifications, hint => hint.Type == "approval-request" && hint.StepId == plan.Steps[1].Id);
}
[Fact]
public void Plan_WithSecretReference_RecordsSecretMetadata()
{
var manifest = TestManifests.Load(TestManifests.Secret);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var plan = result.Plan!;
Assert.Single(plan.Secrets);
Assert.Equal("apiKey", plan.Secrets[0].Name);
var param = plan.Steps[0].Parameters!["token"];
Assert.True(param.RequiresRuntimeValue);
Assert.Equal("secrets.apiKey", param.Expression);
}
[Fact]
[Fact]
public void Plan_WhenConditionEvaluatesFalse_DisablesStep()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["dryRun"] = JsonValue.Create(true)
};
var result = planner.Plan(manifest, inputs);
Assert.True(result.Success);
Assert.False(result.Plan!.Steps[2].Enabled);
}
[Fact]
public void Plan_WithStepReferences_MarksParametersAsRuntime()
{
var manifest = TestManifests.Load(TestManifests.StepReference);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var plan = result.Plan!;
Assert.Equal(2, plan.Steps.Count);
var referenceParameters = plan.Steps[1].Parameters!;
Assert.True(referenceParameters["sourceSummary"].RequiresRuntimeValue);
Assert.Equal("steps.prepare.outputs.summary", referenceParameters["sourceSummary"].Expression);
}
[Fact]
public void Plan_WithMapStep_ExpandsIterations()
{
var manifest = TestManifests.Load(TestManifests.Map);
var planner = new TaskPackPlanner();
var inputs = new Dictionary<string, JsonNode?>
{
["targets"] = new JsonArray("alpha", "beta", "gamma")
};
var result = planner.Plan(manifest, inputs);
Assert.True(result.Success);
var plan = result.Plan!;
var mapStep = plan.Steps.Single(s => s.Type == "map");
Assert.Equal(3, mapStep.Children!.Count);
Assert.All(mapStep.Children!, child => Assert.Equal("echo-step", child.TemplateId));
Assert.Equal(3, mapStep.Parameters!["iterationCount"].Value!.GetValue<int>());
Assert.Equal("alpha", mapStep.Children![0].Parameters!["item"].Value!.GetValue<string>());
}
[Fact]
public void CollectApprovalRequirements_GroupsGates()
{
var manifest = TestManifests.Load(TestManifests.Sample);
var planner = new TaskPackPlanner();
var plan = planner.Plan(manifest).Plan!;
var requirements = TaskPackPlanInsights.CollectApprovalRequirements(plan);
Assert.Single(requirements);
var requirement = requirements[0];
Assert.Equal("security-review", requirement.ApprovalId);
Assert.Contains("Packs.Approve", requirement.Grants);
Assert.Equal(plan.Steps[1].Id, requirement.StepIds.Single());
var notifications = TaskPackPlanInsights.CollectNotificationHints(plan);
Assert.Contains(notifications, hint => hint.Type == "approval-request" && hint.StepId == plan.Steps[1].Id);
}
[Fact]
public void Plan_WithSecretReference_RecordsSecretMetadata()
{
var manifest = TestManifests.Load(TestManifests.Secret);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var plan = result.Plan!;
Assert.Single(plan.Secrets);
Assert.Equal("apiKey", plan.Secrets[0].Name);
var param = plan.Steps[0].Parameters!["token"];
Assert.True(param.RequiresRuntimeValue);
Assert.Equal("secrets.apiKey", param.Expression);
}
[Fact]
public void Plan_WithOutputs_ProjectsResolvedValues()
{
var manifest = TestManifests.Load(TestManifests.Output);
var planner = new TaskPackPlanner();
var result = planner.Plan(manifest);
Assert.True(result.Success);
var plan = result.Plan!;
Assert.Equal(2, plan.Outputs.Count);
var bundle = plan.Outputs.First(o => o.Name == "bundlePath");
Assert.NotNull(bundle.Path);
Assert.False(bundle.Path!.RequiresRuntimeValue);
Assert.Equal("artifacts/report.txt", bundle.Path.Value!.GetValue<string>());
var result = planner.Plan(manifest);
Assert.True(result.Success);
var plan = result.Plan!;
Assert.Equal(2, plan.Outputs.Count);
var bundle = plan.Outputs.First(o => o.Name == "bundlePath");
Assert.NotNull(bundle.Path);
Assert.False(bundle.Path!.RequiresRuntimeValue);
Assert.Equal("artifacts/report.txt", bundle.Path.Value!.GetValue<string>());
var evidence = plan.Outputs.First(o => o.Name == "evidenceModel");
Assert.NotNull(evidence.Expression);
Assert.True(evidence.Expression!.RequiresRuntimeValue);
@@ -211,8 +211,8 @@ public sealed class TaskPackPlannerTests
Assert.False(result.Success);
Assert.Contains(result.Errors, error => error.Message.Contains("example.com", StringComparison.OrdinalIgnoreCase));
}
[Fact]
[Fact]
public void Plan_WhenRequiredInputMissing_ReturnsError()
{
var manifest = TestManifests.Load(TestManifests.RequiredInput);

View File

@@ -9,16 +9,16 @@ using StellaOps.TaskRunner.Core.Configuration;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Worker.Services;
public sealed class PackRunWorkerService : BackgroundService
{
private const string ChildFailureReason = "child-failure";
private const string AwaitingRetryReason = "awaiting-retry";
private readonly IPackRunJobDispatcher dispatcher;
private readonly PackRunProcessor processor;
namespace StellaOps.TaskRunner.Worker.Services;
public sealed class PackRunWorkerService : BackgroundService
{
private const string ChildFailureReason = "child-failure";
private const string AwaitingRetryReason = "awaiting-retry";
private readonly IPackRunJobDispatcher dispatcher;
private readonly PackRunProcessor processor;
private readonly PackRunWorkerOptions options;
private readonly IPackRunStateStore stateStore;
private readonly PackRunExecutionGraphBuilder graphBuilder;
@@ -66,25 +66,25 @@ public sealed class PackRunWorkerService : BackgroundService
: 0));
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var context = await dispatcher.TryDequeueAsync(stoppingToken).ConfigureAwait(false);
if (context is null)
{
await Task.Delay(options.IdleDelay, stoppingToken).ConfigureAwait(false);
continue;
}
try
{
await ProcessRunAsync(context, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var context = await dispatcher.TryDequeueAsync(stoppingToken).ConfigureAwait(false);
if (context is null)
{
await Task.Delay(options.IdleDelay, stoppingToken).ConfigureAwait(false);
continue;
}
try
{
await ProcessRunAsync(context, stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
@@ -103,7 +103,7 @@ public sealed class PackRunWorkerService : BackgroundService
}
}
}
private async Task ProcessRunAsync(PackRunExecutionContext context, CancellationToken cancellationToken)
{
logger.LogInformation("Processing pack run {RunId}.", context.RunId);
@@ -206,55 +206,55 @@ public sealed class PackRunWorkerService : BackgroundService
var entry = new PackRunLogEntry(DateTimeOffset.UtcNow, level, eventType, message, stepId, metadata);
return logStore.AppendAsync(runId, entry, cancellationToken);
}
private async Task<PackRunState> ExecuteGraphAsync(
PackRunExecutionContext context,
PackRunExecutionGraph graph,
PackRunState state,
CancellationToken cancellationToken)
{
var mutable = new ConcurrentDictionary<string, PackRunStepStateRecord>(state.Steps, StringComparer.Ordinal);
var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy;
var executionContext = new ExecutionContext(context.RunId, failurePolicy, mutable, cancellationToken);
foreach (var step in graph.Steps)
{
var outcome = await ExecuteStepAsync(step, executionContext).ConfigureAwait(false);
if (outcome is StepExecutionOutcome.AbortRun or StepExecutionOutcome.Defer)
{
break;
}
}
var updated = new ReadOnlyDictionary<string, PackRunStepStateRecord>(mutable);
return state with
{
UpdatedAt = DateTimeOffset.UtcNow,
Steps = updated
};
}
private async Task<StepExecutionOutcome> ExecuteStepAsync(
PackRunExecutionStep step,
ExecutionContext executionContext)
{
executionContext.CancellationToken.ThrowIfCancellationRequested();
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
{
return StepExecutionOutcome.Continue;
}
if (!record.Enabled)
{
return StepExecutionOutcome.Continue;
}
if (record.Status == PackRunStepExecutionStatus.Succeeded || record.Status == PackRunStepExecutionStatus.Skipped)
{
return StepExecutionOutcome.Continue;
}
private async Task<PackRunState> ExecuteGraphAsync(
PackRunExecutionContext context,
PackRunExecutionGraph graph,
PackRunState state,
CancellationToken cancellationToken)
{
var mutable = new ConcurrentDictionary<string, PackRunStepStateRecord>(state.Steps, StringComparer.Ordinal);
var failurePolicy = graph.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy;
var executionContext = new ExecutionContext(context.RunId, failurePolicy, mutable, cancellationToken);
foreach (var step in graph.Steps)
{
var outcome = await ExecuteStepAsync(step, executionContext).ConfigureAwait(false);
if (outcome is StepExecutionOutcome.AbortRun or StepExecutionOutcome.Defer)
{
break;
}
}
var updated = new ReadOnlyDictionary<string, PackRunStepStateRecord>(mutable);
return state with
{
UpdatedAt = DateTimeOffset.UtcNow,
Steps = updated
};
}
private async Task<StepExecutionOutcome> ExecuteStepAsync(
PackRunExecutionStep step,
ExecutionContext executionContext)
{
executionContext.CancellationToken.ThrowIfCancellationRequested();
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
{
return StepExecutionOutcome.Continue;
}
if (!record.Enabled)
{
return StepExecutionOutcome.Continue;
}
if (record.Status == PackRunStepExecutionStatus.Succeeded || record.Status == PackRunStepExecutionStatus.Skipped)
{
return StepExecutionOutcome.Continue;
}
if (record.NextAttemptAt is { } scheduled && scheduled > DateTimeOffset.UtcNow)
{
logger.LogInformation(
@@ -285,7 +285,7 @@ public sealed class PackRunWorkerService : BackgroundService
executionContext.Steps[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
StatusReason = null,
LastTransitionAt = DateTimeOffset.UtcNow,
NextAttemptAt = null
};
@@ -302,11 +302,11 @@ public sealed class PackRunWorkerService : BackgroundService
return await ExecuteParallelStepAsync(step, executionContext).ConfigureAwait(false);
case PackRunStepKind.Map:
return await ExecuteMapStepAsync(step, executionContext).ConfigureAwait(false);
case PackRunStepKind.Run:
return await ExecuteRunStepAsync(step, executionContext).ConfigureAwait(false);
return await ExecuteMapStepAsync(step, executionContext).ConfigureAwait(false);
case PackRunStepKind.Run:
return await ExecuteRunStepAsync(step, executionContext).ConfigureAwait(false);
default:
logger.LogWarning("Run {RunId} encountered unsupported step kind '{Kind}' for step {StepId}. Marking as skipped.",
executionContext.RunId,
@@ -332,7 +332,7 @@ public sealed class PackRunWorkerService : BackgroundService
return StepExecutionOutcome.Continue;
}
}
private async Task<StepExecutionOutcome> ExecuteRunStepAsync(
PackRunExecutionStep step,
ExecutionContext executionContext)
@@ -468,185 +468,185 @@ public sealed class PackRunWorkerService : BackgroundService
{
PackRunStepFailureOutcome.Retry => StepExecutionOutcome.Defer,
PackRunStepFailureOutcome.Abort when step.ContinueOnError => StepExecutionOutcome.Continue,
PackRunStepFailureOutcome.Abort => StepExecutionOutcome.AbortRun,
_ => StepExecutionOutcome.AbortRun
};
}
private async Task<StepExecutionOutcome> ExecuteParallelStepAsync(
PackRunExecutionStep step,
ExecutionContext executionContext)
{
var children = step.Children;
if (children.Count == 0)
{
MarkContainerSucceeded(step, executionContext);
return StepExecutionOutcome.Continue;
}
var maxParallel = step.MaxParallel is > 0 ? step.MaxParallel.Value : children.Count;
var queue = new Queue<PackRunExecutionStep>(children);
var running = new List<Task<StepExecutionOutcome>>(maxParallel);
var outcome = StepExecutionOutcome.Continue;
var childFailureDetected = false;
while (queue.Count > 0 || running.Count > 0)
{
while (queue.Count > 0 && running.Count < maxParallel)
{
var child = queue.Dequeue();
running.Add(ExecuteStepAsync(child, executionContext));
}
var completed = await Task.WhenAny(running).ConfigureAwait(false);
running.Remove(completed);
var childOutcome = await completed.ConfigureAwait(false);
switch (childOutcome)
{
case StepExecutionOutcome.AbortRun:
if (step.ContinueOnError)
{
childFailureDetected = true;
outcome = StepExecutionOutcome.Continue;
}
else
{
outcome = StepExecutionOutcome.AbortRun;
running.Clear();
queue.Clear();
}
break;
case StepExecutionOutcome.Defer:
outcome = StepExecutionOutcome.Defer;
running.Clear();
queue.Clear();
break;
default:
break;
}
if (!step.ContinueOnError && outcome != StepExecutionOutcome.Continue)
{
break;
}
}
if (outcome == StepExecutionOutcome.Continue)
{
if (childFailureDetected)
{
MarkContainerFailure(step, executionContext, ChildFailureReason);
}
else
{
MarkContainerSucceeded(step, executionContext);
}
}
else if (outcome == StepExecutionOutcome.AbortRun)
{
MarkContainerFailure(step, executionContext, ChildFailureReason);
}
else if (outcome == StepExecutionOutcome.Defer)
{
MarkContainerPending(step, executionContext, AwaitingRetryReason);
}
return outcome;
}
private async Task<StepExecutionOutcome> ExecuteMapStepAsync(
PackRunExecutionStep step,
ExecutionContext executionContext)
{
foreach (var child in step.Children)
{
var outcome = await ExecuteStepAsync(child, executionContext).ConfigureAwait(false);
if (outcome != StepExecutionOutcome.Continue)
{
if (outcome == StepExecutionOutcome.Defer)
{
MarkContainerPending(step, executionContext, AwaitingRetryReason);
return outcome;
}
if (!step.ContinueOnError)
{
MarkContainerFailure(step, executionContext, ChildFailureReason);
return outcome;
}
MarkContainerFailure(step, executionContext, ChildFailureReason);
}
}
MarkContainerSucceeded(step, executionContext);
return StepExecutionOutcome.Continue;
}
private void MarkContainerSucceeded(PackRunExecutionStep step, ExecutionContext executionContext)
{
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
{
return;
}
if (record.Status == PackRunStepExecutionStatus.Succeeded)
{
return;
}
executionContext.Steps[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = DateTimeOffset.UtcNow,
NextAttemptAt = null
};
}
private void MarkContainerFailure(PackRunExecutionStep step, ExecutionContext executionContext, string reason)
{
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
{
return;
}
executionContext.Steps[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Failed,
StatusReason = reason,
LastTransitionAt = DateTimeOffset.UtcNow
};
}
private void MarkContainerPending(PackRunExecutionStep step, ExecutionContext executionContext, string reason)
{
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
{
return;
}
executionContext.Steps[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Pending,
StatusReason = reason,
LastTransitionAt = DateTimeOffset.UtcNow
};
}
PackRunStepFailureOutcome.Abort => StepExecutionOutcome.AbortRun,
_ => StepExecutionOutcome.AbortRun
};
}
private async Task<StepExecutionOutcome> ExecuteParallelStepAsync(
PackRunExecutionStep step,
ExecutionContext executionContext)
{
var children = step.Children;
if (children.Count == 0)
{
MarkContainerSucceeded(step, executionContext);
return StepExecutionOutcome.Continue;
}
var maxParallel = step.MaxParallel is > 0 ? step.MaxParallel.Value : children.Count;
var queue = new Queue<PackRunExecutionStep>(children);
var running = new List<Task<StepExecutionOutcome>>(maxParallel);
var outcome = StepExecutionOutcome.Continue;
var childFailureDetected = false;
while (queue.Count > 0 || running.Count > 0)
{
while (queue.Count > 0 && running.Count < maxParallel)
{
var child = queue.Dequeue();
running.Add(ExecuteStepAsync(child, executionContext));
}
var completed = await Task.WhenAny(running).ConfigureAwait(false);
running.Remove(completed);
var childOutcome = await completed.ConfigureAwait(false);
switch (childOutcome)
{
case StepExecutionOutcome.AbortRun:
if (step.ContinueOnError)
{
childFailureDetected = true;
outcome = StepExecutionOutcome.Continue;
}
else
{
outcome = StepExecutionOutcome.AbortRun;
running.Clear();
queue.Clear();
}
break;
case StepExecutionOutcome.Defer:
outcome = StepExecutionOutcome.Defer;
running.Clear();
queue.Clear();
break;
default:
break;
}
if (!step.ContinueOnError && outcome != StepExecutionOutcome.Continue)
{
break;
}
}
if (outcome == StepExecutionOutcome.Continue)
{
if (childFailureDetected)
{
MarkContainerFailure(step, executionContext, ChildFailureReason);
}
else
{
MarkContainerSucceeded(step, executionContext);
}
}
else if (outcome == StepExecutionOutcome.AbortRun)
{
MarkContainerFailure(step, executionContext, ChildFailureReason);
}
else if (outcome == StepExecutionOutcome.Defer)
{
MarkContainerPending(step, executionContext, AwaitingRetryReason);
}
return outcome;
}
private async Task<StepExecutionOutcome> ExecuteMapStepAsync(
PackRunExecutionStep step,
ExecutionContext executionContext)
{
foreach (var child in step.Children)
{
var outcome = await ExecuteStepAsync(child, executionContext).ConfigureAwait(false);
if (outcome != StepExecutionOutcome.Continue)
{
if (outcome == StepExecutionOutcome.Defer)
{
MarkContainerPending(step, executionContext, AwaitingRetryReason);
return outcome;
}
if (!step.ContinueOnError)
{
MarkContainerFailure(step, executionContext, ChildFailureReason);
return outcome;
}
MarkContainerFailure(step, executionContext, ChildFailureReason);
}
}
MarkContainerSucceeded(step, executionContext);
return StepExecutionOutcome.Continue;
}
private void MarkContainerSucceeded(PackRunExecutionStep step, ExecutionContext executionContext)
{
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
{
return;
}
if (record.Status == PackRunStepExecutionStatus.Succeeded)
{
return;
}
executionContext.Steps[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Succeeded,
StatusReason = null,
LastTransitionAt = DateTimeOffset.UtcNow,
NextAttemptAt = null
};
}
private void MarkContainerFailure(PackRunExecutionStep step, ExecutionContext executionContext, string reason)
{
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
{
return;
}
executionContext.Steps[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Failed,
StatusReason = reason,
LastTransitionAt = DateTimeOffset.UtcNow
};
}
private void MarkContainerPending(PackRunExecutionStep step, ExecutionContext executionContext, string reason)
{
if (!executionContext.Steps.TryGetValue(step.Id, out var record))
{
return;
}
executionContext.Steps[step.Id] = record with
{
Status = PackRunStepExecutionStatus.Pending,
StatusReason = reason,
LastTransitionAt = DateTimeOffset.UtcNow
};
}
private sealed record ExecutionContext(
string RunId,
TaskPackPlanFailurePolicy FailurePolicy,
ConcurrentDictionary<string, PackRunStepStateRecord> Steps,
CancellationToken CancellationToken);
private enum StepExecutionOutcome
{
Continue,
Defer,
AbortRun
}
}
private enum StepExecutionOutcome
{
Continue,
Defer,
AbortRun
}
}