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

- 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:
master
2025-11-07 10:01:35 +02:00
parent e5ffcd6535
commit a1ce3f74fa
122 changed files with 8730 additions and 914 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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());
}

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)
{

View File

@@ -0,0 +1,8 @@
using System.Diagnostics;
namespace StellaOps.AdvisoryAI.Diagnostics;
internal static class AdvisoryAiActivitySource
{
public static readonly ActivitySource Instance = new("StellaOps.AdvisoryAI");
}

View File

@@ -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);
}
}

View File

@@ -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()

View File

@@ -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.

View File

@@ -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(

View File

@@ -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
}
}
}
}