diff --git a/src/AdvisoryAI/README.md b/src/AdvisoryAI/README.md index a2b4b9c5e..9e1ffce15 100644 --- a/src/AdvisoryAI/README.md +++ b/src/AdvisoryAI/README.md @@ -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 diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiCoreRuntimePersistenceExtensions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiCoreRuntimePersistenceExtensions.cs new file mode 100644 index 000000000..29c04b75e --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Hosting/AdvisoryAiCoreRuntimePersistenceExtensions.cs @@ -0,0 +1,111 @@ +// +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. +// + +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(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( + AdvisoryAiDataSource.DefaultSchemaName, + "AdvisoryAI", + typeof(AdvisoryAiDataSource).Assembly, + static options => options.ConnectionString); + + services.TryAddSingleton(); + + services.RemoveAll(); + services.AddSingleton(); + + services.RemoveAll(); + services.AddSingleton(); + + services.RemoveAll(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(provider => provider.GetRequiredService()); + + services.RemoveAll(); + services.AddSingleton(); + + services.RemoveAll(); + services.AddSingleton(); + + services.RemoveAll(); + services.AddSingleton(); + + 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(); + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs index 1689b126e..5328a99cb 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Program.cs @@ -86,12 +86,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); // 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 } - diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Services/AdvisoryAiRuntimePersistenceExtensions.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Services/AdvisoryAiRuntimePersistenceExtensions.cs new file mode 100644 index 000000000..496cb4627 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Services/AdvisoryAiRuntimePersistenceExtensions.cs @@ -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; + +/// +/// Registers authoritative AdvisoryAI runtime persistence for consent and attestation state. +/// +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(); + services.AddSingleton(); + + services.RemoveAll(); + services.AddInMemoryAiAttestationStore(); + return services; + } + + services.RemoveAll(); + services.AddSingleton(); + + services.RemoveAll(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Services/PostgresAiAttestationStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Services/PostgresAiAttestationStore.cs new file mode 100644 index 000000000..654d339d4 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Services/PostgresAiAttestationStore.cs @@ -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; + +/// +/// PostgreSQL-backed attestation store for AdvisoryAI runtime state. +/// +public sealed class PostgresAiAttestationStore : RepositoryBase, IAiAttestationStore +{ + public PostgresAiAttestationStore( + AdvisoryAiDataSource dataSource, + ILogger 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 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 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> 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> 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 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.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 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( + string.Empty, + sql, + command => AddParameter(command, "run_id", runId.Trim()), + ct).ConfigureAwait(false); + } + + public async Task> 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 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."); +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Services/PostgresAiConsentStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Services/PostgresAiConsentStore.cs new file mode 100644 index 000000000..016788b5c --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/Services/PostgresAiConsentStore.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.Logging; +using StellaOps.AdvisoryAI.Storage.Postgres; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.AdvisoryAI.WebService.Services; + +/// +/// PostgreSQL-backed consent storage for AdvisoryAI runtime state. +/// +public sealed class PostgresAiConsentStore : RepositoryBase, IAiConsentStore +{ + private readonly TimeProvider _timeProvider; + + public PostgresAiConsentStore( + AdvisoryAiDataSource dataSource, + TimeProvider timeProvider, + ILogger logger) + : base(dataSource, logger) + { + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + public async Task 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 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 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, + }; + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/TASKS.md b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/TASKS.md index 90b34d436..aaa102842 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/TASKS.md +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.WebService/TASKS.md @@ -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. | diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/Program.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/Program.cs index 8c3f35ca3..182821348 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/Program.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/Program.cs @@ -14,6 +14,7 @@ builder.Configuration .AddEnvironmentVariables(prefix: "ADVISORYAI__"); builder.Services.AddAdvisoryAiCore(builder.Configuration); +builder.Services.AddAdvisoryAiCoreRuntimePersistence(builder.Configuration, builder.Environment); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/TASKS.md b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/TASKS.md index dfc46f0ec..7b554a809 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/TASKS.md +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI.Worker/TASKS.md @@ -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. | diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/PostgresConversationService.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/PostgresConversationService.cs new file mode 100644 index 000000000..b01ef49a2 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/PostgresConversationService.cs @@ -0,0 +1,123 @@ +// +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. +// + +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 _logger; + + public PostgresConversationService( + IConversationStore store, + IOptions options, + TimeProvider timeProvider, + IGuidGenerator guidGenerator, + ILogger logger) + { + _store = store; + _options = options.Value; + _timeProvider = timeProvider; + _guidGenerator = guidGenerator; + _logger = logger; + } + + public async Task 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.Empty, + Metadata = request.Metadata ?? ImmutableDictionary.Empty + }; + + var created = await _store.CreateAsync(conversation, cancellationToken).ConfigureAwait(false); + _logger.LogDebug("Created durable conversation {ConversationId}", created.ConversationId); + return created; + } + + public Task GetAsync(string conversationId, CancellationToken cancellationToken = default) + => _store.GetByIdAsync(conversationId, cancellationToken); + + public async Task 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.Empty, + ProposedActions = request.ProposedActions ?? ImmutableArray.Empty, + Metadata = request.Metadata ?? ImmutableDictionary.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 DeleteAsync(string conversationId, CancellationToken cancellationToken = default) + => _store.DeleteAsync(conversationId, cancellationToken); + + public Task> 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 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"); + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/Settings/PostgresAdvisoryChatSettingsStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/Settings/PostgresAdvisoryChatSettingsStore.cs new file mode 100644 index 000000000..5fbd5b677 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Chat/Settings/PostgresAdvisoryChatSettingsStore.cs @@ -0,0 +1,143 @@ +// +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. +// + +using Microsoft.Extensions.Logging; +using StellaOps.AdvisoryAI.Storage.Postgres; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.AdvisoryAI.Chat.Settings; + +public sealed class PostgresAdvisoryChatSettingsStore + : RepositoryBase, IAdvisoryChatSettingsStore +{ + private const string TenantScopeUserId = ""; + + public PostgresAdvisoryChatSettingsStore( + AdvisoryAiDataSource dataSource, + ILogger logger) + : base(dataSource, logger) + { + } + + public Task GetTenantOverridesAsync( + string tenantId, + CancellationToken cancellationToken = default) + => GetOverridesAsync(tenantId, TenantScopeUserId, cancellationToken); + + public Task 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 ClearTenantOverridesAsync( + string tenantId, + CancellationToken cancellationToken = default) + => ClearOverridesAsync(tenantId, TenantScopeUserId, cancellationToken); + + public Task ClearUserOverridesAsync( + string tenantId, + string userId, + CancellationToken cancellationToken = default) + => ClearOverridesAsync(tenantId, userId, cancellationToken); + + private async Task 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(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 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; + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Explanation/PostgresExplanationStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Explanation/PostgresExplanationStore.cs new file mode 100644 index 000000000..8a452c5e3 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Explanation/PostgresExplanationStore.cs @@ -0,0 +1,126 @@ +// +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. +// + +using Microsoft.Extensions.Logging; +using StellaOps.AdvisoryAI.Storage.Postgres; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.AdvisoryAI.Explanation; + +public sealed class PostgresExplanationStore + : RepositoryBase, IExplanationStore, IExplanationRequestStore +{ + public PostgresExplanationStore( + AdvisoryAiDataSource dataSource, + ILogger 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 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(json); + } + + public async Task 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(json); + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/PolicyStudio/PostgresPolicyIntentStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/PolicyStudio/PostgresPolicyIntentStore.cs new file mode 100644 index 000000000..426796628 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/PolicyStudio/PostgresPolicyIntentStore.cs @@ -0,0 +1,65 @@ +// +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. +// + +using Microsoft.Extensions.Logging; +using StellaOps.AdvisoryAI.Storage.Postgres; +using StellaOps.Infrastructure.Postgres.Repositories; + +namespace StellaOps.AdvisoryAI.PolicyStudio; + +public sealed class PostgresPolicyIntentStore : RepositoryBase, IPolicyIntentStore +{ + public PostgresPolicyIntentStore( + AdvisoryAiDataSource dataSource, + ILogger 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 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); + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs index d56d25f93..4d86296aa 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/Models/RunEvent.cs @@ -3,6 +3,7 @@ // using System.Collections.Immutable; +using System.Text.Json.Serialization; namespace StellaOps.AdvisoryAI.Runs; @@ -163,6 +164,16 @@ public enum RunEventType /// /// Content of a run event (polymorphic). /// +[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; /// diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/PostgresRunStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/PostgresRunStore.cs new file mode 100644 index 000000000..24774c779 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Runs/PostgresRunStore.cs @@ -0,0 +1,365 @@ +// +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. +// + +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, IRunStore +{ + private static readonly ImmutableArray ActiveStatuses = + [ + RunStatus.Created, + RunStatus.Active, + RunStatus.PendingApproval + ]; + + public PostgresRunStore( + AdvisoryAiDataSource dataSource, + ILogger 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 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 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(); + 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 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> GetByStatusAsync( + string tenantId, + ImmutableArray 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> 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> 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 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> QueryRunsAsync( + string tenantId, + string whereClause, + Action 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(); + 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? 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(name, NpgsqlDbType.Array | NpgsqlDbType.Integer) + { + TypedValue = statuses.Value.Select(static status => (int)status).ToArray() + }); + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/ConversationStore.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/ConversationStore.cs index 1e179722e..a0412a5ba 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/ConversationStore.cs +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/ConversationStore.cs @@ -143,6 +143,28 @@ public sealed class ConversationStore : RepositoryBase, IC return entities.Select(MapConversation).ToList(); } + /// + public async Task> 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(); + } + /// public async Task AddTurnAsync( string conversationId, @@ -188,6 +210,33 @@ public sealed class ConversationStore : RepositoryBase, IC return (await GetByIdAsync(conversationId, cancellationToken).ConfigureAwait(false))!; } + /// + public async Task 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); + } + /// public async Task DeleteAsync( string conversationId, @@ -355,9 +404,15 @@ public interface IConversationStore /// Gets conversations for a user. Task> GetByUserAsync(string tenantId, string userId, int limit = 20, CancellationToken cancellationToken = default); + /// Gets conversations for a tenant. + Task> GetByTenantAsync(string tenantId, int limit = 50, CancellationToken cancellationToken = default); + /// Adds a turn to a conversation. Task AddTurnAsync(string conversationId, ConversationTurn turn, CancellationToken cancellationToken = default); + /// Updates the context for a conversation. + Task UpdateContextAsync(string conversationId, ConversationContext context, CancellationToken cancellationToken = default); + /// Deletes a conversation. Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/Migrations/009_ai_runtime_state.sql b/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/Migrations/009_ai_runtime_state.sql new file mode 100644 index 000000000..82af41bbb --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/Migrations/009_ai_runtime_state.sql @@ -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); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/Migrations/010_advisory_ai_runtime_state_extensions.sql b/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/Migrations/010_advisory_ai_runtime_state_extensions.sql new file mode 100644 index 000000000..661e511a8 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/Migrations/010_advisory_ai_runtime_state_extensions.sql @@ -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"); diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/Postgres/AdvisoryAiRuntimeJson.cs b/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/Postgres/AdvisoryAiRuntimeJson.cs new file mode 100644 index 000000000..d5386a453 --- /dev/null +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/Storage/Postgres/AdvisoryAiRuntimeJson.cs @@ -0,0 +1,133 @@ +// +// Copyright (c) StellaOps. Licensed under the BUSL-1.1. +// + +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 value) + => JsonSerializer.Serialize(value, JsonOptions); + + public static T? Deserialize(string? json) + => string.IsNullOrWhiteSpace(json) + ? default + : JsonSerializer.Deserialize(json, JsonOptions); + + public static Run? DeserializeRun(string? json) + => Deserialize(json); + + public static PolicyIntent? DeserializePolicyIntent(string? json) + { + var intent = Deserialize(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 NormalizeDictionary(IReadOnlyDictionary parameters) + { + var normalized = new Dictionary(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(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(); + foreach (var item in enumerable) + { + items.Add(NormalizeValue(item)); + } + + return items; + } +} diff --git a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md index 8ca0fc075..3628b0ce2 100644 --- a/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md +++ b/src/AdvisoryAI/StellaOps.AdvisoryAI/TASKS.md @@ -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). | diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Chat/ChatIntegrationTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Chat/ChatIntegrationTests.cs index 59b113b57..704697e61 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Chat/ChatIntegrationTests.cs +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Chat/ChatIntegrationTests.cs @@ -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>(result); + } + + public Task> 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>(result); @@ -574,6 +584,18 @@ internal sealed class InMemoryConversationStore : IConversationStore return Task.FromResult(updated); } + public Task UpdateContextAsync(string conversationId, ConversationContext context, CancellationToken cancellationToken = default) + { + if (!_conversations.TryGetValue(conversationId, out var conversation)) + { + return Task.FromResult(null); + } + + var updated = conversation with { Context = context, UpdatedAt = DateTimeOffset.UtcNow }; + _conversations[conversationId] = updated; + return Task.FromResult(updated); + } + public Task DeleteAsync(string conversationId, CancellationToken cancellationToken = default) { return Task.FromResult(_conversations.Remove(conversationId)); diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/AdvisoryAiDurableRuntimeTests.cs b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/AdvisoryAiDurableRuntimeTests.cs new file mode 100644 index 000000000..02db4f6a6 --- /dev/null +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/Integration/AdvisoryAiDurableRuntimeTests.cs @@ -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 +{ + 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().Should().BeOfType(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + } + + using (var restartedFactory = CreateDurableFactory(connectionString)) + { + using var restartedClient = CreateAuthorizedClient(restartedFactory, "advisory-ai:view"); + + var consentStatus = await restartedClient.GetFromJsonAsync( + "/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(); + + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + + 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( + $"/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( + $"/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( + 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( + cancellationToken: TestContext.Current.CancellationToken))!; + created.ExplanationId.Should().NotBeNullOrWhiteSpace(); + + using var scope = factory.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + } + + using (var restartedFactory = CreateDurableFactory(connectionString)) + { + using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory:explain advisory-ai:view"); + + var replay = await client.GetFromJsonAsync( + $"/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( + cancellationToken: TestContext.Current.CancellationToken))!; + parsed.Intent.IntentId.Should().NotBeNullOrWhiteSpace(); + + using var scope = factory.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + } + + 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( + 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( + 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().Should().BeOfType(); + } + + using (var restartedFactory = CreateDurableFactory(connectionString)) + { + using var client = CreateAuthorizedClient(restartedFactory, "advisory-ai:operate advisory-ai:view"); + + var run = await client.GetFromJsonAsync( + $"/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>( + "/v1/advisory-ai/runs/pending-approval", + TestContext.Current.CancellationToken); + pending.Should().ContainSingle(item => item.RunId == createdRun.RunId); + + var query = await client.GetFromJsonAsync( + "/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( + 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().Should().BeOfType(); + scope.ServiceProvider.GetRequiredService().Should().BeOfType(); + } + + 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( + "/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( + $"/v1/advisory-ai/conversations/{createdConversation.ConversationId}", + TestContext.Current.CancellationToken); + conversation.Should().NotBeNull(); + conversation!.Turns.Should().HaveCountGreaterThanOrEqualTo(2); + + var conversations = await client.GetFromJsonAsync( + "/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(); + 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(); + 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 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 CreateDurableFactory(string connectionString) + { + return new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseEnvironment("Testing"); + builder.UseSetting("AdvisoryAI:Storage:ConnectionString", connectionString); + }); + } +} diff --git a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/TASKS.md b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/TASKS.md index a4798ce70..9de3ed7d3 100644 --- a/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/TASKS.md +++ b/src/AdvisoryAI/__Tests/StellaOps.AdvisoryAI.Tests/TASKS.md @@ -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. |