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;
}