feat(advisoryai): postgres runtime state cutover
Sprint SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover. - Migrations 009 ai_runtime_state + 010 advisory_ai_runtime_state_extensions. - PostgresConversationService + PostgresAdvisoryChatSettingsStore. - PostgresExplanationStore, PostgresPolicyIntentStore, PostgresRunStore, PostgresAiAttestationStore, PostgresAiConsentStore. - Core + WebService runtime persistence extensions and program wiring. - Chat integration + durable runtime tests. Sub-sprint _022 (testing-only runtime fallback) follows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ AdvisoryAI provides AI-assisted security advisory analysis, remediation planning
|
||||
- `opsmemory` (via Router) — decision recording, playbook suggestions, similarity-based recall
|
||||
|
||||
## Storage
|
||||
PostgreSQL (OpsMemory: `ConnectionStrings:Default`; AdvisoryAI knowledge search: `KnowledgeSearch:ConnectionString`); file-system queue/plans/outputs for AdvisoryAI
|
||||
PostgreSQL (OpsMemory: `ConnectionStrings:Default`; AdvisoryAI knowledge search: `KnowledgeSearch:ConnectionString`; AdvisoryAI consent + attestation runtime state: `AdvisoryAI:Storage:ConnectionString` -> `ConnectionStrings:Default` -> `Database:ConnectionString`, fail-fast outside Development/Testing); file-system queue/plans/outputs for AdvisoryAI
|
||||
|
||||
## Background Workers
|
||||
- `KnowledgeSearchStartupRebuildService` — rebuilds knowledge search index on startup
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// <copyright file="AdvisoryAiCoreRuntimePersistenceExtensions.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using StellaOps.AdvisoryAI.Chat.Settings;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.PolicyStudio;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using StellaOps.AdvisoryAI.Storage;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Infrastructure.Postgres.Options;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Hosting;
|
||||
|
||||
public static class AdvisoryAiCoreRuntimePersistenceExtensions
|
||||
{
|
||||
private const string StorageSectionName = "AdvisoryAI:Storage";
|
||||
|
||||
public static IServiceCollection AddAdvisoryAiCoreRuntimePersistence(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
|
||||
var connectionString = ResolveConnectionString(configuration);
|
||||
var allowInMemory = environment.IsEnvironment("Testing");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
if (!allowInMemory)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AdvisoryAI requires PostgreSQL-backed runtime state outside Testing. Configure AdvisoryAI:Storage:ConnectionString, ConnectionStrings:Default, or Database:ConnectionString.");
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
services.AddPostgresOptions(configuration, StorageSectionName);
|
||||
services.PostConfigure<PostgresOptions>(options =>
|
||||
{
|
||||
options.ConnectionString = string.IsNullOrWhiteSpace(options.ConnectionString)
|
||||
? connectionString.Trim()
|
||||
: options.ConnectionString.Trim();
|
||||
options.SchemaName = string.IsNullOrWhiteSpace(options.SchemaName)
|
||||
? AdvisoryAiDataSource.DefaultSchemaName
|
||||
: options.SchemaName.Trim();
|
||||
});
|
||||
|
||||
services.AddStartupMigrations<PostgresOptions>(
|
||||
AdvisoryAiDataSource.DefaultSchemaName,
|
||||
"AdvisoryAI",
|
||||
typeof(AdvisoryAiDataSource).Assembly,
|
||||
static options => options.ConnectionString);
|
||||
|
||||
services.TryAddSingleton<AdvisoryAiDataSource>();
|
||||
|
||||
services.RemoveAll<IConversationStore>();
|
||||
services.AddSingleton<IConversationStore, ConversationStore>();
|
||||
|
||||
services.RemoveAll<IConversationService>();
|
||||
services.AddSingleton<IConversationService, PostgresConversationService>();
|
||||
|
||||
services.RemoveAll<IExplanationStore>();
|
||||
services.AddSingleton<PostgresExplanationStore>();
|
||||
services.AddSingleton<IExplanationStore>(provider => provider.GetRequiredService<PostgresExplanationStore>());
|
||||
services.AddSingleton<IExplanationRequestStore>(provider => provider.GetRequiredService<PostgresExplanationStore>());
|
||||
|
||||
services.RemoveAll<IPolicyIntentStore>();
|
||||
services.AddSingleton<IPolicyIntentStore, PostgresPolicyIntentStore>();
|
||||
|
||||
services.RemoveAll<IRunStore>();
|
||||
services.AddSingleton<IRunStore, PostgresRunStore>();
|
||||
|
||||
services.RemoveAll<IAdvisoryChatSettingsStore>();
|
||||
services.AddSingleton<IAdvisoryChatSettingsStore, PostgresAdvisoryChatSettingsStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static string? ResolveConnectionString(IConfiguration configuration)
|
||||
{
|
||||
var explicitConnectionString = configuration[$"{StorageSectionName}:ConnectionString"];
|
||||
if (!string.IsNullOrWhiteSpace(explicitConnectionString))
|
||||
{
|
||||
return explicitConnectionString.Trim();
|
||||
}
|
||||
|
||||
var defaultConnectionString = configuration.GetConnectionString("Default");
|
||||
if (!string.IsNullOrWhiteSpace(defaultConnectionString))
|
||||
{
|
||||
return defaultConnectionString.Trim();
|
||||
}
|
||||
|
||||
var legacyConnectionString = configuration["Database:ConnectionString"];
|
||||
return string.IsNullOrWhiteSpace(legacyConnectionString)
|
||||
? null
|
||||
: legacyConnectionString.Trim();
|
||||
}
|
||||
}
|
||||
@@ -86,12 +86,11 @@ builder.Services.AddSingleton<StellaOps.AdvisoryAI.WebService.Services.IRateLimi
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// VEX-AI-016: Consent and justification services
|
||||
builder.Services.AddSingleton<IAiConsentStore, InMemoryAiConsentStore>();
|
||||
builder.Services.AddSingleton<IAiJustificationGenerator, DefaultAiJustificationGenerator>();
|
||||
|
||||
// AI Attestations (Sprint: SPRINT_20260109_011_001 Task: AIAT-009)
|
||||
builder.Services.AddAiAttestationServices();
|
||||
builder.Services.AddInMemoryAiAttestationStore();
|
||||
builder.Services.AddAdvisoryAiRuntimePersistence(builder.Configuration, builder.Environment);
|
||||
|
||||
// Evidence Packs (Sprint: SPRINT_20260109_011_005 Task: EVPK-010)
|
||||
builder.Services.AddEvidencePack();
|
||||
@@ -1929,4 +1928,3 @@ namespace StellaOps.AdvisoryAI.WebService
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.AdvisoryAI.Hosting;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Attestation.Storage;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Registers authoritative AdvisoryAI runtime persistence for consent and attestation state.
|
||||
/// </summary>
|
||||
public static class AdvisoryAiRuntimePersistenceExtensions
|
||||
{
|
||||
public static IServiceCollection AddAdvisoryAiRuntimePersistence(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
|
||||
services.AddAdvisoryAiCoreRuntimePersistence(configuration, environment);
|
||||
|
||||
var connectionString = AdvisoryAiCoreRuntimePersistenceExtensions.ResolveConnectionString(configuration);
|
||||
var allowInMemory = environment.IsEnvironment("Testing");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
if (!allowInMemory)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AdvisoryAI requires PostgreSQL-backed consent and attestation storage outside Testing. Configure AdvisoryAI:Storage:ConnectionString, ConnectionStrings:Default, or Database:ConnectionString.");
|
||||
}
|
||||
|
||||
services.RemoveAll<IAiConsentStore>();
|
||||
services.AddSingleton<IAiConsentStore, InMemoryAiConsentStore>();
|
||||
|
||||
services.RemoveAll<IAiAttestationStore>();
|
||||
services.AddInMemoryAiAttestationStore();
|
||||
return services;
|
||||
}
|
||||
|
||||
services.RemoveAll<IAiConsentStore>();
|
||||
services.AddSingleton<IAiConsentStore, PostgresAiConsentStore>();
|
||||
|
||||
services.RemoveAll<IAiAttestationStore>();
|
||||
services.AddSingleton<IAiAttestationStore, PostgresAiAttestationStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using StellaOps.AdvisoryAI.Attestation.Storage;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed attestation store for AdvisoryAI runtime state.
|
||||
/// </summary>
|
||||
public sealed class PostgresAiAttestationStore : RepositoryBase<AdvisoryAiDataSource>, IAiAttestationStore
|
||||
{
|
||||
public PostgresAiAttestationStore(
|
||||
AdvisoryAiDataSource dataSource,
|
||||
ILogger<PostgresAiAttestationStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public Task StoreRunAttestationAsync(AiRunAttestation attestation, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO ai_run_attestations (
|
||||
run_id,
|
||||
tenant_id,
|
||||
started_at,
|
||||
completed_at,
|
||||
created_at,
|
||||
content_digest,
|
||||
attestation_json
|
||||
) VALUES (
|
||||
@run_id,
|
||||
@tenant_id,
|
||||
@started_at,
|
||||
@completed_at,
|
||||
@created_at,
|
||||
@content_digest,
|
||||
@attestation_json
|
||||
)
|
||||
ON CONFLICT (run_id) DO UPDATE
|
||||
SET tenant_id = EXCLUDED.tenant_id,
|
||||
started_at = EXCLUDED.started_at,
|
||||
completed_at = EXCLUDED.completed_at,
|
||||
created_at = EXCLUDED.created_at,
|
||||
content_digest = EXCLUDED.content_digest,
|
||||
attestation_json = EXCLUDED.attestation_json;
|
||||
""";
|
||||
|
||||
var attestationJson = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiRunAttestation);
|
||||
var contentDigest = attestation.ComputeDigest();
|
||||
|
||||
return ExecuteAsync(
|
||||
attestation.TenantId,
|
||||
sql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "run_id", attestation.RunId);
|
||||
AddParameter(command, "tenant_id", attestation.TenantId);
|
||||
AddParameter(command, "started_at", attestation.StartedAt);
|
||||
AddParameter(command, "completed_at", attestation.CompletedAt);
|
||||
AddParameter(command, "created_at", attestation.CompletedAt);
|
||||
AddParameter(command, "content_digest", contentDigest);
|
||||
AddJsonbParameter(command, "attestation_json", attestationJson);
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task StoreSignedEnvelopeAsync(string runId, object envelope, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
ArgumentNullException.ThrowIfNull(envelope);
|
||||
|
||||
const string sql = """
|
||||
UPDATE ai_run_attestations
|
||||
SET signed_envelope_json = @signed_envelope_json
|
||||
WHERE run_id = @run_id;
|
||||
""";
|
||||
|
||||
var envelopeJson = SerializeEnvelope(envelope);
|
||||
|
||||
return ExecuteAsync(
|
||||
string.Empty,
|
||||
sql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "run_id", runId.Trim());
|
||||
AddJsonbParameter(command, "signed_envelope_json", envelopeJson);
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task StoreClaimAttestationAsync(AiClaimAttestation attestation, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attestation);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO ai_claim_attestations (
|
||||
claim_id,
|
||||
run_id,
|
||||
tenant_id,
|
||||
turn_id,
|
||||
content_digest,
|
||||
"timestamp",
|
||||
claim_json
|
||||
) VALUES (
|
||||
@claim_id,
|
||||
@run_id,
|
||||
@tenant_id,
|
||||
@turn_id,
|
||||
@content_digest,
|
||||
@timestamp,
|
||||
@claim_json
|
||||
)
|
||||
ON CONFLICT (claim_id) DO UPDATE
|
||||
SET run_id = EXCLUDED.run_id,
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
turn_id = EXCLUDED.turn_id,
|
||||
content_digest = EXCLUDED.content_digest,
|
||||
"timestamp" = EXCLUDED."timestamp",
|
||||
claim_json = EXCLUDED.claim_json;
|
||||
""";
|
||||
|
||||
var claimJson = JsonSerializer.Serialize(attestation, AiAttestationJsonContext.Default.AiClaimAttestation);
|
||||
|
||||
return ExecuteAsync(
|
||||
attestation.TenantId,
|
||||
sql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "claim_id", attestation.ClaimId);
|
||||
AddParameter(command, "run_id", attestation.RunId);
|
||||
AddParameter(command, "tenant_id", attestation.TenantId);
|
||||
AddParameter(command, "turn_id", attestation.TurnId);
|
||||
AddParameter(command, "content_digest", attestation.ContentDigest);
|
||||
AddParameter(command, "timestamp", attestation.Timestamp);
|
||||
AddJsonbParameter(command, "claim_json", claimJson);
|
||||
},
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
const string sql = """
|
||||
SELECT attestation_json::text AS attestation_json
|
||||
FROM ai_run_attestations
|
||||
WHERE run_id = @run_id
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
string.Empty,
|
||||
sql,
|
||||
command => AddParameter(command, "run_id", runId.Trim()),
|
||||
reader => DeserializeRunAttestation(reader.GetString(reader.GetOrdinal("attestation_json"))),
|
||||
ct);
|
||||
}
|
||||
|
||||
public Task<AiClaimAttestation?> GetClaimAttestationAsync(string claimId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(claimId);
|
||||
|
||||
const string sql = """
|
||||
SELECT claim_json::text AS claim_json
|
||||
FROM ai_claim_attestations
|
||||
WHERE claim_id = @claim_id
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
string.Empty,
|
||||
sql,
|
||||
command => AddParameter(command, "claim_id", claimId.Trim()),
|
||||
reader => DeserializeClaimAttestation(reader.GetString(reader.GetOrdinal("claim_json"))),
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
const string sql = """
|
||||
SELECT claim_json::text AS claim_json
|
||||
FROM ai_claim_attestations
|
||||
WHERE run_id = @run_id
|
||||
ORDER BY "timestamp", claim_id;
|
||||
""";
|
||||
|
||||
var results = await QueryAsync(
|
||||
string.Empty,
|
||||
sql,
|
||||
command => AddParameter(command, "run_id", runId.Trim()),
|
||||
reader => DeserializeClaimAttestation(reader.GetString(reader.GetOrdinal("claim_json"))),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<AiClaimAttestation>> GetClaimAttestationsByTurnAsync(
|
||||
string runId,
|
||||
string turnId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(turnId);
|
||||
|
||||
const string sql = """
|
||||
SELECT claim_json::text AS claim_json
|
||||
FROM ai_claim_attestations
|
||||
WHERE run_id = @run_id
|
||||
AND turn_id = @turn_id
|
||||
ORDER BY "timestamp", claim_id;
|
||||
""";
|
||||
|
||||
var results = await QueryAsync(
|
||||
string.Empty,
|
||||
sql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "run_id", runId.Trim());
|
||||
AddParameter(command, "turn_id", turnId.Trim());
|
||||
},
|
||||
reader => DeserializeClaimAttestation(reader.GetString(reader.GetOrdinal("claim_json"))),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public async Task<object?> GetSignedEnvelopeAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
const string sql = """
|
||||
SELECT signed_envelope_json::text AS signed_envelope_json
|
||||
FROM ai_run_attestations
|
||||
WHERE run_id = @run_id
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
var envelopeJson = await ExecuteScalarAsync<string>(
|
||||
string.Empty,
|
||||
sql,
|
||||
command => AddParameter(command, "run_id", runId.Trim()),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(envelopeJson))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(envelopeJson);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
const string sql = """
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM ai_run_attestations
|
||||
WHERE run_id = @run_id
|
||||
);
|
||||
""";
|
||||
|
||||
return await ExecuteScalarAsync<bool>(
|
||||
string.Empty,
|
||||
sql,
|
||||
command => AddParameter(command, "run_id", runId.Trim()),
|
||||
ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<AiRunAttestation>> GetByTenantAsync(
|
||||
string tenantId,
|
||||
DateTimeOffset from,
|
||||
DateTimeOffset to,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
const string sql = """
|
||||
SELECT attestation_json::text AS attestation_json
|
||||
FROM ai_run_attestations
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND started_at >= @from
|
||||
AND started_at <= @to
|
||||
ORDER BY started_at, run_id;
|
||||
""";
|
||||
|
||||
var results = await QueryAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId.Trim());
|
||||
AddParameter(command, "from", from);
|
||||
AddParameter(command, "to", to);
|
||||
},
|
||||
reader => DeserializeRunAttestation(reader.GetString(reader.GetOrdinal("attestation_json"))),
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
return results.ToImmutableArray();
|
||||
}
|
||||
|
||||
public Task<AiClaimAttestation?> GetByContentDigestAsync(string contentDigest, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentDigest);
|
||||
|
||||
const string sql = """
|
||||
SELECT claim_json::text AS claim_json
|
||||
FROM ai_claim_attestations
|
||||
WHERE content_digest = @content_digest
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
return QuerySingleOrDefaultAsync(
|
||||
string.Empty,
|
||||
sql,
|
||||
command => AddParameter(command, "content_digest", contentDigest.Trim()),
|
||||
reader => DeserializeClaimAttestation(reader.GetString(reader.GetOrdinal("claim_json"))),
|
||||
ct);
|
||||
}
|
||||
|
||||
private static string SerializeEnvelope(object envelope)
|
||||
{
|
||||
return envelope switch
|
||||
{
|
||||
JsonElement jsonElement => jsonElement.GetRawText(),
|
||||
JsonDocument jsonDocument => jsonDocument.RootElement.GetRawText(),
|
||||
_ => JsonSerializer.Serialize(envelope),
|
||||
};
|
||||
}
|
||||
|
||||
private static AiRunAttestation DeserializeRunAttestation(string json)
|
||||
=> JsonSerializer.Deserialize(json, AiAttestationJsonContext.Default.AiRunAttestation)
|
||||
?? throw new InvalidOperationException("Stored AdvisoryAI run attestation payload could not be deserialized.");
|
||||
|
||||
private static AiClaimAttestation DeserializeClaimAttestation(string json)
|
||||
=> JsonSerializer.Deserialize(json, AiAttestationJsonContext.Default.AiClaimAttestation)
|
||||
?? throw new InvalidOperationException("Stored AdvisoryAI claim attestation payload could not be deserialized.");
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL-backed consent storage for AdvisoryAI runtime state.
|
||||
/// </summary>
|
||||
public sealed class PostgresAiConsentStore : RepositoryBase<AdvisoryAiDataSource>, IAiConsentStore
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public PostgresAiConsentStore(
|
||||
AdvisoryAiDataSource dataSource,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PostgresAiConsentStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<AiConsentRecord?> GetConsentAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
const string sql = """
|
||||
SELECT tenant_id, user_id, consented, scope, consented_at, expires_at, session_level
|
||||
FROM ai_consents
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND user_id = @user_id
|
||||
AND (expires_at IS NULL OR expires_at >= @now)
|
||||
LIMIT 1;
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId.Trim());
|
||||
AddParameter(command, "user_id", userId.Trim());
|
||||
AddParameter(command, "now", _timeProvider.GetUtcNow());
|
||||
},
|
||||
MapConsentRecord,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<AiConsentRecord> GrantConsentAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
AiConsentGrant grant,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
ArgumentNullException.ThrowIfNull(grant);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var record = new AiConsentRecord
|
||||
{
|
||||
TenantId = tenantId.Trim(),
|
||||
UserId = userId.Trim(),
|
||||
Consented = true,
|
||||
Scope = grant.Scope.Trim(),
|
||||
ConsentedAt = now,
|
||||
ExpiresAt = grant.Duration.HasValue ? now.Add(grant.Duration.Value) : null,
|
||||
SessionLevel = grant.SessionLevel,
|
||||
};
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO ai_consents (
|
||||
tenant_id,
|
||||
user_id,
|
||||
consented,
|
||||
scope,
|
||||
consented_at,
|
||||
expires_at,
|
||||
session_level,
|
||||
updated_at
|
||||
) VALUES (
|
||||
@tenant_id,
|
||||
@user_id,
|
||||
@consented,
|
||||
@scope,
|
||||
@consented_at,
|
||||
@expires_at,
|
||||
@session_level,
|
||||
@updated_at
|
||||
)
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE
|
||||
SET consented = EXCLUDED.consented,
|
||||
scope = EXCLUDED.scope,
|
||||
consented_at = EXCLUDED.consented_at,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
session_level = EXCLUDED.session_level,
|
||||
updated_at = EXCLUDED.updated_at;
|
||||
""";
|
||||
|
||||
await ExecuteAsync(
|
||||
record.TenantId,
|
||||
sql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", record.TenantId);
|
||||
AddParameter(command, "user_id", record.UserId);
|
||||
AddParameter(command, "consented", record.Consented);
|
||||
AddParameter(command, "scope", record.Scope);
|
||||
AddParameter(command, "consented_at", record.ConsentedAt);
|
||||
AddParameter(command, "expires_at", record.ExpiresAt);
|
||||
AddParameter(command, "session_level", record.SessionLevel);
|
||||
AddParameter(command, "updated_at", now);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Logger.LogInformation(
|
||||
"Persisted AdvisoryAI consent for tenant {TenantId}, user {UserId}, scope {Scope}",
|
||||
record.TenantId,
|
||||
record.UserId,
|
||||
record.Scope);
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeConsentAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
const string sql = """
|
||||
DELETE FROM ai_consents
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND user_id = @user_id;
|
||||
""";
|
||||
|
||||
var affectedRows = await ExecuteAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId.Trim());
|
||||
AddParameter(command, "user_id", userId.Trim());
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return affectedRows > 0;
|
||||
}
|
||||
|
||||
private static AiConsentRecord MapConsentRecord(Npgsql.NpgsqlDataReader reader)
|
||||
{
|
||||
var tenantId = reader.GetString(reader.GetOrdinal("tenant_id"));
|
||||
var userId = reader.GetString(reader.GetOrdinal("user_id"));
|
||||
var consented = reader.GetBoolean(reader.GetOrdinal("consented"));
|
||||
var scope = reader.GetString(reader.GetOrdinal("scope"));
|
||||
var consentedAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("consented_at"));
|
||||
var expiresAt = GetNullableDateTimeOffset(reader, reader.GetOrdinal("expires_at"));
|
||||
var sessionLevel = reader.GetBoolean(reader.GetOrdinal("session_level"));
|
||||
|
||||
return new AiConsentRecord
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Consented = consented,
|
||||
Scope = scope,
|
||||
ConsentedAt = consentedAt,
|
||||
ExpiresAt = expiresAt,
|
||||
SessionLevel = sessionLevel,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AIAIRUNTIME-022 | DONE | `docs/implplan/SPRINT_20260417_022_AdvisoryAI_truthful_testing_only_runtime_fallback.md`: tightened AdvisoryAI runtime persistence fallback from `Development/Testing` to `Testing` only; `advisory-ai-web` now fails fast without the runtime DB contract in `Development`, while direct xUnit proof covers startup contract (`2/2`) and durable non-regression (`AdvisoryAiDurableRuntimeTests`, `6/6`). |
|
||||
| AIAIRUNTIME-018 | DONE | `docs/implplan/SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover.md`: `advisory-ai-web` resolves explanation replay, policy intent, run, conversation, and chat-settings state through the shared PostgreSQL runtime persistence path; fallback wording was later tightened to `Testing` only in `AIAIRUNTIME-022`. |
|
||||
| OPSREAL-003 | DONE | `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`: `advisory-ai-web` binds `PostgresAiConsentStore` and `PostgresAiAttestationStore` through `AddAdvisoryAiRuntimePersistence` and auto-migrates `advisoryai` runtime tables; the environment gate was later tightened from `Development/Testing` to `Testing` only in `AIAIRUNTIME-022`. |
|
||||
| SPRINT_20260405_010-AIAI-PG | DONE | `docs/implplan/SPRINT_20260405_010_AdvisoryAI_pg_pooling_and_gitea_spike_followup.md`: advisory-ai-web PostgreSQL attribution/pooling follow-up and Gitea spike capture. |
|
||||
| SPRINT_20260222_051-AKS-API | DONE | Extended AKS search/open-action endpoint contract and added header-based authentication wiring (`AddAuthentication` + `AddAuthorization` + `UseAuthorization`) so `RequireAuthorization()` endpoints execute without runtime middleware errors. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/StellaOps.AdvisoryAI.WebService.md. |
|
||||
|
||||
@@ -14,6 +14,7 @@ builder.Configuration
|
||||
.AddEnvironmentVariables(prefix: "ADVISORYAI__");
|
||||
|
||||
builder.Services.AddAdvisoryAiCore(builder.Configuration);
|
||||
builder.Services.AddAdvisoryAiCoreRuntimePersistence(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddSingleton<IAdvisoryJitterSource, DefaultAdvisoryJitterSource>();
|
||||
builder.Services.AddHostedService<AdvisoryTaskWorker>();
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AIAIRUNTIME-018 | DONE | `docs/implplan/SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover.md`: `advisory-ai-worker` now resolves shared AdvisoryAI explanation/policy/run/conversation/chat-settings runtime state through `AddAdvisoryAiCoreRuntimePersistence` instead of live in-memory authoritative stores. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/StellaOps.AdvisoryAI.Worker.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// <copyright file="PostgresConversationService.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AdvisoryAI.Storage;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat;
|
||||
|
||||
public sealed class PostgresConversationService : IConversationService
|
||||
{
|
||||
private readonly IConversationStore _store;
|
||||
private readonly ConversationOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IGuidGenerator _guidGenerator;
|
||||
private readonly ILogger<PostgresConversationService> _logger;
|
||||
|
||||
public PostgresConversationService(
|
||||
IConversationStore store,
|
||||
IOptions<ConversationOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
IGuidGenerator guidGenerator,
|
||||
ILogger<PostgresConversationService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_options = options.Value;
|
||||
_timeProvider = timeProvider;
|
||||
_guidGenerator = guidGenerator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Conversation> CreateAsync(
|
||||
ConversationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var conversation = new Conversation
|
||||
{
|
||||
ConversationId = GenerateConversationId(request),
|
||||
TenantId = request.TenantId,
|
||||
UserId = request.UserId,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
Context = request.InitialContext ?? new ConversationContext(),
|
||||
Turns = ImmutableArray<ConversationTurn>.Empty,
|
||||
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
var created = await _store.CreateAsync(conversation, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Created durable conversation {ConversationId}", created.ConversationId);
|
||||
return created;
|
||||
}
|
||||
|
||||
public Task<Conversation?> GetAsync(string conversationId, CancellationToken cancellationToken = default)
|
||||
=> _store.GetByIdAsync(conversationId, cancellationToken);
|
||||
|
||||
public async Task<ConversationTurn> AddTurnAsync(
|
||||
string conversationId,
|
||||
TurnRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var conversation = await _store.GetByIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
if (conversation is null)
|
||||
{
|
||||
throw new ConversationNotFoundException(conversationId);
|
||||
}
|
||||
|
||||
var turn = new ConversationTurn
|
||||
{
|
||||
TurnId = $"{conversationId}-{conversation.Turns.Length + 1}",
|
||||
Role = request.Role,
|
||||
Content = request.Content,
|
||||
Timestamp = _timeProvider.GetUtcNow(),
|
||||
EvidenceLinks = request.EvidenceLinks ?? ImmutableArray<EvidenceLink>.Empty,
|
||||
ProposedActions = request.ProposedActions ?? ImmutableArray<ProposedAction>.Empty,
|
||||
Metadata = request.Metadata ?? ImmutableDictionary<string, string>.Empty
|
||||
};
|
||||
|
||||
if (conversation.Turns.Length >= _options.MaxTurnsPerConversation)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Durable conversation {ConversationId} exceeded max turn count {MaxTurns}; retention trimming remains append-only until turn-level pruning is added.",
|
||||
conversationId,
|
||||
_options.MaxTurnsPerConversation);
|
||||
}
|
||||
|
||||
await _store.AddTurnAsync(conversationId, turn, cancellationToken).ConfigureAwait(false);
|
||||
return turn;
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default)
|
||||
=> _store.DeleteAsync(conversationId, cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<Conversation>> ListAsync(
|
||||
string tenantId,
|
||||
string? userId = null,
|
||||
int? limit = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> userId is null
|
||||
? _store.GetByTenantAsync(tenantId, limit ?? 50, cancellationToken)
|
||||
: _store.GetByUserAsync(tenantId, userId, limit ?? 50, cancellationToken);
|
||||
|
||||
public Task<Conversation?> UpdateContextAsync(
|
||||
string conversationId,
|
||||
ConversationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _store.UpdateContextAsync(conversationId, context, cancellationToken);
|
||||
|
||||
private string GenerateConversationId(ConversationRequest request)
|
||||
{
|
||||
var input = $"{request.TenantId}:{request.UserId}:{_timeProvider.GetUtcNow():O}:{_guidGenerator.NewGuid()}";
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
var guidBytes = new byte[16];
|
||||
Array.Copy(hash, guidBytes, 16);
|
||||
guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50);
|
||||
guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80);
|
||||
return new Guid(guidBytes).ToString("N");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// <copyright file="PostgresAdvisoryChatSettingsStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Chat.Settings;
|
||||
|
||||
public sealed class PostgresAdvisoryChatSettingsStore
|
||||
: RepositoryBase<AdvisoryAiDataSource>, IAdvisoryChatSettingsStore
|
||||
{
|
||||
private const string TenantScopeUserId = "";
|
||||
|
||||
public PostgresAdvisoryChatSettingsStore(
|
||||
AdvisoryAiDataSource dataSource,
|
||||
ILogger<PostgresAdvisoryChatSettingsStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public Task<AdvisoryChatSettingsOverrides?> GetTenantOverridesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> GetOverridesAsync(tenantId, TenantScopeUserId, cancellationToken);
|
||||
|
||||
public Task<AdvisoryChatSettingsOverrides?> GetUserOverridesAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> GetOverridesAsync(tenantId, userId, cancellationToken);
|
||||
|
||||
public Task SetTenantOverridesAsync(
|
||||
string tenantId,
|
||||
AdvisoryChatSettingsOverrides overrides,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> SetOverridesAsync(tenantId, TenantScopeUserId, overrides, cancellationToken);
|
||||
|
||||
public Task SetUserOverridesAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
AdvisoryChatSettingsOverrides overrides,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> SetOverridesAsync(tenantId, userId, overrides, cancellationToken);
|
||||
|
||||
public Task<bool> ClearTenantOverridesAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ClearOverridesAsync(tenantId, TenantScopeUserId, cancellationToken);
|
||||
|
||||
public Task<bool> ClearUserOverridesAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> ClearOverridesAsync(tenantId, userId, cancellationToken);
|
||||
|
||||
private async Task<AdvisoryChatSettingsOverrides?> GetOverridesAsync(
|
||||
string tenantId,
|
||||
string scopeUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var json = await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
"""
|
||||
SELECT overrides_json::text
|
||||
FROM advisoryai.runtime_chat_settings_overrides
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND scope_user_id = @scope_user_id;
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "scope_user_id", scopeUserId);
|
||||
},
|
||||
reader => reader.GetString(0),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AdvisoryAiRuntimeJson.Deserialize<AdvisoryChatSettingsOverrides>(json);
|
||||
}
|
||||
|
||||
private Task SetOverridesAsync(
|
||||
string tenantId,
|
||||
string scopeUserId,
|
||||
AdvisoryChatSettingsOverrides overrides,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(overrides);
|
||||
|
||||
return ExecuteAsync(
|
||||
tenantId,
|
||||
"""
|
||||
INSERT INTO advisoryai.runtime_chat_settings_overrides (
|
||||
tenant_id,
|
||||
scope_user_id,
|
||||
updated_at,
|
||||
overrides_json)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@scope_user_id,
|
||||
@updated_at,
|
||||
@overrides_json)
|
||||
ON CONFLICT (tenant_id, scope_user_id) DO UPDATE
|
||||
SET updated_at = EXCLUDED.updated_at,
|
||||
overrides_json = EXCLUDED.overrides_json;
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "scope_user_id", scopeUserId);
|
||||
AddParameter(command, "updated_at", DateTimeOffset.UtcNow);
|
||||
AddJsonbParameter(command, "overrides_json", AdvisoryAiRuntimeJson.Serialize(overrides));
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<bool> ClearOverridesAsync(
|
||||
string tenantId,
|
||||
string scopeUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
"""
|
||||
DELETE FROM advisoryai.runtime_chat_settings_overrides
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND scope_user_id = @scope_user_id;
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "scope_user_id", scopeUserId);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// <copyright file="PostgresExplanationStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Explanation;
|
||||
|
||||
public sealed class PostgresExplanationStore
|
||||
: RepositoryBase<AdvisoryAiDataSource>, IExplanationStore, IExplanationRequestStore
|
||||
{
|
||||
public PostgresExplanationStore(
|
||||
AdvisoryAiDataSource dataSource,
|
||||
ILogger<PostgresExplanationStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public Task StoreAsync(ExplanationResult result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
var generatedAt = DateTimeOffset.TryParse(result.GeneratedAt, out var parsed)
|
||||
? parsed
|
||||
: DateTimeOffset.UtcNow;
|
||||
|
||||
return ExecuteAsync(
|
||||
string.Empty,
|
||||
"""
|
||||
INSERT INTO advisoryai.runtime_explanations (
|
||||
explanation_id,
|
||||
generated_at,
|
||||
updated_at,
|
||||
result_json)
|
||||
VALUES (
|
||||
@explanation_id,
|
||||
@generated_at,
|
||||
@updated_at,
|
||||
@result_json)
|
||||
ON CONFLICT (explanation_id) DO UPDATE
|
||||
SET generated_at = EXCLUDED.generated_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
result_json = EXCLUDED.result_json;
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "explanation_id", result.ExplanationId);
|
||||
AddParameter(command, "generated_at", generatedAt);
|
||||
AddParameter(command, "updated_at", DateTimeOffset.UtcNow);
|
||||
AddJsonbParameter(command, "result_json", AdvisoryAiRuntimeJson.Serialize(result));
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task StoreRequestAsync(
|
||||
string explanationId,
|
||||
ExplanationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(explanationId);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return ExecuteAsync(
|
||||
string.Empty,
|
||||
"""
|
||||
INSERT INTO advisoryai.runtime_explanations (
|
||||
explanation_id,
|
||||
updated_at,
|
||||
request_json)
|
||||
VALUES (
|
||||
@explanation_id,
|
||||
@updated_at,
|
||||
@request_json)
|
||||
ON CONFLICT (explanation_id) DO UPDATE
|
||||
SET updated_at = EXCLUDED.updated_at,
|
||||
request_json = EXCLUDED.request_json;
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "explanation_id", explanationId);
|
||||
AddParameter(command, "updated_at", DateTimeOffset.UtcNow);
|
||||
AddJsonbParameter(command, "request_json", AdvisoryAiRuntimeJson.Serialize(request));
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ExplanationResult?> GetAsync(string explanationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(explanationId);
|
||||
|
||||
var json = await QuerySingleOrDefaultAsync(
|
||||
string.Empty,
|
||||
"""
|
||||
SELECT result_json::text
|
||||
FROM advisoryai.runtime_explanations
|
||||
WHERE explanation_id = @explanation_id
|
||||
AND result_json IS NOT NULL;
|
||||
""",
|
||||
command => AddParameter(command, "explanation_id", explanationId),
|
||||
reader => reader.GetString(0),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AdvisoryAiRuntimeJson.Deserialize<ExplanationResult>(json);
|
||||
}
|
||||
|
||||
public async Task<ExplanationRequest?> GetRequestAsync(string explanationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(explanationId);
|
||||
|
||||
var json = await QuerySingleOrDefaultAsync(
|
||||
string.Empty,
|
||||
"""
|
||||
SELECT request_json::text
|
||||
FROM advisoryai.runtime_explanations
|
||||
WHERE explanation_id = @explanation_id
|
||||
AND request_json IS NOT NULL;
|
||||
""",
|
||||
command => AddParameter(command, "explanation_id", explanationId),
|
||||
reader => reader.GetString(0),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AdvisoryAiRuntimeJson.Deserialize<ExplanationRequest>(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// <copyright file="PostgresPolicyIntentStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.PolicyStudio;
|
||||
|
||||
public sealed class PostgresPolicyIntentStore : RepositoryBase<AdvisoryAiDataSource>, IPolicyIntentStore
|
||||
{
|
||||
public PostgresPolicyIntentStore(
|
||||
AdvisoryAiDataSource dataSource,
|
||||
ILogger<PostgresPolicyIntentStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public Task StoreAsync(PolicyIntent intent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(intent);
|
||||
|
||||
return ExecuteAsync(
|
||||
string.Empty,
|
||||
"""
|
||||
INSERT INTO advisoryai.runtime_policy_intents (
|
||||
intent_id,
|
||||
updated_at,
|
||||
intent_json)
|
||||
VALUES (
|
||||
@intent_id,
|
||||
@updated_at,
|
||||
@intent_json)
|
||||
ON CONFLICT (intent_id) DO UPDATE
|
||||
SET updated_at = EXCLUDED.updated_at,
|
||||
intent_json = EXCLUDED.intent_json;
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "intent_id", intent.IntentId);
|
||||
AddParameter(command, "updated_at", DateTimeOffset.UtcNow);
|
||||
AddJsonbParameter(command, "intent_json", AdvisoryAiRuntimeJson.Serialize(intent));
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<PolicyIntent?> GetAsync(string intentId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(intentId);
|
||||
|
||||
var json = await QuerySingleOrDefaultAsync(
|
||||
string.Empty,
|
||||
"""
|
||||
SELECT intent_json::text
|
||||
FROM advisoryai.runtime_policy_intents
|
||||
WHERE intent_id = @intent_id;
|
||||
""",
|
||||
command => AddParameter(command, "intent_id", intentId),
|
||||
reader => reader.GetString(0),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AdvisoryAiRuntimeJson.DeserializePolicyIntent(json);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// </copyright>
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
@@ -163,6 +164,16 @@ public enum RunEventType
|
||||
/// <summary>
|
||||
/// Content of a run event (polymorphic).
|
||||
/// </summary>
|
||||
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
|
||||
[JsonDerivedType(typeof(TurnContent), "turn")]
|
||||
[JsonDerivedType(typeof(ToolCallContent), "tool_call")]
|
||||
[JsonDerivedType(typeof(ToolResultContent), "tool_result")]
|
||||
[JsonDerivedType(typeof(ActionProposedContent), "action_proposed")]
|
||||
[JsonDerivedType(typeof(ActionExecutedContent), "action_executed")]
|
||||
[JsonDerivedType(typeof(ArtifactProducedContent), "artifact_produced")]
|
||||
[JsonDerivedType(typeof(StatusChangedContent), "status_changed")]
|
||||
[JsonDerivedType(typeof(OpsMemoryEnrichedContent), "opsmemory_enriched")]
|
||||
[JsonDerivedType(typeof(ErrorContent), "error")]
|
||||
public abstract record RunEventContent;
|
||||
|
||||
/// <summary>
|
||||
|
||||
365
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/PostgresRunStore.cs
Normal file
365
src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/PostgresRunStore.cs
Normal file
@@ -0,0 +1,365 @@
|
||||
// <copyright file="PostgresRunStore.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using NpgsqlTypes;
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Infrastructure.Postgres.Repositories;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
public sealed class PostgresRunStore : RepositoryBase<AdvisoryAiDataSource>, IRunStore
|
||||
{
|
||||
private static readonly ImmutableArray<RunStatus> ActiveStatuses =
|
||||
[
|
||||
RunStatus.Created,
|
||||
RunStatus.Active,
|
||||
RunStatus.PendingApproval
|
||||
];
|
||||
|
||||
public PostgresRunStore(
|
||||
AdvisoryAiDataSource dataSource,
|
||||
ILogger<PostgresRunStore> logger)
|
||||
: base(dataSource, logger)
|
||||
{
|
||||
}
|
||||
|
||||
public Task SaveAsync(Run run, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(run);
|
||||
|
||||
var approvers = run.Approval?.Approvers.IsDefaultOrEmpty == false
|
||||
? run.Approval.Approvers.ToArray()
|
||||
: null;
|
||||
|
||||
return ExecuteAsync(
|
||||
run.TenantId,
|
||||
"""
|
||||
INSERT INTO advisoryai.runtime_runs (
|
||||
tenant_id,
|
||||
run_id,
|
||||
initiated_by,
|
||||
title,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
focused_cve_id,
|
||||
focused_component,
|
||||
current_owner,
|
||||
approval_required,
|
||||
approvers,
|
||||
run_json)
|
||||
VALUES (
|
||||
@tenant_id,
|
||||
@run_id,
|
||||
@initiated_by,
|
||||
@title,
|
||||
@status,
|
||||
@created_at,
|
||||
@updated_at,
|
||||
@completed_at,
|
||||
@focused_cve_id,
|
||||
@focused_component,
|
||||
@current_owner,
|
||||
@approval_required,
|
||||
@approvers,
|
||||
@run_json)
|
||||
ON CONFLICT (tenant_id, run_id) DO UPDATE
|
||||
SET initiated_by = EXCLUDED.initiated_by,
|
||||
title = EXCLUDED.title,
|
||||
status = EXCLUDED.status,
|
||||
created_at = EXCLUDED.created_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
completed_at = EXCLUDED.completed_at,
|
||||
focused_cve_id = EXCLUDED.focused_cve_id,
|
||||
focused_component = EXCLUDED.focused_component,
|
||||
current_owner = EXCLUDED.current_owner,
|
||||
approval_required = EXCLUDED.approval_required,
|
||||
approvers = EXCLUDED.approvers,
|
||||
run_json = EXCLUDED.run_json;
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", run.TenantId);
|
||||
AddParameter(command, "run_id", run.RunId);
|
||||
AddParameter(command, "initiated_by", run.InitiatedBy);
|
||||
AddParameter(command, "title", run.Title);
|
||||
AddParameter(command, "status", (int)run.Status);
|
||||
AddParameter(command, "created_at", run.CreatedAt);
|
||||
AddParameter(command, "updated_at", run.UpdatedAt);
|
||||
AddParameter(command, "completed_at", run.CompletedAt);
|
||||
AddParameter(command, "focused_cve_id", run.Context.FocusedCveId);
|
||||
AddParameter(command, "focused_component", run.Context.FocusedComponent);
|
||||
AddParameter(command, "current_owner", run.Metadata.GetValueOrDefault("current_owner"));
|
||||
AddParameter(command, "approval_required", run.Approval?.Required ?? false);
|
||||
AddTextArrayParameter(command, "approvers", approvers);
|
||||
AddJsonbParameter(command, "run_json", AdvisoryAiRuntimeJson.Serialize(run));
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Run?> GetAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var json = await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
"""
|
||||
SELECT run_json::text
|
||||
FROM advisoryai.runtime_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND run_id = @run_id;
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "run_id", runId);
|
||||
},
|
||||
reader => reader.GetString(0),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return AdvisoryAiRuntimeJson.DeserializeRun(json);
|
||||
}
|
||||
|
||||
public async Task<(ImmutableArray<Run> Runs, int TotalCount)> QueryAsync(
|
||||
RunQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
await using var connection = await DataSource.OpenConnectionAsync(query.TenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var whereClause = """
|
||||
tenant_id = @tenant_id
|
||||
AND (@statuses IS NULL OR status = ANY(@statuses))
|
||||
AND (CAST(@initiated_by AS text) IS NULL OR initiated_by = @initiated_by)
|
||||
AND (CAST(@cve_id AS text) IS NULL OR focused_cve_id = @cve_id)
|
||||
AND (CAST(@component AS text) IS NULL OR focused_component = @component)
|
||||
AND (CAST(@created_after AS timestamptz) IS NULL OR created_at >= @created_after)
|
||||
AND (CAST(@created_before AS timestamptz) IS NULL OR created_at <= @created_before)
|
||||
""";
|
||||
|
||||
await using var countCommand = CreateCommand(
|
||||
$"SELECT COUNT(*) FROM advisoryai.runtime_runs WHERE {whereClause};",
|
||||
connection);
|
||||
ConfigureQueryCommand(countCommand, query);
|
||||
var totalCount = Convert.ToInt32(await countCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false));
|
||||
|
||||
await using var command = CreateCommand(
|
||||
$"""
|
||||
SELECT run_json::text
|
||||
FROM advisoryai.runtime_runs
|
||||
WHERE {whereClause}
|
||||
ORDER BY created_at DESC
|
||||
OFFSET @skip
|
||||
LIMIT @take;
|
||||
""",
|
||||
connection);
|
||||
ConfigureQueryCommand(command, query);
|
||||
AddParameter(command, "skip", query.Skip);
|
||||
AddParameter(command, "take", query.Take);
|
||||
|
||||
var runs = ImmutableArray.CreateBuilder<Run>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var run = AdvisoryAiRuntimeJson.DeserializeRun(reader.GetString(0));
|
||||
if (run is not null)
|
||||
{
|
||||
runs.Add(run);
|
||||
}
|
||||
}
|
||||
|
||||
return (runs.ToImmutable(), totalCount);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string tenantId, string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(runId);
|
||||
|
||||
var rows = await ExecuteAsync(
|
||||
tenantId,
|
||||
"""
|
||||
DELETE FROM advisoryai.runtime_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND run_id = @run_id;
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "run_id", runId);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<Run>> GetByStatusAsync(
|
||||
string tenantId,
|
||||
ImmutableArray<RunStatus> statuses,
|
||||
int skip = 0,
|
||||
int take = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
return await QueryRunsAsync(
|
||||
tenantId,
|
||||
"""
|
||||
tenant_id = @tenant_id
|
||||
AND status = ANY(@statuses)
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddStatusArrayParameter(command, "statuses", statuses);
|
||||
AddParameter(command, "skip", skip);
|
||||
AddParameter(command, "take", take);
|
||||
},
|
||||
"created_at DESC",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<Run>> GetActiveForUserAsync(
|
||||
string tenantId,
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
|
||||
|
||||
return await QueryRunsAsync(
|
||||
tenantId,
|
||||
"""
|
||||
tenant_id = @tenant_id
|
||||
AND status = ANY(@statuses)
|
||||
AND (initiated_by = @user_id OR current_owner = @user_id)
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddStatusArrayParameter(command, "statuses", ActiveStatuses);
|
||||
AddParameter(command, "user_id", userId);
|
||||
AddParameter(command, "skip", 0);
|
||||
AddParameter(command, "take", 100);
|
||||
},
|
||||
"updated_at DESC",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<ImmutableArray<Run>> GetPendingApprovalAsync(
|
||||
string tenantId,
|
||||
string? approverId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
return await QueryRunsAsync(
|
||||
tenantId,
|
||||
"""
|
||||
tenant_id = @tenant_id
|
||||
AND status = @pending_status
|
||||
AND (@approver_id IS NULL OR @approver_id = ANY(COALESCE(approvers, ARRAY[]::text[])))
|
||||
""",
|
||||
command =>
|
||||
{
|
||||
AddParameter(command, "tenant_id", tenantId);
|
||||
AddParameter(command, "pending_status", (int)RunStatus.PendingApproval);
|
||||
AddParameter(command, "approver_id", approverId);
|
||||
AddParameter(command, "skip", 0);
|
||||
AddParameter(command, "take", 100);
|
||||
},
|
||||
"updated_at DESC",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatusAsync(
|
||||
string tenantId,
|
||||
string runId,
|
||||
RunStatus newStatus,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var run = await GetAsync(tenantId, runId, cancellationToken).ConfigureAwait(false);
|
||||
if (run is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await SaveAsync(run with { Status = newStatus }, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<ImmutableArray<Run>> QueryRunsAsync(
|
||||
string tenantId,
|
||||
string whereClause,
|
||||
Action<NpgsqlCommand> configureCommand,
|
||||
string orderBy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(tenantId, "reader", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var command = CreateCommand(
|
||||
$"""
|
||||
SELECT run_json::text
|
||||
FROM advisoryai.runtime_runs
|
||||
WHERE {whereClause}
|
||||
ORDER BY {orderBy}
|
||||
OFFSET @skip
|
||||
LIMIT @take;
|
||||
""",
|
||||
connection);
|
||||
|
||||
configureCommand(command);
|
||||
|
||||
var runs = ImmutableArray.CreateBuilder<Run>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
var run = AdvisoryAiRuntimeJson.DeserializeRun(reader.GetString(0));
|
||||
if (run is not null)
|
||||
{
|
||||
runs.Add(run);
|
||||
}
|
||||
}
|
||||
|
||||
return runs.ToImmutable();
|
||||
}
|
||||
|
||||
private static void ConfigureQueryCommand(NpgsqlCommand command, RunQuery query)
|
||||
{
|
||||
AddParameter(command, "tenant_id", query.TenantId);
|
||||
AddParameter(command, "initiated_by", query.InitiatedBy);
|
||||
AddParameter(command, "cve_id", query.CveId);
|
||||
AddParameter(command, "component", query.Component);
|
||||
AddParameter(command, "created_after", query.CreatedAfter);
|
||||
AddParameter(command, "created_before", query.CreatedBefore);
|
||||
AddStatusArrayParameter(command, "statuses", query.Statuses);
|
||||
}
|
||||
|
||||
private static void AddStatusArrayParameter(
|
||||
NpgsqlCommand command,
|
||||
string name,
|
||||
ImmutableArray<RunStatus>? statuses)
|
||||
{
|
||||
if (statuses is null || statuses.Value.IsDefaultOrEmpty)
|
||||
{
|
||||
command.Parameters.Add(new NpgsqlParameter(name, NpgsqlDbType.Array | NpgsqlDbType.Integer)
|
||||
{
|
||||
Value = DBNull.Value
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
command.Parameters.Add(new NpgsqlParameter<int[]>(name, NpgsqlDbType.Array | NpgsqlDbType.Integer)
|
||||
{
|
||||
TypedValue = statuses.Value.Select(static status => (int)status).ToArray()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,28 @@ public sealed class ConversationStore : RepositoryBase<AdvisoryAiDataSource>, IC
|
||||
return entities.Select(MapConversation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<Conversation>> GetByTenantAsync(
|
||||
string tenantId,
|
||||
int limit = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
tenantId, "reader", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var entities = await dbContext.Conversations
|
||||
.AsNoTracking()
|
||||
.Where(c => c.TenantId == tenantId)
|
||||
.OrderByDescending(c => c.UpdatedAt)
|
||||
.Take(limit)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities.Select(MapConversation).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Conversation> AddTurnAsync(
|
||||
string conversationId,
|
||||
@@ -188,6 +210,33 @@ public sealed class ConversationStore : RepositoryBase<AdvisoryAiDataSource>, IC
|
||||
return (await GetByIdAsync(conversationId, cancellationToken).ConfigureAwait(false))!;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Conversation?> UpdateContextAsync(
|
||||
string conversationId,
|
||||
ConversationContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await DataSource.OpenConnectionAsync(
|
||||
string.Empty, "writer", cancellationToken).ConfigureAwait(false);
|
||||
await using var dbContext = AdvisoryAiDbContextFactory.Create(
|
||||
connection, CommandTimeoutSeconds, GetSchemaName());
|
||||
|
||||
var conversation = await dbContext.Conversations
|
||||
.FirstOrDefaultAsync(c => c.ConversationId == conversationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (conversation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
conversation.Context = JsonSerializer.Serialize(context, JsonOptions);
|
||||
conversation.UpdatedAt = _timeProvider.GetUtcNow().UtcDateTime;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await GetByIdAsync(conversationId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(
|
||||
string conversationId,
|
||||
@@ -355,9 +404,15 @@ public interface IConversationStore
|
||||
/// <summary>Gets conversations for a user.</summary>
|
||||
Task<IReadOnlyList<Conversation>> GetByUserAsync(string tenantId, string userId, int limit = 20, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Gets conversations for a tenant.</summary>
|
||||
Task<IReadOnlyList<Conversation>> GetByTenantAsync(string tenantId, int limit = 50, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Adds a turn to a conversation.</summary>
|
||||
Task<Conversation> AddTurnAsync(string conversationId, ConversationTurn turn, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Updates the context for a conversation.</summary>
|
||||
Task<Conversation?> UpdateContextAsync(string conversationId, ConversationContext context, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Deletes a conversation.</summary>
|
||||
Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default);
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- 009_ai_runtime_state.sql
|
||||
-- Durable runtime state for AdvisoryAI consent and attestation records.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS advisoryai;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.ai_consents
|
||||
(
|
||||
tenant_id text NOT NULL,
|
||||
user_id text NOT NULL,
|
||||
consented boolean NOT NULL,
|
||||
scope text NOT NULL,
|
||||
consented_at timestamptz NULL,
|
||||
expires_at timestamptz NULL,
|
||||
session_level boolean NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (tenant_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_consents_tenant_updated
|
||||
ON advisoryai.ai_consents (tenant_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_consents_expires
|
||||
ON advisoryai.ai_consents (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.ai_run_attestations
|
||||
(
|
||||
run_id text PRIMARY KEY,
|
||||
tenant_id text NOT NULL,
|
||||
started_at timestamptz NOT NULL,
|
||||
completed_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
content_digest text NOT NULL,
|
||||
attestation_json jsonb NOT NULL,
|
||||
signed_envelope_json jsonb NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_run_attestations_tenant_created
|
||||
ON advisoryai.ai_run_attestations (tenant_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_run_attestations_content_digest
|
||||
ON advisoryai.ai_run_attestations (content_digest);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.ai_claim_attestations
|
||||
(
|
||||
claim_id text PRIMARY KEY,
|
||||
run_id text NOT NULL REFERENCES advisoryai.ai_run_attestations (run_id) ON DELETE CASCADE,
|
||||
tenant_id text NOT NULL,
|
||||
turn_id text NOT NULL,
|
||||
content_digest text NOT NULL,
|
||||
"timestamp" timestamptz NOT NULL,
|
||||
claim_json jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_claim_attestations_run_timestamp
|
||||
ON advisoryai.ai_claim_attestations (run_id, "timestamp");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_claim_attestations_content_digest
|
||||
ON advisoryai.ai_claim_attestations (content_digest);
|
||||
@@ -0,0 +1,109 @@
|
||||
-- 010_advisory_ai_runtime_state_extensions.sql
|
||||
-- Durable runtime state for explanations, policy intents, runs, and chat settings.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS advisoryai;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.runtime_explanations
|
||||
(
|
||||
explanation_id text PRIMARY KEY,
|
||||
generated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
result_json jsonb NULL,
|
||||
request_json jsonb NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_explanations_generated_at
|
||||
ON advisoryai.runtime_explanations (generated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.runtime_policy_intents
|
||||
(
|
||||
intent_id text PRIMARY KEY,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
intent_json jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_policy_intents_updated_at
|
||||
ON advisoryai.runtime_policy_intents (updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.runtime_runs
|
||||
(
|
||||
tenant_id text NOT NULL,
|
||||
run_id text NOT NULL,
|
||||
initiated_by text NOT NULL,
|
||||
title text NOT NULL,
|
||||
status integer NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
completed_at timestamptz NULL,
|
||||
focused_cve_id text NULL,
|
||||
focused_component text NULL,
|
||||
current_owner text NULL,
|
||||
approval_required boolean NOT NULL DEFAULT false,
|
||||
approvers text[] NULL,
|
||||
run_json jsonb NOT NULL,
|
||||
PRIMARY KEY (tenant_id, run_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_created
|
||||
ON advisoryai.runtime_runs (tenant_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_status_updated
|
||||
ON advisoryai.runtime_runs (tenant_id, status, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_initiated
|
||||
ON advisoryai.runtime_runs (tenant_id, initiated_by, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_cve
|
||||
ON advisoryai.runtime_runs (tenant_id, focused_cve_id)
|
||||
WHERE focused_cve_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_runs_tenant_component
|
||||
ON advisoryai.runtime_runs (tenant_id, focused_component)
|
||||
WHERE focused_component IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.runtime_chat_settings_overrides
|
||||
(
|
||||
tenant_id text NOT NULL,
|
||||
scope_user_id text NOT NULL DEFAULT '',
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
overrides_json jsonb NOT NULL,
|
||||
PRIMARY KEY (tenant_id, scope_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_chat_settings_updated_at
|
||||
ON advisoryai.runtime_chat_settings_overrides (tenant_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.conversations
|
||||
(
|
||||
conversation_id text PRIMARY KEY,
|
||||
tenant_id text NOT NULL,
|
||||
user_id text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
context jsonb NULL,
|
||||
metadata jsonb NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisoryai_conversations_tenant_updated
|
||||
ON advisoryai.conversations (tenant_id, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisoryai_conversations_tenant_user
|
||||
ON advisoryai.conversations (tenant_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS advisoryai.turns
|
||||
(
|
||||
turn_id text PRIMARY KEY,
|
||||
conversation_id text NOT NULL REFERENCES advisoryai.conversations (conversation_id) ON DELETE CASCADE,
|
||||
role text NOT NULL,
|
||||
content text NOT NULL,
|
||||
"timestamp" timestamptz NOT NULL,
|
||||
evidence_links jsonb NULL,
|
||||
proposed_actions jsonb NULL,
|
||||
metadata jsonb NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisoryai_turns_conversation
|
||||
ON advisoryai.turns (conversation_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_advisoryai_turns_conversation_timestamp
|
||||
ON advisoryai.turns (conversation_id, "timestamp");
|
||||
@@ -0,0 +1,133 @@
|
||||
// <copyright file="AdvisoryAiRuntimeJson.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Collections;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.AdvisoryAI.PolicyStudio;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
|
||||
internal static class AdvisoryAiRuntimeJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateOptions();
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
=> JsonSerializer.Serialize(value, JsonOptions);
|
||||
|
||||
public static T? Deserialize<T>(string? json)
|
||||
=> string.IsNullOrWhiteSpace(json)
|
||||
? default
|
||||
: JsonSerializer.Deserialize<T>(json, JsonOptions);
|
||||
|
||||
public static Run? DeserializeRun(string? json)
|
||||
=> Deserialize<Run>(json);
|
||||
|
||||
public static PolicyIntent? DeserializePolicyIntent(string? json)
|
||||
{
|
||||
var intent = Deserialize<PolicyIntent>(json);
|
||||
return intent is null ? null : Normalize(intent);
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
private static PolicyIntent Normalize(PolicyIntent intent)
|
||||
{
|
||||
return intent with
|
||||
{
|
||||
Conditions = intent.Conditions
|
||||
.Select(condition => condition with { Value = NormalizeValue(condition.Value) ?? string.Empty })
|
||||
.ToArray(),
|
||||
Actions = intent.Actions
|
||||
.Select(action => action with
|
||||
{
|
||||
Parameters = NormalizeDictionary(action.Parameters)
|
||||
})
|
||||
.ToArray(),
|
||||
Alternatives = intent.Alternatives?.Select(Normalize).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object> NormalizeDictionary(IReadOnlyDictionary<string, object> parameters)
|
||||
{
|
||||
var normalized = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (var entry in parameters)
|
||||
{
|
||||
normalized[entry.Key] = NormalizeValue(entry.Value) ?? string.Empty;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static object? NormalizeValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => null,
|
||||
JsonElement element => NormalizeJsonElement(element),
|
||||
JsonDocument document => NormalizeJsonElement(document.RootElement),
|
||||
IDictionary dictionary => NormalizeDictionary(dictionary),
|
||||
IEnumerable enumerable when value is not string => NormalizeList(enumerable),
|
||||
_ => value
|
||||
};
|
||||
}
|
||||
|
||||
private static object? NormalizeJsonElement(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var longValue)
|
||||
? longValue
|
||||
: element.TryGetDecimal(out var decimalValue)
|
||||
? decimalValue
|
||||
: element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Array => element.EnumerateArray().Select(NormalizeJsonElement).ToList(),
|
||||
JsonValueKind.Object => element.EnumerateObject()
|
||||
.ToDictionary(property => property.Name, property => NormalizeJsonElement(property.Value) ?? string.Empty, StringComparer.Ordinal),
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
|
||||
private static object NormalizeDictionary(IDictionary dictionary)
|
||||
{
|
||||
var normalized = new Dictionary<string, object>(StringComparer.Ordinal);
|
||||
foreach (DictionaryEntry entry in dictionary)
|
||||
{
|
||||
var key = entry.Key?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[key] = NormalizeValue(entry.Value) ?? string.Empty;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static object NormalizeList(IEnumerable enumerable)
|
||||
{
|
||||
var items = new List<object?>();
|
||||
foreach (var item in enumerable)
|
||||
{
|
||||
items.Add(NormalizeValue(item));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ Source of truth: `docs/implplan/SPRINT_20260113_005_ADVISORYAI_controlled_conver
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AIAIRUNTIME-018 | DONE | `docs/implplan/SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover.md`: explanation replay, policy intent, run state, conversation state, and chat settings are now authoritative PostgreSQL-backed runtime stores under `advisoryai`, with embedded migration `010_advisory_ai_runtime_state_extensions.sql` and restart-survival proof in `AdvisoryAiDurableRuntimeTests` (`6/6`). |
|
||||
| OPSREAL-003 | DONE | `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`: advisory AI attestation flow is now store-backed end-to-end, and migration `009_ai_runtime_state.sql` adds durable consent/run/claim attestation tables under `advisoryai`. Focused evidence: `StellaOps.AdvisoryAI.Attestation.Tests` `58/58`, `AdvisoryAiDurableRuntimeTests` `2/2`. |
|
||||
| SPRINT_20260405_011-XPORT | DONE | `docs/implplan/SPRINT_20260405_011___Libraries_transport_pooling_and_attribution_hardening.md`: named advisory chat audit PostgreSQL sessions and aligned the first repo-wide transport hardening wave. |
|
||||
| SPRINT_20260405_010-AIAI-PG | DONE | `docs/implplan/SPRINT_20260405_010_AdvisoryAI_pg_pooling_and_gitea_spike_followup.md`: AdvisoryAI PostgreSQL application-name/pooling follow-up and Gitea spike capture. |
|
||||
| SPRINT_20260223_100-USRCH-POL-005 | DONE | Security hardening closure: tenant-scoped adapter identities, backend+frontend snippet sanitization, and threat-model docs. Evidence: `UnifiedSearchLiveAdapterIntegrationTests` (11/11), `UnifiedSearchSprintIntegrationTests` (109/109), targeted snippet test (1/1). |
|
||||
|
||||
@@ -555,7 +555,17 @@ internal sealed class InMemoryConversationStore : IConversationStore
|
||||
{
|
||||
var result = _conversations.Values
|
||||
.Where(c => c.TenantId == tenantId && c.UserId == userId)
|
||||
.OrderByDescending(c => c.CreatedAt)
|
||||
.OrderByDescending(c => c.UpdatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Conversation>>(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Conversation>> GetByTenantAsync(string tenantId, int limit = 50, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = _conversations.Values
|
||||
.Where(c => c.TenantId == tenantId)
|
||||
.OrderByDescending(c => c.UpdatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<Conversation>>(result);
|
||||
@@ -574,6 +584,18 @@ internal sealed class InMemoryConversationStore : IConversationStore
|
||||
return Task.FromResult(updated);
|
||||
}
|
||||
|
||||
public Task<Conversation?> UpdateContextAsync(string conversationId, ConversationContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_conversations.TryGetValue(conversationId, out var conversation))
|
||||
{
|
||||
return Task.FromResult<Conversation?>(null);
|
||||
}
|
||||
|
||||
var updated = conversation with { Context = context, UpdatedAt = DateTimeOffset.UtcNow };
|
||||
_conversations[conversationId] = updated;
|
||||
return Task.FromResult<Conversation?>(updated);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_conversations.Remove(conversationId));
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
using StellaOps.AdvisoryAI.Attestation;
|
||||
using StellaOps.AdvisoryAI.Attestation.Models;
|
||||
using StellaOps.AdvisoryAI.Attestation.Storage;
|
||||
using StellaOps.AdvisoryAI.Chat;
|
||||
using StellaOps.AdvisoryAI.Chat.Settings;
|
||||
using StellaOps.AdvisoryAI.Explanation;
|
||||
using StellaOps.AdvisoryAI.PolicyStudio;
|
||||
using StellaOps.AdvisoryAI.Runs;
|
||||
using StellaOps.AdvisoryAI.WebService.Contracts;
|
||||
using StellaOps.AdvisoryAI.WebService.Endpoints;
|
||||
using StellaOps.AdvisoryAI.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using StellaOps.TestKit.Fixtures;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Tests.Integration;
|
||||
|
||||
public sealed class AdvisoryAiDurableRuntimeTests : IClassFixture<PostgresFixture>
|
||||
{
|
||||
private readonly PostgresFixture _postgres;
|
||||
|
||||
public AdvisoryAiDurableRuntimeTests(PostgresFixture postgres)
|
||||
{
|
||||
_postgres = postgres;
|
||||
_postgres.IsolationMode = PostgresIsolationMode.DatabasePerTest;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Consent_PersistsAcrossHostRestart_WithDurableStore()
|
||||
{
|
||||
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_consent_runtime");
|
||||
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
|
||||
|
||||
using (var factory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory-ai:view");
|
||||
|
||||
var grantResponse = await client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/consent",
|
||||
new AiConsentGrantRequest
|
||||
{
|
||||
Scope = "all",
|
||||
SessionLevel = false,
|
||||
DataShareAcknowledged = true,
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
grantResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<IAiConsentStore>().Should().BeOfType<PostgresAiConsentStore>();
|
||||
scope.ServiceProvider.GetRequiredService<IAiAttestationStore>().Should().BeOfType<PostgresAiAttestationStore>();
|
||||
}
|
||||
|
||||
using (var restartedFactory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var restartedClient = CreateAuthorizedClient(restartedFactory, "advisory-ai:view");
|
||||
|
||||
var consentStatus = await restartedClient.GetFromJsonAsync<AiConsentStatusResponse>(
|
||||
"/v1/advisory-ai/consent",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
consentStatus.Should().NotBeNull();
|
||||
consentStatus!.Consented.Should().BeTrue();
|
||||
consentStatus.Scope.Should().Be("all");
|
||||
consentStatus.SessionLevel.Should().BeFalse();
|
||||
consentStatus.ConsentedBy.Should().Be("test-user");
|
||||
}
|
||||
|
||||
await AssertRuntimeTablesExistAsync(connectionString);
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Attestations_PersistAcrossHostRestart_AndRoundTripThroughEndpoints()
|
||||
{
|
||||
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_attestation_runtime");
|
||||
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
|
||||
|
||||
const string tenantId = "tenant-advisory-proof";
|
||||
const string runId = "run-durable-001";
|
||||
const string claimId = "claim-durable-001";
|
||||
|
||||
using (var factory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var attestationService = scope.ServiceProvider.GetRequiredService<IAiAttestationService>();
|
||||
|
||||
scope.ServiceProvider.GetRequiredService<IAiAttestationStore>().Should().BeOfType<PostgresAiAttestationStore>();
|
||||
|
||||
var runAttestation = new AiRunAttestation
|
||||
{
|
||||
RunId = runId,
|
||||
TenantId = tenantId,
|
||||
UserId = "test-user",
|
||||
ConversationId = "conversation-durable-001",
|
||||
StartedAt = new DateTimeOffset(2026, 04, 15, 10, 00, 00, TimeSpan.Zero),
|
||||
CompletedAt = new DateTimeOffset(2026, 04, 15, 10, 05, 00, TimeSpan.Zero),
|
||||
Model = new AiModelInfo
|
||||
{
|
||||
Provider = "openai",
|
||||
ModelId = "gpt-5.4-mini",
|
||||
},
|
||||
OverallGroundingScore = 0.92,
|
||||
TotalTokens = 512,
|
||||
};
|
||||
|
||||
var claimAttestation = new AiClaimAttestation
|
||||
{
|
||||
ClaimId = claimId,
|
||||
RunId = runId,
|
||||
TurnId = "turn-durable-001",
|
||||
TenantId = tenantId,
|
||||
ClaimText = "The advisory remediation remains gated by policy evidence.",
|
||||
ClaimDigest = "sha256:claim-durable-001",
|
||||
GroundingScore = 0.88,
|
||||
Timestamp = new DateTimeOffset(2026, 04, 15, 10, 04, 00, TimeSpan.Zero),
|
||||
ContentDigest = "sha256:content-durable-001",
|
||||
};
|
||||
|
||||
var createdRun = await attestationService.CreateRunAttestationAsync(
|
||||
runAttestation,
|
||||
sign: true,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
createdRun.Signed.Should().BeTrue();
|
||||
|
||||
await attestationService.CreateClaimAttestationAsync(
|
||||
claimAttestation,
|
||||
sign: false,
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
using (var restartedFactory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:view");
|
||||
|
||||
var runResponse = await client.GetFromJsonAsync<RunAttestationResponse>(
|
||||
$"/v1/advisory-ai/runs/{runId}/attestation",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
runResponse.Should().NotBeNull();
|
||||
runResponse!.RunId.Should().Be(runId);
|
||||
runResponse.Attestation.TenantId.Should().Be(tenantId);
|
||||
runResponse.Envelope.Should().NotBeNull();
|
||||
|
||||
var claimsResponse = await client.GetFromJsonAsync<ClaimsListResponse>(
|
||||
$"/v1/advisory-ai/runs/{runId}/claims",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
claimsResponse.Should().NotBeNull();
|
||||
claimsResponse!.Count.Should().Be(1);
|
||||
claimsResponse.Claims.Should().ContainSingle(claim => claim.ClaimId == claimId);
|
||||
|
||||
var verifyResponse = await client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/attestations/verify",
|
||||
new VerifyAttestationRequest { RunId = runId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var verification = await verifyResponse.Content.ReadFromJsonAsync<AttestationVerificationResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
verification.Should().NotBeNull();
|
||||
verification!.IsValid.Should().BeTrue();
|
||||
verification.DigestValid.Should().BeTrue();
|
||||
verification.SignatureValid.Should().BeTrue();
|
||||
verification.SigningKeyId.Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ExplanationReplay_PersistsAcrossHostRestart_WithDurableStore()
|
||||
{
|
||||
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_explanation_runtime");
|
||||
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
|
||||
|
||||
ExplainResponse created;
|
||||
using (var factory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory:explain advisory-ai:view");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/explain",
|
||||
new ExplainRequest
|
||||
{
|
||||
FindingId = "finding-001",
|
||||
ArtifactDigest = "sha256:artifact-001",
|
||||
Scope = "tenant",
|
||||
ScopeId = "tenant-advisory-proof",
|
||||
VulnerabilityId = "CVE-2026-0001",
|
||||
ComponentPurl = "pkg:npm/example@1.2.3",
|
||||
ExplanationType = "full",
|
||||
PlainLanguage = true,
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
created = (await response.Content.ReadFromJsonAsync<ExplainResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken))!;
|
||||
created.ExplanationId.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<IExplanationStore>().Should().BeOfType<PostgresExplanationStore>();
|
||||
}
|
||||
|
||||
using (var restartedFactory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory:explain advisory-ai:view");
|
||||
|
||||
var replay = await client.GetFromJsonAsync<ExplainResponse>(
|
||||
$"/v1/advisory-ai/explain/{created.ExplanationId}/replay",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
replay.Should().NotBeNull();
|
||||
replay!.ExplanationId.Should().Be(created.ExplanationId);
|
||||
replay.Content.Should().Be(created.Content);
|
||||
replay.OutputHash.Should().Be(created.OutputHash);
|
||||
}
|
||||
|
||||
await AssertCoreRuntimeTablesExistAsync(connectionString);
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task PolicyIntent_PersistsAcrossHostRestart_AndGenerateEndpointUsesStoredIntent()
|
||||
{
|
||||
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_policy_runtime");
|
||||
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
|
||||
|
||||
PolicyParseApiResponse parsed;
|
||||
using (var factory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory:run advisory-ai:view");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/policy/studio/parse",
|
||||
new PolicyParseApiRequest
|
||||
{
|
||||
Input = "Block critical reachable vulnerabilities for production workloads.",
|
||||
DefaultScope = "service",
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
parsed = (await response.Content.ReadFromJsonAsync<PolicyParseApiResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken))!;
|
||||
parsed.Intent.IntentId.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<IPolicyIntentStore>().Should().BeOfType<PostgresPolicyIntentStore>();
|
||||
}
|
||||
|
||||
using (var restartedFactory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory:run advisory-ai:view");
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/policy/studio/generate",
|
||||
new PolicyGenerateApiRequest { IntentId = parsed.Intent.IntentId },
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var generated = await response.Content.ReadFromJsonAsync<RuleGenerationApiResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
generated.Should().NotBeNull();
|
||||
generated!.IntentId.Should().Be(parsed.Intent.IntentId);
|
||||
generated.Success.Should().BeTrue();
|
||||
generated.Rules.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Runs_PersistAcrossHostRestart_AndRemainQueryable()
|
||||
{
|
||||
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_run_runtime");
|
||||
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
|
||||
|
||||
StellaOps.AdvisoryAI.WebService.Endpoints.RunDto createdRun;
|
||||
using (var factory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory-ai:view");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/runs",
|
||||
new StellaOps.AdvisoryAI.WebService.Endpoints.CreateRunRequestDto
|
||||
{
|
||||
Title = "Investigate CVE-2026-0002",
|
||||
Objective = "Confirm runtime exposure",
|
||||
Context = new StellaOps.AdvisoryAI.WebService.Endpoints.RunContextDto
|
||||
{
|
||||
FocusedCveId = "CVE-2026-0002",
|
||||
FocusedComponent = "pkg:npm/runtime@2.0.0",
|
||||
}
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
createdRun = (await createResponse.Content.ReadFromJsonAsync<StellaOps.AdvisoryAI.WebService.Endpoints.RunDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken))!;
|
||||
|
||||
var approvalResponse = await client.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/runs/{createdRun.RunId}/approval/request",
|
||||
new StellaOps.AdvisoryAI.WebService.Endpoints.RequestApprovalDto
|
||||
{
|
||||
Approvers = ["approver-1"],
|
||||
Reason = "Needs security approval",
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
approvalResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<IRunStore>().Should().BeOfType<PostgresRunStore>();
|
||||
}
|
||||
|
||||
using (var restartedFactory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory-ai:view");
|
||||
|
||||
var run = await client.GetFromJsonAsync<StellaOps.AdvisoryAI.WebService.Endpoints.RunDto>(
|
||||
$"/v1/advisory-ai/runs/{createdRun.RunId}",
|
||||
TestContext.Current.CancellationToken);
|
||||
run.Should().NotBeNull();
|
||||
run!.Status.Should().Be(RunStatus.PendingApproval.ToString());
|
||||
run.Context!.FocusedCveId.Should().Be("CVE-2026-0002");
|
||||
|
||||
var pending = await client.GetFromJsonAsync<List<StellaOps.AdvisoryAI.WebService.Endpoints.RunDto>>(
|
||||
"/v1/advisory-ai/runs/pending-approval",
|
||||
TestContext.Current.CancellationToken);
|
||||
pending.Should().ContainSingle(item => item.RunId == createdRun.RunId);
|
||||
|
||||
var query = await client.GetFromJsonAsync<StellaOps.AdvisoryAI.WebService.Endpoints.RunQueryResultDto>(
|
||||
"/v1/advisory-ai/runs?cveId=CVE-2026-0002&take=10",
|
||||
TestContext.Current.CancellationToken);
|
||||
query.Should().NotBeNull();
|
||||
query!.Runs.Should().Contain(item => item.RunId == createdRun.RunId);
|
||||
}
|
||||
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Conversations_AndChatSettings_PersistAcrossHostRestart()
|
||||
{
|
||||
await using var session = await _postgres.CreateDatabaseSessionAsync("advisoryai_chat_runtime");
|
||||
var connectionString = CreateNonPooledConnectionString(session.ConnectionString);
|
||||
|
||||
ConversationResponse createdConversation;
|
||||
using (var factory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(factory, "advisory-ai:operate advisory-ai:view advisory:chat chat:user");
|
||||
|
||||
var settingsResponse = await client.PutAsJsonAsync(
|
||||
"/api/v1/chat/settings?scope=user",
|
||||
new ChatSettingsUpdateRequest
|
||||
{
|
||||
Quotas = new ChatQuotaSettingsUpdateRequest
|
||||
{
|
||||
RequestsPerMinute = 7,
|
||||
ToolCallsPerDay = 11,
|
||||
},
|
||||
Tools = new ChatToolAccessUpdateRequest
|
||||
{
|
||||
AllowAll = false,
|
||||
AllowedTools = ["policy.read", "sbom.read"],
|
||||
}
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
settingsResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var createConversationResponse = await client.PostAsJsonAsync(
|
||||
"/v1/advisory-ai/conversations",
|
||||
new CreateConversationRequest
|
||||
{
|
||||
TenantId = "tenant-advisory-proof",
|
||||
Context = new ConversationContextRequest
|
||||
{
|
||||
CurrentCveId = "CVE-2026-0003",
|
||||
}
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
createConversationResponse.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
createdConversation = (await createConversationResponse.Content.ReadFromJsonAsync<ConversationResponse>(
|
||||
cancellationToken: TestContext.Current.CancellationToken))!;
|
||||
|
||||
var addTurnResponse = await client.PostAsJsonAsync(
|
||||
$"/v1/advisory-ai/conversations/{createdConversation.ConversationId}/turns",
|
||||
new AddTurnRequest
|
||||
{
|
||||
Content = "Explain the blast radius for this finding.",
|
||||
Stream = false,
|
||||
},
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
addTurnResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
scope.ServiceProvider.GetRequiredService<IConversationService>().Should().BeOfType<PostgresConversationService>();
|
||||
scope.ServiceProvider.GetRequiredService<IAdvisoryChatSettingsStore>().Should().BeOfType<PostgresAdvisoryChatSettingsStore>();
|
||||
}
|
||||
|
||||
using (var restartedFactory = CreateDurableFactory(connectionString))
|
||||
{
|
||||
using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory-ai:view advisory:chat chat:user");
|
||||
|
||||
var settings = await client.GetFromJsonAsync<ChatSettingsResponse>(
|
||||
"/api/v1/chat/settings",
|
||||
TestContext.Current.CancellationToken);
|
||||
settings.Should().NotBeNull();
|
||||
settings!.Quotas.RequestsPerMinute.Should().Be(7);
|
||||
settings.Quotas.ToolCallsPerDay.Should().Be(11);
|
||||
settings.Tools.AllowAll.Should().BeFalse();
|
||||
settings.Tools.AllowedTools.Should().Contain(["policy.read", "sbom.read"]);
|
||||
|
||||
var conversation = await client.GetFromJsonAsync<ConversationResponse>(
|
||||
$"/v1/advisory-ai/conversations/{createdConversation.ConversationId}",
|
||||
TestContext.Current.CancellationToken);
|
||||
conversation.Should().NotBeNull();
|
||||
conversation!.Turns.Should().HaveCountGreaterThanOrEqualTo(2);
|
||||
|
||||
var conversations = await client.GetFromJsonAsync<ConversationListResponse>(
|
||||
"/v1/advisory-ai/conversations?tenantId=tenant-advisory-proof&limit=10",
|
||||
TestContext.Current.CancellationToken);
|
||||
conversations.Should().NotBeNull();
|
||||
conversations!.Conversations.Should().Contain(item => item.ConversationId == createdConversation.ConversationId);
|
||||
}
|
||||
|
||||
NpgsqlConnection.ClearAllPools();
|
||||
}
|
||||
|
||||
private static async Task AssertRuntimeTablesExistAsync(string connectionString)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'advisoryai'
|
||||
AND table_name IN ('ai_consents', 'ai_run_attestations', 'ai_claim_attestations')
|
||||
ORDER BY table_name;
|
||||
""",
|
||||
connection);
|
||||
|
||||
var tableNames = new List<string>();
|
||||
await using var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken);
|
||||
while (await reader.ReadAsync(TestContext.Current.CancellationToken))
|
||||
{
|
||||
tableNames.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
tableNames.Should().Equal("ai_claim_attestations", "ai_consents", "ai_run_attestations");
|
||||
}
|
||||
|
||||
private static async Task AssertCoreRuntimeTablesExistAsync(string connectionString)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'advisoryai'
|
||||
AND table_name IN (
|
||||
'runtime_explanations',
|
||||
'runtime_policy_intents',
|
||||
'runtime_runs',
|
||||
'runtime_chat_settings_overrides')
|
||||
ORDER BY table_name;
|
||||
""",
|
||||
connection);
|
||||
|
||||
var tableNames = new List<string>();
|
||||
await using var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken);
|
||||
while (await reader.ReadAsync(TestContext.Current.CancellationToken))
|
||||
{
|
||||
tableNames.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
tableNames.Should().Equal(
|
||||
"runtime_chat_settings_overrides",
|
||||
"runtime_explanations",
|
||||
"runtime_policy_intents",
|
||||
"runtime_runs");
|
||||
}
|
||||
|
||||
private static HttpClient CreateAuthorizedClient(WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> factory, string scopes)
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "test-user");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Scopes", scopes);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-advisory-proof");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-advisory-proof");
|
||||
client.DefaultRequestHeaders.Add("X-User-Id", "test-user");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Client", "test-client");
|
||||
return client;
|
||||
}
|
||||
|
||||
private static string CreateNonPooledConnectionString(string connectionString)
|
||||
{
|
||||
var builder = new NpgsqlConnectionStringBuilder(connectionString)
|
||||
{
|
||||
Pooling = false,
|
||||
};
|
||||
|
||||
return builder.ConnectionString;
|
||||
}
|
||||
|
||||
private static WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program> CreateDurableFactory(string connectionString)
|
||||
{
|
||||
return new WebApplicationFactory<StellaOps.AdvisoryAI.WebService.Program>().WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
builder.UseSetting("AdvisoryAI:Storage:ConnectionString", connectionString);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| AIAIRUNTIME-022 | DONE | `docs/implplan/SPRINT_20260417_022_AdvisoryAI_truthful_testing_only_runtime_fallback.md`: added `AdvisoryAiRuntimeStartupContractTests` to prove `Testing`-only fallback (`2/2`) and reran `AdvisoryAiDurableRuntimeTests` directly through the xUnit v3 runner for durable non-regression (`6/6`). |
|
||||
| AIAIRUNTIME-018 | DONE | `docs/implplan/SPRINT_20260417_018_AdvisoryAI_truthful_runtime_state_cutover.md`: `AdvisoryAiDurableRuntimeTests` now prove explanation replay, policy-intent lookup, run state, conversation state, chat settings, consent, and attestation survive host restart against the real AdvisoryAI web runtime (`6/6`). |
|
||||
| OPSREAL-003 | DONE | `docs/implplan/SPRINT_20260415_005_DOCS_platform_binaryindex_doctor_real_backend_cutover.md`: added `AdvisoryAiDurableRuntimeTests` for consent and attestation restart-survival against live `WebApplicationFactory` + PostgreSQL (`2/2`). A separate whole-project MTP run was attempted but not used as evidence because it stalled without emitting terminal results. |
|
||||
| SPRINT_20260222_051-AKS-TESTS | DONE | Revalidated AKS tests with xUnit v3 `--filter-class`: `KnowledgeSearchEndpointsIntegrationTests` (3/3) and `*KnowledgeSearch*` suite slice (6/6) on 2026-02-22. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/StellaOps.AdvisoryAI.Tests.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
Reference in New Issue
Block a user