feat: Add Go module and workspace test fixtures
- Created expected JSON files for Go modules and workspaces. - Added go.mod and go.sum files for example projects. - Implemented private module structure with expected JSON output. - Introduced vendored dependencies with corresponding expected JSON. - Developed PostgresGraphJobStore for managing graph jobs. - Established SQL migration scripts for graph jobs schema. - Implemented GraphJobRepository for CRUD operations on graph jobs. - Created IGraphJobRepository interface for repository abstraction. - Added unit tests for GraphJobRepository to ensure functionality.
This commit is contained in:
@@ -25,7 +25,9 @@ public enum PackRunStepKind
|
||||
GateApproval,
|
||||
GatePolicy,
|
||||
Parallel,
|
||||
Map
|
||||
Map,
|
||||
Loop,
|
||||
Conditional
|
||||
}
|
||||
|
||||
public sealed class PackRunExecutionStep
|
||||
@@ -41,7 +43,10 @@ public sealed class PackRunExecutionStep
|
||||
string? gateMessage,
|
||||
int? maxParallel,
|
||||
bool continueOnError,
|
||||
IReadOnlyList<PackRunExecutionStep> children)
|
||||
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;
|
||||
@@ -54,6 +59,9 @@ public sealed class PackRunExecutionStep
|
||||
MaxParallel = maxParallel;
|
||||
ContinueOnError = continueOnError;
|
||||
Children = children ?? throw new ArgumentNullException(nameof(children));
|
||||
LoopConfig = loopConfig;
|
||||
ConditionalConfig = conditionalConfig;
|
||||
PolicyGateConfig = policyGateConfig;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
@@ -78,9 +86,155 @@ public sealed class PackRunExecutionStep
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ public sealed class PackRunExecutionGraphBuilder
|
||||
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,
|
||||
@@ -41,7 +46,10 @@ public sealed class PackRunExecutionGraphBuilder
|
||||
step.GateMessage,
|
||||
maxParallel,
|
||||
continueOnError,
|
||||
children);
|
||||
children,
|
||||
loopConfig,
|
||||
conditionalConfig,
|
||||
policyGateConfig);
|
||||
}
|
||||
|
||||
private static PackRunStepKind DetermineKind(string? type)
|
||||
@@ -52,9 +60,153 @@ public sealed class PackRunExecutionGraphBuilder
|
||||
"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)
|
||||
@@ -74,4 +226,18 @@ public sealed class PackRunExecutionGraphBuilder
|
||||
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,32 @@ public sealed class PackRunSimulationEngine
|
||||
? 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,
|
||||
@@ -42,7 +68,10 @@ public sealed class PackRunSimulationEngine
|
||||
step.MaxParallel,
|
||||
step.ContinueOnError,
|
||||
status,
|
||||
children);
|
||||
children,
|
||||
loopInfo,
|
||||
conditionalInfo,
|
||||
policyInfo);
|
||||
}
|
||||
|
||||
private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step)
|
||||
@@ -56,6 +85,8 @@ public sealed class PackRunSimulationEngine
|
||||
{
|
||||
PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval,
|
||||
PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy,
|
||||
PackRunStepKind.Loop => PackRunSimulationStatus.WillIterate,
|
||||
PackRunStepKind.Conditional => PackRunSimulationStatus.WillBranch,
|
||||
_ => PackRunSimulationStatus.Pending
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +48,10 @@ public sealed class PackRunSimulationNode
|
||||
int? maxParallel,
|
||||
bool continueOnError,
|
||||
PackRunSimulationStatus status,
|
||||
IReadOnlyList<PackRunSimulationNode> children)
|
||||
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;
|
||||
@@ -62,6 +65,9 @@ public sealed class PackRunSimulationNode
|
||||
ContinueOnError = continueOnError;
|
||||
Status = status;
|
||||
Children = children ?? throw new ArgumentNullException(nameof(children));
|
||||
LoopInfo = loopInfo;
|
||||
ConditionalInfo = conditionalInfo;
|
||||
PolicyInfo = policyInfo;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
@@ -88,6 +94,15 @@ public sealed class PackRunSimulationNode
|
||||
|
||||
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>());
|
||||
}
|
||||
@@ -97,9 +112,53 @@ public enum PackRunSimulationStatus
|
||||
Pending = 0,
|
||||
Skipped,
|
||||
RequiresApproval,
|
||||
RequiresPolicy
|
||||
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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,11 +54,11 @@ public sealed class TaskPackMaintainer
|
||||
public string? Email { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackSpec
|
||||
{
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
|
||||
|
||||
public sealed class TaskPackSpec
|
||||
{
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("secrets")]
|
||||
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
|
||||
|
||||
@@ -72,17 +72,17 @@ public sealed class TaskPackSpec
|
||||
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; }
|
||||
}
|
||||
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; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackInput
|
||||
{
|
||||
@@ -154,35 +154,41 @@ public sealed class TaskPackStep
|
||||
|
||||
[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 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")]
|
||||
@@ -231,6 +237,69 @@ public sealed class TaskPackMapStep
|
||||
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")]
|
||||
@@ -261,41 +330,41 @@ public sealed class TaskPackFailure
|
||||
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; }
|
||||
}
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Linq;
|
||||
|
||||
@@ -124,21 +124,23 @@ public sealed class TaskPackManifestValidator
|
||||
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.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, or map."));
|
||||
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, or map."));
|
||||
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.Run is not null)
|
||||
{
|
||||
ValidateRunStep(step.Run, $"{path}.run", errors);
|
||||
}
|
||||
|
||||
if (step.Gate is not null)
|
||||
{
|
||||
@@ -154,47 +156,57 @@ public sealed class TaskPackManifestValidator
|
||||
{
|
||||
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 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)
|
||||
{
|
||||
@@ -250,6 +262,77 @@ public sealed class TaskPackManifestValidator
|
||||
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);
|
||||
|
||||
@@ -72,4 +72,71 @@ public sealed class PackRunSimulationEngineTests
|
||||
Assert.True(evidence.RequiresRuntimeValue);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_LoopStep_SetsWillIterateStatus()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Loop);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["targets"] = new JsonArray { "a", "b", "c" }
|
||||
};
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.NotNull(result.Plan);
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var simResult = engine.Simulate(result.Plan);
|
||||
|
||||
var loopStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Loop);
|
||||
Assert.Equal(PackRunSimulationStatus.WillIterate, loopStep.Status);
|
||||
Assert.Equal("process-loop", loopStep.Id);
|
||||
Assert.NotNull(loopStep.LoopInfo);
|
||||
Assert.Equal("target", loopStep.LoopInfo.Iterator);
|
||||
Assert.Equal("idx", loopStep.LoopInfo.Index);
|
||||
Assert.Equal(100, loopStep.LoopInfo.MaxIterations);
|
||||
Assert.Equal("collect", loopStep.LoopInfo.AggregationMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_ConditionalStep_SetsWillBranchStatus()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Conditional);
|
||||
var planner = new TaskPackPlanner();
|
||||
var inputs = new Dictionary<string, JsonNode?>
|
||||
{
|
||||
["environment"] = JsonValue.Create("production")
|
||||
};
|
||||
var result = planner.Plan(manifest, inputs);
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.NotNull(result.Plan);
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var simResult = engine.Simulate(result.Plan);
|
||||
|
||||
var conditionalStep = simResult.Steps.Single(s => s.Kind == PackRunStepKind.Conditional);
|
||||
Assert.Equal(PackRunSimulationStatus.WillBranch, conditionalStep.Status);
|
||||
Assert.Equal("env-branch", conditionalStep.Id);
|
||||
Assert.NotNull(conditionalStep.ConditionalInfo);
|
||||
Assert.Equal(2, conditionalStep.ConditionalInfo.Branches.Count);
|
||||
Assert.True(conditionalStep.ConditionalInfo.OutputUnion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Simulate_PolicyGateStep_HasPolicyInfo()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.PolicyGate);
|
||||
var planner = new TaskPackPlanner();
|
||||
var plan = planner.Plan(manifest).Plan!;
|
||||
|
||||
var engine = new PackRunSimulationEngine();
|
||||
var result = engine.Simulate(plan);
|
||||
|
||||
var policyStep = result.Steps.Single(s => s.Kind == PackRunStepKind.GatePolicy);
|
||||
Assert.Equal(PackRunSimulationStatus.RequiresPolicy, policyStep.Status);
|
||||
Assert.NotNull(policyStep.PolicyInfo);
|
||||
Assert.Equal("security-hold", policyStep.PolicyInfo.PolicyId);
|
||||
Assert.Equal("abort", policyStep.PolicyInfo.FailureAction);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TaskRunner.Core.TaskPacks;
|
||||
|
||||
namespace StellaOps.TaskRunner.Tests;
|
||||
|
||||
internal static partial class TestManifests
|
||||
{
|
||||
public static TaskPackManifest Load(string yaml)
|
||||
@@ -10,15 +10,15 @@ internal static partial class TestManifests
|
||||
var loader = new TaskPackManifestLoader();
|
||||
return loader.Deserialize(yaml);
|
||||
}
|
||||
|
||||
public const string Sample = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: sample-pack
|
||||
version: 1.0.0
|
||||
description: Sample pack for planner tests
|
||||
tags: [tests]
|
||||
|
||||
public const string Sample = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: sample-pack
|
||||
version: 1.0.0
|
||||
description: Sample pack for planner tests
|
||||
tags: [tests]
|
||||
spec:
|
||||
inputs:
|
||||
- name: dryRun
|
||||
@@ -40,23 +40,23 @@ spec:
|
||||
grants: ["packs.approve"]
|
||||
steps:
|
||||
- id: plan-step
|
||||
name: Plan
|
||||
run:
|
||||
uses: builtin:plan
|
||||
with:
|
||||
dryRun: "{{ inputs.dryRun }}"
|
||||
- id: approval
|
||||
gate:
|
||||
approval:
|
||||
id: security-review
|
||||
message: "Security approval required."
|
||||
- id: apply-step
|
||||
when: "{{ not inputs.dryRun }}"
|
||||
run:
|
||||
uses: builtin:apply
|
||||
""";
|
||||
|
||||
public const string RequiredInput = """
|
||||
name: Plan
|
||||
run:
|
||||
uses: builtin:plan
|
||||
with:
|
||||
dryRun: "{{ inputs.dryRun }}"
|
||||
- id: approval
|
||||
gate:
|
||||
approval:
|
||||
id: security-review
|
||||
message: "Security approval required."
|
||||
- id: apply-step
|
||||
when: "{{ not inputs.dryRun }}"
|
||||
run:
|
||||
uses: builtin:apply
|
||||
""";
|
||||
|
||||
public const string RequiredInput = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
@@ -86,11 +86,11 @@ spec:
|
||||
""";
|
||||
|
||||
public const string StepReference = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: step-ref-pack
|
||||
version: 1.0.0
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: step-ref-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
sandbox:
|
||||
mode: sealed
|
||||
@@ -107,18 +107,18 @@ spec:
|
||||
run:
|
||||
uses: builtin:prepare
|
||||
- id: consume
|
||||
run:
|
||||
uses: builtin:consume
|
||||
with:
|
||||
sourceSummary: "{{ steps.prepare.outputs.summary }}"
|
||||
""";
|
||||
|
||||
public const string Map = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: map-pack
|
||||
version: 1.0.0
|
||||
run:
|
||||
uses: builtin:consume
|
||||
with:
|
||||
sourceSummary: "{{ steps.prepare.outputs.summary }}"
|
||||
""";
|
||||
|
||||
public const string Map = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: map-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
inputs:
|
||||
- name: targets
|
||||
@@ -139,19 +139,19 @@ spec:
|
||||
map:
|
||||
items: "{{ inputs.targets }}"
|
||||
step:
|
||||
id: echo-step
|
||||
run:
|
||||
uses: builtin:echo
|
||||
with:
|
||||
target: "{{ item }}"
|
||||
""";
|
||||
|
||||
public const string Secret = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: secret-pack
|
||||
version: 1.0.0
|
||||
id: echo-step
|
||||
run:
|
||||
uses: builtin:echo
|
||||
with:
|
||||
target: "{{ item }}"
|
||||
""";
|
||||
|
||||
public const string Secret = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: secret-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
secrets:
|
||||
- name: apiKey
|
||||
@@ -172,15 +172,15 @@ spec:
|
||||
run:
|
||||
uses: builtin:http
|
||||
with:
|
||||
token: "{{ secrets.apiKey }}"
|
||||
""";
|
||||
|
||||
public const string Output = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: output-pack
|
||||
version: 1.0.0
|
||||
token: "{{ secrets.apiKey }}"
|
||||
""";
|
||||
|
||||
public const string Output = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: output-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
sandbox:
|
||||
mode: sealed
|
||||
@@ -197,9 +197,9 @@ spec:
|
||||
run:
|
||||
uses: builtin:generate
|
||||
outputs:
|
||||
- name: bundlePath
|
||||
type: file
|
||||
path: artifacts/report.txt
|
||||
- name: bundlePath
|
||||
type: file
|
||||
path: artifacts/report.txt
|
||||
- name: evidenceModel
|
||||
type: object
|
||||
expression: "{{ steps.generate.outputs.evidence }}"
|
||||
@@ -379,4 +379,87 @@ spec:
|
||||
with:
|
||||
url: "{{ inputs.targetUrl }}"
|
||||
""";
|
||||
|
||||
public const string Loop = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: loop-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
inputs:
|
||||
- name: targets
|
||||
type: array
|
||||
required: true
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: process-loop
|
||||
loop:
|
||||
items: "{{ inputs.targets }}"
|
||||
iterator: target
|
||||
index: idx
|
||||
maxIterations: 100
|
||||
aggregation: collect
|
||||
steps:
|
||||
- id: process-item
|
||||
run:
|
||||
uses: builtin:process
|
||||
""";
|
||||
|
||||
public const string Conditional = """
|
||||
apiVersion: stellaops.io/pack.v1
|
||||
kind: TaskPack
|
||||
metadata:
|
||||
name: conditional-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
inputs:
|
||||
- name: environment
|
||||
type: string
|
||||
required: true
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: env-branch
|
||||
conditional:
|
||||
branches:
|
||||
- condition: "{{ inputs.environment == 'production' }}"
|
||||
steps:
|
||||
- id: deploy-prod
|
||||
run:
|
||||
uses: builtin:deploy
|
||||
with:
|
||||
target: production
|
||||
- condition: "{{ inputs.environment == 'staging' }}"
|
||||
steps:
|
||||
- id: deploy-staging
|
||||
run:
|
||||
uses: builtin:deploy
|
||||
with:
|
||||
target: staging
|
||||
else:
|
||||
- id: deploy-dev
|
||||
run:
|
||||
uses: builtin:deploy
|
||||
with:
|
||||
target: development
|
||||
outputUnion: true
|
||||
""";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user