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

@@ -18,7 +18,8 @@ using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
using StellaOps.Cli.Services.Models.Transport;
namespace StellaOps.Cli.Services;
@@ -30,10 +31,12 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
private static readonly IReadOnlyDictionary<string, object?> EmptyMetadata =
new ReadOnlyDictionary<string, object?>(new Dictionary<string, object?>(0, StringComparer.OrdinalIgnoreCase));
private const string OperatorReasonParameterName = "operator_reason";
private const string OperatorTicketParameterName = "operator_ticket";
private const string BackfillReasonParameterName = "backfill_reason";
private const string BackfillTicketParameterName = "backfill_ticket";
private const string OperatorReasonParameterName = "operator_reason";
private const string OperatorTicketParameterName = "operator_ticket";
private const string BackfillReasonParameterName = "backfill_reason";
private const string BackfillTicketParameterName = "backfill_ticket";
private const string AdvisoryScopesHeader = "X-StellaOps-Scopes";
private const string AdvisoryRunScope = "advisory:run";
private readonly HttpClient _httpClient;
private readonly StellaOpsCliOptions _options;
@@ -885,13 +888,122 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
throw new InvalidOperationException("EntryTrace response payload was empty.");
}
return result;
}
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
return result;
}
public async Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(
AdvisoryAiTaskType taskType,
AdvisoryPipelinePlanRequestModel request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var taskSegment = taskType.ToString().ToLowerInvariant();
var relative = $"v1/advisory-ai/pipeline/{taskSegment}";
var payload = new AdvisoryPipelinePlanRequestModel
{
TaskType = taskType,
AdvisoryKey = string.IsNullOrWhiteSpace(request.AdvisoryKey) ? string.Empty : request.AdvisoryKey.Trim(),
ArtifactId = string.IsNullOrWhiteSpace(request.ArtifactId) ? null : request.ArtifactId!.Trim(),
ArtifactPurl = string.IsNullOrWhiteSpace(request.ArtifactPurl) ? null : request.ArtifactPurl!.Trim(),
PolicyVersion = string.IsNullOrWhiteSpace(request.PolicyVersion) ? null : request.PolicyVersion!.Trim(),
Profile = string.IsNullOrWhiteSpace(request.Profile) ? "default" : request.Profile!.Trim(),
PreferredSections = request.PreferredSections is null
? null
: request.PreferredSections
.Where(static section => !string.IsNullOrWhiteSpace(section))
.Select(static section => section.Trim())
.ToArray(),
ForceRefresh = request.ForceRefresh
};
using var httpRequest = CreateRequest(HttpMethod.Post, relative);
ApplyAdvisoryAiEndpoint(httpRequest, taskType);
await AuthorizeRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
httpRequest.Content = JsonContent.Create(payload, options: SerializerOptions);
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
var plan = await response.Content.ReadFromJsonAsync<AdvisoryPipelinePlanResponseModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
if (plan is null)
{
throw new InvalidOperationException("Advisory AI plan response was empty.");
}
return plan;
}
catch (JsonException ex)
{
var raw = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse advisory plan response. {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
}
public async Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(
string cacheKey,
AdvisoryAiTaskType taskType,
string profile,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(cacheKey))
{
throw new ArgumentException("Cache key is required.", nameof(cacheKey));
}
var encodedKey = Uri.EscapeDataString(cacheKey);
var taskSegment = Uri.EscapeDataString(taskType.ToString().ToLowerInvariant());
var resolvedProfile = string.IsNullOrWhiteSpace(profile) ? "default" : profile.Trim();
var relative = $"v1/advisory-ai/outputs/{encodedKey}?taskType={taskSegment}&profile={Uri.EscapeDataString(resolvedProfile)}";
using var request = CreateRequest(HttpMethod.Get, relative);
ApplyAdvisoryAiEndpoint(request, taskType);
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var failure = await CreateFailureMessageAsync(response, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException(failure);
}
try
{
return await response.Content.ReadFromJsonAsync<AdvisoryPipelineOutputModel>(SerializerOptions, cancellationToken).ConfigureAwait(false);
}
catch (JsonException ex)
{
var raw = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Failed to parse advisory output response. {ex.Message}", ex)
{
Data = { ["payload"] = raw }
};
}
}
public async Task<IReadOnlyList<ExcititorProviderSummary>> GetExcititorProvidersAsync(bool includeDisabled, CancellationToken cancellationToken)
{
EnsureBackendConfigured();
var query = includeDisabled ? "?includeDisabled=true" : string.Empty;
using var request = CreateRequest(HttpMethod.Get, $"excititor/providers{query}");
await AuthorizeRequestAsync(request, cancellationToken).ConfigureAwait(false);
@@ -1778,7 +1890,44 @@ internal sealed class BackendOperationsClient : IBackendOperationsClient
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
private void ApplyAdvisoryAiEndpoint(HttpRequestMessage request, AdvisoryAiTaskType taskType)
{
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}
var requestUri = request.RequestUri ?? throw new InvalidOperationException("Request URI was not initialized.");
if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl) &&
Uri.TryCreate(_options.AdvisoryAiUrl, UriKind.Absolute, out var advisoryBase))
{
if (!requestUri.IsAbsoluteUri)
{
request.RequestUri = new Uri(advisoryBase, requestUri.ToString());
}
}
else if (!string.IsNullOrWhiteSpace(_options.AdvisoryAiUrl))
{
throw new InvalidOperationException($"Advisory AI URL '{_options.AdvisoryAiUrl}' is not a valid absolute URI.");
}
else
{
EnsureBackendConfigured();
}
var taskScope = $"advisory:{taskType.ToString().ToLowerInvariant()}";
var combined = $"{AdvisoryRunScope} {taskScope}";
if (request.Headers.Contains(AdvisoryScopesHeader))
{
request.Headers.Remove(AdvisoryScopesHeader);
}
request.Headers.TryAddWithoutValidation(AdvisoryScopesHeader, combined);
}
private HttpRequestMessage CreateRequest(HttpMethod method, string relativeUri)
{
if (!Uri.TryCreate(relativeUri, UriKind.RelativeOrAbsolute, out var requestUri))
{

View File

@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Models;
using StellaOps.Cli.Services.Models.AdvisoryAi;
namespace StellaOps.Cli.Services;
@@ -46,4 +47,8 @@ internal interface IBackendOperationsClient
Task<PolicyFindingExplainResult> GetPolicyFindingExplainAsync(string policyId, string findingId, string? mode, CancellationToken cancellationToken);
Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken);
Task<AdvisoryPipelinePlanResponseModel> CreateAdvisoryPipelinePlanAsync(AdvisoryAiTaskType taskType, AdvisoryPipelinePlanRequestModel request, CancellationToken cancellationToken);
Task<AdvisoryPipelineOutputModel?> TryGetAdvisoryPipelineOutputAsync(string cacheKey, AdvisoryAiTaskType taskType, string profile, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Cli.Services.Models.AdvisoryAi;
internal enum AdvisoryAiTaskType
{
Summary,
Conflict,
Remediation
}
internal sealed class AdvisoryPipelinePlanRequestModel
{
public AdvisoryAiTaskType TaskType { get; init; }
public string AdvisoryKey { get; init; } = string.Empty;
public string? ArtifactId { get; init; }
public string? ArtifactPurl { get; init; }
public string? PolicyVersion { get; init; }
public string Profile { get; init; } = "default";
public IReadOnlyList<string>? PreferredSections { get; init; }
public bool ForceRefresh { get; init; }
}
internal sealed class AdvisoryPipelinePlanResponseModel
{
public string CacheKey { get; init; } = string.Empty;
public string TaskType { get; init; } = string.Empty;
public string PromptTemplate { get; init; } = string.Empty;
public AdvisoryTaskBudgetModel Budget { get; init; } = new();
public IReadOnlyList<PipelineChunkSummaryModel> Chunks { get; init; } = Array.Empty<PipelineChunkSummaryModel>();
public IReadOnlyList<PipelineVectorSummaryModel> Vectors { get; init; } = Array.Empty<PipelineVectorSummaryModel>();
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
}
internal sealed class AdvisoryTaskBudgetModel
{
public int PromptTokens { get; init; }
public int CompletionTokens { get; init; }
}
internal sealed class PipelineChunkSummaryModel
{
public string DocumentId { get; init; } = string.Empty;
public string ChunkId { get; init; } = string.Empty;
public string Section { get; init; } = string.Empty;
public string? DisplaySection { get; init; }
}
internal sealed class PipelineVectorSummaryModel
{
public string Query { get; init; } = string.Empty;
public IReadOnlyList<PipelineVectorMatchSummaryModel> Matches { get; init; } = Array.Empty<PipelineVectorMatchSummaryModel>();
}
internal sealed class PipelineVectorMatchSummaryModel
{
public string ChunkId { get; init; } = string.Empty;
public double Score { get; init; }
}
internal sealed class AdvisoryPipelineOutputModel
{
public string CacheKey { get; init; } = string.Empty;
public string TaskType { get; init; } = string.Empty;
public string Profile { get; init; } = string.Empty;
public string Prompt { get; init; } = string.Empty;
public IReadOnlyList<AdvisoryOutputCitationModel> Citations { get; init; } = Array.Empty<AdvisoryOutputCitationModel>();
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
public AdvisoryOutputGuardrailModel Guardrail { get; init; } = new();
public AdvisoryOutputProvenanceModel Provenance { get; init; } = new();
public DateTimeOffset GeneratedAtUtc { get; init; }
public bool PlanFromCache { get; init; }
}
internal sealed class AdvisoryOutputCitationModel
{
public int Index { get; init; }
public string DocumentId { get; init; } = string.Empty;
public string ChunkId { get; init; } = string.Empty;
}
internal sealed class AdvisoryOutputGuardrailModel
{
public bool Blocked { get; init; }
public string SanitizedPrompt { get; init; } = string.Empty;
public IReadOnlyList<AdvisoryOutputGuardrailViolationModel> Violations { get; init; } = Array.Empty<AdvisoryOutputGuardrailViolationModel>();
public Dictionary<string, string> Metadata { get; init; } = new(StringComparer.Ordinal);
}
internal sealed class AdvisoryOutputGuardrailViolationModel
{
public string Code { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
}
internal sealed class AdvisoryOutputProvenanceModel
{
public string InputDigest { get; init; } = string.Empty;
public string OutputHash { get; init; } = string.Empty;
public IReadOnlyList<string> Signatures { get; init; } = Array.Empty<string>();
}