feat: Enhance Task Runner with simulation and failure policy support
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Added tests for output projection and failure policy population in TaskPackPlanner.
- Introduced new failure policy manifest in TestManifests.
- Implemented simulation endpoints in the web service for task execution.
- Created TaskRunnerServiceOptions for configuration management.
- Updated appsettings.json to include TaskRunner configuration.
- Enhanced PackRunWorkerService to handle execution graphs and state management.
- Added support for parallel execution and conditional steps in the worker service.
- Updated documentation to reflect new features and changes in execution flow.
This commit is contained in:
master
2025-11-04 19:05:50 +02:00
parent 2eb6852d34
commit 3bd0955202
83 changed files with 15161 additions and 10678 deletions

View File

@@ -0,0 +1,13 @@
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public interface IPackRunStepExecutor
{
Task<PackRunStepExecutionResult> ExecuteAsync(
PackRunExecutionStep step,
IReadOnlyDictionary<string, TaskPackPlanParameterValue> parameters,
CancellationToken cancellationToken);
}
public sealed record PackRunStepExecutionResult(bool Succeeded, string? Error = null);

View File

@@ -0,0 +1,86 @@
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
}
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)
{
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));
}
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; }
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>();
}

View File

@@ -0,0 +1,77 @@
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");
return new PackRunExecutionStep(
step.Id,
step.TemplateId,
kind,
step.Enabled,
step.Uses,
parameters,
step.ApprovalId,
step.GateMessage,
maxParallel,
continueOnError,
children);
}
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,
_ => PackRunStepKind.Unknown
};
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;
}
}

View File

@@ -0,0 +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);

View File

@@ -0,0 +1,50 @@
using System.Collections.ObjectModel;
using StellaOps.TaskRunner.Core.Planning;
namespace StellaOps.TaskRunner.Core.Execution;
public sealed record PackRunState(
string RunId,
string PlanHash,
TaskPackPlanFailurePolicy FailurePolicy,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
IReadOnlyDictionary<string, PackRunStepStateRecord> Steps)
{
public static PackRunState Create(
string runId,
string planHash,
TaskPackPlanFailurePolicy failurePolicy,
IReadOnlyDictionary<string, PackRunStepStateRecord> steps,
DateTimeOffset timestamp)
=> new(
runId,
planHash,
failurePolicy,
timestamp,
timestamp,
new ReadOnlyDictionary<string, PackRunStepStateRecord>(new Dictionary<string, PackRunStepStateRecord>(steps, StringComparer.Ordinal)));
}
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

@@ -0,0 +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
}

View File

@@ -0,0 +1,78 @@
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());
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);
}
private static PackRunSimulationStatus DetermineStatus(PackRunExecutionStep step)
{
if (!step.Enabled)
{
return PackRunSimulationStatus.Skipped;
}
return step.Kind switch
{
PackRunStepKind.GateApproval => PackRunSimulationStatus.RequiresApproval,
PackRunStepKind.GatePolicy => PackRunSimulationStatus.RequiresPolicy,
_ => 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

@@ -0,0 +1,131 @@
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)
{
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));
}
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; }
public static IReadOnlyList<PackRunSimulationNode> Empty { get; } =
new ReadOnlyCollection<PackRunSimulationNode>(Array.Empty<PackRunSimulationNode>());
}
public enum PackRunSimulationStatus
{
Pending = 0,
Skipped,
RequiresApproval,
RequiresPolicy
}
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

