using System.Diagnostics; using System.Linq; using System.Net; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using StellaOps.AdvisoryAI.Caching; using StellaOps.AdvisoryAI.Diagnostics; using StellaOps.AdvisoryAI.Explanation; using StellaOps.AdvisoryAI.Hosting; using StellaOps.AdvisoryAI.Metrics; using StellaOps.AdvisoryAI.Outputs; using StellaOps.AdvisoryAI.Orchestration; using StellaOps.AdvisoryAI.Queue; using StellaOps.AdvisoryAI.Remediation; using StellaOps.AdvisoryAI.WebService.Contracts; using StellaOps.Router.AspNet; var builder = WebApplication.CreateBuilder(args); 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.AddOpenApi(); builder.Services.AddProblemDetails(); // Stella Router integration var routerOptions = builder.Configuration.GetSection("AdvisoryAI:Router").Get(); builder.Services.TryAddStellaRouter( serviceName: "advisoryai", version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0", routerOptions: routerOptions); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("advisory-ai", context => { var clientId = context.Request.Headers.TryGetValue("X-StellaOps-Client", out var value) ? value.ToString() : "anonymous"; return RateLimitPartition.GetTokenBucketLimiter( clientId, _ => new TokenBucketRateLimiterOptions { TokenLimit = 30, TokensPerPeriod = 30, ReplenishmentPeriod = TimeSpan.FromMinutes(1), QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0, AutoReplenishment = true }); }); }); var app = builder.Build(); app.UseExceptionHandler(static options => options.Run(async context => { var problem = Results.Problem(statusCode: StatusCodes.Status500InternalServerError); await problem.ExecuteAsync(context); })); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); } app.UseRateLimiter(); app.TryUseStellaRouter(routerOptions); app.MapGet("/health", () => Results.Ok(new { status = "ok" })); app.MapPost("/v1/advisory-ai/pipeline/{taskType}", HandleSinglePlan) .RequireRateLimiting("advisory-ai"); app.MapPost("/v1/advisory-ai/pipeline:batch", HandleBatchPlans) .RequireRateLimiting("advisory-ai"); app.MapGet("/v1/advisory-ai/outputs/{cacheKey}", HandleGetOutput) .RequireRateLimiting("advisory-ai"); // Explanation endpoints (SPRINT_20251226_015_AI_zastava_companion) app.MapPost("/v1/advisory-ai/explain", HandleExplain) .RequireRateLimiting("advisory-ai"); app.MapGet("/v1/advisory-ai/explain/{explanationId}/replay", HandleExplanationReplay) .RequireRateLimiting("advisory-ai"); // Remediation endpoints (SPRINT_20251226_016_AI_remedy_autopilot) app.MapPost("/v1/advisory-ai/remediation/plan", HandleRemediationPlan) .RequireRateLimiting("advisory-ai"); app.MapPost("/v1/advisory-ai/remediation/apply", HandleApplyRemediation) .RequireRateLimiting("advisory-ai"); app.MapGet("/v1/advisory-ai/remediation/status/{prId}", HandleRemediationStatus) .RequireRateLimiting("advisory-ai"); // Refresh Router endpoint cache app.TryRefreshStellaRouterEndpoints(routerOptions); app.Run(); static async Task HandleSinglePlan( HttpContext httpContext, string taskType, PipelinePlanRequest request, IAdvisoryPipelineOrchestrator orchestrator, IAdvisoryPlanCache planCache, IAdvisoryTaskQueue taskQueue, AdvisoryAiMetrics requestMetrics, AdvisoryPipelineMetrics pipelineMetrics, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.plan_request", ActivityKind.Server); activity?.SetTag("advisory.task_type", taskType); activity?.SetTag("advisory.advisory_key", request.AdvisoryKey); if (!Enum.TryParse(taskType, ignoreCase: true, out var parsedType)) { return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." }); } if (!EnsureAuthorized(httpContext, parsedType)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } if (string.IsNullOrWhiteSpace(request.AdvisoryKey)) { return Results.BadRequest(new { error = "AdvisoryKey is required." }); } var normalizedRequest = request with { TaskType = parsedType }; var taskRequest = normalizedRequest.ToTaskRequest(); var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false); activity?.SetTag("advisory.plan_cache_key", plan.CacheKey); await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false); await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false); requestMetrics.RecordRequest(plan.Request.TaskType.ToString()); requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString()); pipelineMetrics.RecordPlanQueued(plan.Request.TaskType); var response = AdvisoryPipelinePlanResponse.FromPlan(plan); return Results.Ok(response); } static async Task HandleBatchPlans( HttpContext httpContext, BatchPipelinePlanRequest batchRequest, IAdvisoryPipelineOrchestrator orchestrator, IAdvisoryPlanCache planCache, IAdvisoryTaskQueue taskQueue, AdvisoryAiMetrics requestMetrics, AdvisoryPipelineMetrics pipelineMetrics, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.plan_batch", ActivityKind.Server); activity?.SetTag("advisory.batch_size", batchRequest.Requests.Count); if (batchRequest.Requests.Count == 0) { return Results.BadRequest(new { error = "At least one request must be supplied." }); } var results = new List(batchRequest.Requests.Count); foreach (var item in batchRequest.Requests) { var taskType = item.TaskType?.ToString() ?? "summary"; if (!Enum.TryParse(taskType, ignoreCase: true, out var parsedType)) { return Results.BadRequest(new { error = $"Unknown task type '{taskType}' in batch item." }); } if (!EnsureAuthorized(httpContext, parsedType)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } if (string.IsNullOrWhiteSpace(item.AdvisoryKey)) { return Results.BadRequest(new { error = "AdvisoryKey is required for every batch item." }); } var normalizedRequest = item with { TaskType = parsedType }; var taskRequest = normalizedRequest.ToTaskRequest(); var plan = await orchestrator.CreatePlanAsync(taskRequest, cancellationToken).ConfigureAwait(false); activity?.AddEvent(new ActivityEvent("advisory.plan.created", tags: new ActivityTagsCollection { { "advisory.task_type", plan.Request.TaskType.ToString() }, { "advisory.advisory_key", plan.Request.AdvisoryKey }, { "advisory.plan_cache_key", plan.CacheKey } })); await planCache.SetAsync(plan.CacheKey, plan, cancellationToken).ConfigureAwait(false); await taskQueue.EnqueueAsync(new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request), cancellationToken).ConfigureAwait(false); requestMetrics.RecordRequest(plan.Request.TaskType.ToString()); requestMetrics.RecordEnqueued(plan.Request.TaskType.ToString()); pipelineMetrics.RecordPlanQueued(plan.Request.TaskType); results.Add(AdvisoryPipelinePlanResponse.FromPlan(plan)); } return Results.Ok(results); } static async Task HandleGetOutput( HttpContext httpContext, string cacheKey, string taskType, string? profile, IAdvisoryOutputStore outputStore, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(outputStore); if (!Enum.TryParse(taskType, ignoreCase: true, out var parsedTaskType)) { return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." }); } if (!EnsureAuthorized(httpContext, parsedTaskType)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile!.Trim(); var output = await outputStore.TryGetAsync(cacheKey, parsedTaskType, resolvedProfile, cancellationToken) .ConfigureAwait(false); if (output is null) { return Results.NotFound(new { error = "Output not found." }); } return Results.Ok(AdvisoryOutputResponse.FromDomain(output)); } static bool EnsureAuthorized(HttpContext context, AdvisoryTaskType taskType) { if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes)) { return false; } var allowed = scopes .SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (allowed.Contains("advisory:run")) { return true; } return allowed.Contains($"advisory:{taskType.ToString().ToLowerInvariant()}"); } static bool EnsureExplainAuthorized(HttpContext context) { if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes)) { return false; } var allowed = scopes .SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .ToHashSet(StringComparer.OrdinalIgnoreCase); return allowed.Contains("advisory:run") || allowed.Contains("advisory:explain"); } // ZASTAVA-13: POST /v1/advisory-ai/explain static async Task HandleExplain( HttpContext httpContext, ExplainRequest request, IExplanationGenerator explanationGenerator, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain", ActivityKind.Server); activity?.SetTag("advisory.finding_id", request.FindingId); activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId); activity?.SetTag("advisory.explanation_type", request.ExplanationType); if (!EnsureExplainAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } try { var domainRequest = request.ToDomain(); var result = await explanationGenerator.GenerateAsync(domainRequest, cancellationToken).ConfigureAwait(false); activity?.SetTag("advisory.explanation_id", result.ExplanationId); activity?.SetTag("advisory.authority", result.Authority.ToString()); activity?.SetTag("advisory.citation_rate", result.CitationRate); return Results.Ok(ExplainResponse.FromDomain(result)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } // ZASTAVA-14: GET /v1/advisory-ai/explain/{explanationId}/replay static async Task HandleExplanationReplay( HttpContext httpContext, string explanationId, IExplanationGenerator explanationGenerator, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.explain_replay", ActivityKind.Server); activity?.SetTag("advisory.explanation_id", explanationId); if (!EnsureExplainAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } try { var result = await explanationGenerator.ReplayAsync(explanationId, cancellationToken).ConfigureAwait(false); activity?.SetTag("advisory.replayed_explanation_id", result.ExplanationId); activity?.SetTag("advisory.authority", result.Authority.ToString()); return Results.Ok(ExplainResponse.FromDomain(result)); } catch (InvalidOperationException ex) { return Results.NotFound(new { error = ex.Message }); } } static bool EnsureRemediationAuthorized(HttpContext context) { if (!context.Request.Headers.TryGetValue("X-StellaOps-Scopes", out var scopes)) { return false; } var allowed = scopes .SelectMany(value => value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .ToHashSet(StringComparer.OrdinalIgnoreCase); return allowed.Contains("advisory:run") || allowed.Contains("advisory:remediate"); } // REMEDY-19: POST /v1/advisory-ai/remediation/plan static async Task HandleRemediationPlan( HttpContext httpContext, RemediationPlanApiRequest request, IRemediationPlanner remediationPlanner, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_plan", ActivityKind.Server); activity?.SetTag("advisory.finding_id", request.FindingId); activity?.SetTag("advisory.vulnerability_id", request.VulnerabilityId); activity?.SetTag("advisory.remediation_type", request.RemediationType); if (!EnsureRemediationAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } try { var domainRequest = request.ToDomain(); var plan = await remediationPlanner.GeneratePlanAsync(domainRequest, cancellationToken).ConfigureAwait(false); activity?.SetTag("advisory.plan_id", plan.PlanId); activity?.SetTag("advisory.risk_assessment", plan.RiskAssessment.ToString()); activity?.SetTag("advisory.pr_ready", plan.PrReady); return Results.Ok(RemediationPlanApiResponse.FromDomain(plan)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } // REMEDY-20: POST /v1/advisory-ai/remediation/apply static async Task HandleApplyRemediation( HttpContext httpContext, ApplyRemediationRequest request, IRemediationPlanner remediationPlanner, IEnumerable prGenerators, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.apply_remediation", ActivityKind.Server); activity?.SetTag("advisory.plan_id", request.PlanId); activity?.SetTag("advisory.scm_type", request.ScmType); if (!EnsureRemediationAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } var plan = await remediationPlanner.GetPlanAsync(request.PlanId, cancellationToken).ConfigureAwait(false); if (plan is null) { return Results.NotFound(new { error = $"Plan {request.PlanId} not found" }); } var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(request.ScmType, StringComparison.OrdinalIgnoreCase)); if (generator is null) { return Results.BadRequest(new { error = $"SCM type '{request.ScmType}' not supported" }); } try { var prResult = await generator.CreatePullRequestAsync(plan, cancellationToken).ConfigureAwait(false); activity?.SetTag("advisory.pr_id", prResult.PrId); activity?.SetTag("advisory.pr_status", prResult.Status.ToString()); return Results.Ok(PullRequestApiResponse.FromDomain(prResult)); } catch (InvalidOperationException ex) { return Results.BadRequest(new { error = ex.Message }); } } // REMEDY-21: GET /v1/advisory-ai/remediation/status/{prId} static async Task HandleRemediationStatus( HttpContext httpContext, string prId, string? scmType, IEnumerable prGenerators, CancellationToken cancellationToken) { using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.remediation_status", ActivityKind.Server); activity?.SetTag("advisory.pr_id", prId); if (!EnsureRemediationAuthorized(httpContext)) { return Results.StatusCode(StatusCodes.Status403Forbidden); } var resolvedScmType = scmType ?? "github"; var generator = prGenerators.FirstOrDefault(g => g.ScmType.Equals(resolvedScmType, StringComparison.OrdinalIgnoreCase)); if (generator is null) { return Results.BadRequest(new { error = $"SCM type '{resolvedScmType}' not supported" }); } try { var prResult = await generator.GetStatusAsync(prId, cancellationToken).ConfigureAwait(false); activity?.SetTag("advisory.pr_status", prResult.Status.ToString()); return Results.Ok(PullRequestApiResponse.FromDomain(prResult)); } catch (InvalidOperationException ex) { return Results.NotFound(new { error = ex.Message }); } } internal sealed record PipelinePlanRequest( AdvisoryTaskType? TaskType, string AdvisoryKey, string? ArtifactId, string? ArtifactPurl, string? PolicyVersion, string Profile = "default", IReadOnlyCollection? PreferredSections = null, bool ForceRefresh = false) { public AdvisoryTaskRequest ToTaskRequest() { if (TaskType is null) { throw new InvalidOperationException("Task type must be specified."); } return new AdvisoryTaskRequest( TaskType.Value, AdvisoryKey, ArtifactId, ArtifactPurl, PolicyVersion, Profile, PreferredSections, ForceRefresh); } } internal sealed record BatchPipelinePlanRequest { public IReadOnlyList Requests { get; init; } = Array.Empty(); }