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:
@@ -13,6 +13,8 @@ public sealed class AdvisoryAiServiceOptions
|
||||
|
||||
public AdvisoryAiQueueOptions Queue { get; set; } = new();
|
||||
|
||||
public AdvisoryAiStorageOptions Storage { get; set; } = new();
|
||||
|
||||
internal string ResolveQueueDirectory(string contentRoot)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot);
|
||||
@@ -31,9 +33,45 @@ public sealed class AdvisoryAiServiceOptions
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
internal string ResolvePlanCacheDirectory(string contentRoot)
|
||||
=> Storage.ResolvePlanCacheDirectory(contentRoot);
|
||||
|
||||
internal string ResolveOutputDirectory(string contentRoot)
|
||||
=> Storage.ResolveOutputDirectory(contentRoot);
|
||||
}
|
||||
|
||||
public sealed class AdvisoryAiQueueOptions
|
||||
{
|
||||
public string DirectoryPath { get; set; } = Path.Combine("data", "advisory-ai", "queue");
|
||||
}
|
||||
|
||||
public sealed class AdvisoryAiStorageOptions
|
||||
{
|
||||
public string PlanCacheDirectory { get; set; } = Path.Combine("data", "advisory-ai", "plans");
|
||||
|
||||
public string OutputDirectory { get; set; } = Path.Combine("data", "advisory-ai", "outputs");
|
||||
|
||||
internal string ResolvePlanCacheDirectory(string contentRoot)
|
||||
=> Resolve(contentRoot, PlanCacheDirectory, Path.Combine("data", "advisory-ai", "plans"));
|
||||
|
||||
internal string ResolveOutputDirectory(string contentRoot)
|
||||
=> Resolve(contentRoot, OutputDirectory, Path.Combine("data", "advisory-ai", "outputs"));
|
||||
|
||||
private static string Resolve(string contentRoot, string configuredPath, string fallback)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentRoot);
|
||||
|
||||
var path = string.IsNullOrWhiteSpace(configuredPath)
|
||||
? fallback
|
||||
: configuredPath;
|
||||
|
||||
if (!Path.IsPathFullyQualified(path))
|
||||
{
|
||||
path = Path.GetFullPath(Path.Combine(contentRoot, path));
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ internal static class AdvisoryAiServiceOptionsValidator
|
||||
options.Queue.DirectoryPath = Path.Combine("data", "advisory-ai", "queue");
|
||||
}
|
||||
|
||||
options.Storage ??= new AdvisoryAiStorageOptions();
|
||||
if (string.IsNullOrWhiteSpace(options.Storage.PlanCacheDirectory))
|
||||
{
|
||||
options.Storage.PlanCacheDirectory = Path.Combine("data", "advisory-ai", "plans");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Storage.OutputDirectory))
|
||||
{
|
||||
options.Storage.OutputDirectory = Path.Combine("data", "advisory-ai", "outputs");
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Guardrails;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Prompting;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
internal sealed class FileSystemAdvisoryOutputStore : IAdvisoryOutputStore
|
||||
{
|
||||
private readonly string _rootDirectory;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly ILogger<FileSystemAdvisoryOutputStore> _logger;
|
||||
|
||||
public FileSystemAdvisoryOutputStore(
|
||||
IOptions<AdvisoryAiServiceOptions> serviceOptions,
|
||||
ILogger<FileSystemAdvisoryOutputStore> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceOptions);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var options = serviceOptions.Value ?? throw new InvalidOperationException("Advisory AI options are required.");
|
||||
AdvisoryAiServiceOptionsValidator.Validate(options);
|
||||
_rootDirectory = options.ResolveOutputDirectory(AppContext.BaseDirectory);
|
||||
Directory.CreateDirectory(_rootDirectory);
|
||||
}
|
||||
|
||||
public async Task SaveAsync(AdvisoryPipelineOutput output, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(output);
|
||||
|
||||
var path = GetOutputPath(output.CacheKey, output.TaskType, output.Profile);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
var envelope = OutputEnvelope.FromOutput(output);
|
||||
var tmpPath = $"{path}.tmp";
|
||||
|
||||
await using (var stream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, envelope, _serializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
File.Move(tmpPath, path, overwrite: true);
|
||||
}
|
||||
|
||||
public async Task<AdvisoryPipelineOutput?> TryGetAsync(string cacheKey, AdvisoryTaskType taskType, string profile, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(profile);
|
||||
|
||||
var path = GetOutputPath(cacheKey, taskType, profile);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var envelope = await JsonSerializer
|
||||
.DeserializeAsync<OutputEnvelope>(stream, _serializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return envelope?.ToOutput();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read advisory output file {Path}", path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetOutputPath(string cacheKey, AdvisoryTaskType taskType, string profile)
|
||||
{
|
||||
var safeKey = Sanitize(cacheKey);
|
||||
var safeProfile = Sanitize(profile);
|
||||
var taskDirectory = Path.Combine(_rootDirectory, taskType.ToString().ToLowerInvariant(), safeProfile);
|
||||
return Path.Combine(taskDirectory, $"{safeKey}.json");
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var buffer = new char[value.Length];
|
||||
var length = 0;
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
buffer[length++] = invalid.Contains(ch) ? '_' : ch;
|
||||
}
|
||||
|
||||
return new string(buffer, 0, length);
|
||||
}
|
||||
|
||||
private sealed record OutputEnvelope(
|
||||
string CacheKey,
|
||||
AdvisoryTaskType TaskType,
|
||||
string Profile,
|
||||
string Prompt,
|
||||
List<AdvisoryPromptCitation> Citations,
|
||||
Dictionary<string, string> Metadata,
|
||||
GuardrailEnvelope Guardrail,
|
||||
ProvenanceEnvelope Provenance,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
bool PlanFromCache)
|
||||
{
|
||||
public static OutputEnvelope FromOutput(AdvisoryPipelineOutput output)
|
||||
=> new(
|
||||
output.CacheKey,
|
||||
output.TaskType,
|
||||
output.Profile,
|
||||
output.Prompt,
|
||||
output.Citations.ToList(),
|
||||
output.Metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal),
|
||||
GuardrailEnvelope.FromResult(output.Guardrail),
|
||||
ProvenanceEnvelope.FromProvenance(output.Provenance),
|
||||
output.GeneratedAtUtc,
|
||||
output.PlanFromCache);
|
||||
|
||||
public AdvisoryPipelineOutput ToOutput()
|
||||
{
|
||||
var guardrail = Guardrail.ToResult();
|
||||
var citations = Citations.ToImmutableArray();
|
||||
var metadata = Metadata.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
|
||||
return new AdvisoryPipelineOutput(
|
||||
CacheKey,
|
||||
TaskType,
|
||||
Profile,
|
||||
Prompt,
|
||||
citations,
|
||||
metadata,
|
||||
guardrail,
|
||||
Provenance.ToProvenance(),
|
||||
GeneratedAtUtc,
|
||||
PlanFromCache);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record GuardrailEnvelope(
|
||||
bool Blocked,
|
||||
string SanitizedPrompt,
|
||||
List<AdvisoryGuardrailViolation> Violations,
|
||||
Dictionary<string, string> Metadata)
|
||||
{
|
||||
public static GuardrailEnvelope FromResult(AdvisoryGuardrailResult result)
|
||||
=> new(
|
||||
result.Blocked,
|
||||
result.SanitizedPrompt,
|
||||
result.Violations.ToList(),
|
||||
result.Metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
|
||||
|
||||
public AdvisoryGuardrailResult ToResult()
|
||||
=> Blocked
|
||||
? AdvisoryGuardrailResult.Reject(SanitizedPrompt, Violations, Metadata.ToImmutableDictionary(StringComparer.Ordinal))
|
||||
: AdvisoryGuardrailResult.Allowed(SanitizedPrompt, Metadata.ToImmutableDictionary(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private sealed record ProvenanceEnvelope(
|
||||
string InputDigest,
|
||||
string OutputHash,
|
||||
List<string> Signatures)
|
||||
{
|
||||
public static ProvenanceEnvelope FromProvenance(AdvisoryDsseProvenance provenance)
|
||||
=> new(
|
||||
provenance.InputDigest,
|
||||
provenance.OutputHash,
|
||||
provenance.Signatures.ToList());
|
||||
|
||||
public AdvisoryDsseProvenance ToProvenance()
|
||||
=> new(InputDigest, OutputHash, Signatures.ToImmutableArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Abstractions;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
internal sealed class FileSystemAdvisoryPlanCache : IAdvisoryPlanCache
|
||||
{
|
||||
private readonly string _directory;
|
||||
private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly ILogger<FileSystemAdvisoryPlanCache> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _defaultTtl;
|
||||
private readonly TimeSpan _cleanupInterval;
|
||||
private DateTimeOffset _lastCleanup;
|
||||
|
||||
public FileSystemAdvisoryPlanCache(
|
||||
IOptions<AdvisoryAiServiceOptions> serviceOptions,
|
||||
IOptions<AdvisoryPlanCacheOptions> cacheOptions,
|
||||
ILogger<FileSystemAdvisoryPlanCache> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serviceOptions);
|
||||
ArgumentNullException.ThrowIfNull(cacheOptions);
|
||||
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
var options = serviceOptions.Value ?? throw new InvalidOperationException("Advisory AI options are required.");
|
||||
AdvisoryAiServiceOptionsValidator.Validate(options);
|
||||
_directory = options.ResolvePlanCacheDirectory(AppContext.BaseDirectory);
|
||||
Directory.CreateDirectory(_directory);
|
||||
|
||||
var cache = cacheOptions.Value ?? throw new InvalidOperationException("Plan cache options are required.");
|
||||
if (cache.DefaultTimeToLive <= TimeSpan.Zero)
|
||||
{
|
||||
cache.DefaultTimeToLive = TimeSpan.FromMinutes(10);
|
||||
}
|
||||
|
||||
if (cache.CleanupInterval <= TimeSpan.Zero)
|
||||
{
|
||||
cache.CleanupInterval = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
_defaultTtl = cache.DefaultTimeToLive;
|
||||
_cleanupInterval = cache.CleanupInterval;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_lastCleanup = _timeProvider.GetUtcNow();
|
||||
}
|
||||
|
||||
public async Task SetAsync(string cacheKey, AdvisoryTaskPlan plan, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
await CleanupIfRequiredAsync(now, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var envelope = PlanEnvelope.FromPlan(plan, now + _defaultTtl);
|
||||
var targetPath = GetPlanPath(cacheKey);
|
||||
var tmpPath = $"{targetPath}.tmp";
|
||||
|
||||
await using (var stream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, envelope, _serializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
File.Move(tmpPath, targetPath, overwrite: true);
|
||||
}
|
||||
|
||||
public async Task<AdvisoryTaskPlan?> TryGetAsync(string cacheKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
|
||||
var path = GetPlanPath(cacheKey);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var envelope = await JsonSerializer
|
||||
.DeserializeAsync<PlanEnvelope>(stream, _serializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (envelope is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
if (envelope.ExpiresAtUtc <= now)
|
||||
{
|
||||
TryDelete(path);
|
||||
return null;
|
||||
}
|
||||
|
||||
return envelope.ToPlan();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read advisory plan cache file {Path}", path);
|
||||
TryDelete(path);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string cacheKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheKey);
|
||||
|
||||
var path = GetPlanPath(cacheKey);
|
||||
TryDelete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task CleanupIfRequiredAsync(DateTimeOffset now, CancellationToken cancellationToken)
|
||||
{
|
||||
if (now - _lastCleanup < _cleanupInterval)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(_directory, "*.json", SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var envelope = await JsonSerializer
|
||||
.DeserializeAsync<PlanEnvelope>(stream, _serializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (envelope is null || envelope.ExpiresAtUtc <= now)
|
||||
{
|
||||
TryDelete(file);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to inspect advisory plan cache file {Path}", file);
|
||||
TryDelete(file);
|
||||
}
|
||||
}
|
||||
|
||||
_lastCleanup = now;
|
||||
}
|
||||
|
||||
private string GetPlanPath(string cacheKey)
|
||||
{
|
||||
var safeName = Sanitize(cacheKey);
|
||||
return Path.Combine(_directory, $"{safeName}.json");
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var builder = new char[value.Length];
|
||||
var length = 0;
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
builder[length++] = invalid.Contains(ch) ? '_' : ch;
|
||||
}
|
||||
|
||||
return new string(builder, 0, length);
|
||||
}
|
||||
|
||||
private void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to delete advisory plan cache file {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PlanEnvelope(
|
||||
AdvisoryTaskRequestEnvelope Request,
|
||||
string CacheKey,
|
||||
string PromptTemplate,
|
||||
List<AdvisoryChunkEnvelope> StructuredChunks,
|
||||
List<VectorResultEnvelope> VectorResults,
|
||||
SbomContextEnvelope? SbomContext,
|
||||
DependencyAnalysisEnvelope? DependencyAnalysis,
|
||||
AdvisoryTaskBudget Budget,
|
||||
Dictionary<string, string> Metadata,
|
||||
DateTimeOffset ExpiresAtUtc)
|
||||
{
|
||||
public static PlanEnvelope FromPlan(AdvisoryTaskPlan plan, DateTimeOffset expiry)
|
||||
=> new(
|
||||
AdvisoryTaskRequestEnvelope.FromRequest(plan.Request),
|
||||
plan.CacheKey,
|
||||
plan.PromptTemplate,
|
||||
plan.StructuredChunks.Select(AdvisoryChunkEnvelope.FromChunk).ToList(),
|
||||
plan.VectorResults.Select(VectorResultEnvelope.FromResult).ToList(),
|
||||
plan.SbomContext is null ? null : SbomContextEnvelope.FromContext(plan.SbomContext),
|
||||
plan.DependencyAnalysis is null ? null : DependencyAnalysisEnvelope.FromAnalysis(plan.DependencyAnalysis),
|
||||
plan.Budget,
|
||||
plan.Metadata.ToDictionary(static p => p.Key, static p => p.Value, StringComparer.Ordinal),
|
||||
expiry);
|
||||
|
||||
public AdvisoryTaskPlan ToPlan()
|
||||
{
|
||||
var chunks = StructuredChunks
|
||||
.Select(static chunk => chunk.ToChunk())
|
||||
.ToImmutableArray();
|
||||
|
||||
var vectors = VectorResults
|
||||
.Select(static result => result.ToResult())
|
||||
.ToImmutableArray();
|
||||
|
||||
var sbom = SbomContext?.ToContext();
|
||||
var dependency = DependencyAnalysis?.ToAnalysis();
|
||||
var metadata = Metadata.ToImmutableDictionary(StringComparer.Ordinal);
|
||||
|
||||
return new AdvisoryTaskPlan(
|
||||
Request.ToRequest(),
|
||||
CacheKey,
|
||||
PromptTemplate,
|
||||
chunks,
|
||||
vectors,
|
||||
sbom,
|
||||
dependency,
|
||||
Budget,
|
||||
metadata);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record AdvisoryTaskRequestEnvelope(
|
||||
AdvisoryTaskType TaskType,
|
||||
string AdvisoryKey,
|
||||
string? ArtifactId,
|
||||
string? ArtifactPurl,
|
||||
string? PolicyVersion,
|
||||
string Profile,
|
||||
IReadOnlyList<string>? PreferredSections,
|
||||
bool ForceRefresh)
|
||||
{
|
||||
public static AdvisoryTaskRequestEnvelope FromRequest(AdvisoryTaskRequest request)
|
||||
=> new(
|
||||
request.TaskType,
|
||||
request.AdvisoryKey,
|
||||
request.ArtifactId,
|
||||
request.ArtifactPurl,
|
||||
request.PolicyVersion,
|
||||
request.Profile,
|
||||
request.PreferredSections?.ToArray(),
|
||||
request.ForceRefresh);
|
||||
|
||||
public AdvisoryTaskRequest ToRequest()
|
||||
=> new(
|
||||
TaskType,
|
||||
AdvisoryKey,
|
||||
ArtifactId,
|
||||
ArtifactPurl,
|
||||
PolicyVersion,
|
||||
Profile,
|
||||
PreferredSections,
|
||||
ForceRefresh);
|
||||
}
|
||||
|
||||
private sealed record AdvisoryChunkEnvelope(
|
||||
string DocumentId,
|
||||
string ChunkId,
|
||||
string Section,
|
||||
string ParagraphId,
|
||||
string Text,
|
||||
Dictionary<string, string> Metadata)
|
||||
{
|
||||
public static AdvisoryChunkEnvelope FromChunk(AdvisoryChunk chunk)
|
||||
=> new(
|
||||
chunk.DocumentId,
|
||||
chunk.ChunkId,
|
||||
chunk.Section,
|
||||
chunk.ParagraphId,
|
||||
chunk.Text,
|
||||
chunk.Metadata.ToDictionary(static p => p.Key, static p => p.Value, StringComparer.Ordinal));
|
||||
|
||||
public AdvisoryChunk ToChunk()
|
||||
=> AdvisoryChunk.Create(
|
||||
DocumentId,
|
||||
ChunkId,
|
||||
Section,
|
||||
ParagraphId,
|
||||
Text,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private sealed record VectorResultEnvelope(string Query, List<VectorMatchEnvelope> Matches)
|
||||
{
|
||||
public static VectorResultEnvelope FromResult(AdvisoryVectorResult result)
|
||||
=> new(
|
||||
result.Query,
|
||||
result.Matches.Select(VectorMatchEnvelope.FromMatch).ToList());
|
||||
|
||||
public AdvisoryVectorResult ToResult()
|
||||
=> new(Query, Matches.Select(static match => match.ToMatch()).ToImmutableArray());
|
||||
}
|
||||
|
||||
private sealed record VectorMatchEnvelope(
|
||||
string DocumentId,
|
||||
string ChunkId,
|
||||
string Text,
|
||||
double Score,
|
||||
Dictionary<string, string> Metadata)
|
||||
{
|
||||
public static VectorMatchEnvelope FromMatch(VectorRetrievalMatch match)
|
||||
=> new(
|
||||
match.DocumentId,
|
||||
match.ChunkId,
|
||||
match.Text,
|
||||
match.Score,
|
||||
match.Metadata.ToDictionary(static p => p.Key, static p => p.Value, StringComparer.Ordinal));
|
||||
|
||||
public VectorRetrievalMatch ToMatch()
|
||||
=> new(DocumentId, ChunkId, Text, Score, Metadata);
|
||||
}
|
||||
|
||||
private sealed record SbomContextEnvelope(
|
||||
string ArtifactId,
|
||||
string? Purl,
|
||||
List<SbomVersionTimelineEntryEnvelope> VersionTimeline,
|
||||
List<SbomDependencyPathEnvelope> DependencyPaths,
|
||||
Dictionary<string, string> EnvironmentFlags,
|
||||
SbomBlastRadiusEnvelope? BlastRadius,
|
||||
Dictionary<string, string> Metadata)
|
||||
{
|
||||
public static SbomContextEnvelope FromContext(SbomContextResult context)
|
||||
=> new(
|
||||
context.ArtifactId,
|
||||
context.Purl,
|
||||
context.VersionTimeline.Select(SbomVersionTimelineEntryEnvelope.FromEntry).ToList(),
|
||||
context.DependencyPaths.Select(SbomDependencyPathEnvelope.FromPath).ToList(),
|
||||
context.EnvironmentFlags.ToDictionary(static p => p.Key, static p => p.Value, StringComparer.Ordinal),
|
||||
context.BlastRadius is null ? null : SbomBlastRadiusEnvelope.FromBlastRadius(context.BlastRadius),
|
||||
context.Metadata.ToDictionary(static p => p.Key, static p => p.Value, StringComparer.Ordinal));
|
||||
|
||||
public SbomContextResult ToContext()
|
||||
=> SbomContextResult.Create(
|
||||
ArtifactId,
|
||||
Purl,
|
||||
VersionTimeline.Select(static entry => entry.ToEntry()),
|
||||
DependencyPaths.Select(static path => path.ToPath()),
|
||||
EnvironmentFlags,
|
||||
BlastRadius?.ToBlastRadius(),
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private sealed record SbomVersionTimelineEntryEnvelope(
|
||||
string Version,
|
||||
DateTimeOffset FirstObserved,
|
||||
DateTimeOffset? LastObserved,
|
||||
string Status,
|
||||
string Source)
|
||||
{
|
||||
public static SbomVersionTimelineEntryEnvelope FromEntry(SbomVersionTimelineEntry entry)
|
||||
=> new(entry.Version, entry.FirstObserved, entry.LastObserved, entry.Status, entry.Source);
|
||||
|
||||
public SbomVersionTimelineEntry ToEntry()
|
||||
=> new(Version, FirstObserved, LastObserved, Status, Source);
|
||||
}
|
||||
|
||||
private sealed record SbomDependencyPathEnvelope(
|
||||
List<SbomDependencyNodeEnvelope> Nodes,
|
||||
bool IsRuntime,
|
||||
string? Source,
|
||||
Dictionary<string, string> Metadata)
|
||||
{
|
||||
public static SbomDependencyPathEnvelope FromPath(SbomDependencyPath path)
|
||||
=> new(
|
||||
path.Nodes.Select(SbomDependencyNodeEnvelope.FromNode).ToList(),
|
||||
path.IsRuntime,
|
||||
path.Source,
|
||||
path.Metadata.ToDictionary(static p => p.Key, static p => p.Value, StringComparer.Ordinal));
|
||||
|
||||
public SbomDependencyPath ToPath()
|
||||
=> new(
|
||||
Nodes.Select(static node => node.ToNode()),
|
||||
IsRuntime,
|
||||
Source,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private sealed record SbomDependencyNodeEnvelope(string Identifier, string? Version)
|
||||
{
|
||||
public static SbomDependencyNodeEnvelope FromNode(SbomDependencyNode node)
|
||||
=> new(node.Identifier, node.Version);
|
||||
|
||||
public SbomDependencyNode ToNode()
|
||||
=> new(Identifier, Version);
|
||||
}
|
||||
|
||||
private sealed record SbomBlastRadiusEnvelope(
|
||||
int ImpactedAssets,
|
||||
int ImpactedWorkloads,
|
||||
int ImpactedNamespaces,
|
||||
double? ImpactedPercentage,
|
||||
Dictionary<string, string> Metadata)
|
||||
{
|
||||
public static SbomBlastRadiusEnvelope FromBlastRadius(SbomBlastRadiusSummary blastRadius)
|
||||
=> new(
|
||||
blastRadius.ImpactedAssets,
|
||||
blastRadius.ImpactedWorkloads,
|
||||
blastRadius.ImpactedNamespaces,
|
||||
blastRadius.ImpactedPercentage,
|
||||
blastRadius.Metadata.ToDictionary(static p => p.Key, static p => p.Value, StringComparer.Ordinal));
|
||||
|
||||
public SbomBlastRadiusSummary ToBlastRadius()
|
||||
=> new(
|
||||
ImpactedAssets,
|
||||
ImpactedWorkloads,
|
||||
ImpactedNamespaces,
|
||||
ImpactedPercentage,
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private sealed record DependencyAnalysisEnvelope(
|
||||
string ArtifactId,
|
||||
List<DependencyNodeSummaryEnvelope> Nodes,
|
||||
Dictionary<string, string> Metadata)
|
||||
{
|
||||
public static DependencyAnalysisEnvelope FromAnalysis(DependencyAnalysisResult analysis)
|
||||
=> new(
|
||||
analysis.ArtifactId,
|
||||
analysis.Nodes.Select(DependencyNodeSummaryEnvelope.FromNode).ToList(),
|
||||
analysis.Metadata.ToDictionary(static p => p.Key, static p => p.Value, StringComparer.Ordinal));
|
||||
|
||||
public DependencyAnalysisResult ToAnalysis()
|
||||
=> DependencyAnalysisResult.Create(
|
||||
ArtifactId,
|
||||
Nodes.Select(static node => node.ToNode()),
|
||||
Metadata);
|
||||
}
|
||||
|
||||
private sealed record DependencyNodeSummaryEnvelope(
|
||||
string Identifier,
|
||||
List<string> Versions,
|
||||
int RuntimeOccurrences,
|
||||
int DevelopmentOccurrences)
|
||||
{
|
||||
public static DependencyNodeSummaryEnvelope FromNode(DependencyNodeSummary node)
|
||||
=> new(
|
||||
node.Identifier,
|
||||
node.Versions.ToList(),
|
||||
node.RuntimeOccurrences,
|
||||
node.DevelopmentOccurrences);
|
||||
|
||||
public DependencyNodeSummary ToNode()
|
||||
=> new(Identifier, Versions, RuntimeOccurrences, DevelopmentOccurrences);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,16 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.DependencyInjection;
|
||||
using StellaOps.AdvisoryAI.Providers;
|
||||
using StellaOps.AdvisoryAI.Queue;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdvisoryAiCore(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
@@ -43,6 +45,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddAdvisoryPipelineInfrastructure();
|
||||
|
||||
services.Replace(ServiceDescriptor.Singleton<IAdvisoryTaskQueue, FileSystemAdvisoryTaskQueue>());
|
||||
services.Replace(ServiceDescriptor.Singleton<IAdvisoryPlanCache, FileSystemAdvisoryPlanCache>());
|
||||
services.Replace(ServiceDescriptor.Singleton<IAdvisoryOutputStore, FileSystemAdvisoryOutputStore>());
|
||||
services.TryAddSingleton<AdvisoryAiMetrics>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -1,27 +1,66 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StellaOps.AdvisoryAI.Guardrails;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
public sealed record AdvisoryOutputResponse(
|
||||
internal sealed record AdvisoryOutputResponse(
|
||||
string CacheKey,
|
||||
AdvisoryTaskType TaskType,
|
||||
string TaskType,
|
||||
string Profile,
|
||||
string OutputHash,
|
||||
bool GuardrailBlocked,
|
||||
IReadOnlyCollection<AdvisoryGuardrailViolationResponse> GuardrailViolations,
|
||||
IReadOnlyDictionary<string, string> GuardrailMetadata,
|
||||
string Prompt,
|
||||
IReadOnlyCollection<AdvisoryCitationResponse> Citations,
|
||||
IReadOnlyList<AdvisoryOutputCitation> Citations,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
AdvisoryOutputGuardrail Guardrail,
|
||||
AdvisoryOutputProvenance Provenance,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
bool PlanFromCache);
|
||||
|
||||
public sealed record AdvisoryGuardrailViolationResponse(string Code, string Message)
|
||||
bool PlanFromCache)
|
||||
{
|
||||
public static AdvisoryGuardrailViolationResponse From(AdvisoryGuardrailViolation violation)
|
||||
=> new(violation.Code, violation.Message);
|
||||
public static AdvisoryOutputResponse FromDomain(AdvisoryPipelineOutput output)
|
||||
=> new(
|
||||
output.CacheKey,
|
||||
output.TaskType.ToString(),
|
||||
output.Profile,
|
||||
output.Prompt,
|
||||
output.Citations
|
||||
.Select(citation => new AdvisoryOutputCitation(citation.Index, citation.DocumentId, citation.ChunkId))
|
||||
.ToList(),
|
||||
output.Metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal),
|
||||
AdvisoryOutputGuardrail.FromDomain(output.Guardrail),
|
||||
AdvisoryOutputProvenance.FromDomain(output.Provenance),
|
||||
output.GeneratedAtUtc,
|
||||
output.PlanFromCache);
|
||||
}
|
||||
|
||||
public sealed record AdvisoryCitationResponse(int Index, string DocumentId, string ChunkId);
|
||||
internal sealed record AdvisoryOutputCitation(int Index, string DocumentId, string ChunkId);
|
||||
|
||||
internal sealed record AdvisoryOutputGuardrail(
|
||||
bool Blocked,
|
||||
string SanitizedPrompt,
|
||||
IReadOnlyList<AdvisoryOutputGuardrailViolation> Violations,
|
||||
IReadOnlyDictionary<string, string> Metadata)
|
||||
{
|
||||
public static AdvisoryOutputGuardrail FromDomain(AdvisoryGuardrailResult result)
|
||||
=> new(
|
||||
result.Blocked,
|
||||
result.SanitizedPrompt,
|
||||
result.Violations
|
||||
.Select(violation => new AdvisoryOutputGuardrailViolation(violation.Code, violation.Message))
|
||||
.ToList(),
|
||||
result.Metadata.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
internal sealed record AdvisoryOutputGuardrailViolation(string Code, string Message);
|
||||
|
||||
internal sealed record AdvisoryOutputProvenance(
|
||||
string InputDigest,
|
||||
string OutputHash,
|
||||
IReadOnlyList<string> Signatures)
|
||||
{
|
||||
public static AdvisoryOutputProvenance FromDomain(AdvisoryDsseProvenance provenance)
|
||||
=> new(
|
||||
provenance.InputDigest,
|
||||
provenance.OutputHash,
|
||||
provenance.Signatures.ToArray());
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.RateLimiting;
|
||||
@@ -9,10 +10,13 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.Diagnostics;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
using StellaOps.AdvisoryAI.Metrics;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Queue;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -72,6 +76,9 @@ app.MapPost("/v1/advisory-ai/pipeline/{taskType}", HandleSinglePlan)
|
||||
app.MapPost("/v1/advisory-ai/pipeline:batch", HandleBatchPlans)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.MapGet("/v1/advisory-ai/outputs/{cacheKey}", HandleGetOutput)
|
||||
.RequireRateLimiting("advisory-ai");
|
||||
|
||||
app.Run();
|
||||
|
||||
static async Task<IResult> HandleSinglePlan(
|
||||
@@ -85,6 +92,10 @@ static async Task<IResult> HandleSinglePlan(
|
||||
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<AdvisoryTaskType>(taskType, ignoreCase: true, out var parsedType))
|
||||
{
|
||||
return Results.BadRequest(new { error = $"Unknown task type '{taskType}'." });
|
||||
@@ -103,6 +114,7 @@ static async Task<IResult> HandleSinglePlan(
|
||||
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);
|
||||
@@ -125,6 +137,9 @@ static async Task<IResult> HandleBatchPlans(
|
||||
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." });
|
||||
@@ -153,6 +168,12 @@ static async Task<IResult> HandleBatchPlans(
|
||||
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);
|
||||
@@ -167,6 +188,37 @@ static async Task<IResult> HandleBatchPlans(
|
||||
return Results.Ok(results);
|
||||
}
|
||||
|
||||
static async Task<IResult> HandleGetOutput(
|
||||
HttpContext httpContext,
|
||||
string cacheKey,
|
||||
string taskType,
|
||||
string? profile,
|
||||
IAdvisoryOutputStore outputStore,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(outputStore);
|
||||
if (!Enum.TryParse<AdvisoryTaskType>(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))
|
||||
|
||||
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
using StellaOps.AdvisoryAI.Worker.Services;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.Configuration
|
||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.Diagnostics;
|
||||
using StellaOps.AdvisoryAI.Metrics;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Queue;
|
||||
@@ -50,8 +52,14 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
|
||||
continue;
|
||||
}
|
||||
|
||||
using var activity = AdvisoryAiActivitySource.Instance.StartActivity("advisory_ai.process", ActivityKind.Consumer);
|
||||
activity?.SetTag("advisory.task_type", message.Request.TaskType.ToString());
|
||||
activity?.SetTag("advisory.advisory_key", message.Request.AdvisoryKey);
|
||||
|
||||
var processStart = _timeProvider.GetTimestamp();
|
||||
AdvisoryTaskPlan? plan = await _cache.TryGetAsync(message.PlanCacheKey, stoppingToken).ConfigureAwait(false);
|
||||
var fromCache = plan is not null && !message.Request.ForceRefresh;
|
||||
activity?.SetTag("advisory.plan_cache_hit", fromCache);
|
||||
|
||||
if (!fromCache)
|
||||
{
|
||||
@@ -68,8 +76,12 @@ internal sealed class AdvisoryTaskWorker : BackgroundService
|
||||
message.Request.AdvisoryKey,
|
||||
fromCache);
|
||||
|
||||
plan ??= throw new InvalidOperationException("Advisory task plan could not be generated.");
|
||||
await _executor.ExecuteAsync(plan, message, fromCache, stoppingToken).ConfigureAwait(false);
|
||||
_metrics.RecordPlanProcessed(message.Request.TaskType, fromCache);
|
||||
var totalElapsed = _timeProvider.GetElapsedTime(processStart);
|
||||
_metrics.RecordPipelineLatency(message.Request.TaskType, totalElapsed.TotalSeconds, fromCache);
|
||||
activity?.SetTag("advisory.pipeline_latency_seconds", totalElapsed.TotalSeconds);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Diagnostics;
|
||||
|
||||
internal static class AdvisoryAiActivitySource
|
||||
{
|
||||
public static readonly ActivitySource Instance = new("StellaOps.AdvisoryAI");
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using StellaOps.AdvisoryAI.Guardrails;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
@@ -53,27 +56,72 @@ internal sealed class AdvisoryPipelineExecutor : IAdvisoryPipelineExecutor
|
||||
|
||||
var prompt = await _promptAssembler.AssembleAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
var guardrailResult = await _guardrailPipeline.EvaluateAsync(prompt, cancellationToken).ConfigureAwait(false);
|
||||
var violationCount = guardrailResult.Violations.Length;
|
||||
|
||||
if (guardrailResult.Blocked)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Guardrail blocked advisory pipeline output for {TaskType} on advisory {AdvisoryKey}",
|
||||
"Guardrail blocked advisory pipeline output for {TaskType} on advisory {AdvisoryKey} with {ViolationCount} violations",
|
||||
plan.Request.TaskType,
|
||||
plan.Request.AdvisoryKey,
|
||||
violationCount);
|
||||
}
|
||||
else if (violationCount > 0)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Guardrail recorded {ViolationCount} advisory validation violations for {TaskType} on advisory {AdvisoryKey}",
|
||||
violationCount,
|
||||
plan.Request.TaskType,
|
||||
plan.Request.AdvisoryKey);
|
||||
}
|
||||
|
||||
var citationCoverage = CalculateCitationCoverage(plan, prompt);
|
||||
var activity = Activity.Current;
|
||||
activity?.SetTag("advisory.guardrail_blocked", guardrailResult.Blocked);
|
||||
activity?.SetTag("advisory.validation_failures", violationCount);
|
||||
activity?.SetTag("advisory.citation_coverage", citationCoverage);
|
||||
_metrics.RecordGuardrailOutcome(plan.Request.TaskType, guardrailResult.Blocked, violationCount);
|
||||
_metrics.RecordCitationCoverage(
|
||||
plan.Request.TaskType,
|
||||
citationCoverage,
|
||||
prompt.Citations.Length,
|
||||
plan.StructuredChunks.Length);
|
||||
|
||||
var generatedAt = _timeProvider.GetUtcNow();
|
||||
var output = AdvisoryPipelineOutput.Create(plan, prompt, guardrailResult, generatedAt, planFromCache);
|
||||
await _outputStore.SaveAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_metrics.RecordGuardrailResult(plan.Request.TaskType, guardrailResult.Blocked);
|
||||
_metrics.RecordOutputStored(plan.Request.TaskType, planFromCache, guardrailResult.Blocked);
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Stored advisory pipeline output {CacheKey} (task {TaskType}, cache:{CacheHit}, guardrail_blocked:{Blocked})",
|
||||
"Stored advisory pipeline output {CacheKey} (task {TaskType}, cache:{CacheHit}, guardrail_blocked:{Blocked}, validation_failures:{ValidationFailures}, citation_coverage:{CitationCoverage:0.00})",
|
||||
output.CacheKey,
|
||||
plan.Request.TaskType,
|
||||
planFromCache,
|
||||
guardrailResult.Blocked);
|
||||
guardrailResult.Blocked,
|
||||
violationCount,
|
||||
citationCoverage);
|
||||
}
|
||||
|
||||
private static double CalculateCitationCoverage(AdvisoryTaskPlan plan, AdvisoryPrompt prompt)
|
||||
{
|
||||
var structuredCount = plan.StructuredChunks.Length;
|
||||
if (structuredCount <= 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
if (prompt.Citations.IsDefaultOrEmpty)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
|
||||
var uniqueCitations = prompt.Citations
|
||||
.Select(citation => (citation.DocumentId, citation.ChunkId))
|
||||
.Distinct()
|
||||
.Count();
|
||||
|
||||
var coverage = (double)uniqueCitations / structuredCount;
|
||||
return Math.Clamp(coverage, 0d, 1d);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ public sealed class AdvisoryPipelineMetrics : IDisposable
|
||||
private readonly Counter<long> _plansProcessed;
|
||||
private readonly Counter<long> _outputsStored;
|
||||
private readonly Counter<long> _guardrailBlocks;
|
||||
private readonly Counter<long> _validationFailures;
|
||||
private readonly Histogram<double> _planBuildDuration;
|
||||
private readonly Histogram<double> _pipelineLatencySeconds;
|
||||
private readonly Histogram<double> _citationCoverageRatio;
|
||||
private bool _disposed;
|
||||
|
||||
public AdvisoryPipelineMetrics(IMeterFactory meterFactory)
|
||||
@@ -25,8 +28,11 @@ public sealed class AdvisoryPipelineMetrics : IDisposable
|
||||
_plansQueued = _meter.CreateCounter<long>("advisory_plans_queued");
|
||||
_plansProcessed = _meter.CreateCounter<long>("advisory_plans_processed");
|
||||
_outputsStored = _meter.CreateCounter<long>("advisory_outputs_stored");
|
||||
_guardrailBlocks = _meter.CreateCounter<long>("advisory_guardrail_blocks");
|
||||
_guardrailBlocks = _meter.CreateCounter<long>("advisory_ai_guardrail_blocks_total");
|
||||
_validationFailures = _meter.CreateCounter<long>("advisory_ai_validation_failures_total");
|
||||
_planBuildDuration = _meter.CreateHistogram<double>("advisory_plan_build_duration_seconds");
|
||||
_pipelineLatencySeconds = _meter.CreateHistogram<double>("advisory_ai_latency_seconds");
|
||||
_citationCoverageRatio = _meter.CreateHistogram<double>("advisory_ai_citation_coverage_ratio");
|
||||
}
|
||||
|
||||
public void RecordPlanCreated(double buildSeconds, AdvisoryTaskType taskType)
|
||||
@@ -55,12 +61,40 @@ public sealed class AdvisoryPipelineMetrics : IDisposable
|
||||
KeyValuePair.Create<string, object?>("guardrail_blocked", guardrailBlocked));
|
||||
}
|
||||
|
||||
public void RecordGuardrailResult(AdvisoryTaskType taskType, bool blocked)
|
||||
public void RecordGuardrailOutcome(AdvisoryTaskType taskType, bool blocked, int validationFailures)
|
||||
{
|
||||
if (blocked)
|
||||
{
|
||||
_guardrailBlocks.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType.ToString()));
|
||||
}
|
||||
|
||||
if (validationFailures > 0)
|
||||
{
|
||||
_validationFailures.Add(
|
||||
validationFailures,
|
||||
KeyValuePair.Create<string, object?>("task_type", taskType.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordPipelineLatency(AdvisoryTaskType taskType, double seconds, bool planFromCache)
|
||||
{
|
||||
_pipelineLatencySeconds.Record(
|
||||
seconds,
|
||||
KeyValuePair.Create<string, object?>("task_type", taskType.ToString()),
|
||||
KeyValuePair.Create<string, object?>("plan_cache_hit", planFromCache));
|
||||
}
|
||||
|
||||
public void RecordCitationCoverage(
|
||||
AdvisoryTaskType taskType,
|
||||
double coverageRatio,
|
||||
int citationCount,
|
||||
int structuredChunkCount)
|
||||
{
|
||||
_citationCoverageRatio.Record(
|
||||
coverageRatio,
|
||||
KeyValuePair.Create<string, object?>("task_type", taskType.ToString()),
|
||||
KeyValuePair.Create<string, object?>("citations", citationCount),
|
||||
KeyValuePair.Create<string, object?>("structured_chunks", structuredChunkCount));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
| AIAI-31-003 | DONE (2025-11-04) | Advisory AI Guild | AIAI-31-001..002 | Implement deterministic toolset (version comparators, range checks, dependency analysis, policy lookup) exposed via orchestrator. | Tools validated with property tests; outputs cached; docs updated. |
|
||||
| AIAI-31-004 | DONE (2025-11-04) | Advisory AI Guild | AIAI-31-001..003, AUTH-VULN-29-001 | Build orchestration pipeline for Summary/Conflict/Remediation tasks (prompt templates, tool calls, token budgets, caching). | Pipeline executes tasks deterministically; caches keyed by tuple+policy; integration tests cover tasks. |
|
||||
| AIAI-31-004A | DONE (2025-11-04) | Advisory AI Guild, Platform Guild | AIAI-31-004, AIAI-31-002 | Wire `AdvisoryPipelineOrchestrator` into WebService/Worker, expose API/queue contracts, emit metrics, and stand up cache stub. | API returns plan metadata; worker executes queue message; metrics recorded; doc updated. |
|
||||
| AIAI-31-004B | TODO | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. |
|
||||
| AIAI-31-004C | TODO | Advisory AI Guild, CLI Guild, Docs Guild | AIAI-31-004B, CLI-AIAI-31-003 | Deliver CLI `stella advise run <task>` command, renderers, documentation updates, and CLI golden tests. | CLI command produces deterministic output; docs published; smoke run recorded. |
|
||||
| AIAI-31-004B | DONE (2025-11-06) | Advisory AI Guild, Security Guild | AIAI-31-004A, DOCS-AIAI-31-003, AUTH-AIAI-31-004 | Implement prompt assembler, guardrail plumbing, cache persistence, DSSE provenance; add golden outputs. | Deterministic outputs cached; guardrails enforced; tests cover prompt assembly + caching. |
|
||||
| AIAI-31-004C | DONE (2025-11-06) | Advisory AI Guild, CLI Guild, Docs Guild | AIAI-31-004B, CLI-AIAI-31-003 | Deliver CLI `stella advise run <task>` command, renderers, documentation updates, and CLI golden tests. | CLI command produces deterministic output; docs published; smoke run recorded. |
|
||||
| AIAI-31-005 | DONE (2025-11-04) | Advisory AI Guild, Security Guild | AIAI-31-004 | Implement guardrails (redaction, injection defense, output validation, citation enforcement) and fail-safe handling. | Guardrails block adversarial inputs; output validator enforces schemas; security tests pass. |
|
||||
| AIAI-31-006 | DONE (2025-11-04) | Advisory AI Guild | AIAI-31-004..005 | Expose REST API endpoints (`/advisory/ai/*`) with RBAC, rate limits, OpenAPI schemas, and batching support. | Endpoints deployed with schema validation; rate limits enforced; integration tests cover error codes. |
|
||||
| AIAI-31-007 | TODO | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
|
||||
| AIAI-31-007 | DONE (2025-11-06) | Advisory AI Guild, Observability Guild | AIAI-31-004..006 | Instrument metrics (`advisory_ai_latency`, `guardrail_blocks`, `validation_failures`, `citation_coverage`), logs, and traces; publish dashboards/alerts. | Telemetry live; dashboards approved; alerts configured. |
|
||||
| AIAI-31-008 | TODO | Advisory AI Guild, DevOps Guild | AIAI-31-006..007 | Package inference on-prem container, remote inference toggle, Helm/Compose manifests, scaling guidance, offline kit instructions. | Deployment docs merged; smoke deploy executed; offline kit updated; feature flags documented. |
|
||||
| AIAI-31-010 | DONE (2025-11-02) | Advisory AI Guild | CONCELIER-VULN-29-001, EXCITITOR-VULN-29-001 | Implement Concelier advisory raw document provider mapping CSAF/OSV payloads into structured chunks for retrieval. | Provider resolves content format, preserves metadata, and passes unit tests covering CSAF/OSV cases. |
|
||||
| AIAI-31-011 | DONE (2025-11-02) | Advisory AI Guild | EXCITITOR-LNM-21-201, EXCITITOR-CORE-AOC-19-002 | Implement Excititor VEX document provider to surface structured VEX statements for vector retrieval. | Provider returns conflict-aware VEX chunks with deterministic metadata and tests for representative statements. |
|
||||
@@ -31,3 +31,5 @@
|
||||
> 2025-11-04: AIAI-31-005 DONE – guardrail pipeline redacts secrets, enforces citation/injection policies, emits block counters, and tests (`AdvisoryGuardrailPipelineTests`) cover redaction + citation validation.
|
||||
|
||||
> 2025-11-04: AIAI-31-006 DONE – REST endpoints enforce header scopes, apply token bucket rate limiting, sanitize prompts via guardrails, and queue execution with cached metadata. Tests executed via `dotnet test src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.csproj --no-restore`.
|
||||
> 2025-11-06: AIAI-31-004B/C – Resuming prompt/cache hardening and CLI integration; first focus on backend client wiring and deterministic CLI outputs before full suite.
|
||||
> 2025-11-06: AIAI-31-004B/C DONE – Advisory AI Mongo integration validated, backend client + CLI `advise run` wired, deterministic console renderer with provenance/guardrail display added, docs refreshed, and targeted CLI tests executed.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics.Metrics;
|
||||
@@ -65,6 +66,58 @@ public sealed class AdvisoryPipelineExecutorTests : IDisposable
|
||||
saved.Prompt.Should().Be("{\"prompt\":\"value\"}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RecordsTelemetryMeasurements()
|
||||
{
|
||||
using var listener = new MeterListener();
|
||||
var doubleMeasurements = new List<(string Name, double Value, IEnumerable<KeyValuePair<string, object?>> Tags)>();
|
||||
var longMeasurements = new List<(string Name, long Value, IEnumerable<KeyValuePair<string, object?>> Tags)>();
|
||||
|
||||
listener.InstrumentPublished = (instrument, l) =>
|
||||
{
|
||||
if (instrument.Meter.Name == AdvisoryPipelineMetrics.MeterName)
|
||||
{
|
||||
l.EnableMeasurementEvents(instrument);
|
||||
}
|
||||
};
|
||||
|
||||
listener.SetMeasurementEventCallback<double>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
doubleMeasurements.Add((instrument.Name, measurement, tags));
|
||||
});
|
||||
|
||||
listener.SetMeasurementEventCallback<long>((instrument, measurement, tags, state) =>
|
||||
{
|
||||
longMeasurements.Add((instrument.Name, measurement, tags));
|
||||
});
|
||||
|
||||
listener.Start();
|
||||
|
||||
var plan = BuildMinimalPlan(cacheKey: "CACHE-3");
|
||||
var assembler = new StubPromptAssembler();
|
||||
var guardrail = new StubGuardrailPipeline(blocked: true);
|
||||
var store = new InMemoryAdvisoryOutputStore();
|
||||
using var metrics = new AdvisoryPipelineMetrics(_meterFactory);
|
||||
var executor = new AdvisoryPipelineExecutor(assembler, guardrail, store, metrics, TimeProvider.System);
|
||||
|
||||
var message = new AdvisoryTaskQueueMessage(plan.CacheKey, plan.Request);
|
||||
await executor.ExecuteAsync(plan, message, planFromCache: false, CancellationToken.None);
|
||||
|
||||
listener.Dispose();
|
||||
|
||||
longMeasurements.Should().Contain(measurement =>
|
||||
measurement.Name == "advisory_ai_guardrail_blocks_total" &&
|
||||
measurement.Value == 1);
|
||||
|
||||
longMeasurements.Should().Contain(measurement =>
|
||||
measurement.Name == "advisory_ai_validation_failures_total" &&
|
||||
measurement.Value == 1);
|
||||
|
||||
doubleMeasurements.Should().Contain(measurement =>
|
||||
measurement.Name == "advisory_ai_citation_coverage_ratio" &&
|
||||
Math.Abs(measurement.Value - 1d) < 0.0001);
|
||||
}
|
||||
|
||||
private static AdvisoryTaskPlan BuildMinimalPlan(string cacheKey)
|
||||
{
|
||||
var request = new AdvisoryTaskRequest(
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Caching;
|
||||
using StellaOps.AdvisoryAI.Context;
|
||||
using StellaOps.AdvisoryAI.Documents;
|
||||
using StellaOps.AdvisoryAI.Guardrails;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
using StellaOps.AdvisoryAI.Outputs;
|
||||
using StellaOps.AdvisoryAI.Orchestration;
|
||||
using StellaOps.AdvisoryAI.Tools;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests;
|
||||
|
||||
public sealed class FileSystemAdvisoryPersistenceTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tempDir = new();
|
||||
|
||||
[Fact]
|
||||
public async Task PlanCache_PersistsPlanOnDisk()
|
||||
{
|
||||
var serviceOptions = Options.Create(new AdvisoryAiServiceOptions
|
||||
{
|
||||
Storage = new AdvisoryAiStorageOptions
|
||||
{
|
||||
PlanCacheDirectory = Path.Combine(_tempDir.Path, "plans"),
|
||||
OutputDirectory = Path.Combine(_tempDir.Path, "outputs")
|
||||
}
|
||||
});
|
||||
var cacheOptions = Options.Create(new AdvisoryPlanCacheOptions
|
||||
{
|
||||
DefaultTimeToLive = TimeSpan.FromMinutes(5),
|
||||
CleanupInterval = TimeSpan.FromMinutes(5)
|
||||
});
|
||||
var cache = new FileSystemAdvisoryPlanCache(serviceOptions, cacheOptions, NullLogger<FileSystemAdvisoryPlanCache>.Instance);
|
||||
|
||||
var plan = CreatePlan("cache-123");
|
||||
await cache.SetAsync(plan.CacheKey, plan, CancellationToken.None);
|
||||
|
||||
var reloaded = await cache.TryGetAsync(plan.CacheKey, CancellationToken.None);
|
||||
reloaded.Should().NotBeNull();
|
||||
reloaded!.CacheKey.Should().Be(plan.CacheKey);
|
||||
reloaded.Request.AdvisoryKey.Should().Be(plan.Request.AdvisoryKey);
|
||||
reloaded.StructuredChunks.Length.Should().Be(plan.StructuredChunks.Length);
|
||||
reloaded.Metadata.Should().ContainKey("advisory_key").WhoseValue.Should().Be("adv-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OutputStore_PersistsOutputOnDisk()
|
||||
{
|
||||
var serviceOptions = Options.Create(new AdvisoryAiServiceOptions
|
||||
{
|
||||
Storage = new AdvisoryAiStorageOptions
|
||||
{
|
||||
PlanCacheDirectory = Path.Combine(_tempDir.Path, "plans"),
|
||||
OutputDirectory = Path.Combine(_tempDir.Path, "outputs")
|
||||
}
|
||||
});
|
||||
var store = new FileSystemAdvisoryOutputStore(serviceOptions, NullLogger<FileSystemAdvisoryOutputStore>.Instance);
|
||||
var plan = CreatePlan("cache-abc");
|
||||
var prompt = "{\"prompt\":\"value\"}";
|
||||
var guardrail = AdvisoryGuardrailResult.Allowed(prompt);
|
||||
var output = new AdvisoryPipelineOutput(
|
||||
plan.CacheKey,
|
||||
plan.Request.TaskType,
|
||||
plan.Request.Profile,
|
||||
prompt,
|
||||
ImmutableArray.Create(new AdvisoryPromptCitation(1, "doc-1", "chunk-1")),
|
||||
ImmutableDictionary<string, string>.Empty.Add("advisory_key", plan.Request.AdvisoryKey),
|
||||
guardrail,
|
||||
new AdvisoryDsseProvenance(plan.CacheKey, "hash", ImmutableArray<string>.Empty),
|
||||
DateTimeOffset.UtcNow,
|
||||
planFromCache: false);
|
||||
|
||||
await store.SaveAsync(output, CancellationToken.None);
|
||||
var reloaded = await store.TryGetAsync(plan.CacheKey, plan.Request.TaskType, plan.Request.Profile, CancellationToken.None);
|
||||
|
||||
reloaded.Should().NotBeNull();
|
||||
reloaded!.Prompt.Should().Be(prompt);
|
||||
reloaded.Metadata.Should().ContainKey("advisory_key").WhoseValue.Should().Be(plan.Request.AdvisoryKey);
|
||||
}
|
||||
|
||||
private static AdvisoryTaskPlan CreatePlan(string cacheKey)
|
||||
{
|
||||
var request = new AdvisoryTaskRequest(
|
||||
AdvisoryTaskType.Summary,
|
||||
advisoryKey: "adv-key",
|
||||
artifactId: "artifact-1",
|
||||
artifactPurl: "pkg:docker/sample@1.0.0",
|
||||
policyVersion: "policy-1",
|
||||
profile: "default",
|
||||
preferredSections: new[] { "Summary" },
|
||||
forceRefresh: false);
|
||||
|
||||
var chunk = AdvisoryChunk.Create("doc-1", "doc-1:chunk-1", "Summary", "para-1", "Summary text", new Dictionary<string, string> { ["section"] = "Summary" });
|
||||
var structured = ImmutableArray.Create(chunk);
|
||||
var vectorMatch = new VectorRetrievalMatch("doc-1", "doc-1:chunk-1", "Summary text", 0.95, new Dictionary<string, string>());
|
||||
var vectorResult = new AdvisoryVectorResult("summary-query", ImmutableArray.Create(vectorMatch));
|
||||
var sbom = SbomContextResult.Create(
|
||||
"artifact-1",
|
||||
"pkg:docker/sample@1.0.0",
|
||||
new[]
|
||||
{
|
||||
new SbomVersionTimelineEntry("1.0.0", DateTimeOffset.UtcNow.AddDays(-10), null, "affected", "scanner")
|
||||
},
|
||||
new[]
|
||||
{
|
||||
new SbomDependencyPath(
|
||||
new[]
|
||||
{
|
||||
new SbomDependencyNode("root", "1.0.0"),
|
||||
new SbomDependencyNode("runtime-lib", "2.1.0")
|
||||
},
|
||||
isRuntime: true)
|
||||
});
|
||||
var dependency = DependencyAnalysisResult.Create(
|
||||
"artifact-1",
|
||||
new[]
|
||||
{
|
||||
new DependencyNodeSummary("runtime-lib", new[] { "2.1.0" }, 1, 0)
|
||||
},
|
||||
new Dictionary<string, string> { ["artifact_id"] = "artifact-1" });
|
||||
|
||||
var metadata = ImmutableDictionary<string, string>.Empty.Add("advisory_key", "adv-key");
|
||||
var budget = new AdvisoryTaskBudget { PromptTokens = 1024, CompletionTokens = 256 };
|
||||
|
||||
return new AdvisoryTaskPlan(
|
||||
request,
|
||||
cacheKey,
|
||||
promptTemplate: "prompts/advisory/summary.liquid",
|
||||
structured,
|
||||
ImmutableArray.Create(vectorResult),
|
||||
sbom,
|
||||
dependency,
|
||||
budget,
|
||||
metadata);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tempDir.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TempDirectory : IDisposable
|
||||
{
|
||||
public TempDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"advisory-ai-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
{
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore cleanup failures in tests
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user