From ab638cc1902a5ea76a66672c2123aa01a725c9c8 Mon Sep 17 00:00:00 2001 From: Shanu Date: Wed, 26 Aug 2026 01:14:56 +0530 Subject: [PATCH 1/2] Serve the Composio connection bootstrap over the bus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ComposioProvider::on_connection_created` is the last thing in the memory seam that a host can only reach by naming the engine. OpenHuman's connection-created subscriber builds a `tinymemory_core` `ProviderContext` purely to hand it to that hook, which is why `memory/sync/composio/ providers/context_ext.rs` still imports the engine crate — and why the crate cannot leave the product dependency graph (openhuman#5560). `BootstrapConnection(toolkit, connection_id)` closes it. It sits in `MemorySourceSync` beside `RunConnectionSync`, and deliberately not inside it: a sync moves items and runs many times, a bootstrap establishes what a sync then assumes and runs once. They also fail differently, and a caller can only decline to stop syncing over a failed bootstrap if it can tell the two apart. Caps are left at their defaults rather than accepted as arguments. No provider overrides the hook today and the default impl reads neither `max_items` nor `sync_depth_days` — they cap how much a *sync* walks, and giving a bootstrap a walk budget would imply it walks. The registry init is the part worth reviewing. `get_provider` resolves through a process-global that the *host's* boot used to fill; a `cdylib` has its own statics, so inside the module that registry is empty and `get_provider` answers `None` rather than erroring — every bootstrap would have reported "no composio provider registered" over a working connection, with nothing in a build or a type check saying so. The module now calls `init_default_providers` beside the other seams it installs for exactly this reason. The existing sync pipeline resolves its provider a different way and never depended on it, so this is a new requirement rather than a fix. Two tests, because the failure is invisible to compilation: a unit test that the registry really registers what it claims, and an E2E against the dlopen'd artifact asserting `BootstrapConnection` fails because no client resolves and never because the registry is empty. Those two are one line apart in the engine and worlds apart in meaning. Note for reviewers: `cargo clippy --workspace --all-targets -- -D warnings` is already red on main with six `unwrap`/`unwrap_err` findings in `crates/tinymemory-documents/src/convert/test.rs`. Untouched here. --- crates/tinymemory-api/src/null_tests.rs | 4 ++ crates/tinymemory-api/src/provider/sync.rs | 44 +++++++++++++++++++ crates/tinymemory-bus/src/lib.rs | 2 +- crates/tinymemory-bus/src/names.rs | 5 ++- crates/tinymemory-module/src/lib.rs | 10 +++++ crates/tinymemory-module/src/service/mod.rs | 14 ++++++ crates/tinymemory-module/src/service/test.rs | 38 ++++++++++++++++ crates/tinymemory-module/tests/module_e2e.rs | 37 ++++++++++++++++ .../tinymemory-tinycortex/src/engine/mod.rs | 44 +++++++++++++++++++ 9 files changed, 196 insertions(+), 2 deletions(-) diff --git a/crates/tinymemory-api/src/null_tests.rs b/crates/tinymemory-api/src/null_tests.rs index d473a0a..afde688 100644 --- a/crates/tinymemory-api/src/null_tests.rs +++ b/crates/tinymemory-api/src/null_tests.rs @@ -514,6 +514,10 @@ fn every_optional_method_fails_with_its_advertised_family_name() { block_on(driver.run_connection_sync("gmail", "conn-1")), Capability::SourceSync, ); + assert_unsupported( + block_on(driver.bootstrap_connection("gmail", "conn-1")), + Capability::SourceSync, + ); assert_unsupported( block_on(driver.source_sync_state("gmail", "conn-1")), Capability::SourceSync, diff --git a/crates/tinymemory-api/src/provider/sync.rs b/crates/tinymemory-api/src/provider/sync.rs index 185b26f..af81f9d 100644 --- a/crates/tinymemory-api/src/provider/sync.rs +++ b/crates/tinymemory-api/src/provider/sync.rs @@ -155,6 +155,50 @@ pub trait MemorySourceSync: Send + Sync { Err(MemoryError::unsupported(Capability::SourceSync)) } + /// Run one connection's first-time bootstrap. + /// + /// What a host's "this connection was just authorised" event reaches. The + /// driver resolves the provider for `toolkit` and runs its bootstrap: at + /// minimum fetching and persisting the account profile, and for providers + /// that override it, registering triggers or seeding labels as well. + /// + /// # Why this is not part of [`Self::run_connection_sync`] + /// + /// A sync moves items and is expected to run many times; a bootstrap + /// establishes the things a sync then assumes and is expected to run once. + /// Folding them together would either re-register triggers on every sync + /// or leave a connection whose first sync silently has no profile behind + /// it — and the two also fail differently, which is the more practical + /// reason: a bootstrap that fails should not stop items from syncing, and + /// a caller can only make that choice if it can tell the two apart. + /// + /// # Not idempotent, and the caller owns that + /// + /// Calling it twice runs the provider's bootstrap twice. Providers whose + /// bootstrap is a trigger registration should make that registration + /// idempotent themselves; the contract does not promise it, because a + /// driver cannot know whether a second call means "retry the one that + /// failed" or "the connection was re-authorised". + /// + /// # Errors + /// + /// [`MemoryError::Invalid`] for a toolkit the driver has no provider for, + /// or a connection it cannot resolve — the same rule + /// [`Self::run_connection_sync`] follows, and for the same reason: a + /// silent success over a connection that can never bootstrap is worse than + /// an error. + /// + /// [`MemoryError::Unsupported`] from a driver that serves this family but + /// not this member. Otherwise the provider's own failure. + async fn bootstrap_connection( + &self, + toolkit: &str, + connection_id: &str, + ) -> Result<(), MemoryError> { + let _ = (toolkit, connection_id); + Err(MemoryError::unsupported(Capability::SourceSync)) + } + async fn source_sync_state( &self, toolkit: &str, diff --git a/crates/tinymemory-bus/src/lib.rs b/crates/tinymemory-bus/src/lib.rs index 983abcd..2bebc2b 100644 --- a/crates/tinymemory-bus/src/lib.rs +++ b/crates/tinymemory-bus/src/lib.rs @@ -2,7 +2,7 @@ //! the members that carry them. //! //! TinyMemory ships as a loadable `TinyBus` module: `crates/tinymemory-module` -//! exports one object with 121 members on it, built as a `cdylib`. A host that +//! exports one object with 122 members on it, built as a `cdylib`. A host that //! loads it — OpenHuman — can call into it but cannot `use` anything out of it, //! so the payload vocabulary has to be published as an ordinary library. This //! is that library. diff --git a/crates/tinymemory-bus/src/names.rs b/crates/tinymemory-bus/src/names.rs index cb57347..d95d5d4 100644 --- a/crates/tinymemory-bus/src/names.rs +++ b/crates/tinymemory-bus/src/names.rs @@ -294,6 +294,8 @@ pub mod methods { /// `RunSourceSync` — run one configured memory source's sync now, /// whatever kind it is. pub const RUN_SOURCE_SYNC: &str = "RunSourceSync"; + /// `BootstrapConnection` — run one connection's first-time bootstrap. + pub const BOOTSTRAP_CONNECTION: &str = "BootstrapConnection"; /// `SourceSyncState` — the persisted cursor and budget for one connection. pub const SOURCE_SYNC_STATE: &str = "SourceSyncState"; /// `SyncAuditLog` — past sync runs, newest first. @@ -319,7 +321,7 @@ pub mod methods { /// The order matters: `tinybus`'s `Interface::members()` returns declaration /// order, and the module compares the two sequences directly rather than as /// sets, so a reordering is caught alongside an addition or a removal. -pub const METHODS: [&str; 121] = [ +pub const METHODS: [&str; 122] = [ methods::DRIVER_ID, methods::CAPABILITIES, methods::HEALTH, @@ -433,6 +435,7 @@ pub const METHODS: [&str; 121] = [ methods::DIAGNOSE, methods::RUN_CONNECTION_SYNC, methods::RUN_SOURCE_SYNC, + methods::BOOTSTRAP_CONNECTION, methods::SOURCE_SYNC_STATE, methods::SYNC_AUDIT_LOG, methods::ESTIMATE_SYNC_COST_USD, diff --git a/crates/tinymemory-module/src/lib.rs b/crates/tinymemory-module/src/lib.rs index f430a7c..c874dc5 100644 --- a/crates/tinymemory-module/src/lib.rs +++ b/crates/tinymemory-module/src/lib.rs @@ -170,6 +170,15 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() // credential strip above, because this is the seam that hands the config // back out to the engine repeatedly. tinymemory_core::config_loader::set_config_loader(Arc::new(ModuleConfigLoader::new(&config))); + // The Composio provider registry is a process-global too, and it is the one + // the host used to fill on its own boot. This process has its own statics, + // so without this line `get_provider` answers `None` for every toolkit + // inside the module — and it answers `None` rather than failing to build, + // which is why nothing above catches it. `BootstrapConnection` is the + // member that reads it; the sync pipeline resolves its provider a different + // way and is unaffected either way. Idempotent by the registry's own + // contract, so a second call from a host that also inits is harmless. + tinymemory_core::sync::composio::providers::init_default_providers(); host::install(connection.clone()); // The two seams no bus interface serves, and no local answer can honestly // stand in for. Both degraded in silence rather than with a named cause; @@ -763,6 +772,7 @@ mod exports { // live here; these are the on-demand half plus what past runs cost. "RunConnectionSync", "RunSourceSync", + "BootstrapConnection", "SourceSyncState", "SyncAuditLog", "EstimateSyncCostUsd", diff --git a/crates/tinymemory-module/src/service/mod.rs b/crates/tinymemory-module/src/service/mod.rs index a92c33a..bb732a6 100644 --- a/crates/tinymemory-module/src/service/mod.rs +++ b/crates/tinymemory-module/src/service/mod.rs @@ -65,6 +65,7 @@ //! Diagnose() -> Diagnosis //! //! RunConnectionSync(toolkit, connection_id) -> SyncRunOutcome +//! BootstrapConnection(toolkit, connection_id) -> () //! RunSourceSync(source_id) -> SyncRunOutcome //! SourceSyncState(toolkit, connection_id) -> Option //! SyncAuditLog(limit) -> [SyncAuditEntry] @@ -1750,6 +1751,19 @@ impl MemoryService { .map_err(|error| into_bus_error(&error)) } + /// Run one connection's first-time bootstrap. + /// + /// Beside `RunConnectionSync` rather than inside it: a sync moves items and + /// runs many times, a bootstrap establishes what a sync then assumes and + /// runs once. They also fail differently, and a caller can only decline to + /// stop syncing over a failed bootstrap if it can tell the two apart. + async fn bootstrap_connection(&self, toolkit: String, connection_id: String) -> BusResult<()> { + require_family!(self, as_source_sync, Capability::SourceSync) + .bootstrap_connection(&toolkit, &connection_id) + .await + .map_err(|error| into_bus_error(&error)) + } + /// The persisted cursor, dedup and budget state for one connection. /// /// `None` is "never synced", which is a state and not an error — a status diff --git a/crates/tinymemory-module/src/service/test.rs b/crates/tinymemory-module/src/service/test.rs index 5a7b6b5..577f9df 100644 --- a/crates/tinymemory-module/src/service/test.rs +++ b/crates/tinymemory-module/src/service/test.rs @@ -743,6 +743,12 @@ async fn the_two_new_families_are_gated_on_their_own_capability() { .expect_err("a driver without the source-sync family must refuse"); assert_eq!(refusal(error), wire::UNSUPPORTED); + let error = service + .bootstrap_connection("gmail".to_string(), "conn-1".to_string()) + .await + .expect_err("a driver without the source-sync family must refuse"); + assert_eq!(refusal(error), wire::UNSUPPORTED); + let error = service .coding_session_status() .await @@ -763,3 +769,35 @@ async fn the_two_new_families_are_gated_on_their_own_capability() { .expect_err("a driver without the maintenance family must refuse"); assert_eq!(refusal(error), wire::UNSUPPORTED); } + +/// The Composio provider registry is filled by this process, not by the host. +/// +/// It is a process-global, and before the memory engine moved into a module the +/// host's own boot was what called `init_default_providers`. A `cdylib` has its +/// own statics, so that call does nothing for this process — and the failure is +/// silent in the worst way: `get_provider` answers `None` rather than erroring, +/// so `BootstrapConnection` would report "no composio provider registered for +/// 'gmail'" on a perfectly good connection, and nothing in a build or a type +/// check would have said so. +/// +/// This pins the call the module's startup makes. It is deliberately asserting +/// a toolkit the registry's own `init_default_providers` registers rather than +/// an arbitrary string, so that a rename upstream fails here instead of in the +/// field. +#[test] +fn the_default_composio_providers_populate_the_registry() { + use tinymemory_core::sync::composio::providers::{get_provider, init_default_providers}; + + init_default_providers(); + + assert!( + get_provider("gmail").is_some(), + "init_default_providers must register the gmail provider; BootstrapConnection \ + resolves through this registry and answers Invalid when it is empty" + ); + assert!( + get_provider("__definitely_not_a_real_toolkit__").is_none(), + "an unregistered toolkit must stay unregistered — otherwise the assertion above \ + would pass against a registry that returns something for everything" + ); +} diff --git a/crates/tinymemory-module/tests/module_e2e.rs b/crates/tinymemory-module/tests/module_e2e.rs index f9f8456..2fbab1e 100644 --- a/crates/tinymemory-module/tests/module_e2e.rs +++ b/crates/tinymemory-module/tests/module_e2e.rs @@ -695,6 +695,7 @@ const EXPECTED_METHODS: &[&str] = &[ "Diagnose", "RunConnectionSync", "RunSourceSync", + "BootstrapConnection", "SourceSyncState", "SyncAuditLog", "EstimateSyncCostUsd", @@ -1671,3 +1672,39 @@ async fn portability_and_lifecycle_round_trip(bus: &tinybus::Proxy) { .expect("Shutdown timed out") .expect("Shutdown"); } + +#[tokio::test] +#[ignore = "drives a real dlopen'ed module; must be the only such test in the process — see the module docs"] +async fn bootstrap_connection_finds_its_provider_registry_inside_the_module() { + // The Composio provider registry is a process-global that the *host's* boot + // used to fill. This module is a `cdylib` with its own statics, so unless + // its startup calls `init_default_providers` the registry here is empty — + // and `get_provider` answers `None` rather than erroring, so every + // `BootstrapConnection` would report "no composio provider registered" over + // a perfectly good connection. Nothing in a build, a type check or a unit + // test in the module's own workspace sees that, because they all run in a + // process the host has already initialised. + // + // So this asserts against the *loaded artifact*, and it asserts the + // distinction rather than success: with no Composio configured in a temp + // workspace the call is expected to fail, but it must fail because no + // client resolves, never because the registry is empty. Those two are one + // line apart in the engine and worlds apart in what they mean. + let workspace = tempfile::tempdir().expect("tempdir"); + let (client, _host, _task) = admit_module(workspace.path()).await; + + let result: Result<(), _> = proxy(&client) + .call( + "BootstrapConnection", + ("gmail".to_string(), "conn-1".to_string()), + ) + .await; + + let error = result.expect_err("no Composio is configured in a temp workspace"); + let rendered = format!("{error:?}"); + assert!( + !rendered.contains("no composio provider registered"), + "the module's provider registry is empty — its startup did not call \ + init_default_providers. Error was: {rendered}" + ); +} diff --git a/crates/tinymemory-tinycortex/src/engine/mod.rs b/crates/tinymemory-tinycortex/src/engine/mod.rs index 895fb26..46e2e1b 100644 --- a/crates/tinymemory-tinycortex/src/engine/mod.rs +++ b/crates/tinymemory-tinycortex/src/engine/mod.rs @@ -2502,6 +2502,50 @@ impl MemorySourceSync for TinycortexProvider { }) } + async fn bootstrap_connection( + &self, + toolkit: &str, + connection_id: &str, + ) -> Result<(), MemoryError> { + use tinymemory_core::sync::composio::providers::{get_provider, ProviderContext}; + + // Same gate as `run_connection_sync`, and deliberately before the + // provider lookup: a toolkit with no pipeline cannot bootstrap into + // anything a later sync would read, so reporting it here names the + // real problem rather than "no provider". + ensure_syncable_toolkit(toolkit)?; + + let provider = get_provider(toolkit).ok_or_else(|| { + MemoryError::Invalid(format!("no composio provider registered for '{toolkit}'")) + })?; + + // `from_config` answers `None` when no Composio client resolves in + // either mode — the not-signed-in case. That is `Invalid` rather than a + // silent `Ok`: a caller that just authorised a connection and gets a + // success back would believe the profile was fetched. + let ctx = ProviderContext::from_config( + self.config.to_arc(), + toolkit, + Some(connection_id.to_string()), + ) + .ok_or_else(|| { + MemoryError::Invalid(format!( + "no viable composio client for '{toolkit}'; connection {connection_id} \ + cannot bootstrap" + )) + })?; + + // `max_items` / `sync_depth_days` are left at their defaults on + // purpose. They cap how much a *sync* walks; a bootstrap fetches one + // profile and registers what the provider needs, and giving it a walk + // budget would imply it walks. + provider.on_connection_created(&ctx).await.map_err(|error| { + MemoryError::Other(anyhow::anyhow!( + "bootstrap {toolkit} connection {connection_id}: {error}" + )) + }) + } + async fn source_sync_state( &self, toolkit: &str, From be821bddfa64bc7e088e670ce863858d1841aae9 Mon Sep 17 00:00:00 2001 From: Shanu Date: Wed, 26 Aug 2026 01:45:19 +0530 Subject: [PATCH 2/2] Assert the registry mechanism, not the symptom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loader E2E required `BootstrapConnection` to fail, on the assumption that a temp workspace has no Composio configured. It does not fail: the module's proxied `ComposioHost` answers through the test harness and the default bootstrap is content with that, so the call returns `Ok` and `expect_err` panicked in CI. Asserting the symptom instead of the mechanism made the test wrong about the one thing it exists to pin. It now accepts either outcome and rejects exactly one: an error naming an empty provider registry. `Ok` means the provider resolved and its bootstrap ran; any other error means it resolved and the run failed on its own terms. Mutation-tested rather than assumed, against a built cdylib with TINYMEMORY_TEST_MODULE set — without that variable the harness aborts before reaching the assertion, which is how the first local check passed judgement on nothing: init present -> pass init removed -> fail, "no composio provider registered for 'gmail'" --- crates/tinymemory-module/tests/module_e2e.rs | 29 ++++++++++++-------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/tinymemory-module/tests/module_e2e.rs b/crates/tinymemory-module/tests/module_e2e.rs index 2fbab1e..7494d6c 100644 --- a/crates/tinymemory-module/tests/module_e2e.rs +++ b/crates/tinymemory-module/tests/module_e2e.rs @@ -1686,10 +1686,16 @@ async fn bootstrap_connection_finds_its_provider_registry_inside_the_module() { // process the host has already initialised. // // So this asserts against the *loaded artifact*, and it asserts the - // distinction rather than success: with no Composio configured in a temp - // workspace the call is expected to fail, but it must fail because no - // client resolves, never because the registry is empty. Those two are one - // line apart in the engine and worlds apart in what they mean. + // distinction rather than the outcome. `Ok` means the provider resolved + // and its bootstrap ran; any other error means it resolved and the run + // failed on its own terms. Exactly one result says the registry was never + // populated, and that is the regression. + // + // An earlier revision required failure here, on the assumption that a temp + // workspace has no Composio — and the call succeeded, because the module's + // proxied `ComposioHost` answers through the test harness and the default + // bootstrap is content with that. Asserting the symptom instead of the + // mechanism made the test wrong about the one thing it exists to pin. let workspace = tempfile::tempdir().expect("tempdir"); let (client, _host, _task) = admit_module(workspace.path()).await; @@ -1700,11 +1706,12 @@ async fn bootstrap_connection_finds_its_provider_registry_inside_the_module() { ) .await; - let error = result.expect_err("no Composio is configured in a temp workspace"); - let rendered = format!("{error:?}"); - assert!( - !rendered.contains("no composio provider registered"), - "the module's provider registry is empty — its startup did not call \ - init_default_providers. Error was: {rendered}" - ); + if let Err(error) = result { + let rendered = format!("{error:?}"); + assert!( + !rendered.contains("no composio provider registered"), + "the module's provider registry is empty — its startup did not call \ + init_default_providers. Error was: {rendered}" + ); + } }