From 258886ba394be4b72f686da20ffbef6c6b0d9c58 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:03:04 +0200 Subject: [PATCH 1/2] mason: consolidate helpers and fence production test support Share hex, poison-tolerant locking, position conversion, refusal encoding, settlement and clock helpers without changing their contracts. Narrow internal APIs and remove unused mock controls. Test-only hosts, catalogs, selectors, resolvers, polling helpers and harnesses require explicit test support; aliased development dependencies preserve package-qualified feature flags. Drop the ownership boolean ignored by every production configure_flow endpoint. Test recordings retain scope and lifecycle assertions but no longer assert the vacuous forwarded flag. Batch health token windows in one statement, prove unauthorized fetches do not resolve, and distinguish listener retry errors from fatal errors. Reduce the identified oversized functions while keeping commit and hook ordering, and retain an independent literal oracle when sharing schedule ID formatting. Production staging rejects retained test-support markers, with release-byte positive and negative controls and an offline script test. Re-anchor extracted guards and prove 18 controls on their declared hosts. Workspace tests and both warning-denying clippy configurations passed on Linux and macOS; final helper edits have package-scoped follow-up coverage. Windows verification is requested separately. --- Cargo.lock | 4 + README.md | 10 +- crates/basal-core/Cargo.toml | 7 + crates/basal-core/src/audit.rs | 5 +- crates/basal-core/src/authorize.rs | 14 +- crates/basal-core/src/codemode/model_tests.rs | 3 +- crates/basal-core/src/codemode/supervisor.rs | 6 +- .../src/codemode/supervisor_tests.rs | 11 +- crates/basal-core/src/decisions.rs | 8 +- crates/basal-core/src/driver.rs | 187 +++++++----- crates/basal-core/src/gate.rs | 2 +- crates/basal-core/src/grant_loss.rs | 4 +- crates/basal-core/src/grant_loss_tests.rs | 2 +- crates/basal-core/src/ids.rs | 5 +- crates/basal-core/src/install.rs | 1 + crates/basal-core/src/journal.rs | 4 +- crates/basal-core/src/kv.rs | 3 +- crates/basal-core/src/lib.rs | 2 + crates/basal-core/src/manifest.rs | 12 + crates/basal-core/src/model.rs | 9 +- crates/basal-core/src/reconcile.rs | 10 +- crates/basal-core/src/runs.rs | 26 +- crates/basal-core/src/runtime.rs | 20 +- crates/basal-core/src/schedule/config.rs | 4 + crates/basal-core/src/schedule/mod.rs | 5 +- crates/basal-core/src/store.rs | 7 +- crates/basal-core/src/test_support.rs | 16 + crates/basal-core/src/tokens.rs | 145 +++++++-- crates/basal-core/tests/store_dir_mode.rs | 12 +- crates/basal-host/Cargo.toml | 5 + crates/basal-host/src/broca/mod.rs | 24 +- crates/basal-host/src/broca/subc.rs | 21 +- crates/basal-host/src/builtins/mod.rs | 21 +- crates/basal-host/src/builtins/net.rs | 10 + crates/basal-host/src/catalog.rs | 18 +- crates/basal-host/src/cingulate_consent.rs | 1 + crates/basal-host/src/consent.rs | 28 +- crates/basal-host/src/core_consent.rs | 1 + crates/basal-host/src/core_host.rs | 16 +- crates/basal-host/src/flow_scope.rs | 32 +- crates/basal-host/src/lib.rs | 58 +++- crates/basal-host/src/mock.rs | 52 +--- crates/basal-host/src/routing.rs | 30 +- crates/basal-host/src/selector.rs | 6 +- crates/basal-host/src/subc_catalog.rs | 4 - crates/basal-host/src/transport.rs | 278 +++++++++--------- crates/basal-host/tests/flow_scope_routes.rs | 37 ++- crates/basal-module/Cargo.toml | 6 +- crates/basal-module/src/acceptance_tests.rs | 4 +- crates/basal-module/src/card.rs | 4 +- crates/basal-module/src/engine.rs | 1 + crates/basal-module/src/events.rs | 10 +- crates/basal-module/src/fatal.rs | 2 +- crates/basal-module/src/harness.rs | 150 ++++++---- crates/basal-module/src/harness_transport.rs | 24 +- crates/basal-module/src/lib.rs | 1 + crates/basal-module/src/main.rs | 3 +- crates/basal-module/src/module.rs | 2 +- crates/basal-module/src/ops.rs | 80 +++-- crates/basal-module/src/pool.rs | 8 +- crates/basal-module/src/serve.rs | 7 +- crates/basal-module/src/unconfigured.rs | 6 +- crates/basal-module/tests/caller_binding.rs | 14 +- crates/basal-module/tests/common/mod.rs | 23 +- crates/basal-module/tests/decisions.rs | 4 +- crates/basal-module/tests/ops.rs | 14 +- crates/basal-module/tests/pool.rs | 1 + crates/basal-module/tests/store_dacl.rs | 12 +- crates/basal-proto/src/lib.rs | 1 + crates/basal-proto/src/types.rs | 4 +- crates/basal-proto/src/util.rs | 60 ++++ crates/basal-rig/Cargo.toml | 2 +- .../basal-rig/src/bin/basal-rig-contract.rs | 86 ++++-- crates/basal-testkit/Cargo.toml | 20 ++ crates/basal-testkit/src/bin/journal-bench.rs | 2 +- crates/basal-testkit/src/https.rs | 74 ++++- crates/basal-testkit/src/lib.rs | 4 +- crates/basal-testkit/src/mock.rs | 10 +- crates/basal-testkit/src/parent.rs | 8 +- crates/basal-testkit/tests/builtins_net.rs | 16 +- crates/basal-testkit/tests/dispatch_tokens.rs | 135 +++++++++ crates/basal-testkit/tests/flow_scopes.rs | 19 +- .../basal-testkit/tests/global_model_scope.rs | 4 +- .../basal-testkit/tests/package_instances.rs | 22 ++ crates/basal-testkit/tests/schedule_tick.rs | 13 +- crates/basal-worker/Cargo.toml | 2 +- crates/basal-worker/src/engine.rs | 2 +- mutations.d/core-authority.toml | 16 + mutations.d/core-codemode.toml | 2 +- mutations.d/core-install.toml | 14 +- mutations.d/core-runtime.toml | 12 +- mutations.d/host-builtins.toml | 16 + mutations.d/module.toml | 11 +- mutations.d/scripts.toml | 16 + script/stage.sh | 7 + script/tests/stage_card.py | 30 ++ 96 files changed, 1386 insertions(+), 784 deletions(-) create mode 100644 crates/basal-core/src/test_support.rs create mode 100644 crates/basal-proto/src/util.rs diff --git a/Cargo.lock b/Cargo.lock index b7648832..e899b12d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" name = "basal-core" version = "0.1.0" dependencies = [ + "basal-core", "basal-host", "basal-proto", "blake3", @@ -114,6 +115,7 @@ dependencies = [ name = "basal-host" version = "0.1.0" dependencies = [ + "basal-host", "basal-launch", "basal-proto", "cortexkit-resource-busy", @@ -147,6 +149,7 @@ dependencies = [ "basal-core", "basal-host", "basal-launch", + "basal-module", "basal-proto", "basal-testkit", "base64", @@ -209,6 +212,7 @@ dependencies = [ "basal-host", "basal-launch", "basal-proto", + "basal-testkit", "blake3", "jiff", "libc", diff --git a/README.md b/README.md index 6db9d5eb..b4f922ce 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,14 @@ CI also refuses Cargo path dependencies that resolve outside this repository and The `rig-kill-hook` feature adds a one-shot kill switch used only by the test rig; a production build never enables it. +Mock hosts, fake providers and test-only runtime helpers require `cfg(test)` or +the owning crate's `test-support` feature. Development dependencies enable that +feature for `cargo test`; production builds do not enable it. The harness and +testkit binaries also require `test-support` when built by hand, for example +`cargo build -p basal-module --bin ck-basal-harness --features test-support`. +Production staging rejects the retained test-support marker as well as the rig +kill-hook marker, even if the linker removes unused test doubles. + ## Mutation proofs A passing safety test proves little until it has been seen to fail. [`mutations.d/`](mutations.d) is the checked-in catalogue of source edits and the exact full libtest paths that must catch them, one TOML file per crate or area. The shared `ckdev-mutate` runner builds each mutant separately, runs its target's tests, requires the named test to fail, and restores the saved source bytes and checks `Cargo.lock`. The existing proofs name one guard each; other tests may also catch the mutant (`only = false`). Install the reviewed, immutable revision (ckdev-mutate 0.12.0): @@ -66,7 +74,7 @@ The runner reads every visible `*.toml` file directly inside `mutations.d/`, in Moving rows between files is not a change to any proof, and `ckdev-mutate run --diff` does not select a row only because it moved. `python3 script/mutation_catalogue.py --same-rows-as ` compares every row with the catalogue at a Git revision, field for field, and fails on any added, removed or changed row, so a reorganisation can show it moved rows without editing them. -New rows can narrow two things the runner otherwise applies to a whole file or a whole target; the existing rows use neither: +Rows can narrow two things the runner otherwise applies to a whole file or a whole target; the catalogue uses both `scope` and `select`: - **Anchor scope.** `scope = { after = "fn verify_signature(", before = "\nfn " }` bounds where `old` may match. `after` must occur exactly once in the file, and the region starts right after it; the region ends at the first `before` after that, or at the end of the file when `before` is omitted. `old` must occur exactly once inside the region, though it may appear elsewhere in the file. Use it when the anchor text could also appear in another function: a file-wide anchor that still matches once after a refactor could otherwise edit the wrong code. `check` reports a missing or ambiguous boundary, or an `old` that is not unique in the region. A row has one scope, so all its `edits` must be in one file. `prove --scope-after [--scope-before ]` writes it. - **Run set.** `select = { filter = "trust_roster_" }` runs only the target's tests whose full name contains that literal text (not a regex), and `select = { tests = ["tests::a", "tests::b"] }` runs exactly the listed full names. The set must contain every `expect_red` name and at least one other test that executes; otherwise use `select = "expected"`. With `only = true`, a failure of any other test in the set grades `WRONG_TEST`; tests outside the set do not run, and `--broad` does not widen it. Command rows (`runner = "command"`) and HUB rows (rows with `hub` and `hub_targets`, described below) cannot use a run set. `prove --select-filter ` or `prove --select-tests ,` writes it. diff --git a/crates/basal-core/Cargo.toml b/crates/basal-core/Cargo.toml index e1e17176..35aec411 100644 --- a/crates/basal-core/Cargo.toml +++ b/crates/basal-core/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true publish.workspace = true rust-version.workspace = true +[features] +test-support = ["basal-host/test-support"] + # basal-core owns every durable thing and talks to hosts only through the Host # trait. It never depends on basal-worker: workers are separate processes # reached through the WorkerChannel trait. @@ -29,3 +32,7 @@ sha2.workspace = true serde_json = { workspace = true, features = ["float_roundtrip"] } subc-protocol.workspace = true tracing.workspace = true + +[dev-dependencies] +# Use a distinct name so package-qualified feature flags still name the package. +basal-core-test-support = { package = "basal-core", path = ".", features = ["test-support"] } diff --git a/crates/basal-core/src/audit.rs b/crates/basal-core/src/audit.rs index b6374458..9d44c3ca 100644 --- a/crates/basal-core/src/audit.rs +++ b/crates/basal-core/src/audit.rs @@ -11,7 +11,7 @@ use basal_proto::{ArgsDigest, CallKind}; use rusqlite::{Connection, Transaction, params}; -use crate::error::{CoreError, Result}; +use crate::error::Result; use crate::model::to_u64; /// The outcome recorded for a call that was allowed. @@ -30,8 +30,7 @@ pub fn record( outcome: &str, at: i64, ) -> Result<()> { - let position = - i64::try_from(position).map_err(|_| CoreError::Invalid(format!("position {position}")))?; + let position = crate::model::position_i64(position)?; tx.execute( "INSERT INTO call_audit (run_id, position, flow_id, op, args_digest, outcome, at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", diff --git a/crates/basal-core/src/authorize.rs b/crates/basal-core/src/authorize.rs index d265d296..0bd42181 100644 --- a/crates/basal-core/src/authorize.rs +++ b/crates/basal-core/src/authorize.rs @@ -53,12 +53,7 @@ impl Refusal { /// The rejection codes basal journals for calls it refuses. pub mod codes { - /// Not allowed by the approved manifest or by the flow profile. - pub const DENIED: &str = "denied"; - /// Arguments that are not JSON or do not have the call's shape. - pub const INVALID_ARGUMENTS: &str = "invalid_arguments"; - /// An argument over the byte cap, refused before it was parsed. - pub const TOO_LARGE: &str = "too_large"; + pub use basal_host::builtins::codes::{DENIED, INVALID_ARGUMENTS, TOO_LARGE}; /// The flow is disabled: it makes no new calls outside its VM. pub const FLOW_DISABLED: &str = "flow_disabled"; /// The flow's dispatch budget for the current window is spent. @@ -236,8 +231,11 @@ pub fn check( Ok(()) } CallKind::Primitive(p) => match p { - Primitive::Now | Primitive::Random => Ok(()), - Primitive::KvGet | Primitive::KvSet | Primitive::KvDelete => Ok(()), + Primitive::Now + | Primitive::Random + | Primitive::KvGet + | Primitive::KvSet + | Primitive::KvDelete => Ok(()), Primitive::InstanceEnd => { if manifest.params.is_some() { Ok(()) diff --git a/crates/basal-core/src/codemode/model_tests.rs b/crates/basal-core/src/codemode/model_tests.rs index e524e62c..5c7859e7 100644 --- a/crates/basal-core/src/codemode/model_tests.rs +++ b/crates/basal-core/src/codemode/model_tests.rs @@ -89,10 +89,9 @@ impl Host for CommittedBroca { fn configure_flow( &self, flow_id: &str, - agent_owned: bool, scope: Option, ) { - self.host.configure_flow(flow_id, agent_owned, scope); + self.host.configure_flow(flow_id, scope); } fn classify(&self, kind: &CallKind) -> CallClass { self.host.classify(kind) diff --git a/crates/basal-core/src/codemode/supervisor.rs b/crates/basal-core/src/codemode/supervisor.rs index a4fad0ef..03ff43ab 100644 --- a/crates/basal-core/src/codemode/supervisor.rs +++ b/crates/basal-core/src/codemode/supervisor.rs @@ -590,11 +590,11 @@ impl Driver { } self.shared .transport - .configure_flow(&self.key(), false, Some(self.start.scope.clone())); + .configure_flow(&self.key(), Some(self.start.scope.clone())); if let Some(models) = self.shared.models.lock().unwrap().as_ref() { models .host - .configure_flow(&self.key(), false, Some(self.start.scope.clone())); + .configure_flow(&self.key(), Some(self.start.scope.clone())); } let scope_events = self.event_tx.clone(); self.shared.transport.watch_run_scope( @@ -1381,7 +1381,7 @@ fn terminate( .write(|tx| store::settle_pending_calls(tx, id, at))?; shared .transport - .configure_flow(&format!("codemode:{id}"), false, None); + .configure_flow(&format!("codemode:{id}"), None); shared .store .write(|tx| store::commit_terminal(tx, id, &terminal, at))?; diff --git a/crates/basal-core/src/codemode/supervisor_tests.rs b/crates/basal-core/src/codemode/supervisor_tests.rs index c409998b..28a32288 100644 --- a/crates/basal-core/src/codemode/supervisor_tests.rs +++ b/crates/basal-core/src/codemode/supervisor_tests.rs @@ -59,7 +59,7 @@ type Provider = dyn Fn(&str, Value, &str) -> std::result::Result, events: Arc>>, - registrations: Mutex)>>, + registrations: Mutex)>>, catalog_error: AtomicBool, broca_opted_in: AtomicBool, readiness: Mutex>, @@ -95,11 +95,11 @@ impl Transport for FakeTransport { ) } } - fn configure_flow(&self, key: &str, agent_owned: bool, scope: Option) { + fn configure_flow(&self, key: &str, scope: Option) { self.registrations .lock() .unwrap() - .push((key.into(), agent_owned, scope.clone())); + .push((key.into(), scope.clone())); if scope.is_some() { self.events.lock().unwrap().push("register".into()); } else { @@ -707,14 +707,13 @@ fn supervisor_registers_routes_records_before_delivery_and_releases() { let registrations = f.host.registrations.lock().unwrap(); assert_eq!(registrations.len(), 2); assert_eq!(registrations[0].0, "codemode:r"); - assert!(!registrations[0].1); - let scope = registrations[0].2.as_ref().unwrap(); + let scope = registrations[0].1.as_ref().unwrap(); assert_eq!( serde_json::to_value(&scope.selector).unwrap(), json!({"owner":{"kind":"reserved","module_id":"core"},"ref":"scope-r","epoch":7}) ); assert!(scope.targets.contains("notes")); - assert_eq!(registrations[1], ("codemode:r".into(), false, None)); + assert_eq!(registrations[1], ("codemode:r".into(), None)); f.assert_order(); } diff --git a/crates/basal-core/src/decisions.rs b/crates/basal-core/src/decisions.rs index 39706c49..b58c7348 100644 --- a/crates/basal-core/src/decisions.rs +++ b/crates/basal-core/src/decisions.rs @@ -462,13 +462,9 @@ pub fn all(conn: &Connection) -> Result> { select(conn, "ORDER BY seq", []) } -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} +use basal_proto::util::hex; -fn pos(p: u64) -> Result { - i64::try_from(p).map_err(|_| CoreError::Invalid(format!("position {p}"))) -} +use crate::model::position_i64 as pos; /// Writes or updates the reconcile card intent of every unknown call of /// every run in `needs_reconcile`. Cheap when nothing changed: it reads the diff --git a/crates/basal-core/src/driver.rs b/crates/basal-core/src/driver.rs index 2f0b6e98..4a3db37d 100644 --- a/crates/basal-core/src/driver.rs +++ b/crates/basal-core/src/driver.rs @@ -577,7 +577,7 @@ impl Activation<'_> { /// operator reconciled as not applied: sent again with the same key; /// - any other mutation: its outcome cannot be proven, so the run stops /// in `needs_reconcile` naming it. - fn recover_calls(&mut self) -> Result { + fn recover_calls(&self) -> Result { let run_id = self.lease.run_id.clone(); let (rows, waiting) = self.rt.store().read(|c| { Ok(( @@ -764,6 +764,16 @@ impl Activation<'_> { Err(refusal) => return self.refuse(&call, &flow_id, &refusal), }; let class = self.rt.class_of(&call.kind, prepared.args.as_ref()); + self.commit_host_call(call, flow_id, prepared, class) + } + + fn commit_host_call( + &mut self, + call: HostCall, + flow_id: String, + prepared: Prepared, + class: StoredClass, + ) -> Result { if matches!( call.kind, CallKind::Primitive(Primitive::Llm | Primitive::Classify) @@ -781,84 +791,8 @@ impl Activation<'_> { }; let audited_at = self.rt.config.clock.now_ms(); match class { - StoredClass::Sync => { - let (settlement, value, clock) = self.sync_value(&call.kind)?; - let outcome = self.rt.store().write(|tx| { - journal::insert_call(tx, &self.lease, &flow_id, &new)?; - audit::record( - tx, - &flow_id, - &self.lease.run_id, - call.position, - &call.kind, - &ArgsDigest::of(&call.args), - audit::ALLOWED, - audited_at, - )?; - // Re-read the latest clock inside the transaction, so - // the value is monotonic against everything committed. - let clock = match (clock, journal::last_clock(tx, &self.lease.run_id)?) { - (Some(c), Some(last)) => Some(c.max(last)), - (c, _) => c, - }; - let value = clock.map_or(value.clone(), number_text); - journal::record_sync(tx, &self.lease, call.position, settlement, &value, clock) - })?; - self.next_position += 1; - self.at(Boundary::SyncCommitted { - position: call.position, - })?; - self.reply(outcome) - } - StoredClass::Local => { - let limits = self.rt.config.kv; - let refused = self.rt.store().write(|tx| { - if install::is_disabled(tx, &flow_id)? { - self.record_refusal(tx, &flow_id, &call, &disabled_refusal())?; - return Ok(true); - } - journal::insert_call(tx, &self.lease, &flow_id, &new)?; - // The effect, its audit row and its outcome commit with - // the journal row: all of them or none. - let outcome = - if matches!(call.kind, CallKind::Primitive(Primitive::InstanceEnd)) { - crate::templates::request_end(tx, &self.lease.run_id, &call.args)? - } else { - kv::apply(tx, &flow_id, &call.kind, &call.args, &limits)? - }; - audit::record( - tx, - &flow_id, - &self.lease.run_id, - call.position, - &call.kind, - &ArgsDigest::of(&call.args), - &outcome_label(&outcome), - audited_at, - )?; - journal::accept_outcome( - tx, - &self.lease.run_id, - call.position, - None, - &outcome, - Source::Local, - )?; - Ok(false) - })?; - self.next_position += 1; - self.at(if refused { - Boundary::RefusalCommitted { - position: call.position, - } - } else { - Boundary::LocalCommitted { - position: call.position, - } - })?; - self.rt.shared.signal.bump(); - Ok(Flow::Continue) - } + StoredClass::Sync => self.commit_sync_call(&call, &flow_id, &new, audited_at), + StoredClass::Local => self.commit_local_call(&call, &flow_id, &new, audited_at), _ => { let run_id = self.lease.run_id.clone(); self.rt.register(&run_id, call.position); @@ -905,6 +839,97 @@ impl Activation<'_> { } } + fn commit_local_call( + &mut self, + call: &HostCall, + flow_id: &str, + new: &NewCall<'_>, + audited_at: i64, + ) -> Result { + let limits = self.rt.config.kv; + let refused = self.rt.store().write(|tx| { + if install::is_disabled(tx, flow_id)? { + self.record_refusal(tx, flow_id, call, &disabled_refusal())?; + return Ok(true); + } + journal::insert_call(tx, &self.lease, flow_id, new)?; + // The effect, its audit row and its outcome commit with + // the journal row: all of them or none. + let outcome = if matches!(call.kind, CallKind::Primitive(Primitive::InstanceEnd)) { + crate::templates::request_end(tx, &self.lease.run_id, &call.args)? + } else { + kv::apply(tx, flow_id, &call.kind, &call.args, &limits)? + }; + audit::record( + tx, + flow_id, + &self.lease.run_id, + call.position, + &call.kind, + &ArgsDigest::of(&call.args), + &outcome_label(&outcome), + audited_at, + )?; + journal::accept_outcome( + tx, + &self.lease.run_id, + call.position, + None, + &outcome, + Source::Local, + )?; + Ok(false) + })?; + self.next_position += 1; + self.at(if refused { + Boundary::RefusalCommitted { + position: call.position, + } + } else { + Boundary::LocalCommitted { + position: call.position, + } + })?; + self.rt.shared.signal.bump(); + Ok(Flow::Continue) + } + + fn commit_sync_call( + &mut self, + call: &HostCall, + flow_id: &str, + new: &NewCall<'_>, + audited_at: i64, + ) -> Result { + let (settlement, value, clock) = self.sync_value(&call.kind)?; + let outcome = self.rt.store().write(|tx| { + journal::insert_call(tx, &self.lease, flow_id, new)?; + audit::record( + tx, + flow_id, + &self.lease.run_id, + call.position, + &call.kind, + &ArgsDigest::of(&call.args), + audit::ALLOWED, + audited_at, + )?; + // Re-read the latest clock inside the transaction, so + // the value is monotonic against everything committed. + let clock = match (clock, journal::last_clock(tx, &self.lease.run_id)?) { + (Some(c), Some(last)) => Some(c.max(last)), + (c, _) => c, + }; + let value = clock.map_or(value.clone(), number_text); + journal::record_sync(tx, &self.lease, call.position, settlement, &value, clock) + })?; + self.next_position += 1; + self.at(Boundary::SyncCommitted { + position: call.position, + })?; + self.reply(outcome) + } + /// Checks a new call's argument size and its grant in the approved /// manifest, then selects a model and builds its clamped request. The /// selector runs before the intent transaction, so refusal sends no model work. @@ -1127,7 +1152,7 @@ impl Activation<'_> { /// fails the run before anything about the call is written, refusal /// included: a refusal would itself add a journal row. fn check_run_limits( - &mut self, + &self, call: &HostCall, request: Option<&JsonText>, ) -> Result> { @@ -1442,7 +1467,7 @@ impl Activation<'_> { } } - fn on_finished(&mut self, result: ActivationResult) -> Result { + fn on_finished(&self, result: ActivationResult) -> Result { match result { ActivationResult::Completed { value } => { let exit = Exit::Succeeded { diff --git a/crates/basal-core/src/gate.rs b/crates/basal-core/src/gate.rs index 95c8f944..9910b29e 100644 --- a/crates/basal-core/src/gate.rs +++ b/crates/basal-core/src/gate.rs @@ -237,7 +237,7 @@ impl Runtime { } } } - self.shared.host.configure_flow(flow_id, true, scope); + self.shared.host.configure_flow(flow_id, scope); self.clear_backoff(&run.run_id); return Ok(Gate::Open); } diff --git a/crates/basal-core/src/grant_loss.rs b/crates/basal-core/src/grant_loss.rs index 6d2295b4..f353def8 100644 --- a/crates/basal-core/src/grant_loss.rs +++ b/crates/basal-core/src/grant_loss.rs @@ -341,9 +341,7 @@ impl Runtime { .filter(|owner| { *owner != crate::decisions::OPERATOR_ACTOR && *owner != "local:unverified" }); - self.shared - .host - .configure_flow(&loss.flow_id, agent.is_some(), Some(scope)); + self.shared.host.configure_flow(&loss.flow_id, Some(scope)); Ok(self .shared .host diff --git a/crates/basal-core/src/grant_loss_tests.rs b/crates/basal-core/src/grant_loss_tests.rs index 7793797e..ddb3de09 100644 --- a/crates/basal-core/src/grant_loss_tests.rs +++ b/crates/basal-core/src/grant_loss_tests.rs @@ -47,7 +47,7 @@ impl Host for ProbeHost { } Ok(self.yes.load(Ordering::SeqCst)) } - fn configure_flow(&self, flow: &str, _: bool, scope: Option) { + fn configure_flow(&self, flow: &str, scope: Option) { assert!(scope.is_some()); *lock(&self.configured) = Some(flow.into()); } diff --git a/crates/basal-core/src/ids.rs b/crates/basal-core/src/ids.rs index f642a9ce..e6d09098 100644 --- a/crates/basal-core/src/ids.rs +++ b/crates/basal-core/src/ids.rs @@ -11,10 +11,7 @@ use crate::schema::JOURNAL_FORMAT; /// every recorded call look divergent. pub const ARGS_DIGEST_FORMAT: &str = "blake3-exact-bytes-v1"; -/// Lowercase hex, as core writes a code hash. -pub fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} +pub use basal_proto::util::hex; /// BLAKE3 of the exact script and manifest bytes. The script's length comes /// first so that moving bytes between the two cannot produce the same hash. diff --git a/crates/basal-core/src/install.rs b/crates/basal-core/src/install.rs index ac1110c1..d54d5dd5 100644 --- a/crates/basal-core/src/install.rs +++ b/crates/basal-core/src/install.rs @@ -1054,6 +1054,7 @@ impl Runtime { } /// Enables a disabled flow again. Operator only. + #[cfg(any(test, feature = "test-support"))] pub fn enable_flow(&self, flow_id: &str) -> std::result::Result { let now = self.config().clock.now_ms(); decide(self, |tx| enable(tx, flow_id, now)) diff --git a/crates/basal-core/src/journal.rs b/crates/basal-core/src/journal.rs index f82e5bab..5f4dbadf 100644 --- a/crates/basal-core/src/journal.rs +++ b/crates/basal-core/src/journal.rs @@ -31,9 +31,7 @@ use crate::model::{ use crate::runs::{FENCE, Lease}; use crate::store::now_ms; -fn pos(p: u64) -> Result { - i64::try_from(p).map_err(|_| CoreError::Invalid(format!("position {p}"))) -} +use crate::model::position_i64 as pos; /// Every call row of a run, in position order. pub fn rows(conn: &Connection, run_id: &str) -> Result> { diff --git a/crates/basal-core/src/kv.rs b/crates/basal-core/src/kv.rs index 91d29ce8..6a1b7093 100644 --- a/crates/basal-core/src/kv.rs +++ b/crates/basal-core/src/kv.rs @@ -50,8 +50,7 @@ pub fn is_kv(kind: &CallKind) -> bool { /// A rejection outcome with a code the script can branch on. pub fn rejection(code: &str, message: &str) -> HostOutcome { HostOutcome::rejected( - JsonText::new(json!({"message": message, "code": code}).to_string()) - .unwrap_or_else(|_| JsonText::null()), + basal_host::rejection_text(code, message).unwrap_or_else(|_| JsonText::null()), ) } diff --git a/crates/basal-core/src/lib.rs b/crates/basal-core/src/lib.rs index 92519240..2860d749 100644 --- a/crates/basal-core/src/lib.rs +++ b/crates/basal-core/src/lib.rs @@ -67,6 +67,8 @@ pub mod schema; pub mod store; pub mod template_events; pub mod templates; +#[cfg(any(test, feature = "test-support"))] +pub mod test_support; pub mod tokens; pub use admission::{Admission, TriggerSpec}; diff --git a/crates/basal-core/src/manifest.rs b/crates/basal-core/src/manifest.rs index 9eb49f03..1250c8b1 100644 --- a/crates/basal-core/src/manifest.rs +++ b/crates/basal-core/src/manifest.rs @@ -647,6 +647,10 @@ impl Manifest { }); } } + self.check_targets() + } + + fn check_targets(&self) -> Result<(), ManifestError> { if let Some(audience) = &self.audience { match audience { Audience::Global {} => {} @@ -718,6 +722,10 @@ impl Manifest { } check_list("facts.targets", facts.targets.iter().cloned())?; } + self.check_host_grants() + } + + fn check_host_grants(&self) -> Result<(), ManifestError> { if let Some(llm) = &self.llm { if llm.iq > 100 { return Err(invalid("llm.iq", "must be between 0 and 100")); @@ -809,6 +817,10 @@ impl Manifest { } check_list("net.fetch", net.fetch.iter().map(|f| f.host.clone()))?; } + self.check_runtime_limits() + } + + fn check_runtime_limits(&self) -> Result<(), ManifestError> { if let Some(p) = &self.placement && (p.is_empty() || p.len() > MAX_NAME_BYTES || p.chars().any(char::is_control)) { diff --git a/crates/basal-core/src/model.rs b/crates/basal-core/src/model.rs index d20ae646..c1df6179 100644 --- a/crates/basal-core/src/model.rs +++ b/crates/basal-core/src/model.rs @@ -154,11 +154,10 @@ impl DispatchState { } } -pub fn settlement_str(s: Settlement) -> &'static str { - match s { - Settlement::Fulfilled => "fulfilled", - Settlement::Rejected => "rejected", - } +pub use basal_proto::util::settlement_str; + +pub(crate) fn position_i64(position: u64) -> Result { + i64::try_from(position).map_err(|_| CoreError::Invalid(format!("position {position}"))) } pub fn parse_settlement(s: &str) -> Result { diff --git a/crates/basal-core/src/reconcile.rs b/crates/basal-core/src/reconcile.rs index 9fe32315..98d63a29 100644 --- a/crates/basal-core/src/reconcile.rs +++ b/crates/basal-core/src/reconcile.rs @@ -68,9 +68,7 @@ pub(crate) fn audit_answer( detail: &str, elicitation_id: Option<&str>, ) -> Result<()> { - let position = position - .map(|p| i64::try_from(p).map_err(|_| CoreError::Invalid(format!("position {p}")))) - .transpose()?; + let position = position.map(crate::model::position_i64).transpose()?; tx.execute( "INSERT INTO audit (at, actor, action, run_id, position, detail, elicitation_id) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", @@ -152,11 +150,7 @@ pub(crate) fn resolve( tx.execute( "UPDATE journal SET dispatch = 'not_applied' WHERE run_id = ?1 \ AND position = ?2 AND settlement IS NULL", - params![ - run_id, - i64::try_from(position) - .map_err(|_| CoreError::Invalid(format!("position {position}")))? - ], + params![run_id, crate::model::position_i64(position)?], )?; audit("reconcile.not_applied", "")?; } diff --git a/crates/basal-core/src/runs.rs b/crates/basal-core/src/runs.rs index 99a49ee0..0c2c7449 100644 --- a/crates/basal-core/src/runs.rs +++ b/crates/basal-core/src/runs.rs @@ -60,7 +60,7 @@ impl Lease { } } -pub fn load(conn: &Connection, run_id: &str) -> Result { +pub(crate) fn load(conn: &Connection, run_id: &str) -> Result { conn.query_row( &format!("SELECT {RUN_COLUMNS} FROM runs WHERE run_id = ?1"), [run_id], @@ -70,7 +70,7 @@ pub fn load(conn: &Connection, run_id: &str) -> Result { .ok_or_else(|| CoreError::NoSuchRun(run_id.to_owned()))? } -pub fn state(conn: &Connection, run_id: &str) -> Result { +pub(crate) fn state(conn: &Connection, run_id: &str) -> Result { let s: Option = conn .query_row("SELECT state FROM runs WHERE run_id = ?1", [run_id], |r| { r.get(0) @@ -81,7 +81,7 @@ pub fn state(conn: &Connection, run_id: &str) -> Result { /// The run's readiness sequence: bumped by every outcome, acceptance or /// unknown result recorded for one of its calls. -pub fn readiness(conn: &Connection, run_id: &str) -> Result { +pub(crate) fn readiness(conn: &Connection, run_id: &str) -> Result { let r: i64 = conn.query_row( "SELECT readiness FROM runs WHERE run_id = ?1", [run_id], @@ -97,7 +97,7 @@ pub const HOLDS_SLOT: &str = "('pending', 'running', 'suspended', 'needs_reconci /// The earliest-admitted run of the same flow that was admitted before /// `run_id` and has not reached a terminal state, if any. Runs of one flow /// run one at a time in trigger order, so such a run holds the slot. -pub fn slot_holder(conn: &Connection, run_id: &str) -> Result> { +fn slot_holder(conn: &Connection, run_id: &str) -> Result> { Ok(conn .query_row( &format!( @@ -114,7 +114,7 @@ pub fn slot_holder(conn: &Connection, run_id: &str) -> Result> { /// Pending runs that no earlier run of their flow holds back, in admission /// order, as (run, flow). Runs of one flow run one at a time, so this is at /// most one run per flow: the runs a scheduler of activations may start now. -pub fn startable(conn: &Connection) -> Result> { +pub(crate) fn startable(conn: &Connection) -> Result> { let mut stmt = conn.prepare(&format!( "SELECT r.run_id, r.flow_id FROM runs r WHERE r.state = 'pending' AND NOT EXISTS \ (SELECT 1 FROM runs o WHERE o.flow_id = r.flow_id AND o.admit_seq < r.admit_seq \ @@ -180,7 +180,7 @@ pub fn claim(tx: &Transaction, run_id: &str, owner: &str, clock_ms: i64) -> Resu /// run already dispatched keep their obligations. `needs_reconcile` runs /// are left for the operator: failing one would take away the chance to /// reconcile its unknown call as not applied. -pub fn expire( +pub(crate) fn expire( tx: &Transaction, clock_ms: i64, only: Option<&str>, @@ -210,7 +210,7 @@ pub fn expire( /// Takes a running run away from its current activation, as a lease /// takeover does when the owner is presumed dead. The old activation's next /// fenced write fails. -pub fn take_over( +pub(crate) fn take_over( tx: &Transaction, run_id: &str, owner: &str, @@ -260,7 +260,7 @@ pub fn requeue_orphans(tx: &Transaction) -> Result> { } /// Records the runtime fingerprint at a run's first activation. -pub fn record_fingerprint(tx: &Transaction, lease: &Lease, fingerprint: &str) -> Result<()> { +pub(crate) fn record_fingerprint(tx: &Transaction, lease: &Lease, fingerprint: &str) -> Result<()> { let changed = tx.execute( &format!( "UPDATE runs SET fingerprint = ?4 WHERE run_id = ?1 AND fingerprint IS NULL AND {FENCE}" @@ -311,7 +311,7 @@ pub enum Exit { } /// Moves the run out of `running`, fenced by the activation's lease. -pub fn exit(tx: &Transaction, lease: &Lease, exit: &Exit) -> Result { +pub(crate) fn exit(tx: &Transaction, lease: &Lease, exit: &Exit) -> Result { let g = lease.generation_i64()?; let now = now_ms(); let (changed, state) = match exit { @@ -403,7 +403,7 @@ pub fn exit(tx: &Transaction, lease: &Lease, exit: &Exit) -> Result { /// Removes the activation row a claim wrote, for a claim the install gate /// turned back before the run reached a worker: no activation started. -pub fn forget_claim(tx: &Transaction, lease: &Lease) -> Result<()> { +pub(crate) fn forget_claim(tx: &Transaction, lease: &Lease) -> Result<()> { tx.execute( "DELETE FROM activations WHERE run_id = ?1 AND generation = ?2", params![lease.run_id, lease.generation_i64()?], @@ -452,7 +452,7 @@ pub fn park( )?; let mut settled_awaited = false; for p in awaited { - let p = i64::try_from(*p).map_err(|_| CoreError::Invalid(format!("position {p}")))?; + let p = crate::model::position_i64(*p)?; let settled: bool = tx.query_row( "SELECT EXISTS (SELECT 1 FROM journal WHERE run_id = ?1 AND position = ?2 \ AND settlement IS NOT NULL) OR EXISTS (SELECT 1 FROM mailbox WHERE run_id = ?1 \ @@ -495,7 +495,7 @@ pub fn park( /// Cancels a run that has not ended. Bumps the generation so an activation /// still driving it loses ownership at its next fenced write. Calls already /// dispatched keep their obligations. -pub fn cancel(tx: &Transaction, run_id: &str, detail: &str) -> Result<()> { +pub(crate) fn cancel(tx: &Transaction, run_id: &str, detail: &str) -> Result<()> { let changed = tx.execute( "UPDATE runs SET state = 'cancelled', owner = NULL, generation = generation + 1, \ error_kind = 'cancelled', error_detail = ?2, ended_at = ?3 \ @@ -537,7 +537,7 @@ pub(crate) fn fail_external( } /// Number of activations ever started for the run. -pub fn activation_count(conn: &Connection, run_id: &str) -> Result { +pub(crate) fn activation_count(conn: &Connection, run_id: &str) -> Result { let n: i64 = conn.query_row( "SELECT COUNT(*) FROM activations WHERE run_id = ?1", [run_id], diff --git a/crates/basal-core/src/runtime.rs b/crates/basal-core/src/runtime.rs index b5bc3f17..987071bb 100644 --- a/crates/basal-core/src/runtime.rs +++ b/crates/basal-core/src/runtime.rs @@ -212,7 +212,7 @@ pub(crate) struct Signal { impl Signal { fn lock(&self) -> MutexGuard<'_, u64> { - self.epoch.lock().unwrap_or_else(|p| p.into_inner()) + lock(&self.epoch) } pub(crate) fn current(&self) -> u64 { @@ -308,9 +308,7 @@ impl CompletionSink for Sink { } } -fn lock(m: &Mutex) -> MutexGuard<'_, T> { - m.lock().unwrap_or_else(|p| p.into_inner()) -} +use basal_proto::util::lock; /// The rejection recorded for a call the host could not be reached for, /// when the call provably had no effect. @@ -368,6 +366,7 @@ impl Runtime { /// Another handle on the same store and process state with a different /// owner identity, standing in for a second owner taking runs over. + #[cfg(any(test, feature = "test-support"))] pub fn with_owner(&self, owner: &str) -> Self { let mut config = (*self.config).clone(); config.owner = owner.to_owned(); @@ -1166,6 +1165,7 @@ impl Runtime { /// reports usage with the run's end). Deduplicated by `send_id`: only /// the first report for a reservation is applied, whichever way it /// came. Returns whether this one was applied. + #[cfg(any(test, feature = "test-support"))] pub fn report_usage(&self, send_id: &str, usage: crate::tokens::Usage) -> Result { let now = self.config.clock.now_ms(); self.shared.store.write(|tx| { @@ -1198,6 +1198,18 @@ impl Runtime { crate::tokens::window_usage(c, flow_id, window_ms, start).map(Some) }) } + + /// Current token windows for approved flows, read in one store query. + /// Unknown flows and flows without token grants are omitted. + pub fn token_usage_many( + &self, + flow_ids: &[&str], + at_ms: i64, + ) -> Result> { + self.shared + .store + .read(|c| crate::tokens::window_usage_many(c, flow_ids, at_ms)) + } } #[cfg(test)] diff --git a/crates/basal-core/src/schedule/config.rs b/crates/basal-core/src/schedule/config.rs index 24f72124..1d68ba32 100644 --- a/crates/basal-core/src/schedule/config.rs +++ b/crates/basal-core/src/schedule/config.rs @@ -1,6 +1,7 @@ //! Scheduler settings, the clock it reads, and the points where a test can //! stop it. +#[cfg(any(test, feature = "test-support"))] use std::sync::Mutex; use std::time::Duration; @@ -45,10 +46,12 @@ pub trait Clock: Send + Sync { } /// A clock that moves only when told to. +#[cfg(any(test, feature = "test-support"))] pub struct ManualClock { now: Mutex, } +#[cfg(any(test, feature = "test-support"))] impl ManualClock { pub fn new(now: Timestamp) -> Self { Self { @@ -61,6 +64,7 @@ impl ManualClock { } } +#[cfg(any(test, feature = "test-support"))] impl Clock for ManualClock { fn now(&self) -> Timestamp { *self.now.lock().unwrap_or_else(|p| p.into_inner()) diff --git a/crates/basal-core/src/schedule/mod.rs b/crates/basal-core/src/schedule/mod.rs index 81950ab1..b7f6743a 100644 --- a/crates/basal-core/src/schedule/mod.rs +++ b/crates/basal-core/src/schedule/mod.rs @@ -28,7 +28,9 @@ use std::sync::Arc; use jiff::Timestamp; -pub use config::{Clock, ManualClock, SchedulerConfig, TickHooks, TickPoint}; +#[cfg(any(test, feature = "test-support"))] +pub use config::ManualClock; +pub use config::{Clock, SchedulerConfig, TickHooks, TickPoint}; pub use due::DueError; pub use spec::{ CompiledSchedule, DEFAULT_EACH_CAP, MAX_EACH_CAP, MAX_INTERVAL_SECS, MIN_INTERVAL_SECS, Missed, @@ -121,6 +123,7 @@ impl Scheduler { /// The same scheduler, asking `hooks` at every commit boundary of a tick /// whether to carry on or to stop as if the process had died there. + #[cfg(any(test, feature = "test-support"))] pub fn with_hooks(mut self, hooks: Arc) -> Self { self.hooks = hooks; self diff --git a/crates/basal-core/src/store.rs b/crates/basal-core/src/store.rs index 7f987bd9..131cbd01 100644 --- a/crates/basal-core/src/store.rs +++ b/crates/basal-core/src/store.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::SystemTime; use cortexkit_store::{SqliteStore, StoreError, open_sqlite}; use cortexkit_store_types::{Isolation, StorageBackend, StorageDescriptor}; @@ -67,10 +67,7 @@ pub(crate) fn now_ms() -> i64 { } pub(crate) fn system_now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) + basal_proto::util::unix_millis() as i64 } impl Store { diff --git a/crates/basal-core/src/test_support.rs b/crates/basal-core/src/test_support.rs new file mode 100644 index 00000000..959e25e0 --- /dev/null +++ b/crates/basal-core/src/test_support.rs @@ -0,0 +1,16 @@ +//! Filesystem fixtures shared by core and module integration tests. + +/// Create a process-local scratch directory whose suffix is the current time. +/// This only creates the directory; it never removes a pre-existing path. +pub fn timestamp_scratch(prefix: &str, name: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "basal-{prefix}-{name}-{}-{nanos}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} diff --git a/crates/basal-core/src/tokens.rs b/crates/basal-core/src/tokens.rs index f48bc970..95a3e0f9 100644 --- a/crates/basal-core/src/tokens.rs +++ b/crates/basal-core/src/tokens.rs @@ -38,13 +38,14 @@ use crate::manifest::LlmGrant; /// Added to every input estimate for what the request's bytes do not show: /// the provider's own framing of a request (roles, system wrapping, /// message separators). -pub const INPUT_OVERHEAD_TOKENS: u64 = 256; +const INPUT_OVERHEAD_TOKENS: u64 = 256; /// The output ceiling of a `classify` call: it answers with one label. -pub const CLASSIFY_MAX_OUTPUT: u32 = 64; +pub(crate) const CLASSIFY_MAX_OUTPUT: u32 = 64; /// Token usage as Broca reports it. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[cfg(any(test, feature = "test-support"))] pub struct Usage { /// Fresh input. pub input_tokens: u64, @@ -55,27 +56,16 @@ pub struct Usage { pub cached_input_tokens: u64, } -impl Usage { - /// What counts against the cap. - pub fn capped(&self) -> u64 { - self.input_tokens - .saturating_add(self.cache_write_tokens) - .saturating_add(self.output_tokens) - } -} - fn to_i64(n: u64) -> Result { i64::try_from(n).map_err(|_| CoreError::Invalid(format!("{n} tokens"))) } -fn to_u64(n: i64, what: &str) -> Result { - crate::model::to_u64(n, what) -} +use crate::model::to_u64; /// A model call ready to journal: the exact request to send, and the /// tokens to reserve for it. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Clamped { +pub(crate) struct Clamped { pub request: JsonText, pub max_output: u32, pub reserve: u64, @@ -88,7 +78,7 @@ pub struct Clamped { /// an upper bound on the text's tokens, and JSON quoting and the envelope's /// own fields only add bytes that are not tokens. The fixed overhead covers /// the provider's framing. -pub fn estimate_input(bytes: usize) -> u64 { +pub(crate) fn estimate_input(bytes: usize) -> u64 { (bytes as u64).saturating_add(INPUT_OVERHEAD_TOKENS) } @@ -97,7 +87,7 @@ fn json_string(s: &str) -> String { } /// Parent-assigned call identity, used for the send key and isolated session. -pub struct Identity<'a> { +pub(crate) struct Identity<'a> { pub flow_id: &'a str, pub run_id: &'a str, pub position: u64, @@ -109,7 +99,7 @@ pub struct Identity<'a> { /// the `send_id`, the flow's work class and session, and the clamped /// output ceiling. llm steps get no tools, so a request asking for them is /// refused. -pub fn clamp( +pub(crate) fn clamp( primitive: Primitive, args_text: &JsonText, args: &Value, @@ -184,7 +174,7 @@ pub fn window_start(now_ms: i64, window_ms: i64) -> i64 { /// One reservation to make. #[derive(Debug, Clone)] -pub struct Reservation<'a> { +pub(crate) struct Reservation<'a> { pub send_id: &'a str, pub flow_id: &'a str, pub run_id: &'a str, @@ -199,7 +189,10 @@ pub struct Reservation<'a> { /// what the window already counts (outstanding reservations included) plus /// `amount` exceeds the cap. Runs in the transaction that journals the /// call, so the check and the reservation are one atomic step. -pub fn reserve(tx: &Transaction, r: &Reservation<'_>) -> Result> { +pub(crate) fn reserve( + tx: &Transaction, + r: &Reservation<'_>, +) -> Result> { let start = window_start(r.now_ms, r.window_ms); tx.execute( "INSERT OR IGNORE INTO token_windows (flow_id, window_ms, window_start) VALUES (?1, ?2, ?3)", @@ -229,8 +222,7 @@ pub fn reserve(tx: &Transaction, r: &Reservation<'_>) -> Result) -> Result { +pub(crate) enum Key<'a> { + #[cfg(any(test, feature = "test-support"))] SendId(&'a str), - Call { run_id: &'a str, position: u64 }, + Call { + run_id: &'a str, + position: u64, + }, } /// Replaces a reservation with what was reported. Applied once per send /// id: a reservation already settled is left alone, so a duplicate report /// changes nothing. Returns whether this report was the one applied. -pub fn settle(tx: &Transaction, key: Key<'_>, report: Report, now_ms: i64) -> Result { +pub(crate) fn settle(tx: &Transaction, key: Key<'_>, report: Report, now_ms: i64) -> Result { let row: Option<(String, String, i64, i64, i64)> = match key { + #[cfg(any(test, feature = "test-support"))] Key::SendId(send_id) => tx .query_row( "SELECT send_id, flow_id, window_ms, window_start, reserved FROM token_ledger \ @@ -285,8 +283,7 @@ pub fn settle(tx: &Transaction, key: Key<'_>, report: Report, now_ms: i64) -> Re ) .optional()?, Key::Call { run_id, position } => { - let position = i64::try_from(position) - .map_err(|_| CoreError::Invalid(format!("position {position}")))?; + let position = crate::model::position_i64(position)?; tx.query_row( "SELECT send_id, flow_id, window_ms, window_start, reserved FROM token_ledger \ WHERE run_id = ?1 AND position = ?2 AND state = 'reserved'", @@ -300,6 +297,7 @@ pub fn settle(tx: &Transaction, key: Key<'_>, report: Report, now_ms: i64) -> Re return Ok(false); }; let (usage, mut unreported) = match report { + #[cfg(any(test, feature = "test-support"))] Report::Usage(u) => ( basal_host::TokenUsage { input_tokens: Some(u.input_tokens), @@ -388,7 +386,7 @@ fn saturate_usage(tokens: u64) -> i64 { /// Settles the reservation of the call at (run, position), if it has one /// still open, from the outcome that arrived for it. -pub fn settle_outcome( +pub(crate) fn settle_outcome( tx: &Transaction, run_id: &str, position: u64, @@ -443,7 +441,7 @@ impl WindowUsage { } /// What the window starting at `window_start` counts for the flow. -pub fn window_usage( +pub(crate) fn window_usage( conn: &Connection, flow_id: &str, window_ms: i64, @@ -469,6 +467,15 @@ pub fn window_usage( .optional()?; let (reserved, input, cache_write, output, cached, unreported) = row.unwrap_or((0, 0, 0, 0, 0, 0)); + window_counts( + window_ms, + window_start, + [reserved, input, cache_write, output, cached, unreported], + ) +} + +fn window_counts(window_ms: i64, window_start: i64, counts: [i64; 6]) -> Result { + let [reserved, input, cache_write, output, cached, unreported] = counts; Ok(WindowUsage { window_ms, window_start, @@ -481,6 +488,84 @@ pub fn window_usage( }) } +/// Read approved manifests and their current token windows in one statement. +/// Package instances prefer the package approval, just as `install::approved` +/// does; a missing ledger row still means an empty window, not a missing flow. +pub(crate) fn window_usage_many( + conn: &Connection, + flow_ids: &[&str], + at_ms: i64, +) -> Result> { + let ids = serde_json::json!(flow_ids).to_string(); + let mut stmt = conn.prepare( + "SELECT f.flow_id, COALESCE(p.version, i.version), + COALESCE(p.code_hash, i.code_hash), COALESCE(p.script, i.script), + COALESCE(p.manifest, i.manifest), w.window_ms, w.window_start, + w.reserved, w.input_tokens, w.cache_write_tokens, w.output_tokens, + w.cached_input_tokens, w.unreported_tokens + FROM flows f + LEFT JOIN package_versions p ON p.package=f.package AND p.version=f.approved_version + AND f.removed=0 AND NOT EXISTS ( + SELECT 1 FROM instance_revocations r + WHERE r.flow_id=f.flow_id AND r.version=f.approved_version) + LEFT JOIN installs i ON i.flow_id=f.flow_id AND i.version=f.approved_version + AND i.state='approved' + LEFT JOIN token_windows w ON w.flow_id=f.flow_id + AND w.window_start<=?2 AND (?2-w.window_start)>(5)? == Some(window.window_ms) + && row.get::<_, Option>(6)? == Some(window.window_start) + { + *window = window_counts( + window.window_ms, + window.window_start, + [ + row.get(7)?, + row.get(8)?, + row.get(9)?, + row.get(10)?, + row.get(11)?, + row.get(12)?, + ], + )?; + } + } + Ok(windows + .into_iter() + .filter_map(|(id, window)| window.map(|w| (id, w))) + .collect()) +} + /// Model-call reservations not yet settled, for a run. pub fn open_reservations(conn: &Connection, run_id: &str) -> Result { let n: i64 = conn.query_row( diff --git a/crates/basal-core/tests/store_dir_mode.rs b/crates/basal-core/tests/store_dir_mode.rs index 36a476bf..e6516381 100644 --- a/crates/basal-core/tests/store_dir_mode.rs +++ b/crates/basal-core/tests/store_dir_mode.rs @@ -9,19 +9,9 @@ use basal_core::{Durability, Store}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; -use std::time::{SystemTime, UNIX_EPOCH}; fn scratch(name: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "basal-store-mode-{name}-{}-{nanos}", - std::process::id() - )); - fs::create_dir_all(&dir).unwrap(); - dir + basal_core::test_support::timestamp_scratch("store-mode", name) } struct Cleanup(PathBuf); diff --git a/crates/basal-host/Cargo.toml b/crates/basal-host/Cargo.toml index a7f7113c..eb765fed 100644 --- a/crates/basal-host/Cargo.toml +++ b/crates/basal-host/Cargo.toml @@ -8,6 +8,9 @@ publish.workspace = true rust-version.workspace = true [features] +# In-memory hosts, catalogs, consent planes and deterministic polling helpers. +# Production staging rejects binaries built with this feature. +test-support = [] # Test-only switches, never enabled in a production build. The rig is basal's # isolated test deployment (script/flows-rig.sh): a private subc daemon with # its own modules, whose ck-basal is built with this feature through @@ -43,4 +46,6 @@ basal-launch.workspace = true windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Pipes", "Win32_System_Threading"] } [dev-dependencies] +# Use a distinct name so package-qualified feature flags still name the package. +basal-host-test-support = { package = "basal-host", path = ".", features = ["test-support"] } subc-transport.workspace = true diff --git a/crates/basal-host/src/broca/mod.rs b/crates/basal-host/src/broca/mod.rs index 86320555..4afdac8a 100644 --- a/crates/basal-host/src/broca/mod.rs +++ b/crates/basal-host/src/broca/mod.rs @@ -9,6 +9,7 @@ //! stream event costs latency, never correctness. `run.result` reads through //! Broca's archive, so it answers for archived sessions too. +#[cfg(any(test, feature = "test-support"))] pub mod fake; pub mod subc; pub mod wire; @@ -81,13 +82,7 @@ pub trait Transport: Send + Sync { fn refresh_flow(&self, _identity: &FlowIdentity) -> Result<(), BrocaError> { Ok(()) } - fn configure_flow( - &self, - _flow_id: &str, - _agent_owned: bool, - _scope: Option, - ) { - } + fn configure_flow(&self, _flow_id: &str, _scope: Option) {} fn send(&self, route: &Route, params: &[u8]) -> Result; /// Makes sure a subscription attached at the session's live head is /// open, and that it wakes `BrocaHost::poll` when the session's run @@ -218,9 +213,7 @@ pub struct BrocaHost { codemode_sink: Mutex>>, } -fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { - m.lock().unwrap_or_else(|p| p.into_inner()) -} +use basal_proto::util::lock; impl BrocaHost { pub fn new( @@ -834,13 +827,8 @@ impl Host for BrocaHost { self.scope_checks .store(enabled, std::sync::atomic::Ordering::SeqCst); } - fn configure_flow( - &self, - flow_id: &str, - agent_owned: bool, - scope: Option, - ) { - self.transport.configure_flow(flow_id, agent_owned, scope); + fn configure_flow(&self, flow_id: &str, scope: Option) { + self.transport.configure_flow(flow_id, scope); } fn classify(&self, _: &CallKind) -> CallClass { CallClass::Mutation { @@ -948,7 +936,7 @@ fn rejection(code: &str, detail: &str, usage: Option) -> StoredOutco fn json_text(value: Value) -> JsonText { // This helper only handles small, host-owned errors. Oversized wire values // are checked by StoredOutcome::host and never use this fallback. - JsonText::new(value.to_string()).unwrap_or_else(|_| JsonText::null()) + crate::json_text_or_null(&value) } mod params_bytes { diff --git a/crates/basal-host/src/broca/subc.rs b/crates/basal-host/src/broca/subc.rs index 585da95f..ac9f7f9e 100644 --- a/crates/basal-host/src/broca/subc.rs +++ b/crates/basal-host/src/broca/subc.rs @@ -12,9 +12,7 @@ use std::time::Duration; use subc_client_rs::consumer::ConnectionState; use subc_protocol::BindIdentity; -fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { - m.lock().unwrap_or_else(|p| p.into_inner()) -} +use basal_proto::util::lock; fn error(e: WireError) -> BrocaError { match e { WireError::Typed(refusal) => BrocaError::Flow(refusal), @@ -301,8 +299,7 @@ impl Transport for SubcBrocaTransport { crate::InstallStatus::Active { code_hash, scope } if code_hash == identity.code_hash => { - self.connection - .configure_flow(&identity.flow_id, true, scope); + self.connection.configure_flow(&identity.flow_id, scope); Ok(()) } _ => Err(BrocaError::Unavailable { @@ -311,18 +308,8 @@ impl Transport for SubcBrocaTransport { }), } } - fn configure_flow( - &self, - flow_id: &str, - agent_owned: bool, - scope: Option, - ) { - crate::transport::Transport::configure_flow( - self.connection.as_ref(), - flow_id, - agent_owned, - scope, - ); + fn configure_flow(&self, flow_id: &str, scope: Option) { + crate::transport::Transport::configure_flow(self.connection.as_ref(), flow_id, scope); } fn send(&self, route: &Route, params: &[u8]) -> Result { #[cfg(feature = "rig-kill-hook")] diff --git a/crates/basal-host/src/builtins/mod.rs b/crates/basal-host/src/builtins/mod.rs index ecd96154..c9bd46c9 100644 --- a/crates/basal-host/src/builtins/mod.rs +++ b/crates/basal-host/src/builtins/mod.rs @@ -32,12 +32,11 @@ use basal_proto::{CallKind, JsonText, Primitive}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; -use crate::core_host::{sample, system_now}; -use crate::{ - CallClass, CallRequest, CompletionSink, Dispatched, Host, HostOutcome, TransportError, -}; +use crate::{CallClass, CallRequest, Dispatched, Host, HostOutcome, TransportError}; -pub use net::{HostRule, NetConfig, Resolve, StaticResolver, SystemResolver}; +#[cfg(any(test, feature = "test-support"))] +pub use net::StaticResolver; +pub use net::{HostRule, NetConfig, Resolve, SystemResolver}; /// The rejection codes the built-ins answer with, as the script sees them /// in `e.data.code`. @@ -91,7 +90,7 @@ impl Denial { /// The rejection a script sees. pub fn outcome(&self) -> HostOutcome { - HostOutcome::rejected(text(&json!({ "message": self.message, "code": self.code })).unwrap_or_else(|_| { + HostOutcome::rejected(crate::rejection_text(self.code, &self.message).unwrap_or_else(|_| { JsonText::new(json!({"message":"built-in result exceeds the encoded limit", "code":codes::TOO_LARGE}).to_string()).expect("small host-owned refusal") })) } @@ -591,15 +590,7 @@ impl Host for BuiltinHost { self.dispatch_checked(request, Some(class)) } - fn now_ms(&self) -> f64 { - system_now() - } - - fn random(&self) -> f64 { - sample() - } - - fn attach(&self, _: Arc) {} + crate::live_host_services!(); fn bind_fs_temps(&self, ledger: Arc) { *self.temps.write().unwrap_or_else(|p| p.into_inner()) = Some(ledger); diff --git a/crates/basal-host/src/builtins/net.rs b/crates/basal-host/src/builtins/net.rs index 3a133f36..d1db6020 100644 --- a/crates/basal-host/src/builtins/net.rs +++ b/crates/basal-host/src/builtins/net.rs @@ -27,6 +27,7 @@ #[cfg(test)] mod tests; +#[cfg(any(test, feature = "test-support"))] use std::collections::BTreeMap; #[cfg(any(test, feature = "rig-kill-hook"))] use std::collections::BTreeSet; @@ -565,8 +566,10 @@ fn dns_worker() -> &'static mpsc::SyncSender { /// A fixed table of host names to addresses, for tests. The port each /// entry names is used whatever port the URL asks for. #[derive(Debug, Clone, Default)] +#[cfg(any(test, feature = "test-support"))] pub struct StaticResolver(pub BTreeMap>); +#[cfg(any(test, feature = "test-support"))] impl Resolve for StaticResolver { fn resolve(&self, host: &str, _port: u16) -> std::io::Result> { self.0.get(host).cloned().ok_or_else(|| { @@ -636,6 +639,13 @@ fn unsent(detail: String) -> Failure { } impl Client { + #[cfg_attr( + not(any(test, feature = "rig-kill-hook")), + expect( + clippy::unused_self, + reason = "production always denies private-address exemptions" + ) + )] fn private_host(&self, _host: &str) -> bool { #[cfg(any(test, feature = "rig-kill-hook"))] { diff --git a/crates/basal-host/src/catalog.rs b/crates/basal-host/src/catalog.rs index 9da1a9a8..8459d2a6 100644 --- a/crates/basal-host/src/catalog.rs +++ b/crates/basal-host/src/catalog.rs @@ -8,7 +8,9 @@ //! approved is still refused. In production the catalog is the daemon's //! module catalog; [`MockCatalog`] stands in for it in tests. +#[cfg(any(test, feature = "test-support"))] use std::collections::{BTreeMap, BTreeSet}; +#[cfg(any(test, feature = "test-support"))] use std::sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}; /// Read-only tool used by install validation and the event-body fetch that @@ -91,6 +93,7 @@ pub trait Catalog: Send + Sync { } } +#[cfg(any(test, feature = "test-support"))] #[derive(Default)] struct Entries { flow_capable: BTreeSet, @@ -103,11 +106,13 @@ struct Entries { /// An in-memory catalog. Cloning shares the same entries, so a test can /// change a declaration after a flow was approved and watch the runtime /// react. +#[cfg(any(test, feature = "test-support"))] #[derive(Clone, Default)] pub struct MockCatalog { entries: Arc>, } +#[cfg(any(test, feature = "test-support"))] impl MockCatalog { pub fn set_flow_capable(&self, module: &str, capable: bool) { if capable { @@ -146,17 +151,17 @@ impl MockCatalog { "unmarked", OpDecl { kind: None, - ..query.clone() + ..query }, ); - c.set_op("plexus", "pr.get", query.clone()); + c.set_op("plexus", "pr.get", query); c.set_op("plexus", "pr.comment", mutate.clone()); c.set_op( "plexus", "pr.label", OpDecl { cause_echo: true, - ..mutate.clone() + ..mutate }, ); c.set_op( @@ -164,7 +169,7 @@ impl MockCatalog { "bash", OpDecl { shell_capable: true, - ..mutate.clone() + ..mutate }, ); c.set_event( @@ -208,10 +213,6 @@ impl MockCatalog { .insert((module.to_owned(), op.to_owned()), decl); } - pub fn remove_op(&self, module: &str, op: &str) { - self.write().ops.remove(&(module.to_owned(), op.to_owned())); - } - pub fn set_event(&self, module: &str, name: &str, version: u32, decl: EventDecl) { self.write() .events @@ -235,6 +236,7 @@ impl MockCatalog { } } +#[cfg(any(test, feature = "test-support"))] impl Catalog for MockCatalog { fn workspace_known(&self, id: &str) -> Result { Ok(self.read().workspaces.contains(id)) diff --git a/crates/basal-host/src/cingulate_consent.rs b/crates/basal-host/src/cingulate_consent.rs index 270ac452..41c2fc13 100644 --- a/crates/basal-host/src/cingulate_consent.rs +++ b/crates/basal-host/src/cingulate_consent.rs @@ -30,6 +30,7 @@ impl CingulateConsent { /// Read and apply settlement pages immediately, without a polling timer. /// Saved providers remain pollable when absent from the daemon catalog. + #[cfg(any(test, feature = "test-support"))] pub fn poll_once(&self) -> Result<(), ConsentError> { self.core.poll_once() } diff --git a/crates/basal-host/src/consent.rs b/crates/basal-host/src/consent.rs index ab7fb707..65e8f545 100644 --- a/crates/basal-host/src/consent.rs +++ b/crates/basal-host/src/consent.rs @@ -3,9 +3,12 @@ //! Operator decision cards use the consent provider and deliver their answers //! to an attached [`DecisionSink`] until accepted, including after a restart. +#[cfg(any(test, feature = "test-support"))] use std::collections::BTreeMap; use std::fmt; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::Arc; +#[cfg(any(test, feature = "test-support"))] +use std::sync::{Mutex, MutexGuard}; use crate::{DisabledReason, SinkError, UnknownReason}; @@ -155,11 +158,6 @@ pub struct DecisionCard { } impl DecisionCard { - /// The fail-closed default shown by the consent plane: the one that declines. - pub fn default_option(&self) -> Option<&DecisionOption> { - self.options.iter().find(|o| o.decline) - } - pub fn decision(&self) -> DecisionKind { self.context.kind() } @@ -371,6 +369,7 @@ pub trait Consent: Send + Sync { fn attach(&self, sink: Arc); } +#[cfg(any(test, feature = "test-support"))] #[derive(Default)] struct State { cards: BTreeMap, @@ -379,7 +378,6 @@ struct State { undelivered: Vec, /// Decision cards by deduplication key, with their elicitation ids. decision_cards: BTreeMap, - decision_raises: BTreeMap, withdrawn: Vec, /// Answers to decision cards not yet accepted by the sink. undelivered_answers: Vec, @@ -388,19 +386,20 @@ struct State { } /// An in-memory consent plane for tests. Cloning shares the same state. +#[cfg(any(test, feature = "test-support"))] #[derive(Clone, Default)] pub struct MockConsent { state: Arc>, } +#[cfg(any(test, feature = "test-support"))] impl MockConsent { pub fn new() -> Self { Self::default() } fn lock(&self) -> MutexGuard<'_, State> { - // A panic in another test thread must not make the mock unusable. - self.state.lock().unwrap_or_else(|p| p.into_inner()) + basal_proto::util::lock(&self.state) } /// Every card raised, by card id. @@ -450,15 +449,6 @@ impl MockConsent { .collect() } - /// How many times a decision card was raised under `dedup_key`. - pub fn decision_raise_count(&self, dedup_key: &str) -> usize { - self.lock() - .decision_raises - .get(dedup_key) - .copied() - .unwrap_or(0) - } - pub fn withdrawn_decisions(&self) -> Vec { self.lock().withdrawn.clone() } @@ -529,6 +519,7 @@ impl MockConsent { } } +#[cfg(any(test, feature = "test-support"))] impl Consent for MockConsent { fn withdraw_decision(&self, elicitation_id: &str) -> Result<(), ConsentError> { let mut state = self.lock(); @@ -568,7 +559,6 @@ impl Consent for MockConsent { )); } let key = card.dedup_key.clone().unwrap_or_default(); - *state.decision_raises.entry(key.clone()).or_insert(0) += 1; let next = format!("mock-el-{}", state.decision_cards.len() + 1); let entry = state .decision_cards diff --git a/crates/basal-host/src/core_consent.rs b/crates/basal-host/src/core_consent.rs index 5e672731..247e167a 100644 --- a/crates/basal-host/src/core_consent.rs +++ b/crates/basal-host/src/core_consent.rs @@ -501,6 +501,7 @@ impl CoreConsent { self } /// Also exposed for deterministic tests, which drive pages without sleeping. + #[cfg(any(test, feature = "test-support"))] pub fn poll_once(&self) -> Result<(), ConsentError> { poll(&self.state) } diff --git a/crates/basal-host/src/core_host.rs b/crates/basal-host/src/core_host.rs index 17518421..ada8ddb1 100644 --- a/crates/basal-host/src/core_host.rs +++ b/crates/basal-host/src/core_host.rs @@ -2,8 +2,8 @@ use crate::subc_catalog::CORE; use crate::transport::{Transport, WireError}; use crate::{ - CallClass, CallRequest, CompletionSink, Dispatched, Host, HostOutcome, InstallApproval, - InstallStatus, TransportError, UnknownReason, + CallClass, CallRequest, Dispatched, Host, HostOutcome, InstallApproval, InstallStatus, + TransportError, UnknownReason, }; use basal_proto::{CallKind, JsonText, Primitive}; use serde_json::{Value, json}; @@ -215,13 +215,7 @@ impl Host for CoreHost { .and_then(|v| validate_reply(p, v)), ) } - fn now_ms(&self) -> f64 { - system_now() - } - fn random(&self) -> f64 { - sample() - } - fn attach(&self, _: Arc) {} + crate::live_host_services!(); fn install_status(&self, flow_id: &str, version: u32) -> Result { self.install_status_with_approval(flow_id, version) .map(|(status, _)| status) @@ -269,9 +263,7 @@ impl Host for CoreHost { } } pub(crate) fn system_now() -> f64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0.0, |d| d.as_millis() as f64) + basal_proto::util::unix_millis() as f64 } pub(crate) fn sample() -> f64 { let mut x = 0u64; diff --git a/crates/basal-host/src/flow_scope.rs b/crates/basal-host/src/flow_scope.rs index fe205bdd..dc58c406 100644 --- a/crates/basal-host/src/flow_scope.rs +++ b/crates/basal-host/src/flow_scope.rs @@ -125,12 +125,7 @@ impl ScopedRoutes { self.routes.retain(|_, (_, handle, _)| handle.is_none()); self.connection_generation = self.connection_generation.wrapping_add(1); } - pub fn configure( - &mut self, - flow: &str, - _agent_owned: bool, - scope: Option, - ) -> Vec { + pub fn configure(&mut self, flow: &str, scope: Option) -> Vec { if let Some((_, key)) = self.flows.remove(flow) { let users = self .scope_users @@ -433,7 +428,7 @@ mod tests { fn a_scope_closed_route_is_not_reopened_under_the_same_selector() { let mut routes = ScopedRoutes::::default(); let scope = decode(&fixture()).unwrap().unwrap().selector; - routes.configure("a", true, Some(registered(scope.clone()))); + routes.configure("a", Some(registered(scope.clone()))); let target = subc_protocol::RouteTarget::ManagementSurface { module_id: "broca".into(), }; @@ -443,7 +438,7 @@ mod tests { routes.route("a", &target, |_| panic!("scope close reopened")), Err(WireError::Typed(_)) )); - routes.configure("a", true, Some(registered(scope.clone()))); + routes.configure("a", Some(registered(scope.clone()))); assert!( routes .route("a", &target, |_| panic!( @@ -453,7 +448,7 @@ mod tests { ); let mut next = scope; next.epoch += 1; - routes.configure("a", true, Some(registered(next))); + routes.configure("a", Some(registered(next))); assert_eq!(routes.route("a", &target, |_| Ok(2)).unwrap(), Some(2)); } #[test] @@ -463,7 +458,7 @@ mod tests { let target = subc_protocol::RouteTarget::ManagementSurface { module_id: "broca".into(), }; - routes.configure("a", true, Some(registered(scope.clone()))); + routes.configure("a", Some(registered(scope.clone()))); routes.route("a", &target, |_| Ok(1)).unwrap(); assert_eq!( routes @@ -472,10 +467,7 @@ mod tests { Some(1) ); scope.epoch += 1; - assert_eq!( - routes.configure("a", true, Some(registered(scope))), - vec![1] - ); + assert_eq!(routes.configure("a", Some(registered(scope))), vec![1]); assert_eq!(routes.route("a", &target, |_| Ok(2)).unwrap(), Some(2)); } @@ -486,17 +478,17 @@ mod tests { let target = subc_protocol::RouteTarget::ManagementSurface { module_id: "broca".into(), }; - routes.configure("a", true, Some(scope.clone())); - routes.configure("b", true, Some(scope.clone())); + routes.configure("a", Some(scope.clone())); + routes.configure("b", Some(scope.clone())); routes.route("a", &target, |_| Ok(1)).unwrap(); - assert!(routes.configure("a", true, None).is_empty()); + assert!(routes.configure("a", None).is_empty()); assert_eq!( routes .route("b", &target, |_| panic!("shared handle dropped")) .unwrap(), Some(1) ); - assert_eq!(routes.configure("b", true, None), [1]); + assert_eq!(routes.configure("b", None), [1]); assert!(routes.flows.is_empty()); assert!(routes.scope_users.is_empty()); assert!(routes.routes.is_empty()); @@ -509,7 +501,7 @@ mod tests { let target = subc_protocol::RouteTarget::ManagementSurface { module_id: "broca".into(), }; - routes.configure("a", true, Some(scope.clone())); + routes.configure("a", Some(scope.clone())); let plan = routes.prepare("a", &target, None).unwrap(); routes.connection_restored(); assert!(matches!( @@ -519,7 +511,7 @@ mod tests { let plan = routes.prepare("a", &target, None).unwrap(); let mut next = scope; next.selector.epoch += 1; - routes.configure("a", true, Some(next)); + routes.configure("a", Some(next)); assert!(matches!( routes.install("a", plan, 2), Err(WireError::NeverSent(_)) diff --git a/crates/basal-host/src/lib.rs b/crates/basal-host/src/lib.rs index 1c389472..5dc8639a 100644 --- a/crates/basal-host/src/lib.rs +++ b/crates/basal-host/src/lib.rs @@ -37,6 +37,7 @@ pub mod core_consent; pub mod core_host; pub mod flow_refusal; pub mod flow_scope; +#[cfg(any(test, feature = "test-support"))] pub mod mock; pub mod routing; pub mod run_scope; @@ -46,15 +47,58 @@ pub mod subc_catalog; pub mod target_scope; pub mod transport; -pub use catalog::{Catalog, EventBody, EventDecl, EventOrigin, MockCatalog, OpDecl, OpKind}; +#[cfg(any(test, feature = "test-support"))] +pub use catalog::MockCatalog; +pub use catalog::{Catalog, EventBody, EventDecl, EventOrigin, OpDecl, OpKind}; +#[cfg(any(test, feature = "test-support"))] +pub use consent::MockConsent; pub use consent::{ CardDecision, Consent, ConsentError, DecisionAnswer, DecisionCard, DecisionContext, DecisionEvent, DecisionKind, DecisionOption, DecisionPath, DecisionSink, InstallCard, - InstallOutcome, MockConsent, + InstallOutcome, }; + +/// Retain a staging sentinel whenever test support is enabled, even if the +/// linker discards unused test doubles. The production entry point calls this. +#[inline] +pub fn retain_test_support_marker() { + #[cfg(feature = "test-support")] + std::hint::black_box("BASAL_TEST_SUPPORT_ENABLED"); +} + +/// Small host-owned JSON values use null if they exceed the IPC value limit. +/// Built-in result payloads instead return a typed size refusal. +pub fn json_text_or_null(value: &serde_json::Value) -> basal_proto::JsonText { + basal_proto::JsonText::new(value.to_string()).unwrap_or_else(|_| basal_proto::JsonText::null()) +} + +/// Encode the common local rejection shape. Callers choose their own bounded +/// fallback because KV and built-in refusals have different oversize contracts. +pub fn rejection_text( + code: &str, + message: &str, +) -> Result { + basal_proto::JsonText::new(serde_json::json!({"message": message, "code": code}).to_string()) +} pub use scope_describe::{ScopeDescribeError, ScopeDescription}; pub use subc_protocol::scope::{ScopeStamp, ScopeStatus}; +// Local hosts read wall time and OS randomness and need no completion +// subscription. The routing host forwards these methods to child hosts; +// the model adapter instead needs its own completion polling. +macro_rules! live_host_services { + () => { + fn now_ms(&self) -> f64 { + $crate::core_host::system_now() + } + fn random(&self) -> f64 { + $crate::core_host::sample() + } + fn attach(&self, _: std::sync::Arc) {} + }; +} +pub(crate) use live_host_services; + /// The usage fields of a Broca outcome, in Broca's canonical names: fresh /// input, cache write, output (reasoning is already inside it) and cached /// input. @@ -130,7 +174,7 @@ pub enum CallClass { impl CallClass { /// Whether sending the call again can never produce a second effect. - pub fn safe_to_repeat(self) -> bool { + pub(crate) fn safe_to_repeat(self) -> bool { match self { Self::Query => true, Self::Mutation { @@ -467,13 +511,7 @@ pub trait Host: Send + Sync { fn scope_checks(&self, _enabled: bool) {} /// Registers authority under a route key (a flow id or `codemode:`). /// Passing no scope releases the key, including one never registered. - fn configure_flow( - &self, - _flow_id: &str, - _agent_owned: bool, - _scope: Option, - ) { - } + fn configure_flow(&self, _flow_id: &str, _scope: Option) {} /// Whether the call only reads, and if not whether it honours /// idempotency keys. fn classify(&self, kind: &CallKind) -> CallClass; diff --git a/crates/basal-host/src/mock.rs b/crates/basal-host/src/mock.rs index 332bf5e1..65206091 100644 --- a/crates/basal-host/src/mock.rs +++ b/crates/basal-host/src/mock.rs @@ -41,7 +41,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -133,7 +133,6 @@ struct Rendezvous { #[derive(Default)] struct Controls { faults: HashMap<(String, String), Vec>, - classes: HashMap<(String, String), CallClass>, open_gates: HashSet, rendezvous: HashMap, /// What core answers about each flow version; `None` is no answer (core @@ -174,14 +173,9 @@ impl Default for MockHost { } } -fn text(value: &Value) -> JsonText { - JsonText::new(value.to_string()).unwrap_or_else(|_| JsonText::null()) -} +use crate::json_text_or_null as text; -fn lock(m: &Mutex) -> MutexGuard<'_, T> { - // A panic in another test thread must not make the mock unusable. - m.lock().unwrap_or_else(|p| p.into_inner()) -} +use basal_proto::util::lock; fn op_names(kind: &CallKind) -> (String, String) { match kind { @@ -291,15 +285,6 @@ impl MockHost { } } - /// Declares how an op is classified, overriding the table above. This is - /// the stand-in for the operator-approved list of ops whose idempotency - /// claim basal trusts. - pub fn set_class(&self, module: &str, op: &str, class: CallClass) { - lock(&self.shared.controls) - .classes - .insert((module.to_owned(), op.to_owned()), class); - } - /// Makes the next sends of (module, op) fail, one fault per send. pub fn inject(&self, module: &str, op: &str, faults: &[Fault]) { assert!( @@ -346,15 +331,6 @@ impl MockHost { lock(&self.shared.state).effects.clone() } - /// Effects per idempotency key. - pub fn effect_counts(&self) -> BTreeMap { - let mut counts = BTreeMap::new(); - for e in &lock(&self.shared.state).effects { - *counts.entry(e.key.clone()).or_insert(0) += 1; - } - counts - } - /// Sends that reached the mock under `key`, effects or not. pub fn send_count(&self, key: &str) -> usize { lock(&self.shared.state) @@ -548,7 +524,6 @@ impl MockHost { /// Applies a keyed mutation once and returns its reply. fn mutate( - &self, state: &mut State, request: &CallRequest, module: &str, @@ -614,7 +589,7 @@ impl Host for MockHost { ) -> Result { let mut controls = lock(&self.shared.controls); let key = (owner.clone(), scope_ref.to_owned()); - controls.scope_queries.push(key.clone()); + controls.scope_queries.push(key); controls .scope_descriptions .iter() @@ -634,10 +609,6 @@ impl Host for MockHost { } fn classify(&self, kind: &CallKind) -> CallClass { - let names = op_names(kind); - if let Some(class) = lock(&self.shared.controls).classes.get(&names) { - return *class; - } Self::default_class(kind) } @@ -725,7 +696,7 @@ impl Host for MockHost { || matches!(&request.kind, CallKind::Op { module, op } if module == "mock" && op == "long"); let result = if long { let handle = format!("h-{}", request.idempotency_key); - let reply = self.mutate( + let reply = Self::mutate( &mut state, request, &module, @@ -744,7 +715,7 @@ impl Host for MockHost { key: request.idempotency_key.clone(), outcome: None, acknowledged: false, - usage: usage.clone(), + usage, }); if let Some(ms) = args.get("complete_after_ms").and_then(Value::as_u64) { let mock = self.clone(); @@ -768,7 +739,7 @@ impl Host for MockHost { ))) } (_, CallClass::Mutation { .. }) => { - let reply = self.mutate( + let reply = Self::mutate( &mut state, request, &module, @@ -790,7 +761,7 @@ impl Host for MockHost { .cloned() .unwrap_or(Value::Null); let mut outcome = HostOutcome::fulfilled(text(&label)); - outcome.usage = usage.clone().and_then(|u| serde_json::from_value(u).ok()); + outcome.usage = usage.and_then(|u| serde_json::from_value(u).ok()); Dispatched::Completed(outcome) } _ => Dispatched::Completed(HostOutcome::fulfilled(request.args.clone())), @@ -856,12 +827,7 @@ impl MockHost { } } -fn settlement_code(s: Settlement) -> &'static str { - match s { - Settlement::Fulfilled => "fulfilled", - Settlement::Rejected => "rejected", - } -} +use basal_proto::util::settlement_str as settlement_code; fn encode_state(state: &State) -> Value { json!({ diff --git a/crates/basal-host/src/routing.rs b/crates/basal-host/src/routing.rs index 5ebfc768..587679a1 100644 --- a/crates/basal-host/src/routing.rs +++ b/crates/basal-host/src/routing.rs @@ -1,7 +1,7 @@ //! Send module ops to their providers and grant-checked facts and sinks to //! core. Model calls use a separate host that can be replaced independently. use crate::builtins::BuiltinHost; -use crate::core_host::{outcome, sample, system_now}; +use crate::core_host::outcome; use crate::subc_catalog::{SubcCatalog, Surface}; use crate::transport::{Transport, WireError}; use crate::{CallClass, CallRequest, CompletionSink, Dispatched, Host, OpKind, TransportError}; @@ -176,13 +176,8 @@ impl Host for ModuleOpsHost { Ok(()) } } - fn configure_flow( - &self, - flow_id: &str, - agent_owned: bool, - scope: Option, - ) { - self.transport.configure_flow(flow_id, agent_owned, scope); + fn configure_flow(&self, flow_id: &str, scope: Option) { + self.transport.configure_flow(flow_id, scope); } fn classify(&self, kind: &CallKind) -> CallClass { if let CallKind::Op { module, op } | CallKind::OpFor { module, op } = kind @@ -204,13 +199,7 @@ impl Host for ModuleOpsHost { ) -> Result { self.dispatch_checked(request, Some(class)) } - fn now_ms(&self) -> f64 { - system_now() - } - fn random(&self) -> f64 { - sample() - } - fn attach(&self, _: Arc) {} + crate::live_host_services!(); } pub struct RoutingHost { @@ -296,14 +285,9 @@ impl Host for RoutingHost { fn scope_checks(&self, enabled: bool) { self.model.scope_checks(enabled); } - fn configure_flow( - &self, - flow_id: &str, - agent_owned: bool, - scope: Option, - ) { - self.ops.configure_flow(flow_id, agent_owned, scope.clone()); - self.model.configure_flow(flow_id, agent_owned, scope); + fn configure_flow(&self, flow_id: &str, scope: Option) { + self.ops.configure_flow(flow_id, scope.clone()); + self.model.configure_flow(flow_id, scope); } fn classify(&self, kind: &CallKind) -> CallClass { self.target(kind).classify(kind) diff --git a/crates/basal-host/src/selector.rs b/crates/basal-host/src/selector.rs index 405252f3..7675f9c4 100644 --- a/crates/basal-host/src/selector.rs +++ b/crates/basal-host/src/selector.rs @@ -5,7 +5,9 @@ use crate::transport::{Transport, WireError}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::fmt; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; +#[cfg(any(test, feature = "test-support"))] +use std::sync::Mutex; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SelectionRequest { @@ -183,12 +185,14 @@ impl ModelSelector for RoutingSelector { /// Tests supply this fake explicitly. The runtime defaults to a refusing /// selector rather than choosing a model without fleet routing. #[derive(Debug, Default)] +#[cfg(any(test, feature = "test-support"))] pub struct FakeSelector { pub requests: Mutex>, pub reports: Mutex>, pub replies: Mutex>>, pub report_error: Mutex>, } +#[cfg(any(test, feature = "test-support"))] impl ModelSelector for FakeSelector { fn select(&self, request: &SelectionRequest) -> Result { self.requests diff --git a/crates/basal-host/src/subc_catalog.rs b/crates/basal-host/src/subc_catalog.rs index 8f5ddc3f..3a0873e2 100644 --- a/crates/basal-host/src/subc_catalog.rs +++ b/crates/basal-host/src/subc_catalog.rs @@ -95,10 +95,6 @@ impl SubcCatalog { self.daemon_capabilities = capabilities; self } - pub fn with_denylist(mut self, denylist: Vec<(String, String)>) -> Self { - self.denylist = denylist; - self - } pub fn resolve(&self, module: &str, op: &str) -> Result, WireError> { self.resolve_for_dispatch(module, op).map(|(decl, _)| decl) } diff --git a/crates/basal-host/src/transport.rs b/crates/basal-host/src/transport.rs index 3e6436bd..66bea125 100644 --- a/crates/basal-host/src/transport.rs +++ b/crates/basal-host/src/transport.rs @@ -85,13 +85,7 @@ pub trait Transport: Send + Sync { ) -> Result<(), crate::flow_refusal::FlowRefusal> { Ok(()) } - fn configure_flow( - &self, - _flow: &str, - _agent_owned: bool, - _scope: Option, - ) { - } + fn configure_flow(&self, _flow: &str, _scope: Option) {} fn management_for_flow( &self, _flow: &str, @@ -164,12 +158,11 @@ pub fn map_error(error: CallError) -> WireError { let cause = error.outcome_cause(); match error { CallError::NotSent(e) => WireError::NeverSent(e.to_string()), - e @ CallError::StaleRouteHandle(_) => WireError::NeverSent(e.to_string()), CallError::Module(e) => refusal(e), - // The capability resolver's errors are raised before any request - // frame for a call is written. The client classifies all three as - // not sent, unlike failures of an already accepted request. - e @ (CallError::CapabilityUnprovided { .. } + // Stale handles and capability resolution errors are rejected before + // any request frame is written, unlike failures of accepted requests. + e @ (CallError::StaleRouteHandle(_) + | CallError::CapabilityUnprovided { .. } | CallError::CapabilityAmbiguous { .. } | CallError::InvalidCapabilityIdentifier { .. }) => WireError::NeverSent(e.to_string()), // A request the writer accepted and that got no reply; the client @@ -222,15 +215,14 @@ fn outcome_unknown(cause: Option, message: String) -> WireE | OutcomeUnknownCause::ConsumerClosed | OutcomeUnknownCause::ConnectionFailed | OutcomeUnknownCause::RouteEnded, - ) => WireError::Unknown(message), + ) + | None => WireError::Unknown(message), // A completion inside the client was dropped or did not match its // request, so basal never received a usable reply. Some(OutcomeUnknownCause::CompletionFailed) => WireError::Unreadable(message), - // The client attaches a cause to every OutcomeUnknown it builds, so - // `None` is an error built elsewhere. It and any cause added in a - // later release are recorded as a lost connection: the request may - // have been sent, and nothing more is known. - None => WireError::Unknown(message), + // The client attaches a cause to its OutcomeUnknown errors. A missing + // cause still means a request may have left, so it uses Unknown rather + // than NeverSent. New causes are logged with that same uncertainty. Some(other) => { tracing::warn!(cause = ?other, %message, "unrecognised subc outcome-unknown cause; recording a lost connection"); WireError::Unknown(message) @@ -662,7 +654,7 @@ impl Transport for SubcTransport { let retired = cache.take_retired(); drop(cache); for key in retired { - self.configure_flow(&key, true, None); + self.configure_flow(&key, None); } let scope = result?; let targets = self @@ -674,7 +666,6 @@ impl Transport for SubcTransport { let key = scope.route_key(); self.configure_flow( &key, - true, Some(crate::flow_scope::RegisteredScope { selector: scope.selector(), targets, @@ -717,12 +708,7 @@ impl Transport for SubcTransport { .unwrap_or_else(|p| p.into_inner()) .ready(flow, module, action) } - fn configure_flow( - &self, - flow: &str, - agent_owned: bool, - scope: Option, - ) { + fn configure_flow(&self, flow: &str, scope: Option) { if scope.is_none() { self.run_watchers .lock() @@ -733,7 +719,7 @@ impl Transport for SubcTransport { .routes .lock() .unwrap_or_else(|p| p.into_inner()) - .configure(flow, agent_owned, scope); + .configure(flow, scope); for handle in dropped { let _ = self .handle @@ -977,7 +963,7 @@ mod tests { let (entered, received) = std::sync::mpsc::sync_channel(1); let (release, paused) = tokio::sync::oneshot::channel(); with_paused_open(Some((entered, paused)), |transport, _, _, _| { - transport.configure_flow("a", true, Some(registered())); + transport.configure_flow("a", Some(registered())); let opening = transport.clone(); let thread = std::thread::spawn(move || { opening.scoped_handle( @@ -1003,7 +989,7 @@ mod tests { fn stale_handle_does_not_leave_a_scope_tombstone() { with_scoped_transport(|transport, _, _, _| { let scope = registered(); - transport.configure_flow("a", true, Some(scope.clone())); + transport.configure_flow("a", Some(scope.clone())); let target = RouteTarget::ManagementSurface { module_id: "broca".into(), }; @@ -1018,7 +1004,7 @@ mod tests { )); // A reconnect and the same core selector must not preserve a local stale handle. transport.routes.lock().unwrap().connection_restored(); - transport.configure_flow("a", true, Some(scope)); + transport.configure_flow("a", Some(scope)); assert_eq!( transport .management_for_flow("a", "broca", "echo", json!({"n":1})) @@ -1030,7 +1016,7 @@ mod tests { #[test] fn stale_model_subscription_evicts_its_dead_handle() { with_scoped_transport(|transport, _, _, _| { - transport.configure_flow("a", true, Some(registered())); + transport.configure_flow("a", Some(registered())); let target = RouteTarget::ManagementSurface { module_id: "broca".into(), }; @@ -1057,7 +1043,7 @@ mod tests { #[test] fn stale_model_request_can_reopen_without_a_scope_epoch_change() { with_scoped_transport(|transport, _, _, _| { - transport.configure_flow("a", true, Some(registered())); + transport.configure_flow("a", Some(registered())); let target = RouteTarget::ManagementSurface { module_id: "broca".into(), }; @@ -1084,7 +1070,7 @@ mod tests { #[test] fn model_refusal_has_provider_and_action_after_envelope_decoding() { with_scoped_transport(|transport, _, _, _| { - transport.configure_flow("a", true, Some(registered())); + transport.configure_flow("a", Some(registered())); let result = transport.management_for_model( "a", BindIdentity::new("/", "basal", "model"), @@ -1101,7 +1087,7 @@ mod tests { #[test] fn stale_handle_mapping_keeps_the_client_handle_detail() { with_scoped_transport(|transport, _, _, _| { - transport.configure_flow("a", true, Some(registered())); + transport.configure_flow("a", Some(registered())); let handle = transport .scoped_handle( "a", @@ -1298,7 +1284,7 @@ mod tests { assert!(opens.lock().unwrap().is_empty()); let mut scope = registered(); scope.targets.insert("mock".into()); - transport.configure_flow("flow-a", true, Some(scope)); + transport.configure_flow("flow-a", Some(scope)); assert!( transport .grant_would_ask("flow-a", Some("owner"), "mock", "g1.ref") @@ -1359,7 +1345,6 @@ mod tests { }; transport.configure_flow( "codemode:run", - false, Some(crate::flow_scope::RegisteredScope { selector: selector.clone(), targets: ["mock".into()].into_iter().collect(), @@ -1402,7 +1387,7 @@ mod tests { .count(), 1 ); - transport.configure_flow("codemode:run", false, None); + transport.configure_flow("codemode:run", None); }); } @@ -1421,7 +1406,7 @@ mod tests { selector: selector.clone(), targets: ["mock".into(), "broca".into()].into_iter().collect(), }; - transport.configure_flow("flow-a", true, Some(registered)); + transport.configure_flow("flow-a", Some(registered)); let (scope_ended, scope_events) = std::sync::mpsc::channel(); transport.watch_run_scope( "flow-a", @@ -1514,7 +1499,6 @@ mod tests { ); transport.configure_flow( "flow-a", - true, Some(RegisteredScope { selector: selector.clone(), targets: ["mock".into(), "broca".into()].into_iter().collect(), @@ -1532,7 +1516,6 @@ mod tests { next.epoch += 1; transport.configure_flow( "flow-a", - true, Some(RegisteredScope { selector: next.clone(), targets: ["mock".into(), "broca".into()].into_iter().collect(), @@ -1541,7 +1524,7 @@ mod tests { transport .management_for_flow("flow-a", "mock", "echo", json!({})) .unwrap(); - transport.configure_flow("flow-a", true, None); + transport.configure_flow("flow-a", None); assert!( !transport .run_watchers @@ -1599,7 +1582,6 @@ mod tests { with_scoped_transport(|transport, opens, calls, _| { transport.configure_flow( "armed", - true, Some(RegisteredScope { selector: FlowScope { owner: subc_protocol::Principal::Reserved { @@ -1642,12 +1624,121 @@ mod tests { }); } + fn scoped_model_requests( + selection: &dyn crate::selector::ModelSelector, + ) -> Vec { + use crate::selector::SelectionRequest; + use basal_proto::{CallKind, JsonText, Primitive}; + let mut requests = Vec::new(); + for (run, position, primitive, session) in [ + ("run-a", 0, Primitive::Llm, "basal:flow-flow-a:run-a:0"), + ("run-a", 1, Primitive::Classify, "basal:flow-flow-a:run-a:1"), + ("run-b", 0, Primitive::Llm, "basal:flow-flow-a:run-b:0"), + ] { + let send = format!("send-{}", requests.len()); + let chosen = selection + .select(&SelectionRequest { + caller_class: "flow".into(), + iq: 70, + eq: 20, + flow_id: "flow-a".into(), + run_id: run.into(), + send_id: send.clone(), + }) + .unwrap(); + let args = if primitive == Primitive::Classify { + json!({"text":"hello","labels":["answer","other"]}) + } else { + json!({"prompt":"hello"}) + }; + requests.push(crate::CallRequest { + flow_id: "flow-a".into(), run_id: run.into(), position, + kind: CallKind::Primitive(primitive), + args: JsonText::new(json!({"send_id":send,"work_class":"flow:flow-a","session":session,"op":primitive.name(),"max_output":16,"request":args,"selection":chosen}).to_string()).unwrap(), + idempotency_key: send, attempt: 1, + }); + } + requests + } + + fn assert_model_opens( + send_opens: &[Value], + selector: &crate::flow_scope::FlowScope, + expected: &[&str], + ) { + assert_eq!(send_opens.len(), 3, "one scoped Broca route per call"); + let mut sessions: Vec<_> = send_opens + .iter() + .map(|open| { + assert_eq!( + open["scope"], + serde_json::to_value(selector.selector()).unwrap() + ); + open["identity"]["session"] + .as_str() + .unwrap_or_else(|| panic!("missing bind session: {open}")) + .to_owned() + }) + .collect(); + sessions.sort(); + assert_eq!(sessions, expected); + for open in send_opens { + assert_eq!(open["identity"]["project_root"], "/"); + assert_eq!(open["identity"]["harness"], "basal"); + } + } + + fn assert_recovered_model_calls( + recorded_opens: &[Value], + recorded_calls: &[(u16, Value)], + expected_session: &str, + ) { + let session_for_channel = |channel: u16| { + recorded_opens[usize::from(channel - 11)]["identity"]["session"] + .as_str() + .unwrap() + }; + for (channel, send) in recorded_calls + .iter() + .filter(|(_, body)| body["method"] == "session.send") + { + let session = session_for_channel(*channel); + let run = format!("broca-run:{}", send["params"]["send_id"].as_str().unwrap()); + for method in ["run.result", "run.status"] { + let reads: Vec<_> = recorded_calls + .iter() + .filter(|(_, body)| body["method"] == method && body["params"]["run_id"] == run) + .collect(); + assert_eq!(reads.len(), 1); + assert_ne!( + reads[0].0, *channel, + "recovery uses the current selector's new channel" + ); + assert_eq!( + session_for_channel(reads[0].0), + session, + "recovery must read the send's conversation" + ); + } + assert!( + recorded_calls + .iter() + .any(|(channel, body)| body["method"] == "session.subscribe" + && session_for_channel(*channel) == session) + ); + } + let read = recorded_calls + .iter() + .find(|(_, body)| body["method"] == "session.read") + .unwrap(); + assert_eq!(session_for_channel(read.0), expected_session); + } + #[test] fn scoped_model_calls_keep_per_call_sessions_across_runs_and_recovery() { use crate::broca::{BrocaHost, Transport as _}; use crate::flow_scope::{FlowScope, RegisteredScope}; - use crate::selector::{FakeSelector, ModelSelector, SelectionRequest}; - use basal_proto::{CallKind, JsonText, Primitive}; + use crate::selector::FakeSelector; with_scoped_transport(|transport, opens, calls, _| { let selector = FlowScope { owner: subc_protocol::Principal::Reserved { @@ -1660,7 +1751,7 @@ mod tests { selector, targets: ["broca".into()].into_iter().collect(), }; - transport.configure_flow("flow-a", true, Some(register(selector.clone()))); + transport.configure_flow("flow-a", Some(register(selector.clone()))); let broca = crate::broca::subc::SubcBrocaTransport::new( transport.clone(), "broca".into(), @@ -1675,35 +1766,7 @@ mod tests { "basal".into(), selection.clone(), ); - let mut requests = Vec::new(); - for (run, position, primitive, session) in [ - ("run-a", 0, Primitive::Llm, "basal:flow-flow-a:run-a:0"), - ("run-a", 1, Primitive::Classify, "basal:flow-flow-a:run-a:1"), - ("run-b", 0, Primitive::Llm, "basal:flow-flow-a:run-b:0"), - ] { - let send = format!("send-{}", requests.len()); - let chosen = selection - .select(&SelectionRequest { - caller_class: "flow".into(), - iq: 70, - eq: 20, - flow_id: "flow-a".into(), - run_id: run.into(), - send_id: send.clone(), - }) - .unwrap(); - let args = if primitive == Primitive::Classify { - json!({"text":"hello","labels":["answer","other"]}) - } else { - json!({"prompt":"hello"}) - }; - requests.push(crate::CallRequest { - flow_id: "flow-a".into(), run_id: run.into(), position, - kind: CallKind::Primitive(primitive), - args: JsonText::new(json!({"send_id":send,"work_class":"flow:flow-a","session":session,"op":primitive.name(),"max_output":16,"request":args,"selection":chosen}).to_string()).unwrap(), - idempotency_key: send, attempt: 1, - }); - } + let requests = scoped_model_requests(selection.as_ref()); // The accepted calls remain in flight together. Broca must not // receive their prompts under a shared conversation identity. std::thread::scope(|threads| { @@ -1718,26 +1781,7 @@ mod tests { "basal:flow-flow-a:run-b:0", ]; let send_opens = opens.lock().unwrap().clone(); - assert_eq!(send_opens.len(), 3, "one scoped Broca route per call"); - let mut sessions: Vec<_> = send_opens - .iter() - .map(|open| { - assert_eq!( - open["scope"], - serde_json::to_value(selector.selector()).unwrap() - ); - open["identity"]["session"] - .as_str() - .unwrap_or_else(|| panic!("missing bind session: {open}")) - .to_owned() - }) - .collect(); - sessions.sort(); - assert_eq!(sessions, expected); - for open in &send_opens { - assert_eq!(open["identity"]["project_root"], "/"); - assert_eq!(open["identity"]["harness"], "basal"); - } + assert_model_opens(&send_opens, &selector, &expected); drop(host); drop(broca); @@ -1745,7 +1789,7 @@ mod tests { // scoped channels without changing any call's conversation name. let mut current = selector; current.epoch += 1; - transport.configure_flow("flow-a", true, Some(register(current.clone()))); + transport.configure_flow("flow-a", Some(register(current.clone()))); let broca = crate::broca::subc::SubcBrocaTransport::new( transport.clone(), "broca".into(), @@ -1774,47 +1818,7 @@ mod tests { ) .unwrap(); let recorded_calls = calls.lock().unwrap().clone(); - let session_for_channel = |channel: u16| { - recorded_opens[usize::from(channel - 11)]["identity"]["session"] - .as_str() - .unwrap() - }; - for (channel, send) in recorded_calls - .iter() - .filter(|(_, body)| body["method"] == "session.send") - { - let session = session_for_channel(*channel); - let run = format!("broca-run:{}", send["params"]["send_id"].as_str().unwrap()); - for method in ["run.result", "run.status"] { - let reads: Vec<_> = recorded_calls - .iter() - .filter(|(_, body)| { - body["method"] == method && body["params"]["run_id"] == run - }) - .collect(); - assert_eq!(reads.len(), 1); - assert_ne!( - reads[0].0, *channel, - "recovery uses the current selector's new channel" - ); - assert_eq!( - session_for_channel(reads[0].0), - session, - "recovery must read the send's conversation" - ); - } - assert!( - recorded_calls - .iter() - .any(|(channel, body)| body["method"] == "session.subscribe" - && session_for_channel(*channel) == session) - ); - } - let read = recorded_calls - .iter() - .find(|(_, body)| body["method"] == "session.read") - .unwrap(); - assert_eq!(session_for_channel(read.0), expected[0]); + assert_recovered_model_calls(&recorded_opens, &recorded_calls, expected[0]); broca.release(&crate::broca::Route { flow_id: Some("flow-a".into()), project_root: "/".into(), @@ -1995,7 +1999,7 @@ mod tests { let mut scope = registered(); scope.selector.scope_ref = reference.into(); scope.targets.insert("mock".into()); - transport.configure_flow(flow, true, Some(scope)); + transport.configure_flow(flow, Some(scope)); let error = transport .management_for_flow(flow, "mock", "cut_scope", json!({})) .unwrap_err(); diff --git a/crates/basal-host/tests/flow_scope_routes.rs b/crates/basal-host/tests/flow_scope_routes.rs index f644ef9d..4ba8a8eb 100644 --- a/crates/basal-host/tests/flow_scope_routes.rs +++ b/crates/basal-host/tests/flow_scope_routes.rs @@ -24,7 +24,7 @@ fn route_cache_reuses_handles_and_replaces_changed_selectors() { let target = RouteTarget::ManagementSurface { module_id: "broca".into(), }; - routes.configure("flow-a", true, Some(registered(scope.clone()))); + routes.configure("flow-a", Some(registered(scope.clone()))); assert_eq!( routes .route("flow-a", &target, |actual| { @@ -44,7 +44,7 @@ fn route_cache_reuses_handles_and_replaces_changed_selectors() { ); scope.epoch = 2; assert_eq!( - routes.configure("flow-a", true, Some(registered(scope.clone()))), + routes.configure("flow-a", Some(registered(scope.clone()))), vec![7] ); assert_eq!( @@ -58,7 +58,7 @@ fn route_cache_reuses_handles_and_replaces_changed_selectors() { ); scope.scope_ref = "flow-replacement".into(); assert_eq!( - routes.configure("flow-a", true, Some(registered(scope.clone()))), + routes.configure("flow-a", Some(registered(scope.clone()))), vec![13] ); assert_eq!( @@ -75,18 +75,15 @@ fn route_cache_reuses_handles_and_replaces_changed_selectors() { #[derive(Default)] struct RouteTransport { routes: Mutex>, - configurations: Mutex)>>, + configurations: Mutex)>>, } impl Transport for RouteTransport { - fn configure_flow(&self, key: &str, agent_owned: bool, scope: Option) { + fn configure_flow(&self, key: &str, scope: Option) { self.configurations .lock() .unwrap() - .push((key.into(), agent_owned, scope.clone())); - self.routes - .lock() - .unwrap() - .configure(key, agent_owned, scope); + .push((key.into(), scope.clone())); + self.routes.lock().unwrap().configure(key, scope); } fn catalog(&self) -> Result { panic!("configure must not query providers") @@ -129,10 +126,10 @@ fn target() -> RouteTarget { #[test] fn configure_flow_registers_a_codemode_route_key() { let (host, wire) = route_host(); - host.configure_flow("codemode:run-1", false, Some(codemode_scope())); + host.configure_flow("codemode:run-1", Some(codemode_scope())); assert_eq!( *wire.configurations.lock().unwrap(), - vec![("codemode:run-1".into(), false, Some(codemode_scope()))] + vec![("codemode:run-1".into(), Some(codemode_scope()))] ); assert_eq!( wire.routes @@ -150,8 +147,8 @@ fn configure_flow_registers_a_codemode_route_key() { #[test] fn configure_flow_releases_a_codemode_route_key_without_releasing_other_users() { let (host, wire) = route_host(); - host.configure_flow("codemode:run-1", false, Some(codemode_scope())); - host.configure_flow("flow:other", true, Some(codemode_scope())); + host.configure_flow("codemode:run-1", Some(codemode_scope())); + host.configure_flow("flow:other", Some(codemode_scope())); assert_eq!( wire.routes .lock() @@ -160,10 +157,10 @@ fn configure_flow_releases_a_codemode_route_key_without_releasing_other_users() .unwrap(), Some(13) ); - host.configure_flow("codemode:run-1", false, None); + host.configure_flow("codemode:run-1", None); assert_eq!( wire.configurations.lock().unwrap().last(), - Some(&("codemode:run-1".into(), false, None)) + Some(&("codemode:run-1".into(), None)) ); assert!( matches!(wire.routes.lock().unwrap().route("codemode:run-1", &target(), |_| panic!("released scopes cannot open routes")), Err(WireError::Typed(refusal)) if refusal.reason == basal_host::flow_refusal::RefusalReason::NoFlowScope) @@ -178,7 +175,7 @@ fn configure_flow_releases_a_codemode_route_key_without_releasing_other_users() .unwrap(), Some(13) ); - host.configure_flow("flow:other", true, None); + host.configure_flow("flow:other", None); assert!( wire.routes .lock() @@ -193,7 +190,7 @@ fn configure_flow_releases_a_codemode_route_key_without_releasing_other_users() #[test] fn configure_flow_release_of_an_unregistered_codemode_key_is_a_noop() { let (host, wire) = route_host(); - host.configure_flow("flow:other", true, Some(codemode_scope())); + host.configure_flow("flow:other", Some(codemode_scope())); assert_eq!( wire.routes .lock() @@ -202,8 +199,8 @@ fn configure_flow_release_of_an_unregistered_codemode_key_is_a_noop() { .unwrap(), Some(17) ); - host.configure_flow("codemode:never-registered", false, None); - host.configure_flow("codemode:never-registered", false, None); + host.configure_flow("codemode:never-registered", None); + host.configure_flow("codemode:never-registered", None); assert_eq!( wire.routes .lock() diff --git a/crates/basal-module/Cargo.toml b/crates/basal-module/Cargo.toml index c036ef65..156a30c4 100644 --- a/crates/basal-module/Cargo.toml +++ b/crates/basal-module/Cargo.toml @@ -18,8 +18,10 @@ path = "src/main.rs" name = "ck-basal-harness" path = "src/bin/ck-basal-harness.rs" test = false +required-features = ["test-support"] [features] +test-support = ["basal-core/test-support", "basal-host/test-support"] # Compiles the one-shot kill switch (`src/rig_kill.rs`) into ck-basal. Only # script/flows-rig.sh turns it on, for the ckdev-flows rig's own binary; a # production build never does. @@ -66,7 +68,9 @@ windows-sys = { version = "0.61", features = ["Win32_System_Threading"] } sha2.workspace = true [dev-dependencies] -basal-testkit.workspace = true +# Use a distinct name so package-qualified feature flags still name the package. +basal-module-test-support = { package = "basal-module", path = ".", features = ["test-support"] } +basal-testkit = { workspace = true, features = ["test-support"] } cortexkit-role-tool-provider-conformance.workspace = true cortexkit-role-harness = "=0.1.0" subc-client-rs = { workspace = true, features = ["test-support"] } diff --git a/crates/basal-module/src/acceptance_tests.rs b/crates/basal-module/src/acceptance_tests.rs index 398d7f7a..efe3d5dc 100644 --- a/crates/basal-module/src/acceptance_tests.rs +++ b/crates/basal-module/src/acceptance_tests.rs @@ -1,5 +1,7 @@ use super::*; -use basal_proto::{LandlockReport, PROTOCOL_VERSION, PreludeHash}; +#[cfg(unix)] +use basal_proto::LandlockReport; +use basal_proto::{PROTOCOL_VERSION, PreludeHash}; fn welcome(confinement: Confinement) -> Welcome { Welcome { diff --git a/crates/basal-module/src/card.rs b/crates/basal-module/src/card.rs index f36bd782..7f2d6ade 100644 --- a/crates/basal-module/src/card.rs +++ b/crates/basal-module/src/card.rs @@ -8,9 +8,7 @@ use basal_core::{Installed, Warning}; use basal_host::Catalog; use serde_json::{Value, json}; -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} +use basal_proto::util::hex; pub fn code_hash_hex(hash: &[u8; 32]) -> String { hex(hash) diff --git a/crates/basal-module/src/engine.rs b/crates/basal-module/src/engine.rs index 33699c45..ffa6a750 100644 --- a/crates/basal-module/src/engine.rs +++ b/crates/basal-module/src/engine.rs @@ -534,6 +534,7 @@ impl Engine { /// Waits until an activation is in progress for `run_id`, up to /// `timeout`. For tests that act while a run is being driven. + #[cfg(any(test, feature = "test-support"))] pub fn wait_active(&self, run_id: &str, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; let mut active = self.active(); diff --git a/crates/basal-module/src/events.rs b/crates/basal-module/src/events.rs index 7e36122d..7f9cf7d6 100644 --- a/crates/basal-module/src/events.rs +++ b/crates/basal-module/src/events.rs @@ -5,7 +5,7 @@ use std::sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use async_nats::jetstream::{self, consumer::PullConsumer}; use basal_core::{ @@ -90,13 +90,7 @@ impl Drop for Reader { } fn now_ms() -> i64 { - i64::try_from( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), - ) - .unwrap_or(i64::MAX) + i64::try_from(basal_proto::util::unix_millis()).unwrap_or(i64::MAX) } #[derive(Clone, Deserialize)] diff --git a/crates/basal-module/src/fatal.rs b/crates/basal-module/src/fatal.rs index 9170608a..596aaa94 100644 --- a/crates/basal-module/src/fatal.rs +++ b/crates/basal-module/src/fatal.rs @@ -61,7 +61,7 @@ impl Fatal { } fn lock(&self) -> MutexGuard<'_, Option> { - self.inner.0.lock().unwrap_or_else(|p| p.into_inner()) + basal_proto::util::lock(&self.inner.0) } /// Records a fatal error. The first one wins; later ones are logged only. diff --git a/crates/basal-module/src/harness.rs b/crates/basal-module/src/harness.rs index 6b637086..c2884066 100644 --- a/crates/basal-module/src/harness.rs +++ b/crates/basal-module/src/harness.rs @@ -320,13 +320,7 @@ pub fn main() -> std::process::ExitCode { return std::process::ExitCode::FAILURE; } }; - let broca = Arc::new(BrocaHost::new( - fake.clone(), - broca_store.clone(), - args.dir.to_string_lossy().into_owned(), - "basal".into(), - selector.clone(), - )); + let broca = make_broca_host(&args, &fake, &broca_store, &selector); let model_host: Arc = if args.broca { broca.clone() } else { @@ -345,50 +339,13 @@ pub fn main() -> std::process::ExitCode { let mut pool = PoolConfig::new(&args.worker, crate::process::WorkerLaunch::Plain); pool.warm_spares = args.warm_spares; pool.handshake_timeout = Duration::from_secs(180); - let config = ModuleConfig { - store_path: args.dir.join("basal.db"), - durability: Durability { fullfsync: false }, - runtime: Config { - clock: clock.clone(), - selector: selector.clone(), - activation_deadline: Duration::from_secs(60), - // The harness's consent plane is a mock, so no core approved its - // flows and no core could be asked about them. - install_gate: basal_core::InstallGate::Off, - ..Config::default() - }, - pool: pool.clone(), - engine: EngineConfig { - max_concurrent_activations: args.max_concurrent, - ..EngineConfig::default() - }, - dry_run: DryRunConfig::new(args.dir.join("dry-run")), - }; - let host: Arc = if args.routing_fake { - use basal_host::{ - core_host::CoreHost, - routing::{ModuleOpsHost, RoutingHost}, - subc_catalog::SubcCatalog, - }; - let transport = Arc::new(harness_transport::HarnessTransport(mock.clone())); - let catalog = Arc::new(SubcCatalog::new(transport.clone())); - let builtins = match builtins_config(&args) { - Ok(config) => config, - Err(e) => { - eprintln!("ck-basal-harness: {e}"); - return std::process::ExitCode::from(64); - } - }; - Arc::new( - RoutingHost::new( - Arc::new(ModuleOpsHost::new(transport.clone(), catalog)), - Arc::new(CoreHost::new(transport)), - model_host.clone(), - ) - .with_builtins(Arc::new(basal_host::builtins::BuiltinHost::new(builtins))), - ) - } else { - model_host + let config = module_config(&args, &clock, selector, &pool); + let host = match configured_host(&args, model_host, &mock) { + Ok(host) => host, + Err(e) => { + eprintln!("ck-basal-harness: {e}"); + return std::process::ExitCode::from(64); + } }; let module = match Module::start_with_store( config, @@ -422,7 +379,95 @@ pub fn main() -> std::process::ExitCode { } let _ = pool_slot.set(module.pool.clone()); module.fatal.exit_when_raised(); + command_loop( + &args, + &module, + &mock, + &consent, + &clock, + fake.as_ref(), + broca.as_ref(), + ) +} +fn make_broca_host( + args: &Args, + fake: &Arc, + store: &Arc, + selector: &Arc, +) -> Arc { + Arc::new(BrocaHost::new( + fake.clone(), + store.clone(), + args.dir.to_string_lossy().into_owned(), + "basal".into(), + selector.clone(), + )) +} + +fn module_config( + args: &Args, + clock: &Clock, + selector: Arc, + pool: &PoolConfig, +) -> ModuleConfig { + ModuleConfig { + store_path: args.dir.join("basal.db"), + durability: Durability { fullfsync: false }, + runtime: Config { + clock: clock.clone(), + selector, + activation_deadline: Duration::from_secs(60), + // These flows are approved by an in-memory consent fixture, not + // the fleet's core service. Do not ask that service to confirm them. + install_gate: basal_core::InstallGate::Off, + ..Config::default() + }, + pool: pool.clone(), + engine: EngineConfig { + max_concurrent_activations: args.max_concurrent, + ..EngineConfig::default() + }, + dry_run: DryRunConfig::new(args.dir.join("dry-run")), + } +} + +fn configured_host( + args: &Args, + model_host: Arc, + mock: &MockHost, +) -> Result, String> { + Ok(if args.routing_fake { + use basal_host::{ + core_host::CoreHost, + routing::{ModuleOpsHost, RoutingHost}, + subc_catalog::SubcCatalog, + }; + let transport = Arc::new(harness_transport::HarnessTransport(mock.clone())); + let catalog = Arc::new(SubcCatalog::new(transport.clone())); + let builtins = builtins_config(args)?; + Arc::new( + RoutingHost::new( + Arc::new(ModuleOpsHost::new(transport.clone(), catalog)), + Arc::new(CoreHost::new(transport)), + model_host, + ) + .with_builtins(Arc::new(basal_host::builtins::BuiltinHost::new(builtins))), + ) + } else { + model_host + }) +} + +fn command_loop( + args: &Args, + module: &Module, + mock: &MockHost, + consent: &MockConsent, + clock: &Clock, + fake: &FakeBroca, + broca: &BrocaHost, +) -> std::process::ExitCode { let stdin = std::io::stdin(); let mut out = std::io::stdout(); for line in stdin.lock().lines() { @@ -464,8 +509,7 @@ pub fn main() -> std::process::ExitCode { let sends: Vec = fake.sends().into_iter().map(|(route, bytes)| json!({"route": route, "params": serde_json::from_slice::(&bytes).ok()})).collect(); json!({"ok": true, "result": sends}) } else { - let reply = - command_reply(&module, &mock, &consent, &clock, &args.dir, &command); + let reply = command_reply(module, mock, consent, clock, &args.dir, &command); if args.broca && let Err(error) = broca.poll() { diff --git a/crates/basal-module/src/harness_transport.rs b/crates/basal-module/src/harness_transport.rs index 80977718..c9246742 100644 --- a/crates/basal-module/src/harness_transport.rs +++ b/crates/basal-module/src/harness_transport.rs @@ -46,9 +46,27 @@ impl HarnessTransport { } impl Transport for HarnessTransport { fn catalog(&self) -> Result { - Ok( - json!({"generation":1,"modules":[{"module_id":"mock","roles":[{"role":"management_surface","operations":[{"name":"echo","kind":"query"},{"name":"post","kind":"mutate"}],"identity_scope":[],"config_schema":{},"observability":[],"concurrency":"serial"},{"role":"tool_provider","tools":[{"name":"send","execution_mode":"mutating","schema":{}}],"identity_scope":[],"concurrency":"serial","emits_push":false,"sub_supervises":false}],"control_ops":[]}]}), - ) + Ok(json!({ + "generation": 1, + "modules": [{ + "module_id": "mock", + "roles": [{ + "role": "management_surface", + "operations": [ + {"name": "echo", "kind": "query"}, + {"name": "post", "kind": "mutate"} + ], + "identity_scope": [], "config_schema": {}, "observability": [], + "concurrency": "serial" + }, { + "role": "tool_provider", + "tools": [{"name": "send", "execution_mode": "mutating", "schema": {}}], + "identity_scope": [], "concurrency": "serial", + "emits_push": false, "sub_supervises": false + }], + "control_ops": [] + }] + })) } fn management(&self, module: &str, op: &str, params: Value) -> Result { if module == "prefrontal-core" && op == "sink.digest" { diff --git a/crates/basal-module/src/lib.rs b/crates/basal-module/src/lib.rs index d4160041..281c8c67 100644 --- a/crates/basal-module/src/lib.rs +++ b/crates/basal-module/src/lib.rs @@ -28,6 +28,7 @@ pub mod dryrun; pub mod engine; pub mod events; pub mod fatal; +#[cfg(any(test, feature = "test-support"))] pub mod harness; pub mod manifest; pub mod metrics; diff --git a/crates/basal-module/src/main.rs b/crates/basal-module/src/main.rs index 80530f5b..5b00273c 100644 --- a/crates/basal-module/src/main.rs +++ b/crates/basal-module/src/main.rs @@ -23,6 +23,7 @@ use basal_module::serve::BasalHandler; use subc_os::privacy_identity; fn main() -> ExitCode { + basal_host::retain_test_support_marker(); #[cfg(target_os = "macos")] { let args: Vec<_> = std::env::args_os().skip(1).collect(); @@ -117,7 +118,7 @@ fn serve() -> ExitCode { let configured_models = models.clone(); let built_models = models.clone(); let initialized_models = models.clone(); - let ready_models = models.clone(); + let ready_models = models; let event_transport = transport.clone(); // Only the rig's kill switch reads this: the store path, once known. let store_path_cell = Arc::new(std::sync::OnceLock::::new()); diff --git a/crates/basal-module/src/module.rs b/crates/basal-module/src/module.rs index e2ed0d16..7bab7454 100644 --- a/crates/basal-module/src/module.rs +++ b/crates/basal-module/src/module.rs @@ -203,7 +203,7 @@ impl Module { hosts.catalog.clone(), hosts.host, pool.clone(), - config.dry_run.clone(), + config.dry_run, runtime_config, metrics.clone(), ); diff --git a/crates/basal-module/src/ops.rs b/crates/basal-module/src/ops.rs index 1bfd85f2..72be694b 100644 --- a/crates/basal-module/src/ops.rs +++ b/crates/basal-module/src/ops.rs @@ -447,6 +447,22 @@ impl Module { // ---- flow.install -------------------------------------------------- + fn parse_install_manifest(&self, text: &str) -> Result { + Manifest::parse_ordinary(text).map_err(|e| match e { + e @ (ManifestError::ParamsRequirePackage { .. } + | ManifestError::AudienceSinkNamed + | ManifestError::AudiencePlaceholderWithoutAudience + | ManifestError::ForTargetRequiresAudience + | ManifestError::RunBoundExceedsAudienceLimit) => { + self.install_error(InstallError::Manifest(e)) + } + other => OpError::new( + "install_refused", + format!("the manifest is invalid: {other}"), + ), + }) + } + fn op_install(&self, caller: &Caller, params: Value) -> OpResult { #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -460,21 +476,7 @@ impl Module { loop_override: bool, } let p: Params = serde_json::from_value(params).map_err(invalid_params)?; - let manifest = Manifest::parse_ordinary(&p.manifest).map_err(|e| match e { - ManifestError::ParamsRequirePackage { .. } => { - self.install_error(InstallError::Manifest(e)) - } - e @ (ManifestError::AudienceSinkNamed - | ManifestError::AudiencePlaceholderWithoutAudience - | ManifestError::ForTargetRequiresAudience - | ManifestError::RunBoundExceedsAudienceLimit) => { - self.install_error(InstallError::Manifest(e)) - } - other => OpError::new( - "install_refused", - format!("the manifest is invalid: {other}"), - ), - })?; + let manifest = self.parse_install_manifest(&p.manifest)?; // Package instances derive their ids from the package and agent. // Plain installs must not occupy that namespace before an instance exists. if manifest.id.rsplit_once('_').is_some_and(|(_, suffix)| { @@ -560,18 +562,28 @@ impl Module { .install(&request) .map_err(|e| self.install_error(e))?; + self.install_card(caller, &manifest, &request, installed) + } + + fn install_card( + &self, + caller: &Caller, + manifest: &Manifest, + request: &InstallRequest, + installed: basal_core::Installed, + ) -> OpResult { let existing = self .rt .cards(&installed.flow_id) .map_err(|e| self.core_error(e))? .into_iter() .find(|c| c.version == installed.version); - let mut record = match existing { + let record = match existing { Some(card) => card, None => { let now = self.rt.config().clock.now_ms(); let dry_run = match self.dry.run_install( - &request, + request, Mode::Capture, &DryTrigger::Schedule { window: None }, now, @@ -598,7 +610,7 @@ impl Module { }) }); let mut fields = card::fields(&CardInput { - manifest: &manifest, + manifest, script: &request.script, manifest_text: &request.manifest, author: &request.author, @@ -620,6 +632,16 @@ impl Module { } }; let fields: Value = serde_json::from_str(&record.card).unwrap_or(Value::Null); + self.raise_install_card(caller, record, fields, installed.new) + } + + fn raise_install_card( + &self, + caller: &Caller, + mut record: basal_core::cards::CardRecord, + fields: Value, + new: bool, + ) -> OpResult { if record.state == CardState::Pending { // Raised every time the version is installed while undecided: a // raise that was lost (the consent plane was down) is retried by @@ -712,6 +734,15 @@ impl Module { } } } + self.install_reply(record, &fields, new) + } + + fn install_reply( + &self, + record: basal_core::cards::CardRecord, + fields: &Value, + new: bool, + ) -> OpResult { let approval = self .rt .store() @@ -727,7 +758,7 @@ impl Module { "card_id": if policy { Value::Null } else { json!(record.card_id) }, "state": record.state.as_str(), "attempt": record.attempt, - "new": installed.new, + "new": new, "warnings": fields.get("warnings").cloned().unwrap_or(Value::Null), "dry_run": fields.get("dry_run_summary").cloned().unwrap_or(Value::Null), }); @@ -1168,12 +1199,13 @@ impl Module { let ages = self.rt.run_ages().map_err(|e| self.core_error(e))?; let health = self.rt.health().map_err(|e| self.core_error(e))?; let mut tokens = Vec::new(); + let flow_ids: Vec<_> = flows.iter().map(|f| f.flow_id.as_str()).collect(); + let token_usage = self + .rt + .token_usage_many(&flow_ids, now) + .map_err(|e| self.core_error(e))?; for f in &flows { - if let Some(w) = self - .rt - .token_usage(&f.flow_id, now) - .map_err(|e| self.core_error(e))? - { + if let Some(w) = token_usage.get(&f.flow_id) { tokens.push(json!({ "flow_id": f.flow_id, "window_start": w.window_start, diff --git a/crates/basal-module/src/pool.rs b/crates/basal-module/src/pool.rs index b948880e..2934dea5 100644 --- a/crates/basal-module/src/pool.rs +++ b/crates/basal-module/src/pool.rs @@ -651,6 +651,7 @@ impl Pool { } /// Waits until at least `n` spares are ready, up to `timeout`. + #[cfg(any(test, feature = "test-support"))] pub fn wait_for_spares(&self, n: usize, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; let mut state = self.lock(); @@ -758,11 +759,9 @@ impl Pool { let crash_status = status.filter(|status| !lease.killed || is_confinement_fault(status)); let crashed = crash_status.is_some(); let keep = match (&lease.binding, lease.killed || crashed) { - (_, true) => None, - (Binding::DryRun(_) | Binding::Codemode, false) => None, (Binding::Flow(_), false) if activations >= self.shared.config.max_activations => None, (Binding::Flow(flow), false) if !state.stopped => Some(flow.clone()), - (Binding::Flow(_), false) => None, + _ => None, }; match keep { Some(flow) => { @@ -815,8 +814,7 @@ impl Shared { } } fn lock(&self) -> MutexGuard<'_, State> { - // A panic on another thread must not make the pool unusable. - self.state.lock().unwrap_or_else(|p| p.into_inner()) + basal_proto::util::lock(&self.state) } /// Accounts for a worker that is gone. One killed or crashed is owed a diff --git a/crates/basal-module/src/serve.rs b/crates/basal-module/src/serve.rs index 892bb812..cdc790c8 100644 --- a/crates/basal-module/src/serve.rs +++ b/crates/basal-module/src/serve.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use async_trait::async_trait; @@ -128,9 +128,7 @@ pub struct BasalHandler { tool_foreground: Duration, } -fn lock(m: &Mutex) -> MutexGuard<'_, T> { - m.lock().unwrap_or_else(|p| p.into_inner()) -} +use basal_proto::util::lock; impl BasalHandler { pub fn new(configure: Configure, hosts: MakeHosts) -> Self { @@ -311,7 +309,6 @@ fn store_path(ack: &ModuleHelloAckBody) -> Result { }; match descriptor.backend { StorageBackend::Sqlite { path } => Ok(PathBuf::from(path)), - #[allow(unreachable_patterns)] other => Err(format!( "basal needs a SQLite store, the daemon offered {other:?}" )), diff --git a/crates/basal-module/src/unconfigured.rs b/crates/basal-module/src/unconfigured.rs index 7806023f..d1aa8830 100644 --- a/crates/basal-module/src/unconfigured.rs +++ b/crates/basal-module/src/unconfigured.rs @@ -11,7 +11,6 @@ use std::collections::hash_map::RandomState; use std::hash::{BuildHasher, Hasher}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; use basal_host::{ CallClass, CallRequest, Catalog, Consent, ConsentError, DecisionCard, DecisionSink, Dispatched, @@ -91,10 +90,7 @@ impl Host for UnconfiguredHost { } fn now_ms(&self) -> f64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0) + basal_proto::util::unix_millis() as f64 } fn random(&self) -> f64 { diff --git a/crates/basal-module/tests/caller_binding.rs b/crates/basal-module/tests/caller_binding.rs index 9d2e6313..e342ecf7 100644 --- a/crates/basal-module/tests/caller_binding.rs +++ b/crates/basal-module/tests/caller_binding.rs @@ -88,20 +88,10 @@ fn agent_route(handler: &BasalHandler, channel: u16, agent: &str, session: &str) ) } -fn call(f: &Fixture, caller: &Caller, method: &str, params: Value) -> Result { - f.module - .handle(caller, method, params) - .map_err(|e| format!("{}: {}", e.code, e.message)) -} - -fn refused(r: &Result) -> bool { - matches!(r, Err(e) if e.starts_with("not_permitted")) -} +use common::{call, refused}; fn v2() -> Value { - let mut m = events_manifest(FLOW); - m["version"] = json!(2); - m + common::events_v2(FLOW) } /// Every op a flow's owning agent may call on it, refused for `caller`. diff --git a/crates/basal-module/tests/common/mod.rs b/crates/basal-module/tests/common/mod.rs index 325390cb..bf8b5f52 100644 --- a/crates/basal-module/tests/common/mod.rs +++ b/crates/basal-module/tests/common/mod.rs @@ -63,12 +63,23 @@ impl Drop for Fixture { } pub fn scratch(tag: &str) -> PathBuf { - static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!("basal-module-{tag}-{}-{n}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("scratch dir"); - dir + basal_testkit::harness::scratch(&format!("module-{tag}")) +} + +pub fn call(f: &Fixture, caller: &Caller, method: &str, params: Value) -> Result { + f.module + .handle(caller, method, params) + .map_err(|e| format!("{}: {}", e.code, e.message)) +} + +pub fn refused(r: &Result) -> bool { + matches!(r, Err(e) if e.starts_with("not_permitted")) +} + +pub fn events_v2(flow: &str) -> Value { + let mut manifest = events_manifest(flow); + manifest["version"] = json!(2); + manifest } /// The real path of an existing `path`, spelled as a manifest root. On diff --git a/crates/basal-module/tests/decisions.rs b/crates/basal-module/tests/decisions.rs index a90ab933..61433094 100644 --- a/crates/basal-module/tests/decisions.rs +++ b/crates/basal-module/tests/decisions.rs @@ -273,9 +273,7 @@ fn body<'a>(request: &'a Value, field: &str) -> &'a Value { &request["flow_decision"][field] } -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} +use basal_proto::util::hex; #[test] fn a_run_in_needs_reconcile_raises_one_card_per_unknown_call_to_the_operator_only() { diff --git a/crates/basal-module/tests/ops.rs b/crates/basal-module/tests/ops.rs index 98c56cf2..506fa8d1 100644 --- a/crates/basal-module/tests/ops.rs +++ b/crates/basal-module/tests/ops.rs @@ -19,15 +19,7 @@ const SCRIPT: &str = "const r = await ops.call('mock', 'echo', { n: 1 }); return const OWNER: &str = "SYNAPSE"; const FLOW: &str = "flow-own"; -fn call(f: &Fixture, caller: &Caller, method: &str, params: Value) -> Result { - f.module - .handle(caller, method, params) - .map_err(|e| format!("{}: {}", e.code, e.message)) -} - -fn refused(r: &Result) -> bool { - matches!(r, Err(e) if e.starts_with("not_permitted")) -} +use common::{call, refused}; /// A flow owned by SYNAPSE, approved, with a second version installed by /// the owner and pending. @@ -38,9 +30,7 @@ fn owned_flow(tag: &str) -> Fixture { } fn v2() -> Value { - let mut m = events_manifest(FLOW); - m["version"] = json!(2); - m + common::events_v2(FLOW) } /// What an op did for one caller: ran (or failed for a reason other than diff --git a/crates/basal-module/tests/pool.rs b/crates/basal-module/tests/pool.rs index 6bfda55a..88d92386 100644 --- a/crates/basal-module/tests/pool.rs +++ b/crates/basal-module/tests/pool.rs @@ -20,6 +20,7 @@ use common::{ }; use basal_proto::Budgets; +#[cfg(unix)] use serde_json::{Value, json}; const SCRIPT: &str = "const r = await ops.call('mock', 'echo', { n: 1 }); return r.n;"; diff --git a/crates/basal-module/tests/store_dacl.rs b/crates/basal-module/tests/store_dacl.rs index b0970df7..ca41df9d 100644 --- a/crates/basal-module/tests/store_dacl.rs +++ b/crates/basal-module/tests/store_dacl.rs @@ -12,7 +12,6 @@ use basal_core::{Durability, Store}; use std::os::windows::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::ptr::null_mut; -use std::time::{SystemTime, UNIX_EPOCH}; use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree}; use windows_sys::Win32::Security::Authorization::{ ConvertSidToStringSidW, GetNamedSecurityInfoW, SE_FILE_OBJECT, @@ -27,16 +26,7 @@ use windows_sys::Win32::System::SystemServices::ACCESS_ALLOWED_ACE_TYPE; use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; fn scratch(name: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "basal-store-dacl-{name}-{}-{nanos}", - std::process::id() - )); - std::fs::create_dir_all(&dir).unwrap(); - dir + basal_core::test_support::timestamp_scratch("store-dacl", name) } struct Cleanup(PathBuf); diff --git a/crates/basal-proto/src/lib.rs b/crates/basal-proto/src/lib.rs index 08d8cc00..24980583 100644 --- a/crates/basal-proto/src/lib.rs +++ b/crates/basal-proto/src/lib.rs @@ -27,6 +27,7 @@ mod codec; mod frame; mod limits; mod types; +pub mod util; mod wire; pub use codec::DecodeError; diff --git a/crates/basal-proto/src/types.rs b/crates/basal-proto/src/types.rs index d492292d..0df56f06 100644 --- a/crates/basal-proto/src/types.rs +++ b/crates/basal-proto/src/types.rs @@ -120,9 +120,7 @@ impl fmt::Debug for PreludeHash { } } -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} +use crate::util::hex; /// Which host-call primitives a script is given. Both profiles get the same /// primitives (ops, facts, sinks, kv, llm, classify, clock and random) except diff --git a/crates/basal-proto/src/util.rs b/crates/basal-proto/src/util.rs new file mode 100644 index 00000000..73166488 --- /dev/null +++ b/crates/basal-proto/src/util.rs @@ -0,0 +1,60 @@ +//! Small, dependency-free encodings and synchronization shared by the parent +//! and worker crates. + +use std::sync::{Mutex, MutexGuard}; + +use crate::Settlement; + +/// Milliseconds since the Unix epoch, or zero if the clock precedes it. +pub fn unix_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_millis()) +} + +/// Encode bytes as lowercase hexadecimal. +pub fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Recover the protected value after another thread panics while holding it. +pub fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// The settlement spelling stored in the journal and persistent host fixtures. +pub fn settlement_str(settlement: Settlement) -> &'static str { + match settlement { + Settlement::Fulfilled => "fulfilled", + Settlement::Rejected => "rejected", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lowercase_hex_preserves_leading_zeroes() { + assert_eq!(hex(&[]), ""); + assert_eq!(hex(&[0, 1, 15, 16, 255]), "00010f10ff"); + } + + #[test] + fn poisoned_lock_recovers_the_value() { + let mutex = Mutex::new(7); + let _ = std::panic::catch_unwind(|| { + let _guard = mutex.lock().unwrap(); + panic!("poison the mutex"); + }); + assert_eq!(*lock(&mutex), 7); + } + + #[test] + fn settlement_spellings_are_stable() { + assert_eq!(settlement_str(Settlement::Fulfilled), "fulfilled"); + assert_eq!(settlement_str(Settlement::Rejected), "rejected"); + } +} diff --git a/crates/basal-rig/Cargo.toml b/crates/basal-rig/Cargo.toml index 809aac59..ec0e51e4 100644 --- a/crates/basal-rig/Cargo.toml +++ b/crates/basal-rig/Cargo.toml @@ -29,6 +29,7 @@ path = "src/bin/basal-rig-contract.rs" test = true [dependencies] +basal-proto.workspace = true async-trait.workspace = true blake3.workspace = true rusqlite.workspace = true @@ -49,4 +50,3 @@ windows-sys = { version = "0.61", features = [ [dev-dependencies] basal-host.workspace = true -basal-proto.workspace = true diff --git a/crates/basal-rig/src/bin/basal-rig-contract.rs b/crates/basal-rig/src/bin/basal-rig-contract.rs index 07ca5e57..64d6fa5e 100644 --- a/crates/basal-rig/src/bin/basal-rig-contract.rs +++ b/crates/basal-rig/src/bin/basal-rig-contract.rs @@ -32,7 +32,7 @@ //! the test agent through core's relay, never as a local caller. use std::path::PathBuf; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant}; use basal_rig::client::{Client, Relayed, Reply, evidence, suite_identity}; use basal_rig::flows::{self, Flow}; @@ -150,10 +150,7 @@ fn parse_args() -> Result { } fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX)) - .unwrap_or(0) + i64::try_from(basal_proto::util::unix_millis()).unwrap_or(i64::MAX) } // ------------------------------------------------------------------ report @@ -950,7 +947,7 @@ async fn declined_install(rig: &Rig, flow: &Flow) -> Case { case.check( "core holds no install record for the declined flow", installs.as_ref().is_ok_and(|v| v == &json!([])), - json!(installs.map_err(|e| e.to_string())), + json!(installs), ); } case @@ -1689,7 +1686,7 @@ fn kill_evidence(rig: &Rig) -> Value { } /// Compare each send's independent admission evidence with core's durable selector. -async fn check_model_scope(rig: &Rig, case: &mut Case, flow: &Flow, run: &Run, snapshot: &Value) { +fn check_model_scope(rig: &Rig, case: &mut Case, flow: &Flow, run: &Run, snapshot: &Value) { let route = &snapshot["route"]; let ownership = rig.broca.ownership(route); let registered = rig.core.flow_scope(&flow.id, flow.version); @@ -1852,7 +1849,7 @@ async fn models_inner( .flatten() .unwrap_or(Value::Null); if let Some(r) = &run { - check_model_scope(rig, &mut minimal, &first, r, &call["broca"]).await; + check_model_scope(rig, &mut minimal, &first, r, &call["broca"]); } cases.push(minimal); let mut routing = if args.no_kill_hook { @@ -1869,7 +1866,7 @@ async fn models_inner( && before_send["settlement"].is_null() && selected_luna(&before_send["args"]["selection"]) && before_send["args"]["selection"] == call["journal"]["selection"], before_send); if let Some(r) = &run { - check_model_scope(rig, &mut routing, &first, r, &call["broca"]).await; + check_model_scope(rig, &mut routing, &first, r, &call["broca"]); } } cases.push(routing); @@ -1889,9 +1886,22 @@ async fn models_inner( if call.is_null() { return Err("first model call has no Broca snapshot".into()); } - let snapshot = &call["broca"]; cases.push(non_carrier(rig, &first, &run).await?); + models_result(rig, args, agent, tag, cases, (first, run, call)).await +} + +async fn models_result( + rig: &Rig, + args: &Args, + agent: &Agent, + tag: &str, + cases: &mut Vec, + initial: (Flow, Run, Value), +) -> Result<(), String> { + let (first, run, call) = initial; + let snapshot = &call["broca"]; + let mut result = Case::new(MODEL_CASES[2]); let indexed = rig.broca.runs(&snapshot["route"])?; result.record("broca_run_index", json!(indexed)); @@ -1900,7 +1910,7 @@ async fn models_inner( json!({"bind": snapshot["route"], "ownership": rig.broca.ownership(&snapshot["route"])?}), ); result.record("saved_call", call.clone()); - check_model_scope(rig, &mut result, &first, &run, snapshot).await; + check_model_scope(rig, &mut result, &first, &run, snapshot); let text = poll(Duration::from_secs(20), || async { rig.broca.final_text(&snapshot["route"]).ok().flatten() }) @@ -1930,7 +1940,7 @@ async fn models_inner( cases.push(result); let mut tools = Case::new(MODEL_CASES[6]); - check_model_scope(rig, &mut tools, &first, &run, snapshot).await; + check_model_scope(rig, &mut tools, &first, &run, snapshot); let send = frozen_send(snapshot).ok_or("invalid frozen Broca send")?; tools.check( "the frozen Broca send has no tools and tool_choice none", @@ -1938,6 +1948,35 @@ async fn models_inner( send, ); + models_followups(rig, args, agent, tag, cases, snapshot, tools).await +} + +async fn model_cap(rig: &Rig, agent: &Agent, tag: &str, snapshot: &Value) -> Result { + let capped = flows::model(&format!("rig-model-cap-{tag}"), &agent.name, false, true); + let mut cap = Case::new(MODEL_CASES[4]); + if let Some(r) = model_run(rig, agent, &capped, &mut cap).await { + let calls = rig.basal.calls(&capped.id)?; + let route = json!({"project_root": snapshot["route"]["project_root"], "harness": snapshot["route"]["harness"], + "session": format!("basal:flow-{}:{}:0", capped.id, r.run_id)}); + let sends = rig.broca.runs(&route)?; + cap.check("token_cap rejection is journaled with no dispatch, reservation or Broca run", + r.result.as_ref().is_some_and(|v| v["refused"]["data"]["code"] == "token_cap") + && calls.len() == 1 && calls[0].attempts == 0 && calls[0].settlement.as_deref() == Some("rejected") + && rig.basal.ledger(&r.run_id)?.is_empty() && rig.basal.model_call(&r.run_id)?.is_none() && sends.is_empty(), + json!({"result": r.result, "broca_runs": sends, "attempts": calls.first().map(|c| c.attempts)})); + } + Ok(cap) +} + +async fn models_followups( + rig: &Rig, + args: &Args, + agent: &Agent, + tag: &str, + cases: &mut Vec, + snapshot: &Value, + mut tools: Case, +) -> Result<(), String> { let classify = flows::model( &format!("rig-model-classify-{tag}"), &agent.name, @@ -1958,8 +1997,8 @@ async fn models_inner( ); if let Some(r) = &classified { let saved = rig.basal.model_call(&r.run_id)?.unwrap_or(Value::Null); - check_model_scope(rig, &mut label, &classify, r, &saved["broca"]).await; - check_model_scope(rig, &mut tools, &classify, r, &saved["broca"]).await; + check_model_scope(rig, &mut label, &classify, r, &saved["broca"]); + check_model_scope(rig, &mut tools, &classify, r, &saved["broca"]); if !saved.is_null() { label.record( "broca_send", @@ -1977,20 +2016,7 @@ async fn models_inner( } cases.push(label); - let capped = flows::model(&format!("rig-model-cap-{tag}"), &agent.name, false, true); - let mut cap = Case::new(MODEL_CASES[4]); - if let Some(r) = model_run(rig, agent, &capped, &mut cap).await { - let calls = rig.basal.calls(&capped.id)?; - let route = json!({"project_root": snapshot["route"]["project_root"], "harness": snapshot["route"]["harness"], - "session": format!("basal:flow-{}:{}:0", capped.id, r.run_id)}); - let sends = rig.broca.runs(&route)?; - cap.check("token_cap rejection is journaled with no dispatch, reservation or Broca run", - r.result.as_ref().is_some_and(|v| v["refused"]["data"]["code"] == "token_cap") - && calls.len() == 1 && calls[0].attempts == 0 && calls[0].settlement.as_deref() == Some("rejected") - && rig.basal.ledger(&r.run_id)?.is_empty() && rig.basal.model_call(&r.run_id)?.is_none() && sends.is_empty(), - json!({"result": r.result, "broca_runs": sends, "attempts": calls.first().map(|c| c.attempts)})); - } - cases.push(cap); + cases.push(model_cap(rig, agent, tag, snapshot).await?); if args.no_kill_hook { cases.push(Case::skipped(MODEL_CASES[5], NO_KILL_HOOK_REASON)); @@ -2015,8 +2041,8 @@ async fn models_inner( ); if let Some(r) = recovered { let saved = rig.basal.model_call(&r.run_id)?.unwrap_or(Value::Null); - check_model_scope(rig, &mut crash, &flow, &r, &saved["broca"]).await; - check_model_scope(rig, &mut tools, &flow, &r, &saved["broca"]).await; + check_model_scope(rig, &mut crash, &flow, &r, &saved["broca"]); + check_model_scope(rig, &mut tools, &flow, &r, &saved["broca"]); let sends = rig.broca.runs(&saved["broca"]["route"])?; crash.record( "broca_send", diff --git a/crates/basal-testkit/Cargo.toml b/crates/basal-testkit/Cargo.toml index f8112a22..4eff36ed 100644 --- a/crates/basal-testkit/Cargo.toml +++ b/crates/basal-testkit/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true publish.workspace = true rust-version.workspace = true +[features] +test-support = ["basal-core/test-support", "basal-host/test-support"] + [dependencies] basal-core.workspace = true basal-host.workspace = true @@ -32,6 +35,23 @@ windows-sys = { version = "0.61", features = [ serde_json.workspace = true [dev-dependencies] +# Use a distinct name so package-qualified feature flags still name the package. +basal-testkit-test-support = { package = "basal-testkit", path = ".", features = ["test-support"] } basal-host = { workspace = true, features = ["rig-kill-hook"] } jiff.workspace = true subc-protocol.workspace = true + +[[bin]] +name = "basal-test-parent" +path = "src/bin/basal-test-parent.rs" +required-features = ["test-support"] + +[[bin]] +name = "journal-bench" +path = "src/bin/journal-bench.rs" +required-features = ["test-support"] + +[[bin]] +name = "worker-bench" +path = "src/bin/worker-bench.rs" +required-features = ["test-support"] diff --git a/crates/basal-testkit/src/bin/journal-bench.rs b/crates/basal-testkit/src/bin/journal-bench.rs index 16b7c121..de7dd7dd 100644 --- a/crates/basal-testkit/src/bin/journal-bench.rs +++ b/crates/basal-testkit/src/bin/journal-bench.rs @@ -1,7 +1,7 @@ //! Measurements for the parent side: the store and the activation driver. //! //! ```text -//! cargo run --release -p basal-testkit --bin journal-bench -- \ +//! cargo run --release -p basal-testkit --bin journal-bench --features test-support -- \ //! --worker target/release/ck-basal-worker --out evidence/slice-2-measurements.json //! ``` //! diff --git a/crates/basal-testkit/src/https.rs b/crates/basal-testkit/src/https.rs index ac4c328b..91f1f7f8 100644 --- a/crates/basal-testkit/src/https.rs +++ b/crates/basal-testkit/src/https.rs @@ -20,6 +20,52 @@ use rustls::{ServerConfig, ServerConnection, StreamOwned}; /// The host names the certificate covers. pub const TEST_HOSTS: [&str; 3] = ["api.test", "other.test", "evil.test"]; +/// A resolver that records actual lookups before delegating to the fixture's +/// fixed addresses, so authorization tests need not infer DNS from accepts. +pub struct ResolverSpy { + resolver: basal_host::builtins::net::StaticResolver, + calls: AtomicUsize, +} + +impl ResolverSpy { + pub fn new(server: &TestServer) -> Self { + Self { + resolver: basal_host::builtins::net::StaticResolver( + TEST_HOSTS + .iter() + .map(|host| ((*host).to_owned(), vec![server.addr()])) + .collect(), + ), + calls: AtomicUsize::new(0), + } + } + + pub fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +impl basal_host::builtins::net::Resolve for ResolverSpy { + fn resolve(&self, host: &str, port: u16) -> std::io::Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.resolver.resolve(host, port) + } +} + +#[derive(Debug, PartialEq, Eq)] +enum AcceptRetry { + Immediate, + Wait, +} + +fn accept_retry(error: std::io::Error) -> std::io::Result { + match error.kind() { + std::io::ErrorKind::Interrupted => Ok(AcceptRetry::Immediate), + std::io::ErrorKind::WouldBlock => Ok(AcceptRetry::Wait), + _ => Err(error), + } +} + /// One answer the server gives. #[derive(Debug, Clone)] pub struct Reply { @@ -153,8 +199,13 @@ impl TestServer { let _ = serve(tcp, config, &routes, &seen); }); } - Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, - Err(e) => panic!("accept test HTTPS connection: {e}"), + Err(e) => match accept_retry(e) { + Ok(AcceptRetry::Immediate) => continue, + // The fixture normally blocks in accept. If the + // listener ever becomes nonblocking, avoid spinning. + Ok(AcceptRetry::Wait) => thread::park_timeout(Duration::from_millis(1)), + Err(e) => panic!("accept test HTTPS connection: {e}"), + }, } } }) @@ -296,6 +347,25 @@ fn serve( mod tests { use super::*; + #[test] + fn listener_errors_distinguish_interrupt_wait_and_fatal() { + use std::io::{Error, ErrorKind}; + assert_eq!( + accept_retry(Error::from(ErrorKind::Interrupted)).unwrap(), + AcceptRetry::Immediate + ); + assert_eq!( + accept_retry(Error::from(ErrorKind::WouldBlock)).unwrap(), + AcceptRetry::Wait + ); + assert_eq!( + accept_retry(Error::from(ErrorKind::PermissionDenied)) + .unwrap_err() + .kind(), + ErrorKind::PermissionDenied + ); + } + #[test] fn idle_https_accept_blocks_and_shutdown_wakes_it_without_a_request() { let server = TestServer::start(|_| BTreeMap::new()); diff --git a/crates/basal-testkit/src/lib.rs b/crates/basal-testkit/src/lib.rs index bbe57e5c..e139b6e6 100644 --- a/crates/basal-testkit/src/lib.rs +++ b/crates/basal-testkit/src/lib.rs @@ -7,6 +7,8 @@ //! The mutation controls proving these tests catch broken rules are listed in //! `mutations.d/` at the repository root, and `ckdev-mutate` replays them. +#![cfg(any(test, feature = "test-support"))] + mod backoff; pub mod binaries; pub mod channel; @@ -22,6 +24,6 @@ pub mod run_scopes; pub use binaries::{DevBinaries, dev_binary}; pub use channel::{FrameCounts, ProcessChannel, ProcessSource, worker_binary}; -pub use mock::{Answer, MockHost}; +pub use mock::{Answer, ScriptHost}; pub use parent::{Ending, Journal, JournalEntry, Report, TestParent}; pub use process::{ParentError, WorkerProcess}; diff --git a/crates/basal-testkit/src/mock.rs b/crates/basal-testkit/src/mock.rs index bb1b304e..7b98f945 100644 --- a/crates/basal-testkit/src/mock.rs +++ b/crates/basal-testkit/src/mock.rs @@ -38,7 +38,7 @@ pub enum Answer { /// Clock, randomness and op behaviour. #[derive(Debug, Clone)] -pub struct MockHost { +pub struct ScriptHost { /// The next clock read returns this, in milliseconds since the epoch. pub clock_ms: u64, /// Added to the clock after every read, so reads are monotonic. @@ -48,7 +48,7 @@ pub struct MockHost { random_state: u64, } -impl Default for MockHost { +impl Default for ScriptHost { fn default() -> Self { Self { clock_ms: 1_767_225_600_000, // 2026-01-01T00:00:00Z @@ -59,11 +59,9 @@ impl Default for MockHost { } } -fn text(value: &Value) -> JsonText { - JsonText::new(value.to_string()).unwrap_or_else(|_| JsonText::null()) -} +use basal_host::json_text_or_null as text; -impl MockHost { +impl ScriptHost { pub fn advance(&mut self, by: Duration) { self.clock_ms += by.as_millis() as u64; } diff --git a/crates/basal-testkit/src/parent.rs b/crates/basal-testkit/src/parent.rs index ac80bdc5..fab83511 100644 --- a/crates/basal-testkit/src/parent.rs +++ b/crates/basal-testkit/src/parent.rs @@ -11,7 +11,7 @@ use basal_proto::{ Settlement, Welcome, WorkerMessage, }; -use crate::mock::{Answer, MockHost}; +use crate::mock::{Answer, ScriptHost}; use crate::process::{ParentError, WorkerProcess}; /// One journaled call. @@ -131,7 +131,7 @@ pub struct TestParent { binary: PathBuf, worker: Option, pub welcome: Option, - pub host: MockHost, + pub host: ScriptHost, pub journals: HashMap, pub profile: Profile, pub budgets: Budgets, @@ -147,7 +147,7 @@ impl TestParent { binary: binary.as_ref().to_path_buf(), worker: None, welcome: None, - host: MockHost::default(), + host: ScriptHost::default(), journals: HashMap::new(), profile: Profile::Flow, budgets: Budgets::default(), @@ -439,7 +439,7 @@ impl TestParent { } } -fn pending_for(host: &mut MockHost, call: &HostCall, tick: u64) -> Pending { +fn pending_for(host: &mut ScriptHost, call: &HostCall, tick: u64) -> Pending { match host.answer(call) { Answer::Ready { delay_ticks, diff --git a/crates/basal-testkit/tests/builtins_net.rs b/crates/basal-testkit/tests/builtins_net.rs index 7156f45a..165aaf7a 100644 --- a/crates/basal-testkit/tests/builtins_net.rs +++ b/crates/basal-testkit/tests/builtins_net.rs @@ -12,7 +12,7 @@ use std::time::Duration; use basal_host::builtins::net::{self, Client, HostRule, NetConfig, Resolve, StaticResolver}; use basal_host::builtins::{Failure, codes}; use basal_host::{Sent, TransportError, UnknownReason}; -use basal_testkit::https::{Reply, TEST_HOSTS, TestServer}; +use basal_testkit::https::{Reply, ResolverSpy, TEST_HOSTS, TestServer}; use serde_json::{Value, json}; fn routes(_server_port: u16) -> BTreeMap { @@ -217,7 +217,13 @@ fn only_https_urls_are_fetched() { #[test] fn a_host_or_method_not_approved_is_refused_before_connecting() { let server = server(); - let client = client(&server); + let resolver = Arc::new(ResolverSpy::new(&server)); + let client = Client::new(NetConfig { + resolver: resolver.clone(), + trust_anchors: Some(vec![server.trust_der()]), + private_hosts: TEST_HOSTS.iter().map(|h| (*h).to_owned()).collect(), + ..NetConfig::default() + }); let rules = rules(&[("api.test", GET_HEAD), ("other.test", &["POST"])]); assert_eq!( refused(fetch( @@ -236,6 +242,7 @@ fn a_host_or_method_not_approved_is_refused_before_connecting() { codes::DENIED ); assert_eq!(server.connections(), 0); + assert_eq!(resolver.calls(), 0, "unauthorized requests reached DNS"); // The method approved for other.test is sent, with its body. let posted = fetch( &client, @@ -244,6 +251,11 @@ fn a_host_or_method_not_approved_is_refused_before_connecting() { ) .expect("post"); assert_eq!(posted["status"], 201); + assert_eq!( + resolver.calls(), + 1, + "the permitted request must exercise the spy" + ); let seen = server.seen(); assert_eq!(seen.len(), 1); assert_eq!(seen[0].method, "POST"); diff --git a/crates/basal-testkit/tests/dispatch_tokens.rs b/crates/basal-testkit/tests/dispatch_tokens.rs index e3e52139..127d5299 100644 --- a/crates/basal-testkit/tests/dispatch_tokens.rs +++ b/crates/basal-testkit/tests/dispatch_tokens.rs @@ -51,6 +51,141 @@ fn manual(at_ms: i64) -> (Config, Clock) { /// An hour boundary in 2027, plus ten minutes. const T0: i64 = 1_798_761_600_000 + 600_000; +struct StatementTrace<'a> { + store: &'a basal_core::Store, + count: std::sync::Arc, +} + +impl<'a> StatementTrace<'a> { + fn start(store: &'a basal_core::Store) -> Self { + let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + unsafe extern "C" fn record( + event: u32, + context: *mut std::ffi::c_void, + _: *mut std::ffi::c_void, + _: *mut std::ffi::c_void, + ) -> i32 { + if event == rusqlite::ffi::SQLITE_TRACE_STMT as u32 { + // SAFETY: start registers the Arc's stable allocation; Drop + // removes the callback before that allocation is released. + let count = unsafe { &*context.cast::() }; + count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + 0 + } + store + .read(|conn| { + // SAFETY: Store::read holds the connection's lock throughout this + // call. StatementTrace retains the Arc allocation passed as the + // callback context, and unregisters it before dropping that Arc. + let status = unsafe { + rusqlite::ffi::sqlite3_trace_v2( + conn.handle(), + rusqlite::ffi::SQLITE_TRACE_STMT as u32, + Some(record), + std::sync::Arc::as_ptr(&count).cast_mut().cast(), + ) + }; + assert_eq!(status, rusqlite::ffi::SQLITE_OK); + Ok(()) + }) + .unwrap(); + Self { store, count } + } +} + +impl Drop for StatementTrace<'_> { + fn drop(&mut self) { + self.store + .read(|conn| { + // SAFETY: Store::read locks the connection that registered record, + // excluding any concurrent callback while tracing is disabled. + unsafe { + rusqlite::ffi::sqlite3_trace_v2(conn.handle(), 0, None, std::ptr::null_mut()) + }; + Ok(()) + }) + .unwrap(); + } +} + +#[test] +fn batched_token_usage_keeps_current_windows_and_omits_unapproved_flows() { + let world = World::new("tokens-batch"); + let (config, _clock) = manual(T0); + let rt = world + .runtime(std::sync::Arc::new(basal_core::NoHooks), config) + .unwrap(); + for (id, window) in [ + ("hour", Some("1h")), + ("day", Some("1d")), + ("no-model", None), + ("pending", Some("1h")), + ] { + let mut m = manifest(10_000, window.unwrap_or("1h"), 100); + m["id"] = json!(id); + if window.is_none() { + m.as_object_mut().unwrap().remove("llm"); + } + world.spec_with(&rt, "return 1", &m).unwrap(); + } + let hour = 3_600_000; + let start = window_start(T0, hour); + rt.store().write(|tx| { + tx.execute("UPDATE flows SET approved_version=NULL WHERE flow_id='pending'", [])?; + for (window_start, input) in [(start, 7), (start-hour, 999)] { + tx.execute( + "INSERT INTO token_windows (flow_id,window_ms,window_start,reserved,input_tokens,cache_write_tokens,output_tokens,cached_input_tokens,unreported_tokens) VALUES ('hour',?1,?2,3,?3,11,13,17,19)", + rusqlite::params![hour, window_start, input], + )?; + } + Ok(()) + }).unwrap(); + let trace = StatementTrace::start(rt.store()); + let got = rt + .token_usage_many( + &["hour", "day", "no-model", "pending", "missing", "hour"], + T0, + ) + .unwrap(); + let statements = trace.count.load(std::sync::atomic::Ordering::SeqCst); + drop(trace); + assert_eq!( + statements, 1, + "the batch must execute one SQL statement, not one per flow" + ); + assert_eq!( + got.keys().map(String::as_str).collect::>(), + ["day", "hour"] + ); + let usage = &got["hour"]; + assert_eq!( + (usage.window_ms, usage.window_start, usage.reserved), + (hour, start, 3) + ); + assert_eq!( + ( + usage.input_tokens, + usage.cache_write_tokens, + usage.output_tokens, + usage.cached_input_tokens, + usage.unreported_tokens + ), + (7, 11, 13, 17, 19) + ); + assert_eq!(got["day"].counted(), 0); + assert_eq!(got["day"].reserved, 0); + assert_eq!(got["day"].window_ms, 86_400_000); + for id in ["hour", "day"] { + assert_eq!(Some(&got[id]), rt.token_usage(id, T0).unwrap().as_ref()); + } + assert!(rt.token_usage_many(&[], T0).unwrap().is_empty()); + assert_eq!( + rt.token_usage_many(&["hour"], start - hour).unwrap()["hour"].input_tokens, + 999 + ); +} + /// Each call reserves about 1,450 tokens (its envelope's bytes, 256 of /// overhead and its 1,000 of output), so one fits under 2,000 and two do /// not. Both are issued before either completes; the second must see the diff --git a/crates/basal-testkit/tests/flow_scopes.rs b/crates/basal-testkit/tests/flow_scopes.rs index 248d6dbd..43233c8e 100644 --- a/crates/basal-testkit/tests/flow_scopes.rs +++ b/crates/basal-testkit/tests/flow_scopes.rs @@ -77,7 +77,7 @@ impl Transport for Wire { || self.management("prefrontal-core", "flow.target_scope", json!({"flow_id":request.flow_id,"version":call.version,"agent":call.agent,"expires_at_ms":call.expires_at_ms})), ); for key in cache.take_retired() { - self.configure_flow(&key, true, None); + self.configure_flow(&key, None); } let scope = result?; let key = scope.route_key(); @@ -89,7 +89,6 @@ impl Transport for Wire { .unwrap(); self.configure_flow( &key, - true, Some(RegisteredScope { selector: scope.selector(), targets, @@ -100,8 +99,8 @@ impl Transport for Wire { fn provider_ready(&self, flow: &str, module: &str, action: &str) -> Result<(), FlowRefusal> { self.routes.lock().unwrap().ready(flow, module, action) } - fn configure_flow(&self, flow: &str, owned: bool, scope: Option) { - self.routes.lock().unwrap().configure(flow, owned, scope); + fn configure_flow(&self, flow: &str, scope: Option) { + self.routes.lock().unwrap().configure(flow, scope); } fn catalog(&self) -> Result { let mut modules = vec![ @@ -994,7 +993,7 @@ fn core_flow_ops_require_a_scope_and_never_change_script_arguments() { *wire.core_capable.lock().unwrap() = true; let mut core_scope = scope(1); core_scope.targets.insert("prefrontal-core".into()); - host.configure_flow("flow-a", true, Some(core_scope)); + host.configure_flow("flow-a", Some(core_scope)); host.dispatch(&request).unwrap(); assert_eq!( wire.calls.lock().unwrap()[0], @@ -1004,7 +1003,7 @@ fn core_flow_ops_require_a_scope_and_never_change_script_arguments() { management_body("board.read", args) ) ); - host.configure_flow("flow-a", false, None); + host.configure_flow("flow-a", None); assert!(matches!( host.dispatch(&request), Err(basal_host::TransportError::Refused(FlowRefusal { @@ -1286,7 +1285,7 @@ fn answer(reference: &str, epoch: u64, expiry: i64) -> Result #[test] fn rolling_target_scopes_preserve_each_runs_pinned_ref() { let f = target_fixture("rolling-target-scopes", "return 1;"); - f.wire.configure_flow("flow-test", false, Some(scope(1))); + f.wire.configure_flow("flow-test", Some(scope(1))); f.wire .target_answers .lock() @@ -1323,7 +1322,7 @@ fn rolling_target_scopes_preserve_each_runs_pinned_ref() { #[test] fn target_scope_expiry_and_deadline_extension_reask_and_expired_refs_are_dropped() { let f = target_fixture("expired-target-scopes", "return 1;"); - f.wire.configure_flow("flow-test", false, Some(scope(1))); + f.wire.configure_flow("flow-test", Some(scope(1))); f.wire.target_answers.lock().unwrap().extend([ answer("first", 1, 200), answer("second", 1, 400), @@ -1345,7 +1344,7 @@ fn target_scope_expiry_and_deadline_extension_reask_and_expired_refs_are_dropped #[test] fn closed_target_scope_reasks_and_never_reopens_the_ended_ref() { let f = target_fixture("closed-target-scopes", "return 1;"); - f.wire.configure_flow("flow-test", false, Some(scope(1))); + f.wire.configure_flow("flow-test", Some(scope(1))); f.wire.target_answers.lock().unwrap().extend([ answer("first", 1, 200), answer("first", 1, 200), @@ -1372,7 +1371,7 @@ fn closed_target_scope_reasks_and_never_reopens_the_ended_ref() { #[test] fn changed_target_epoch_drops_old_routes_and_invalidates_old_run_pins() { let f = target_fixture("changed-target-epoch", "return 1;"); - f.wire.configure_flow("flow-test", false, Some(scope(1))); + f.wire.configure_flow("flow-test", Some(scope(1))); f.wire .target_answers .lock() diff --git a/crates/basal-testkit/tests/global_model_scope.rs b/crates/basal-testkit/tests/global_model_scope.rs index 0848fdce..36e9f90d 100644 --- a/crates/basal-testkit/tests/global_model_scope.rs +++ b/crates/basal-testkit/tests/global_model_scope.rs @@ -44,8 +44,8 @@ struct Models { sends: Mutex>, } impl Transport for Models { - fn configure_flow(&self, flow: &str, owned: bool, scope: Option) { - self.routes.lock().unwrap().configure(flow, owned, scope); + fn configure_flow(&self, flow: &str, scope: Option) { + self.routes.lock().unwrap().configure(flow, scope); } fn provider_ready(&self, flow: &str, action: &str) -> Result<(), FlowRefusal> { self.routes.lock().unwrap().ready(flow, "broca", action) diff --git a/crates/basal-testkit/tests/package_instances.rs b/crates/basal-testkit/tests/package_instances.rs index 276f1004..cc682800 100644 --- a/crates/basal-testkit/tests/package_instances.rs +++ b/crates/basal-testkit/tests/package_instances.rs @@ -48,6 +48,28 @@ fn admit(rt: &basal_core::Runtime, id: &str, trigger: &str) -> String { admission.run_id().unwrap().to_owned() } +#[test] +fn batched_token_usage_reads_package_approvals_and_omits_revoked_instances() { + let world = World::new("pkg-token-batch"); + let rt = runtime(&world, InstallGate::Core); + let id = ensure(&rt, "return 1", 1, 1); + let usage = rt.token_usage_many(&[&id], 1_000).unwrap(); + assert_eq!(usage.len(), 1); + assert_eq!(usage[&id].window_ms, 86_400_000); + assert_eq!(usage[&id].window_start, 0); + assert_eq!(usage[&id].counted(), 0); + assert_eq!( + Some(&usage[&id]), + rt.token_usage(&id, 1_000).unwrap().as_ref() + ); + rt.store().write(|tx| { + tx.execute("INSERT INTO instance_revocations (flow_id,version,reason,revoked_at) VALUES (?1,1,'revoked',1)", [&id])?; + Ok(()) + }).unwrap(); + assert!(rt.token_usage_many(&[&id], 1_000).unwrap().is_empty()); + assert!(rt.token_usage(&id, 1_000).unwrap().is_none()); +} + #[test] fn self_grants_resolve_to_the_owner_and_sink_calls_carry_stable_id() { let world = World::new("pkg-resolution"); diff --git a/crates/basal-testkit/tests/schedule_tick.rs b/crates/basal-testkit/tests/schedule_tick.rs index 704ecd73..389876a1 100644 --- a/crates/basal-testkit/tests/schedule_tick.rs +++ b/crates/basal-testkit/tests/schedule_tick.rs @@ -243,7 +243,13 @@ fn new_runs(report: &basal_core::schedule::TickReport) -> Vec { } fn id(due: &str) -> String { - format!("schedule:{due}") + basal_core::schedule::fire_trigger_id(ts(due)) +} + +#[test] +fn schedule_fire_ids_keep_the_literal_timestamp_contract() { + assert_eq!(id("2026-05-01T01:00:00Z"), "schedule:2026-05-01T01:00:00Z"); + assert_eq!(id("2026-05-01T10:15:07Z"), "schedule:2026-05-01T10:15:07Z"); } /// Ticks once a minute from `from` to `to` inclusive and returns, for @@ -458,10 +464,7 @@ fn a_sleep_gap_under_each_fires_the_newest_up_to_the_cap_oldest_first() { ] ); for (trigger_id, trigger, _) in fx.fires("hourly").into_iter().skip(1) { - assert_eq!( - trigger_id, - format!("schedule:{}", trigger["due"].as_str().expect("due")) - ); + assert_eq!(trigger_id, id(trigger["due"].as_str().expect("due"))); assert_eq!( catch_up(&trigger), &json!({ diff --git a/crates/basal-worker/Cargo.toml b/crates/basal-worker/Cargo.toml index 3ed997c6..085970e4 100644 --- a/crates/basal-worker/Cargo.toml +++ b/crates/basal-worker/Cargo.toml @@ -52,7 +52,7 @@ deviations = [] [dev-dependencies] serde_json.workspace = true -basal-testkit.workspace = true +basal-testkit = { workspace = true, features = ["test-support"] } # On Windows every test spawn of the worker goes through the launcher, under # the real confinement. Its test variants break one confinement property at a diff --git a/crates/basal-worker/src/engine.rs b/crates/basal-worker/src/engine.rs index 49878b9d..d51b56b1 100644 --- a/crates/basal-worker/src/engine.rs +++ b/crates/basal-worker/src/engine.rs @@ -720,7 +720,7 @@ pub(crate) fn run_activation_with_raw_bridge( fn run( request: &ActivationRequest, link: Rc>, - #[allow(unused_variables)] expose_raw_bridge: bool, + #[cfg_attr(not(test), expect(unused_variables))] expose_raw_bridge: bool, prefix: Vec, ) -> ActivationResult { let bridge = match plan(request, prefix) { diff --git a/mutations.d/core-authority.toml b/mutations.d/core-authority.toml index ebd05f80..37bfc6ed 100644 --- a/mutations.d/core-authority.toml +++ b/mutations.d/core-authority.toml @@ -967,3 +967,19 @@ timeout_s = 600 file = "crates/basal-core/src/authorize.rs" old = "if !tag\n .as_str()\n .is_some_and(crate::manifest::valid_status_label)" new = "if false && !tag\n .as_str()\n .is_some_and(crate::manifest::valid_status_label) // NON-VACUITY BREAK" +[[control]] +id = "token-usage-batch-executes-an-extra-query" +guards = "the flow.health token batch executes exactly one SQL statement" +file = "crates/basal-core/src/tokens.rs" +old = "let ids = serde_json::json!(flow_ids).to_string();" +new = """let ids = serde_json::json!(flow_ids).to_string(); + conn.query_row("SELECT 1", [], |r| r.get::<_, i64>(0))?; // NON-VACUITY BREAK""" +test_file = "crates/basal-testkit/tests/dispatch_tokens.rs" +runner = "cargo" +package = "basal-testkit" +platforms = ["linux"] +target = "--test dispatch_tokens" +expect_red = ["batched_token_usage_keeps_current_windows_and_omits_unapproved_flows"] +only = true +timeout_s = 600 +build_timeout_s = 3600 diff --git a/mutations.d/core-codemode.toml b/mutations.d/core-codemode.toml index 4c683a84..a4e9fed0 100644 --- a/mutations.d/core-codemode.toml +++ b/mutations.d/core-codemode.toml @@ -1669,7 +1669,7 @@ only = false file = "crates/basal-core/src/codemode/supervisor.rs" old = '''shared .transport - .configure_flow(&format!("codemode:{id}"), false, None);''' + .configure_flow(&format!("codemode:{id}"), None);''' new = 'let _ = id; // NON-VACUITY BREAK' [[control]] diff --git a/mutations.d/core-install.toml b/mutations.d/core-install.toml index 5484bb7f..2b58a6fa 100644 --- a/mutations.d/core-install.toml +++ b/mutations.d/core-install.toml @@ -514,6 +514,7 @@ new = "#[serde(default)]\n pub iq: u32," [[control]] id = "selector-iq-upper-bound-omitted" +scope = { after = "fn check_host_grants(", before = "\n fn " } guards = "selector: iq upper bound omitted" test_file = "crates/basal-testkit/tests/dispatch_manifest.rs" runner = "cargo" @@ -526,10 +527,11 @@ build_timeout_s = 3600 timeout_s = 600 file = "crates/basal-core/src/manifest.rs" old = "if llm.iq > 100 {" -new = "if false {" +new = "if false { // NON-VACUITY BREAK" [[control]] id = "selector-eq-upper-bound-omitted" +scope = { after = "fn check_host_grants(", before = "\n fn " } guards = "selector: eq upper bound omitted" test_file = "crates/basal-testkit/tests/dispatch_manifest.rs" runner = "cargo" @@ -542,7 +544,7 @@ build_timeout_s = 3600 timeout_s = 600 file = "crates/basal-core/src/manifest.rs" old = "if llm.eq > 100 {" -new = "if false {" +new = "if false { // NON-VACUITY BREAK" [[control]] id = "install-a-root-that-does-not-exist-is-accepted" @@ -562,6 +564,7 @@ new = "let exists = root.starts_with('/') || root.starts_with('~'); // NON-VACUI [[control]] id = "install-wildcard-and-ip-literal-hosts-are-accepted" +scope = { after = "fn check_host_grants(", before = "\n fn " } guards = "install: wildcard and IP-literal hosts are accepted" test_file = "crates/basal-testkit/tests/builtins_dispatch.rs" runner = "cargo" @@ -578,6 +581,7 @@ new = "let Some(problem) = None::<&str> /* NON-VACUITY BREAK */" [[control]] id = "install-any-method-name-is-accepted" +scope = { after = "fn check_host_grants(", before = "\n fn " } guards = "install: any method name is accepted" test_file = "crates/basal-testkit/tests/builtins_dispatch.rs" runner = "cargo" @@ -1006,6 +1010,7 @@ new = '#[serde(tag = "kind", rename_all = "snake_case")] // NON-VACUITY BREAK' [[control]] id = "audience-sinks-allow-named-targets" +scope = { after = "fn check_targets(", before = "\n fn " } guards = "audience sink declarations cannot mix static named targets" test_file = "crates/basal-core/tests/audience.rs" runner = "cargo" @@ -1022,6 +1027,7 @@ new = 'if false && agent != AUDIENCE_TARGET { // NON-VACUITY BREAK' [[control]] id = "audience-placeholder-without-reach" +scope = { after = "fn check_targets(", before = "\n fn " } guards = "the audience placeholder requires an audience" test_file = "crates/basal-core/tests/audience.rs" runner = "cargo" @@ -1156,6 +1162,7 @@ new = '} else if !catalog.agent_known(agent) && field != "audience.id" { // NON- [[control]] id = "target-grant-requires-audience-at-install" +scope = { after = "fn check_targets(", before = "\n fn " } guards = "Delegating grants without an audience are refused when validating installs" test_file = "crates/basal-module/tests/audience.rs" runner = "cargo" @@ -1172,6 +1179,7 @@ new = "if false && o.for_target.is_some() && self.audience.is_none() { // NON-VA [[control]] id = "target-audience-run-bound-limit" +scope = { after = "fn check_runtime_limits(", before = "\n ///" } guards = "Audience manifests cannot promise a run longer than half the daemon scope limit" test_file = "crates/basal-core/tests/audience.rs" runner = "cargo" @@ -1188,6 +1196,7 @@ new = "if false && self.audience.is_some() && self.deadline_ms(600_000) > 12 * 6 [[control]] id = "target-max-targets-range" +scope = { after = "fn check_targets(", before = "\n fn " } guards = "max_targets remains bounded between one and 1024" test_file = "crates/basal-core/tests/audience.rs" runner = "cargo" @@ -1221,6 +1230,7 @@ new = "let _ = (field, spelling); Ok(()) // NON-VACUITY BREAK" [[control]] id = "digest-status-group-validation" +scope = { after = "fn check_targets(", before = "\n fn " } guards = "digest status groups accept only one to four uppercase ASCII letters" test_file = "crates/basal-core/src/manifest.rs" runner = "cargo" diff --git a/mutations.d/core-runtime.toml b/mutations.d/core-runtime.toml index cb768998..c62ecb5a 100644 --- a/mutations.d/core-runtime.toml +++ b/mutations.d/core-runtime.toml @@ -57,6 +57,7 @@ new = "if true || row.class != StoredClass::Sync || row.outcome.is_some() {" [[control]] id = "clock-reads-not-kept-monotonic-within-a-run" +scope = { after = "fn commit_sync_call(", before = "\n ///" } guards = "clock reads not kept monotonic within a run" test_file = "crates/basal-testkit/tests/journal_races.rs" runner = "cargo" @@ -67,9 +68,9 @@ expect_red = ["clock_stays_monotonic_across_activations"] only = false build_timeout_s = 3600 timeout_s = 600 -edits = [ - { file = "crates/basal-core/src/driver.rs", old = "(Some(c), Some(last)) => Some(c.max(last)),", new = "(Some(c), Some(_)) => Some(c)," }, -] +file = "crates/basal-core/src/driver.rs" +old = "(Some(c), Some(last)) => Some(c.max(last))," +new = "(Some(c), Some(_)) => Some(c), // NON-VACUITY BREAK" [[control]] id = "recovery-resends-a-call-this-process-is-still-dispatching" @@ -845,6 +846,7 @@ new = " limit: limits.max_dispatches,\n" [[control]] id = "selector-selection-checkpoint-moved-after-intent" +scope = { after = "fn commit_host_call(", before = "\n fn " } guards = "selector: selection checkpoint moved after intent" test_file = "crates/basal-module/tests/hosts.rs" runner = "cargo" @@ -856,8 +858,8 @@ only = false build_timeout_s = 3600 timeout_s = 600 edits = [ - { file = "crates/basal-core/src/driver.rs", old = " if matches!(\n call.kind,\n CallKind::Primitive(Primitive::Llm | Primitive::Classify)\n ) {\n self.at(Boundary::ModelSelected {\n position: call.position,\n })?;\n }\n let new = NewCall {", new = " let new = NewCall {" }, - { file = "crates/basal-core/src/driver.rs", old = " self.at(Boundary::CallCommitted {\n position: call.position,\n })?;", new = " self.at(Boundary::CallCommitted {\n position: call.position,\n })?; if matches!(call.kind, CallKind::Primitive(Primitive::Llm | Primitive::Classify)) { self.at(Boundary::ModelSelected {position:call.position})?; }" }, + { file = "crates/basal-core/src/driver.rs", old = " if matches!(\n call.kind,\n CallKind::Primitive(Primitive::Llm | Primitive::Classify)\n ) {\n self.at(Boundary::ModelSelected {\n position: call.position,\n })?;\n }\n let new = NewCall {", new = " let new = NewCall { // NON-VACUITY BREAK" }, + { file = "crates/basal-core/src/driver.rs", old = " self.at(Boundary::CallCommitted {\n position: call.position,\n })?;", new = " self.at(Boundary::CallCommitted {\n position: call.position,\n })?; if matches!(call.kind, CallKind::Primitive(Primitive::Llm | Primitive::Classify)) { self.at(Boundary::ModelSelected {position:call.position})?; } // NON-VACUITY BREAK" }, ] [[control]] diff --git a/mutations.d/host-builtins.toml b/mutations.d/host-builtins.toml index b75b2d17..50e1a967 100644 --- a/mutations.d/host-builtins.toml +++ b/mutations.d/host-builtins.toml @@ -1108,3 +1108,19 @@ only = false platforms = ["macos"] timeout_s = 600 build_timeout_s = 3600 +[[control]] +id = "net-unauthorized-fetch-resolves-before-refusal" +guards = "an unauthorized net.fetch is refused before any DNS lookup" +file = "crates/basal-host/src/builtins/net.rs" +old = "let approval = allowed_url(rules, &url, &method);" +new = """let approval = allowed_url(rules, &url, &method); + if approval.is_err() { let _ = self.config.resolver.resolve(&url.host, url.port); } // NON-VACUITY BREAK""" +test_file = "crates/basal-testkit/tests/builtins_net.rs" +runner = "cargo" +package = "basal-testkit" +platforms = ["linux"] +target = "--test builtins_net" +expect_red = ["a_host_or_method_not_approved_is_refused_before_connecting"] +only = true +timeout_s = 600 +build_timeout_s = 3600 diff --git a/mutations.d/module.toml b/mutations.d/module.toml index 28b200ad..6f0137ff 100644 --- a/mutations.d/module.toml +++ b/mutations.d/module.toml @@ -502,6 +502,7 @@ new = "Caller::Agent { agent_id, .. } => Ok(json!({ \"scope\": agent_id }))," [[control]] id = "a-pending-card-is-raised-again-in-the-name-of-whoever-first-installed-it" +scope = { after = "fn raise_install_card(", before = "\n fn " } guards = "a pending card is raised again in the name of whoever first installed it" test_file = "crates/basal-module/tests/hosts.rs" runner = "cargo" @@ -514,7 +515,7 @@ build_timeout_s = 3600 timeout_s = 600 file = "crates/basal-module/src/ops.rs" old = "raised_fields[\"wire_author\"] = card_author(caller)?;" -new = "let _ = card_author(caller)?;" +new = "let _ = card_author(caller)?; // NON-VACUITY BREAK" [[control]] id = "an-idle-worker-bound-to-any-flow-is-reused-for-another" @@ -1697,9 +1698,11 @@ target = "--test codemode" expect_red = ["codemode_pool_retires_even_an_unused_lease"] only = true file = "crates/basal-module/src/pool.rs" -old = '(Binding::DryRun(_) | Binding::Codemode, false) => None,' -new = '''(Binding::DryRun(_), false) => None, - (Binding::Codemode, false) => Some("codemode".into()), // NON-VACUITY BREAK''' +old = '''(Binding::Flow(flow), false) if !state.stopped => Some(flow.clone()), + _ => None,''' +new = '''(Binding::Flow(flow), false) if !state.stopped => Some(flow.clone()), + (Binding::Codemode, false) => Some("codemode".into()), // NON-VACUITY BREAK + _ => None,''' [[control]] id = "s7-codemode-linux-address-space" diff --git a/mutations.d/scripts.toml b/mutations.d/scripts.toml index 2b524383..4a9966d3 100644 --- a/mutations.d/scripts.toml +++ b/mutations.d/scripts.toml @@ -1,5 +1,21 @@ # script/: staging, signing, the flows rig and the repository fences. +[[control]] +id = "stage-test-support-marker-refusal" +guards = "production staging rejects test-support markers in either binary" +file = "script/stage.sh" +old = '[ "$support" = 0 ] || die' +new = '[ "$support" -ge 0 ] || die # NON-VACUITY BREAK' +test_file = "script/tests/stage_card.py" +runner = "command" +platforms = ["linux"] +command = ["python3.14", "-m", "unittest", "{test}"] +test_count_pattern = "Ran {count} test" +expect_red = ["script.tests.stage_card.StageBinaryChecks.test_test_support_is_rejected_in_each_staged_binary"] +only = false +timeout_s = 120 +build_timeout_s = 1800 + [[control]] id = "rig-routing-pin-accepts-a-catalog-without-luna" guards = "the routing pin refuses a served catalog without openai/gpt-6-luna" diff --git a/script/stage.sh b/script/stage.sh index 9f766c23..d6181b3d 100755 --- a/script/stage.sh +++ b/script/stage.sh @@ -50,6 +50,9 @@ control_of() { # The rig's kill switch reads this variable; only a rig-kill-hook build # contains it, and a production ck-basal must not. KILL_HOOK_NEEDLE=BASAL_RIG_KILL_FILE +# basal-host retains BASAL_TEST_SUPPORT_ENABLED in ck-basal's entry point, +# so test-support builds are detectable even if unused test doubles disappear. +TEST_SUPPORT_NEEDLE=BASAL_TEST_SUPPORT_ENABLED say() { printf '%s\n' "$*"; } die() { printf 'stage: %s\n' "$*" >&2; exit 1; } @@ -178,11 +181,15 @@ say "ck-basal carries no rig kill switch ($KILL_HOOK_NEEDLE: 0)" # OSO entries pointing at object files on the build machine). Cargo strips # release builds, but a stripping failure is only a warning, so check. for bin in $BINARIES; do + support_table=$(strings "$TARGET/release/$bin") || die "cannot inspect $bin for test support; nothing staged" + support=$(printf '%s\n' "$support_table" | grep -cF -- "$TEST_SUPPORT_NEEDLE" || true) + [ "$support" = 0 ] || die "$bin contains test support ($TEST_SUPPORT_NEEDLE); nothing staged" symbols=$(nm -a "$TARGET/release/$bin") || die "cannot inspect $bin's symbols; nothing staged" oso=$(printf '%s\n' "$symbols" | grep -c ' OSO ' || true) [ "$oso" = 0 ] || die "$bin still carries $oso debug-map entries: stripping failed; nothing staged" done say "debug map: 0 entries in each binary" +say "test support: 0 markers in each binary" # ---------------------------------------------------------------- sign diff --git a/script/tests/stage_card.py b/script/tests/stage_card.py index e20ced41..e8d7e9e6 100644 --- a/script/tests/stage_card.py +++ b/script/tests/stage_card.py @@ -35,6 +35,7 @@ def test_binary_inspection_fails_closed(self): TARGET=/unused BINARIES="ck-basal ck-basal-worker" KILL_HOOK_NEEDLE=BASAL_RIG_KILL_FILE +TEST_SUPPORT_NEEDLE=BASAL_TEST_SUPPORT_ENABLED say() { :; } die() { echo "$*" >&2; exit 1; } strings() { :; } @@ -44,6 +45,35 @@ def test_binary_inspection_fails_closed(self): capture_output=True, check=False) self.assertNotEqual(result.returncode, 0, reader + ' failure was accepted') + def test_test_support_is_rejected_in_each_staged_binary(self): + checks = SOURCE.split('[ -z "$(git -C "$ROOT" status --porcelain --untracked-files=all)" ]')[1] + checks = checks.split('\n', 2)[2].split('# ---------------------------------------------------------------- sign')[0] + for contaminated in ['ck-basal', 'ck-basal-worker', 'none']: + with self.subTest(binary=contaminated): + code = '''set -eu +TARGET=/unused +BINARIES="ck-basal ck-basal-worker" +KILL_HOOK_NEEDLE=BASAL_RIG_KILL_FILE +TEST_SUPPORT_NEEDLE=BASAL_TEST_SUPPORT_ENABLED +say() { :; } +die() { echo "$*" >&2; exit 1; } +nm() { :; } +strings() { + case "$1" in + */''' + contaminated + ''') printf '%s\\n' BASAL_TEST_SUPPORT_ENABLED ;; + *) printf '%s\\n' production ;; + esac +} +''' + checks + result = subprocess.run(['/bin/sh', '-c', code], text=True, + capture_output=True, check=False) + if contaminated == 'none': + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0, + contaminated + ' test support was accepted') + self.assertIn(contaminated + ' contains test support', result.stderr) + class StageSmokeNameChecks(unittest.TestCase): def test_signed_bytes_run_only_under_development_names(self): From 5b41c518f0c1a7b6ab1e7ba1763e4c826f2aa2c0 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:59:46 +0200 Subject: [PATCH 2/2] mason: keep staging inspections independently fail-closed Reuse ck-basal's checked strings table instead of reading it again for the test-support marker. Isolate strings and nm failures per executable, including partial stdout, so a later failing read cannot hide an earlier fail-open inspection. Preserve the existing strings and symbol mutation anchors unchanged. Use python3 in the new command control, matching the CI runner's available interpreter. The support-marker, strings-failure and symbol-failure controls are caught on Linux, with exact named failures and indexed applied/restored diffs; all 84 script tests and repository script fences pass. --- mutations.d/scripts.toml | 2 +- script/stage.sh | 5 ++++- script/tests/stage_card.py | 20 ++++++++++++++++---- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/mutations.d/scripts.toml b/mutations.d/scripts.toml index 4a9966d3..08172073 100644 --- a/mutations.d/scripts.toml +++ b/mutations.d/scripts.toml @@ -9,7 +9,7 @@ new = '[ "$support" -ge 0 ] || die # NON-VACUITY BREAK' test_file = "script/tests/stage_card.py" runner = "command" platforms = ["linux"] -command = ["python3.14", "-m", "unittest", "{test}"] +command = ["python3", "-m", "unittest", "{test}"] test_count_pattern = "Ran {count} test" expect_red = ["script.tests.stage_card.StageBinaryChecks.test_test_support_is_rejected_in_each_staged_binary"] only = false diff --git a/script/stage.sh b/script/stage.sh index d6181b3d..9b427e04 100755 --- a/script/stage.sh +++ b/script/stage.sh @@ -181,7 +181,10 @@ say "ck-basal carries no rig kill switch ($KILL_HOOK_NEEDLE: 0)" # OSO entries pointing at object files on the build machine). Cargo strips # release builds, but a stripping failure is only a warning, so check. for bin in $BINARIES; do - support_table=$(strings "$TARGET/release/$bin") || die "cannot inspect $bin for test support; nothing staged" + case "$bin" in + ck-basal) support_table=$hook_table ;; + *) support_table=$(strings "$TARGET/release/$bin") || die "cannot inspect $bin for test support; nothing staged" ;; + esac support=$(printf '%s\n' "$support_table" | grep -cF -- "$TEST_SUPPORT_NEEDLE" || true) [ "$support" = 0 ] || die "$bin contains test support ($TEST_SUPPORT_NEEDLE); nothing staged" symbols=$(nm -a "$TARGET/release/$bin") || die "cannot inspect $bin's symbols; nothing staged" diff --git a/script/tests/stage_card.py b/script/tests/stage_card.py index e8d7e9e6..4f672751 100644 --- a/script/tests/stage_card.py +++ b/script/tests/stage_card.py @@ -29,8 +29,11 @@ class StageBinaryChecks(unittest.TestCase): def test_binary_inspection_fails_closed(self): checks = SOURCE.split('[ -z "$(git -C "$ROOT" status --porcelain --untracked-files=all)" ]')[1] checks = checks.split('\n', 2)[2].split('# ---------------------------------------------------------------- sign')[0] - for reader in ['strings', 'nm']: - with self.subTest(reader=reader): + # Isolate each reader's failure to one executable so another failed + # inspection cannot mask a fail-open check on the first executable. + for reader, binary in [('strings', 'ck-basal'), ('strings', 'ck-basal-worker'), + ('nm', 'ck-basal'), ('nm', 'ck-basal-worker')]: + with self.subTest(reader=reader, binary=binary): code = '''set -eu TARGET=/unused BINARIES="ck-basal ck-basal-worker" @@ -40,10 +43,19 @@ def test_binary_inspection_fails_closed(self): die() { echo "$*" >&2; exit 1; } strings() { :; } nm() { :; } -''' + reader + '() { return 17; }\n' + checks +''' + reader + '''() { + for inspected in "$@"; do + case "$inspected" in + */''' + binary + ''') printf '%s\\n' 'partial inspection'; return 17 ;; + esac + done + return 0 +} +''' + checks result = subprocess.run(['/bin/sh', '-c', code], text=True, capture_output=True, check=False) - self.assertNotEqual(result.returncode, 0, reader + ' failure was accepted') + self.assertNotEqual(result.returncode, 0, + reader + ' failure was accepted for ' + binary) def test_test_support_is_rejected_in_each_staged_binary(self): checks = SOURCE.split('[ -z "$(git -C "$ROOT" status --porcelain --untracked-files=all)" ]')[1]