diff --git a/Cargo.lock b/Cargo.lock index bf3873ae..26dbfa62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3622,7 +3622,6 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "nexum-runtime", - "nexum-status-body", "nexum-tasks", "redb", "serde", @@ -5175,6 +5174,7 @@ dependencies = [ "nexum-runtime", "shepherd-cow-host", "tokio", + "videre-host", ] [[package]] @@ -6040,6 +6040,23 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "videre-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "futures", + "nexum-runtime", + "nexum-status-body", + "nexum-tasks", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "wasmtime", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 0a562877..0d85dead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/shepherd-cow-host", "crates/shepherd-sdk", "crates/shepherd-sdk-test", + "crates/videre-host", "modules/ethflow-watcher", "modules/example", "modules/examples/balance-tracker", diff --git a/crates/nexum-runtime/Cargo.toml b/crates/nexum-runtime/Cargo.toml index 7b66aa90..63c4be27 100644 --- a/crates/nexum-runtime/Cargo.toml +++ b/crates/nexum-runtime/Cargo.toml @@ -36,9 +36,6 @@ tokio.workspace = true # Task lifecycle and graceful shutdown; the sole crate that raw-spawns # tokio tasks. Every engine task routes through its executor. nexum-tasks = { path = "../nexum-tasks" } -# Encoder for the opaque status body the host `event` stream carries; -# the router lowers an adapter-reported status through it. -nexum-status-body = { path = "../nexum-status-body" } # Manifest parsing. serde.workspace = true diff --git a/crates/nexum-runtime/examples/embed.rs b/crates/nexum-runtime/examples/embed.rs index 7d92343f..feba0440 100644 --- a/crates/nexum-runtime/examples/embed.rs +++ b/crates/nexum-runtime/examples/embed.rs @@ -3,7 +3,7 @@ //! //! [`CoreRuntime`] is the domain-free preset: it bundles the reference core //! backends (chain provider pool, local redb store, empty extension slot) and -//! the Prometheus add-on. A domain capability such as cow-api is added by +//! the Prometheus add-on. A domain capability is added by //! writing a preset that names its extension builder in the `Ext` slot and //! returns its linker extensions, or by dropping to the explicit //! `with_components` builder path. The returned [`RuntimeHandle`] carries the diff --git a/crates/nexum-runtime/src/bindings.rs b/crates/nexum-runtime/src/bindings.rs index 022fcdf1..46fb2424 100644 --- a/crates/nexum-runtime/src/bindings.rs +++ b/crates/nexum-runtime/src/bindings.rs @@ -3,19 +3,18 @@ //! The core host binds the `nexum:host/event-module` world: the six core //! primitives. Outbound HTTP is not a `nexum:host` interface: it is //! wasi:http, linked separately; clocks are ambient wasi:clocks. Domain -//! extensions such as cow-api bind their own world and wire themselves in -//! at the composition root; they are not part of this core surface. +//! extensions bind their own world and wire themselves in at the +//! composition root; they are not part of this core surface. //! //! Every `Host` trait impl in `crate::host::impls` consumes types //! generated here. //! -//! `nexum:host` is a leaf package: the host `event` variant carries an -//! intent-status transition as opaque bytes, so the core world resolves -//! against `wit/nexum-host` alone. The videre vocabulary generates in the -//! adapter bindgen below, and the client bindgen remaps onto it with -//! `with`, so one Rust type serves the registry and the adapter face -//! alike. `PartialEq` is derived so the registry can compare a polled -//! status against the last delivered one. +//! `nexum:host` is a leaf package: the host `event` variant carries a +//! status transition as opaque bytes, so the core world resolves against +//! `wit/nexum-host` alone. An extension's bindgen remaps onto the shared +//! interfaces here with `with`, so the `Host` impls and the `fault` type +//! its components see are the very ones the core host constructs. +//! `PartialEq` is derived so extension services can compare event payloads. wasmtime::component::bindgen!({ path: ["../../wit/nexum-host"], @@ -24,233 +23,3 @@ wasmtime::component::bindgen!({ exports: { default: async }, additional_derives: [PartialEq], }); - -/// WIT bindings for the second component kind: the -/// `videre:venue/venue-adapter` world. An adapter imports only the scoped -/// transport it needs (chain and messaging; outbound HTTP is wasi:http, -/// linked and allowlisted separately as for event-module) and exports the -/// `videre:venue/adapter` face plus `init`. The shared `nexum:host` -/// interfaces are reused from the `event-module` bindings above via -/// `with`, so the `chain`/`messaging` `Host` impls and the `fault` type -/// an adapter sees are the very ones the core host constructs. The -/// `videre:types` and `videre:value-flow` types generate here (the leaf -/// host world no longer reaches them) and the client bindgen below remaps -/// onto them. -mod venue_adapter { - wasmtime::component::bindgen!({ - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", - ], - world: "videre:venue/venue-adapter", - imports: { default: async }, - exports: { default: async }, - with: { - "nexum:host/types": super::nexum::host::types, - "nexum:host/chain": super::nexum::host::chain, - "nexum:host/messaging": super::nexum::host::messaging, - }, - additional_derives: [PartialEq], - }); -} - -pub use venue_adapter::VenueAdapter; - -/// The keeper-facing `videre:venue/client` import bound host-side. The -/// world imports the interface a module calls; the videre types it uses -/// are reused from the adapter bindings above via `with`, so the -/// `SubmitOutcome` and `VenueError` the registry hands back to a module -/// are the very ones an adapter's `submit` produced - no lift between two -/// structurally identical copies. Async, because the `Host` impl awaits the -/// per-adapter mutex and the adapter's own async guest calls. -mod client_host { - wasmtime::component::bindgen!({ - inline: " - package videre:client-host; - world client-host { - import videre:venue/client@0.1.0; - } - ", - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", - ], - imports: { default: async }, - with: { - "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, - "videre:types/types": super::venue_adapter::videre::types::types, - }, - }); -} - -/// The host-bound client interface: the `Host` trait the registry implements -/// and the `add_to_linker` the module linker calls. -pub use client_host::videre::venue::client; -/// The registry-observed status transition delivered through the `event` -/// variant, re-exported at the plain spelling the registry names. -pub use nexum::host::types::IntentStatusUpdate; -/// The shared intent ontology, re-exported at the plain spellings the -/// registry and the `client::Host` impl name. -pub use venue_adapter::videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, - UnsignedTx, VenueError, -}; -/// The value-flow vocabulary the header is expressed in. -pub use venue_adapter::videre::value_flow::types as value_flow; - -/// Bindgen smoke for the `videre:value-flow` types package, compiled under -/// test through a throwaway world that imports the interface. Its value is -/// the identifier-hygiene gate: the test names every generated type, -/// variant, and field by its plain Rust spelling, so a WIT id that collided -/// with a Rust keyword would surface as an `r#` escape and fail to compile -/// here rather than in a downstream binding. -#[cfg(test)] -mod value_flow_smoke { - wasmtime::component::bindgen!({ - inline: " - package videre:value-flow-smoke; - world smoke { - import videre:value-flow/types@0.1.0; - } - ", - path: ["../../wit/videre-value-flow"], - }); - - #[test] - fn identifiers_bind_unescaped() { - use videre::value_flow::types::{Asset, AssetAmount, Erc20}; - - let erc20 = Erc20 { - token: vec![0u8; 20], - }; - let _ = Asset::Native; - let asset = Asset::Erc20(erc20); - - let amount = AssetAmount { - asset, - amount: Vec::new(), - }; - assert!(amount.amount.is_empty()); - } -} - -/// Bindgen smoke for the `videre:types` and `videre:venue` packages, -/// mirroring the value-flow smoke above: a throwaway world imports the -/// client interface and, transitively, the types interface and its -/// value-flow dependency. The test names every generated type, case, and -/// field by its plain Rust spelling, and a dummy `client` host impl pins -/// the four function signatures, so a keyword collision or an accidental -/// signature change fails this build rather than a downstream binding. -#[cfg(test)] -mod client_smoke { - wasmtime::component::bindgen!({ - inline: " - package videre:client-smoke; - world smoke { - import videre:venue/client@0.1.0; - } - ", - path: [ - "../../wit/videre-value-flow", - "../../wit/videre-types", - "../../wit/nexum-host", - "../../wit/videre-venue", - ], - }); - - use videre::types::types::{ - AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, - UnsignedTx, VenueError, - }; - use videre::value_flow::types::{Asset, AssetAmount}; - - struct DummyClient; - - impl videre::venue::client::Host for DummyClient { - fn quote(&mut self, _venue: String, _body: Vec) -> Result { - Err(VenueError::UnknownVenue) - } - - fn submit(&mut self, _venue: String, _body: Vec) -> Result { - Err(VenueError::UnknownVenue) - } - - fn status( - &mut self, - _venue: String, - _receipt: Vec, - ) -> Result { - Err(VenueError::UnknownVenue) - } - - fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { - Err(VenueError::UnknownVenue) - } - } - - fn amount(bytes: Vec) -> AssetAmount { - AssetAmount { - asset: Asset::Native, - amount: bytes, - } - } - - #[test] - fn identifiers_bind_unescaped() { - use videre::venue::client::Host; - - let _ = AuthScheme::Eip1271; - let _ = AuthScheme::Eip712; - - let header = IntentHeader { - gives: amount(vec![1]), - wants: amount(Vec::new()), - settlement: Settlement { chain: 1 }, - authorisation: AuthScheme::Eip712, - }; - assert!(header.wants.amount.is_empty()); - - let _ = IntentStatus::Pending; - let _ = IntentStatus::Open; - let _ = IntentStatus::Fulfilled; - let _ = IntentStatus::Cancelled; - let _ = IntentStatus::Expired; - - let tx = UnsignedTx { - chain: 1, - to: Vec::new(), - value: Vec::new(), - data: Vec::new(), - }; - let _ = SubmitOutcome::Accepted(Vec::new()); - let _ = SubmitOutcome::RequiresSigning(tx); - - let quotation = Quotation { - gives: amount(vec![1]), - wants: amount(Vec::new()), - fee: amount(Vec::new()), - valid_until_ms: 0, - }; - assert!(quotation.fee.amount.is_empty()); - - let _ = VenueError::UnknownVenue; - let _ = VenueError::InvalidBody(String::new()); - let _ = VenueError::Unsupported; - let _ = VenueError::Denied(String::new()); - let _ = VenueError::RateLimited(RateLimit { - retry_after_ms: Some(250), - }); - let _ = VenueError::Unavailable(String::new()); - let _ = VenueError::Timeout; - - let mut client = DummyClient; - assert!(client.quote(String::new(), Vec::new()).is_err()); - assert!(client.submit(String::new(), Vec::new()).is_err()); - assert!(client.status(String::new(), Vec::new()).is_err()); - assert!(client.cancel(String::new(), Vec::new()).is_err()); - } -} diff --git a/crates/nexum-runtime/src/bootstrap.rs b/crates/nexum-runtime/src/bootstrap.rs index 4e0c3c18..9ae57a94 100644 --- a/crates/nexum-runtime/src/bootstrap.rs +++ b/crates/nexum-runtime/src/bootstrap.rs @@ -3,7 +3,7 @@ //! //! Parameterised over the [`RuntimeTypes`] lattice. The composition root //! builds the concrete [`Components`] and the extension list (including any -//! domain extension such as cow-api) and hands them here; this thin wrapper +//! domain extension) and hands them here; this thin wrapper //! forwards to the [`builder`](crate::builder) launcher and blocks until the //! event loop returns. A launcher that wants the //! [`RuntimeHandle`](crate::builder::RuntimeHandle) back drives diff --git a/crates/nexum-runtime/src/builder.rs b/crates/nexum-runtime/src/builder.rs index 7c836f07..47145902 100644 --- a/crates/nexum-runtime/src/builder.rs +++ b/crates/nexum-runtime/src/builder.rs @@ -32,7 +32,7 @@ use crate::engine_config::EngineConfig; use crate::host::component::{ BuilderContext, ComponentBuilder, Components, ComponentsBuilder, RuntimeTypes, }; -use crate::host::extension::Extension; +use crate::host::extension::{EventSources, Extension}; use crate::host::logs::LogPipeline; use crate::preset::Runtime; use crate::runtime::event_loop; @@ -268,19 +268,29 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // the components. let logs = components.logs.clone(); let chain_log_subs = supervisor.chain_log_subscriptions(); - // Status polling runs only when it can produce something a module - // will see: at least one intent-status subscriber, a registered - // venue-registry service, and at least one installed adapter to poll. - let status_registry = supervisor - .has_intent_status_subscribers() - .then(|| supervisor.venue_registry()) - .flatten() - .filter(|registry| registry.venue_count() > 0); - let poll_statuses = status_registry.is_some(); + // Extension event sources open only for subscription kinds some + // loaded module declares; each extension gates further on its own + // service state and returns no stream when it has nothing to + // observe. + let subscribed = supervisor.extension_subscription_kinds(); + let mut reconnect_tasks = TaskSet::new(); + let mut extension_streams = Vec::new(); + { + let mut sources = EventSources::new( + engine_cfg, + supervisor.services(), + &subscribed, + &executor, + &mut reconnect_tasks, + ); + for ext in &extensions { + extension_streams.extend(ext.events(&mut sources)?); + } + } // No subscriptions: nothing to drive. Return a handle whose event loop // is already complete so `wait` resolves immediately. - if block_chains.is_empty() && chain_log_subs.is_empty() && !poll_statuses { + if block_chains.is_empty() && chain_log_subs.is_empty() && extension_streams.is_empty() { if supervisor.dead_modules_hold_subscriptions() { anyhow::bail!( "every declared [[subscription]] belongs to an init-failed module - \ @@ -301,7 +311,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { // Open per-chain block subscriptions + per-module chain-log // subscriptions through the executor, then drive them in the event // loop until shutdown. - let mut reconnect_tasks = TaskSet::new(); let block_streams = event_loop::open_block_streams( &components.chain, &block_chains, @@ -314,15 +323,6 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &executor, &mut reconnect_tasks, ); - let intent_status_stream = status_registry.map(|registry| { - event_loop::open_intent_status_stream( - registry, - engine_cfg.limits.status_poll_interval(), - &executor, - &mut reconnect_tasks, - ) - }); - // The event-loop task holds the graceful guard until `run` returns // (after its final dispatch and cursor commit); shutdown ends the // loop between dispatches rather than cancelling it, so the drain @@ -333,7 +333,7 @@ impl LaunchRuntime for AssembledRuntime<'_, T> { &mut supervisor, block_streams, chain_log_streams, - intent_status_stream, + extension_streams, reconnect_tasks, graceful.into_future(), ) @@ -447,7 +447,7 @@ impl<'a, R: Runtime> PresetBuilder<'a, R> { data_dir: &data_dir, executor: &executor, }; - let mut extensions = self.preset.extensions(); + let mut extensions = self.preset.extensions(self.config); extensions.extend(self.extensions); // `add_ons` owns the boxed add-ons; `add_on_refs` borrows into it and is // consumed by the launch call, so both must stay in scope for that call. @@ -689,7 +689,7 @@ mod tests { Vec::new() } - fn extensions(&self) -> Vec>> { + fn extensions(&self, _config: &EngineConfig) -> Vec>> { vec![Arc::new(CountingExt { namespace: "alpha", prefix: "alpha:ext/", diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index 6d79c95c..c950cf1f 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -26,15 +26,108 @@ use strum::IntoStaticStr; use thiserror::Error; use tracing::{info, warn}; -use crate::host::venue_registry::{ - DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW, DEFAULT_WATCH_EXPIRY, - DEFAULT_WATCH_MAX_ENTRIES, SubmitQuota, WatchLimit, -}; use crate::runtime::dispatch_rate::{ DEFAULT_DISPATCH_BURST, DEFAULT_DISPATCH_REFILL_PER_SEC, DispatchRatePolicy, }; use crate::runtime::poison_policy::{POISON_MAX_FAILURES, POISON_WINDOW, PoisonPolicy}; +/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. +pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; +/// Default sliding window the per-caller submission budget is counted over. +pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); +/// Default cap on receipts under status watch at once. +pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; +/// Default base window: the poll cadence a healthy provider refreshes +/// within. The give-up deadline is the derived `grace`, not this directly. +pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); +/// Derived grace defaults to this many `expiry` windows, so a watch rides +/// out a provider outage of a couple of poll cadences before giving up. +pub const WATCH_GRACE_MULTIPLIER: u64 = 2; +/// Ceiling on the derived grace window, so a long `expiry` cannot stretch +/// the give-up deadline past a day. +pub const WATCH_GRACE_MAX: Duration = Duration::from_secs(86_400); + +/// The give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. +/// An explicit `grace_secs` in config overrides this. +const fn derive_grace(expiry: Duration) -> Duration { + let scaled = expiry.as_secs().saturating_mul(WATCH_GRACE_MULTIPLIER); + let capped = if scaled < WATCH_GRACE_MAX.as_secs() { + scaled + } else { + WATCH_GRACE_MAX.as_secs() + }; + Duration::from_secs(capped) +} + +/// Per-caller submission quota toward installed providers. Both a +/// forwarded submission and a charged decode failure consume one unit; +/// the window slides so a caller's budget refills as old charges age out. +/// Resolved from `[limits.quota]`; the extension service that meters +/// callers consumes it. +#[derive(Debug, Clone, Copy)] +pub struct SubmitQuota { + /// Maximum charges a single caller may accrue within `window`. + pub max_charges: u32, + /// Sliding window the charges are counted across. + pub window: Duration, +} + +impl SubmitQuota { + /// Pair a budget with the window it is counted over. + pub const fn new(max_charges: u32, window: Duration) -> Self { + Self { + max_charges, + window, + } + } +} + +impl Default for SubmitQuota { + fn default() -> Self { + Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) + } +} + +/// Bounds on a provider status-watch set. The cap bounds the per-cadence +/// poll fan-out; `grace` is the give-up deadline: how long a watch rides +/// out an unreachable provider before it is evicted unreported. `expiry` +/// is the base window `grace` derives from. Resolved from `[limits.watch]`. +#[derive(Debug, Clone, Copy)] +pub struct WatchLimit { + /// Maximum receipts under status watch at once. + pub max_entries: usize, + /// Base window a healthy provider refreshes the deadline within. + pub expiry: Duration, + /// Give-up deadline: how long a watch survives an unreachable provider + /// before it is evicted unreported. A reachable poll (the provider + /// answered) resets it; a resolve failure or an errored poll rides out + /// against it. Derived `min(MULTIPLIER * expiry, MAX)` unless set. + pub grace: Duration, +} + +impl WatchLimit { + /// Pair a cap with the base expiry; `grace` derives from `expiry`. + pub const fn new(max_entries: usize, expiry: Duration) -> Self { + Self::with_grace(max_entries, expiry, derive_grace(expiry)) + } + + /// As [`new`](Self::new) but with an explicit `grace` window, the path + /// a configured `grace_secs` takes. + pub const fn with_grace(max_entries: usize, expiry: Duration, grace: Duration) -> Self { + Self { + max_entries, + expiry, + grace, + } + } +} + +impl Default for WatchLimit { + fn default() -> Self { + Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) + } +} + /// Errors surfaced by [`load_or_default`]. /// /// Library-side modules must not propagate `anyhow::Error`; the rust @@ -89,11 +182,11 @@ pub struct EngineConfig { /// `docs/03-module-discovery.md`. #[serde(default)] pub modules: Vec, - /// Venue adapters the supervisor should boot alongside the modules. - /// Each entry resolves a `(component.wasm, module.toml)` pair like a - /// module, but the operator scopes its transport here rather than in - /// the adapter's own manifest: the installer of a venue adapter, not - /// the adapter author, decides which hosts and messaging topics it may + /// Provider components the supervisor should boot alongside the + /// modules. Each entry resolves a `(component.wasm, module.toml)` pair + /// like a module, but the operator scopes its transport here rather + /// than in the provider's own manifest: the installer of a provider, + /// not its author, decides which hosts and messaging topics it may /// reach. #[serde(default)] pub adapters: Vec, @@ -287,9 +380,9 @@ const DEFAULT_LOG_BYTES_PER_RUN: usize = 256 * 1024; /// history for diagnosis without unbounded growth. const DEFAULT_LOG_RUNS_RETAINED: usize = 16; -/// Default cadence for registry-driven intent status polling (5 s). Fast -/// enough that a settling intent is observed within a block time or two, -/// slow enough that per-receipt venue calls stay negligible. +/// Default cadence for provider status polling (5 s). Fast enough that a +/// settling submission is observed within a block time or two, slow +/// enough that per-receipt provider calls stay negligible. const DEFAULT_STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5); /// Saturate an operator-supplied millisecond knob into [1 ms, 24 h]: @@ -354,10 +447,10 @@ pub struct ModuleLimits { /// Poison-pill quarantine thresholds. #[serde(default)] pub poison: PoisonLimitsSection, - /// Per-caller intent submission quota. + /// Per-caller provider submission quota. #[serde(default)] pub quota: QuotaLimitsSection, - /// Router-driven intent status polling cadence. + /// Provider status polling cadence. #[serde(default)] pub status_poll: StatusPollSection, /// Status-watch set bounds. @@ -494,9 +587,9 @@ impl ModuleLimits { } /// Resolved per-caller submission quota (overrides or defaults). A zero - /// `max_charges` is saturated up to 1 by the registry builder, so a - /// misconfigured budget still admits one submission rather than bricking - /// every venue. + /// `max_charges` is saturated up to 1 by the consuming service, so a + /// misconfigured budget still admits one submission rather than + /// bricking every provider. pub fn quota(&self) -> SubmitQuota { SubmitQuota::new( self.quota.max_charges.unwrap_or(DEFAULT_QUOTA_MAX_CHARGES), @@ -617,13 +710,13 @@ pub struct PoisonLimitsSection { pub window_secs: Option, } -/// `[limits.quota]` per-caller intent submission budget. Both optional; -/// omitted values resolve to the registry defaults via [`ModuleLimits::quota`]. +/// `[limits.quota]` per-caller provider submission budget. Both optional; +/// omitted values resolve to the defaults via [`ModuleLimits::quota`]. /// /// A caller (a strategy module, keyed by its namespace) may accrue at most /// `max_charges` submissions within a sliding `window_secs`; a decode failure /// charged back to the caller counts the same, so a module feeding garbage -/// bodies exhausts its own budget rather than the adapter's fuel. +/// bodies exhausts its own budget rather than the provider's fuel. #[derive(Debug, Default, Deserialize)] pub struct QuotaLimitsSection { /// Maximum submissions (plus charged decode failures) per caller in the @@ -633,13 +726,13 @@ pub struct QuotaLimitsSection { pub window_secs: Option, } -/// `[limits.status_poll]` intent status polling cadence. Optional; an +/// `[limits.status_poll]` provider status polling cadence. Optional; an /// omitted value resolves to the built-in default and a degenerate zero /// saturates up to 1 ms via [`ModuleLimits::status_poll_interval`]. /// -/// The cadence is how often the registry polls each installed adapter's -/// `status` export for the receipts it watches; only observed transitions -/// fan out as `intent-status` events. +/// The cadence is how often the consuming service polls each installed +/// provider's `status` export for the receipts it watches; only observed +/// transitions fan out as events. #[derive(Debug, Default, Deserialize)] pub struct StatusPollSection { /// Milliseconds between status poll sweeps. @@ -647,13 +740,13 @@ pub struct StatusPollSection { } /// `[limits.watch]` status-watch set bounds. Both optional; omitted -/// values resolve to the registry defaults via [`ModuleLimits::watch`] -/// and degenerate zeroes saturate up to a usable minimum. +/// values resolve to the defaults via [`ModuleLimits::watch`] and +/// degenerate zeroes saturate up to a usable minimum. /// -/// The registry watches each accepted receipt until a terminal status: -/// the cap bounds the per-cadence poll fan-out, and the give-up deadline -/// evicts a watch whose venue stays unreachable. At the cap a new watch is -/// refused and logged; live watches are never dropped. +/// The consuming service watches each accepted receipt until a terminal +/// status: the cap bounds the per-cadence poll fan-out, and the give-up +/// deadline evicts a watch whose provider stays unreachable. At the cap a +/// new watch is refused and logged; live watches are never dropped. #[derive(Debug, Default, Deserialize)] pub struct WatchLimitsSection { /// Maximum receipts under status watch at once. @@ -1088,9 +1181,9 @@ window_secs = 0 let cfg: EngineConfig = toml::from_str( r#" [[adapters]] -path = "adapters/cow/cow_adapter.wasm" -http_allow = ["api.cow.fi", "*.cow.fi"] -messaging_topics = ["/nexum/1/cow-orders/proto"] +path = "providers/acme/acme_provider.wasm" +http_allow = ["api.acme.example", "*.acme.example"] +messaging_topics = ["/nexum/1/acme-orders/proto"] [[adapters]] path = "adapters/bare/bare.wasm" @@ -1100,10 +1193,13 @@ manifest = "adapters/bare/module.toml" .expect("adapters parse"); assert_eq!(cfg.adapters.len(), 2); let first = &cfg.adapters[0]; - assert_eq!(first.path, PathBuf::from("adapters/cow/cow_adapter.wasm")); + assert_eq!( + first.path, + PathBuf::from("providers/acme/acme_provider.wasm") + ); assert!(first.manifest.is_none(), "manifest defaults to sibling"); - assert_eq!(first.http_allow, vec!["api.cow.fi", "*.cow.fi"]); - assert_eq!(first.messaging_topics, vec!["/nexum/1/cow-orders/proto"]); + assert_eq!(first.http_allow, vec!["api.acme.example", "*.acme.example"]); + assert_eq!(first.messaging_topics, vec!["/nexum/1/acme-orders/proto"]); let second = &cfg.adapters[1]; assert_eq!( second.manifest.as_deref(), diff --git a/crates/nexum-runtime/src/host/component/runtime_types.rs b/crates/nexum-runtime/src/host/component/runtime_types.rs index a96cbeb1..0540e4d4 100644 --- a/crates/nexum-runtime/src/host/component/runtime_types.rs +++ b/crates/nexum-runtime/src/host/component/runtime_types.rs @@ -5,7 +5,7 @@ //! Time, randomness, and outbound HTTP are deliberately not members: all //! are WASI concerns serviced per store (WasiCtxBuilder for clocks and //! randomness, wasi:http behind the allowlist gate), not host backends. -//! Domain backends such as cow-api are not core seams: they live behind +//! Domain backends are not core seams: they live behind //! the [`RuntimeTypes::Ext`] slot and are wired in as extensions. use crate::host::component::{ChainProvider, StateStore}; diff --git a/crates/nexum-runtime/src/host/error.rs b/crates/nexum-runtime/src/host/error.rs index 8bdb4f9b..65f0c303 100644 --- a/crates/nexum-runtime/src/host/error.rs +++ b/crates/nexum-runtime/src/host/error.rs @@ -49,7 +49,7 @@ pub(crate) fn fault_message(fault: &Fault) -> &str { /// A structured JSON-RPC `ErrorResp` (the node returned a `code`, /// typically `-32000` for an `eth_call` revert) becomes a /// [`ChainError::Rpc`] carrying that code and any decoded revert bytes, -/// so the SDK revert classifier can dispatch the ComposableCoW +/// so an SDK revert classifier can dispatch the revert /// envelopes. Everything else - transport failures, an unknown chain, /// bad params - becomes a shared [`Fault`]. impl From for ChainError { diff --git a/crates/nexum-runtime/src/host/extension.rs b/crates/nexum-runtime/src/host/extension.rs index 00d82442..dfa05f7f 100644 --- a/crates/nexum-runtime/src/host/extension.rs +++ b/crates/nexum-runtime/src/host/extension.rs @@ -1,16 +1,22 @@ //! The extension seam: what one extension contributes to the host - a //! namespace, a capability namespace, a linker hook, an optional host -//! service, and an optional provider kind. Assembled at the composition -//! root and threaded into every module linker. +//! service, an optional provider kind, and optional event sources. +//! Assembled at the composition root and threaded into every module +//! linker. use std::any::Any; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use std::pin::Pin; use std::sync::Arc; use async_trait::async_trait; +use futures::Stream; +use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; use wasmtime::Store; use wasmtime::component::{Component, Linker}; +use crate::bindings::nexum::host::types::Event; +use crate::engine_config::EngineConfig; use crate::host::actor::Liveness; use crate::host::component::RuntimeTypes; use crate::host::state::HostState; @@ -43,6 +49,78 @@ pub trait Extension: Send + Sync + 'static { fn provider(&self) -> Option>> { None } + + /// Manifest subscription kinds this extension's event sources emit. + /// A `[[subscription]]` entry of any other non-core kind is refused + /// at boot. + fn subscriptions(&self) -> &'static [&'static str] { + &[] + } + + /// Open the extension's event sources once the engine is booted. The + /// event loop merges the returned streams and dispatches each item to + /// the modules its kind and attributes admit. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + let _ = sources; + Ok(Vec::new()) + } +} + +/// One extension-observed event: dispatched to every module holding a +/// `[[subscription]]` of `kind` whose filters all match `attrs`. +pub struct ExtensionEvent { + /// Manifest subscription kind that routes this event. + pub kind: &'static str, + /// Routing attributes a subscription's filters match against. + pub attrs: Vec<(&'static str, String)>, + /// The host event delivered to each matching module. + pub event: Event, +} + +/// A stream of extension events the event loop merges and drives. +pub type ExtensionEventStream = Pin + Send>>; + +/// Ambient launch inputs for [`Extension::events`]: the loaded config, the +/// booted service map, the subscription kinds at least one module declares, +/// and the spawn surface for source tasks. +pub struct EventSources<'a> { + /// The loaded engine config. + pub config: &'a EngineConfig, + /// Extension-owned services, as booted. + pub services: &'a HostServices, + /// Extension subscription kinds declared by at least one module. + pub subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, +} + +impl<'a> EventSources<'a> { + /// Bundle the launch inputs for one [`Extension::events`] pass. + pub fn new( + config: &'a EngineConfig, + services: &'a HostServices, + subscribed: &'a BTreeSet, + executor: &'a TaskExecutor, + tasks: &'a mut TaskSet, + ) -> Self { + Self { + config, + services, + subscribed, + executor, + tasks, + } + } + + /// Spawn one event-source task through the engine's executor. The task + /// must end when its stream's receiver drops; the engine drains it on + /// shutdown. + pub fn spawn(&mut self, task: impl Future + Send + 'static) { + self.tasks.push(self.executor.spawn(async move { + task.await; + TaskExit::ReceiverGone + })); + } } /// A type-erased host service an extension owns. Held per namespace on @@ -212,13 +290,13 @@ mod tests { #[test] fn get_downcasts_by_namespace() { let services = - HostServices::from_extensions(&[ext("videre", Arc::new(Registry(7)))]).expect("build"); + HostServices::from_extensions(&[ext("acme", Arc::new(Registry(7)))]).expect("build"); - let registry = services.get::("videre").expect("registered"); + let registry = services.get::("acme").expect("registered"); assert_eq!(registry.0, 7); - assert!(services.get::("videre").is_none()); + assert!(services.get::("acme").is_none()); assert!(services.get::("absent").is_none()); - assert!(services.raw("videre").is_some()); + assert!(services.raw("acme").is_some()); } /// A serviceless extension contributes nothing to the map. @@ -236,10 +314,10 @@ mod tests { #[test] fn duplicate_namespace_is_refused() { let err = HostServices::from_extensions(&[ - ext("videre", Arc::new(Registry(1))), - ext("videre", Arc::new(Clockwork)), + ext("acme", Arc::new(Registry(1))), + ext("acme", Arc::new(Clockwork)), ]) .expect_err("duplicate namespace"); - assert!(err.to_string().contains("videre"), "{err}"); + assert!(err.to_string().contains("acme"), "{err}"); } } diff --git a/crates/nexum-runtime/src/host/http.rs b/crates/nexum-runtime/src/host/http.rs index ec74e6c8..2624457e 100644 --- a/crates/nexum-runtime/src/host/http.rs +++ b/crates/nexum-runtime/src/host/http.rs @@ -268,26 +268,53 @@ mod tests { #[test] fn exact_host_passes() { - assert!(admit(&uri("https://api.cow.fi/v1/x"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("http://api.cow.fi/"), &allow(&["api.cow.fi"])).is_ok()); + assert!( + admit( + &uri("https://api.acme.example/v1/x"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("http://api.acme.example/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); } #[test] fn off_list_host_is_denied() { - assert!(denied("https://evil.example/", &["api.cow.fi"])); - assert!(denied("https://api.cow.fi.evil.example/", &["api.cow.fi"])); + assert!(denied("https://evil.example/", &["api.acme.example"])); + assert!(denied( + "https://api.acme.example.evil.example/", + &["api.acme.example"] + )); } #[test] fn empty_allowlist_denies_everything() { - assert!(denied("https://api.cow.fi/", &[])); + assert!(denied("https://api.acme.example/", &[])); assert!(denied("http://127.0.0.1/", &[])); } #[test] fn matching_is_case_insensitive() { - assert!(admit(&uri("https://API.COW.FI/"), &allow(&["api.cow.fi"])).is_ok()); - assert!(admit(&uri("https://api.cow.fi/"), &allow(&["API.COW.FI"])).is_ok()); + assert!( + admit( + &uri("https://API.ACME.EXAMPLE/"), + &allow(&["api.acme.example"]) + ) + .is_ok() + ); + assert!( + admit( + &uri("https://api.acme.example/"), + &allow(&["API.ACME.EXAMPLE"]) + ) + .is_ok() + ); } #[test] @@ -301,7 +328,10 @@ mod tests { #[test] fn exact_entry_does_not_match_subdomains() { - assert!(denied("https://sub.api.cow.fi/", &["api.cow.fi"])); + assert!(denied( + "https://sub.api.acme.example/", + &["api.acme.example"] + )); } #[test] @@ -321,13 +351,16 @@ mod tests { #[test] fn ports_do_not_affect_matching() { - let list = allow(&["api.cow.fi"]); - assert!(admit(&uri("https://api.cow.fi:8443/v1"), &list).is_ok()); - assert!(admit(&uri("http://api.cow.fi:80/v1"), &list).is_ok()); - assert!(denied("https://evil.example:443/", &["api.cow.fi"])); + let list = allow(&["api.acme.example"]); + assert!(admit(&uri("https://api.acme.example:8443/v1"), &list).is_ok()); + assert!(admit(&uri("http://api.acme.example:80/v1"), &list).is_ok()); + assert!(denied("https://evil.example:443/", &["api.acme.example"])); // A port spelled in the allowlist entry never matches: entries // are hosts, not authorities. - assert!(denied("https://api.cow.fi:8443/", &["api.cow.fi:8443"])); + assert!(denied( + "https://api.acme.example:8443/", + &["api.acme.example:8443"] + )); } // ----------------- SSRF-style bypass regressions (#57) --------- @@ -449,14 +482,14 @@ mod tests { for scheme in ["http", "https"] { assert!( admit( - &uri(&format!("{scheme}://api.cow.fi/")), - &allow(&["api.cow.fi"]) + &uri(&format!("{scheme}://api.acme.example/")), + &allow(&["api.acme.example"]) ) .is_ok() ); assert!(denied( &format!("{scheme}://evil.example/"), - &["api.cow.fi"] + &["api.acme.example"] )); } } @@ -464,7 +497,7 @@ mod tests { #[test] fn uri_without_authority_is_invalid_not_denied() { assert!(matches!( - admit(&uri("/relative/path"), &allow(&["api.cow.fi"])), + admit(&uri("/relative/path"), &allow(&["api.acme.example"])), Err(ErrorCode::HttpRequestUriInvalid) )); } @@ -491,7 +524,7 @@ mod tests { #[tokio::test] async fn send_request_denies_off_list_host_with_http_request_denied() { - let mut gate = HttpGate::new("test-module", allow(&["api.cow.fi"]), limits()); + let mut gate = HttpGate::new("test-module", allow(&["api.acme.example"]), limits()); let Err(err) = gate.send_request(request("http://evil.example/x"), config()) else { panic!("off-list host must be denied"); }; diff --git a/crates/nexum-runtime/src/host/impls/messaging.rs b/crates/nexum-runtime/src/host/impls/messaging.rs index bd450cb3..4cf45da5 100644 --- a/crates/nexum-runtime/src/host/impls/messaging.rs +++ b/crates/nexum-runtime/src/host/impls/messaging.rs @@ -1,7 +1,7 @@ //! `nexum:host/messaging`: the Waku backend is deferred to 0.3, so //! `publish` reports `unsupported` and `query` returns empty, the same //! posture as `identity::accounts`. The per-store topic scope is enforced -//! ahead of that stub: a venue adapter carrying a +//! ahead of that stub: a provider carrying a //! `[[adapters]].messaging_topics` grant may only publish within it, so //! the egress boundary is live even though delivery is not. @@ -15,7 +15,7 @@ use crate::host::state::HostState; /// when it equals a scope entry or descends from one read as a path prefix /// (`/nexum/1/` scopes the whole family beneath it). The prefix boundary is /// the `/` path separator, so a grant never leaks into a longer sibling -/// segment (`/nexum/1/cow` does not admit `/nexum/1/cow-orders/...`). +/// segment (`/nexum/1/acme` does not admit `/nexum/1/acme-orders/...`). fn topic_in_scope(topic: &str, scope: &[String]) -> bool { if scope.is_empty() { return true; @@ -63,27 +63,27 @@ mod tests { #[test] fn exact_topic_is_admitted() { - let scope = vec!["/nexum/1/cow-orders/proto".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + let scope = vec!["/nexum/1/acme-orders/proto".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); assert!(!topic_in_scope("/nexum/1/other/proto", &scope)); } #[test] fn prefix_scope_admits_the_family_but_not_a_sibling() { let scope = vec!["/nexum/1/".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + assert!(topic_in_scope("/nexum/1/acme-orders/proto", &scope)); assert!(topic_in_scope("/nexum/1/twap/proto", &scope)); // A sibling namespace stays out. - assert!(!topic_in_scope("/nexum/2/cow-orders/proto", &scope)); + assert!(!topic_in_scope("/nexum/2/acme-orders/proto", &scope)); } #[test] fn prefix_boundary_is_a_path_segment_not_a_substring() { // A scope entry without a trailing slash still bounds on the path // separator, so it cannot leak into a longer sibling segment. - let scope = vec!["/nexum/1/cow".to_owned()]; - assert!(topic_in_scope("/nexum/1/cow", &scope)); - assert!(topic_in_scope("/nexum/1/cow/orders", &scope)); - assert!(!topic_in_scope("/nexum/1/cow-orders/proto", &scope)); + let scope = vec!["/nexum/1/acme".to_owned()]; + assert!(topic_in_scope("/nexum/1/acme", &scope)); + assert!(topic_in_scope("/nexum/1/acme/orders", &scope)); + assert!(!topic_in_scope("/nexum/1/acme-orders/proto", &scope)); } } diff --git a/crates/nexum-runtime/src/host/impls/mod.rs b/crates/nexum-runtime/src/host/impls/mod.rs index 40b65ade..2247ed60 100644 --- a/crates/nexum-runtime/src/host/impls/mod.rs +++ b/crates/nexum-runtime/src/host/impls/mod.rs @@ -13,4 +13,3 @@ mod logging; mod messaging; mod remote_store; mod types; -mod venue_client; diff --git a/crates/nexum-runtime/src/host/mod.rs b/crates/nexum-runtime/src/host/mod.rs index ac9e0634..a2842435 100644 --- a/crates/nexum-runtime/src/host/mod.rs +++ b/crates/nexum-runtime/src/host/mod.rs @@ -15,10 +15,11 @@ //! - `impls` (private): the bindgen-side trait impls, one file per core //! WIT interface, that dispatch to the backends above. //! - [`component`]: backend traits over the capability backends, the seam a generic runtime consumes. -//! - [`extension`]: the extension seam (linker hook + capability -//! namespace) an extension is wired in through at the composition root. -//! Domain extensions such as cow-api live in their own crates and plug -//! in through this seam rather than being hard-linked into the core host. +//! - [`extension`]: the extension seam (linker hook, capability +//! namespace, service, provider kind, event sources) an extension is +//! wired in through at the composition root. Domain extensions live in +//! their own crates and plug in through this seam rather than being +//! hard-linked into the core host. //! - [`actor`]: the supervised host-actor primitive provider instances //! run behind (refuel, trap projection, serialising slot). //! - [`http`]: the wasi:http outgoing gate enforcing the per-module @@ -36,4 +37,3 @@ pub mod local_store_redb; pub mod logs; pub mod provider_pool; pub mod state; -pub mod venue_registry; diff --git a/crates/nexum-runtime/src/host/provider_pool.rs b/crates/nexum-runtime/src/host/provider_pool.rs index 23230d4f..55d5e912 100644 --- a/crates/nexum-runtime/src/host/provider_pool.rs +++ b/crates/nexum-runtime/src/host/provider_pool.rs @@ -428,7 +428,7 @@ pub enum ProviderError { /// Decoded `ErrorResp.data` payload - for `eth_call` reverts /// this is the abi-encoded revert body, hex-decoded from the /// upstream JSON string once here (consumed directly by - /// `shepherd_sdk::cow::decode_revert`). `None` when the failure + /// an SDK revert decoder). `None` when the failure /// was transport-level or the payload was not a hex string. data: Option>, /// Transport-side typed error. diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index b1b103cb..2023baf4 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -30,8 +30,8 @@ pub struct HostState { pub http_gate: HttpGate, /// Messaging content topics this store may publish to. Empty means /// unscoped (the module default and current messaging posture); a - /// venue adapter carries its `[[adapters]].messaging_topics` grant - /// here, so an out-of-scope publish is refused before it reaches the + /// provider carries its `[[adapters]].messaging_topics` grant here, + /// so an out-of-scope publish is refused before it reaches the /// backend. pub messaging_topics: Vec, /// Identity of this store's run: module namespace plus the restart diff --git a/crates/nexum-runtime/src/manifest/capabilities.rs b/crates/nexum-runtime/src/manifest/capabilities.rs index 8ad2b4d3..dc6edeb4 100644 --- a/crates/nexum-runtime/src/manifest/capabilities.rs +++ b/crates/nexum-runtime/src/manifest/capabilities.rs @@ -24,7 +24,7 @@ use super::types::{CORE_CAPABILITIES, LoadedManifest}; /// One WIT namespace prefix plus the interface names under it that count as /// capabilities. Core registers `nexum:host/`; an extension registers its -/// own (e.g. `shepherd:cow/`). +/// own. #[derive(Clone, Copy)] pub struct NamespaceCaps { /// Interface-name prefix, e.g. `"nexum:host/"`. @@ -39,20 +39,6 @@ pub const CORE_NAMESPACE: NamespaceCaps = NamespaceCaps { ifaces: CORE_CAPABILITIES, }; -/// Capability names under the `videre:venue/` package a module may import. -/// Only the keeper-facing `client` interface is a capability; the -/// `videre:types` and `videre:value-flow` packages are type-only and need -/// no declaration. -pub const VENUE_CAPABILITIES: &[&str] = &["client"]; - -/// The venue namespace: the `videre:venue/client` import is linked into -/// every module linker, so a module that submits intents declares the -/// `client` capability the same way it declares a `nexum:host/` one. -pub const VENUE_NAMESPACE: NamespaceCaps = NamespaceCaps { - prefix: "videre:venue/", - ifaces: VENUE_CAPABILITIES, -}; - /// The interfaces a provider world links: the scoped transport only. A /// provider has no local-store, remote-store, identity, or logging - it /// moves bytes to and from its counterparty and nothing else. `http` is @@ -127,11 +113,10 @@ impl Default for CapabilityRegistry { } impl CapabilityRegistry { - /// The registry with the core `nexum:host/` namespace plus the - /// keeper-facing `videre:venue/client` import every module linker carries. + /// The registry with the core `nexum:host/` namespace. pub fn core() -> Self { Self { - namespaces: vec![CORE_NAMESPACE, VENUE_NAMESPACE], + namespaces: vec![CORE_NAMESPACE], } } @@ -181,7 +166,7 @@ impl CapabilityRegistry { /// /// Examples: /// - `"nexum:host/chain@0.1.0"` -> `Some("chain")` - /// - `"shepherd:cow/cow-api@0.1.0"` -> `Some("cow-api")` once the cow + /// - `"test:acme/acme-api@0.1.0"` -> `Some("acme-api")` once that /// namespace is registered /// - `"wasi:http/outgoing-handler@0.2.12"` -> `Some("http")` /// - `"nexum:host/types@0.1.0"` -> `None` (type-only, not a capability) @@ -270,13 +255,13 @@ mod tests { use super::*; use crate::manifest::types::{CapabilitiesSection, Manifest}; - /// A registry with the cow extension namespace registered, mirroring - /// what the composition root assembles. - fn registry_with_cow() -> CapabilityRegistry { + /// A registry with one extension namespace registered, mirroring + /// what a composition root assembles. + fn registry_with_ext() -> CapabilityRegistry { let mut r = CapabilityRegistry::core(); r.register(NamespaceCaps { - prefix: "shepherd:cow/", - ifaces: &["cow-api"], + prefix: "test:acme/", + ifaces: &["acme-api"], }); r } @@ -315,35 +300,21 @@ mod tests { } #[test] - fn wit_import_to_cap_shepherd_cow_needs_registration() { - // Core registry does not recognise the cow namespace. + fn wit_import_to_cap_extension_needs_registration() { + // Core registry does not recognise an extension namespace. let core = CapabilityRegistry::core(); - assert_eq!(core.wit_import_to_cap("shepherd:cow/cow-api@0.1.0"), None); + assert_eq!(core.wit_import_to_cap("test:acme/acme-api@0.1.0"), None); // Once registered, it resolves. - let r = registry_with_cow(); - assert_eq!( - r.wit_import_to_cap("shepherd:cow/cow-api@0.1.0"), - Some("cow-api") - ); - } - - #[test] - fn venue_client_is_a_core_capability_but_videre_types_is_not() { - let r = CapabilityRegistry::core(); + let r = registry_with_ext(); assert_eq!( - r.wit_import_to_cap("videre:venue/client@0.1.0"), - Some("client") + r.wit_import_to_cap("test:acme/acme-api@0.1.0"), + Some("acme-api") ); - assert!(r.is_known("client")); - // The type-only interfaces are not capabilities and need no - // declaration. - assert_eq!(r.wit_import_to_cap("videre:types/types@0.1.0"), None); - assert_eq!(r.wit_import_to_cap("videre:value-flow/types@0.1.0"), None); } #[test] fn wit_import_to_cap_non_http_wasi_is_none() { - let r = registry_with_cow(); + let r = registry_with_ext(); assert_eq!(r.wit_import_to_cap("wasi:io/streams@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:cli/stdin@0.2.0"), None); assert_eq!(r.wit_import_to_cap("wasi:sockets/tcp@0.2.0"), None); @@ -377,20 +348,20 @@ mod tests { // 0.1-fallback: no capabilities section -> all imports allowed let loaded = manifest_no_caps(); let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn enforce_passes_when_all_imports_declared() { - let loaded = manifest_with_caps(&["chain", "cow-api"], &["http"]); + let loaded = manifest_with_caps(&["chain", "acme-api"], &["http"]); let imports = [ "nexum:host/chain@0.1.0", - "shepherd:cow/cow-api@0.1.0", + "test:acme/acme-api@0.1.0", "wasi:http/outgoing-handler@0.2.12", "wasi:io/streams@0.2.0", // non-http wasi is always skipped ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -401,7 +372,7 @@ mod tests { "nexum:host/chain@0.1.0", "wasi:http/outgoing-handler@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -419,7 +390,7 @@ mod tests { "wasi:http/outgoing-handler@0.2.12", "wasi:http/types@0.2.12", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } } @@ -429,7 +400,7 @@ mod tests { let loaded = manifest_with_caps(&["chain"], &[]); // module imports remote-store but didn't declare it let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, imports.into_iter(), &r).unwrap_err(); let CapabilityError::Undeclared(v) = err else { panic!("expected undeclared: {err:?}") @@ -441,7 +412,7 @@ mod tests { fn enforce_optional_caps_are_also_allowed() { let loaded = manifest_with_caps(&["chain"], &["remote-store"]); let imports = ["nexum:host/chain@0.1.0", "nexum:host/remote-store@0.1.0"]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } @@ -501,14 +472,14 @@ mod tests { "wasi:cli/terminal-stdout@0.2.6", "wasi:cli/environment@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn undeclared_gated_wasi_is_refused() { let loaded = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); for (import, cap) in [ ("wasi:sockets/tcp@0.2.6", "wasi-sockets"), ("wasi:filesystem/types@0.2.6", "wasi-filesystem"), @@ -531,14 +502,14 @@ mod tests { "wasi:filesystem/types@0.2.6", "wasi:filesystem/preopens@0.2.6", ]; - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&loaded, imports.into_iter(), &r).is_ok()); } #[test] fn declaring_one_gated_cap_does_not_grant_another() { let loaded = manifest_with_caps(&["wasi-filesystem"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( enforce_capabilities(&loaded, ["wasi:filesystem/types@0.2.6"].into_iter(), &r).is_ok() ); @@ -550,7 +521,7 @@ mod tests { // Even with an unrelated gated cap declared, an unrecognised wasi: // namespace is denied outright. let loaded = manifest_with_caps(&["wasi-sockets"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); let err = enforce_capabilities(&loaded, ["wasi:nn/tensor@0.2.0"].into_iter(), &r).unwrap_err(); assert!(matches!(err, CapabilityError::UnknownWasi { .. })); @@ -560,7 +531,7 @@ mod tests { fn wasi_gate_ignores_version_suffix() { let declared = manifest_with_caps(&["wasi-sockets"], &[]); let none = manifest_with_caps(&["logging"], &[]); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!(enforce_capabilities(&declared, ["wasi:sockets/tcp"].into_iter(), &r).is_ok()); assert!( enforce_capabilities(&declared, ["wasi:sockets/tcp@0.2.6"].into_iter(), &r).is_ok() @@ -573,7 +544,7 @@ mod tests { // No [capabilities] section -> 0.1-fallback: registry imports pass, // but the WASI surface is still gated fail-closed. let loaded = manifest_no_caps(); - let r = registry_with_cow(); + let r = registry_with_ext(); assert!( enforce_capabilities(&loaded, ["nexum:host/remote-store@0.1.0"].into_iter(), &r) .is_ok() @@ -588,7 +559,7 @@ mod tests { #[test] fn wasi_capability_names_are_known() { - let r = registry_with_cow(); + let r = registry_with_ext(); for cap in ["wasi-sockets", "wasi-filesystem"] { assert!(r.is_known(cap), "{cap} missing from known set"); assert!(r.known_names().split(", ").any(|n| n == cap)); diff --git a/crates/nexum-runtime/src/manifest/load.rs b/crates/nexum-runtime/src/manifest/load.rs index 6ad22674..132dd838 100644 --- a/crates/nexum-runtime/src/manifest/load.rs +++ b/crates/nexum-runtime/src/manifest/load.rs @@ -172,54 +172,66 @@ event_signature = "0x00000000000000000000000000000000000000000000000000000000dea } #[test] - fn load_rejects_the_retired_log_kind() { + fn load_parses_the_retired_log_kind_as_an_extension_kind() { // The chain-event kind is `chain-log`; a stale `kind = "log"` - // fails to parse with an unknown-variant error naming the valid - // set so a not-yet-migrated manifest surfaces clearly at load. + // parses as an extension kind and boot refuses it against the + // extension vocabulary, so a not-yet-migrated manifest still + // surfaces clearly rather than silently dropping events. let toml = r#" [module] name = "stale" [[subscription]] kind = "log" -chain_id = 1 +chain_id = "1" "#; - let err = toml::from_str::(toml).expect_err("stale kind rejected"); - let msg = err.to_string(); - assert!( - msg.contains("chain-log"), - "error names the valid set: {msg}" - ); - assert!( - !msg.contains("unknown field"), - "kind is the discriminator: {msg}" - ); + let manifest: Manifest = toml::from_str(toml).expect("parse"); + assert!(matches!( + &manifest.subscriptions[0], + Subscription::Extension { kind, .. } if kind == "log" + )); } #[test] - fn load_parses_intent_status_subscription() { + fn load_parses_extension_subscriptions_with_string_filters() { let toml = r#" [module] name = "watcher" [[subscription]] -kind = "intent-status" +kind = "acme-status" [[subscription]] -kind = "intent-status" -venue = "cow" +kind = "acme-status" +scope = "primary" "#; let manifest: Manifest = toml::from_str(toml).expect("parse"); assert!(matches!( &manifest.subscriptions[0], - Subscription::IntentStatus { venue: None } + Subscription::Extension { kind, filters } if kind == "acme-status" && filters.is_empty() )); assert!(matches!( &manifest.subscriptions[1], - Subscription::IntentStatus { venue: Some(v) } if v == "cow" + Subscription::Extension { kind, filters } + if kind == "acme-status" && filters.get("scope").is_some_and(|v| v == "primary") )); } + /// A non-string filter value on an extension kind is refused at parse. + #[test] + fn load_rejects_a_non_string_extension_filter() { + let toml = r#" +[module] +name = "watcher" + +[[subscription]] +kind = "acme-status" +scope = 7 +"#; + let err = toml::from_str::(toml).expect_err("non-string filter"); + assert!(err.to_string().contains("must be a string"), "{err}"); + } + #[test] fn load_parses_cron_subscription() { let toml = r#" @@ -313,14 +325,14 @@ name = "plain" let manifest: Manifest = toml::from_str( r#" [module] -name = "cow" -kind = "venue-adapter" +name = "acme" +kind = "acme-provider" "#, ) .expect("parse"); assert_eq!( manifest.module.kind, - ComponentKind::Provider("venue-adapter".to_owned()), + ComponentKind::Provider("acme-provider".to_owned()), ); } @@ -395,9 +407,9 @@ max_state_bytes = 52428800 #[test] fn host_allowed_exact_and_wildcard() { - let allow = vec!["api.cow.fi".to_string(), "*.discord.com".to_string()]; - assert!(host_allowed("api.cow.fi", &allow)); - assert!(!host_allowed("evil.api.cow.fi", &allow)); + let allow = vec!["api.acme.example".to_string(), "*.discord.com".to_string()]; + assert!(host_allowed("api.acme.example", &allow)); + assert!(!host_allowed("evil.api.acme.example", &allow)); assert!(host_allowed("foo.discord.com", &allow)); assert!(host_allowed("a.b.discord.com", &allow)); assert!(!host_allowed("discord.com", &allow)); @@ -406,17 +418,20 @@ max_state_bytes = 52428800 #[test] fn host_allowed_is_case_insensitive_both_ways() { - let upper = vec!["API.COW.FI".to_string()]; - let lower = vec!["api.cow.fi".to_string()]; - assert!(host_allowed("api.cow.fi", &upper)); - assert!(host_allowed("Api.Cow.Fi", &lower)); + let upper = vec!["API.ACME.EXAMPLE".to_string()]; + let lower = vec!["api.acme.example".to_string()]; + assert!(host_allowed("api.acme.example", &upper)); + assert!(host_allowed("Api.Acme.Example", &lower)); } #[test] fn host_allowed_matches_hosts_not_authorities() { // Entries are bare hosts; a port or userinfo in a pattern can // never match a host string. - let allow = vec!["api.cow.fi:8443".to_string(), "u@api.cow.fi".to_string()]; - assert!(!host_allowed("api.cow.fi", &allow)); + let allow = vec![ + "api.acme.example:8443".to_string(), + "u@api.acme.example".to_string(), + ]; + assert!(!host_allowed("api.acme.example", &allow)); } } diff --git a/crates/nexum-runtime/src/manifest/types.rs b/crates/nexum-runtime/src/manifest/types.rs index dbaf774a..c13ca680 100644 --- a/crates/nexum-runtime/src/manifest/types.rs +++ b/crates/nexum-runtime/src/manifest/types.rs @@ -4,17 +4,18 @@ //! and validation logic lives in [`mod@super::load`]; capability enforcement //! in [`super::capabilities`]. +use std::collections::BTreeMap; use std::fmt; use serde::Deserialize; +use serde::de::Error as _; /// Core capability names: the `nexum:host` interfaces the `event-module` /// world links into every module linker. The `http` capability is not a /// `nexum:host` interface (it gates `wasi:http/*` imports) and is handled -/// separately by the registry. Domain-extension capabilities (e.g. -/// cow-api) are not listed here; each extension contributes its own -/// namespace to the [`super::capabilities::CapabilityRegistry`] at the -/// composition root. +/// separately by the registry. Domain-extension capabilities are not +/// listed here; each extension contributes its own namespace to the +/// [`super::capabilities::CapabilityRegistry`] at the composition root. pub const CORE_CAPABILITIES: &[&str] = &[ "chain", "identity", @@ -43,10 +44,11 @@ pub struct Manifest { /// One `[[subscription]]` table in `module.toml`. /// /// The discriminator is the `kind` field; remaining fields are -/// validated per-kind by the supervisor. Unknown kinds are surfaced -/// at load time so a typo does not silently disable an event source. -#[derive(Debug, Deserialize, Clone)] -#[serde(tag = "kind", rename_all = "lowercase")] +/// validated per-kind by the supervisor. A kind outside the core set +/// parses as [`Subscription::Extension`] and is validated at boot +/// against the kinds the wired extensions declare, so a typo still +/// fails loudly rather than silently disabling an event source. +#[derive(Debug, Clone)] pub enum Subscription { /// New-block events. Fan-out is shared per chain - the /// supervisor opens one subscription per chain id and routes to @@ -59,17 +61,14 @@ pub enum Subscription { /// per-module - the supervisor opens one subscription per /// `[[subscription]]` entry and tags emitted events with the /// owning module. - #[serde(rename = "chain-log")] ChainLog { /// EVM chain id. chain_id: u64, /// Contract address as `0x`-prefixed 20-byte hex. Optional. - #[serde(default)] address: Option, /// Topic-0 of the event the module wants to consume. `0x`- /// prefixed 32-byte hex. Optional - when absent the /// subscription matches every event from the address(es). - #[serde(default)] event_signature: Option, /// Resume across engine restarts. When `true` the host persists a /// durable per-subscription cursor and re-opens the log poller @@ -77,13 +76,11 @@ pub enum Subscription { /// current head. Delivery is then at-least-once, so the module must /// tolerate redelivery (the keeper idempotency journal already /// dedups it). - #[serde(default)] resume: bool, /// Optional cap on how far back a `resume` subscription will /// backfill, in blocks. `None` (the default) backfills the entire /// gap with no loss; set it only for a consumer that explicitly /// tolerates dropping the oldest missed blocks. - #[serde(default)] max_lookback: Option, }, /// Cron-scheduled tick. 0.2 parses but does not dispatch; the @@ -95,19 +92,96 @@ pub enum Subscription { #[allow(dead_code)] schedule: String, }, - /// Router-polled intent status transitions, delivered as - /// `intent-status` events. Fan-out is shared: the router polls each - /// installed adapter once per cadence and every subscribed module - /// receives the transition, filtered by `venue` when set. - #[serde(rename = "intent-status")] - IntentStatus { - /// Restrict delivery to transitions from this venue id. - /// Absent means transitions from every venue. + /// An extension-owned event kind. Every non-`kind` key is a string + /// filter matched against the event's routing attributes: an event + /// is delivered when its kind matches and every filter pair is + /// present in the event's attributes. + Extension { + /// The extension-declared subscription kind. + kind: String, + /// Attribute filters; empty admits every event of the kind. + filters: BTreeMap, + }, +} + +/// The core subscription kinds, parsed by shape. Any other kind falls +/// through to [`Subscription::Extension`]. +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum CoreSubscription { + Block { + chain_id: u64, + }, + #[serde(rename = "chain-log")] + ChainLog { + chain_id: u64, + #[serde(default)] + address: Option, + #[serde(default)] + event_signature: Option, + #[serde(default)] + resume: bool, #[serde(default)] - venue: Option, + max_lookback: Option, + }, + Cron { + schedule: String, }, } +impl From for Subscription { + fn from(sub: CoreSubscription) -> Self { + match sub { + CoreSubscription::Block { chain_id } => Self::Block { chain_id }, + CoreSubscription::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + } => Self::ChainLog { + chain_id, + address, + event_signature, + resume, + max_lookback, + }, + CoreSubscription::Cron { schedule } => Self::Cron { schedule }, + } + } +} + +impl<'de> Deserialize<'de> for Subscription { + fn deserialize>(deserializer: D) -> Result { + let table = toml::Table::deserialize(deserializer)?; + let Some(kind) = table.get("kind").and_then(toml::Value::as_str) else { + return Err(D::Error::missing_field("kind")); + }; + match kind { + "block" | "chain-log" | "cron" => toml::Value::Table(table.clone()) + .try_into::() + .map(Into::into) + .map_err(D::Error::custom), + _ => { + let kind = kind.to_owned(); + let mut filters = BTreeMap::new(); + for (key, value) in table { + if key == "kind" { + continue; + } + let Some(value) = value.as_str() else { + return Err(D::Error::custom(format!( + "subscription filter `{key}` must be a string" + ))); + }; + filters.insert(key, value.to_owned()); + } + Ok(Self::Extension { kind, filters }) + } + } + } +} + #[derive(Debug, Deserialize, Default)] #[allow(dead_code)] // version + component parsed for future 0.3 hash-verification. pub struct ModuleSection { diff --git a/crates/nexum-runtime/src/preset.rs b/crates/nexum-runtime/src/preset.rs index 17044af2..9ec4dd64 100644 --- a/crates/nexum-runtime/src/preset.rs +++ b/crates/nexum-runtime/src/preset.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use crate::addons::{AddOns, PrometheusAddOn}; +use crate::engine_config::EngineConfig; use crate::host::component::{ ComponentBuilder, ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, @@ -55,10 +56,13 @@ pub trait Runtime { /// The cross-cutting add-ons installed before the engine boots. fn add_ons(&self) -> AddOns; - /// The linker extensions the preset launches with. None by default; + /// The extensions the preset launches with, derived from the loaded + /// config so an extension can carry config-resolved policy. None by + /// default; /// [`PresetBuilder::with_extensions`](crate::builder::PresetBuilder::with_extensions) /// appends on top. - fn extensions(&self) -> Vec>> { + fn extensions(&self, config: &EngineConfig) -> Vec>> { + let _ = config; Vec::new() } } diff --git a/crates/nexum-runtime/src/runtime/event_loop.rs b/crates/nexum-runtime/src/runtime/event_loop.rs index a63526df..3f1aa287 100644 --- a/crates/nexum-runtime/src/runtime/event_loop.rs +++ b/crates/nexum-runtime/src/runtime/event_loop.rs @@ -40,8 +40,8 @@ use tracing::{info, warn}; use crate::bindings::nexum; use crate::host::component::{ChainProvider, RuntimeTypes}; +use crate::host::extension::{ExtensionEvent, ExtensionEventStream}; use crate::host::provider_pool::ProviderError; -use crate::host::venue_registry::VenueRegistry; use crate::runtime::restart_policy::backoff_for; use crate::supervisor::{ChainLogSub, Supervisor}; use nexum_tasks::{TaskExecutor, TaskExit, TaskSet}; @@ -147,40 +147,6 @@ where streams } -/// Registry-driven intent status polling: one task that, on every cadence -/// tick, polls each installed adapter's status export through the shared -/// [`VenueRegistry`] and forwards the observed transitions. The task is -/// spawned via `executor` into `tasks` like the reconnect tasks and exits -/// cleanly when the loop's receiver drops. -pub fn open_intent_status_stream( - registry: VenueRegistry, - cadence: Duration, - executor: &TaskExecutor, - tasks: &mut TaskSet, -) -> IntentStatusStream { - let (tx, rx) = mpsc::channel::(RECONNECT_CHANNEL_BUF); - tasks.push(executor.spawn(Box::pin(status_poll_task(registry, cadence, tx)))); - Box::pin(receiver_stream(rx)) -} - -/// Poll loop behind [`open_intent_status_stream`]. Sleeps the cadence -/// first so the engine's boot dispatch settles before the first poll. -async fn status_poll_task( - registry: VenueRegistry, - cadence: Duration, - tx: mpsc::Sender, -) -> TaskExit { - loop { - tokio::time::sleep(cadence).await; - for update in registry.poll_status_transitions().await { - if tx.send(update).await.is_err() { - // Receiver dropped -> engine shutting down. - return TaskExit::ReceiverGone; - } - } - } -} - /// Wrap an `mpsc::Receiver` as a `Stream` using /// `futures::stream::unfold`. Avoids pulling in `tokio-stream` just /// for `ReceiverStream`. @@ -492,12 +458,6 @@ pub type TaggedChainLog = Result<(String, Chain, alloy_rpc_types_eth::Log, Option>), StreamError>; pub type TaggedChainLogStream = std::pin::Pin + Send>>; -/// Router-observed intent status transitions, fanned to subscribers by the -/// event loop. Infallible items: poll failures are retried inside the poll -/// task on the next cadence rather than surfaced here. -pub type IntentStatusStream = - std::pin::Pin + Send>>; - /// Drive the supervisor with events until `shutdown` resolves. /// /// Graceful shutdown: the dispatch path is structured so @@ -516,7 +476,7 @@ pub async fn run( supervisor: &mut Supervisor, block_streams: Vec, chain_log_streams: Vec, - intent_status_stream: Option, + extension_streams: Vec, tasks: TaskSet, shutdown: impl std::future::Future + Send, ) -> (u64, u64) { @@ -538,14 +498,15 @@ pub async fn run( } else { select_all(chain_log_streams).boxed() }; - let mut intent_statuses: BoxStream<'_, _> = match intent_status_stream { - Some(stream) => stream, - None => futures::stream::pending().boxed(), + let mut extension_events: BoxStream<'_, _> = if extension_streams.is_empty() { + futures::stream::pending().boxed() + } else { + select_all(extension_streams).boxed() }; let mut shutdown = Box::pin(shutdown); let mut dispatched_blocks: u64 = 0; let mut dispatched_chain_logs: u64 = 0; - let mut dispatched_intent_statuses: u64 = 0; + let mut dispatched_extension_events: u64 = 0; let started = Instant::now(); loop { // Phase 1: pick the next event OR observe shutdown. The @@ -562,7 +523,7 @@ pub async fn run( Box, Option>, ), - IntentStatus(nexum::host::types::IntentStatusUpdate), + Extension(ExtensionEvent), // Carries the drain guard `shutdown` yielded. Shutdown(G), StreamPanic(&'static str), @@ -593,10 +554,10 @@ pub async fn run( } None => NextEvent::StreamPanic("chain-log"), }, - next = intent_statuses.next() => match next { - Some(update) => NextEvent::IntentStatus(update), - // The poll task loops forever; `None` means it exited. - None => NextEvent::StreamPanic("intent-status"), + next = extension_events.next() => match next { + Some(event) => NextEvent::Extension(event), + // Extension source tasks loop forever; `None` means one exited. + None => NextEvent::StreamPanic("extension-event"), }, }; @@ -611,9 +572,9 @@ pub async fn run( .await; dispatched_chain_logs += 1; } - NextEvent::IntentStatus(update) => { - supervisor.dispatch_intent_status(update).await; - dispatched_intent_statuses += 1; + NextEvent::Extension(event) => { + supervisor.dispatch_extension_event(event).await; + dispatched_extension_events += 1; } NextEvent::Shutdown(guard) => { // Drop the stream-end receivers so the reconnect @@ -622,12 +583,12 @@ pub async fn run( // finish before returning. drop(blocks); drop(chain_logs); - drop(intent_statuses); + drop(extension_events); tasks.shutdown().await; info!( dispatched_blocks, dispatched_chain_logs, - dispatched_intent_statuses, + dispatched_extension_events, uptime_secs = started.elapsed().as_secs(), "graceful shutdown complete", ); @@ -640,7 +601,7 @@ pub async fn run( // exited (panic or channel closed). Bail loudly. drop(blocks); drop(chain_logs); - drop(intent_statuses); + drop(extension_events); tasks.shutdown().await; warn!( kind, diff --git a/crates/nexum-runtime/src/runtime/poison_policy.rs b/crates/nexum-runtime/src/runtime/poison_policy.rs index 70998a6e..bfc1a427 100644 --- a/crates/nexum-runtime/src/runtime/poison_policy.rs +++ b/crates/nexum-runtime/src/runtime/poison_policy.rs @@ -31,7 +31,7 @@ use std::time::Duration; /// Aggressive enough to catch a deterministically broken module /// without waiting out the full exponential backoff (the 5th trap /// happens at ~31 s into the schedule: 1+2+4+8+16 s); lenient -/// enough that a one-off RPC blip during a real cow-api submit does +/// enough that a one-off RPC blip during a real extension submit does /// not get a module quarantined. pub const POISON_MAX_FAILURES: u32 = 5; pub const POISON_WINDOW: Duration = Duration::from_secs(600); diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index 029fa1e3..102dd196 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -18,11 +18,10 @@ //! `next_attempt = None` and never get scheduled - the init failure //! is treated as a manifest / config bug, not a transient. //! -//! Providers (venue adapters) ride the same sweeps: a trap inside a -//! routed call flips the [`Liveness`] their actor shares with the -//! supervisor, the venue resolves to `unavailable` (not -//! `unknown-venue`) while dead, and the sweep reinstalls the provider -//! after the same backoff and poison policies. +//! Providers ride the same sweeps: a trap inside a routed call flips +//! the [`Liveness`] their actor shares with the supervisor, the owning +//! service reports the instance unavailable while dead, and the sweep +//! reinstalls the provider after the same backoff and poison policies. //! //! Multi-chain isolation: `dispatch_block(block)` walks //! every module but only enters those whose subscriptions match @@ -32,7 +31,7 @@ //! tasks own one per-chain backoff timer each, so a //! chain-A connection drop does not block chain-B events. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -51,6 +50,7 @@ use crate::engine_config::{ }; use crate::host::actor::Liveness; use crate::host::component::{Components, RuntimeTypes, StateHandle, StateStore}; +use crate::host::extension::ExtensionEvent; use crate::host::extension::{ Extension, HostService, HostServices, Installed, ProviderInstance, ProviderKind, }; @@ -61,7 +61,6 @@ use crate::host::logs::{LogRecord, LogSource, RunId, StdioStream}; #[cfg(test)] use crate::host::provider_pool::ProviderPool; use crate::host::state::HostState; -use crate::host::venue_registry::{VenueAdapterKind, VenueRegistry, VenueRegistryBuilder}; use crate::manifest::{ self, CapabilityRegistry, ComponentKind, LoadedManifest, ResourceSection, Subscription, }; @@ -71,8 +70,8 @@ use crate::manifest::{ /// the component seam backends. pub struct Supervisor { modules: Vec>, - /// Providers (venue adapters) loaded at boot, whether or not `init` - /// succeeded. Swept for restart and poison alongside the modules. + /// Providers loaded at boot, whether or not `init` succeeded. Swept + /// for restart and poison alongside the modules. providers: Vec, /// Registered provider kinds paired with their services, kept for the /// provider restart sweep to reinstall through. @@ -261,11 +260,12 @@ struct LoadedModule { dispatch_bucket: crate::runtime::dispatch_rate::TokenBucket, } -/// One loaded provider (venue adapter). Mirrors [`LoadedModule`]'s restart -/// and poison bookkeeping; liveness is shared with the installed actor, -/// which marks it dead on a trap, and read back by the sweep. +/// One loaded provider. Mirrors [`LoadedModule`]'s restart and poison +/// bookkeeping; liveness is shared with the installed actor, which marks +/// it dead on a trap, and read back by the sweep. struct LoadedProvider { - /// The provider's namespace: its manifest name, and its venue id. + /// The provider's namespace: its manifest name, and the id its kind + /// installs it under. name: String, /// Registered kind spelling the restart sweep reinstalls through. kind: &'static str, @@ -326,6 +326,17 @@ fn provider_kinds( Ok(kinds) } +/// The union of subscription kinds the wired extensions declare; a +/// manifest subscription of any other non-core kind fails the load. +fn extension_subscription_vocabulary( + extensions: &[Arc>], +) -> BTreeSet<&'static str> { + extensions + .iter() + .flat_map(|ext| ext.subscriptions().iter().copied()) + .collect() +} + /// Insert one kind row, refusing a duplicate manifest spelling. fn register_kind( kinds: &mut ProviderKinds, @@ -357,22 +368,12 @@ impl Supervisor { clocks: Option, ) -> Result { let registry = capability_registry(extensions); - // The venue registry rides the generic service map under the videre - // namespace, seeded here while it lives in-core; the videre - // extension takes it over. Same for the venue-adapter kind row. - let venue_service: Arc = Arc::new( - VenueRegistryBuilder::new(engine_cfg.limits.quota()) - .with_watch_limit(engine_cfg.limits.watch()) - .build(), - ); - let services = HostServices::from_extensions(extensions)? - .with_service(VenueRegistry::NAMESPACE, Arc::clone(&venue_service))?; + let services = HostServices::from_extensions(extensions)?; // Provider kinds the boot loop resolves manifest kinds against. - let mut kinds = provider_kinds(extensions, &services)?; - register_kind(&mut kinds, Box::new(VenueAdapterKind), venue_service)?; - // Providers boot first into the shared registry handle, so every - // module store built below already routes to the installed venues. - // Providers link only their kind's scoped imports. + let kinds = provider_kinds(extensions, &services)?; + // Providers boot first into their extension-owned services, so + // every module store built below already routes to the installed + // instances. Providers link only their kind's scoped imports. let provider_registry = CapabilityRegistry::provider(); let mut providers = Vec::with_capacity(engine_cfg.adapters.len()); for entry in &engine_cfg.adapters { @@ -390,6 +391,7 @@ impl Supervisor { providers.push(loaded); } + let extension_kinds = extension_subscription_vocabulary(extensions); let mut modules = Vec::with_capacity(engine_cfg.modules.len()); for entry in &engine_cfg.modules { let loaded = Self::load_one( @@ -401,6 +403,7 @@ impl Supervisor { ®istry, clocks.as_ref(), services.clone(), + &extension_kinds, ) .await .with_context(|| format!("load module {}", entry.path.display()))?; @@ -451,9 +454,9 @@ impl Supervisor { path: wasm.to_path_buf(), manifest: manifest.map(Path::to_path_buf), }; - // The single-module override path serves `just run`; adapters are - // configured through `engine.toml`, so no registry service is - // published and every client call resolves to `unknown-venue`. + // The single-module override path serves `just run`; providers + // are configured through `engine.toml`, so none boot here. + let extension_kinds = extension_subscription_vocabulary(extensions); let loaded = Self::load_one( engine, linker, @@ -463,6 +466,7 @@ impl Supervisor { ®istry, clocks.as_ref(), services.clone(), + &extension_kinds, ) .await?; Ok(Self { @@ -574,6 +578,7 @@ impl Supervisor { registry: &CapabilityRegistry, clocks: Option<&WasiClockOverride>, services: HostServices, + extension_kinds: &BTreeSet<&'static str>, ) -> Result> { let manifest_path = resolve_manifest_path(&entry.path, entry.manifest.as_deref()); let loaded_manifest: LoadedManifest = match manifest_path.as_deref() { @@ -630,8 +635,8 @@ impl Supervisor { run.clone(), loaded_manifest.http_allowlist.clone(), limits_cfg.http(), - // Event modules are unscoped for messaging; only venue - // adapters carry a topic grant. + // Event modules are unscoped for messaging; only providers + // carry a topic grant. Vec::new(), memory, fuel, @@ -692,13 +697,23 @@ impl Supervisor { // Surface any `[[subscription]]` entries the host cannot // service yet, so an operator running 0.2 against a 0.3 - // manifest does not silently drop events. + // manifest does not silently drop events, and refuse an + // extension kind no wired extension declares. for sub in &loaded_manifest.manifest.subscriptions { - if matches!(sub, Subscription::Cron { .. }) { - warn!( + match sub { + Subscription::Cron { .. } => warn!( module = %module_namespace, "cron subscriptions are declared but inert in 0.2 (lands in 0.3)", - ); + ), + Subscription::Extension { kind, .. } + if !extension_kinds.contains(kind.as_str()) => + { + return Err(anyhow!( + "module {module_namespace} subscribes to unknown event kind {kind}; \ + no wired extension declares it" + )); + } + _ => {} } } @@ -892,7 +907,7 @@ impl Supervisor { self.modules.len() } - /// Number of venue adapters loaded at boot, alive or not. + /// Number of providers loaded at boot, alive or not. pub fn adapter_count(&self) -> usize { self.providers.len() } @@ -1230,15 +1245,12 @@ impl Supervisor { ok } - /// Dispatch a registry-observed intent status transition to every module - /// subscribed to `intent-status` events whose venue filter admits the - /// update's venue. Returns the number of modules invoked. Mirrors + /// Dispatch one extension-observed event to every module holding a + /// subscription of its kind whose filters all match the event's + /// attributes. Returns the number of modules invoked. Mirrors /// `dispatch_block`: dead modules past their backoff are restarted /// first, poisoned modules are skipped. - pub async fn dispatch_intent_status( - &mut self, - update: nexum::host::types::IntentStatusUpdate, - ) -> usize { + pub async fn dispatch_extension_event(&mut self, event: ExtensionEvent) -> usize { let now = std::time::Instant::now(); let restart_candidates: Vec = (0..self.modules.len()) .filter(|&i| { @@ -1260,19 +1272,20 @@ impl Supervisor { m.subscriptions.iter().any(|s| { matches!( s, - Subscription::IntentStatus { venue } - if venue.as_deref().is_none_or(|v| v == update.venue) + Subscription::Extension { kind, filters } + if kind == event.kind && filters.iter().all(|(fk, fv)| { + event.attrs.iter().any(|(ak, av)| ak == fk && av == fv) + }) ) }) }) .collect(); - let event = nexum::host::types::Event::IntentStatus(update); let mut dispatched = 0; for idx in candidate_indices { - // Status transitions are venue-scoped, not chain-scoped: the - // telemetry chain id and block number carry the 0 sentinel. + // Extension events are not chain-scoped: the telemetry chain + // id and block number carry the 0 sentinel. if matches!( - self.dispatch_to(idx, 0, "intent-status", 0, &event).await, + self.dispatch_to(idx, 0, event.kind, 0, &event.event).await, DispatchOutcome::Ok, ) { dispatched += 1; @@ -1281,23 +1294,25 @@ impl Supervisor { dispatched } - /// Whether any loaded module subscribes to `intent-status` events. - /// The launcher polls adapter statuses only when this holds: with no - /// subscriber every transition would be dropped on arrival. - pub fn has_intent_status_subscribers(&self) -> bool { - self.modules.iter().any(|m| { - m.subscriptions - .iter() - .any(|s| matches!(s, Subscription::IntentStatus { .. })) - }) + /// The extension subscription kinds at least one loaded module + /// declares. An extension opens an event source only when its kind + /// appears here: with no subscriber every event would be dropped on + /// arrival. + pub fn extension_subscription_kinds(&self) -> BTreeSet { + self.modules + .iter() + .flat_map(|m| m.subscriptions.iter()) + .filter_map(|s| match s { + Subscription::Extension { kind, .. } => Some(kind.clone()), + _ => None, + }) + .collect() } - /// The venue registry published under the videre service namespace, - /// when one is. Shared by every module store through the service map. - pub fn venue_registry(&self) -> Option { - self.services - .get::(VenueRegistry::NAMESPACE) - .map(|registry| (*registry).clone()) + /// The extension-owned services, as booted. Shared by every module + /// store through the service map. + pub fn services(&self) -> &HostServices { + &self.services } /// Shared per-module dispatch path: refuel, call `on_event`, and @@ -1658,13 +1673,6 @@ pub fn build_linker( ) -> anyhow::Result>> { let mut linker = Linker::>::new(engine); EventModule::add_to_linker::, HasSelf>>(&mut linker, |state| state)?; - // The venue client import is linked into every module linker; it - // dispatches to the shared registry carried in each store's `HostState`. - // Modules that do not import it are unaffected. - crate::bindings::client::add_to_linker::, HasSelf>>( - &mut linker, - |state| state, - )?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; // wasi:http only; the p2 call above already covers the shared // wasi:io/wasi:clocks interfaces. diff --git a/crates/nexum-runtime/src/supervisor/tests.rs b/crates/nexum-runtime/src/supervisor/tests.rs index 0bf06086..6396ffe0 100644 --- a/crates/nexum-runtime/src/supervisor/tests.rs +++ b/crates/nexum-runtime/src/supervisor/tests.rs @@ -72,7 +72,7 @@ async fn run_does_not_bail_when_both_stream_kinds_are_empty() { &mut supervisor, Vec::new(), Vec::new(), - None, + Vec::new(), nexum_tasks::TaskSet::new(), shutdown, ) @@ -145,7 +145,7 @@ async fn run_delivers_block_and_chain_log_events_without_starvation() { &mut supervisor, block_streams, chain_log_streams, - None, + Vec::new(), tasks, shutdown, ), @@ -202,7 +202,7 @@ async fn run_drains_reconnect_tasks_cleanly_on_shutdown() { &mut supervisor, block_streams, vec![], - None, + Vec::new(), tasks, shutdown, ), @@ -234,77 +234,6 @@ fn example_module_toml() -> PathBuf { .join("modules/example/module.toml") } -fn echo_venue_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/examples/echo-venue/module.toml") -} - -fn echo_client_module_toml() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("modules/examples/echo-client/module.toml") -} - -/// Path to the pre-built reference venue adapter. Built by -/// `just build-venue`; the import-pinning test skips when absent. -fn echo_venue_wasm() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/echo_venue.wasm") -} - -/// Returns `None` and prints a skip message if the venue fixture isn't -/// built. -fn echo_venue_wasm_or_skip() -> Option { - let p = echo_venue_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-venue` to enable the venue import test", - p.display() - ); - None - } -} - -/// Path to the pre-built echo-client module, the strategy half of the echo -/// pair. Built by `just build-echo-client`; the round-trip test skips when -/// absent. -fn echo_client_wasm() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("target/wasm32-wasip2/release/echo_client.wasm") -} - -/// Returns `None` and prints a skip message if the echo-client fixture -/// isn't built. -fn echo_client_wasm_or_skip() -> Option { - let p = echo_client_wasm(); - if p.exists() { - Some(p) - } else { - eprintln!( - "SKIP: {} not found - run `just build-echo-client` to enable the echo round-trip test", - p.display() - ); - None - } -} - /// Returns `None` and prints a skip message if the fixture isn't built. fn example_wasm_or_skip() -> Option { let p = example_wasm(); @@ -433,54 +362,12 @@ fn e2e_example_component_imports_equal_declared_capabilities() { "imports were: {imports:?}" ); - // No extension interface leaks in either: the blanket cow world is - // gone from modules that never declared it. + // No extension interface leaks in either: the per-module world holds + // exactly what the manifest declared. assert!( imports .iter() - .all(|name| !name.starts_with("shepherd:cow/")), - "imports were: {imports:?}" - ); -} - -/// The per-component venue-adapter world contract: an adapter built -/// through `#[nexum_venue_sdk::venue]` imports exactly the scoped -/// transport its manifest declares (`chain`), by construction of the -/// emitted world. The venue side never depended on toolchain elision; -/// this pins that it does not regress to it. -#[test] -fn e2e_echo_venue_component_imports_equal_declared_capabilities() { - let Some(wasm) = echo_venue_wasm_or_skip() else { - return; - }; - let engine = make_wasmtime_engine(); - let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); - let imports: Vec = component - .component_type() - .imports(&engine) - .map(|(name, _)| name.to_owned()) - .collect(); - - // Capability-bearing imports resolve to exactly the declared set. - let registry = CapabilityRegistry::core(); - let caps: std::collections::BTreeSet<&str> = imports - .iter() - .filter_map(|name| registry.wit_import_to_cap(name)) - .collect(); - assert_eq!( - caps, - std::collections::BTreeSet::from(["chain"]), - "imports were: {imports:?}" - ); - - // No host key-material or persistence interface leaks in: an adapter - // structurally cannot reach messaging it never declared, local-store, - // identity, or logging. - assert!( - imports.iter().all(|name| !name.contains("messaging") - && !name.contains("local-store") - && !name.contains("identity") - && !name.contains("logging")), + .all(|name| name.starts_with("nexum:host/") || name.starts_with("wasi:")), "imports were: {imports:?}" ); } @@ -540,415 +427,6 @@ chain_id = 1 assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); } -// ── intent-status subscription E2E ──────────────────────────────────── - -/// A scripted venue adapter for the registry: accepts every submission with -/// a fixed receipt and serves statuses front-first from a script, falling -/// back to `open` once drained. -struct ScriptedAdapter { - statuses: std::collections::VecDeque, -} - -impl ScriptedAdapter { - fn new(statuses: impl IntoIterator) -> Self { - Self { - statuses: statuses.into_iter().collect(), - } - } -} - -impl crate::host::venue_registry::VenueInvoker for ScriptedAdapter { - fn derive_header<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::IntentHeader { - gives: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: vec![1], - }, - wants: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: Vec::new(), - }, - settlement: crate::bindings::Settlement { chain: 1 }, - authorisation: crate::bindings::AuthScheme::Eip712, - }) - }) - } - - fn quote<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::Quotation { - gives: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: vec![1], - }, - wants: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: Vec::new(), - }, - fee: crate::bindings::value_flow::AssetAmount { - asset: crate::bindings::value_flow::Asset::Native, - amount: Vec::new(), - }, - valid_until_ms: 0, - }) - }) - } - - fn submit<'a>( - &'a mut self, - _body: &'a [u8], - ) -> futures::future::BoxFuture< - 'a, - Result, - > { - Box::pin(async move { - Ok(crate::bindings::SubmitOutcome::Accepted( - b"receipt".to_vec(), - )) - }) - } - - fn status( - &mut self, - _receipt: Vec, - ) -> futures::future::BoxFuture< - '_, - Result, - > { - Box::pin(async move { - Ok(self - .statuses - .pop_front() - .unwrap_or(crate::bindings::IntentStatus::Open)) - }) - } - - fn cancel( - &mut self, - _receipt: Vec, - ) -> futures::future::BoxFuture<'_, Result<(), crate::bindings::VenueError>> { - Box::pin(async move { Ok(()) }) - } -} - -/// Build a registry with one scripted adapter installed under `cow`. -fn scripted_registry(adapter: ScriptedAdapter) -> crate::host::venue_registry::VenueRegistry { - let registry = crate::host::venue_registry::VenueRegistryBuilder::new( - crate::host::venue_registry::SubmitQuota::default(), - ) - .build(); - registry - .install( - crate::host::venue_registry::VenueId::from("cow"), - crate::host::actor::Liveness::default(), - adapter, - ) - .expect("install"); - registry -} - -/// Write a manifest subscribing the example module to intent-status -/// events from the `cow` venue. -fn intent_status_manifest(dir: &Path) -> PathBuf { - let manifest = dir.join("module.toml"); - std::fs::write( - &manifest, - r#" -[module] -name = "example" - -[capabilities] -required = ["logging"] - -[[subscription]] -kind = "intent-status" -venue = "cow" -"#, - ) - .unwrap(); - manifest -} - -/// The acceptance path: a module subscribed to `intent-status` receives -/// the transitions the registry observed by polling the adapter's status -/// export, and a transition from a venue outside its filter is not -/// delivered. -#[tokio::test] -async fn e2e_intent_status_subscription_receives_polled_transitions() { - use crate::bindings::IntentStatus; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = intent_status_manifest(dir.path()); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let limits = ModuleLimits::default(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - assert!(supervisor.has_intent_status_subscribers()); - - // The registry watches the receipt of an accepted submission and polls - // the adapter's status export; each poll here observes a transition. - let registry = scripted_registry(ScriptedAdapter::new([ - IntentStatus::Pending, - IntentStatus::Fulfilled, - ])); - registry - .submit( - "test-caller", - &crate::host::venue_registry::VenueId::from("cow"), - b"body".to_vec(), - ) - .await - .expect("submit"); - - let mut delivered = 0; - for _ in 0..2 { - for update in registry.poll_status_transitions().await { - delivered += supervisor.dispatch_intent_status(update).await; - } - } - assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // A venue outside the module's filter is not delivered. - let foreign = crate::bindings::IntentStatusUpdate { - venue: "other".to_owned(), - receipt: b"receipt".to_vec(), - status: nexum_status_body::StatusBody { - status: nexum_status_body::IntentStatus::Open, - proof: None, - reason: None, - } - .encode() - .expect("encode"), - }; - assert_eq!(supervisor.dispatch_intent_status(foreign).await, 0); -} - -/// The event-loop wiring: the poll task's stream drives the supervisor, -/// and the module's handler observably ran (its log line is retained). -#[tokio::test] -async fn e2e_intent_status_flows_through_the_event_loop() { - use std::time::Duration; - - use nexum_tasks::{TaskManager, TaskSet}; - - let Some(wasm) = example_wasm_or_skip() else { - return; - }; - let dir = tempfile::tempdir().unwrap(); - let manifest = intent_status_manifest(dir.path()); - - let engine = make_wasmtime_engine(); - let linker = make_linker(&engine); - let (_dir, local_store) = temp_local_store(); - let components = test_components(local_store); - let logs = components.logs.clone(); - let limits = ModuleLimits::default(); - - let mut supervisor = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &core_extensions(), - None, - ) - .await - .expect("boot_single"); - - let registry = scripted_registry(ScriptedAdapter::new([])); - registry - .submit( - "test-caller", - &crate::host::venue_registry::VenueId::from("cow"), - b"body".to_vec(), - ) - .await - .expect("submit"); - - let manager = TaskManager::new(); - let executor = manager.executor(); - let mut tasks = TaskSet::new(); - let stream = crate::runtime::event_loop::open_intent_status_stream( - registry, - Duration::from_millis(10), - &executor, - &mut tasks, - ); - crate::runtime::event_loop::run( - &mut supervisor, - Vec::new(), - Vec::new(), - Some(stream), - tasks, - tokio::time::sleep(Duration::from_millis(300)), - ) - .await; - - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - let runs = logs.list_runs("example"); - assert_eq!(runs.len(), 1, "one run recorded for the example module"); - let page = logs.read(&runs[0].run, 0); - assert!( - page.records - .iter() - .any(|r| r.message.contains("intent status update from venue cow")), - "the module's on_intent_status handler ran; records were: {:?}", - page.records - .iter() - .map(|r| r.message.as_str()) - .collect::>(), - ); -} - -/// The first-train acceptance path, end to end over two real components: -/// the echo-client module submits through `videre:venue/client`, the host -/// registry forwards to the installed echo-venue adapter, and the module -/// receives the fulfilled `intent-status` the registry polls back. Proves -/// the intent core round-trips module -> host registry -> venue adapter -/// with no -/// scripted stand-ins on either side. -#[tokio::test] -async fn e2e_echo_module_registry_adapter_round_trip() { - use crate::engine_config::{AdapterEntry, EngineConfig, ModuleEntry}; - use crate::host::component::ChainMethod; - use crate::test_utils::{MockChainProvider, MockStateStore, MockTypes}; - - let (Some(adapter_wasm), Some(module_wasm)) = - (echo_venue_wasm_or_skip(), echo_client_wasm_or_skip()) - else { - return; - }; - - // The adapter reads eth_blockNumber on submit to justify its `chain` - // grant; program the mock so that read succeeds. The response body is - // discarded by the adapter, so any Ok value serves. - let chain = MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - let components = crate::test_utils::mock_components_from(chain, MockStateStore::new()); - let logs = components.logs.clone(); - - let engine = make_wasmtime_engine(); - let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); - - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(echo_venue_module_toml()), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - modules: vec![ModuleEntry { - path: module_wasm, - manifest: Some(echo_client_module_toml()), - }], - ..Default::default() - }; - - let mut supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) - .await - .expect("boot"); - assert_eq!( - supervisor.adapter_alive_count(), - 1, - "echo-venue is routable" - ); - assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); - assert!(supervisor.has_intent_status_subscribers()); - - // A block drives the module's on_block, which submits to the echo venue - // through the shared registry; the registry watches the accepted receipt. - let block = nexum::host::types::Block { - chain_id: 1, - number: 19_000_000, - hash: vec![0xab; 32], - timestamp: 1_700_000_000_000, - }; - assert_eq!(supervisor.dispatch_block(block).await, 1); - - // Poll the registry the module submitted through and fan its transitions - // back to the module. echo-venue settles instantly, so the first poll - // reports a terminal status and the watch is pruned. - let registry = supervisor.venue_registry().expect("registry service"); - let mut delivered = 0; - for _ in 0..2 { - for update in registry.poll_status_transitions().await { - assert_eq!(update.venue, "echo-venue"); - let body = - nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); - assert_eq!( - body.status, - nexum_status_body::IntentStatus::Fulfilled, - "echo settles instantly", - ); - delivered += supervisor.dispatch_intent_status(update).await; - } - } - assert_eq!( - delivered, 1, - "one terminal status delivered to the subscriber" - ); - assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); - - // The module observably completed the round trip: it quoted, it - // submitted, and it received the settled status from the echo venue. - let runs = logs.list_runs("echo-client"); - assert_eq!(runs.len(), 1, "one run recorded for echo-client"); - let page = logs.read(&runs[0].run, 0); - let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); - assert!( - messages - .iter() - .any(|m| m.contains("quoted") && m.contains("echo-venue")), - "module quoted through the client face; records were: {messages:?}", - ); - assert!( - messages - .iter() - .any(|m| m.contains("submitted") && m.contains("echo-venue")), - "module submitted through the client face; records were: {messages:?}", - ); - assert!( - messages - .iter() - .any(|m| m.contains("intent status from venue echo-venue")), - "module received the settled status; records were: {messages:?}", - ); -} - /// A `ManualClock` override threads through `boot_single` onto the module /// store and is behaviour-neutral: the module boots, dispatches a block, and /// stays alive exactly as it does on the ambient clock. Locks the plumbing so @@ -1114,53 +592,6 @@ async fn boot_production_module( .expect("boot_single") } -/// The boot-order invariant, exercised (not merely asserted in prose): -/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT -/// boot when the cow extension is absent from the linker AND the -/// capability registry. The paired linker-hook + capability-namespace -/// registration is what makes the same module boot once the cow extension -/// is wired at the composition root; drop the pairing and boot fails. The -/// positive direction (boots WITH the cow extension) is covered by the -/// extension crate that owns the backend. -#[tokio::test] -async fn twap_monitor_without_cow_extension_fails_to_boot() { - let Some(wasm) = module_wasm_or_skip("twap-monitor") else { - return; - }; - let manifest = production_module_toml("modules/twap-monitor/module.toml"); - let engine = make_wasmtime_engine(); - // Core-only: no cow linker hook, no cow capability namespace. - let linker = crate::supervisor::build_linker::(&engine, &[]).expect("build_linker"); - let (_dir, store) = temp_local_store(); - let components = test_components(store); - let limits = ModuleLimits::default(); - - let result = Supervisor::boot_single( - &engine, - &linker, - &wasm, - Some(&manifest), - &components, - &limits, - &[], - None, - ) - .await; - - let err = result - .err() - .expect("cow-importing module must not boot without the cow extension registered"); - // Pin the failure to its specific cause: twap-monitor declares the - // cow-api capability, which a core-only registry does not recognise - // (registering it is exactly what the cow extension does). Rules out - // an unrelated failure masquerading as the invariant. - let chain = format!("{err:#}"); - assert!( - chain.contains(r#"unknown capability "cow-api""#), - "expected the cow-api unknown-capability failure, got: {chain}", - ); -} - #[tokio::test] async fn e2e_price_alert_block_dispatch() { let Some(wasm) = module_wasm_or_skip("price-alert") else { @@ -2828,44 +2259,97 @@ fn chainlog_cursor_key_differs_by_each_input() { ); } -// ── venue-adapter boot ──────────────────────────────────────────────── +// ── provider boot gating ────────────────────────────────────────────── -/// The venue-adapter provider linker binds only the scoped transport -/// (chain, messaging, wasi base, allowlisted http) and withholds the -/// core-only interfaces. Assembling it proves the scope wires without a -/// duplicate-definition clash between the shared `nexum:host` interfaces. -#[tokio::test] -async fn provider_linker_assembles_with_scoped_transport() { - let engine = make_wasmtime_engine(); - crate::supervisor::build_provider_linker::( - &engine, - &crate::host::venue_registry::VenueAdapterKind, - ) - .expect("provider linker assembles"); +/// A stub extension registering the `acme-adapter` provider kind behind a +/// unit service, so the boot-gate tests exercise the generic kind loop +/// without a real provider component. +struct AcmeService; +impl crate::host::extension::HostService for AcmeService {} + +struct AcmeKind; + +#[async_trait::async_trait] +impl ProviderKind for AcmeKind { + fn kind(&self) -> &'static str { + "acme-adapter" + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn install( + &self, + _instance: ProviderInstance<'_, crate::test_utils::MockTypes>, + _service: &Arc, + ) -> anyhow::Result { + Ok(Installed::Live) + } +} + +struct AcmeExtension; + +impl Extension for AcmeExtension { + fn namespace(&self) -> &'static str { + "acme" + } + + fn capabilities(&self) -> manifest::NamespaceCaps { + manifest::NamespaceCaps { + prefix: "test:acme/", + ifaces: &[], + } + } + + fn link( + &self, + _linker: &mut Linker>, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::new(AcmeService)) + } + + fn provider(&self) -> Option>> { + Some(Box::new(AcmeKind)) + } } -/// The module-kind discriminator gates the adapter load path: an +/// The stub extension set registering the `acme-adapter` kind. +fn acme_extensions() -> Vec>> { + vec![Arc::new(AcmeExtension)] +} + +/// The module-kind discriminator gates the provider load path: an /// `[[adapters]]` entry whose manifest is (or defaults to) an event-module -/// is rejected before instantiation with a message naming the required -/// kind. +/// is rejected before instantiation with a message naming the registered +/// kinds. #[tokio::test] -async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { +async fn boot_rejects_provider_whose_manifest_is_an_event_module() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); std::fs::write( &manifest, - "[module]\nname = \"cow\"\nkind = \"event-module\"\n", + "[module]\nname = \"acme\"\nkind = \"event-module\"\n", ) .expect("write manifest"); let config = EngineConfig { adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("cow.wasm"), + path: dir.path().join("acme.wasm"), manifest: Some(manifest), http_allow: Vec::new(), messaging_topics: Vec::new(), @@ -2873,14 +2357,15 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("event-module manifest in an [[adapters]] slot must be rejected"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("venue-adapter"), - "the kind gate names the required kind: {msg}", + msg.contains("acme-adapter"), + "the kind gate names the registered kinds: {msg}", ); } @@ -2890,8 +2375,10 @@ async fn boot_rejects_adapter_whose_manifest_is_an_event_module() { async fn boot_rejects_an_unregistered_provider_kind() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); @@ -2908,54 +2395,58 @@ async fn boot_rejects_an_unregistered_provider_kind() { ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("an unregistered provider kind must be refused"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("an unregistered provider kind must be refused"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("unregistered provider kind gadget") && msg.contains("venue-adapter"), + msg.contains("unregistered provider kind gadget") && msg.contains("acme-adapter"), "the refusal names the unknown spelling and the registered kinds: {msg}", ); } -/// A venue-adapter manifest clears the discriminator; boot then reaches the +/// A registered kind clears the discriminator; boot then reaches the /// compile step and fails only because the referenced wasm is absent. This -/// proves the discriminator routed the entry to the adapter load path +/// proves the discriminator routed the entry to the provider load path /// rather than rejecting it on kind. #[tokio::test] -async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { +async fn boot_admits_a_registered_provider_kind_past_the_kind_gate() { let engine = make_wasmtime_engine(); let components = crate::test_utils::mock_components(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); + let extensions = acme_extensions(); + let linker = + crate::supervisor::build_linker::(&engine, &extensions) + .expect("build_linker"); let dir = tempfile::tempdir().expect("tempdir"); let manifest = dir.path().join("module.toml"); std::fs::write( &manifest, - "[module]\nname = \"cow\"\nkind = \"venue-adapter\"\n\n\ + "[module]\nname = \"acme\"\nkind = \"acme-adapter\"\n\n\ [capabilities]\nrequired = [\"chain\"]\n", ) .expect("write manifest"); let config = EngineConfig { adapters: vec![crate::engine_config::AdapterEntry { - path: dir.path().join("missing-cow.wasm"), + path: dir.path().join("missing-acme.wasm"), manifest: Some(manifest), - http_allow: vec!["api.cow.fi".into()], - messaging_topics: vec!["/nexum/1/cow-orders/proto".into()], + http_allow: vec!["api.acme.example".into()], + messaging_topics: vec!["/nexum/1/acme-orders/proto".into()], }], ..Default::default() }; - let err = match Supervisor::boot(&engine, &linker, &config, &components, &[], None).await { - Ok(_) => panic!("absent adapter wasm must fail the compile step"), - Err(err) => err, - }; + let err = + match Supervisor::boot(&engine, &linker, &config, &components, &extensions, None).await { + Ok(_) => panic!("absent provider wasm must fail the compile step"), + Err(err) => err, + }; let msg = format!("{err:#}"); assert!( - msg.contains("compile") || msg.contains("missing-cow"), + msg.contains("compile") || msg.contains("missing-acme"), "boot reached the compile step past the kind gate: {msg}", ); assert!( @@ -2964,163 +2455,53 @@ async fn boot_admits_a_venue_adapter_manifest_past_the_kind_gate() { ); } -// ── venue-adapter trap recovery ─────────────────────────────────────── - -/// Boot one flaky-venue adapter over the mock chain, whose head starts at -/// the fixture's poison sentinel. Returns the chain handle so the test can -/// let the venue recover. -async fn boot_flaky_venue( - adapter_wasm: PathBuf, - limits: crate::engine_config::ModuleLimits, -) -> ( - Supervisor, - crate::test_utils::MockChainProvider, -) { - use crate::engine_config::AdapterEntry; - use crate::host::component::ChainMethod; - - let chain = crate::test_utils::MockChainProvider::new(); - chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); - let components = crate::test_utils::mock_components_from( - chain.clone(), - crate::test_utils::MockStateStore::new(), - ); - let engine = make_wasmtime_engine(); - let linker = crate::supervisor::build_linker::(&engine, &[]) - .expect("build_linker"); - let config = EngineConfig { - adapters: vec![AdapterEntry { - path: adapter_wasm, - manifest: Some(fixture_module_toml( - "modules/fixtures/flaky-venue/module.toml", - )), - http_allow: Vec::new(), - messaging_topics: Vec::new(), - }], - limits, - ..Default::default() - }; - let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &[], None) - .await - .expect("boot"); - (supervisor, chain) -} - -/// A test block that drives the dispatch-time sweeps. -fn sweep_block() -> nexum::host::types::Block { - nexum::host::types::Block { - chain_id: 1, - number: 1, - hash: vec![0; 32], - timestamp: 1_700_000_000_000, - } -} - -/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped -/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the -/// provider restart sweep reinstantiates it after backoff, after which a -/// submit succeeds again. +/// A module subscribing to an extension kind no wired extension declares +/// is refused at boot, preserving the unknown-kind fail-fast. #[tokio::test] -async fn e2e_trapped_adapter_is_swept_and_restarts() { - use crate::bindings::{SubmitOutcome, VenueError}; - use crate::host::component::ChainMethod; - use crate::host::venue_registry::VenueId; - - let Some(wasm) = module_wasm_or_skip("flaky-venue") else { +async fn boot_refuses_an_undeclared_extension_subscription_kind() { + let Some(wasm) = example_wasm_or_skip() else { return; }; - let (mut supervisor, chain) = - boot_flaky_venue(wasm, crate::engine_config::ModuleLimits::default()).await; - assert_eq!(supervisor.adapter_count(), 1); - assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); - let registry = supervisor.venue_registry().expect("registry service"); - let venue = VenueId::from("flaky-venue"); - - // The poison head detonates submit: the guest panic traps the store - // and the shared liveness drops. - let err = registry - .submit("mod-a", &venue, b"body".to_vec()) - .await - .expect_err("the poison head traps the adapter"); - assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); - assert_eq!( - supervisor.adapter_alive_count(), - 0, - "the trap drops liveness" - ); + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = dir.path().join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" - // Temporarily dead resolves distinctly from never installed. - assert!(matches!( - registry.submit("mod-a", &venue, b"body".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); - assert!(matches!( - registry - .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) - .await, - Err(VenueError::UnknownVenue) - )); - - // The venue recovers; past the 1s backoff the dispatch-time sweep - // reinstalls the adapter on a fresh store. - chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); - tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); - let outcome = registry - .submit("mod-a", &venue, b"body".to_vec()) - .await - .expect("the recovered adapter accepts"); - assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); -} +[capabilities] +required = ["logging"] -/// A crash-looping adapter is quarantined by the provider poison sweep: -/// at the threshold the restarts stop, and the venue stays dead past every -/// backoff until an operator intervenes. -#[tokio::test] -async fn e2e_crash_looping_adapter_is_poisoned() { - use crate::bindings::VenueError; - use crate::engine_config::PoisonLimitsSection; - use crate::host::venue_registry::VenueId; +[[subscription]] +kind = "acme-status" +"#, + ) + .expect("write manifest"); - let Some(wasm) = module_wasm_or_skip("flaky-venue") else { - return; - }; - let limits = ModuleLimits { - poison: PoisonLimitsSection { - max_failures: Some(2), - window_secs: Some(600), - }, - ..ModuleLimits::default() - }; - // The chain head stays at the poison sentinel for the whole test: every - // submit after a restart traps again. - let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; - let registry = supervisor.venue_registry().expect("registry service"); - let venue = VenueId::from("flaky-venue"); - - // Trap 1, then a successful restart past the 1s backoff. - let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; - tokio::time::sleep(std::time::Duration::from_millis(1_200)).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); - - // Trap 2 crosses the 2-failure threshold: the sweep quarantines the - // adapter instead of scheduling another restart. - let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); - - // Past every backoff the poisoned adapter stays dead and unavailable. - tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; - supervisor.dispatch_block(sweep_block()).await; - assert_eq!( - supervisor.adapter_alive_count(), - 0, - "no restart while poisoned" + let engine = make_wasmtime_engine(); + let linker = make_linker(&engine); + let (_dir, local_store) = temp_local_store(); + let components = test_components(local_store); + let limits = ModuleLimits::default(); + + let result = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &core_extensions(), + None, + ) + .await; + let err = result + .err() + .expect("an undeclared extension subscription kind must refuse boot"); + let msg = format!("{err:#}"); + assert!( + msg.contains("unknown event kind acme-status"), + "the refusal names the kind: {msg}", ); - assert!(matches!( - registry.submit("mod-a", &venue, b"body".to_vec()).await, - Err(VenueError::Unavailable(_)) - )); } diff --git a/crates/shepherd-cow-host/tests/cow_boot.rs b/crates/shepherd-cow-host/tests/cow_boot.rs index 559cf28e..4612f12b 100644 --- a/crates/shepherd-cow-host/tests/cow_boot.rs +++ b/crates/shepherd-cow-host/tests/cow_boot.rs @@ -1,7 +1,6 @@ //! Boot-order coverage for the cow-api extension: a module that imports //! `shepherd:cow/cow-api` boots and dispatches once the extension is wired -//! at the composition root. The negative direction (fails to boot without -//! the extension) lives in the runtime's own supervisor tests. +//! at the composition root, and fails to boot without it. //! //! These exercise the real wit-bindgen + supervisor path against pre-built //! wasm artefacts and skip gracefully when the artefact is absent. @@ -218,3 +217,48 @@ async fn e2e_stop_loss_block_dispatch() { assert_eq!(dispatched, 1); assert_eq!(supervisor.alive_count(), 1); } + +/// The boot-order invariant, exercised (not merely asserted in prose): +/// a module that imports `shepherd:cow/cow-api` (twap-monitor) must NOT +/// boot when the cow extension is absent from the linker AND the +/// capability registry. The paired linker-hook + capability-namespace +/// registration is what makes the same module boot in the tests above; +/// drop the pairing and boot fails. +#[tokio::test] +async fn twap_monitor_without_cow_extension_fails_to_boot() { + let Some(wasm) = module_wasm_or_skip("twap-monitor") else { + return; + }; + let manifest = production_module_toml("modules/twap-monitor/module.toml"); + let engine = make_wasmtime_engine(); + // Core-only: no cow linker hook, no cow capability namespace. + let linker = build_linker::(&engine, &[]).expect("build_linker"); + let (_dir, store) = temp_local_store(); + let components = test_components(store).await; + let limits = ModuleLimits::default(); + + let result = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &[], + None, + ) + .await; + + let err = result + .err() + .expect("cow-importing module must not boot without the cow extension registered"); + // Pin the failure to its specific cause: twap-monitor declares the + // cow-api capability, which a core-only registry does not recognise + // (registering it is exactly what the cow extension does). Rules out + // an unrelated failure masquerading as the invariant. + let chain = format!("{err:#}"); + assert!( + chain.contains(r#"unknown capability "cow-api""#), + "expected the cow-api unknown-capability failure, got: {chain}", + ); +} diff --git a/crates/shepherd/Cargo.toml b/crates/shepherd/Cargo.toml index 63e99085..2b5ed588 100644 --- a/crates/shepherd/Cargo.toml +++ b/crates/shepherd/Cargo.toml @@ -16,6 +16,7 @@ path = "src/main.rs" nexum-launch = { path = "../nexum-launch" } nexum-runtime = { path = "../nexum-runtime" } shepherd-cow-host = { path = "../shepherd-cow-host" } +videre-host = { path = "../videre-host" } anyhow.workspace = true tokio.workspace = true diff --git a/crates/shepherd/src/main.rs b/crates/shepherd/src/main.rs index 4022ff57..9a32d9fd 100644 --- a/crates/shepherd/src/main.rs +++ b/crates/shepherd/src/main.rs @@ -1,12 +1,14 @@ //! The `shepherd` binary: the cow composition root. Binds the reference -//! lattice with the cow-api extension payload in the `Ext` slot and hands -//! it to the generic launcher; the engine itself stays cow-free. +//! lattice with the cow-api extension payload in the `Ext` slot, registers +//! the videre venue platform, and hands it all to the generic launcher; +//! the engine itself stays venue- and cow-free. #![cfg_attr(not(test), warn(unused_crate_dependencies))] use std::sync::Arc; use nexum_runtime::addons::{AddOns, PrometheusAddOn}; +use nexum_runtime::engine_config::EngineConfig; use nexum_runtime::host::component::{ ComponentsBuilder, LocalStoreBuilder, LogPipelineBuilder, ProviderPoolBuilder, RuntimeTypes, }; @@ -27,8 +29,8 @@ impl RuntimeTypes for ReferenceTypes { type Ext = ReferenceExt; } -/// The cow preset: reference backends, the cow-api extension, and the -/// Prometheus add-on. +/// The cow preset: reference backends, the videre venue platform, the +/// cow-api extension, and the Prometheus add-on. #[derive(Debug, Clone, Copy, Default)] struct ShepherdRuntime; @@ -49,8 +51,11 @@ impl Runtime for ShepherdRuntime { vec![Box::new(PrometheusAddOn)] } - fn extensions(&self) -> Vec>> { - vec![extension::()] + fn extensions(&self, config: &EngineConfig) -> Vec>> { + vec![ + Arc::new(videre_host::platform(config)), + extension::(), + ] } } diff --git a/crates/videre-host/Cargo.toml b/crates/videre-host/Cargo.toml new file mode 100644 index 00000000..a2d9ae30 --- /dev/null +++ b/crates/videre-host/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "videre-host" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "The videre venue platform as one nexum-runtime extension: the venue-adapter provider kind, the venue registry service, the advisory egress-guard seam, and the videre:venue/client interface." + +[lints] +workspace = true + +[dependencies] +# The runtime seam this crate plugs into; the only nexum crate edge. +nexum-runtime = { path = "../nexum-runtime" } +# Encoder for the opaque status body the host event stream carries; the +# registry lowers an adapter-reported status through it. +nexum-status-body = { path = "../nexum-status-body" } + +wasmtime.workspace = true +anyhow.workspace = true +thiserror.workspace = true +async-trait.workspace = true +futures.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +nexum-runtime = { path = "../nexum-runtime", features = ["test-utils"] } +nexum-tasks = { path = "../nexum-tasks" } +tempfile.workspace = true diff --git a/crates/videre-host/src/bindings.rs b/crates/videre-host/src/bindings.rs new file mode 100644 index 00000000..c6281016 --- /dev/null +++ b/crates/videre-host/src/bindings.rs @@ -0,0 +1,231 @@ +//! WIT bindings for the venue platform, generated by +//! `wasmtime::component::bindgen!`. +//! +//! The shared `nexum:host` interfaces are reused from the runtime's +//! `event-module` bindings via `with`, so the `chain`/`messaging` `Host` +//! impls and the `fault` type an adapter sees are the very ones the core +//! host constructs. The `videre:types` and `videre:value-flow` types +//! generate in the adapter bindgen and the client bindgen remaps onto +//! them, so one Rust type serves the registry and the adapter face alike. +//! `PartialEq` is derived so the registry can compare a polled status +//! against the last delivered one. + +/// The provider face: the `videre:venue/venue-adapter` world. An adapter +/// imports only the scoped transport it needs (chain and messaging; +/// outbound HTTP is wasi:http, linked and allowlisted separately) and +/// exports the `videre:venue/adapter` face plus `init`. +mod venue_adapter { + wasmtime::component::bindgen!({ + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + world: "videre:venue/venue-adapter", + imports: { default: async }, + exports: { default: async }, + with: { + "nexum:host/types": nexum_runtime::bindings::nexum::host::types, + "nexum:host/chain": nexum_runtime::bindings::nexum::host::chain, + "nexum:host/messaging": nexum_runtime::bindings::nexum::host::messaging, + }, + additional_derives: [PartialEq], + }); +} + +pub use venue_adapter::VenueAdapter; + +/// The keeper-facing `videre:venue/client` import bound host-side. The +/// world imports the interface a module calls; the videre types it uses +/// are reused from the adapter bindings above via `with`, so the +/// `SubmitOutcome` and `VenueError` the registry hands back to a module +/// are the very ones an adapter's `submit` produced. Async, because the +/// `Host` impl awaits the per-adapter mutex and the adapter's own async +/// guest calls. +mod client_host { + wasmtime::component::bindgen!({ + inline: " + package videre:client-host; + world client-host { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + imports: { default: async }, + with: { + "videre:value-flow/types": super::venue_adapter::videre::value_flow::types, + "videre:types/types": super::venue_adapter::videre::types::types, + }, + }); +} + +/// The host-bound client interface: the `Host` trait the registry implements +/// and the `add_to_linker` the videre extension's linker hook calls. +pub use client_host::videre::venue::client; +/// The shared intent ontology, re-exported at the plain spellings the +/// registry and the `client::Host` impl name. +pub use venue_adapter::videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, +}; +/// The value-flow vocabulary the header is expressed in. +pub use venue_adapter::videre::value_flow::types as value_flow; + +/// Bindgen smoke for the `videre:value-flow` types package, compiled under +/// test through a throwaway world that imports the interface. Its value is +/// the identifier-hygiene gate: the test names every generated type, +/// variant, and field by its plain Rust spelling, so a WIT id that collided +/// with a Rust keyword would surface as an `r#` escape and fail to compile +/// here rather than in a downstream binding. +#[cfg(test)] +mod value_flow_smoke { + wasmtime::component::bindgen!({ + inline: " + package videre:value-flow-smoke; + world smoke { + import videre:value-flow/types@0.1.0; + } + ", + path: ["../../wit/videre-value-flow"], + }); + + #[test] + fn identifiers_bind_unescaped() { + use videre::value_flow::types::{Asset, AssetAmount, Erc20}; + + let erc20 = Erc20 { + token: vec![0u8; 20], + }; + let _ = Asset::Native; + let asset = Asset::Erc20(erc20); + + let amount = AssetAmount { + asset, + amount: Vec::new(), + }; + assert!(amount.amount.is_empty()); + } +} + +/// Bindgen smoke for the `videre:types` and `videre:venue` packages, +/// mirroring the value-flow smoke above: a throwaway world imports the +/// client interface and, transitively, the types interface and its +/// value-flow dependency. The test names every generated type, case, and +/// field by its plain Rust spelling, and a dummy `client` host impl pins +/// the four function signatures, so a keyword collision or an accidental +/// signature change fails this build rather than a downstream binding. +#[cfg(test)] +mod client_smoke { + wasmtime::component::bindgen!({ + inline: " + package videre:client-smoke; + world smoke { + import videre:venue/client@0.1.0; + } + ", + path: [ + "../../wit/videre-value-flow", + "../../wit/videre-types", + "../../wit/nexum-host", + "../../wit/videre-venue", + ], + }); + + use videre::types::types::{ + AuthScheme, IntentHeader, IntentStatus, Quotation, RateLimit, Settlement, SubmitOutcome, + UnsignedTx, VenueError, + }; + use videre::value_flow::types::{Asset, AssetAmount}; + + struct DummyClient; + + impl videre::venue::client::Host for DummyClient { + fn quote(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn submit(&mut self, _venue: String, _body: Vec) -> Result { + Err(VenueError::UnknownVenue) + } + + fn status( + &mut self, + _venue: String, + _receipt: Vec, + ) -> Result { + Err(VenueError::UnknownVenue) + } + + fn cancel(&mut self, _venue: String, _receipt: Vec) -> Result<(), VenueError> { + Err(VenueError::UnknownVenue) + } + } + + fn amount(bytes: Vec) -> AssetAmount { + AssetAmount { + asset: Asset::Native, + amount: bytes, + } + } + + #[test] + fn identifiers_bind_unescaped() { + use videre::venue::client::Host; + + let _ = AuthScheme::Eip1271; + let _ = AuthScheme::Eip712; + + let header = IntentHeader { + gives: amount(vec![1]), + wants: amount(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: AuthScheme::Eip712, + }; + assert!(header.wants.amount.is_empty()); + + let _ = IntentStatus::Pending; + let _ = IntentStatus::Open; + let _ = IntentStatus::Fulfilled; + let _ = IntentStatus::Cancelled; + let _ = IntentStatus::Expired; + + let tx = UnsignedTx { + chain: 1, + to: Vec::new(), + value: Vec::new(), + data: Vec::new(), + }; + let _ = SubmitOutcome::Accepted(Vec::new()); + let _ = SubmitOutcome::RequiresSigning(tx); + + let quotation = Quotation { + gives: amount(vec![1]), + wants: amount(Vec::new()), + fee: amount(Vec::new()), + valid_until_ms: 0, + }; + assert!(quotation.fee.amount.is_empty()); + + let _ = VenueError::UnknownVenue; + let _ = VenueError::InvalidBody(String::new()); + let _ = VenueError::Unsupported; + let _ = VenueError::Denied(String::new()); + let _ = VenueError::RateLimited(RateLimit { + retry_after_ms: Some(250), + }); + let _ = VenueError::Unavailable(String::new()); + let _ = VenueError::Timeout; + + let mut client = DummyClient; + assert!(client.quote(String::new(), Vec::new()).is_err()); + assert!(client.submit(String::new(), Vec::new()).is_err()); + assert!(client.status(String::new(), Vec::new()).is_err()); + assert!(client.cancel(String::new(), Vec::new()).is_err()); + } +} diff --git a/crates/nexum-runtime/src/host/impls/venue_client.rs b/crates/videre-host/src/client.rs similarity index 84% rename from crates/nexum-runtime/src/host/impls/venue_client.rs rename to crates/videre-host/src/client.rs index 8b86e8a4..2973b01e 100644 --- a/crates/nexum-runtime/src/host/impls/venue_client.rs +++ b/crates/videre-host/src/client.rs @@ -4,15 +4,18 @@ //! resolution, per-adapter serialisation, guard seam (advisory-only for //! now), and quota. The caller identity the registry meters against is this //! store's module namespace. No registry service means no venues, so every -//! call resolves to `unknown-venue`. +//! call resolves to `unknown-venue`. The `Host` trait is local to this +//! crate's bindgen, so implementing it for the runtime's `HostState` is +//! orphan-legal. use std::sync::Arc; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::state::HostState; + use crate::bindings::client::Host; use crate::bindings::{IntentStatus, Quotation, SubmitOutcome, VenueError}; -use crate::host::component::RuntimeTypes; -use crate::host::state::HostState; -use crate::host::venue_registry::{VenueId, VenueRegistry}; +use crate::registry::{VenueId, VenueRegistry}; /// The registry published under the videre service namespace. fn registry(state: &HostState) -> Result, VenueError> { diff --git a/crates/videre-host/src/lib.rs b/crates/videre-host/src/lib.rs new file mode 100644 index 00000000..fcd715bb --- /dev/null +++ b/crates/videre-host/src/lib.rs @@ -0,0 +1,145 @@ +//! The videre venue platform, packaged as one [`nexum_runtime`] +//! extension: the venue-adapter provider kind, the [`VenueRegistry`] +//! service, the advisory [`EgressGuard`] seam, and the keeper-facing +//! `videre:venue/client` interface. A composition root registers it all +//! with `builder.with_extensions([Arc::new(videre_host::platform(cfg))])`. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] + +pub mod bindings; +mod client; +mod registry; + +use std::sync::Arc; +use std::time::Duration; + +use nexum_runtime::bindings::nexum::host::types::Event; +use nexum_runtime::engine_config::EngineConfig; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + EventSources, Extension, ExtensionEvent, ExtensionEventStream, HostService, ProviderKind, +}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::NamespaceCaps; +use tokio::sync::mpsc; +use wasmtime::component::{HasSelf, Linker}; + +pub use registry::{ + DuplicateVenue, EgressGuard, GuardContext, GuardVerdict, IntentStatusUpdate, VenueActor, + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, +}; + +/// The subscription kind the platform's status poller emits. +const INTENT_STATUS_KIND: &str = "intent-status"; + +/// Buffer for the status poll channel; small because the event loop +/// drains in real time. +const STATUS_CHANNEL_BUF: usize = 64; + +/// The venue platform over the config-resolved quota and watch policy, +/// with the unit guard. The single registration entrypoint. +pub fn platform(config: &EngineConfig) -> Videre { + Videre::from_registry( + VenueRegistryBuilder::new(config.limits.quota()) + .with_watch_limit(config.limits.watch()) + .build(), + ) +} + +/// The videre platform as one runtime extension. Registers the +/// `videre:venue/client` interface and its capability namespace on every +/// worker linker, publishes the [`VenueRegistry`] service, installs the +/// venue-adapter provider kind, and opens the status-poll event source. +pub struct Videre { + registry: Arc, +} + +impl Videre { + /// Assemble over a pre-built registry, for a custom [`EgressGuard`] + /// or policy; [`platform`] covers the config-resolved default. + pub fn from_registry(registry: VenueRegistry) -> Self { + Self { + registry: Arc::new(registry), + } + } +} + +impl Extension for Videre { + fn namespace(&self) -> &'static str { + VenueRegistry::NAMESPACE + } + + /// Only the keeper-facing `client` interface is a capability; the + /// `videre:types` and `videre:value-flow` packages are type-only and + /// need no declaration. + fn capabilities(&self) -> NamespaceCaps { + NamespaceCaps { + prefix: "videre:venue/", + ifaces: &["client"], + } + } + + fn link(&self, linker: &mut Linker>) -> anyhow::Result<()> { + bindings::client::add_to_linker::, HasSelf>>(linker, |state| { + state + })?; + Ok(()) + } + + fn service(&self) -> Option> { + Some(Arc::clone(&self.registry) as Arc) + } + + fn provider(&self) -> Option>> { + Some(Box::new(VenueAdapterKind)) + } + + fn subscriptions(&self) -> &'static [&'static str] { + &[INTENT_STATUS_KIND] + } + + /// The status poll source: on every cadence tick, poll each installed + /// adapter's status export through the shared registry and forward the + /// observed transitions. Opened only when a module subscribes and at + /// least one venue is installed. + fn events(&self, sources: &mut EventSources<'_>) -> anyhow::Result> { + if !sources.subscribed.contains(INTENT_STATUS_KIND) { + return Ok(Vec::new()); + } + let registry = (*self.registry).clone(); + if registry.venue_count() == 0 { + return Ok(Vec::new()); + } + let cadence = sources.config.limits.status_poll_interval(); + let (tx, rx) = mpsc::channel::(STATUS_CHANNEL_BUF); + sources.spawn(status_poll_task(registry, cadence, tx)); + let stream = futures::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + Ok(vec![Box::pin(stream)]) + } +} + +/// Poll loop behind [`Extension::events`]. Sleeps the cadence first so +/// the engine's boot dispatch settles before the first poll; ends when +/// the event loop drops its receiver. +async fn status_poll_task( + registry: VenueRegistry, + cadence: Duration, + tx: mpsc::Sender, +) { + loop { + tokio::time::sleep(cadence).await; + for update in registry.poll_status_transitions().await { + let event = ExtensionEvent { + kind: INTENT_STATUS_KIND, + attrs: vec![("venue", update.venue.clone())], + event: Event::IntentStatus(update), + }; + if tx.send(event).await.is_err() { + // Receiver dropped -> engine shutting down. + return; + } + } + } +} diff --git a/crates/nexum-runtime/src/host/venue_registry.rs b/crates/videre-host/src/registry.rs similarity index 93% rename from crates/nexum-runtime/src/host/venue_registry.rs rename to crates/videre-host/src/registry.rs index c53113b9..b7e961a0 100644 --- a/crates/nexum-runtime/src/host/venue_registry.rs +++ b/crates/videre-host/src/registry.rs @@ -31,50 +31,27 @@ use std::time::{Duration, Instant}; use anyhow::{Context, anyhow}; use async_trait::async_trait; use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{SubmitQuota, WatchLimit}; +use nexum_runtime::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; +use nexum_runtime::host::component::RuntimeTypes; +use nexum_runtime::host::extension::{ + HostService, Installed, ProviderInstance, ProviderKind, downcast_service, +}; +use nexum_runtime::host::state::HostState; use nexum_status_body::StatusBody; use tokio::sync::Mutex as AsyncMutex; use tracing::{info, warn}; use wasmtime::Store; use wasmtime::component::HasSelf; +/// The registry-observed status transition delivered through the host +/// `event` variant, re-exported at the spelling the registry names. +pub use nexum_runtime::bindings::nexum::host::types::IntentStatusUpdate; + use crate::bindings::{ - IntentHeader, IntentStatus, IntentStatusUpdate, Quotation, RateLimit, SubmitOutcome, - VenueAdapter, VenueError, nexum, -}; -use crate::host::actor::{ActorFault, ActorSlot, Liveness, SupervisedStore}; -use crate::host::component::RuntimeTypes; -use crate::host::extension::{ - HostService, Installed, ProviderInstance, ProviderKind, downcast_service, + IntentHeader, IntentStatus, Quotation, RateLimit, SubmitOutcome, VenueAdapter, VenueError, }; -use crate::host::state::HostState; - -/// Default per-caller submission budget within [`DEFAULT_QUOTA_WINDOW`]. -pub const DEFAULT_QUOTA_MAX_CHARGES: u32 = 256; -/// Default sliding window the per-caller submission budget is counted over. -pub const DEFAULT_QUOTA_WINDOW: Duration = Duration::from_secs(60); -/// Default cap on receipts under status watch at once. -pub const DEFAULT_WATCH_MAX_ENTRIES: usize = 1024; -/// Default base window: the poll cadence a healthy venue refreshes within. -/// The give-up deadline is the derived `grace`, not this value directly. -pub const DEFAULT_WATCH_EXPIRY: Duration = Duration::from_secs(86_400); -/// Derived grace defaults to this many `expiry` windows, so a watch rides -/// out a venue outage of a couple of poll cadences before giving up. -pub const WATCH_GRACE_MULTIPLIER: u64 = 2; -/// Ceiling on the derived grace window, so a long `expiry` cannot stretch -/// the give-up deadline past a day. -pub const WATCH_GRACE_MAX: Duration = Duration::from_secs(86_400); - -/// The give-up window derived from `expiry`: `min(MULTIPLIER * expiry, MAX)`. -/// An explicit `grace_secs` in config overrides this. -const fn derive_grace(expiry: Duration) -> Duration { - let scaled = expiry.as_secs().saturating_mul(WATCH_GRACE_MULTIPLIER); - let capped = if scaled < WATCH_GRACE_MAX.as_secs() { - scaled - } else { - WATCH_GRACE_MAX.as_secs() - }; - Duration::from_secs(capped) -} /// Venue identifier: the id an adapter registers under and a submission /// names. Opaque beyond equality. @@ -106,73 +83,6 @@ impl fmt::Display for VenueId { } } -/// Per-caller submission quota. Both a forwarded submission and a charged -/// decode failure consume one unit; the window slides so a caller's budget -/// refills as old charges age out. -#[derive(Debug, Clone, Copy)] -pub struct SubmitQuota { - /// Maximum charges a single caller may accrue within `window`. - pub max_charges: u32, - /// Sliding window the charges are counted across. - pub window: Duration, -} - -impl SubmitQuota { - /// Pair a budget with the window it is counted over. - pub const fn new(max_charges: u32, window: Duration) -> Self { - Self { - max_charges, - window, - } - } -} - -impl Default for SubmitQuota { - fn default() -> Self { - Self::new(DEFAULT_QUOTA_MAX_CHARGES, DEFAULT_QUOTA_WINDOW) - } -} - -/// Bounds on the status-watch set. The cap bounds the per-cadence poll -/// fan-out; `grace` is the give-up deadline: how long a watch rides out an -/// unreachable venue before it is evicted unreported. `expiry` is the base -/// window `grace` derives from. -#[derive(Debug, Clone, Copy)] -pub struct WatchLimit { - /// Maximum receipts under status watch at once. - pub max_entries: usize, - /// Base window a healthy venue refreshes the deadline within. - pub expiry: Duration, - /// Give-up deadline: how long a watch survives an unreachable venue - /// before it is evicted unreported. A reachable poll (the venue - /// answered) resets it; a resolve failure or an errored poll rides out - /// against it. Derived `min(MULTIPLIER * expiry, MAX)` unless set. - pub grace: Duration, -} - -impl WatchLimit { - /// Pair a cap with the base expiry; `grace` derives from `expiry`. - pub const fn new(max_entries: usize, expiry: Duration) -> Self { - Self::with_grace(max_entries, expiry, derive_grace(expiry)) - } - - /// As [`new`](Self::new) but with an explicit `grace` window, the path - /// a configured `grace_secs` takes. - pub const fn with_grace(max_entries: usize, expiry: Duration, grace: Duration) -> Self { - Self { - max_entries, - expiry, - grace, - } - } -} - -impl Default for WatchLimit { - fn default() -> Self { - Self::new(DEFAULT_WATCH_MAX_ENTRIES, DEFAULT_WATCH_EXPIRY) - } -} - /// The guard interposition seam. The registry runs this on the /// adapter-derived header after `derive-header` and before `submit`. /// @@ -758,9 +668,8 @@ impl VenueRegistry { } /// The venue-adapter provider kind: boots a `videre:venue/venue-adapter` -/// component and installs its actor in the venue registry. Registered by -/// the boot path while the registry lives in-core; the videre extension -/// takes it over. +/// component and installs its actor in the venue registry. Registered +/// through the videre extension's provider slot. pub struct VenueAdapterKind; impl VenueAdapterKind { @@ -815,8 +724,7 @@ impl ProviderKind for VenueAdapterKind { Err(e) => { warn!( adapter = %venue_id, - kind = crate::host::error::fault_label(&e), - message = crate::host::error::fault_message(&e), + fault = ?e, "adapter init failed - loaded but marked dead", ); return Ok(Installed::Dead); @@ -939,6 +847,7 @@ mod tests { use crate::bindings::value_flow::{Asset, AssetAmount}; use crate::bindings::{AuthScheme, IntentHeader, Settlement, UnsignedTx}; + use nexum_runtime::engine_config::WATCH_GRACE_MAX; use super::*; @@ -1510,7 +1419,7 @@ mod tests { #[test] fn zero_watch_cap_saturates_to_one() { let registry = VenueRegistryBuilder::new(SubmitQuota::default()) - .with_watch_limit(WatchLimit::new(0, DEFAULT_WATCH_EXPIRY)) + .with_watch_limit(WatchLimit::new(0, Duration::from_secs(60))) .build(); assert_eq!(registry.inner.watch_limit.max_entries, 1); } diff --git a/crates/videre-host/tests/platform.rs b/crates/videre-host/tests/platform.rs new file mode 100644 index 00000000..05bc062e --- /dev/null +++ b/crates/videre-host/tests/platform.rs @@ -0,0 +1,714 @@ +//! E2E coverage for the videre platform over the generic runtime seam: +//! the venue-adapter provider boot, the client -> registry -> adapter +//! round trip, the status-poll event source, and the trap-to-recovery +//! sweeps. Exercises pre-built wasm artefacts and skips gracefully when +//! an artefact is absent. + +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use futures::future::BoxFuture; +use nexum_runtime::bindings::nexum; +use nexum_runtime::engine_config::{ + AdapterEntry, EngineConfig, ModuleEntry, ModuleLimits, PoisonLimitsSection, +}; +use nexum_runtime::host::component::ChainMethod; +use nexum_runtime::host::extension::{EventSources, Extension, ExtensionEvent}; +use nexum_runtime::host::state::HostState; +use nexum_runtime::manifest::CapabilityRegistry; +use nexum_runtime::supervisor::{Supervisor, build_linker, build_provider_linker}; +use nexum_runtime::test_utils::{MockChainProvider, MockStateStore, MockTypes, mock_components}; +use videre_host::bindings::{ + IntentHeader, IntentStatus, Quotation, Settlement, SubmitOutcome, VenueError, value_flow, +}; +use videre_host::{ + VenueAdapterKind, VenueId, VenueInvoker, VenueRegistry, VenueRegistryBuilder, Videre, platform, +}; +use wasmtime::component::Linker; + +/// The subscription kind the platform's status poller emits. +const INTENT_STATUS: &str = "intent-status"; + +// ── fixtures + assembly ─────────────────────────────────────────────── + +/// Workspace-root-relative path. `CARGO_MANIFEST_DIR` is +/// `crates/videre-host`; two parents up is the workspace root. +fn workspace_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates dir") + .parent() + .expect("workspace root") + .join(relative) +} + +/// Path to a module's `.wasm` artefact under the workspace target dir, +/// or `None` with a skip message when it is not built. +fn module_wasm_or_skip(module_name: &str) -> Option { + let artifact = module_name.replace('-', "_"); + let p = workspace_path(&format!("target/wasm32-wasip2/release/{artifact}.wasm")); + if p.exists() { + Some(p) + } else { + eprintln!( + "SKIP: {} not found - build with `cargo build -p {module_name} --target wasm32-wasip2 --release`", + p.display() + ); + None + } +} + +fn make_wasmtime_engine() -> wasmtime::Engine { + let mut config = wasmtime::Config::new(); + config.wasm_component_model(true); + config.consume_fuel(true); + wasmtime::Engine::new(&config).expect("wasmtime engine") +} + +/// The platform under test plus the extension slice the boot paths take. +/// The concrete handle stays available for the event-source calls. +fn videre_assembly(videre: &Arc) -> Vec>> { + vec![Arc::clone(videre) as Arc>] +} + +fn make_linker( + engine: &wasmtime::Engine, + extensions: &[Arc>], +) -> Linker> { + build_linker::(engine, extensions).expect("build_linker") +} + +/// The registry the booted supervisor publishes under the videre +/// namespace. +fn registry_of(supervisor: &Supervisor) -> Arc { + supervisor + .services() + .get::(VenueRegistry::NAMESPACE) + .expect("registry service") +} + +/// A test block that drives dispatch and the dispatch-time sweeps. +fn block(chain_id: u64) -> nexum::host::types::Block { + nexum::host::types::Block { + chain_id, + number: 19_000_000, + hash: vec![0xab; 32], + timestamp: 1_700_000_000_000, + } +} + +/// Wrap a polled transition as the extension event the platform emits. +fn status_event(update: videre_host::IntentStatusUpdate) -> ExtensionEvent { + ExtensionEvent { + kind: INTENT_STATUS, + attrs: vec![("venue", update.venue.clone())], + event: nexum::host::types::Event::IntentStatus(update), + } +} + +// ── world contract ──────────────────────────────────────────────────── + +/// The per-component venue-adapter world contract: an adapter built +/// through `#[nexum_venue_sdk::venue]` imports exactly the scoped +/// transport its manifest declares (`chain`), by construction of the +/// emitted world. The venue side never depended on toolchain elision; +/// this pins that it does not regress to it. +#[test] +fn e2e_echo_venue_component_imports_equal_declared_capabilities() { + let Some(wasm) = module_wasm_or_skip("echo-venue") else { + return; + }; + let engine = make_wasmtime_engine(); + let component = wasmtime::component::Component::from_file(&engine, &wasm).expect("compile"); + let imports: Vec = component + .component_type() + .imports(&engine) + .map(|(name, _)| name.to_owned()) + .collect(); + + // Capability-bearing imports resolve to exactly the declared set. + let registry = CapabilityRegistry::core(); + let caps: std::collections::BTreeSet<&str> = imports + .iter() + .filter_map(|name| registry.wit_import_to_cap(name)) + .collect(); + assert_eq!( + caps, + std::collections::BTreeSet::from(["chain"]), + "imports were: {imports:?}" + ); + + // No host key-material or persistence interface leaks in: an adapter + // structurally cannot reach messaging it never declared, local-store, + // identity, or logging. + assert!( + imports.iter().all(|name| !name.contains("messaging") + && !name.contains("local-store") + && !name.contains("identity") + && !name.contains("logging")), + "imports were: {imports:?}" + ); +} + +/// The venue-adapter provider linker binds only the scoped transport +/// (chain, messaging, wasi base, allowlisted http) and withholds the +/// core-only interfaces. Assembling it proves the scope wires without a +/// duplicate-definition clash between the shared `nexum:host` interfaces. +#[tokio::test] +async fn provider_linker_assembles_with_scoped_transport() { + let engine = make_wasmtime_engine(); + build_provider_linker::(&engine, &VenueAdapterKind) + .expect("provider linker assembles"); +} + +// ── intent-status subscription E2E ──────────────────────────────────── + +/// A scripted venue adapter for the registry: accepts every submission +/// with a fixed receipt and serves statuses front-first from a script; +/// once drained, every further call reports `open`. +struct ScriptedAdapter { + statuses: VecDeque, +} + +impl ScriptedAdapter { + fn new(statuses: impl IntoIterator) -> Self { + Self { + statuses: statuses.into_iter().collect(), + } + } +} + +fn native(bytes: Vec) -> value_flow::AssetAmount { + value_flow::AssetAmount { + asset: value_flow::Asset::Native, + amount: bytes, + } +} + +impl VenueInvoker for ScriptedAdapter { + fn derive_header<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(IntentHeader { + gives: native(vec![1]), + wants: native(Vec::new()), + settlement: Settlement { chain: 1 }, + authorisation: videre_host::bindings::AuthScheme::Eip712, + }) + }) + } + + fn quote<'a>(&'a mut self, _body: &'a [u8]) -> BoxFuture<'a, Result> { + Box::pin(async move { + Ok(Quotation { + gives: native(vec![1]), + wants: native(Vec::new()), + fee: native(Vec::new()), + valid_until_ms: 1_700_000_000_000, + }) + }) + } + + fn submit<'a>( + &'a mut self, + _body: &'a [u8], + ) -> BoxFuture<'a, Result> { + Box::pin(async move { Ok(SubmitOutcome::Accepted(b"receipt".to_vec())) }) + } + + fn status(&mut self, _receipt: Vec) -> BoxFuture<'_, Result> { + Box::pin(async move { Ok(self.statuses.pop_front().unwrap_or(IntentStatus::Open)) }) + } + + fn cancel(&mut self, _receipt: Vec) -> BoxFuture<'_, Result<(), VenueError>> { + Box::pin(async move { Ok(()) }) + } +} + +/// A registry with one scripted adapter installed under `cow`. +fn scripted_registry(adapter: ScriptedAdapter) -> VenueRegistry { + let registry = VenueRegistryBuilder::new(Default::default()).build(); + registry + .install( + VenueId::from("cow"), + nexum_runtime::host::actor::Liveness::default(), + adapter, + ) + .expect("install scripted adapter"); + registry +} + +/// Write a manifest subscribing the example module to intent-status +/// events from the `cow` venue. +fn intent_status_manifest(dir: &Path) -> PathBuf { + let manifest = dir.join("module.toml"); + std::fs::write( + &manifest, + r#" +[module] +name = "example" + +[capabilities] +required = ["logging"] + +[[subscription]] +kind = "intent-status" +venue = "cow" +"#, + ) + .expect("write manifest"); + manifest +} + +/// Boot the example module against the given videre platform. +async fn boot_example(videre: &Arc, wasm: &Path, manifest: &Path) -> Supervisor { + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let limits = ModuleLimits::default(); + Supervisor::boot_single( + &engine, + &linker, + wasm, + Some(manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single") +} + +/// The acceptance path: a module subscribed to `intent-status` receives +/// the transitions the registry observed by polling the adapter's status +/// export, and a transition from a venue outside its filter is not +/// delivered. +#[tokio::test] +async fn e2e_intent_status_subscription_receives_polled_transitions() { + let Some(wasm) = module_wasm_or_skip("example") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = intent_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([ + IntentStatus::Pending, + IntentStatus::Fulfilled, + ])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + let mut supervisor = boot_example(&videre, &wasm, &manifest).await; + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // The registry watches the receipt of an accepted submission and polls + // the adapter's status export; each poll here observes a transition. + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!(delivered, 2, "pending then fulfilled, one subscriber each"); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // A venue outside the module's filter is not delivered. + let foreign = videre_host::IntentStatusUpdate { + venue: "other".to_owned(), + receipt: b"receipt".to_vec(), + status: nexum_status_body::StatusBody { + status: nexum_status_body::IntentStatus::Open, + proof: None, + reason: None, + } + .encode() + .expect("encode"), + }; + assert_eq!( + supervisor + .dispatch_extension_event(status_event(foreign)) + .await, + 0 + ); +} + +/// The event-loop wiring, through the real seam: the platform's `events` +/// source opens against the booted service map, its poll task drives the +/// supervisor, and the module's handler observably ran (its log line is +/// retained). +#[tokio::test] +async fn e2e_intent_status_flows_through_the_event_loop() { + use nexum_tasks::{TaskManager, TaskSet}; + + let Some(wasm) = module_wasm_or_skip("example") else { + return; + }; + let dir = tempfile::tempdir().expect("tempdir"); + let manifest = intent_status_manifest(dir.path()); + + let registry = scripted_registry(ScriptedAdapter::new([])); + let videre = Arc::new(Videre::from_registry(registry.clone())); + + let engine = make_wasmtime_engine(); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let components = mock_components(); + let logs = components.logs.clone(); + let limits = ModuleLimits::default(); + let mut supervisor = Supervisor::boot_single( + &engine, + &linker, + &wasm, + Some(&manifest), + &components, + &limits, + &extensions, + None, + ) + .await + .expect("boot_single"); + + registry + .submit("test-caller", &VenueId::from("cow"), b"body".to_vec()) + .await + .expect("submit"); + + // A fast cadence so the 300 ms window sees the first poll. + let mut config = EngineConfig::default(); + config.limits.status_poll.interval_ms = Some(10); + + let manager = TaskManager::new(); + let executor = manager.executor(); + let mut tasks = TaskSet::new(); + let subscribed = supervisor.extension_subscription_kinds(); + let streams = { + let mut sources = EventSources::new( + &config, + supervisor.services(), + &subscribed, + &executor, + &mut tasks, + ); + Extension::::events(&*videre, &mut sources).expect("open event source") + }; + assert_eq!(streams.len(), 1, "one status-poll stream opened"); + + nexum_runtime::runtime::event_loop::run( + &mut supervisor, + Vec::new(), + Vec::new(), + streams, + tasks, + tokio::time::sleep(Duration::from_millis(300)), + ) + .await; + + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + let runs = logs.list_runs("example"); + assert_eq!(runs.len(), 1, "one run recorded for the example module"); + let page = logs.read(&runs[0].run, 0); + assert!( + page.records + .iter() + .any(|r| r.message.contains("intent status update from venue cow")), + "the module's on_intent_status handler ran; records were: {:?}", + page.records + .iter() + .map(|r| r.message.as_str()) + .collect::>(), + ); +} + +/// With no subscriber (or no installed venue) the platform opens no +/// event source. +#[tokio::test] +async fn event_source_stays_closed_without_subscribers_or_venues() { + use nexum_tasks::{TaskManager, TaskSet}; + + let config = EngineConfig::default(); + let manager = TaskManager::new(); + let executor = manager.executor(); + let services = nexum_runtime::host::extension::HostServices::default(); + + // A venue is installed but nothing subscribes. + let with_venue = Arc::new(Videre::from_registry(scripted_registry( + ScriptedAdapter::new([]), + ))); + let empty = std::collections::BTreeSet::new(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &empty, &executor, &mut tasks); + let streams = Extension::::events(&*with_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no subscriber, no stream"); + + // A subscriber exists but no venue is installed. + let no_venue = Arc::new(platform(&config)); + let subscribed: std::collections::BTreeSet = + std::iter::once(INTENT_STATUS.to_owned()).collect(); + let mut tasks = TaskSet::new(); + let mut sources = EventSources::new(&config, &services, &subscribed, &executor, &mut tasks); + let streams = Extension::::events(&*no_venue, &mut sources).expect("events"); + assert!(streams.is_empty(), "no venue, no stream"); +} + +// ── echo round trip ─────────────────────────────────────────────────── + +/// The acceptance path, end to end over two real components: the +/// echo-client module submits through `videre:venue/client`, the host +/// registry forwards to the installed echo-venue adapter, and the module +/// receives the fulfilled `intent-status` the registry polls back. Proves +/// the intent core round-trips module -> host registry -> venue adapter +/// with no scripted stand-ins on either side. +#[tokio::test] +async fn e2e_echo_module_registry_adapter_round_trip() { + let (Some(adapter_wasm), Some(module_wasm)) = ( + module_wasm_or_skip("echo-venue"), + module_wasm_or_skip("echo-client"), + ) else { + return; + }; + + // The adapter reads eth_blockNumber on submit to justify its `chain` + // grant; program the mock so that read succeeds. The response body is + // discarded by the adapter, so any Ok value serves. + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + let components = nexum_runtime::test_utils::mock_components_from(chain, MockStateStore::new()); + let logs = components.logs.clone(); + + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/examples/echo-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + modules: vec![ModuleEntry { + path: module_wasm, + manifest: Some(workspace_path("modules/examples/echo-client/module.toml")), + }], + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + + let mut supervisor = + Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + assert_eq!( + supervisor.adapter_alive_count(), + 1, + "echo-venue is routable" + ); + assert_eq!(supervisor.alive_count(), 1, "echo-client is alive"); + assert!( + supervisor + .extension_subscription_kinds() + .contains(INTENT_STATUS) + ); + + // A block drives the module's on_block, which submits to the echo venue + // through the shared registry; the registry watches the accepted receipt. + assert_eq!(supervisor.dispatch_block(block(1)).await, 1); + + // Poll the registry the module submitted through and fan its transitions + // back to the module. echo-venue settles instantly, so the first poll + // reports a terminal status and the watch is pruned. + let registry = registry_of(&supervisor); + let mut delivered = 0; + for _ in 0..2 { + for update in registry.poll_status_transitions().await { + assert_eq!(update.venue, "echo-venue"); + let body = + nexum_status_body::StatusBody::decode(&update.status).expect("status body decodes"); + assert_eq!( + body.status, + nexum_status_body::IntentStatus::Fulfilled, + "echo settles instantly", + ); + delivered += supervisor + .dispatch_extension_event(status_event(update)) + .await; + } + } + assert_eq!( + delivered, 1, + "one terminal status delivered to the subscriber" + ); + assert_eq!(supervisor.alive_count(), 1, "module must remain alive"); + + // The module observably completed the round trip: it quoted, it + // submitted, and it received the settled status from the echo venue. + let runs = logs.list_runs("echo-client"); + assert_eq!(runs.len(), 1, "one run recorded for echo-client"); + let page = logs.read(&runs[0].run, 0); + let messages: Vec<&str> = page.records.iter().map(|r| r.message.as_str()).collect(); + assert!( + messages + .iter() + .any(|m| m.contains("quoted") && m.contains("echo-venue")), + "module quoted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("submitted") && m.contains("echo-venue")), + "module submitted through the client face; records were: {messages:?}", + ); + assert!( + messages + .iter() + .any(|m| m.contains("intent status from venue echo-venue")), + "module received the settled status; records were: {messages:?}", + ); +} + +// ── venue-adapter trap recovery ─────────────────────────────────────── + +/// Boot one flaky-venue adapter over the mock chain, whose head starts at +/// the fixture's poison sentinel. Returns the chain handle so the test can +/// let the venue recover. +async fn boot_flaky_venue( + adapter_wasm: PathBuf, + limits: ModuleLimits, +) -> (Supervisor, MockChainProvider) { + let chain = MockChainProvider::new(); + chain.on_method(ChainMethod::EthBlockNumber, "\"0xdead\""); + let components = + nexum_runtime::test_utils::mock_components_from(chain.clone(), MockStateStore::new()); + let engine = make_wasmtime_engine(); + let config = EngineConfig { + adapters: vec![AdapterEntry { + path: adapter_wasm, + manifest: Some(workspace_path("modules/fixtures/flaky-venue/module.toml")), + http_allow: Vec::new(), + messaging_topics: Vec::new(), + }], + limits, + ..Default::default() + }; + let videre = Arc::new(platform(&config)); + let extensions = videre_assembly(&videre); + let linker = make_linker(&engine, &extensions); + let supervisor = Supervisor::boot(&engine, &linker, &config, &components, &extensions, None) + .await + .expect("boot"); + (supervisor, chain) +} + +/// The full trap-to-recovery lifecycle over a real wasm adapter: a trapped +/// venue is temporarily dead (`unavailable`, not `unknown-venue`) and the +/// provider restart sweep reinstantiates it after backoff, after which a +/// submit succeeds again. +#[tokio::test] +async fn e2e_trapped_adapter_is_swept_and_restarts() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let (mut supervisor, chain) = boot_flaky_venue(wasm, ModuleLimits::default()).await; + assert_eq!(supervisor.adapter_count(), 1); + assert_eq!(supervisor.adapter_alive_count(), 1, "boots alive"); + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // The poison head detonates submit: the guest panic traps the store + // and the shared liveness drops. + let err = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect_err("the poison head traps the adapter"); + assert!(matches!(err, VenueError::Unavailable(_)), "{err:?}"); + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "the trap drops liveness" + ); + + // Temporarily dead resolves distinctly from never installed. + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); + assert!(matches!( + registry + .submit("mod-a", &VenueId::from("unlisted"), b"body".to_vec()) + .await, + Err(VenueError::UnknownVenue) + )); + + // The venue recovers; past the 1s backoff the dispatch-time sweep + // reinstalls the adapter on a fresh store. + chain.on_method(ChainMethod::EthBlockNumber, "\"0x1\""); + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "the sweep revived it"); + let outcome = registry + .submit("mod-a", &venue, b"body".to_vec()) + .await + .expect("the recovered adapter accepts"); + assert!(matches!(outcome, SubmitOutcome::Accepted(r) if r == b"body")); +} + +/// A crash-looping adapter is quarantined by the provider poison sweep: +/// at the threshold the restarts stop, and the venue stays dead past every +/// backoff until an operator intervenes. +#[tokio::test] +async fn e2e_crash_looping_adapter_is_poisoned() { + let Some(wasm) = module_wasm_or_skip("flaky-venue") else { + return; + }; + let limits = ModuleLimits { + poison: PoisonLimitsSection { + max_failures: Some(2), + window_secs: Some(600), + }, + ..ModuleLimits::default() + }; + // The chain head stays at the poison sentinel for the whole test: every + // submit after a restart traps again. + let (mut supervisor, _chain) = boot_flaky_venue(wasm, limits).await; + let registry = registry_of(&supervisor); + let venue = VenueId::from("flaky-venue"); + + // Trap 1, then a successful restart past the 1s backoff. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + tokio::time::sleep(Duration::from_millis(1_200)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 1, "first restart lands"); + + // Trap 2 crosses the 2-failure threshold: the sweep quarantines the + // adapter instead of scheduling another restart. + let _ = registry.submit("mod-a", &venue, b"body".to_vec()).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!(supervisor.adapter_alive_count(), 0, "quarantined"); + + // Past every backoff the poisoned adapter stays dead and unavailable. + tokio::time::sleep(Duration::from_millis(1_500)).await; + supervisor.dispatch_block(block(1)).await; + assert_eq!( + supervisor.adapter_alive_count(), + 0, + "no restart while poisoned" + ); + assert!(matches!( + registry.submit("mod-a", &venue, b"body".to_vec()).await, + Err(VenueError::Unavailable(_)) + )); +}