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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,8 @@ internal interface IBackendOperationsClient
Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken);
Task<TaskRunnerSimulationResult> SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken);
Task<PolicyActivationResult> ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken);
Task<OfflineKitDownloadResult> DownloadOfflineKitAsync(string? bundleId, string destinationDirectory, bool overwrite, bool resume, CancellationToken cancellationToken);

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Cli.Services.Models;
internal sealed record TaskRunnerSimulationRequest(string Manifest, JsonObject? Inputs);
internal sealed record TaskRunnerSimulationResult(
string PlanHash,
TaskRunnerSimulationFailurePolicy FailurePolicy,
IReadOnlyList<TaskRunnerSimulationStep> Steps,
IReadOnlyList<TaskRunnerSimulationOutput> Outputs,
bool HasPendingApprovals);
internal sealed record TaskRunnerSimulationFailurePolicy(int MaxAttempts, int BackoffSeconds, bool ContinueOnError);
internal sealed record TaskRunnerSimulationStep(
string Id,
string TemplateId,
string Kind,
bool Enabled,
string Status,
string? StatusReason,
string? Uses,
string? ApprovalId,
string? GateMessage,
int? MaxParallel,
bool ContinueOnError,
IReadOnlyList<TaskRunnerSimulationStep> Children);
internal sealed record TaskRunnerSimulationOutput(
string Name,
string Type,
bool RequiresRuntimeValue,
string? PathExpression,
string? ValueExpression);

View File

@@ -0,0 +1,73 @@
using System.Collections.Generic;
using System.Text.Json.Nodes;
namespace StellaOps.Cli.Services.Models.Transport;
internal sealed class TaskRunnerSimulationRequestDocument
{
public string Manifest { get; set; } = string.Empty;
public JsonObject? Inputs { get; set; }
}
internal sealed class TaskRunnerSimulationResponseDocument
{
public string PlanHash { get; set; } = string.Empty;
public TaskRunnerSimulationFailurePolicyDocument? FailurePolicy { get; set; }
public List<TaskRunnerSimulationStepDocument>? Steps { get; set; }
public List<TaskRunnerSimulationOutputDocument>? Outputs { get; set; }
public bool HasPendingApprovals { get; set; }
}
internal sealed class TaskRunnerSimulationFailurePolicyDocument
{
public int MaxAttempts { get; set; }
public int BackoffSeconds { get; set; }
public bool ContinueOnError { get; set; }
}
internal sealed class TaskRunnerSimulationStepDocument
{
public string Id { get; set; } = string.Empty;
public string TemplateId { get; set; } = string.Empty;
public string Kind { get; set; } = string.Empty;
public bool Enabled { get; set; }
public string Status { get; set; } = string.Empty;
public string? StatusReason { get; set; }
public string? Uses { get; set; }
public string? ApprovalId { get; set; }
public string? GateMessage { get; set; }
public int? MaxParallel { get; set; }
public bool ContinueOnError { get; set; }
public List<TaskRunnerSimulationStepDocument>? Children { get; set; }
}
internal sealed class TaskRunnerSimulationOutputDocument
{
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public bool RequiresRuntimeValue { get; set; }
public string? PathExpression { get; set; }
public string? ValueExpression { get; set; }
}

View File

