Implement MongoDB-based storage for Pack Run approval, artifact, log, and state management
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 MongoPackRunApprovalStore for managing approval states with MongoDB. - Introduced MongoPackRunArtifactUploader for uploading and storing artifacts. - Created MongoPackRunLogStore to handle logging of pack run events. - Developed MongoPackRunStateStore for persisting and retrieving pack run states. - Implemented unit tests for MongoDB stores to ensure correct functionality. - Added MongoTaskRunnerTestContext for setting up MongoDB test environment. - Enhanced PackRunStateFactory to correctly initialize state with gate reasons.
This commit is contained in:
@@ -4,7 +4,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
@@ -35,12 +36,13 @@ internal static class CommandFactory
|
||||
root.Add(BuildSourcesCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAocCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAuthCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildTaskRunnerCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildConfigCommand(options));
|
||||
root.Add(BuildKmsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildPolicyCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildTaskRunnerCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildFindingsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildAdviseCommand(services, options, verboseOption, cancellationToken));
|
||||
root.Add(BuildConfigCommand(options));
|
||||
root.Add(BuildKmsCommand(services, verboseOption, cancellationToken));
|
||||
root.Add(BuildVulnCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
var pluginLogger = loggerFactory.CreateLogger<CliCommandModuleLoader>();
|
||||
var pluginLoader = new CliCommandModuleLoader(services, options, pluginLogger);
|
||||
@@ -733,7 +735,7 @@ internal static class CommandFactory
|
||||
var activateVersionOption = new Option<int>("--version")
|
||||
{
|
||||
Description = "Revision version to activate.",
|
||||
IsRequired = true
|
||||
Arity = ArgumentArity.ExactlyOne
|
||||
};
|
||||
|
||||
var activationNoteOption = new Option<string?>("--note")
|
||||
@@ -809,11 +811,11 @@ internal static class CommandFactory
|
||||
var taskRunner = new Command("task-runner", "Interact with Task Runner operations.");
|
||||
|
||||
var simulate = new Command("simulate", "Simulate a task pack and inspect the execution graph.");
|
||||
var manifestOption = new Option<string>("--manifest")
|
||||
{
|
||||
Description = "Path to the task pack manifest (YAML).",
|
||||
IsRequired = true
|
||||
};
|
||||
var manifestOption = new Option<string>("--manifest")
|
||||
{
|
||||
Description = "Path to the task pack manifest (YAML).",
|
||||
Arity = ArgumentArity.ExactlyOne
|
||||
};
|
||||
var inputsOption = new Option<string?>("--inputs")
|
||||
{
|
||||
Description = "Optional JSON file containing Task Pack input values."
|
||||
@@ -1042,13 +1044,110 @@ internal static class CommandFactory
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
findings.Add(list);
|
||||
findings.Add(get);
|
||||
findings.Add(explain);
|
||||
return findings;
|
||||
}
|
||||
|
||||
private static Command BuildVulnCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
findings.Add(list);
|
||||
findings.Add(get);
|
||||
findings.Add(explain);
|
||||
return findings;
|
||||
}
|
||||
|
||||
private static Command BuildAdviseCommand(IServiceProvider services, StellaOpsCliOptions options, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var advise = new Command("advise", "Interact with Advisory AI pipelines.");
|
||||
_ = options;
|
||||
|
||||
var run = new Command("run", "Generate Advisory AI output for the specified task.");
|
||||
var taskArgument = new Argument<string>("task")
|
||||
{
|
||||
Description = "Task to run (summary, conflict, remediation)."
|
||||
};
|
||||
run.Add(taskArgument);
|
||||
|
||||
var advisoryKeyOption = new Option<string>("--advisory-key")
|
||||
{
|
||||
Description = "Advisory identifier to summarise (required).",
|
||||
Required = true
|
||||
};
|
||||
var artifactIdOption = new Option<string?>("--artifact-id")
|
||||
{
|
||||
Description = "Optional artifact identifier to scope SBOM context."
|
||||
};
|
||||
var artifactPurlOption = new Option<string?>("--artifact-purl")
|
||||
{
|
||||
Description = "Optional package URL to scope dependency context."
|
||||
};
|
||||
var policyVersionOption = new Option<string?>("--policy-version")
|
||||
{
|
||||
Description = "Policy revision to evaluate (defaults to current)."
|
||||
};
|
||||
var profileOption = new Option<string?>("--profile")
|
||||
{
|
||||
Description = "Advisory AI execution profile (default, fips-local, etc.)."
|
||||
};
|
||||
var sectionOption = new Option<string[]>("--section")
|
||||
{
|
||||
Description = "Preferred context sections to emphasise (repeatable).",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
sectionOption.AllowMultipleArgumentsPerToken = true;
|
||||
|
||||
var forceRefreshOption = new Option<bool>("--force-refresh")
|
||||
{
|
||||
Description = "Bypass cached plan/output and recompute."
|
||||
};
|
||||
|
||||
var timeoutOption = new Option<int?>("--timeout")
|
||||
{
|
||||
Description = "Seconds to wait for generated output before timing out (0 = single attempt)."
|
||||
};
|
||||
timeoutOption.Arity = ArgumentArity.ZeroOrOne;
|
||||
|
||||
run.Add(advisoryKeyOption);
|
||||
run.Add(artifactIdOption);
|
||||
run.Add(artifactPurlOption);
|
||||
run.Add(policyVersionOption);
|
||||
run.Add(profileOption);
|
||||
run.Add(sectionOption);
|
||||
run.Add(forceRefreshOption);
|
||||
run.Add(timeoutOption);
|
||||
|
||||
run.SetAction((parseResult, _) =>
|
||||
{
|
||||
var taskValue = parseResult.GetValue(taskArgument);
|
||||
var advisoryKey = parseResult.GetValue(advisoryKeyOption) ?? string.Empty;
|
||||
var artifactId = parseResult.GetValue(artifactIdOption);
|
||||
var artifactPurl = parseResult.GetValue(artifactPurlOption);
|
||||
var policyVersion = parseResult.GetValue(policyVersionOption);
|
||||
var profile = parseResult.GetValue(profileOption) ?? "default";
|
||||
var sections = parseResult.GetValue(sectionOption) ?? Array.Empty<string>();
|
||||
var forceRefresh = parseResult.GetValue(forceRefreshOption);
|
||||
var timeoutSeconds = parseResult.GetValue(timeoutOption) ?? 120;
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!Enum.TryParse<AdvisoryAiTaskType>(taskValue, ignoreCase: true, out var taskType))
|
||||
{
|
||||
throw new InvalidOperationException($"Unknown advisory task '{taskValue}'. Expected summary, conflict, or remediation.");
|
||||
}
|
||||
|
||||
return CommandHandlers.HandleAdviseRunAsync(
|
||||
services,
|
||||
taskType,
|
||||
advisoryKey,
|
||||
artifactId,
|
||||
artifactPurl,
|
||||
policyVersion,
|
||||
profile,
|
||||
sections,
|
||||
forceRefresh,
|
||||
timeoutSeconds,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
advise.Add(run);
|
||||
return advise;
|
||||
}
|
||||
|
||||
private static Command BuildVulnCommand(IServiceProvider services, Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
var vuln = new Command("vuln", "Explore vulnerability observations and overlays.");
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@ using Spectre.Console.Rendering;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Prompts;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Cryptography.Kms;
|
||||
|
||||
@@ -426,14 +427,154 @@ internal static class CommandHandlers
|
||||
{
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static async Task HandleSourcesIngestAsync(
|
||||
IServiceProvider services,
|
||||
bool dryRun,
|
||||
string source,
|
||||
string input,
|
||||
}
|
||||
|
||||
public static async Task HandleAdviseRunAsync(
|
||||
IServiceProvider services,
|
||||
AdvisoryAiTaskType taskType,
|
||||
string advisoryKey,
|
||||
string? artifactId,
|
||||
string? artifactPurl,
|
||||
string? policyVersion,
|
||||
string profile,
|
||||
IReadOnlyList<string> preferredSections,
|
||||
bool forceRefresh,
|
||||
int timeoutSeconds,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IBackendOperationsClient>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger("advise-run");
|
||||
var verbosity = scope.ServiceProvider.GetRequiredService<VerbosityState>();
|
||||
var previousLevel = verbosity.MinimumLevel;
|
||||
verbosity.MinimumLevel = verbose ? LogLevel.Debug : LogLevel.Information;
|
||||
using var activity = CliActivitySource.Instance.StartActivity("cli.advisory.run", ActivityKind.Client);
|
||||
activity?.SetTag("stellaops.cli.command", "advise run");
|
||||
activity?.SetTag("stellaops.cli.task", taskType.ToString());
|
||||
using var duration = CliMetrics.MeasureCommandDuration("advisory run");
|
||||
activity?.SetTag("stellaops.cli.force_refresh", forceRefresh);
|
||||
|
||||
var outcome = "error";
|
||||
try
|
||||
{
|
||||
var normalizedKey = advisoryKey?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedKey))
|
||||
{
|
||||
throw new ArgumentException("Advisory key is required.", nameof(advisoryKey));
|
||||
}
|
||||
|
||||
activity?.SetTag("stellaops.cli.advisory.key", normalizedKey);
|
||||
var normalizedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
|
||||
activity?.SetTag("stellaops.cli.profile", normalizedProfile);
|
||||
|
||||
var normalizedSections = NormalizeSections(preferredSections);
|
||||
|
||||
var request = new AdvisoryPipelinePlanRequestModel
|
||||
{
|
||||
TaskType = taskType,
|
||||
AdvisoryKey = normalizedKey,
|
||||
ArtifactId = string.IsNullOrWhiteSpace(artifactId) ? null : artifactId!.Trim(),
|
||||
ArtifactPurl = string.IsNullOrWhiteSpace(artifactPurl) ? null : artifactPurl!.Trim(),
|
||||
PolicyVersion = string.IsNullOrWhiteSpace(policyVersion) ? null : policyVersion!.Trim(),
|
||||
Profile = normalizedProfile,
|
||||
PreferredSections = normalizedSections.Length > 0 ? normalizedSections : null,
|
||||
ForceRefresh = forceRefresh
|
||||
};
|
||||
|
||||
logger.LogInformation("Requesting advisory plan for {TaskType} (advisory={AdvisoryKey}).", taskType, normalizedKey);
|
||||
|
||||
var plan = await client.CreateAdvisoryPipelinePlanAsync(taskType, request, cancellationToken).ConfigureAwait(false);
|
||||
activity?.SetTag("stellaops.cli.advisory.cache_key", plan.CacheKey);
|
||||
RenderAdvisoryPlan(plan);
|
||||
logger.LogInformation("Plan {CacheKey} queued with {Chunks} chunks and {Vectors} vectors.",
|
||||
plan.CacheKey,
|
||||
plan.Chunks.Count,
|
||||
plan.Vectors.Count);
|
||||
|
||||
var pollDelay = TimeSpan.FromSeconds(1);
|
||||
var shouldWait = timeoutSeconds > 0;
|
||||
var deadline = shouldWait ? DateTimeOffset.UtcNow + TimeSpan.FromSeconds(timeoutSeconds) : DateTimeOffset.UtcNow;
|
||||
|
||||
AdvisoryPipelineOutputModel? output = null;
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
output = await client
|
||||
.TryGetAdvisoryPipelineOutputAsync(plan.CacheKey, taskType, normalizedProfile, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (output is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!shouldWait || DateTimeOffset.UtcNow >= deadline)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
logger.LogDebug("Advisory output pending for {CacheKey}; retrying in {DelaySeconds}s.", plan.CacheKey, pollDelay.TotalSeconds);
|
||||
await Task.Delay(pollDelay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (output is null)
|
||||
{
|
||||
logger.LogError("Timed out after {Timeout}s waiting for advisory output (cache key {CacheKey}).",
|
||||
Math.Max(timeoutSeconds, 0),
|
||||
plan.CacheKey);
|
||||
activity?.SetStatus(ActivityStatusCode.Error, "timeout");
|
||||
outcome = "timeout";
|
||||
Environment.ExitCode = Environment.ExitCode == 0 ? 70 : Environment.ExitCode;
|
||||
return;
|
||||
}
|
||||
|
||||
activity?.SetTag("stellaops.cli.advisory.generated_at", output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
activity?.SetTag("stellaops.cli.advisory.cache_hit", output.PlanFromCache);
|
||||
logger.LogInformation("Advisory output ready (cache key {CacheKey}).", output.CacheKey);
|
||||
|
||||
RenderAdvisoryOutput(output);
|
||||
|
||||
if (output.Guardrail.Blocked)
|
||||
{
|
||||
logger.LogError("Guardrail blocked advisory output (cache key {CacheKey}).", output.CacheKey);
|
||||
activity?.SetStatus(ActivityStatusCode.Error, "guardrail_blocked");
|
||||
outcome = "blocked";
|
||||
Environment.ExitCode = Environment.ExitCode == 0 ? 65 : Environment.ExitCode;
|
||||
return;
|
||||
}
|
||||
|
||||
activity?.SetStatus(ActivityStatusCode.Ok);
|
||||
outcome = output.PlanFromCache ? "cache-hit" : "ok";
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
outcome = "cancelled";
|
||||
activity?.SetStatus(ActivityStatusCode.Error, "cancelled");
|
||||
Environment.ExitCode = Environment.ExitCode == 0 ? 130 : Environment.ExitCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
logger.LogError(ex, "Failed to run advisory task.");
|
||||
outcome = "error";
|
||||
Environment.ExitCode = Environment.ExitCode == 0 ? 1 : Environment.ExitCode;
|
||||
}
|
||||
finally
|
||||
{
|
||||
activity?.SetTag("stellaops.cli.advisory.outcome", outcome);
|
||||
CliMetrics.RecordAdvisoryRun(taskType.ToString(), outcome);
|
||||
verbosity.MinimumLevel = previousLevel;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task HandleSourcesIngestAsync(
|
||||
IServiceProvider services,
|
||||
bool dryRun,
|
||||
string source,
|
||||
string input,
|
||||
string? tenantOverride,
|
||||
string format,
|
||||
bool disableColor,
|
||||
@@ -6137,7 +6278,156 @@ internal static class CommandHandlers
|
||||
["ERR_AOC_007"] = 17
|
||||
};
|
||||
|
||||
private static IDictionary<string, object?> RemoveNullValues(Dictionary<string, object?> source)
|
||||
private static string[] NormalizeSections(IReadOnlyList<string> sections)
|
||||
{
|
||||
if (sections is null || sections.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return sections
|
||||
.Where(section => !string.IsNullOrWhiteSpace(section))
|
||||
.Select(section => section.Trim())
|
||||
.Where(section => section.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static void RenderAdvisoryPlan(AdvisoryPipelinePlanResponseModel plan)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
var summary = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Advisory Plan[/]");
|
||||
summary.AddColumn("Field");
|
||||
summary.AddColumn("Value");
|
||||
summary.AddRow("Task", Markup.Escape(plan.TaskType));
|
||||
summary.AddRow("Cache Key", Markup.Escape(plan.CacheKey));
|
||||
summary.AddRow("Prompt Template", Markup.Escape(plan.PromptTemplate));
|
||||
summary.AddRow("Chunks", plan.Chunks.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summary.AddRow("Vectors", plan.Vectors.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summary.AddRow("Prompt Tokens", plan.Budget.PromptTokens.ToString(CultureInfo.InvariantCulture));
|
||||
summary.AddRow("Completion Tokens", plan.Budget.CompletionTokens.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
console.Write(summary);
|
||||
|
||||
if (plan.Metadata.Count > 0)
|
||||
{
|
||||
console.Write(CreateKeyValueTable("Plan Metadata", plan.Metadata));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderAdvisoryOutput(AdvisoryPipelineOutputModel output)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
var summary = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Advisory Output[/]");
|
||||
summary.AddColumn("Field");
|
||||
summary.AddColumn("Value");
|
||||
summary.AddRow("Cache Key", Markup.Escape(output.CacheKey));
|
||||
summary.AddRow("Task", Markup.Escape(output.TaskType));
|
||||
summary.AddRow("Profile", Markup.Escape(output.Profile));
|
||||
summary.AddRow("Generated", output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||
summary.AddRow("Plan From Cache", output.PlanFromCache ? "yes" : "no");
|
||||
summary.AddRow("Citations", output.Citations.Count.ToString(CultureInfo.InvariantCulture));
|
||||
summary.AddRow("Guardrail Blocked", output.Guardrail.Blocked ? "[red]yes[/]" : "no");
|
||||
|
||||
console.Write(summary);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(output.Prompt))
|
||||
{
|
||||
var panel = new Panel(new Markup(Markup.Escape(output.Prompt)))
|
||||
{
|
||||
Header = new PanelHeader("Prompt"),
|
||||
Border = BoxBorder.Rounded,
|
||||
Expand = true
|
||||
};
|
||||
console.Write(panel);
|
||||
}
|
||||
|
||||
if (output.Citations.Count > 0)
|
||||
{
|
||||
var citations = new Table()
|
||||
.Border(TableBorder.Minimal)
|
||||
.Title("[grey]Citations[/]");
|
||||
citations.AddColumn("Index");
|
||||
citations.AddColumn("Document");
|
||||
citations.AddColumn("Chunk");
|
||||
|
||||
foreach (var citation in output.Citations.OrderBy(c => c.Index))
|
||||
{
|
||||
citations.AddRow(
|
||||
citation.Index.ToString(CultureInfo.InvariantCulture),
|
||||
Markup.Escape(citation.DocumentId),
|
||||
Markup.Escape(citation.ChunkId));
|
||||
}
|
||||
|
||||
console.Write(citations);
|
||||
}
|
||||
|
||||
if (output.Metadata.Count > 0)
|
||||
{
|
||||
console.Write(CreateKeyValueTable("Output Metadata", output.Metadata));
|
||||
}
|
||||
|
||||
if (output.Guardrail.Metadata.Count > 0)
|
||||
{
|
||||
console.Write(CreateKeyValueTable("Guardrail Metadata", output.Guardrail.Metadata));
|
||||
}
|
||||
|
||||
if (output.Guardrail.Violations.Count > 0)
|
||||
{
|
||||
var violations = new Table()
|
||||
.Border(TableBorder.Minimal)
|
||||
.Title("[red]Guardrail Violations[/]");
|
||||
violations.AddColumn("Code");
|
||||
violations.AddColumn("Message");
|
||||
|
||||
foreach (var violation in output.Guardrail.Violations)
|
||||
{
|
||||
violations.AddRow(Markup.Escape(violation.Code), Markup.Escape(violation.Message));
|
||||
}
|
||||
|
||||
console.Write(violations);
|
||||
}
|
||||
|
||||
var provenance = new Table()
|
||||
.Border(TableBorder.Minimal)
|
||||
.Title("[grey]Provenance[/]");
|
||||
provenance.AddColumn("Field");
|
||||
provenance.AddColumn("Value");
|
||||
|
||||
provenance.AddRow("Input Digest", Markup.Escape(output.Provenance.InputDigest));
|
||||
provenance.AddRow("Output Hash", Markup.Escape(output.Provenance.OutputHash));
|
||||
|
||||
var signatures = output.Provenance.Signatures.Count == 0
|
||||
? "none"
|
||||
: string.Join(Environment.NewLine, output.Provenance.Signatures.Select(Markup.Escape));
|
||||
provenance.AddRow("Signatures", signatures);
|
||||
|
||||
console.Write(provenance);
|
||||
}
|
||||
|
||||
private static Table CreateKeyValueTable(string title, IReadOnlyDictionary<string, string> entries)
|
||||
{
|
||||
var table = new Table()
|
||||
.Border(TableBorder.Minimal)
|
||||
.Title($"[grey]{Markup.Escape(title)}[/]");
|
||||
table.AddColumn("Key");
|
||||
table.AddColumn("Value");
|
||||
|
||||
foreach (var kvp in entries.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
table.AddRow(Markup.Escape(kvp.Key), Markup.Escape(kvp.Value));
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
private static IDictionary<string, object?> RemoveNullValues(Dictionary<string, object?> source)
|
||||
{
|
||||
foreach (var key in source.Where(kvp => kvp.Value is null).Select(kvp => kvp.Key).ToList())
|
||||
{
|
||||
|
||||
@@ -26,13 +26,15 @@ public static class CliBootstrapper
|
||||
options.PostBind = (cliOptions, configuration) =>
|
||||
{
|
||||
cliOptions.ApiKey = ResolveWithFallback(cliOptions.ApiKey, configuration, "API_KEY", "StellaOps:ApiKey", "ApiKey");
|
||||
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
|
||||
cliOptions.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
|
||||
cliOptions.BackendUrl = ResolveWithFallback(cliOptions.BackendUrl, configuration, "STELLAOPS_BACKEND_URL", "StellaOps:BackendUrl", "BackendUrl");
|
||||
cliOptions.ConcelierUrl = ResolveWithFallback(cliOptions.ConcelierUrl, configuration, "STELLAOPS_CONCELIER_URL", "StellaOps:ConcelierUrl", "ConcelierUrl");
|
||||
cliOptions.AdvisoryAiUrl = ResolveWithFallback(cliOptions.AdvisoryAiUrl, configuration, "STELLAOPS_ADVISORYAI_URL", "StellaOps:AdvisoryAiUrl", "AdvisoryAiUrl");
|
||||
cliOptions.ScannerSignaturePublicKeyPath = ResolveWithFallback(cliOptions.ScannerSignaturePublicKeyPath, configuration, "SCANNER_PUBLIC_KEY", "STELLAOPS_SCANNER_PUBLIC_KEY", "StellaOps:ScannerSignaturePublicKeyPath", "ScannerSignaturePublicKeyPath");
|
||||
|
||||
cliOptions.ApiKey = cliOptions.ApiKey?.Trim() ?? string.Empty;
|
||||
cliOptions.BackendUrl = cliOptions.BackendUrl?.Trim() ?? string.Empty;
|
||||
cliOptions.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
|
||||
cliOptions.ConcelierUrl = cliOptions.ConcelierUrl?.Trim() ?? string.Empty;
|
||||
cliOptions.AdvisoryAiUrl = cliOptions.AdvisoryAiUrl?.Trim() ?? string.Empty;
|
||||
cliOptions.ScannerSignaturePublicKeyPath = cliOptions.ScannerSignaturePublicKeyPath?.Trim() ?? string.Empty;
|
||||
|
||||
var attemptsRaw = ResolveWithFallback(
|
||||
|
||||
@@ -11,7 +11,9 @@ public sealed class StellaOpsCliOptions
|
||||
|
||||
public string BackendUrl { get; set; } = string.Empty;
|
||||
|
||||
public string ConcelierUrl { get; set; } = string.Empty;
|
||||
public string ConcelierUrl { get; set; } = string.Empty;
|
||||
|
||||
public string AdvisoryAiUrl { get; set; } = string.Empty;
|
||||
|
||||
public string ScannerCacheDirectory { get; set; } = "scanners";
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Services.Models.Transport;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
@@ -30,10 +31,12 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
|
||||
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
private const string OperatorReasonParameterName = "operator_reason";
|
||||
private const string OperatorTicketParameterName = "operator_ticket";
|
||||
private const string BackfillReasonParameterName = "backfill_reason";
|
||||
private const string BackfillTicketParameterName = "backfill_ticket";
|
||||
private const string OperatorReasonParameterName = "operator_reason";
|
||||
private const string OperatorTicketParameterName = "operator_ticket";
|
||||
private const string BackfillReasonParameterName = "backfill_reason";
|
||||
private const string BackfillTicketParameterName = "backfill_ticket";
|
||||
private const string AdvisoryScopesHeader = "X-StellaOps-Scopes";
|
||||
private const string AdvisoryRunScope = "advisory:run";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly StellaOpsCliOptions _options;
|
||||
@@ -885,13 +888,122 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
throw new InvalidOperationException("EntryTrace response payload was empty.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(
|
||||
AdvisoryAiTaskType taskType,
|
||||
AdvisoryPipelinePlanRequestModel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var taskSegment = taskType.ToString().ToLowerInvariant();
|
||||
var relative = $"v1/advisory-ai/pipeline/{taskSegment}";
|
||||
|
||||
var payload = new AdvisoryPipelinePlanRequestModel
|
||||
{
|
||||
TaskType = taskType,
|
||||
AdvisoryKey = string.IsNullOrWhiteSpace(request.AdvisoryKey) ? string.Empty : request.AdvisoryKey.Trim(),
|
||||
ArtifactId = string.IsNullOrWhiteSpace(request.ArtifactId) ? null : request.ArtifactId!.Trim(),
|
||||
ArtifactPurl = string.IsNullOrWhiteSpace(request.ArtifactPurl) ? null : request.ArtifactPurl!.Trim(),
|
||||
PolicyVersion = string.IsNullOrWhiteSpace(request.PolicyVersion) ? null : request.PolicyVersion!.Trim(),
|
||||
Profile = string.IsNullOrWhiteSpace(request.Profile) ? "default" : request.Profile!.Trim(),
|
||||
PreferredSections = request.PreferredSections is null
|
||||
? null
|
||||
: request.PreferredSections
|
||||
.Where(static section => !string.IsNullOrWhiteSpace(section))
|
||||
.Select(static section => section.Trim())
|
||||
.ToArray(),
|
||||
ForceRefresh = request.ForceRefresh
|
||||
};
|
||||
|
||||
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
|
||||
ApplyAdvisoryAiEndpoint(httpRequest, taskType);
|
||||
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
|
||||
|
||||
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var plan = await response.Content.ReadFromJsonAsync<AdvisoryPipelinePlanResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (plan is null)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory AI plan response was empty.");
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse advisory plan response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(
|
||||
string cacheKey,
|
||||
AdvisoryAiTaskType taskType,
|
||||
string profile,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cacheKey))
|
||||
{
|
||||
throw new ArgumentException("Cache key is required.", nameof(cacheKey));
|
||||
}
|
||||
|
||||
var encodedKey = Uri.EscapeDataString(cacheKey);
|
||||
var taskSegment = Uri.EscapeDataString(taskType.ToString().ToLowerInvariant());
|
||||
var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
|
||||
var relative = $"v1/advisory-ai/outputs/{encodedKey}?taskType={taskSegment}&profile={Uri.EscapeDataString(resolvedProfile)}";
|
||||
|
||||
using var request = CreateRequest(HttpMethod.Get, relative);
|
||||
ApplyAdvisoryAiEndpoint(request, taskType);
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException(failure);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await response.Content.ReadFromJsonAsync<AdvisoryPipelineOutputModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var raw = response.Content is null
|
||||
? string.Empty
|
||||
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Failed to parse advisory output response. {ex.Message}", ex)
|
||||
{
|
||||
Data = { ["payload"] = raw }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
|
||||
var query = includeDisabled ? "?includeDisabled=true" : string.Empty;
|
||||
using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}");
|
||||
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
@@ -1778,7 +1890,44 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
||||
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, AdvisoryAiTaskType taskType)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var requestUri = request.RequestUri ?? throw new InvalidOperationException("Request URI was not initialized.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl) &&
|
||||
Uri.TryCreate(_options.AdvisoryAiUrl, UriKind.Absolute, out var advisoryBase))
|
||||
{
|
||||
if (!requestUri.IsAbsoluteUri)
|
||||
{
|
||||
request.RequestUri = new Uri(advisoryBase, requestUri.ToString());
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl))
|
||||
{
|
||||
throw new InvalidOperationException($"Advisory AI URL '{_options.AdvisoryAiUrl}' is not a valid absolute URI.");
|
||||
}
|
||||
else
|
||||
{
|
||||
EnsureBackendConfigured();
|
||||
}
|
||||
|
||||
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
|
||||
var combined = $"{AdvisoryRunScope} {taskScope}";
|
||||
|
||||
if (request.Headers.Contains(AdvisoryScopesHeader))
|
||||
{
|
||||
request.Headers.Remove(AdvisoryScopesHeader);
|
||||
}
|
||||
|
||||
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined);
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
|
||||
{
|
||||
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
|
||||
namespace StellaOps.Cli.Services;
|
||||
|
||||
@@ -46,4 +47,8 @@ internal interface IBackendOperationsClient
|
||||
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
|
||||
|
||||
Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
|
||||
internal enum AdvisoryAiTaskType
|
||||
{
|
||||
Summary,
|
||||
Conflict,
|
||||
Remediation
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryPipelinePlanRequestModel
|
||||
{
|
||||
public AdvisoryAiTaskType TaskType { get; init; }
|
||||
|
||||
public string AdvisoryKey { get; init; } = string.Empty;
|
||||
|
||||
public string? ArtifactId { get; init; }
|
||||
|
||||
public string? ArtifactPurl { get; init; }
|
||||
|
||||
public string? PolicyVersion { get; init; }
|
||||
|
||||
public string Profile { get; init; } = "default";
|
||||
|
||||
public IReadOnlyList<string>? PreferredSections { get; init; }
|
||||
|
||||
public bool ForceRefresh { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryPipelinePlanResponseModel
|
||||
{
|
||||
public string CacheKey { get; init; } = string.Empty;
|
||||
|
||||
public string TaskType { get; init; } = string.Empty;
|
||||
|
||||
public string PromptTemplate { get; init; } = string.Empty;
|
||||
|
||||
public AdvisoryTaskBudgetModel Budget { get; init; } = new();
|
||||
|
||||
public IReadOnlyList<PipelineChunkSummaryModel> Chunks { get; init; } = Array.Empty<PipelineChunkSummaryModel>();
|
||||
|
||||
public IReadOnlyList<PipelineVectorSummaryModel> Vectors { get; init; } = Array.Empty<PipelineVectorSummaryModel>();
|
||||
|
||||
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryTaskBudgetModel
|
||||
{
|
||||
public int PromptTokens { get; init; }
|
||||
|
||||
public int CompletionTokens { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PipelineChunkSummaryModel
|
||||
{
|
||||
public string DocumentId { get; init; } = string.Empty;
|
||||
|
||||
public string ChunkId { get; init; } = string.Empty;
|
||||
|
||||
public string Section { get; init; } = string.Empty;
|
||||
|
||||
public string? DisplaySection { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class PipelineVectorSummaryModel
|
||||
{
|
||||
public string Query { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<PipelineVectorMatchSummaryModel> Matches { get; init; } = Array.Empty<PipelineVectorMatchSummaryModel>();
|
||||
}
|
||||
|
||||
internal sealed class PipelineVectorMatchSummaryModel
|
||||
{
|
||||
public string ChunkId { get; init; } = string.Empty;
|
||||
|
||||
public double Score { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryPipelineOutputModel
|
||||
{
|
||||
public string CacheKey { get; init; } = string.Empty;
|
||||
|
||||
public string TaskType { get; init; } = string.Empty;
|
||||
|
||||
public string Profile { get; init; } = string.Empty;
|
||||
|
||||
public string Prompt { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<AdvisoryOutputCitationModel> Citations { get; init; } = Array.Empty<AdvisoryOutputCitationModel>();
|
||||
|
||||
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
|
||||
|
||||
public AdvisoryOutputGuardrailModel Guardrail { get; init; } = new();
|
||||
|
||||
public AdvisoryOutputProvenanceModel Provenance { get; init; } = new();
|
||||
|
||||
public DateTimeOffset GeneratedAtUtc { get; init; }
|
||||
|
||||
public bool PlanFromCache { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryOutputCitationModel
|
||||
{
|
||||
public int Index { get; init; }
|
||||
|
||||
public string DocumentId { get; init; } = string.Empty;
|
||||
|
||||
public string ChunkId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryOutputGuardrailModel
|
||||
{
|
||||
public bool Blocked { get; init; }
|
||||
|
||||
public string SanitizedPrompt { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<AdvisoryOutputGuardrailViolationModel> Violations { get; init; } = Array.Empty<AdvisoryOutputGuardrailViolationModel>();
|
||||
|
||||
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryOutputGuardrailViolationModel
|
||||
{
|
||||
public string Code { get; init; } = string.Empty;
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
internal sealed class AdvisoryOutputProvenanceModel
|
||||
{
|
||||
public string InputDigest { get; init; } = string.Empty;
|
||||
|
||||
public string OutputHash { get; init; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<string> Signatures { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -20,6 +20,7 @@ internal static class CliMetrics
|
||||
private static readonly Counter<long> PolicyFindingsListCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.list.count");
|
||||
private static readonly Counter<long> PolicyFindingsGetCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.get.count");
|
||||
private static readonly Counter<long> PolicyFindingsExplainCounter = Meter.CreateCounter<long>("stellaops.cli.policy.findings.explain.count");
|
||||
private static readonly Counter<long> AdvisoryRunCounter = Meter.CreateCounter<long>("stellaops.cli.advisory.run.count");
|
||||
private static readonly Histogram<double> CommandDurationHistogram = Meter.CreateHistogram<double>("stellaops.cli.command.duration.ms");
|
||||
|
||||
public static void RecordScannerDownload(string channel, bool fromCache)
|
||||
@@ -70,6 +71,13 @@ internal static class CliMetrics
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static void RecordAdvisoryRun(string taskType, string outcome)
|
||||
=> AdvisoryRunCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
new("task", string.IsNullOrWhiteSpace(taskType) ? "unknown" : taskType.ToLowerInvariant()),
|
||||
new("outcome", string.IsNullOrWhiteSpace(outcome) ? "unknown" : outcome)
|
||||
});
|
||||
|
||||
public static void RecordSourcesDryRun(string status)
|
||||
=> SourcesDryRunCounter.Add(1, new KeyValuePair<string, object?>[]
|
||||
{
|
||||
|
||||
@@ -23,6 +23,7 @@ using StellaOps.Cli.Commands;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Services;
|
||||
using StellaOps.Cli.Services.Models;
|
||||
using StellaOps.Cli.Services.Models.AdvisoryAi;
|
||||
using StellaOps.Cli.Telemetry;
|
||||
using StellaOps.Cli.Tests.Testing;
|
||||
using StellaOps.Cryptography;
|
||||
@@ -223,6 +224,291 @@ public sealed class CommandHandlersTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAdviseRunAsync_WritesOutputAndSetsExitCode()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
var testConsole = new TestConsole();
|
||||
|
||||
try
|
||||
{
|
||||
Environment.ExitCode = 0;
|
||||
AnsiConsole.Console = testConsole;
|
||||
|
||||
var planResponse = new AdvisoryPipelinePlanResponseModel
|
||||
{
|
||||
TaskType = AdvisoryAiTaskType.Summary.ToString(),
|
||||
CacheKey = "cache-123",
|
||||
PromptTemplate = "prompts/advisory/summary.liquid",
|
||||
Budget = new AdvisoryTaskBudgetModel
|
||||
{
|
||||
PromptTokens = 512,
|
||||
CompletionTokens = 128
|
||||
},
|
||||
Chunks = new[]
|
||||
{
|
||||
new PipelineChunkSummaryModel
|
||||
{
|
||||
DocumentId = "doc-1",
|
||||
ChunkId = "chunk-1",
|
||||
Section = "Summary",
|
||||
DisplaySection = "Summary"
|
||||
}
|
||||
},
|
||||
Vectors = new[]
|
||||
{
|
||||
new PipelineVectorSummaryModel
|
||||
{
|
||||
Query = "summary query",
|
||||
Matches = new[]
|
||||
{
|
||||
new PipelineVectorMatchSummaryModel
|
||||
{
|
||||
ChunkId = "chunk-1",
|
||||
Score = 0.9
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["profile"] = "default"
|
||||
}
|
||||
};
|
||||
|
||||
var outputResponse = new AdvisoryPipelineOutputModel
|
||||
{
|
||||
CacheKey = planResponse.CacheKey,
|
||||
TaskType = planResponse.TaskType,
|
||||
Profile = "default",
|
||||
Prompt = "Summary result",
|
||||
Citations = new[]
|
||||
{
|
||||
new AdvisoryOutputCitationModel
|
||||
{
|
||||
Index = 0,
|
||||
DocumentId = "doc-1",
|
||||
ChunkId = "chunk-1"
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
["confidence"] = "high"
|
||||
},
|
||||
Guardrail = new AdvisoryOutputGuardrailModel
|
||||
{
|
||||
Blocked = false,
|
||||
SanitizedPrompt = "Summary result",
|
||||
Violations = Array.Empty<AdvisoryOutputGuardrailViolationModel>(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
},
|
||||
Provenance = new AdvisoryOutputProvenanceModel
|
||||
{
|
||||
InputDigest = "sha256:aaa",
|
||||
OutputHash = "sha256:bbb",
|
||||
Signatures = Array.Empty<string>()
|
||||
},
|
||||
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
PlanFromCache = false
|
||||
};
|
||||
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||
{
|
||||
AdvisoryPlanResponse = planResponse,
|
||||
AdvisoryOutputResponse = outputResponse
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
await CommandHandlers.HandleAdviseRunAsync(
|
||||
provider,
|
||||
AdvisoryAiTaskType.Summary,
|
||||
" ADV-1 ",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"default",
|
||||
new[] { "impact", "impact " },
|
||||
forceRefresh: false,
|
||||
timeoutSeconds: 0,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Single(backend.AdvisoryPlanRequests);
|
||||
var request = backend.AdvisoryPlanRequests[0];
|
||||
Assert.Equal(AdvisoryAiTaskType.Summary, request.TaskType);
|
||||
Assert.Equal("ADV-1", request.Request.AdvisoryKey);
|
||||
Assert.NotNull(request.Request.PreferredSections);
|
||||
Assert.Single(request.Request.PreferredSections!);
|
||||
Assert.Equal("impact", request.Request.PreferredSections![0]);
|
||||
|
||||
Assert.Single(backend.AdvisoryOutputRequests);
|
||||
Assert.Equal(planResponse.CacheKey, backend.AdvisoryOutputRequests[0].CacheKey);
|
||||
Assert.Equal("default", backend.AdvisoryOutputRequests[0].Profile);
|
||||
|
||||
var output = testConsole.Output;
|
||||
Assert.Contains("Advisory Output", output, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains(planResponse.CacheKey, output, StringComparison.Ordinal);
|
||||
Assert.Contains("Summary result", output, StringComparison.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AnsiConsole.Console = originalConsole;
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAdviseRunAsync_ReturnsGuardrailExitCodeOnBlock()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
var testConsole = new TestConsole();
|
||||
|
||||
try
|
||||
{
|
||||
Environment.ExitCode = 0;
|
||||
AnsiConsole.Console = testConsole;
|
||||
|
||||
var planResponse = new AdvisoryPipelinePlanResponseModel
|
||||
{
|
||||
TaskType = AdvisoryAiTaskType.Remediation.ToString(),
|
||||
CacheKey = "cache-guard",
|
||||
PromptTemplate = "prompts/advisory/remediation.liquid",
|
||||
Budget = new AdvisoryTaskBudgetModel
|
||||
{
|
||||
PromptTokens = 256,
|
||||
CompletionTokens = 64
|
||||
},
|
||||
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
|
||||
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var outputResponse = new AdvisoryPipelineOutputModel
|
||||
{
|
||||
CacheKey = planResponse.CacheKey,
|
||||
TaskType = planResponse.TaskType,
|
||||
Profile = "default",
|
||||
Prompt = "Blocked output",
|
||||
Citations = Array.Empty<AdvisoryOutputCitationModel>(),
|
||||
Metadata = new Dictionary<string, string>(),
|
||||
Guardrail = new AdvisoryOutputGuardrailModel
|
||||
{
|
||||
Blocked = true,
|
||||
SanitizedPrompt = "Blocked output",
|
||||
Violations = new[]
|
||||
{
|
||||
new AdvisoryOutputGuardrailViolationModel
|
||||
{
|
||||
Code = "PROMPT_INJECTION",
|
||||
Message = "Detected prompt injection attempt."
|
||||
}
|
||||
},
|
||||
Metadata = new Dictionary<string, string>()
|
||||
},
|
||||
Provenance = new AdvisoryOutputProvenanceModel
|
||||
{
|
||||
InputDigest = "sha256:ccc",
|
||||
OutputHash = "sha256:ddd",
|
||||
Signatures = Array.Empty<string>()
|
||||
},
|
||||
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T13:05:00Z", CultureInfo.InvariantCulture),
|
||||
PlanFromCache = true
|
||||
};
|
||||
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||
{
|
||||
AdvisoryPlanResponse = planResponse,
|
||||
AdvisoryOutputResponse = outputResponse
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
await CommandHandlers.HandleAdviseRunAsync(
|
||||
provider,
|
||||
AdvisoryAiTaskType.Remediation,
|
||||
"ADV-2",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"default",
|
||||
Array.Empty<string>(),
|
||||
forceRefresh: true,
|
||||
timeoutSeconds: 0,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(65, Environment.ExitCode);
|
||||
Assert.Contains("Guardrail Violations", testConsole.Output, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AnsiConsole.Console = originalConsole;
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAdviseRunAsync_TimesOutWhenOutputMissing()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
|
||||
try
|
||||
{
|
||||
Environment.ExitCode = 0;
|
||||
AnsiConsole.Console = new TestConsole();
|
||||
|
||||
var planResponse = new AdvisoryPipelinePlanResponseModel
|
||||
{
|
||||
TaskType = AdvisoryAiTaskType.Conflict.ToString(),
|
||||
CacheKey = "cache-timeout",
|
||||
PromptTemplate = "prompts/advisory/conflict.liquid",
|
||||
Budget = new AdvisoryTaskBudgetModel
|
||||
{
|
||||
PromptTokens = 128,
|
||||
CompletionTokens = 32
|
||||
},
|
||||
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
|
||||
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||
{
|
||||
AdvisoryPlanResponse = planResponse,
|
||||
AdvisoryOutputResponse = null
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(backend);
|
||||
|
||||
await CommandHandlers.HandleAdviseRunAsync(
|
||||
provider,
|
||||
AdvisoryAiTaskType.Conflict,
|
||||
"ADV-3",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"default",
|
||||
Array.Empty<string>(),
|
||||
forceRefresh: false,
|
||||
timeoutSeconds: 0,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Assert.Equal(70, Environment.ExitCode);
|
||||
Assert.Single(backend.AdvisoryOutputRequests);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AnsiConsole.Console = originalConsole;
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAuthLoginAsync_UsesClientCredentialsFlow()
|
||||
{
|
||||
@@ -1726,10 +2012,16 @@ spec:
|
||||
Assert.NotNull(backend.LastTaskRunnerSimulationRequest);
|
||||
|
||||
var consoleOutput = writer.ToString();
|
||||
Assert.Contains("\"planHash\":\"hash-xyz789\"", consoleOutput, StringComparison.Ordinal);
|
||||
using (var consoleJson = JsonDocument.Parse(consoleOutput))
|
||||
{
|
||||
Assert.Equal("hash-xyz789", consoleJson.RootElement.GetProperty("planHash").GetString());
|
||||
}
|
||||
|
||||
var fileOutput = await File.ReadAllTextAsync(outputPath);
|
||||
Assert.Contains("\"planHash\":\"hash-xyz789\"", fileOutput, StringComparison.Ordinal);
|
||||
using (var fileJson = JsonDocument.Parse(fileOutput))
|
||||
{
|
||||
Assert.Equal("hash-xyz789", fileJson.RootElement.GetProperty("planHash").GetString());
|
||||
}
|
||||
|
||||
Assert.True(backend.LastTaskRunnerSimulationRequest!.Inputs!.TryGetPropertyValue("dryRun", out var dryRunNode));
|
||||
Assert.False(dryRunNode!.GetValue<bool>());
|
||||
@@ -2738,6 +3030,13 @@ spec:
|
||||
public EntryTraceResponseModel? EntryTraceResponse { get; set; }
|
||||
public Exception? EntryTraceException { get; set; }
|
||||
public string? LastEntryTraceScanId { get; private set; }
|
||||
public List<(AdvisoryAiTaskType TaskType, AdvisoryPipelinePlanRequestModel Request)> AdvisoryPlanRequests { get; } = new();
|
||||
public AdvisoryPipelinePlanResponseModel? AdvisoryPlanResponse { get; set; }
|
||||
public Exception? AdvisoryPlanException { get; set; }
|
||||
public Queue<AdvisoryPipelineOutputModel?> AdvisoryOutputQueue { get; } = new();
|
||||
public AdvisoryPipelineOutputModel? AdvisoryOutputResponse { get; set; }
|
||||
public Exception? AdvisoryOutputException { get; set; }
|
||||
public List<(string CacheKey, AdvisoryAiTaskType TaskType, string Profile)> AdvisoryOutputRequests { get; } = new();
|
||||
|
||||
public Task<ScannerArtifactResult> DownloadScannerAsync(string channel, string outputPath, bool overwrite, bool verbose, CancellationToken cancellationToken)
|
||||
=> throw new NotImplementedException();
|
||||
@@ -2890,10 +3189,52 @@ spec:
|
||||
|
||||
return Task.FromResult(EntryTraceResponse);
|
||||
}
|
||||
|
||||
public Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken)
|
||||
{
|
||||
AdvisoryPlanRequests.Add((taskType, request));
|
||||
if (AdvisoryPlanException is not null)
|
||||
{
|
||||
throw AdvisoryPlanException;
|
||||
}
|
||||
|
||||
var response = AdvisoryPlanResponse ?? new AdvisoryPipelinePlanResponseModel
|
||||
{
|
||||
TaskType = taskType.ToString(),
|
||||
CacheKey = "stub-cache-key",
|
||||
PromptTemplate = "prompts/advisory/stub.liquid",
|
||||
Budget = new AdvisoryTaskBudgetModel
|
||||
{
|
||||
PromptTokens = 0,
|
||||
CompletionTokens = 0
|
||||
},
|
||||
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
|
||||
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
|
||||
Metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
};
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
|
||||
public Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken)
|
||||
{
|
||||
AdvisoryOutputRequests.Add((cacheKey, taskType, profile));
|
||||
if (AdvisoryOutputException is not null)
|
||||
{
|
||||
throw AdvisoryOutputException;
|
||||
}
|
||||
|
||||
if (AdvisoryOutputQueue.Count > 0)
|
||||
{
|
||||
return Task.FromResult(AdvisoryOutputQueue.Dequeue());
|
||||
}
|
||||
|
||||
return Task.FromResult(AdvisoryOutputResponse);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class StubExecutor : IScannerExecutor
|
||||
{
|
||||
|
||||
private sealed class StubExecutor : IScannerExecutor
|
||||
{
|
||||
private readonly ScannerExecutionResult _result;
|
||||
|
||||
public StubExecutor(ScannerExecutionResult result)
|
||||
|
||||
@@ -19,16 +19,20 @@ public sealed class EgressPolicyHttpMessageHandlerTests
|
||||
{
|
||||
Mode = EgressPolicyMode.Sealed
|
||||
};
|
||||
options.AddAllowRule(example.com);
|
||||
options.AddAllowRule("example.com");
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var handler = new EgressPolicyHttpMessageHandler(policy, NullLogger<EgressPolicyHttpMessageHandler>.Instance, cli, test)
|
||||
var handler = new EgressPolicyHttpMessageHandler(
|
||||
policy,
|
||||
NullLogger.Instance,
|
||||
component: "cli-tests",
|
||||
intent: "allow-test")
|
||||
{
|
||||
InnerHandler = new StubHandler()
|
||||
};
|
||||
|
||||
var client = new HttpClient(handler, disposeHandler: true);
|
||||
var response = await client.GetAsync(https://example.com/resource, CancellationToken.None).ConfigureAwait(false);
|
||||
using var client = new HttpClient(handler, disposeHandler: true);
|
||||
var response = await client.GetAsync("https://example.com/resource", CancellationToken.None);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
@@ -42,15 +46,19 @@ public sealed class EgressPolicyHttpMessageHandlerTests
|
||||
};
|
||||
|
||||
var policy = new EgressPolicy(options);
|
||||
var handler = new EgressPolicyHttpMessageHandler(policy, NullLogger<EgressPolicyHttpMessageHandler>.Instance, cli, test)
|
||||
var handler = new EgressPolicyHttpMessageHandler(
|
||||
policy,
|
||||
NullLogger.Instance,
|
||||
component: "cli-tests",
|
||||
intent: "deny-test")
|
||||
{
|
||||
InnerHandler = new StubHandler()
|
||||
};
|
||||
|
||||
var client = new HttpClient(handler, disposeHandler: true);
|
||||
using var client = new HttpClient(handler, disposeHandler: true);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<AirGapEgressBlockedException>(
|
||||
() => client.GetAsync(https://blocked.example, CancellationToken.None)).ConfigureAwait(false);
|
||||
() => client.GetAsync("https://blocked.example", CancellationToken.None));
|
||||
|
||||
Assert.Contains(AirGapEgressBlockedException.ErrorCode, exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@ public sealed class BackendOperationsClientTests
|
||||
var result = await client.TriggerJobAsync("test", new Dictionary<string, object?>(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success);
|
||||
var metadata = Assert.NotNull(tokenClient.LastAdditionalParameters);
|
||||
var metadata = Assert.IsAssignableFrom<IReadOnlyDictionary<string, string>>(tokenClient.LastAdditionalParameters);
|
||||
Assert.Equal("Resume operations", metadata["operator_reason"]);
|
||||
Assert.Equal("INC-6006", metadata["operator_ticket"]);
|
||||
Assert.Equal("Historical rebuild", metadata["backfill_reason"]);
|
||||
|
||||
Reference in New Issue
Block a user