feat: Enhance Task Runner with simulation and failure policy support
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user