@@ -12,36 +12,40 @@ public sealed class TaskPackPlan
IReadOnlyList<TaskPackPlanStep> steps,
string hash,
IReadOnlyList<TaskPackPlanApproval> approvals,
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs)
{
Metadata = metadata;
Inputs = inputs;
Steps = steps;
Hash = hash;
Approvals = approvals;
Secrets = secrets;
Outputs = outputs;
}
public TaskPackPlanMetadata Metadata { get; }
public IReadOnlyDictionary<string, JsonNode?> Inputs { get; }
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs,
TaskPackPlanFailurePolicy? failurePolicy)
{
Metadata = metadata;
Inputs = inputs;
Steps = steps;
Hash = hash;
Approvals = approvals;
Secrets = secrets;
Outputs = outputs;
FailurePolicy = failurePolicy;
}
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<TaskPackPlanSecret> Secrets { get; }
public IReadOnlyList<TaskPackPlanOutput> Outputs { get; }
}
public sealed record TaskPackPlanMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
public sealed record TaskPackPlanStep(
public IReadOnlyList<TaskPackPlanSecret> Secrets { get; }
public IReadOnlyList<TaskPackPlanOutput> Outputs { get; }
public TaskPackPlanFailurePolicy? FailurePolicy { get; }
}
public sealed record TaskPackPlanMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
public sealed record TaskPackPlanStep(
string Id,
string TemplateId,
string? Name,
@@ -71,11 +75,16 @@ public sealed record TaskPackPlanApproval(
public sealed record TaskPackPlanSecret(string Name, string Scope, string? Description);
public sealed record TaskPackPlanOutput(
string Name,
string Type,
TaskPackPlanParameterValue? Path,
TaskPackPlanParameterValue? Expression);
public sealed record TaskPackPlanOutput(
string Name,
string Type,
TaskPackPlanParameterValue? Path,
TaskPackPlanParameterValue? Expression);
public sealed record TaskPackPlanFailurePolicy(
int MaxAttempts,
int BackoffSeconds,
bool ContinueOnError);
public sealed class TaskPackPlanResult
{

View File

@@ -13,13 +13,14 @@ internal static class TaskPackPlanHasher
IReadOnlyDictionary<string, JsonNode?> inputs,
IReadOnlyList<TaskPackPlanStep> steps,
IReadOnlyList<TaskPackPlanApproval> approvals,
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs)
{
var canonical = new CanonicalPlan(
new CanonicalMetadata(metadata.Name, metadata.Version, metadata.Description, metadata.Tags),
inputs.ToDictionary(kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal),
steps.Select(ToCanonicalStep).ToList(),
IReadOnlyList<TaskPackPlanSecret> secrets,
IReadOnlyList<TaskPackPlanOutput> outputs,
TaskPackPlanFailurePolicy? failurePolicy)
{
var canonical = new CanonicalPlan(
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))
@@ -27,16 +28,19 @@ internal static class TaskPackPlanHasher
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)
.Select(ToCanonicalOutput)
.ToList());
var json = CanonicalJson.Serialize(canonical);
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
return ConvertToHex(hashBytes);
.ToList(),
outputs
.OrderBy(o => o.Name, StringComparer.Ordinal)
.Select(ToCanonicalOutput)
.ToList(),
failurePolicy is null
? null
: new CanonicalFailurePolicy(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError));
var json = CanonicalJson.Serialize(canonical);
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
return ConvertToHex(hashBytes);
}
private static string ConvertToHex(byte[] hashBytes)
@@ -66,13 +70,14 @@ internal static class TaskPackPlanHasher
step.GateMessage,
step.Children?.Select(ToCanonicalStep).ToList());
private sealed record CanonicalPlan(
CanonicalMetadata Metadata,
IDictionary<string, JsonNode?> Inputs,
IReadOnlyList<CanonicalPlanStep> Steps,
IReadOnlyList<CanonicalApproval> Approvals,
IReadOnlyList<CanonicalSecret> Secrets,
IReadOnlyList<CanonicalOutput> Outputs);
private sealed record CanonicalPlan(
CanonicalMetadata Metadata,
IDictionary<string, JsonNode?> Inputs,
IReadOnlyList<CanonicalPlanStep> Steps,
IReadOnlyList<CanonicalApproval> Approvals,
IReadOnlyList<CanonicalSecret> Secrets,
IReadOnlyList<CanonicalOutput> Outputs,
CanonicalFailurePolicy? FailurePolicy);
private sealed record CanonicalMetadata(string Name, string Version, string? Description, IReadOnlyList<string> Tags);
@@ -92,13 +97,15 @@ internal static class TaskPackPlanHasher
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(
string Name,
string Type,
CanonicalParameter? Path,
CanonicalParameter? Expression);
private sealed record CanonicalParameter(JsonNode? Value, string? Expression, string? Error, bool RequiresRuntimeValue);
private sealed record CanonicalOutput(
string Name,
string Type,
CanonicalParameter? Path,
CanonicalParameter? Expression);
private sealed record CanonicalFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError);
private static CanonicalOutput ToCanonicalOutput(TaskPackPlanOutput output)
=> new(

View File

@@ -98,14 +98,16 @@ public sealed class TaskPackPlanner
return new TaskPackPlanResult(null, errors.ToImmutable());
}
var hash = TaskPackPlanHasher.ComputeHash(metadata, effectiveInputs, planSteps, planApprovals, planSecrets, planOutputs);
var plan = new TaskPackPlan(metadata, effectiveInputs, planSteps, hash, planApprovals, planSecrets, planOutputs);
return new TaskPackPlanResult(plan, ImmutableArray<TaskPackPlanError>.Empty);
}
private Dictionary<string, JsonNode?> MaterializeInputs(
IReadOnlyList<TaskPackInput>? definitions,
var failurePolicy = MaterializeFailurePolicy(manifest.Spec.Failure);
var hash = TaskPackPlanHasher.ComputeHash(metadata, effectiveInputs, planSteps, planApprovals, planSecrets, planOutputs, failurePolicy);
var plan = new TaskPackPlan(metadata, effectiveInputs, planSteps, hash, planApprovals, planSecrets, planOutputs, failurePolicy);
return new TaskPackPlanResult(plan, ImmutableArray<TaskPackPlanError>.Empty);
}
private Dictionary<string, JsonNode?> MaterializeInputs(
IReadOnlyList<TaskPackInput>? definitions,
IDictionary<string, JsonNode?>? providedInputs,
ImmutableArray<TaskPackPlanError>.Builder errors)
{
@@ -141,9 +143,22 @@ public sealed class TaskPackPlanner
}
}
return effective;
}
return effective;
}
private static TaskPackPlanFailurePolicy? MaterializeFailurePolicy(TaskPackFailure? failure)
{
if (failure?.Retries is not TaskPackRetryPolicy retries)
{
return null;
}
var maxAttempts = retries.MaxAttempts <= 0 ? 1 : retries.MaxAttempts;
var backoffSeconds = retries.BackoffSeconds < 0 ? 0 : retries.BackoffSeconds;
return new TaskPackPlanFailurePolicy(maxAttempts, backoffSeconds, ContinueOnError: false);
}
private TaskPackPlanStep BuildStep(
string packName,
string packVersion,