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

@@ -1,41 +1,242 @@
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
var summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
})
.WithName("GetWeatherForecast");
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StellaOps.TaskRunner.Core.Execution;
using StellaOps.TaskRunner.Core.Execution.Simulation;
using StellaOps.TaskRunner.Core.Planning;
using StellaOps.TaskRunner.Core.TaskPacks;
using StellaOps.TaskRunner.Infrastructure.Execution;
using StellaOps.TaskRunner.WebService;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<TaskRunnerServiceOptions>(builder.Configuration.GetSection("TaskRunner"));
builder.Services.AddSingleton<TaskPackManifestLoader>();
builder.Services.AddSingleton<TaskPackPlanner>();
builder.Services.AddSingleton<PackRunSimulationEngine>();
builder.Services.AddSingleton<PackRunExecutionGraphBuilder>();
builder.Services.AddSingleton<IPackRunStateStore>(sp =>
{
var options = sp.GetRequiredService<IOptions<TaskRunnerServiceOptions>>().Value;
return new FilePackRunStateStore(options.RunStatePath);
});
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapPost("/v1/task-runner/simulations", async (
[FromBody] SimulationRequest request,
TaskPackManifestLoader loader,
TaskPackPlanner planner,
PackRunSimulationEngine simulationEngine,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Manifest))
{
return Results.BadRequest(new { error = "Manifest is required." });
}
TaskPackManifest manifest;
try
{
manifest = loader.Deserialize(request.Manifest);
}
catch (Exception ex)
{
return Results.BadRequest(new { error = "Invalid manifest", detail = ex.Message });
}
var inputs = ConvertInputs(request.Inputs);
var planResult = planner.Plan(manifest, inputs);
if (!planResult.Success || planResult.Plan is null)
{
return Results.BadRequest(new
{
errors = planResult.Errors.Select(error => new { error.Path, error.Message })
});
}
var plan = planResult.Plan;
var simulation = simulationEngine.Simulate(plan);
var response = SimulationMapper.ToResponse(plan, simulation);
return Results.Ok(response);
}).WithName("SimulateTaskPack");
app.MapGet("/v1/task-runner/runs/{runId}", async (
string runId,
IPackRunStateStore stateStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(runId))
{
return Results.BadRequest(new { error = "runId is required." });
}
var state = await stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
return Results.NotFound();
}
return Results.Ok(RunStateMapper.ToResponse(state));
}).WithName("GetRunState");
app.MapGet("/", () => Results.Redirect("/openapi"));
app.Run();
static IDictionary<string, JsonNode?>? ConvertInputs(JsonObject? node)
{
if (node is null)
{
return null;
}
var dictionary = new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
foreach (var property in node)
{
dictionary[property.Key] = property.Value?.DeepClone();
}
return dictionary;
}
internal sealed record SimulationRequest(string Manifest, JsonObject? Inputs);
internal sealed record SimulationResponse(
string PlanHash,
FailurePolicyResponse FailurePolicy,
IReadOnlyList<SimulationStepResponse> Steps,
IReadOnlyList<SimulationOutputResponse> Outputs,
bool HasPendingApprovals);
internal sealed record SimulationStepResponse(
string Id,
string TemplateId,
string Kind,
bool Enabled,
string Status,
string? StatusReason,
string? Uses,
string? ApprovalId,
string? GateMessage,
int? MaxParallel,
bool ContinueOnError,
IReadOnlyList<SimulationStepResponse> Children);
internal sealed record SimulationOutputResponse(
string Name,
string Type,
bool RequiresRuntimeValue,
string? PathExpression,
string? ValueExpression);
internal sealed record FailurePolicyResponse(int MaxAttempts, int BackoffSeconds, bool ContinueOnError);
internal sealed record RunStateResponse(
string RunId,
string PlanHash,
FailurePolicyResponse FailurePolicy,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
IReadOnlyList<RunStateStepResponse> Steps);
internal sealed record RunStateStepResponse(
string StepId,
string Kind,
bool Enabled,
bool ContinueOnError,
int? MaxParallel,
string? ApprovalId,
string? GateMessage,
string Status,
int Attempts,
DateTimeOffset? LastTransitionAt,
DateTimeOffset? NextAttemptAt,
string? StatusReason);
internal static class SimulationMapper
{
public static SimulationResponse ToResponse(TaskPackPlan plan, PackRunSimulationResult result)
{
var failurePolicy = result.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy;
var steps = result.Steps.Select(MapStep).ToList();
var outputs = result.Outputs.Select(MapOutput).ToList();
return new SimulationResponse(
plan.Hash,
new FailurePolicyResponse(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError),
steps,
outputs,
result.HasPendingApprovals);
}
private static SimulationStepResponse MapStep(PackRunSimulationNode node)
{
var children = node.Children.Select(MapStep).ToList();
return new SimulationStepResponse(
node.Id,
node.TemplateId,
node.Kind.ToString(),
node.Enabled,
node.Status.ToString(),
node.Status.ToString() switch
{
nameof(PackRunSimulationStatus.RequiresApproval) => "requires-approval",
nameof(PackRunSimulationStatus.RequiresPolicy) => "requires-policy",
nameof(PackRunSimulationStatus.Skipped) => "condition-false",
_ => null
},
node.Uses,
node.ApprovalId,
node.GateMessage,
node.MaxParallel,
node.ContinueOnError,
children);
}
private static SimulationOutputResponse MapOutput(PackRunSimulationOutput output)
=> new(
output.Name,
output.Type,
output.RequiresRuntimeValue,
output.Path?.Expression,
output.Expression?.Expression);
}
internal static class RunStateMapper
{
public static RunStateResponse ToResponse(PackRunState state)
{
var failurePolicy = state.FailurePolicy ?? PackRunExecutionGraph.DefaultFailurePolicy;
var steps = state.Steps.Values
.OrderBy(step => step.StepId, StringComparer.Ordinal)
.Select(step => new RunStateStepResponse(
step.StepId,
step.Kind.ToString(),
step.Enabled,
step.ContinueOnError,
step.MaxParallel,
step.ApprovalId,
step.GateMessage,
step.Status.ToString(),
step.Attempts,
step.LastTransitionAt,
step.NextAttemptAt,
step.StatusReason))
.ToList();
return new RunStateResponse(
state.RunId,
state.PlanHash,
new FailurePolicyResponse(failurePolicy.MaxAttempts, failurePolicy.BackoffSeconds, failurePolicy.ContinueOnError),
state.CreatedAt,
state.UpdatedAt,
steps);
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.TaskRunner.WebService;
public sealed class TaskRunnerServiceOptions
{
public string RunStatePath { get; set; } = Path.Combine(AppContext.BaseDirectory, "state", "runs");
}

View File

@@ -1,9 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"TaskRunner": {
"RunStatePath": "state/runs"
}
}