@@ -13,6 +13,7 @@ internal static class CliMetrics
private static readonly Counter<long> OfflineKitDownloadCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.download.count");
private static readonly Counter<long> OfflineKitImportCounter = Meter.CreateCounter<long>("stellaops.cli.offline.kit.import.count");
private static readonly Counter<long> PolicySimulationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.simulate.count");
private static readonly Counter<long> TaskRunnerSimulationCounter = Meter.CreateCounter<long>("stellaops.cli.taskrunner.simulate.count");
private static readonly Counter<long> PolicyActivationCounter = Meter.CreateCounter<long>("stellaops.cli.policy.activate.count");
private static readonly Counter<long> SourcesDryRunCounter = Meter.CreateCounter<long>("stellaops.cli.sources.dryrun.count");
private static readonly Counter<long> AocVerifyCounter = Meter.CreateCounter<long>("stellaops.cli.aoc.verify.count");
@@ -57,6 +58,12 @@ internal static class CliMetrics
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordTaskRunnerSimulation(string outcome)
=> TaskRunnerSimulationCounter.Add(1, new KeyValuePair<string, object?>[]
{
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
});
public static void RecordPolicyActivation(string outcome)
=> PolicyActivationCounter.Add(1, new KeyValuePair<string, object?>[]
{

View File

@@ -8,7 +8,8 @@ using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
@@ -1535,11 +1536,11 @@ public sealed class CommandHandlersTests
}
[Fact]
public async Task HandlePolicySimulateAsync_MapsErrorCodes()
{
var originalExit = Environment.ExitCode;
var originalOut = Console.Out;
public async Task HandlePolicySimulateAsync_MapsErrorCodes()
{
var originalExit = Environment.ExitCode;
var originalOut = Console.Out;
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
SimulationException = new PolicyApiException("Missing inputs", HttpStatusCode.BadRequest, "ERR_POL_003")
@@ -1566,18 +1567,185 @@ public sealed class CommandHandlersTests
cancellationToken: CancellationToken.None);
Assert.Equal(21, Environment.ExitCode);
}
finally
{
Console.SetOut(originalOut);
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandlePolicyActivateAsync_DisplaysInteractiveSummary()
{
var originalExit = Environment.ExitCode;
}
finally
{
Console.SetOut(originalOut);
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleTaskRunnerSimulateAsync_WritesInteractiveSummary()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
var console = new TestConsole();
console.Width(120);
console.Interactive();
console.EmitAnsiSequences();
AnsiConsole.Console = console;
const string manifest = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: sample-pack
spec:
steps:
- id: prepare
run:
uses: builtin:prepare
- id: approval
gate:
approval:
id: security-review
message: Security approval required.
""";
using var manifestFile = new TempFile("pack.yaml", Encoding.UTF8.GetBytes(manifest));
var simulationResult = new TaskRunnerSimulationResult(
"hash-abc123",
new TaskRunnerSimulationFailurePolicy(3, 15, false),
new[]
{
new TaskRunnerSimulationStep(
"prepare",
"prepare",
"Run",
true,
"succeeded",
null,
"builtin:prepare",
null,
null,
null,
false,
Array.Empty<TaskRunnerSimulationStep>()),
new TaskRunnerSimulationStep(
"approval",
"approval",
"GateApproval",
true,
"pending",
"requires-approval",
null,
"security-review",
"Security approval required.",
null,
false,
Array.Empty<TaskRunnerSimulationStep>())
},
new[]
{
new TaskRunnerSimulationOutput("bundlePath", "file", false, "artifacts/report.json", null)
},
true);
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
TaskRunnerSimulationResult = simulationResult
};
var provider = BuildServiceProvider(backend);
try
{
await CommandHandlers.HandleTaskRunnerSimulateAsync(
provider,
manifestFile.Path,
inputsPath: null,
format: null,
outputPath: null,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
Assert.NotNull(backend.LastTaskRunnerSimulationRequest);
Assert.Contains("approval", console.Output, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Plan Hash", console.Output, StringComparison.OrdinalIgnoreCase);
}
finally
{
AnsiConsole.Console = originalConsole;
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandleTaskRunnerSimulateAsync_WritesJsonOutput()
{
var originalExit = Environment.ExitCode;
var originalOut = Console.Out;
const string manifest = """
apiVersion: stellaops.io/pack.v1
kind: TaskPack
metadata:
name: sample-pack
spec:
steps:
- id: prepare
run:
uses: builtin:prepare
""";
using var manifestFile = new TempFile("pack.yaml", Encoding.UTF8.GetBytes(manifest));
using var inputsFile = new TempFile("inputs.json", Encoding.UTF8.GetBytes("{\"dryRun\":false}"));
using var outputDirectory = new TempDirectory();
var outputPath = Path.Combine(outputDirectory.Path, "simulation.json");
var simulationResult = new TaskRunnerSimulationResult(
"hash-xyz789",
new TaskRunnerSimulationFailurePolicy(2, 10, true),
Array.Empty<TaskRunnerSimulationStep>(),
Array.Empty<TaskRunnerSimulationOutput>(),
false);
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
{
TaskRunnerSimulationResult = simulationResult
};
var provider = BuildServiceProvider(backend);
using var writer = new StringWriter();
Console.SetOut(writer);
try
{
await CommandHandlers.HandleTaskRunnerSimulateAsync(
provider,
manifestFile.Path,
inputsFile.Path,
format: "json",
outputPath: outputPath,
verbose: false,
cancellationToken: CancellationToken.None);
Assert.Equal(0, Environment.ExitCode);
Assert.NotNull(backend.LastTaskRunnerSimulationRequest);
var consoleOutput = writer.ToString();
Assert.Contains("\"planHash\":\"hash-xyz789\"", consoleOutput, StringComparison.Ordinal);
var fileOutput = await File.ReadAllTextAsync(outputPath);
Assert.Contains("\"planHash\":\"hash-xyz789\"", fileOutput, StringComparison.Ordinal);
Assert.True(backend.LastTaskRunnerSimulationRequest!.Inputs!.TryGetPropertyValue("dryRun", out var dryRunNode));
Assert.False(dryRunNode!.GetValue<bool>());
}
finally
{
Console.SetOut(originalOut);
Environment.ExitCode = originalExit;
}
}
[Fact]
public async Task HandlePolicyActivateAsync_DisplaysInteractiveSummary()
{
var originalExit = Environment.ExitCode;
var originalConsole = AnsiConsole.Console;
var console = new TestConsole();
@@ -2397,7 +2565,15 @@ public sealed class CommandHandlersTests
new ReadOnlyCollection<PolicySimulationRuleDelta>(Array.Empty<PolicySimulationRuleDelta>())),
null);
public PolicyApiException? SimulationException { get; set; }
public (string PolicyId, PolicySimulationInput Input)? LastPolicySimulation { get; private set; }
public (string PolicyId, PolicySimulationInput Input)? LastPolicySimulation { get; private set; }
public TaskRunnerSimulationRequest? LastTaskRunnerSimulationRequest { get; private set; }
public TaskRunnerSimulationResult TaskRunnerSimulationResult { get; set; } = new(
string.Empty,
new TaskRunnerSimulationFailurePolicy(1, 0, false),
Array.Empty<TaskRunnerSimulationStep>(),
Array.Empty<TaskRunnerSimulationOutput>(),
false);
public Exception? TaskRunnerSimulationException { get; set; }
public PolicyActivationResult ActivationResult { get; set; } = new PolicyActivationResult(
"activated",
new PolicyActivationRevision(
@@ -2486,17 +2662,28 @@ public sealed class CommandHandlersTests
public Task<RuntimePolicyEvaluationResult> EvaluateRuntimePolicyAsync(RuntimePolicyEvaluationRequest request, CancellationToken cancellationToken)
=> Task.FromResult(RuntimePolicyResult);
public Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken)
{
LastPolicySimulation = (policyId, input);
if (SimulationException is not null)
{
throw SimulationException;
}
return Task.FromResult(SimulationResult);
}
public Task<PolicySimulationResult> SimulatePolicyAsync(string policyId, PolicySimulationInput input, CancellationToken cancellationToken)
{
LastPolicySimulation = (policyId, input);
if (SimulationException is not null)
{
throw SimulationException;
}
return Task.FromResult(SimulationResult);
}
public Task<TaskRunnerSimulationResult> SimulateTaskRunnerAsync(TaskRunnerSimulationRequest request, CancellationToken cancellationToken)
{
LastTaskRunnerSimulationRequest = request;
if (TaskRunnerSimulationException is not null)
{
throw TaskRunnerSimulationException;
}
return Task.FromResult(TaskRunnerSimulationResult);
}
public Task<PolicyActivationResult> ActivatePolicyRevisionAsync(string policyId, int version, PolicyActivationRequest request, CancellationToken cancellationToken)
{
LastPolicyActivation = (policyId, version, request);