From f8195f7bc03d10b7b5d9e7a9389e5a747cfd206a Mon Sep 17 00:00:00 2001 From: Henry Park Date: Sat, 16 May 2026 17:20:11 -0700 Subject: [PATCH] fix(reborn): wire planned profile resolver --- Cargo.lock | 2 + crates/ironclaw_reborn_composition/Cargo.toml | 2 + .../ironclaw_reborn_composition/src/error.rs | 2 + .../src/factory.rs | 15 ++ .../tests/facade_factory.rs | 193 ++++++++++++++++-- 5 files changed, 200 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2478475ef21..79bec520c2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4635,6 +4635,7 @@ name = "ironclaw_reborn_composition" version = "0.1.0" dependencies = [ "async-trait", + "chrono", "deadpool-postgres", "ironclaw_authorization", "ironclaw_extensions", @@ -4643,6 +4644,7 @@ dependencies = [ "ironclaw_host_runtime", "ironclaw_network", "ironclaw_processes", + "ironclaw_reborn", "ironclaw_reborn_event_store", "ironclaw_resources", "ironclaw_run_state", diff --git a/crates/ironclaw_reborn_composition/Cargo.toml b/crates/ironclaw_reborn_composition/Cargo.toml index 21ee59c74a6..f74156b672f 100644 --- a/crates/ironclaw_reborn_composition/Cargo.toml +++ b/crates/ironclaw_reborn_composition/Cargo.toml @@ -45,6 +45,7 @@ ironclaw_host_api = { path = "../ironclaw_host_api" } ironclaw_host_runtime = { path = "../ironclaw_host_runtime" } ironclaw_network = { path = "../ironclaw_network" } ironclaw_processes = { path = "../ironclaw_processes" } +ironclaw_reborn = { path = "../ironclaw_reborn" } ironclaw_reborn_event_store = { path = "../ironclaw_reborn_event_store" } ironclaw_resources = { path = "../ironclaw_resources" } ironclaw_run_state = { path = "../ironclaw_run_state" } @@ -58,6 +59,7 @@ thiserror = "2" tracing = "0.1" [dev-dependencies] +chrono = "0.4" serde_json = "1" tempfile = "3" testcontainers-modules = { version = "0.11", features = ["postgres"] } diff --git a/crates/ironclaw_reborn_composition/src/error.rs b/crates/ironclaw_reborn_composition/src/error.rs index 0c37d18b9a0..db3ad7dfbd3 100644 --- a/crates/ironclaw_reborn_composition/src/error.rs +++ b/crates/ironclaw_reborn_composition/src/error.rs @@ -12,6 +12,8 @@ pub enum RebornBuildError { EmptyProductionTrustPolicy, #[error("reborn composition requires live turn scheduler wake notifier")] MissingTurnRunWakeNotifier, + #[error("reborn planned run-profile resolver build failed: {reason}")] + PlannedRunProfileResolver { reason: String }, #[error("reborn composition failed production validation")] ProductionWiring { report: ironclaw_host_runtime::ProductionWiringReport, diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index 714649bfde9..aa2bb6bbccb 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -8,6 +8,8 @@ use ironclaw_processes::ProcessServices; use ironclaw_resources::InMemoryResourceGovernor; use ironclaw_run_state::{InMemoryApprovalRequestStore, InMemoryRunStateStore}; use ironclaw_trust::HostTrustPolicy; +#[cfg(any(feature = "libsql", feature = "postgres"))] +use ironclaw_turns::InMemoryRunProfileResolver; use ironclaw_turns::{DefaultTurnCoordinator, InMemoryTurnStateStore}; use crate::input::RebornStorageInput; @@ -226,6 +228,17 @@ fn production_wiring( }) } +#[cfg(any(feature = "libsql", feature = "postgres"))] +fn planned_run_profile_resolver() -> Result, RebornBuildError> { + Ok(Arc::new( + ironclaw_reborn::default_planned_run_profile_resolver().map_err(|error| { + RebornBuildError::PlannedRunProfileResolver { + reason: error.to_string(), + } + })?, + )) +} + #[cfg(feature = "libsql")] async fn build_libsql_production( profile: RebornCompositionProfile, @@ -281,6 +294,7 @@ async fn build_libsql_production( .await? .with_libsql_turn_state_store(db) .await? + .with_run_profile_resolver(planned_run_profile_resolver()?) .with_turn_run_wake_notifier(production_wiring.turn_run_wake_notifier); let turn_coordinator: Arc = @@ -343,6 +357,7 @@ async fn build_postgres_production( .await? .with_postgres_turn_state_store(pool) .await? + .with_run_profile_resolver(planned_run_profile_resolver()?) .with_turn_run_wake_notifier(production_wiring.turn_run_wake_notifier); let turn_coordinator: Arc = diff --git a/crates/ironclaw_reborn_composition/tests/facade_factory.rs b/crates/ironclaw_reborn_composition/tests/facade_factory.rs index fb2a9bfa17e..e565d176eaa 100644 --- a/crates/ironclaw_reborn_composition/tests/facade_factory.rs +++ b/crates/ironclaw_reborn_composition/tests/facade_factory.rs @@ -1,35 +1,45 @@ -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] use std::sync::Arc; -#[cfg(feature = "libsql")] -use ironclaw_host_api::{EffectKind, PackageId}; -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] +use chrono::Utc; +#[cfg(feature = "postgres")] +use deadpool_postgres::tokio_postgres; +#[cfg(any(feature = "libsql", feature = "postgres"))] +use ironclaw_host_api::{AgentId, EffectKind, PackageId, ProjectId, TenantId, ThreadId, UserId}; +#[cfg(any(feature = "libsql", feature = "postgres"))] use ironclaw_host_runtime::{ SchedulerTurnRunWakeNotifier, TurnRunExecutor, TurnRunExecutorError, TurnRunScheduler, TurnRunSchedulerConfig, TurnRunSchedulerHandle, }; +#[cfg(any(feature = "libsql", feature = "postgres"))] +use ironclaw_reborn::PLANNED_DEFAULT_PROFILE_ID; +#[cfg(all(feature = "postgres", not(feature = "libsql")))] +use ironclaw_reborn_composition::RebornCompositionProfile; #[cfg(feature = "libsql")] use ironclaw_reborn_composition::{RebornBuildError, RebornCompositionProfile}; use ironclaw_reborn_composition::{RebornBuildInput, RebornReadinessState, build_reborn_services}; -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] use ironclaw_secrets::SecretMaterial; -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] use ironclaw_trust::{AdminConfig, AdminEntry, HostTrustAssignment, HostTrustPolicy}; -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] use ironclaw_turns::{ - InMemoryTurnStateStore, + AcceptedMessageRef, IdempotencyKey, InMemoryTurnStateStore, ReplyTargetBindingRef, + RunProfileRequest, SourceBindingRef, SubmitTurnRequest, SubmitTurnResponse, TurnActor, + TurnScope, runner::{ClaimedTurnRun, TurnRunTransitionPort}, }; -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] fn test_master_key() -> SecretMaterial { - SecretMaterial::from("x".repeat(32)) + SecretMaterial::from("01234567890123456789012345678901") } -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] struct NoopTurnRunExecutor; -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] #[async_trait::async_trait] impl TurnRunExecutor for NoopTurnRunExecutor { async fn execute_claimed_run( @@ -41,7 +51,7 @@ impl TurnRunExecutor for NoopTurnRunExecutor { } } -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] fn production_trust_policy() -> Arc { Arc::new( HostTrustPolicy::new(vec![Box::new(AdminConfig::with_entries([ @@ -61,7 +71,7 @@ fn empty_trust_policy() -> Arc { Arc::new(HostTrustPolicy::empty()) } -#[cfg(feature = "libsql")] +#[cfg(any(feature = "libsql", feature = "postgres"))] fn live_wake_notifier() -> (Arc, TurnRunSchedulerHandle) { let transitions: Arc = Arc::new(InMemoryTurnStateStore::default()); let executor: Arc = Arc::new(NoopTurnRunExecutor); @@ -80,6 +90,97 @@ async fn libsql_db_at(path: impl AsRef) -> Arc SubmitTurnRequest { + SubmitTurnRequest { + scope: TurnScope::new( + TenantId::new("tenant1").unwrap(), + Some(AgentId::new("agent1").unwrap()), + Some(ProjectId::new("project1").unwrap()), + ThreadId::new(thread).unwrap(), + ), + actor: TurnActor::new(UserId::new("user1").unwrap()), + accepted_message_ref: AcceptedMessageRef::new(format!("message-{thread}")).unwrap(), + source_binding_ref: SourceBindingRef::new("source-web").unwrap(), + reply_target_binding_ref: ReplyTargetBindingRef::new("reply-web").unwrap(), + requested_run_profile: Some(RunProfileRequest::new("default").unwrap()), + idempotency_key: IdempotencyKey::new(idempotency_key).unwrap(), + received_at: Utc::now(), + } +} + +#[cfg(feature = "postgres")] +async fn postgres_pool_or_skip() -> Option<( + testcontainers_modules::testcontainers::ContainerAsync< + testcontainers_modules::postgres::Postgres, + >, + deadpool_postgres::Pool, + String, +)> { + let (container, database_url) = start_postgres_container().await?; + let config: tokio_postgres::Config = database_url + .parse() + .expect("testcontainer database URL must parse"); + let manager = deadpool_postgres::Manager::new(config, tokio_postgres::NoTls); + let pool = deadpool_postgres::Pool::builder(manager) + .max_size(4) + .build() + .expect("Postgres pool must build"); + let _connection = pool + .get() + .await + .expect("Postgres testcontainer must accept connections"); + Some((container, pool, database_url)) +} + +#[cfg(feature = "postgres")] +async fn start_postgres_container() -> Option<( + testcontainers_modules::testcontainers::ContainerAsync< + testcontainers_modules::postgres::Postgres, + >, + String, +)> { + use testcontainers_modules::testcontainers::{ImageExt, runners::AsyncRunner}; + + let image = testcontainers_modules::postgres::Postgres::default() + .with_db_name("ironclaw_test") + .with_user("postgres") + .with_password("postgres") + .with_tag("16-alpine"); + + let container = match image.start().await { + Ok(container) => container, + Err(error) => { + eprintln!( + "skipping Postgres composition tests: docker/testcontainers unavailable ({error})" + ); + return None; + } + }; + let host = match container.get_host().await { + Ok(host) => host, + Err(error) => { + eprintln!( + "skipping Postgres composition tests: could not resolve container host ({error})" + ); + return None; + } + }; + let port = match container.get_host_port_ipv4(5432).await { + Ok(port) => port, + Err(error) => { + eprintln!( + "skipping Postgres composition tests: could not resolve container port ({error})" + ); + return None; + } + }; + Some(( + container, + format!("postgres://postgres:postgres@{host}:{port}/ironclaw_test"), + )) +} + #[tokio::test] async fn disabled_returns_empty_services() { let services = build_reborn_services(RebornBuildInput::disabled("test-owner")) @@ -240,6 +341,70 @@ async fn migration_dry_run_validates_libsql_shape() { .await .unwrap(); + let response = services + .turn_coordinator + .as_ref() + .expect("migration dry-run must expose turn coordinator") + .submit_turn(submit_turn_request( + "thread-planned-profile", + "idem-planned-profile", + )) + .await + .unwrap(); + let SubmitTurnResponse::Accepted { + resolved_run_profile_id, + .. + } = response; + assert_eq!(resolved_run_profile_id.as_str(), PLANNED_DEFAULT_PROFILE_ID); + + handle.shutdown().await; + + assert_eq!( + services.readiness.state, + RebornReadinessState::MigrationDryRunValidated + ); + assert!(services.host_runtime.is_some()); + assert!(services.turn_coordinator.is_some()); +} + +#[cfg(feature = "postgres")] +#[tokio::test] +async fn migration_dry_run_validates_postgres_planned_turn_profile() { + let Some((_container, pool, database_url)) = postgres_pool_or_skip().await else { + return; + }; + let (notifier, handle) = live_wake_notifier(); + + let services = build_reborn_services( + RebornBuildInput::postgres( + RebornCompositionProfile::MigrationDryRun, + "test-owner", + pool, + SecretMaterial::from(database_url), + test_master_key(), + ) + .with_production_trust_policy(production_trust_policy()) + .with_turn_run_wake_notifier(notifier), + ) + .await + .unwrap(); + + let response = services + .turn_coordinator + .as_ref() + .expect("migration dry-run must expose turn coordinator") + .submit_turn(submit_turn_request( + "thread-postgres-planned-profile", + "idem-postgres-planned-profile", + )) + .await + .unwrap(); + let SubmitTurnResponse::Accepted { + resolved_run_profile_id, + .. + } = response; + assert_eq!(resolved_run_profile_id.as_str(), PLANNED_DEFAULT_PROFILE_ID); + handle.shutdown().await; assert_eq!(