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,289 +1,86 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.AdvisoryAI.Caching;
using StellaOps.AdvisoryAI.DependencyInjection;
using StellaOps.AdvisoryAI.Metrics;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.AdvisoryAI.Hosting;
using StellaOps.AdvisoryAI.Orchestration;
using StellaOps.AdvisoryAI.Queue;
using StellaOps.AdvisoryAI.WebService.Contracts;
using StellaOps.AdvisoryAI.Execution;
using StellaOps.AdvisoryAI.Outputs;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Configuration.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "ADVISORYAI_");
builder.Services.AddAdvisoryAiCore(builder.Configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMetrics();
builder.Services.AddAdvisoryPipeline(options => builder.Configuration.GetSection("AdvisoryAI:Pipeline").Bind(options));
builder.Services.AddAdvisoryPipelineInfrastructure();
builder.Services.Configure<AdvisoryPlanCacheOptions>(builder.Configuration.GetSection("AdvisoryAI:PlanCache"));
builder.Services.Configure<AdvisoryTaskQueueOptions>(builder.Configuration.GetSection("AdvisoryAI:TaskQueue"));
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseSwagger();
app.UseSwaggerUI();
app.MapGet("/health/ready", () => Results.Ok(new { status = "ready" }));
app.MapPost("/api/v1/advisory/plan", async Task<Results<Ok<AdvisoryPlanResponse>, ValidationProblem>> (
[FromBody] AdvisoryPlanRequest request,
IAdvisoryPipelineOrchestrator orchestrator,
IAdvisoryPlanCache cache,
AdvisoryPipelineMetrics metrics,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
app.UseExceptionHandler(static options => options.Run(async context =>
{
if (!MiniValidator.TryValidate(request, out var errors))
{
return TypedResults.ValidationProblem(errors);
}
var problem = Results.Problem(statusCode: StatusCodes.Status500InternalServerError);
await problem.ExecuteAsync(context);
}));
var taskRequest = request.ToTaskRequest();
var start = timeProvider.GetTimestamp();
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
await cache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
var elapsed = timeProvider.GetElapsedTime(start);
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
metrics.RecordPlanCreated(elapsed.TotalSeconds, taskRequest.TaskType);
var response = new AdvisoryPlanResponse(
plan.CacheKey,
plan.Request.TaskType,
plan.Request.AdvisoryKey,
plan.Request.Profile,
plan.StructuredChunks.Length,
plan.VectorResults.Sum(result => result.Matches.Length),
plan.SbomContext is not null,
plan.Metadata,
timeProvider.GetUtcNow());
return TypedResults.Ok(response);
});
app.MapPost("/api/v1/advisory/queue", async Task<Results<Accepted<AdvisoryQueueResponse>, ValidationProblem>> (
[FromBody] AdvisoryQueueRequest request,
IAdvisoryPlanCache cache,
IAdvisoryTaskQueue queue,
IAdvisoryPipelineOrchestrator orchestrator,
AdvisoryPipelineMetrics metrics,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
if (request is null)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = new[] { "Request payload is required." }
});
}
AdvisoryTaskPlan? plan = null;
if (!string.IsNullOrWhiteSpace(request.PlanCacheKey))
{
plan = await cache.TryGetAsync(request.PlanCacheKey!, cancellationToken).ConfigureAwait(false);
}
if (plan is null)
{
if (request.Plan is null)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["plan"] = new[] { "Either planCacheKey or plan must be supplied." }
});
}
if (!MiniValidator.TryValidate(request.Plan, out var planErrors))
{
return TypedResults.ValidationProblem(planErrors);
}
var taskRequest = request.Plan.ToTaskRequest();
var start = timeProvider.GetTimestamp();
plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
await cache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
var elapsed = timeProvider.GetElapsedTime(start);
metrics.RecordPlanCreated(elapsed.TotalSeconds, plan.Request.TaskType);
}
await queue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false);
metrics.RecordPlanQueued(plan.Request.TaskType);
var response = new AdvisoryQueueResponse(
plan.CacheKey,
plan.Request.TaskType,
plan.Metadata,
"Plan enqueued for processing.");
return TypedResults.Accepted($"/api/v1/advisory/queue/{plan.CacheKey}", response);
});
app.MapPost("/api/v1/advisory/{taskType}", async Task<Results<Ok<AdvisoryOutputResponse>, ValidationProblem>> (
app.MapPost("/v1/advisory-ai/pipeline/{taskType}", async (
string taskType,
[FromBody] AdvisoryExecuteRequest request,
PipelinePlanRequest request,
IAdvisoryPipelineOrchestrator orchestrator,
IAdvisoryPlanCache cache,
IAdvisoryPipelineExecutor executor,
IAdvisoryOutputStore outputStore,
AdvisoryPipelineMetrics metrics,
TimeProvider timeProvider,
IAdvisoryPipelineQueuePublisher queue,
AdvisoryAiMetrics metrics,
ILoggerFactory loggerFactory,
CancellationToken cancellationToken) =>
{
if (!TryParseTaskType(taskType, out var taskTypeEnum, out var routeError))
if (!Enum.TryParse<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["taskType"] = new[] { routeError }
});
return Results.BadRequest(new { error = $"Unknown task type {taskType}." });
}
if (!MiniValidator.TryValidate(request, out var errors))
{
return TypedResults.ValidationProblem(errors);
}
var httpRequest = request with { TaskType = parsedType };
var orchestratorRequest = httpRequest.ToTaskRequest();
var taskRequest = request.ToTaskRequest(taskTypeEnum);
var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false);
var plan = await orchestrator.CreatePlanAsync(orchestratorRequest, cancellationToken).ConfigureAwait(false);
metrics.RecordRequest(plan.Request.TaskType.ToString());
var existingPlan = await cache.TryGetAsync(plan.CacheKey, cancellationToken).ConfigureAwait(false);
await cache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false);
await queue.EnqueueAsync(new AdvisoryPipelineExecutionMessage(plan.CacheKey, plan.Request, plan.Metadata), cancellationToken).ConfigureAwait(false);
metrics.RecordEnqueued(plan.Request.TaskType.ToString());
var planFromCache = existingPlan is not null && !request.ForceRefresh;
AdvisoryPipelineOutput? output = null;
if (!request.ForceRefresh)
{
output = await outputStore.TryGetAsync(plan.CacheKey, plan.Request.TaskType, plan.Request.Profile, cancellationToken).ConfigureAwait(false);
}
if (output is null)
{
var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request);
await executor.ExecuteAsync(plan, message, planFromCache, cancellationToken).ConfigureAwait(false);
output = await outputStore.TryGetAsync(plan.CacheKey, plan.Request.TaskType, plan.Request.Profile, cancellationToken).ConfigureAwait(false);
}
if (output is null)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["execution"] = new[] { "Failed to generate advisory output." }
});
}
metrics.RecordPlanProcessed(plan.Request.TaskType, planFromCache);
var response = ToOutputResponse(output);
return TypedResults.Ok(response);
});
app.MapGet("/api/v1/advisory/outputs/{cacheKey}", async Task<Results<Ok<AdvisoryOutputResponse>, ValidationProblem, NotFound>> (
string cacheKey,
[FromQuery] AdvisoryTaskType? taskType,
[FromQuery] string? profile,
IAdvisoryOutputStore outputStore,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["cacheKey"] = new[] { "Cache key is required." }
});
}
if (taskType is null)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["taskType"] = new[] { "Task type query parameter is required." }
});
}
if (string.IsNullOrWhiteSpace(profile))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["profile"] = new[] { "Profile query parameter is required." }
});
}
var output = await outputStore.TryGetAsync(cacheKey, taskType.Value, profile!, cancellationToken).ConfigureAwait(false);
if (output is null)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(ToOutputResponse(output));
return Results.Ok(AdvisoryPipelinePlanResponse.FromPlan(plan));
});
app.Run();
static bool TryParseTaskType(string routeValue, out AdvisoryTaskType taskType, out string error)
internal sealed record PipelinePlanRequest(
AdvisoryTaskType? TaskType,
string AdvisoryKey,
string? ArtifactId,
string? ArtifactPurl,
string? PolicyVersion,
string Profile = "default",
IReadOnlyCollection<string>? PreferredSections = null,
bool ForceRefresh = false)
{
if (Enum.TryParse(routeValue, ignoreCase: true, out taskType))
public AdvisoryTaskRequest ToTaskRequest()
{
error = string.Empty;
return true;
}
error = $"Unsupported advisory task type {routeValue}. Expected summary, conflict, or remediation.";
return false;
}
static AdvisoryOutputResponse ToOutputResponse(AdvisoryPipelineOutput output)
{
var violations = output.Guardrail.Violations
.Select(AdvisoryGuardrailViolationResponse.From)
.ToImmutableArray();
var citations = output.Citations
.Select(citation => new AdvisoryCitationResponse(citation.Index, citation.DocumentId, citation.ChunkId))
.ToImmutableArray();
return new AdvisoryOutputResponse(
output.CacheKey,
output.TaskType,
output.Profile,
output.Provenance.OutputHash,
output.Guardrail.Blocked,
violations,
output.Guardrail.Metadata,
output.Prompt,
citations,
output.Metadata,
output.GeneratedAtUtc,
output.PlanFromCache);
}
internal static class MiniValidator
{
public static bool TryValidate(object instance, out Dictionary<string, string[]> errors)
{
var context = new ValidationContext(instance);
var results = new List<ValidationResult>();
if (!Validator.TryValidateObject(instance, context, results, validateAllProperties: true))
if (TaskType is null)
{
errors = results
.GroupBy(result => result.MemberNames.FirstOrDefault() ?? string.Empty)
.ToDictionary(
group => group.Key,
group => group.Select(result => result.ErrorMessage ?? "Invalid value.").ToArray(),
StringComparer.Ordinal);
return false;
throw new InvalidOperationException("Task type must be specified.");
}
errors = new Dictionary<string, string[]>(0);
return true;
return new AdvisoryTaskRequest(
TaskType.Value,
AdvisoryKey,
ArtifactId,
ArtifactPurl,
PolicyVersion,
Profile,
PreferredSections,
ForceRefresh);
}
}