From 2ceabe50409390f1e812f7dacf78e583da8948a4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 20:08:10 +0300 Subject: [PATCH 1/4] fix(ci): refresh tinymemory module lockfile Co-authored-by: Medulla --- crates/tinymemory-module/Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinymemory-module/Cargo.lock b/crates/tinymemory-module/Cargo.lock index f41cacfe..b0d2df52 100644 --- a/crates/tinymemory-module/Cargo.lock +++ b/crates/tinymemory-module/Cargo.lock @@ -1761,7 +1761,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "flate2", @@ -1780,7 +1780,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.1.0" +version = "0.1.1" dependencies = [ "proc-macro2", "quote", @@ -1789,7 +1789,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "serde", @@ -2446,7 +2446,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] From 71f4197191f3933ab86d673fc0a3ad34350804d7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 20:18:31 +0300 Subject: [PATCH 2/4] fix(module): remove stale composio bridge Co-authored-by: Medulla --- crates/tinymemory-api/src/lib.rs | 4 - crates/tinymemory-module/src/composio.rs | 400 ------------------ crates/tinymemory-module/src/composio_test.rs | 301 ------------- crates/tinymemory-module/src/lib.rs | 144 +------ crates/tinymemory-module/src/service/test.rs | 82 ---- 5 files changed, 15 insertions(+), 916 deletions(-) delete mode 100644 crates/tinymemory-module/src/composio.rs delete mode 100644 crates/tinymemory-module/src/composio_test.rs diff --git a/crates/tinymemory-api/src/lib.rs b/crates/tinymemory-api/src/lib.rs index 9eb1e1da..efb7b39a 100644 --- a/crates/tinymemory-api/src/lib.rs +++ b/crates/tinymemory-api/src/lib.rs @@ -64,10 +64,6 @@ //! and how strongly, with the buffer that queues it left in the engine crate. //! - [`composio`]: the connector-sync vocabulary — [`composio::SyncOutcome`], //! [`composio::NormalizedTask`], [`composio::SyncState`], -//! [`composio::ToolScope`] and friends. **Not** [`host::composio`], which is -//! the *client* seam: connections, execute responses and the capability -//! matrix a host serves to the memory layer. This one is what a provider run -//! produces and remembers; that one is how it reaches Composio at all. //! - [`recall`]: the borrowed [`recall::RecallOpts`] and owned, serde-derived //! [`recall::OwnedRecallOpts`] recall filters (both re-exported from //! [`types`]). diff --git a/crates/tinymemory-module/src/composio.rs b/crates/tinymemory-module/src/composio.rs deleted file mode 100644 index b735dff0..00000000 --- a/crates/tinymemory-module/src/composio.rs +++ /dev/null @@ -1,400 +0,0 @@ -//! Composio stays host-side; only the request crosses. -//! -//! # What this seam is for -//! -//! The engine's memory-sync layer needs four things from Composio: which -//! connections the signed-in user has, the ability to run one tool against one -//! of them, the direct-mode API key, and a cheap "is any of this wired up?" -//! probe. It needs none of the rest of the integration — OAuth, the backend -//! session, the toolkit allowlist, HMAC-verified trigger fan-out, or the choice -//! between backend-proxied and direct mode. `tinymemory_core::composio_host` -//! draws that line, and this module is the module-mode implementation of the -//! engine's half. -//! -//! # Why a proxy and not an answer from `ModuleConfig` -//! -//! Because none of the four is a *value*; all four are live host state. The -//! connection list changes when the user completes an OAuth flow in a browser, -//! the direct key changes on a `set_api_key` RPC, and neither restarts -//! anything. A load-time snapshot would report the state as it was when the -//! module loaded and keep reporting it for the life of the process — which for -//! `is_available` means telling the sync layer "not signed in" about a user who -//! signed in a minute ago, and the sync layer treats that as *skip silently*. -//! That is the looks-empty-rather-than-broken failure this whole seam exists to -//! prevent, so the answer has to come from the host at call time. -//! -//! It is the same reasoning [`crate::embedding`] gives for keeping the embed -//! host-side, and the opposite of the one [`crate::config_loader`] gives for -//! answering the config locally — the deciding question in both directions is -//! whether the host holds something the module cannot be handed once. -//! -//! # The credential does cross here, unlike everywhere else -//! -//! [`crate::embedding`] refuses to carry an inference key and this crate's -//! module docs say the module carries no credentials. `ApiKey` is the exception -//! and it is worth naming rather than hiding: the engine's -//! `sync::pipelines::host::composio_config` builds its **own** HTTP client from -//! the direct-mode key, so unlike an embed there is no host-side call to route -//! the work through. A `None` here is therefore not a degraded answer the -//! caller works around — it is "direct-mode sync cannot run at all". -//! -//! The property that survives is the one that was actually load-bearing: -//! [`crate::config::ModuleConfig`] still has nowhere to *hold* a credential, so -//! the key exists in this address space only for the duration of one call and -//! only when the sync layer asked for it. Narrowing this further means moving -//! the direct-mode sync client behind an `Execute`-shaped method, which is a -//! change to the engine's contract rather than something to smuggle in through -//! one of its two halves. - -use std::sync::atomic::{AtomicBool, Ordering}; - -use async_trait::async_trait; -use tinybus::Connection; -use tinymemory_core::composio_host::{ComposioConnection, ComposioExecuteResponse, ComposioHost}; - -use crate::host::report_unserved_once; - -/// Well-known name the host serves its Composio integration under. -pub const COMPOSIO_HOST_BUS_NAME: &str = "ai.tinyhumans.tinymemory.ComposioHost"; - -/// Object path the host serves it at. -pub const COMPOSIO_HOST_OBJECT_PATH: &str = "/ai/tinyhumans/tinymemory/ComposioHost"; - -/// Interface the host serves at [`COMPOSIO_HOST_OBJECT_PATH`]. -/// -/// Equal to [`COMPOSIO_HOST_BUS_NAME`] by convention, but a separate constant -/// for the same reason [`crate::embedding::EMBEDDING_HOST_INTERFACE`] is: one -/// addresses a peer, the other selects a dispatch table on that peer's object. -pub const COMPOSIO_HOST_INTERFACE: &str = "ai.tinyhumans.tinymemory.ComposioHost"; - -/// Every connection the signed-in user has, active or not. -pub const LIST_CONNECTIONS_METHOD: &str = "ListConnections"; - -/// Run one Composio tool against one connection. -/// -/// Takes `(tool, arguments, entity_id, connection_id)`. The last two travel -/// even though backend mode ignores both: which mode is in force is resolved -/// host-side at call time, and omitting them would silently drop the connection -/// pin the moment a user switched to direct mode. -pub const EXECUTE_METHOD: &str = "Execute"; - -/// The direct-mode Composio API key, or `None` when direct mode is unset. -pub const API_KEY_METHOD: &str = "ApiKey"; - -/// Whether *some* viable Composio client resolves host-side right now. -pub const IS_AVAILABLE_METHOD: &str = "IsAvailable"; - -/// The OpenHuman backend bearer for proxied mode, or `None` when signed out. -/// -/// Asked per call rather than carried in `ModuleConfig` because it is a session -/// JWT the host refreshes: a snapshot works until it expires and then reads as -/// a signed-out user on every subsequent sync. -pub const SESSION_BEARER_METHOD: &str = "SessionBearer"; - -/// Latched so the gap is reported once per process rather than once per sync -/// tick — the periodic scheduler consults this seam on every tick, and an -/// unlatched report would page on every one of them. Same guard the scheduler -/// gate and the shutdown host in `crate::host` put on theirs. -static COMPOSIO_REPORTED: AtomicBool = AtomicBool::new(false); - -/// What an unserved Composio host costs, in the terms a reader of the log -/// needs. -const COMPOSIO_UNSERVED: &str = "composio host unserved in module mode: this host serves no \ - `ai.tinyhumans.tinymemory.ComposioHost` interface, so memory \ - sync cannot list connections, run a Composio tool, or resolve a \ - direct-mode key — every synced source stops updating"; - -/// What a probe made from outside a Tokio runtime costs. -/// -/// `api_key` and `is_available` are synchronous on the engine's trait and a bus -/// call is not, so they need a runtime handle to bridge onto. Every caller in -/// the engine reaches them from inside one; a caller that did not would get a -/// silent `None`/`false` without this. -const COMPOSIO_NO_RUNTIME: &str = "composio host probe made outside a Tokio runtime: the \ - synchronous `api_key`/`is_available` probes bridge onto the \ - module runtime to reach the host, and without one they cannot \ - ask — direct-mode sync will report its key as unconfigured"; - -/// The Composio integration, reached over the module's connection. -pub struct BusComposioHost { - connection: Connection, - /// Cleared the first time a call proves the host serves no Composio - /// interface at all. - /// - /// Not a cache of the *user's* Composio state — that is deliberately never - /// cached, see the module docs. This records one structural fact about the - /// host, which cannot change while the process runs: tinybus never unloads - /// a library and a host that did not serve the interface at load will not - /// grow one. Recording it turns every later probe into a local answer - /// instead of a round trip that is already known to fail. - host_serves: AtomicBool, -} - -// `Connection` is not `Debug`, and `ComposioHost` requires it. Rendering the -// connection would say nothing useful anyway, and this type's only other field -// is a latch. -impl std::fmt::Debug for BusComposioHost { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("BusComposioHost") - .field("host_serves", &self.host_serves.load(Ordering::SeqCst)) - .finish_non_exhaustive() - } -} - -impl BusComposioHost { - /// Build the host bridge over the module connection. - /// - /// Takes no configuration on purpose: everything this seam answers is live - /// host state, so there is nothing about it worth capturing at load time. - #[must_use] - pub fn new(connection: Connection) -> Self { - Self { - connection, - host_serves: AtomicBool::new(true), - } - } - - /// Call one member of the host's Composio interface. - /// - /// # Errors - /// - /// The named-unserved message when the host exports no such interface, - /// otherwise the bus failure with the member that produced it. Never - /// carries `arguments`: a Composio tool call's arguments are mail queries, - /// document bodies and connection pins, and an error string is not a place - /// for any of them. - async fn call( - &self, - member: &'static str, - arguments: impl serde::Serialize + Send, - ) -> Result - where - R: serde::de::DeserializeOwned, - { - let proxy = self - .connection - .proxy( - COMPOSIO_HOST_BUS_NAME, - COMPOSIO_HOST_OBJECT_PATH, - COMPOSIO_HOST_INTERFACE, - ) - .map_err(|error| self.classify(member, &error))?; - proxy - .call(member, arguments) - .await - .map_err(|error| self.classify(member, &error)) - } - - /// Turn a bus failure into a message, and name a structural one out loud. - /// - /// Two failures wear the same clothes on this seam and must not be - /// conflated: "the user has no Composio connections" is an ordinary answer, - /// while "this host exports no Composio interface" is a build mismatch that - /// stops every synced source updating. The second is what - /// `report_unserved_once` exists for. - fn classify(&self, member: &'static str, error: &tinybus::Error) -> String { - if is_unserved(error) { - self.host_serves.store(false, Ordering::SeqCst); - report_unserved_once(&COMPOSIO_REPORTED, COMPOSIO_UNSERVED, "composio_host"); - return format!("{COMPOSIO_UNSERVED} (calling {member}: {error})"); - } - format!("composio host call {member} failed: {error}") - } - - /// Ask the host one argument-free question from a synchronous caller. - /// - /// `Some` is the host's answer; `None` means it could not be asked, which - /// each probe below turns into its own fallback. - /// - /// # Why an OS thread and not `block_in_place` - /// - /// `ComposioHost::api_key` and `ComposioHost::is_available` are synchronous - /// on the engine's trait — they are consulted from inside - /// `composio_config` and `ProviderContext::from_config`, neither of which - /// can `await` — and the host serves both as ordinary async bus members. So - /// something has to bridge, and the two candidates behave differently under - /// the runtime flavours this code can find itself on. - /// `tokio::task::block_in_place` panics outright on a current-thread - /// runtime, and this module cannot prove its caller's flavour: the shipped - /// module declares eight worker threads, but nothing stops an in-process - /// harness from driving this code on a current-thread runtime, and a probe - /// that aborted the process would be a far worse failure than the one it - /// was asked about. A fresh thread with a `Handle` works identically under - /// both, and costs one spawn on a path that is about to make a network call - /// anyway. - /// - /// It does occupy the calling thread until the host answers. That is - /// bounded by the bus's own call deadline and happens at most twice per - /// sync tick, against a runtime sized at eight workers — but it is the - /// reason these two are probes and not a general-purpose synchronous call - /// helper, and why nothing else in this file uses this path. - fn probe(&self, member: &'static str) -> Option - where - R: serde::de::DeserializeOwned + Send + 'static, - { - // Already proven unserved: answer locally rather than spawn a thread to - // rediscover it. The report has fired; a second one would be noise. - if !self.host_serves.load(Ordering::SeqCst) { - return None; - } - let Ok(handle) = tokio::runtime::Handle::try_current() else { - report_unserved_once(&COMPOSIO_REPORTED, COMPOSIO_NO_RUNTIME, "composio_host"); - return None; - }; - let connection = self.connection.clone(); - let joined = std::thread::scope(|scope| { - scope - .spawn(move || { - handle.block_on(async move { - let proxy = connection.proxy( - COMPOSIO_HOST_BUS_NAME, - COMPOSIO_HOST_OBJECT_PATH, - COMPOSIO_HOST_INTERFACE, - )?; - proxy.call::(member, ()).await - }) - }) - .join() - }); - match joined { - Ok(Ok(answer)) => Some(answer), - Ok(Err(error)) => { - // Classified *before* the log call, not inside it. `classify` - // latches the seam and fires the once-per-process report, and - // `log::debug!` does not evaluate its arguments when debug - // logging is off — which is every shipped build. Folding the - // two together would make the report depend on the log level. - let named = self.classify(member, &error); - log::debug!("[tinymemory:module] {named}"); - None - } - // A panic inside the bridge thread. Nothing here can panic today, - // but a probe that returned a plausible answer after one would be - // worse than one that says it could not tell. - Err(_) => { - log::error!( - "[tinymemory:module] composio host probe {member} panicked; \ - answering as unreachable" - ); - None - } - } - } -} - -/// Whether `error` means "this host exports no such interface". -/// -/// Matched on [`tinybus::Error::wire_name`] rather than on the enum, because a -/// remote failure is reconstructed as `MethodFailed { name, message }` on this -/// side — the structured variants exist on the *raising* side only, and -/// matching them here would silently never fire. -/// -/// The four names are the whole "nobody is listening" family: no peer owns the -/// name, the peer exports no such object, the object has no such interface, and -/// the interface has no such member. The last one is what an older host with a -/// newer module actually produces. -fn is_unserved(error: &tinybus::Error) -> bool { - matches!( - error.wire_name(), - "ai.tinyhumans.tinybus.Error.NameHasNoOwner" - | "ai.tinyhumans.tinybus.Error.UnknownObject" - | "ai.tinyhumans.tinybus.Error.UnknownInterface" - | "ai.tinyhumans.tinybus.Error.UnknownMethod" - ) -} - -#[async_trait] -impl ComposioHost for BusComposioHost { - /// The user's connections, as the host sees them right now. - /// - /// The `config` argument is dropped rather than forwarded. In module mode - /// it is the *engine's* config, built from the `ModuleConfig` this module - /// was loaded with, and it is not the host's — sending it would ask the - /// host to resolve a Composio client against a config it did not write. - /// `ChatHost` makes the same call for the same reason: `Complete` carries a - /// role and a request and nothing else. - async fn list_connections( - &self, - _config: &tinymemory_core::Config, - ) -> Result, String> { - self.call(LIST_CONNECTIONS_METHOD, ()).await - } - - /// Run `tool`, host-side, against `connection_id`. - /// - /// A provider that answers `successful: false` is **not** an error — that - /// rides back in the [`ComposioExecuteResponse`], because the sync layer - /// bills a completed round trip either way. - async fn execute( - &self, - _config: &tinymemory_core::Config, - tool: &str, - arguments: Option, - entity_id: &str, - connection_id: Option<&str>, - ) -> Result { - log::debug!("[tinymemory:module] composio execute tool={tool}"); - self.call( - EXECUTE_METHOD, - ( - tool.to_string(), - arguments, - entity_id.to_string(), - connection_id.map(str::to_string), - ), - ) - .await - } - - /// The direct-mode key, or `None` when direct mode is unset *or* the host - /// could not be asked. - /// - /// The two are not distinguishable through this signature, and that is - /// tolerable here only because the caller turns both into the same named - /// failure: `composio_config` reports "Composio direct API key is not - /// configured" and refuses to build a client. An unreachable host is - /// additionally reported once through the error reporter by - /// `probe`, so the log distinguishes what the return value cannot. - fn api_key(&self, _config: &tinymemory_core::Config) -> Option { - self.probe::>(API_KEY_METHOD).flatten() - } - - /// The proxied-mode bearer, fetched per call for the reason on - /// [`SESSION_BEARER_METHOD`]. - /// - /// An unreachable host flattens to `None`, which the caller turns into a - /// named refusal rather than silence — the opposite of `is_available`'s - /// optimistic answer below, and deliberately so: a bearer this process - /// cannot obtain is not a credential it may guess at. - fn session_bearer(&self, _config: &tinymemory_core::Config) -> Option { - self.probe::>(SESSION_BEARER_METHOD) - .flatten() - } - - /// Whether the sync layer should treat the user as signed in. - /// - /// # An unreachable host answers *yes*, deliberately - /// - /// This probe has no error channel, so an unreachable host has to be - /// reported as one of the two real answers, and the two are not - /// symmetrical. A wrong `false` makes `ProviderContext::from_config` return - /// `None`, which the sync layer logs at debug and treats as "the user is - /// not signed in" — the run reports nothing to do and looks healthy while - /// no memory is being synced at all. A wrong `true` costs one more call, - /// which reaches `execute` and fails with a named cause that says - /// the Composio host is unserved. - /// - /// One of those is discoverable from a log and the other is not, so this - /// answers `true` whenever it could not ask — including when the host is - /// already known to serve no Composio interface, where the goal is - /// precisely to let the next call fail loudly. Only a host that actually - /// answered `false` reads as "not signed in". - fn is_available(&self, _config: &tinymemory_core::Config) -> bool { - self.probe::(IS_AVAILABLE_METHOD).unwrap_or(true) - } -} - -#[cfg(test)] -#[path = "composio_test.rs"] -mod test; diff --git a/crates/tinymemory-module/src/composio_test.rs b/crates/tinymemory-module/src/composio_test.rs deleted file mode 100644 index 406e3b1a..00000000 --- a/crates/tinymemory-module/src/composio_test.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! Tests for the host-owned Composio bridge over an in-memory TinyBus. -//! -//! Every test here runs multi-threaded with an explicit worker count, and both -//! halves of that matter. `api_key` and `is_available` block their calling -//! thread while the host answers, so the broker and the fake host's dispatch -//! loop need a worker that is not the one waiting — and `worker_threads` -//! defaults to the core count, which on a one-core CI box would leave exactly -//! one. Pinning it makes the test independent of the machine rather than -//! hanging on the small ones. - -use tinybus::broker::Broker; -use tinybus::transport::memory::MemoryBus; -use tinybus::{Connection, Result as BusResult}; -use tinymemory_core::composio_host::{ComposioConnection, ComposioExecuteResponse, ComposioHost}; - -use super::{ - BusComposioHost, COMPOSIO_HOST_BUS_NAME, COMPOSIO_HOST_OBJECT_PATH, COMPOSIO_UNSERVED, -}; -use crate::config::ModuleConfig; - -/// What the fake host was asked to execute. -#[derive(Debug)] -struct Executed { - tool: String, - arguments: Option, - entity_id: String, - connection_id: Option, -} - -struct FakeComposioHost { - executed: tokio::sync::mpsc::UnboundedSender, - api_key: Option, - session_bearer: Option, - available: bool, -} - -#[tinybus::interface(name = "ai.tinyhumans.tinymemory.ComposioHost")] -impl FakeComposioHost { - async fn list_connections(&self) -> BusResult> { - std::future::ready(()).await; - Ok(vec![ComposioConnection { - id: "connection-1".to_string(), - toolkit: "Gmail".to_string(), - status: "ACTIVE".to_string(), - created_at: None, - account_email: Some("user@example.com".to_string()), - workspace: None, - username: None, - }]) - } - - async fn execute( - &self, - tool: String, - arguments: Option, - entity_id: String, - connection_id: Option, - ) -> BusResult { - std::future::ready(()).await; - let _ = self.executed.send(Executed { - tool, - arguments, - entity_id, - connection_id, - }); - Ok(ComposioExecuteResponse { - data: serde_json::json!({ "messages": 2 }), - successful: true, - error: None, - cost_usd: 0.25, - markdown_formatted: Some("two messages".to_string()), - }) - } - - async fn api_key(&self) -> BusResult> { - std::future::ready(()).await; - Ok(self.api_key.clone()) - } - - async fn session_bearer(&self) -> BusResult> { - std::future::ready(()).await; - Ok(self.session_bearer.clone()) - } - - async fn is_available(&self) -> BusResult { - std::future::ready(()).await; - Ok(self.available) - } -} - -async fn bus_with_composio_host( - api_key: Option<&str>, - available: bool, -) -> (Connection, tokio::sync::mpsc::UnboundedReceiver) { - let bus = MemoryBus::new(); - let broker = Broker::new(); - let _broker_task = broker.spawn(bus.clone()); - let (executed, receiver) = tokio::sync::mpsc::unbounded_channel(); - let host = Connection::connect(bus.connect().await.expect("host transport")) - .await - .expect("host connection"); - host.serve_at( - COMPOSIO_HOST_OBJECT_PATH.try_into().expect("object path"), - FakeComposioHost { - executed, - api_key: api_key.map(str::to_string), - session_bearer: Some("bearer-from-the-host".to_string()), - available, - }, - ) - .await - .expect("serve composio host"); - host.request_name(COMPOSIO_HOST_BUS_NAME) - .await - .expect("claim composio host name"); - std::mem::forget(host); - let module = Connection::connect(bus.connect().await.expect("module transport")) - .await - .expect("module connection"); - (module, receiver) -} - -/// A bare connection with nobody serving the Composio name. -async fn bus_without_composio_host() -> Connection { - let bus = MemoryBus::new(); - let broker = Broker::new(); - let _broker_task = broker.spawn(bus.clone()); - Connection::connect(bus.connect().await.expect("transport")) - .await - .expect("connection") -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn connections_execute_and_probes_all_cross_the_composio_bridge() { - let (connection, mut executed) = bus_with_composio_host(Some("direct-key"), true).await; - let bridge = BusComposioHost::new(connection); - let config = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(&ModuleConfig::default()); - - let connections = bridge - .list_connections(&config) - .await - .expect("host lists connections"); - assert_eq!(connections.len(), 1); - assert_eq!(connections[0].id, "connection-1"); - // The engine normalises the slug itself; the bridge must not do it for the - // host, or a toolkit would arrive pre-mangled on one path only. - assert_eq!(connections[0].toolkit, "Gmail"); - assert!(connections[0].is_active()); - - let response = bridge - .execute( - &config, - "GMAIL_FETCH_EMAILS", - Some(serde_json::json!({ "max_results": 5 })), - "entity-7", - Some("connection-1"), - ) - .await - .expect("host executes the tool"); - assert!(response.successful); - assert!((response.cost_usd - 0.25).abs() < f64::EPSILON); - assert_eq!(response.markdown_formatted.as_deref(), Some("two messages")); - assert_eq!(response.data["messages"], 2); - - // Every argument the engine passed reaches the host unchanged. `entity_id` - // and `connection_id` matter most: backend mode ignores both, so a bridge - // that dropped them would look correct until a user switched to direct - // mode and their connection pin silently disappeared. - let recorded = executed.try_recv().expect("execute reached the host"); - assert_eq!(recorded.tool, "GMAIL_FETCH_EMAILS"); - assert_eq!(recorded.entity_id, "entity-7"); - assert_eq!(recorded.connection_id.as_deref(), Some("connection-1")); - assert_eq!( - recorded.arguments, - Some(serde_json::json!({ "max_results": 5 })) - ); - - assert_eq!(bridge.api_key(&config).as_deref(), Some("direct-key")); - assert!(bridge.is_available(&config)); - - let rendered = format!("{bridge:?}"); - assert!(rendered.contains("BusComposioHost"), "{rendered}"); - assert!(!rendered.contains("Connection"), "{rendered}"); -} - -/// A served host that answers "no" is the only thing that reads as "no". -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn a_host_that_answers_no_is_believed() { - let (connection, _executed) = bus_with_composio_host(None, false).await; - let bridge = BusComposioHost::new(connection); - let config = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(&ModuleConfig::default()); - - assert!(bridge.api_key(&config).is_none()); - assert!(!bridge.is_available(&config)); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn an_unserved_host_is_named_and_the_probes_bias_towards_a_loud_failure() { - let bridge = BusComposioHost::new(bus_without_composio_host().await); - let config = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(&ModuleConfig::default()); - - let error = bridge - .list_connections(&config) - .await - .expect_err("no composio host is served"); - assert!(error.contains(COMPOSIO_UNSERVED), "{error}"); - assert!(error.contains(COMPOSIO_HOST_BUS_NAME), "{error}"); - assert!(error.contains(super::LIST_CONNECTIONS_METHOD), "{error}"); - - // The structural fact is latched, so later probes answer locally instead of - // re-dialling a name that is known to have no owner. - assert!(format!("{bridge:?}").contains("host_serves: false")); - - // `None` here becomes "Composio direct API key is not configured" one frame - // up, which is a named refusal rather than a silent skip. - assert!(bridge.api_key(&config).is_none()); - // And this stays `true` on purpose: a `false` would make the sync layer - // report "not signed in" and skip quietly, where `true` lets the next call - // fail with the unserved message asserted above. - assert!(bridge.is_available(&config)); - - let execute_error = bridge - .execute(&config, "GMAIL_FETCH_EMAILS", None, "entity-7", None) - .await - .expect_err("no composio host is served"); - assert!(execute_error.contains(COMPOSIO_UNSERVED), "{execute_error}"); -} - -/// An `Execute` failure must never quote what was executed. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn an_execute_failure_never_carries_its_arguments() { - let bridge = BusComposioHost::new(bus_without_composio_host().await); - let config = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(&ModuleConfig::default()); - - let error = bridge - .execute( - &config, - "GMAIL_FETCH_EMAILS", - Some(serde_json::json!({ "query": "from:accountant@example.com" })), - "entity-7", - Some("connection-1"), - ) - .await - .expect_err("no composio host is served"); - assert!(!error.contains("accountant@example.com"), "{error}"); - assert!(!error.contains("query"), "{error}"); -} - -#[test] -fn only_the_nobody_is_listening_family_reads_as_unserved() { - let unserved = |name: &str| { - super::is_unserved(&tinybus::Error::MethodFailed { - name: name.to_string(), - message: "irrelevant".to_string(), - }) - }; - - // Every remote failure arrives as `MethodFailed`, so these four names are - // the only evidence this side gets that nobody is listening. `UnknownMethod` - // is the one an older host with a newer module actually produces. - assert!(unserved("ai.tinyhumans.tinybus.Error.NameHasNoOwner")); - assert!(unserved("ai.tinyhumans.tinybus.Error.UnknownObject")); - assert!(unserved("ai.tinyhumans.tinybus.Error.UnknownInterface")); - assert!(unserved("ai.tinyhumans.tinybus.Error.UnknownMethod")); - - // A host that ran the method and failed is a working seam having a bad day: - // reporting it as unserved would latch the bridge off for the rest of the - // process over one expired session. - assert!(!unserved("ai.tinyhumans.tinymemory.Error.Host")); - assert!(!unserved("ai.tinyhumans.tinybus.Error.Failed")); - assert!(!unserved("ai.tinyhumans.tinybus.Error.Timeout")); -} - -/// The proxied-mode bearer crosses the bus, and an unreachable host answers -/// `None` rather than guessing. -/// -/// The second half is the half worth pinning. `is_available` deliberately -/// answers `true` when it cannot reach the host, because a wrong `false` there -/// reads as "not signed in" and hides a sync that is actually broken. This -/// member must not copy that: a credential is not something to be optimistic -/// about, and `None` is what lets `composio_config` refuse by name instead of -/// sending an empty bearer at the backend. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn the_session_bearer_crosses_the_bus_and_is_never_guessed() { - let (connection, _executed) = bus_with_composio_host(None, true).await; - let bridge = BusComposioHost::new(connection); - let config = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(&ModuleConfig::default()); - - assert_eq!( - bridge.session_bearer(&config).as_deref(), - Some("bearer-from-the-host"), - "the host's live session must reach the engine unchanged" - ); - - let unserved = BusComposioHost::new(bus_without_composio_host().await); - assert_eq!( - unserved.session_bearer(&config), - None, - "an unreachable host must not be optimistic about a credential" - ); -} diff --git a/crates/tinymemory-module/src/lib.rs b/crates/tinymemory-module/src/lib.rs index 70d56ed9..3d7612b6 100644 --- a/crates/tinymemory-module/src/lib.rs +++ b/crates/tinymemory-module/src/lib.rs @@ -43,14 +43,6 @@ //! [`config::ModuleConfig::strip_host_credentials`], not merely asserted about a //! field list. "Carried verbatim" carries credentials verbatim too. //! -//! The claim is about *configuration*, and there is exactly one place it stops -//! there: [`composio`]'s `ApiKey` fetches the user's direct-mode Composio key -//! from the host for the duration of one call. It is stated here rather than -//! buried because the difference matters — the engine's `composio_config` -//! builds its own HTTP client from that key, so unlike an embed there is no -//! host-side call to route the work through, and refusing it would mean -//! direct-mode memory sync simply cannot run. Nothing stores it; there is still -//! no field it could be stored in. //! //! # Scope: the complete TinyMemory API //! @@ -72,7 +64,6 @@ )] pub mod chat; -pub mod composio; pub mod config; pub mod config_loader; pub mod embedding; @@ -81,10 +72,6 @@ mod provider; mod service; pub use chat::{CHAT_HOST_BUS_NAME, CHAT_HOST_INTERFACE, CHAT_HOST_OBJECT_PATH}; -pub use composio::{ - BusComposioHost, API_KEY_METHOD, COMPOSIO_HOST_BUS_NAME, COMPOSIO_HOST_INTERFACE, - COMPOSIO_HOST_OBJECT_PATH, EXECUTE_METHOD, IS_AVAILABLE_METHOD, LIST_CONNECTIONS_METHOD, -}; pub use config::ModuleConfig; pub use config_loader::ModuleConfigLoader; pub use embedding::{ @@ -99,10 +86,6 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; use tinybus::{Connection, Error as BusError, Result as BusResult}; -// The trait, not only its methods: `composio` is reached as a method on -// `EngineRuntimeConfig` in `composio_sync_can_run`, and without the trait in -// scope rustc points at the struct's `composio_mode` field instead. -use tinymemory_api::host::MemoryHostConfig; use tinymemory_core::store::MemoryClientRef; /// The module refused its configuration or could not bring up a store. @@ -156,29 +139,12 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() connection.clone(), &config, ))); - // Composio is host state end to end — the connection list, the direct key, - // and whether any client resolves at all change on an OAuth completion or a - // `set_api_key` RPC with nothing restarting — so this one is a proxy and - // holds no snapshot. See `composio` for why the direct-mode key is the one - // credential that does cross. - tinymemory_core::composio_host::set_composio_host(Arc::new(composio::BusComposioHost::new( - connection.clone(), - ))); // The config loader is the opposite call, and deliberately: it is answered // from `config` — which is this line's whole argument — rather than asking // the host to re-read what it already handed over. It goes *after* the // 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; @@ -263,8 +229,8 @@ async fn setup(connection: Connection, mut config: ModuleConfig) -> BusResult<() /// Whether the client is bound. A failure is reported and the caller starts no /// sync loops: with no client resolvable, every run in both loops would fail on /// its first line with "memory client is not ready" — a named cause, but a loop -/// that can only fail is not worth the ticks, the Composio list call every 20 -/// minutes, or the failed-sync audit rows it would append forever. +/// that can only fail is not worth the ticks or the failed-sync audit rows it +/// would append forever. fn bind_memory_client(config: &ModuleConfig, client: &MemoryClientRef) -> bool { match tinymemory_core::global::bind(config.workspace_dir.clone(), Arc::clone(client)) { Ok(_) => true, @@ -280,28 +246,22 @@ fn bind_memory_client(config: &ModuleConfig, client: &MemoryClientRef) -> bool { } } -/// Start the engine's two periodic sync loops for this process. +/// Start the engine's workspace periodic sync loop for this process. /// /// # Why the module has to own these /// -/// The same reason [`start_queue_pool`] does. Both loops are engine code — -/// `tinymemory_core::sync::composio::periodic` and -/// `tinymemory_core::sync::workspace::periodic` — and until now the only calls -/// to them in any tree were the host's, made against the second, in-process -/// engine the host also booted. A host that deletes that engine, which is the -/// entire point of loading this module, is left with two loops it can no longer -/// start and a memory that stops updating: Composio connections stop pulling -/// mail, issues and documents, and registered repos, folders, RSS feeds and web -/// pages go stale. The sync layer reports "no connections", which is -/// indistinguishable from a user who has none. +/// The same reason [`start_queue_pool`] does. The workspace loop is engine code +/// and until now the host's in-process engine was the only caller. A host that +/// deletes that engine, which is the entire point of loading this module, would +/// otherwise leave registered repos, folders, RSS feeds, and web pages stale. /// /// # The host must stop starting them in the same change /// /// Not "should" — this is the one part the module cannot guard. The `cdylib` /// carries its own copy of `tinymemory-core`, so the `OnceLock` each loop /// guards itself with is a *different* static from the host's: a host that -/// still calls `start_periodic_sync` while loading this module gets two pairs -/// of loops, neither of which can see the other, both walking the same source +/// still starts this loop while loading this module gets two loops, neither of +/// which can see the other, both walking the same source /// registry into the same store. [`claim_sync_loops`] catches only the /// in-process case. So the host's call site goes in the same change that /// deletes the engine it was calling against. @@ -311,37 +271,19 @@ fn bind_memory_client(config: &ModuleConfig, client: &MemoryClientRef) -> bool { /// Stated rather than hidden, in the same terms [`start_queue_pool`] states its /// own two: /// -/// - **Neither loop honours the scheduler-gate pauses.** Both call +/// - **The loop does not honour scheduler-gate pauses.** It calls /// `periodic_pause_reason` as step 0 of every tick, precisely so a user who /// switched Memory Tree off, or who is signed out, gets no background fetch. /// This module serves no scheduler gate — see the section comment on /// `host::install_unserved_seams` for why it cannot — and the stub in its /// place always answers `Policy::Normal`, so `periodic_pause_reason` is always -/// `None` and both loops tick straight through both pauses. The per-source +/// `None` and it ticks straight through both pauses. The per-source /// `enabled` toggle still applies; the two *global* pauses do not. /// - **Their resume wake never fires.** The stub's `resume_notify` hands back a /// `Notify` nobody signals, so a user who re-enables sync waits out the /// remaining 20-minute tick instead of syncing within seconds. That is the /// benign half of the same gap. /// -/// # Backend-mode Composio sync cannot run here, so it is not started -/// -/// `composio_config` takes its direct branch on `config.composio().mode` and -/// otherwise needs a backend session bearer, which this module holds no field -/// for and refuses to hold — see `ModuleConfig::composio_mode` and -/// `EngineRuntimeConfig::session_token` for that decision in full. Starting the -/// Composio loop under any other mode would list the user's connections every 20 -/// minutes and fail every due one with the same named cause, appending a failed -/// row to the sync audit each time, forever. So it is gated, and the gate says -/// so out loud once instead. -/// -/// The workspace loop is started either way: it drives repos, folders, RSS and -/// web pages through `sources::sync::sync_source`, which never touches Composio. -/// -/// Both the gate and the pipeline read the load-time snapshot, so a user who -/// switches Composio mode after this module loaded is not picked up until the -/// host reloads it — `config_loader`'s documented staleness, and not new here. -/// The gate itself is [`start_composio_periodic_sync`]. fn start_sync_loops(config: &ModuleConfig) { match claim_sync_loops(&config.workspace_dir) { WorkspaceClaim::Start => { @@ -354,9 +296,8 @@ fn start_sync_loops(config: &ModuleConfig) { \"Memory Tree off\" and \"signed out\" pauses are ignored and a re-enable is \ not woken early — though each source's own enabled toggle still applies" ); - // Workspace sources first, because this one runs in every mode. + // Workspace sources run in every module configuration. tinymemory_core::sync::workspace::start_workspace_periodic_sync(); - start_composio_periodic_sync(config); } WorkspaceClaim::AlreadyRunning => { log::debug!( @@ -368,65 +309,13 @@ fn start_sync_loops(config: &ModuleConfig) { log::error!( "[tinymemory:module] the periodic memory sync loops are already running for a \ different workspace in this process, and both guard themselves process-wide, \ - so this store gets no periodic sync: Composio connections and registered \ - sources will not update. One module process serves one workspace" + so this store gets no periodic sync: registered sources will not update. \ + One module process serves one workspace" ); } } } -/// Start the Composio half of [`start_sync_loops`], if this host's mode allows. -/// -/// Split out so the gate is one readable decision rather than a conditional -/// buried in a match arm, and so the refusal branch has somewhere to explain -/// itself. The decision itself is [`composio_sync_can_run`]. -fn start_composio_periodic_sync(config: &ModuleConfig) { - if composio_sync_can_run(config) { - tinymemory_core::sync::composio::start_periodic_sync(); - return; - } - - log::warn!( - "[tinymemory:module] periodic Composio sync is NOT started: this host resolved Composio \ - to neither direct nor backend mode, so no credential can be obtained for it. \ - Composio-connected sources will not update in this process" - ); -} - -/// Whether the Composio pipelines can resolve a credential in this process. -/// -/// Both modes qualify now. Direct mode reads its API key through -/// `ComposioHost::api_key`, and backend mode reads its bearer through -/// `ComposioHost::session_bearer` — the seam added precisely so this gate could -/// stop excluding the mode most hosts actually run. It used to be direct-only, -/// which meant the loop silently did not start for a host whose default is -/// backend, and neither side reported it because neither thought it was -/// responsible. -/// -/// What is still excluded is a host that resolved to *neither* — an empty or -/// unrecognised mode string. There is no credential path for that, so starting -/// the loop would fail on every tick and append a failed audit row each time. -/// -/// Asked of the *same* `EngineRuntimeConfig` the loop's own ticks will be handed -/// and through the same `MemoryHostConfig::composio` accessor `composio_config` -/// reads, so the two cannot disagree about which host this is. What is left that -/// could drift is the comparison — this side calls `ComposioMode::is_direct`, -/// the pipeline inlines the same case-insensitive test against -/// `COMPOSIO_MODE_DIRECT` — so a host that spells its mode `"Direct"` is either -/// started and served or neither, never started and then failed on every tick. -/// -/// A predicate rather than a condition inside its one caller, for the reason -/// [`claim_workspace`] is one: this is the whole of what is worth asserting, and -/// asserting it through the caller would spawn a real 20-minute tick loop into -/// the test binary. -pub(crate) fn composio_sync_can_run(config: &ModuleConfig) -> bool { - let composio = tinymemory_tinycortex::engine::EngineRuntimeConfig::from(config).composio(); - // Mirrors `composio_config`'s own branch: direct, else anything that names - // a mode at all takes the proxied path. An unset mode names neither and is - // the one case with no credential to reach for. - composio.is_direct() || !composio.mode.trim().is_empty() -} - /// The workspace whose queue this process's worker pool drains. /// /// The pool is bound to one workspace — every `queue::store` entry point @@ -628,12 +517,9 @@ mod exports { // two, a draining queue would starve inbound dispatch and the module // would stop answering recalls until the queue emptied. // - // The two periodic sync loops `setup` also starts do not move the + // The periodic sync loop `setup` also starts does not move the // number. They sleep on a 20-minute `interval` and yield across every // fetch, so they hold no worker between ticks; the one moment they do is - // `BusComposioHost::probe`, which blocks its caller for one bus round - // trip and is bounded at twice per tick — see the note on `probe` for - // why that bridge blocks at all. // // Nor do the long-running on-demand members. `RunConnectionSync` and // `RebuildFromRawArchive` await network and inference, so they yield diff --git a/crates/tinymemory-module/src/service/test.rs b/crates/tinymemory-module/src/service/test.rs index 577f9df3..0ce829d7 100644 --- a/crates/tinymemory-module/src/service/test.rs +++ b/crates/tinymemory-module/src/service/test.rs @@ -527,56 +527,6 @@ fn the_sync_loops_are_claimed_once_and_a_foreign_workspace_is_refused() { ); } -/// The Composio gate answers for exactly the branch the pipeline would take. -/// -/// Worth pinning because the two ways it can be wrong are both quiet. A gate -/// that started the loop for a mode with no credential path would list the -/// user's connections every 20 minutes and fail every due one, appending a -/// failed row to the sync audit each time; a gate that refused a mode that CAN -/// resolve one would leave a host whose Composio sources simply stop updating, -/// with a single line at boot to explain it. -/// -/// Backend mode moved from the second category to the first when -/// `ComposioHost::session_bearer` landed. It used to be excluded because -/// `EngineRuntimeConfig::session_token` refuses by design — which meant the -/// loop did not start for a host whose default mode is backend, and neither the -/// host nor the module reported it, because neither thought it was responsible. -/// -/// Asserted through `composio_sync_can_run` rather than -/// `start_composio_periodic_sync` for the reason the claim tests above give: -/// the decision is the whole of what is worth checking, and the call after it -/// spawns a real 20-minute tick loop for the life of the test binary. -#[test] -fn composio_periodic_sync_starts_for_any_mode_that_can_resolve_a_credential() { - let mut config = test_config(std::path::Path::new("/tinymemory-module/composio-gate")); - - assert!( - !crate::composio_sync_can_run(&config), - "a host that states no mode is not direct — and has no bearer either" - ); - - config.composio_mode = tinymemory_api::host::COMPOSIO_MODE_BACKEND.to_string(); - assert!( - crate::composio_sync_can_run(&config), - "backend mode resolves its bearer through ComposioHost::session_bearer" - ); - - config.composio_mode = tinymemory_api::host::COMPOSIO_MODE_DIRECT.to_string(); - assert!( - crate::composio_sync_can_run(&config), - "direct mode resolves its key through ComposioHost::api_key" - ); - - // The pipeline's own branch test is case-insensitive. If the gate were not, - // this host would be started and would then fail every tick — the exact - // shape the gate exists to prevent. - config.composio_mode = "Direct".to_string(); - assert!( - crate::composio_sync_can_run(&config), - "the gate must match `composio_config` on case, or it starts a loop that cannot work" - ); -} - /// A second store opens normally, and needs no pool of its own to do it. /// /// The pairing with the test above is the point. `queue::start` is guarded by a @@ -769,35 +719,3 @@ 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" - ); -} From 246b238fa42d6ae10a9fb53f16271bb7cab1c3f4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 20:20:57 +0300 Subject: [PATCH 3/4] fix(core): remove stale connector remnants Co-authored-by: Medulla --- .../src/sources/readers/mod.rs | 3 +-- .../tinymemory-core/src/sources/reconcile.rs | 20 ------------------- crates/tinymemory-core/src/store/identity.rs | 1 - crates/tinymemory-core/src/sync/mod.rs | 10 +++------- 4 files changed, 4 insertions(+), 30 deletions(-) diff --git a/crates/tinymemory-core/src/sources/readers/mod.rs b/crates/tinymemory-core/src/sources/readers/mod.rs index c3ebe1ab..1d798633 100644 --- a/crates/tinymemory-core/src/sources/readers/mod.rs +++ b/crates/tinymemory-core/src/sources/readers/mod.rs @@ -36,8 +36,7 @@ pub trait SourceReader: Send + Sync { /// forgotten under it, and removing it would orphan every row already written. /// What left is the *reading*: an OAuth connector is reached with a credential /// this crate does not hold and must not, so the host fetches through -/// `tinyconnectors` and hands the records here through -/// [`crate::provider::MemorySourceSink`]. +/// `tinyconnectors` and hands the records to the memory provider. /// /// Returning `Option` rather than a stub reader that always errors is /// deliberate: a caller has to decide what to do about a kind it cannot read, diff --git a/crates/tinymemory-core/src/sources/reconcile.rs b/crates/tinymemory-core/src/sources/reconcile.rs index 8ba1e0e5..7261f274 100644 --- a/crates/tinymemory-core/src/sources/reconcile.rs +++ b/crates/tinymemory-core/src/sources/reconcile.rs @@ -119,26 +119,6 @@ pub async fn apply_composio_source_caps_migration() -> Result<(), String> { Ok(()) } -fn title_case(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().chain(chars).collect(), - } -} - -fn short_id(id: &str) -> &str { - // Show only the last 8 Unicode scalar values to keep labels compact. - // Byte-slicing would panic if the cut point isn't a UTF-8 boundary. - let n = id.chars().count(); - if n <= 8 { - return id; - } - let skip = n - 8; - let start = id.char_indices().nth(skip).map(|(idx, _)| idx).unwrap_or(0); - &id[start..] -} - #[cfg(test)] #[path = "reconcile_tests.rs"] mod tests; diff --git a/crates/tinymemory-core/src/store/identity.rs b/crates/tinymemory-core/src/store/identity.rs index 1a5e4c7a..edb306f9 100644 --- a/crates/tinymemory-core/src/store/identity.rs +++ b/crates/tinymemory-core/src/store/identity.rs @@ -124,7 +124,6 @@ pub fn is_self_identity_any_toolkit(kind: IdentityKind, raw_value: &str) -> bool /// Render a compact section for prompt injection. Skips `user_id` (not /// human-readable), prefixes `handle` with `@`. - /// Fold a token to the shape used in a profile-store key. pub fn normalize_token(raw: &str) -> String { let mut out = String::with_capacity(raw.len()); diff --git a/crates/tinymemory-core/src/sync/mod.rs b/crates/tinymemory-core/src/sync/mod.rs index 8b043f7b..60c6d88b 100644 --- a/crates/tinymemory-core/src/sync/mod.rs +++ b/crates/tinymemory-core/src/sync/mod.rs @@ -2,16 +2,14 @@ //! //! One top-level module hosting every "pull data from upstream → land it //! in memory_store" pipeline, organised by the kind of upstream it talks -//! to. Three kinds today: +//! to. Two kinds today: //! -//! - [`composio`] — Composio managed connectors (Gmail, Slack, GitHub, -//! Notion, Linear, ClickUp, …). Pulls via the Composio Edge API. //! - [`workspace`] — Local workspace connectors (filesystem vault sync, //! local-only ingest, agent-experience capture from the harness). //! - [`mcp`] — Third-party MCP servers. Pulls via the MCP protocol over //! stdio/SSE. //! -//! All three implement the `SyncPipeline` trait so the orchestrator +//! Both implement the `SyncPipeline` trait so the orchestrator //! (`memory::jobs`) can drive them uniformly: `init` → `tick` → repeat. //! //! ## Layer rules @@ -19,9 +17,7 @@ //! - Sync writes into `memory_store` only — never directly into trees, //! never directly into unified. The ingest pipeline in //! `memory::ingest_pipeline` is the seam. -//! - One pipeline per upstream service. Composio's GitHub and MCP's -//! GitHub are distinct pipelines because they hit different surfaces -//! with different cadence and auth. +//! - One pipeline per upstream service. //! - Pipeline modules own their own types, their own state, and their //! own retry/backoff policy. The trait gives the orchestrator a //! single shape to call; everything else stays local. From 84b8f40c79ceaaad56afb8d29014cb22c74e8f33 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 20:23:21 +0300 Subject: [PATCH 4/4] fix(core): retain test-only reconcile helper Co-authored-by: Medulla --- crates/tinymemory-core/src/sources/reconcile.rs | 13 +++++++++++++ crates/tinymemory-core/src/test_seams.rs | 11 ----------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/tinymemory-core/src/sources/reconcile.rs b/crates/tinymemory-core/src/sources/reconcile.rs index 7261f274..6703a46c 100644 --- a/crates/tinymemory-core/src/sources/reconcile.rs +++ b/crates/tinymemory-core/src/sources/reconcile.rs @@ -119,6 +119,19 @@ pub async fn apply_composio_source_caps_migration() -> Result<(), String> { Ok(()) } +#[cfg(test)] +fn short_id(id: &str) -> &str { + // Show only the last 8 Unicode scalar values to keep labels compact. + // Byte-slicing would panic if the cut point isn't a UTF-8 boundary. + let n = id.chars().count(); + if n <= 8 { + return id; + } + let skip = n - 8; + let start = id.char_indices().nth(skip).map(|(idx, _)| idx).unwrap_or(0); + &id[start..] +} + #[cfg(test)] #[path = "reconcile_tests.rs"] mod tests; diff --git a/crates/tinymemory-core/src/test_seams.rs b/crates/tinymemory-core/src/test_seams.rs index 24a27007..d8211d8f 100644 --- a/crates/tinymemory-core/src/test_seams.rs +++ b/crates/tinymemory-core/src/test_seams.rs @@ -87,14 +87,3 @@ impl crate::chat_host::ChatHost for TestChatHost { (true, "test chat host reports a summariser") } } - -/// A [`ComposioHost`] that behaves like a signed-out user. -/// -/// Every method reports the no-backend-session state, which is the branch the -/// core's own tests exercise; a stub that succeeded would need to fake Composio - -/// The message [`TestComposioHost`] reports. Matches the shape the real backend -/// client produces when no session token is stored, which is what the tests -/// assert on. -const NO_SESSION: &str = "composio backend mode unavailable: no backend session token. \ - Sign in first (auth_store_session).";