From 81634989fc5d47bde0c6659fcdcae61e14240678 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:29:47 +0300 Subject: [PATCH 01/44] chore: update Rust project configuration Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src-tauri/Cargo.toml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 48474f67ad0..eb227078073 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -57,27 +57,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" directories = "5" -# Native Save-As file dialog for artifact export (#3162). default-features -# off + xdg-portal keeps Linux off GTK (the Downloads-copy fallback covers -# headless CI where no portal is available); tokio drives the async backend. -rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] } -# Used by gmail/cdp_fetch for decoding binary IO.read chunks. Base64 is -# only emitted by CDP IO.read when the stream contains non-UTF-8 bytes, -# but we opt into the feature to stay robust against unexpected responses. -base64 = "0.22" tokio = { version = "1", features = ["rt-multi-thread", "process", "sync", "time", "net"] } tokio-util = { version = "0.7", features = ["rt"] } -# WebSocket client + server for two uses: -# - Client: Chrome DevTools Protocol connections to the embedded CEF -# instance over `--remote-debugging-port=9222` (IndexedDB reads, -# `Runtime.evaluate` for the WhatsApp recipe, DOMSnapshot / Network -# calls for the Gmail connector). -# - Server: the `webview_apis` bridge at 127.0.0.1 that accepts -# JSON-RPC frames from the core sidecar so core-side handlers can -# reach the live-webview connectors via CDP. -tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake"] } url = "2" -futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } rand = "0.9" @@ -115,9 +97,6 @@ rusqlite = { version = "=0.40.2", features = ["bundled"] } parking_lot = "0.12" chrono = "0.4" async-trait = "0.1" -# Desktop companion: native mic capture + POINT-tag parsing + session ids. -cpal = "0.15" -uuid = { version = "1", features = ["v4"] } # NOTE (#5541): `resvg` + `tiny-skia` were removed here. They existed only for # the mascot fake-camera pipeline, which rasterized the mascot SVG to a Y4M # frame for CEF's `--use-file-for-fake-video-capture` flag. That whole path From d8c366aab150547cf9da3d9b3ccab5bdeead1493 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:31:17 +0300 Subject: [PATCH 02/44] chore: update artifact commands Update artifact command handling to keep the application behavior aligned with the latest implementation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src-tauri/src/artifact_commands.rs | 85 +++++++------------------- 1 file changed, 21 insertions(+), 64 deletions(-) diff --git a/app/src-tauri/src/artifact_commands.rs b/app/src-tauri/src/artifact_commands.rs index b87b4698112..116fa5cecfe 100644 --- a/app/src-tauri/src/artifact_commands.rs +++ b/app/src-tauri/src/artifact_commands.rs @@ -1,70 +1,28 @@ -//! Tauri commands for exporting agent-generated artifacts (#2779, #3162). +//! Tauri commands for exporting agent-generated artifacts (#2779). //! -//! Two export paths, both fed by the frontend resolving an artifact's -//! absolute source path via the `openhuman.ai_get_artifact` core RPC: +//! One export path, fed by the frontend resolving an artifact's absolute +//! source path via the `openhuman.ai_get_artifact` core RPC: +//! [`download_artifact_to_downloads`] copies the artifact into the user's +//! Downloads directory with a non-colliding name and returns the dest path +//! so the UI can offer "Reveal in Finder". Cross-platform. //! -//! 1. [`save_artifact_via_dialog`] (#3162) — opens a native Save-As -//! dialog (macOS / Windows / Linux) pre-filled with the artifact's -//! filename and copies the bytes to the user-chosen destination. -//! Returns `Ok(None)` when the user cancels. Backed by the `rfd` -//! crate, which talks to the OS dialog APIs directly and does NOT -//! pull `tauri-plugin-fs` (whose `schemars` version conflict was the -//! reason the original #2779 work shipped the Downloads fallback -//! below instead of a dialog). -//! 2. [`download_artifact_to_downloads`] (#2779) — copies the artifact -//! into the user's Downloads directory with a non-colliding name and -//! returns the dest path so the UI can offer "Reveal in Finder". -//! Retained as the fallback the frontend uses when the dialog is -//! unavailable (e.g. no portal on headless Linux) or the user cancels. -//! Cross-platform — previously macOS/Linux-only, un-gated so the -//! Save-As fallback works on Windows too. +//! **The native Save-As dialog (#3162) was removed.** It was one call into +//! `rfd`, and `rfd` carried 13 packages — the xdg-desktop-portal client +//! (`ashpd`), `zbus`, and the `async-io`/`polling` executor stack — into a +//! binary that already reaches D-Bus through other paths. The frontend's +//! `saveArtifactViaDialog` had a Downloads fallback for hosts with no +//! portal from the day it landed, so that fallback is simply the only path +//! now; the user still gets the file plus "Reveal in Finder", one dialog +//! fewer. If a real Save-As is wanted again, prefer the destination-picking +//! surface Tauri itself already links over re-adding a second dialog stack. //! -//! Both validate that the source is an existing file inside the -//! OpenHuman data dir's `artifacts/` tree, and sanitize the filename -//! hint, so the renderer can never copy an arbitrary local file out nor -//! write outside the chosen directory. +//! It validates that the source is an existing file inside the OpenHuman +//! data dir's `artifacts/` tree, and sanitizes the filename hint, so the +//! renderer can never copy an arbitrary local file out nor write outside +//! the Downloads directory. use std::path::{Path, PathBuf}; -/// Open a native Save-As dialog pre-filled with `suggested_filename` and -/// copy the artifact at `source_path` to the chosen destination (#3162). -/// -/// Returns: -/// - `Ok(Some(dest))` — the absolute path the user saved to. -/// - `Ok(None)` — the user dismissed the dialog (not an error; the -/// frontend simply stops). -/// - `Err(_)` — bad inputs or a copy failure; the frontend falls back to -/// [`download_artifact_to_downloads`] where available. -#[tauri::command] -pub async fn save_artifact_via_dialog( - source_path: String, - suggested_filename: String, -) -> Result, String> { - let source = validate_source(&source_path)?; - let sanitized = sanitize_filename(&suggested_filename)?; - - // `rfd` drives the OS-native dialog. On Linux this is the xdg-desktop - // portal (no GTK link); on macOS/Windows the system panel. The await - // resolves when the user picks a path or cancels. - let handle = rfd::AsyncFileDialog::new() - .set_file_name(&sanitized) - .save_file() - .await; - - let Some(file) = handle else { - log::info!("[artifact_commands] save_artifact_via_dialog cancelled by user"); - return Ok(None); - }; - - let dest = file.path().to_path_buf(); - let bytes = copy_to_path(&source, &dest).await?; - log::info!( - "[artifact_commands] save_artifact_via_dialog bytes={bytes} dest={}", - dest.display() - ); - Ok(Some(dest.display().to_string())) -} - /// Validate a renderer-supplied source path: must be a non-empty, /// absolute path that exists on disk AND resolve inside the OpenHuman /// data directory's `artifacts/` tree. The path always originates from @@ -115,9 +73,8 @@ fn assert_artifact_source(source: &Path, root: &Path) -> Result<(), String> { Ok(()) } -/// Copy `source` to `dest`, returning the byte count. Shared by the -/// Save-As dialog flow; isolated so it is unit-testable without driving -/// a real OS dialog. +/// Copy `source` to `dest`, returning the byte count. Isolated so it is +/// unit-testable without touching the real Downloads directory. async fn copy_to_path(source: &Path, dest: &Path) -> Result { tokio::fs::copy(source, dest) .await From 2e991a0b664842a433d3aa054647699a79134c98 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:32:13 +0300 Subject: [PATCH 03/44] chore: files changed app/src-tauri/src/artifact_commands.rs,app/src-tauri/src/lib.rs,app/src-tauri/s Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src-tauri/src/artifact_commands.rs | 10 +- app/src-tauri/src/lib.rs | 36 +-- app/src-tauri/src/webview_apis/mod.rs | 47 ---- app/src-tauri/src/webview_apis/router.rs | 43 --- app/src-tauri/src/webview_apis/server.rs | 334 ----------------------- 5 files changed, 6 insertions(+), 464 deletions(-) delete mode 100644 app/src-tauri/src/webview_apis/mod.rs delete mode 100644 app/src-tauri/src/webview_apis/router.rs delete mode 100644 app/src-tauri/src/webview_apis/server.rs diff --git a/app/src-tauri/src/artifact_commands.rs b/app/src-tauri/src/artifact_commands.rs index 116fa5cecfe..6c04920783f 100644 --- a/app/src-tauri/src/artifact_commands.rs +++ b/app/src-tauri/src/artifact_commands.rs @@ -260,16 +260,16 @@ mod tests { } #[tokio::test] - async fn save_via_dialog_rejects_bad_source() { - // Validation runs before any dialog is shown, so these resolve - // without user interaction. + async fn download_rejects_bad_source() { + // Source validation runs before the Downloads directory is touched, + // so these resolve without writing anything. assert!( - save_artifact_via_dialog(String::new(), "x.pptx".to_string()) + download_artifact_to_downloads(String::new(), "x.pptx".to_string()) .await .is_err() ); assert!( - save_artifact_via_dialog("relative".to_string(), "x.pptx".to_string()) + download_artifact_to_downloads("relative".to_string(), "x.pptx".to_string()) .await .is_err() ); diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index fc802f91fe3..1eaf7d9bb3d 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -73,7 +73,6 @@ mod ptt_overlay; #[cfg(target_os = "windows")] mod reset_reboot_schedule; mod stderr_panic_hook; -mod webview_apis; mod whatsapp_data; mod window_state; mod workspace_paths; @@ -1723,8 +1722,6 @@ fn perform_early_teardown_sync(app_handle: &AppHandle) { #[cfg(feature = "gateways")] tauri::async_runtime::block_on(gateway::registry::shutdown()); - webview_apis::server::stop(); - if let Some(core) = app_handle.try_state::() { let core = core.inner().clone(); // Aborts the embedded server task. Synchronous and safe on @@ -1762,8 +1759,6 @@ async fn perform_early_teardown_async(app_handle: &AppHandle) { #[cfg(feature = "gateways")] gateway::registry::shutdown().await; - webview_apis::server::stop(); - if let Some(core) = app_handle.try_state::() { let core = core.inner().clone(); core.send_terminate_signal().await; @@ -3036,32 +3031,6 @@ pub fn run() { deep_link_ipc::drain_pending_urls(app.app_handle()); } - // Start the webview_apis WebSocket bridge BEFORE spawning core — - // core reads OPENHUMAN_WEBVIEW_APIS_PORT on first connect, and - // connects lazily, so the env var must be set before the spawn. - // - // If the bridge fails to bind we clear any inherited port env so - // the core child can't accidentally connect to whichever loopback - // process already owns that port, then abort setup — the bridge - // is load-bearing for every webview_apis RPC method. - let bridge_ok = tauri::async_runtime::block_on(async { - match webview_apis::start().await { - Ok(port) => { - std::env::set_var(webview_apis::server::PORT_ENV, port.to_string()); - log::info!("[webview_apis] bridge ready on port {port}"); - true - } - Err(err) => { - log::error!("[webview_apis] failed to start bridge: {err}"); - std::env::remove_var(webview_apis::server::PORT_ENV); - false - } - } - }); - if !bridge_ok { - return Err("webview_apis bridge failed to start — aborting setup".into()); - } - // Purge stray LaunchAgent left over from a prior worktree's // `service install`. KeepAlive=true on the plist re-spawns the // daemon after every SIGKILL, fighting `ensure_running`'s @@ -3369,7 +3338,6 @@ pub fn run() { // Downloads command was previously macOS/Linux-gated, but the // `directories` + `tokio::fs::copy` flow compiles on Windows too, // and the Save-As fallback needs it there (CodeRabbit on #4127). - artifact_commands::save_artifact_via_dialog, artifact_commands::download_artifact_to_downloads, // Structured WhatsApp data (store lives shell-side). whatsapp_data::whatsapp_data_list_chats, @@ -3543,9 +3511,7 @@ pub fn run() { // (gives them time to settle before cef::shutdown). // 2. abort our long-lived tokio tasks so they're not // driving CDP traffic against CEF as it tears down. - // 3. stop the webview_apis WS listener so its accept - // loop releases the loopback port. - // 4. SIGTERM the core sidecar (non-blocking). Tauri + // 3. SIGTERM the core sidecar (non-blocking). Tauri // spawned the child so we own its lifecycle, but we // do not wait — that would block the main thread // and starve CEF's UI loop. The kernel reaps the diff --git a/app/src-tauri/src/webview_apis/mod.rs b/app/src-tauri/src/webview_apis/mod.rs deleted file mode 100644 index 1b82f5efa16..00000000000 --- a/app/src-tauri/src/webview_apis/mod.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Webview APIs bridge — Tauri side (server). -//! -//! Exposes the connector APIs that live in the Tauri shell (future: -//! Notion, Slack, …) to the core sidecar over a local WebSocket on -//! `127.0.0.1`. Core-side handlers in `src/openhuman/webview_apis/` -//! connect as a client and proxy JSON-RPC calls through this bridge -//! so curl against the core's RPC port reaches the live webview -//! session. The bridge currently has no registered methods; the -//! Gmail embedded-webview connector that previously lived here has -//! been retired so the webview-account flow can stay focused on -//! social / messaging surfaces. -//! -//! ## Protocol -//! -//! JSON text frames, one envelope per frame: -//! -//! ```text -//! request: { "kind": "request", "id": "...", "method": ".", -//! "params": { "account_id": "…" } } -//! response: { "kind": "response", "id": "...", "ok": true, "result": } -//! response: { "kind": "response", "id": "...", "ok": false, "error": "…" } -//! ``` -//! -//! The server is permissive: it accepts requests from any connection on -//! loopback (the spawned core process is the only one expected, but we -//! don't authenticate — the port is never bound to a public interface). -//! -//! ## Startup / port coordination -//! -//! The server always binds `127.0.0.1:0` and lets the OS pick an -//! ephemeral port. The resolved port is exposed via [`resolved_port`] -//! and pushed into the core sidecar's environment as -//! `OPENHUMAN_WEBVIEW_APIS_PORT` by `core_process::spawn_core` so the -//! client side can find it. -//! -//! `OPENHUMAN_WEBVIEW_APIS_PORT` is an **output** of the bridge — it is -//! intentionally never read as input. Honouring a pre-existing value -//! was the cause of Sentry OPENHUMAN-TAURI-82 on Windows: a stale env -//! value left over from a prior run (or inherited from a parent -//! process) led the next launch to re-bind the exact same port and -//! fail with WSAEADDRINUSE (`os error 10048`). - -pub mod router; -pub mod server; - -#[allow(unused_imports)] -pub use server::{resolved_port, start}; diff --git a/app/src-tauri/src/webview_apis/router.rs b/app/src-tauri/src/webview_apis/router.rs deleted file mode 100644 index b3ab93dc6dc..00000000000 --- a/app/src-tauri/src/webview_apis/router.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Method dispatch for webview_apis requests. -//! -//! Maps a protocol method name to the Rust function that handles it. -//! Currently empty — the only consumer was the Gmail embedded-webview -//! bridge, which has been retired so the webview-account flow can stay -//! focused on social / messaging surfaces. Future connectors that want -//! to expose CDP-driven actions through the bridge plug their handlers -//! into [`dispatch_inner`] here. - -use serde_json::{Map, Value}; - -/// Dispatch a single webview_apis request to its handler. Returns the -/// `result` JSON on success or a string error that the server relays -/// back as `{ ok: false, error }`. -/// -/// Outcome logging lives here so the bridge has a single chokepoint -/// for success/failure traces — callers (tests, the WS server) keep -/// their own entry/exit logs but rely on this function to summarise -/// each dispatch decision. -pub async fn dispatch(method: &str, params: Map) -> Result { - log::debug!("[webview_apis] dispatch method={method}"); - let out = dispatch_inner(method, params).await; - match &out { - Ok(_) => log::debug!("[webview_apis] dispatch ok method={method}"), - Err(e) => log::warn!("[webview_apis] dispatch err method={method} error={e}"), - } - out -} - -async fn dispatch_inner(method: &str, _params: Map) -> Result { - Err(format!("unknown webview_apis method: {method}")) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn unknown_method_is_rejected() { - let err = dispatch("something.else", Map::new()).await.unwrap_err(); - assert!(err.contains("unknown webview_apis method")); - } -} diff --git a/app/src-tauri/src/webview_apis/server.rs b/app/src-tauri/src/webview_apis/server.rs deleted file mode 100644 index a538fe9f1a7..00000000000 --- a/app/src-tauri/src/webview_apis/server.rs +++ /dev/null @@ -1,334 +0,0 @@ -//! WebSocket server for the webview_apis bridge. -//! -//! Binds a loopback TCP socket, accepts incoming connections (one per -//! core sidecar instance), and for each frame: decode → route → encode -//! response. Any number of concurrent requests per connection: each is -//! spawned as its own task and the responses are serialised back over -//! the shared sink via an mpsc. - -use std::net::SocketAddr; -use std::sync::atomic::{AtomicU16, Ordering}; -use std::sync::{Mutex, OnceLock}; -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; -use tokio::net::TcpListener; -use tokio::sync::mpsc; -use tokio::task::JoinHandle; -use tokio_tungstenite::tungstenite::Message; - -use super::router; - -/// Env var the Tauri host writes (before spawning core) and core reads -/// (in `src/openhuman/webview_apis/client.rs`) so both agree on the -/// port without a discovery round-trip. -pub const PORT_ENV: &str = "OPENHUMAN_WEBVIEW_APIS_PORT"; - -/// The port the server is bound to. `0` before `start()` resolves it. -static RESOLVED_PORT: AtomicU16 = AtomicU16::new(0); -static STARTED: OnceLock<()> = OnceLock::new(); -/// Handle to the accept loop spawned by `start()`. Held so `stop()` can -/// abort the loop on app shutdown — without this the loop owns the -/// `TcpListener` and keeps the loopback port bound past tokio runtime -/// drop, which on macOS contributes to the "abnormal exit" the OS -/// reports against the app process (issue #920). -static ACCEPT_LOOP: OnceLock>>> = OnceLock::new(); - -pub fn resolved_port() -> u16 { - RESOLVED_PORT.load(Ordering::SeqCst) -} - -/// Start the server. Idempotent: after the first successful call any -/// subsequent call is a no-op. Returns the bound port. -/// -/// Port selection: always bind `127.0.0.1:0` and let the OS pick an -/// ephemeral port. The resolved port is then exported via `PORT_ENV` -/// (by the caller in `lib.rs`) so the core sidecar can discover it. -/// -/// We deliberately ignore any pre-existing `PORT_ENV` value here: -/// honouring it caused Sentry OPENHUMAN-TAURI-82 on Windows — if a -/// previous run wrote `PORT_ENV=49342` into the user's environment -/// (or the env was inherited from a parent process / leftover dev -/// session), the next launch would attempt to re-bind that exact -/// port and fail with WSAEADDRINUSE / os error 10048 whenever the -/// socket was still held by another process or stuck in TIME_WAIT. -/// `PORT_ENV` is an *output* of the bridge, not an input. -pub async fn start() -> Result { - if STARTED.get().is_some() { - return Ok(resolved_port()); - } - - let addr: SocketAddr = "127.0.0.1:0" - .parse() - .map_err(|e| format!("[webview_apis] bad addr: {e}"))?; - let listener = TcpListener::bind(addr) - .await - .map_err(|e| format!("[webview_apis] bind {addr} failed: {e}"))?; - let bound = listener - .local_addr() - .map_err(|e| format!("[webview_apis] local_addr: {e}"))?; - let port = bound.port(); - RESOLVED_PORT.store(port, Ordering::SeqCst); - let _ = STARTED.set(()); - - log::info!("[webview_apis] server listening on {bound} (OS-assigned ephemeral)"); - - let accept_handle = tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((stream, peer)) => { - log::info!("[webview_apis] accepted connection from {peer}"); - tokio::spawn(async move { - if let Err(e) = handle_connection(stream).await { - log::warn!("[webview_apis] connection {peer} ended: {e}"); - } else { - log::info!("[webview_apis] connection {peer} closed cleanly"); - } - }); - } - Err(e) => { - log::warn!("[webview_apis] accept failed: {e}"); - tokio::time::sleep(Duration::from_millis(200)).await; - } - } - } - }); - let slot = ACCEPT_LOOP.get_or_init(|| Mutex::new(None)); - if let Ok(mut g) = slot.lock() { - *g = Some(accept_handle); - } - - Ok(port) -} - -/// Abort the accept loop and release the loopback port. Idempotent. -/// -/// Called from the app's `RunEvent::Exit` shutdown path so the listener -/// task doesn't outlive the tokio runtime / surrounding `AppHandle` — -/// see issue #920. -pub fn stop() { - let Some(slot) = ACCEPT_LOOP.get() else { - return; - }; - let handle = match slot.lock() { - Ok(mut g) => g.take(), - Err(_) => return, - }; - if let Some(h) = handle { - h.abort(); - log::info!("[webview_apis] accept loop aborted"); - } -} - -async fn handle_connection(stream: tokio::net::TcpStream) -> Result<(), String> { - let ws = tokio_tungstenite::accept_async(stream) - .await - .map_err(|e| format!("ws handshake: {e}"))?; - let (mut sink, mut stream) = ws.split(); - - // Responses from per-request tasks fan in here and are written back - // in order. 32 is plenty — the core sidecar issues one request at a - // time per op in the common path. - let (tx, mut rx) = mpsc::channel::(32); - - let writer = tokio::spawn(async move { - while let Some(msg) = rx.recv().await { - if let Err(e) = sink.send(Message::Text(msg)).await { - log::warn!("[webview_apis] ws send failed: {e}"); - break; - } - } - }); - - while let Some(msg) = stream.next().await { - match msg { - Ok(Message::Text(text)) => { - let tx = tx.clone(); - tokio::spawn(async move { - let reply = handle_frame(&text).await; - if let Err(_e) = tx.send(reply).await { - log::warn!("[webview_apis] response channel closed before send"); - } - }); - } - Ok(Message::Binary(_)) => { - log::debug!("[webview_apis] ignoring binary frame"); - } - Ok(Message::Ping(p)) => { - // tungstenite auto-responds to Ping at the protocol layer; - // log for visibility. - log::trace!("[webview_apis] ping {} bytes", p.len()); - } - Ok(Message::Close(_)) => { - log::debug!("[webview_apis] peer requested close"); - break; - } - Ok(_) => {} - Err(e) => { - return Err(format!("ws recv: {e}")); - } - } - } - - drop(tx); - let _ = writer.await; - Ok(()) -} - -async fn handle_frame(text: &str) -> String { - let envelope: Request = match serde_json::from_str(text) { - Ok(v) => v, - Err(e) => { - log::warn!("[webview_apis] bad request frame: {e}"); - return encode_response(Response::error("", format!("bad frame: {e}"))); - } - }; - if envelope.kind != "request" { - return encode_response(Response::error( - &envelope.id, - format!("unsupported envelope kind '{}'", envelope.kind), - )); - } - let params = envelope.params.unwrap_or_default(); - let started = std::time::Instant::now(); - let result = router::dispatch(&envelope.method, params).await; - let ms = started.elapsed().as_millis(); - match result { - Ok(value) => { - log::debug!( - "[webview_apis] {} id={} ok in {ms}ms", - envelope.method, - envelope.id - ); - encode_response(Response::ok(&envelope.id, value)) - } - Err(e) => { - log::warn!( - "[webview_apis] {} id={} err in {ms}ms: {e}", - envelope.method, - envelope.id - ); - encode_response(Response::error(&envelope.id, e)) - } - } -} - -fn encode_response(resp: Response) -> String { - serde_json::to_string(&resp).unwrap_or_else(|e| { - format!( - r#"{{"kind":"response","id":"{}","ok":false,"error":"response encode failed: {}"}}"#, - resp.id, - e.to_string().replace('"', "\\\"") - ) - }) -} - -// ── envelope types ────────────────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -struct Request { - kind: String, - id: String, - method: String, - #[serde(default)] - params: Option>, -} - -#[derive(Debug, Serialize)] -struct Response { - kind: &'static str, - id: String, - ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -impl Response { - fn ok(id: &str, result: Value) -> Self { - Self { - kind: "response", - id: id.to_string(), - ok: true, - result: Some(result), - error: None, - } - } - - fn error(id: &str, error: impl Into) -> Self { - Self { - kind: "response", - id: id.to_string(), - ok: false, - result: None, - error: Some(error.into()), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Regression test for Sentry OPENHUMAN-TAURI-82. - /// - /// If `PORT_ENV` carries a stale value pointing at a port that is - /// already in use (the failure mode reported on Windows: a previous - /// run wrote `49342` into the env, then the same port was held by - /// another process / stuck in TIME_WAIT), `start()` must still - /// succeed by binding a fresh OS-assigned ephemeral port instead of - /// trying to re-bind the stale port. - // Single-threaded runtime: `std::env::set_var` mutates process-global - // state and is not thread-safe in Rust. Under the default multi-threaded - // tokio test runtime, threads spawned by the same test could observe - // the env between `set_var` and the restore. `current_thread` eliminates - // that intra-test window (cross-test races between OS threads from - // OTHER tests are still possible — see the save/restore pattern below - // for that part). Per graycyrus review on PR #1543. - #[tokio::test(flavor = "current_thread")] - async fn start_ignores_stale_port_env_and_binds_ephemeral() { - // Occupy a port so `PORT_ENV` points at something that would - // definitely fail if `start()` honoured it. - let blocker = TcpListener::bind("127.0.0.1:0") - .await - .expect("blocker bind"); - let stale_port = blocker.local_addr().expect("blocker addr").port(); - // Save+restore `PORT_ENV` so parallel tests in the same process - // don't see this test's mutation. (Per CodeRabbit feedback on PR - // #1543.) `std::env::set_var` is process-global; without the - // restore, an unrelated test asserting on `PORT_ENV` could observe - // `stale_port` and flake. - let prev_port_env = std::env::var(PORT_ENV).ok(); - std::env::set_var(PORT_ENV, stale_port.to_string()); - - let bound = start() - .await - .expect("start should succeed despite stale PORT_ENV"); - - assert_ne!( - bound, stale_port, - "start() must pick a fresh ephemeral port, not the stale one in PORT_ENV" - ); - assert_eq!(resolved_port(), bound); - - // Hold `blocker` until after `start()` so the kernel definitely - // can't satisfy a bind on `stale_port` — defends against the - // exact race the Sentry issue describes. - drop(blocker); - // Note: `stop()` only aborts the accept loop — it does NOT (and - // cannot) reset the `STARTED` OnceLock. If a second test in this - // binary later calls `start()` it'll hit the idempotency - // early-return and silently observe this test's port. A future - // refactor to `AtomicBool`-based singleton would let `stop()` - // fully tear down. Tracked as graycyrus feedback on PR #1543; - // currently inert because this is the only test in the module. - stop(); - match prev_port_env { - Some(v) => std::env::set_var(PORT_ENV, v), - None => std::env::remove_var(PORT_ENV), - } - } -} From bd1da7676071d34b5df13f80b06897240560d726 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:32:59 +0300 Subject: [PATCH 04/44] refactor(artifacts): use Downloads copy for artifact saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the native Save-As dialog path and delegate artifact saves directly to the Downloads copy flow. This keeps the existing caller-facing export while aligning with the removal of the shell’s dialog dependency. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/services/artifactDownloadService.ts | 59 +++++++-------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/app/src/services/artifactDownloadService.ts b/app/src/services/artifactDownloadService.ts index 5b705dc858e..4ab50191b09 100644 --- a/app/src/services/artifactDownloadService.ts +++ b/app/src/services/artifactDownloadService.ts @@ -1,17 +1,17 @@ /** - * Artifact export service (#2779, #3162). + * Artifact export service (#2779). * * All paths first resolve the artifact's absolute on-disk path + meta * via the `openhuman.ai_get_artifact` core RPC, then hand a source path - * + filename hint to a Tauri command: + * + filename hint to the `download_artifact_to_downloads` Tauri command, + * which copies into the user's Downloads directory with a non-colliding + * name and returns the dest path so the UI can offer "Reveal in Finder". * - * - {@link saveArtifactViaDialog} (#3162) — native Save-As dialog - * pre-filled with the filename; user picks the destination. Cancel is - * reported as `{ ok: false, code: 'CANCELLED' }`. Falls back to the - * Downloads copy if the dialog itself is unavailable. - * - {@link downloadArtifact} (#2779) — copies into the user's Downloads - * directory with a non-colliding name and returns the dest path so the - * UI can offer "Reveal in Finder". + * {@link saveArtifactViaDialog} is kept as the name its callers use, but + * the native Save-As dialog behind it was removed with the shell's `rfd` + * dependency — it now delegates to {@link downloadArtifact}, which was + * already its fallback on any host without an xdg-desktop portal. Callers + * may still branch on `'CANCELLED'`; nothing produces it any more. * * No-ops outside Tauri (browser dev preview) — export only makes sense in * the desktop shell. @@ -258,44 +258,21 @@ export async function downloadArtifact( } /** - * Export an artifact via the native Save-As dialog (#3162), pre-filled - * with the artifact's filename. On the user dismissing the dialog, - * returns `{ ok: false, code: 'CANCELLED' }` (the caller should treat - * this as a no-op, not an error). If the dialog itself is unavailable — - * e.g. headless Linux with no xdg-desktop portal — falls back to the - * Downloads copy so the artifact is still recoverable. + * Export an artifact under its own filename. + * + * Historically (#3162) this opened a native Save-As dialog and fell back + * to the Downloads copy when no dialog was available. The dialog is gone + * along with the shell's `rfd` dependency, so the fallback is now the only + * path: the artifact lands in Downloads and the caller offers "Reveal in + * Finder". Kept as a named export because `ArtifactCard` calls it, and + * because a real destination picker may return here later. */ export async function saveArtifactViaDialog( artifactId: string, fallbackTitle: string, extension: string ): Promise { - if (!isTauri()) { - return { ok: false, code: 'NOT_DESKTOP', error: 'Saving is only available in the desktop app' }; - } - - const resolved = await resolveArtifactForExport(artifactId, fallbackTitle, extension); - if (!resolved.ok) { - return { ok: false, code: resolved.code, error: resolved.error }; - } - - try { - // Command returns the saved path, or `null` when the user cancelled. - const dest = await invoke('save_artifact_via_dialog', { - sourcePath: resolved.sourcePath, - suggestedFilename: resolved.filename, - }); - if (dest == null) { - return { ok: false, code: 'CANCELLED', error: 'save cancelled by user' }; - } - return { ok: true, path: dest }; - } catch (err) { - // Dialog unavailable (no portal / unsupported) — recover the artifact - // via the Downloads copy rather than stranding the user. - const reason = err instanceof Error ? err.message : String(err); - console.warn('[artifact] save dialog failed, falling back to Downloads:', reason); - return downloadArtifact(artifactId, fallbackTitle, extension); - } + return downloadArtifact(artifactId, fallbackTitle, extension); } /** From 4c6441f33191f94548e7d62496bc1b1b275363cd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:33:24 +0300 Subject: [PATCH 05/44] test(artifact-download): update tests for downloads-only artifact saving Adjust artifact download tests to reflect the removal of the save dialog command, verify Downloads routing, and cover copy failures without invoking the shell. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../__tests__/artifactDownloadService.test.ts | 43 ++++++------------- 1 file changed, 12 insertions(+), 31 deletions(-) diff --git a/app/src/services/__tests__/artifactDownloadService.test.ts b/app/src/services/__tests__/artifactDownloadService.test.ts index 1836ab96537..74123888850 100644 --- a/app/src/services/__tests__/artifactDownloadService.test.ts +++ b/app/src/services/__tests__/artifactDownloadService.test.ts @@ -437,7 +437,7 @@ describe('revealArtifactInFileManager', () => { }); }); -describe('saveArtifactViaDialog (#3162)', () => { +describe('saveArtifactViaDialog (dialog removed with rfd)', () => { beforeEach(() => { vi.clearAllMocks(); hoisted.isTauri.mockReturnValue(true); @@ -462,46 +462,27 @@ describe('saveArtifactViaDialog (#3162)', () => { expect(callCoreRpc).not.toHaveBeenCalled(); }); - it('saves to the user-chosen path and returns it', async () => { + it('routes to the Downloads copy rather than a dialog command', async () => { resolveOk(); - hoisted.invoke.mockResolvedValueOnce('/Users/me/Desktop/Deck.pptx'); + hoisted.invoke.mockResolvedValueOnce('/Users/me/Downloads/Deck.pptx'); const outcome = await saveArtifactViaDialog('a-1', 'Deck', 'pptx'); - expect(outcome).toEqual({ ok: true, path: '/Users/me/Desktop/Deck.pptx' }); - expect(hoisted.invoke).toHaveBeenCalledWith('save_artifact_via_dialog', { + expect(outcome).toEqual({ ok: true, path: '/Users/me/Downloads/Deck.pptx' }); + expect(hoisted.invoke).toHaveBeenCalledWith('download_artifact_to_downloads', { sourcePath: '/ws/artifacts/a-1/deck.pptx', - suggestedFilename: 'Deck.pptx', + filename: 'Deck.pptx', }); + // The removed `rfd` command must never be invoked again. + expect(hoisted.invoke).not.toHaveBeenCalledWith('save_artifact_via_dialog', expect.anything()); }); - it('treats a null result (dialog dismissed) as CANCELLED, not an error', async () => { - resolveOk(); - hoisted.invoke.mockResolvedValueOnce(null); - const outcome = await saveArtifactViaDialog('a-1', 'Deck', 'pptx'); - expect(outcome).toEqual({ ok: false, code: 'CANCELLED', error: expect.any(String) }); - }); - - it('falls back to the Downloads copy when the dialog is unavailable', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - // First resolve (dialog path) → invoke throws; fallback re-resolves - // then invokes the Downloads command successfully. + it('surfaces a copy failure as DOWNLOAD_FAILED', async () => { resolveOk(); - hoisted.invoke.mockRejectedValueOnce(new Error('no portal')); - vi.mocked(callCoreRpc).mockResolvedValueOnce({ - absolute_path: '/ws/artifacts/a-1/deck.pptx', - meta: { id: 'a-1', title: 'Deck' }, - } as never); - hoisted.invoke.mockResolvedValueOnce('/Users/me/Downloads/Deck.pptx'); - + hoisted.invoke.mockRejectedValueOnce(new Error('disk full')); const outcome = await saveArtifactViaDialog('a-1', 'Deck', 'pptx'); - expect(outcome).toEqual({ ok: true, path: '/Users/me/Downloads/Deck.pptx' }); - expect(hoisted.invoke).toHaveBeenNthCalledWith(2, 'download_artifact_to_downloads', { - sourcePath: '/ws/artifacts/a-1/deck.pptx', - filename: 'Deck.pptx', - }); - warn.mockRestore(); + expect(outcome).toEqual({ ok: false, code: 'DOWNLOAD_FAILED', error: 'disk full' }); }); - it('propagates resolve failures without showing a dialog', async () => { + it('propagates resolve failures without invoking the shell', async () => { vi.mocked(callCoreRpc).mockRejectedValueOnce(new Error('rpc down')); const outcome = await saveArtifactViaDialog('a-1', 'Deck', 'pptx'); expect(outcome).toEqual({ ok: false, code: 'RESOLVE_FAILED', error: 'rpc down' }); From 8967e44b1bccc752ccdede7922a63653eba36cae Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:34:00 +0300 Subject: [PATCH 06/44] chore: files changed app/src-tauri/src/lib.rs,app/src-tauri/src/whatsapp_data/global.rs,app/src-taur Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src-tauri/src/lib.rs | 5 - app/src-tauri/src/whatsapp_data/global.rs | 91 -- app/src-tauri/src/whatsapp_data/mod.rs | 146 --- app/src-tauri/src/whatsapp_data/ops.rs | 198 --- .../src/whatsapp_data/sqlite_retry.rs | 240 ---- app/src-tauri/src/whatsapp_data/store.rs | 1100 ----------------- .../src/whatsapp_data/store_tests.rs | 731 ----------- src/core/legacy_aliases.rs | 10 +- src/openhuman/channels/mod.rs | 7 +- .../channels/whatsapp_data/README.md | 54 - src/openhuman/channels/whatsapp_data/mod.rs | 42 - src/openhuman/channels/whatsapp_data/tools.rs | 46 - .../whatsapp_data/tools/list_chats.rs | 154 --- .../whatsapp_data/tools/list_messages.rs | 168 --- .../whatsapp_data/tools/search_messages.rs | 156 --- src/openhuman/channels/whatsapp_data/types.rs | 134 -- src/openhuman/security/policy/types.rs | 4 + src/openhuman/tools/mod.rs | 2 - src/openhuman/tools/ops.rs | 16 - 19 files changed, 9 insertions(+), 3295 deletions(-) delete mode 100644 app/src-tauri/src/whatsapp_data/global.rs delete mode 100644 app/src-tauri/src/whatsapp_data/mod.rs delete mode 100644 app/src-tauri/src/whatsapp_data/ops.rs delete mode 100644 app/src-tauri/src/whatsapp_data/sqlite_retry.rs delete mode 100644 app/src-tauri/src/whatsapp_data/store.rs delete mode 100644 app/src-tauri/src/whatsapp_data/store_tests.rs delete mode 100644 src/openhuman/channels/whatsapp_data/README.md delete mode 100644 src/openhuman/channels/whatsapp_data/mod.rs delete mode 100644 src/openhuman/channels/whatsapp_data/tools.rs delete mode 100644 src/openhuman/channels/whatsapp_data/tools/list_chats.rs delete mode 100644 src/openhuman/channels/whatsapp_data/tools/list_messages.rs delete mode 100644 src/openhuman/channels/whatsapp_data/tools/search_messages.rs delete mode 100644 src/openhuman/channels/whatsapp_data/types.rs diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 1eaf7d9bb3d..77aafb8ef47 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -73,7 +73,6 @@ mod ptt_overlay; #[cfg(target_os = "windows")] mod reset_reboot_schedule; mod stderr_panic_hook; -mod whatsapp_data; mod window_state; mod workspace_paths; @@ -2946,7 +2945,6 @@ pub fn run() { // in-process native handlers so the core agent tools (list/search) // and the scanner ingest path can reach the SQLite store over the // native request bus. No handler = graceful degradation core-side. - whatsapp_data::register_native_handlers(); #[cfg(windows)] { @@ -3340,9 +3338,6 @@ pub fn run() { // and the Save-As fallback needs it there (CodeRabbit on #4127). artifact_commands::download_artifact_to_downloads, // Structured WhatsApp data (store lives shell-side). - whatsapp_data::whatsapp_data_list_chats, - whatsapp_data::whatsapp_data_list_messages, - whatsapp_data::whatsapp_data_search_messages, check_core_update, apply_core_update, check_app_update, diff --git a/app/src-tauri/src/whatsapp_data/global.rs b/app/src-tauri/src/whatsapp_data/global.rs deleted file mode 100644 index e7e7a542f16..00000000000 --- a/app/src-tauri/src/whatsapp_data/global.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Process-global WhatsApp data store singleton. -//! -//! One workspace-bound `WhatsAppDataStore` is active at a time, shared by -//! native handlers, scanners, and Tauri commands. When the active workspace -//! changes without relaunching the shell, the singleton is reopened for the new -//! path so user data cannot leak across sessions. -//! -//! # Usage -//! -//! ```ignore -//! // At startup: -//! whatsapp_data::global::init(workspace_dir)?; -//! -//! // In RPC handlers: -//! let store = whatsapp_data::global::store()?; -//! ``` - -use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; - -use super::store::WhatsAppDataStore; - -/// Shared, thread-safe reference to the store. -pub type WhatsAppDataStoreRef = Arc; - -// `RwLock>` rather than `OnceLock` so tests can swap workspaces -// between runs (each test uses its own temp dir; without reset, the second -// test would attach to a dropped sqlite path). Production callers still get -// strict idempotency: `init` is a no-op once a store is set. -struct WorkspaceStore { - workspace_dir: PathBuf, - store: WhatsAppDataStoreRef, -} - -static GLOBAL_STORE: RwLock> = RwLock::new(None); - -/// Initialise the global store for `workspace_dir`. -/// -/// Reuses the current instance only when it is bound to the same workspace. -/// A different path atomically replaces it with a freshly opened store. -pub fn init(workspace_dir: PathBuf) -> Result { - let mut guard = GLOBAL_STORE - .write() - .map_err(|e| format!("[whatsapp_data:global] write lock poisoned: {e}"))?; - if let Some(existing) = guard - .as_ref() - .filter(|entry| same_workspace(&entry.workspace_dir, &workspace_dir)) - { - log::debug!("[whatsapp_data:global] already initialised"); - return Ok(Arc::clone(&existing.store)); - } - log::info!( - "[whatsapp_data:global] opening store workspace={}", - workspace_dir.display() - ); - let store = Arc::new( - WhatsAppDataStore::new(&workspace_dir) - .map_err(|e| format!("[whatsapp_data] store init failed: {e}"))?, - ); - *guard = Some(WorkspaceStore { - workspace_dir, - store: Arc::clone(&store), - }); - Ok(store) -} - -fn same_workspace(current: &Path, requested: &Path) -> bool { - current == requested -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn init_reuses_same_workspace_and_reopens_when_workspace_changes() { - let first_dir = tempfile::tempdir().unwrap(); - let second_dir = tempfile::tempdir().unwrap(); - - let first = init(first_dir.path().to_path_buf()).unwrap(); - let same = init(first_dir.path().to_path_buf()).unwrap(); - assert!(Arc::ptr_eq(&first, &same)); - - let second = init(second_dir.path().to_path_buf()).unwrap(); - assert!(!Arc::ptr_eq(&first, &second)); - assert!(second_dir - .path() - .join("whatsapp_data/whatsapp_data.db") - .exists()); - } -} diff --git a/app/src-tauri/src/whatsapp_data/mod.rs b/app/src-tauri/src/whatsapp_data/mod.rs deleted file mode 100644 index 4dd092d6bf3..00000000000 --- a/app/src-tauri/src/whatsapp_data/mod.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Shell-side structured WhatsApp Web data store (relocated from the core). -//! -//! Owns the SQLite persistence (`store`), the ingest + list/search business -//! logic (`ops`), the busy/corrupt retry layer (`sqlite_retry`), and a -//! process-global store singleton (`global`). The DB lives at -//! `/whatsapp_data/whatsapp_data.db` — the same workspace the -//! core resolves via [`Config`], so the file stays on the internal-path denylist -//! (`security::policy` `WORKSPACE_INTERNAL_DIRS`) and agent tools cannot write it. -//! -//! Two callers reach this store: -//! -//! - **The scanner** (`whatsapp_scanner`) writes via the `whatsapp_data.ingest` -//! native request (see [`register_native_handlers`]). -//! - **The core agent tools** (`openhuman::channels::whatsapp_data::tools`) query via the -//! `whatsapp_data.{list_chats,list_messages,search_messages}` native requests. -//! - **The frontend** reads via the Tauri commands -//! [`whatsapp_data_list_chats`] / [`whatsapp_data_list_messages`] / -//! [`whatsapp_data_search_messages`]. -//! -//! The shared DTOs (request/response/row types) are defined once in the core -//! crate (`openhuman_core::openhuman::channels::whatsapp_data::types`) so both sides agree -//! on a single definition and the native-request `TypeId` checks line up. - -mod global; -mod ops; -mod sqlite_retry; -mod store; - -use std::sync::Arc; - -use openhuman_core::openhuman::channels::whatsapp_data::methods; -use openhuman_core::openhuman::channels::whatsapp_data::types::{ - IngestRequest, IngestResult, ListChatsRequest, ListMessagesRequest, SearchMessagesRequest, - WhatsAppChat, WhatsAppMessage, -}; -use store::WhatsAppDataStore; - -/// Lazily open (or return) the shell's whatsapp_data store, bound to the active -/// OpenHuman workspace dir. -/// -/// The store is process-global so the scanner, native query handlers, and Tauri -/// commands share one write lock and recovery lifecycle. Every call resolves -/// the active workspace; [`global::init`] reuses the store only when that path -/// still matches, and atomically reopens it after user/reset workspace changes. -pub async fn ensure_store() -> Result, String> { - let cfg = openhuman_core::openhuman::config::Config::load_or_init() - .await - .map_err(|e| format!("[whatsapp_data] config load failed: {e:#}"))?; - log::debug!( - "[whatsapp_data] ensuring shell store (workspace={})", - cfg.workspace_dir.display() - ); - global::init(cfg.workspace_dir.clone()) -} - -/// Register the in-process native request handlers that bridge the core (agent -/// tools + scanner) to this shell store. Call once during Tauri `setup`. -/// -/// Keyed by the method-name constants the core owns -/// (`openhuman_core::openhuman::channels::whatsapp_data::methods`) so the two sides never -/// drift on the string key. -pub fn register_native_handlers() { - use openhuman_core::core::bus::BUS; - - BUS.native() - .register::, _, _>( - methods::LIST_CHATS, - |req| async move { - let store = ensure_store().await?; - ops::list_chats(&store, req).map_err(|e| format!("{e:#}")) - }, - ); - BUS.native() - .register::, _, _>( - methods::LIST_MESSAGES, - |req| async move { - let store = ensure_store().await?; - ops::list_messages(&store, req).map_err(|e| format!("{e:#}")) - }, - ); - BUS.native() - .register::, _, _>( - methods::SEARCH_MESSAGES, - |req| async move { - let store = ensure_store().await?; - ops::search_messages(&store, req).map_err(|e| format!("{e:#}")) - }, - ); - BUS.native() - .register::(methods::INGEST, |req| async move { - let store = ensure_store().await?; - // `{e:#}` renders the full anyhow chain so the underlying SQLite - // cause (locked / malformed / FK) survives to the scanner's log. - ops::ingest(&store, req).map_err(|e| format!("[whatsapp_data] ingest failed: {e:#}")) - }); - log::info!( - "[whatsapp_data] registered shell native handlers (list_chats / list_messages / search_messages / ingest)" - ); -} - -// ── Tauri commands (frontend read surface) ─────────────────────────────────── - -/// List locally-stored WhatsApp chats. Frontend replacement for the former -/// `openhuman.whatsapp_data_list_chats` core RPC. -#[tauri::command] -pub async fn whatsapp_data_list_chats(req: ListChatsRequest) -> Result, String> { - log::debug!( - "[whatsapp_data][cmd] list_chats has_account={} limit={:?} offset={:?}", - req.account_id.is_some(), - req.limit, - req.offset - ); - let store = ensure_store().await?; - ops::list_chats(&store, req).map_err(|e| format!("[whatsapp_data] list_chats failed: {e:#}")) -} - -/// List messages for a chat. Frontend replacement for the former -/// `openhuman.whatsapp_data_list_messages` core RPC. -#[tauri::command] -pub async fn whatsapp_data_list_messages( - req: ListMessagesRequest, -) -> Result, String> { - log::debug!( - "[whatsapp_data][cmd] list_messages has_account={} (chat redacted)", - req.account_id.is_some() - ); - let store = ensure_store().await?; - ops::list_messages(&store, req) - .map_err(|e| format!("[whatsapp_data] list_messages failed: {e:#}")) -} - -/// Full-text search over stored WhatsApp messages. Frontend-facing companion to -/// the agent's `whatsapp_data_search_messages` tool. -#[tauri::command] -pub async fn whatsapp_data_search_messages( - req: SearchMessagesRequest, -) -> Result, String> { - log::debug!( - "[whatsapp_data][cmd] search_messages has_account={} has_chat={}", - req.account_id.is_some(), - req.chat_id.is_some() - ); - let store = ensure_store().await?; - ops::search_messages(&store, req) - .map_err(|e| format!("[whatsapp_data] search_messages failed: {e:#}")) -} diff --git a/app/src-tauri/src/whatsapp_data/ops.rs b/app/src-tauri/src/whatsapp_data/ops.rs deleted file mode 100644 index efdbe331f7c..00000000000 --- a/app/src-tauri/src/whatsapp_data/ops.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Business logic for WhatsApp data ingestion and retrieval. -//! -//! All operations take a `&WhatsAppDataStore` so callers control the store -//! lifetime (shared `Arc` at runtime, fresh instance in tests). - -use anyhow::Result; - -use super::store::WhatsAppDataStore; -use openhuman_core::openhuman::channels::whatsapp_data::types::{ - IngestRequest, IngestResult, ListChatsRequest, ListMessagesRequest, SearchMessagesRequest, - WhatsAppChat, WhatsAppMessage, -}; - -/// Number of seconds in 90 days — the auto-prune horizon. -const PRUNE_HORIZON_SECS: i64 = 90 * 24 * 60 * 60; - -/// Ingest a scanner snapshot: upsert chats and messages, then prune messages -/// older than 90 days. -/// -/// Returns counts for observability / logging at the RPC layer. -pub fn ingest(store: &WhatsAppDataStore, req: IngestRequest) -> Result { - log::debug!( - "[whatsapp_data] ingest start chats={} messages={} (account redacted)", - req.chats.len(), - req.messages.len() - ); - - let chats_upserted = store.upsert_chats(&req.account_id, &req.chats)?; - let messages_upserted = store.upsert_messages(&req.account_id, &req.messages)?; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let cutoff_ts = now - PRUNE_HORIZON_SECS; - let messages_pruned = store.prune_old_messages(cutoff_ts)?; - - let result = IngestResult { - chats_upserted, - messages_upserted, - messages_pruned, - }; - log::debug!( - "[whatsapp_data] ingest done chats_upserted={} messages_upserted={} pruned={} (account redacted)", - result.chats_upserted, - result.messages_upserted, - result.messages_pruned - ); - Ok(result) -} - -/// Return chats from the local store, optionally filtered by account. -pub fn list_chats(store: &WhatsAppDataStore, req: ListChatsRequest) -> Result> { - log::debug!( - "[whatsapp_data] list_chats has_account={} limit={:?} offset={:?}", - req.account_id.is_some(), - req.limit, - req.offset - ); - store.list_chats(&req) -} - -/// Return messages for a chat, with optional time range and pagination. -pub fn list_messages( - store: &WhatsAppDataStore, - req: ListMessagesRequest, -) -> Result> { - log::debug!( - "[whatsapp_data] list_messages has_account={} (chat/account redacted)", - req.account_id.is_some() - ); - store.list_messages(&req) -} - -/// Full-text search over message bodies. -pub fn search_messages( - store: &WhatsAppDataStore, - req: SearchMessagesRequest, -) -> Result> { - log::debug!( - "[whatsapp_data] search_messages has_account={} has_chat={} (query/identifiers redacted)", - req.account_id.is_some(), - req.chat_id.is_some() - ); - store.search_messages(&req) -} - -#[cfg(test)] -mod tests { - use super::*; - use openhuman_core::openhuman::channels::whatsapp_data::types::{ChatMeta, IngestMessage}; - use std::collections::HashMap; - use tempfile::tempdir; - - fn make_store() -> (WhatsAppDataStore, tempfile::TempDir) { - let tmp = tempdir().expect("tempdir"); - let store = WhatsAppDataStore::new(tmp.path()).expect("store"); - (store, tmp) - } - - fn sample_request() -> IngestRequest { - // Use a timestamp close to "now" so messages are not pruned by the - // 90-day auto-prune horizon. We derive it from the system clock - // minus one hour so even on slow CI boxes the message is comfortably - // within the retention window. - let recent_ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64 - 3600) - .unwrap_or(1_750_000_000); - let mut chats = HashMap::new(); - chats.insert( - "alice@c.us".to_string(), - ChatMeta { - name: Some("Alice".to_string()), - }, - ); - IngestRequest { - account_id: "acct1".to_string(), - chats, - messages: vec![IngestMessage { - message_id: "msg1".to_string(), - chat_id: "alice@c.us".to_string(), - sender: Some("Alice".to_string()), - sender_jid: None, - from_me: Some(false), - body: Some("Hello!".to_string()), - timestamp: Some(recent_ts), - message_type: Some("chat".to_string()), - source: Some("cdp-dom".to_string()), - }], - } - } - - #[test] - fn ingest_returns_correct_counts() { - let (store, _tmp) = make_store(); - let result = ingest(&store, sample_request()).unwrap(); - assert_eq!(result.chats_upserted, 1); - assert_eq!(result.messages_upserted, 1); - } - - #[test] - fn list_chats_after_ingest() { - let (store, _tmp) = make_store(); - ingest(&store, sample_request()).unwrap(); - - let chats = list_chats( - &store, - ListChatsRequest { - account_id: None, - limit: None, - offset: None, - }, - ) - .unwrap(); - assert_eq!(chats.len(), 1); - assert_eq!(chats[0].chat_id, "alice@c.us"); - } - - #[test] - fn list_messages_after_ingest() { - let (store, _tmp) = make_store(); - ingest(&store, sample_request()).unwrap(); - - let msgs = list_messages( - &store, - ListMessagesRequest { - chat_id: "alice@c.us".to_string(), - account_id: None, - since_ts: None, - until_ts: None, - limit: None, - offset: None, - }, - ) - .unwrap(); - assert_eq!(msgs.len(), 1); - assert_eq!(msgs[0].body, "Hello!"); - } - - #[test] - fn search_messages_after_ingest() { - let (store, _tmp) = make_store(); - ingest(&store, sample_request()).unwrap(); - - let results = search_messages( - &store, - SearchMessagesRequest { - query: "Hello".to_string(), - chat_id: None, - account_id: None, - limit: None, - }, - ) - .unwrap(); - assert_eq!(results.len(), 1); - } -} diff --git a/app/src-tauri/src/whatsapp_data/sqlite_retry.rs b/app/src-tauri/src/whatsapp_data/sqlite_retry.rs deleted file mode 100644 index 2673d28871e..00000000000 --- a/app/src-tauri/src/whatsapp_data/sqlite_retry.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! SQLite busy/locked detection and retry-with-backoff for WhatsApp data writes. -//! -//! The configured `busy_timeout` absorbs short waits inside rusqlite; this layer -//! catches residual `SQLITE_BUSY` / `SQLITE_LOCKED` after that window. - -use std::thread; -use std::time::Duration; - -// `anyhow::Error::context` (used in `retry_on_sqlite_busy`) is the inherent -// method on `Error`, not the `Context` trait, so only `Result` is imported. -use anyhow::Result; - -/// Per-connection busy handler window (issue #2077). -pub const BUSY_TIMEOUT: Duration = Duration::from_millis(5000); - -/// Application-level retries after rusqlite's busy handler is exhausted. -const WRITE_RETRY_ATTEMPTS: u32 = 6; -const WRITE_RETRY_BASE_MS: u64 = 25; - -/// Returns true when `err` is transient SQLite write-lock contention. -pub fn is_sqlite_busy(err: &anyhow::Error) -> bool { - if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) = - err.downcast_ref::() - { - return matches!( - sqlite_err.code, - rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked - ); - } - let msg = format!("{err:#}").to_ascii_lowercase(); - msg.contains("database is locked") || msg.contains("database table is locked") -} - -/// Returns true when `err` is a `SQLITE_CORRUPT` malformed-image condition -/// (primary code `DatabaseCorrupt`, code 11) or the closely-related -/// `NotADatabase` (code 26 — the header itself is unreadable). -/// -/// Unlike [`is_sqlite_busy`], a malformed on-disk image is **persistent -/// damage**: the upsert can never succeed, so every scan poll (2–30s) -/// re-opens the dead file, re-hits the error, and re-reports to Sentry — -/// turning one corrupt file into an escalating flood (Sentry TAURI-RUST-KNH: -/// 1,813 events from a single host). Detecting it here is what lets the store -/// drive its quarantine + rebuild recovery instead of retrying forever. -/// -/// Matching on the error **code** is rusqlite-version-stable. The text -/// fallback covers the case where the rusqlite error was flattened to a plain -/// `anyhow!` string across `.context()` layers — SQLite renders these as -/// "database disk image is malformed" (code 11) and "file is not a database" -/// (code 26). -pub fn is_sqlite_corrupt(err: &anyhow::Error) -> bool { - if let Some(rusqlite::Error::SqliteFailure(sqlite_err, _)) = - err.downcast_ref::() - { - if matches!( - sqlite_err.code, - rusqlite::ErrorCode::DatabaseCorrupt | rusqlite::ErrorCode::NotADatabase - ) { - return true; - } - } - let msg = format!("{err:#}").to_ascii_lowercase(); - msg.contains("disk image is malformed") || msg.contains("file is not a database") -} - -/// Run `f` up to [`WRITE_RETRY_ATTEMPTS`] times when SQLite reports busy/locked. -pub fn retry_on_sqlite_busy(op_name: &str, mut f: F) -> Result -where - F: FnMut() -> Result, -{ - let mut last_err: Option = None; - - for attempt in 0..WRITE_RETRY_ATTEMPTS { - match f() { - Ok(val) => { - if attempt > 0 { - log::debug!("[whatsapp_data] {op_name} succeeded after {attempt} busy retries"); - } - return Ok(val); - } - Err(e) => { - if !is_sqlite_busy(&e) { - return Err(e); - } - if attempt + 1 == WRITE_RETRY_ATTEMPTS { - last_err = Some(e); - break; - } - let sleep_ms = WRITE_RETRY_BASE_MS - .saturating_mul(2u64.saturating_pow(attempt)) - .min(500); - log::warn!( - "[whatsapp_data] {op_name} SQLite busy/locked \ - (attempt {} of {WRITE_RETRY_ATTEMPTS}), retry in {sleep_ms}ms: {e:#}", - attempt + 1, - ); - thread::sleep(Duration::from_millis(sleep_ms)); - } - } - } - - Err(last_err.expect("WRITE_RETRY_ATTEMPTS > 0").context(format!( - "{op_name} failed after {WRITE_RETRY_ATTEMPTS} SQLite busy retries" - ))) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn is_sqlite_busy_matches_database_busy_code() { - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::DatabaseBusy, - extended_code: 5, - }, - Some("database is locked".into()), - ); - let err = anyhow::Error::from(raw); - assert!(is_sqlite_busy(&err)); - } - - #[test] - fn is_sqlite_busy_does_not_match_constraint_violation() { - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::ConstraintViolation, - extended_code: 19, - }, - Some("UNIQUE constraint failed".into()), - ); - let err = anyhow::Error::from(raw); - assert!(!is_sqlite_busy(&err)); - } - - #[test] - fn is_sqlite_corrupt_matches_database_corrupt_code() { - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::DatabaseCorrupt, - extended_code: 11, - }, - Some("database disk image is malformed".into()), - ); - assert!(is_sqlite_corrupt(&anyhow::Error::from(raw))); - } - - #[test] - fn is_sqlite_corrupt_matches_not_a_database_code() { - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::NotADatabase, - extended_code: 26, - }, - Some("file is not a database".into()), - ); - assert!(is_sqlite_corrupt(&anyhow::Error::from(raw))); - } - - #[test] - fn is_sqlite_corrupt_matches_through_context_layers() { - let raw = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::DatabaseCorrupt, - extended_code: 11, - }, - Some("database disk image is malformed".into()), - ); - let wrapped = anyhow::Error::from(raw) - .context("upsert wa_chat 207897942335683@lid") - .context("[whatsapp_data] ingest failed"); - assert!(is_sqlite_corrupt(&wrapped)); - } - - #[test] - fn is_sqlite_corrupt_text_fallback() { - let err = anyhow::anyhow!( - "[whatsapp_data] ingest failed: upsert wa_chat 207897942335683@lid: \ - database disk image is malformed: Error code 11: The database disk image is malformed" - ); - assert!(is_sqlite_corrupt(&err)); - } - - #[test] - fn is_sqlite_corrupt_does_not_match_busy_or_constraint() { - let busy = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::DatabaseBusy, - extended_code: 5, - }, - Some("database is locked".into()), - ); - assert!(!is_sqlite_corrupt(&anyhow::Error::from(busy))); - - let constraint = rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::ConstraintViolation, - extended_code: 19, - }, - Some("UNIQUE constraint failed".into()), - ); - assert!(!is_sqlite_corrupt(&anyhow::Error::from(constraint))); - - assert!(!is_sqlite_corrupt(&anyhow::anyhow!( - "upstream returned 500: internal server error" - ))); - } - - #[test] - fn retry_on_sqlite_busy_succeeds_after_transient_busy() { - let mut calls = 0u32; - let result = retry_on_sqlite_busy("test_op", || { - calls += 1; - if calls < 3 { - Err(anyhow::Error::from(rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error { - code: rusqlite::ErrorCode::DatabaseBusy, - extended_code: 5, - }, - Some("database is locked".into()), - ))) - } else { - Ok(42usize) - } - }); - assert_eq!(result.unwrap(), 42); - assert_eq!(calls, 3); - } - - #[test] - fn retry_on_sqlite_busy_does_not_retry_non_busy_errors() { - let mut calls = 0u32; - let result: Result<()> = retry_on_sqlite_busy("test_op", || { - calls += 1; - anyhow::bail!("permanent failure"); - }); - assert!(result.is_err()); - assert_eq!(calls, 1); - } -} diff --git a/app/src-tauri/src/whatsapp_data/store.rs b/app/src-tauri/src/whatsapp_data/store.rs deleted file mode 100644 index 889809f319c..00000000000 --- a/app/src-tauri/src/whatsapp_data/store.rs +++ /dev/null @@ -1,1100 +0,0 @@ -//! SQLite-backed persistence for structured WhatsApp Web data. -//! -//! Data is stored in a dedicated `whatsapp_data.db` file inside the -//! workspace directory. Tables: `wa_chats` and `wa_messages`. -//! -//! This store is local-only; no data is transmitted to external services. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Mutex; - -use anyhow::{Context, Result}; -use rusqlite::{params, Connection}; - -use super::sqlite_retry::{is_sqlite_corrupt, retry_on_sqlite_busy, BUSY_TIMEOUT}; -use openhuman_core::openhuman::channels::whatsapp_data::types::{ - ChatMeta, IngestMessage, ListChatsRequest, ListMessagesRequest, SearchMessagesRequest, - WhatsAppChat, WhatsAppMessage, -}; - -/// Process-wide latch so a `SQLITE_CORRUPT` flood is reported to Sentry -/// **once**, not on every scan tick. The whatsapp_scanner re-ingests every -/// 2–30s, so a wedged DB re-hits the malformed-image error on each poll — one -/// corrupt file produced 1,813 escalating Sentry events from a single host -/// (TAURI-RUST-KNH). Set on the first detection; cleared once a recovery -/// attempt settles (quarantine + rebuild, or a quick_check that now passes) so -/// a genuinely-new, later corruption can still page exactly once. -static CORRUPT_REPORTED: AtomicBool = AtomicBool::new(false); - -/// Test-only override: when set, [`WhatsAppDataStore::integrity_check_ok`] -/// reports the (rebuilt) DB as failing its integrity check. This is the seam -/// that lets tests drive the "rebuild still fails integrity_check" branch of -/// [`WhatsAppDataStore::recover_corrupt_db`] without forging a file that both -/// survives quarantine and yet fails `PRAGMA integrity_check`. -#[cfg(test)] -static FORCE_INTEGRITY_CHECK_FAIL: AtomicBool = AtomicBool::new(false); - -/// Test-only serialization guard for the recovery-episode tests. `CORRUPT_REPORTED` -/// and `FORCE_INTEGRITY_CHECK_FAIL` are process-wide, so tests that mutate them -/// must not run concurrently or they clobber each other's latch observations. -#[cfg(test)] -static CORRUPT_TEST_GUARD: Mutex<()> = Mutex::new(()); - -/// SQLite-backed store for WhatsApp chats and messages. -pub struct WhatsAppDataStore { - db_path: std::path::PathBuf, - /// Serializes write paths (upsert + prune) so concurrent ingest RPCs do not - /// open competing writers on the same `whatsapp_data.db` file. - write_lock: Mutex<()>, -} - -impl WhatsAppDataStore { - /// Open or create the `whatsapp_data.db` SQLite database in `workspace_dir`. - /// The directory (and any parents) are created if they do not exist. - pub fn new(workspace_dir: &Path) -> Result { - let db_path = workspace_dir.join("whatsapp_data").join("whatsapp_data.db"); - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create whatsapp_data dir: {}", parent.display()))?; - } - log::debug!("[whatsapp_data] opening store at {}", db_path.display()); - let store = Self { - db_path, - write_lock: Mutex::new(()), - }; - store.init_schema_or_recover()?; - Ok(store) - } - - /// Initialize the schema, self-healing a DB that is already corrupt **at - /// process startup**. - /// - /// Without this, a `whatsapp_data.db` that is malformed before the first - /// write RPC arrives makes `init_schema()` fail, `global::init` leaves the - /// store singleton unset, and every subsequent ingest RPC fails with - /// "store accessed before init" — the corruption never reaches the - /// quarantine + rebuild path (which only guards the *write* wrapper), so it - /// survives restarts and re-pages Sentry on every scanner tick. Recovering - /// here makes a boot-time corrupt DB heal exactly as a mid-run one does. - /// - /// The `CORRUPT_REPORTED` latch semantics are reused verbatim via - /// [`Self::report_and_recover`] (report once per episode, reset only on a - /// confirmed-healthy rebuild), so a boot-time corruption pages at most once. - fn init_schema_or_recover(&self) -> Result<()> { - match self.init_schema() { - Ok(()) => Ok(()), - Err(e) if is_sqlite_corrupt(&e) => { - log::error!( - "[whatsapp_data] init_schema hit SQLITE_CORRUPT at startup for {} — \ - driving quarantine + rebuild before ingest can begin: {e:#}", - self.db_path.display() - ); - // Reports once (latch), quarantines the malformed image, and - // rebuilds the schema. `recover_corrupt_db` is safe to call on - // the just-constructed store: it only needs `db_path` + - // `init_schema`, both available before the store is published. - self.report_and_recover("init_schema", &e); - // Confirm the store is now usable. If recovery failed, this - // re-init returns Err and `global::init` correctly leaves the - // singleton unset — but now only when the DB is genuinely - // unrecoverable, not merely corrupt-on-boot. - self.init_schema() - .context("re-init whatsapp_data schema after boot-time corrupt-DB recovery") - } - Err(e) => Err(e), - } - } - - /// Initialize the schema. Idempotent — safe to call on every startup. - fn init_schema(&self) -> Result<()> { - let conn = self.open_conn()?; - conn.execute_batch( - "PRAGMA journal_mode = WAL; - PRAGMA foreign_keys = ON; - - CREATE TABLE IF NOT EXISTS wa_chats ( - account_id TEXT NOT NULL, - chat_id TEXT NOT NULL, - display_name TEXT NOT NULL DEFAULT '', - is_group INTEGER NOT NULL DEFAULT 0, - last_message_ts INTEGER NOT NULL DEFAULT 0, - message_count INTEGER NOT NULL DEFAULT 0, - updated_at INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (account_id, chat_id) - ); - - CREATE TABLE IF NOT EXISTS wa_messages ( - account_id TEXT NOT NULL, - chat_id TEXT NOT NULL, - message_id TEXT NOT NULL, - sender TEXT NOT NULL DEFAULT '', - sender_jid TEXT, - from_me INTEGER NOT NULL DEFAULT 0, - body TEXT NOT NULL DEFAULT '', - timestamp INTEGER NOT NULL DEFAULT 0, - message_type TEXT, - source TEXT NOT NULL DEFAULT '', - ingested_at INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (account_id, chat_id, message_id) - ); - CREATE INDEX IF NOT EXISTS idx_wa_msg_ts ON wa_messages(account_id, chat_id, timestamp); - CREATE INDEX IF NOT EXISTS idx_wa_msg_body ON wa_messages(account_id, body);", - ) - .context("init whatsapp_data schema")?; - log::debug!("[whatsapp_data] schema ready"); - Ok(()) - } - - /// Open a fresh connection to the DB file. - /// - /// Read paths (`list_chats` / `list_messages` / `search_messages`) call this - /// **without** taking `write_lock`, so a read can race the brief file-rename - /// window inside [`Self::recover_corrupt_db`] (which quarantines the corrupt - /// image then rebuilds under `write_lock`). We deliberately do NOT serialize - /// reads behind the write lock: the race is not a correctness or - /// data-integrity problem, only a rare transient read error that self-heals - /// on the next poll. Three outcomes are possible and all are safe — - /// (1) the read opens before the rename and reads valid (old) data via its - /// still-live fd; (2) it opens after the rebuild and reads the fresh schema; - /// (3) it opens in the sub-millisecond gap after the rename but before the - /// rebuild, gets an empty auto-created file, and returns a benign - /// "no such table" error the caller retries. Guarding this hot path with a - /// global read/write lock would trade that vanishingly-rare transient for - /// permanent read/write contention on every list/search — not worth it. - fn open_conn(&self) -> Result { - let conn = Connection::open(&self.db_path) - .with_context(|| format!("open whatsapp_data db: {}", self.db_path.display()))?; - Self::configure_connection(&conn)?; - Ok(conn) - } - - /// Per-connection pragmas: busy handler + WAL (idempotent on existing DBs). - fn configure_connection(conn: &Connection) -> Result<()> { - conn.busy_timeout(BUSY_TIMEOUT) - .context("configure whatsapp_data busy_timeout")?; - if let Err(wal_err) = conn.execute_batch("PRAGMA journal_mode=WAL;") { - log::warn!( - "[whatsapp_data] failed to enable WAL (filesystem may not support it): {wal_err}" - ); - } - Ok(()) - } - - #[cfg(test)] - pub(crate) fn open_conn_for_test(&self) -> Result { - self.open_conn() - } - - fn now_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) - } - - /// Run a write op through the busy-retry loop, and on a confirmed - /// `SQLITE_CORRUPT` malformed-image error drive a **once-per-call** - /// quarantine + rebuild recovery, then retry the op a single time against - /// the rebuilt schema. - /// - /// The recovery is bounded: at most one quarantine attempt and one retry - /// per public write call. A genuinely unrecoverable file therefore returns - /// the corrupt error to the caller after one recovery pass instead of - /// spinning — the process-wide report latch (`report_and_recover`) then - /// keeps Sentry from re-flooding on every scan tick. - fn write_with_corrupt_recovery( - &self, - op_name: &str, - f: impl Fn() -> Result, - ) -> Result { - match retry_on_sqlite_busy(op_name, &f) { - Ok(val) => Ok(val), - Err(e) if is_sqlite_corrupt(&e) => { - self.report_and_recover(op_name, &e); - // Retry once against the (now rebuilt) DB. If this still fails, - // the error propagates — no further recovery, so a wedged - // filesystem can't loop. - retry_on_sqlite_busy(op_name, &f) - } - Err(e) => Err(e), - } - } - - /// Report a confirmed `SQLITE_CORRUPT` failure to Sentry and drive the - /// quarantine + rebuild recovery. Factored out of - /// [`Self::write_with_corrupt_recovery`] so the report + recovery decision - /// is unit-testable without a live scanner loop. - fn report_and_recover(&self, op_name: &str, err: &anyhow::Error) { - // Report to Sentry at most once per corruption episode. Without this - // latch the scanner's 2–30s poll re-hits the wedged DB and re-pages on - // every tick (TAURI-RUST-KNH: 1,813 events from one host). - if !CORRUPT_REPORTED.swap(true, Ordering::Relaxed) { - openhuman_core::core::observability::report_error( - err, - "whatsapp_data", - "ingest_corrupt", - &[("op", op_name)], - ); - } - log::error!( - "[whatsapp_data] {op_name} hit SQLITE_CORRUPT (malformed DB image), \ - attempting quarantine + rebuild recovery: {err:#}" - ); - match self.recover_corrupt_db() { - Ok(true) => { - log::warn!( - "[whatsapp_data] {op_name} quarantined corrupt DB and rebuilt empty schema; \ - ingest will resume" - ); - // Recovery settled — allow a future, genuinely-new corruption - // to page once more. - CORRUPT_REPORTED.store(false, Ordering::Relaxed); - } - Ok(false) => { - log::info!( - "[whatsapp_data] {op_name} corruption recovery: integrity check now passes, \ - no quarantine needed" - ); - CORRUPT_REPORTED.store(false, Ordering::Relaxed); - } - Err(rec_err) => log::error!( - "[whatsapp_data] {op_name} corruption recovery FAILED, ingest stays degraded: \ - {rec_err:#}" - ), - } - } - - /// Recover from a `SQLITE_CORRUPT` (malformed image) on the whatsapp_data DB. - /// - /// A malformed on-disk image never heals on its own — every write fails - /// forever and the scanner re-pages Sentry on each poll (Sentry - /// TAURI-RUST-KNH: 1,813 events from a single host). This quarantines the - /// damaged file (and its WAL/SHM side-files) to a timestamped - /// `.corrupt-` copy — **preserved, not deleted**, so the bytes can be - /// inspected or salvaged — then rebuilds an empty schema so ingest resumes. - /// - /// Returns `Ok(true)` when a quarantine + rebuild happened, `Ok(false)` - /// when a fresh `PRAGMA quick_check` now passes (the earlier failure was - /// transient and quarantining would have destroyed good data), and `Err` - /// when the quarantine rename or the schema rebuild failed. - /// - /// The store opens a fresh `Connection` per call (no cached handle), so no - /// connection needs to be dropped before the rename — the next `open_conn` - /// naturally picks up the rebuilt file. - pub(crate) fn recover_corrupt_db(&self) -> Result { - // 1. Re-confirm corruption against the on-disk file. `quick_check` is - // the cheap structural scan; if it now reports "ok" the image is - // actually healthy (e.g. the original error was a transient mmap - // fault) and we must NOT destroy good data — bail without quarantine. - if self.db_path.exists() { - match self.quick_check_ok() { - Ok(true) => { - log::info!( - "[whatsapp_data] quick_check passed for {} — no quarantine needed", - self.db_path.display() - ); - return Ok(false); - } - Ok(false) => { - log::warn!( - "[whatsapp_data] quick_check confirms corruption for {}, quarantining", - self.db_path.display() - ); - } - Err(e) => { - // The check couldn't even run (unopenable / unreadable - // header). That is itself a malformed-image signal. - log::warn!( - "[whatsapp_data] quick_check could not run for {} ({e:#}); \ - treating as corrupt", - self.db_path.display() - ); - } - } - } else { - log::warn!( - "[whatsapp_data] corrupt-recovery: {} is missing; rebuilding fresh schema", - self.db_path.display() - ); - } - - // 2. Quarantine the main DB + WAL/SHM side-files to `.corrupt-`. - let ts = Self::now_secs(); - let mut quarantined = 0usize; - for suffix in &["", "-wal", "-shm"] { - let src = with_name_suffix(&self.db_path, suffix); - if !src.exists() { - continue; - } - let dst = with_name_suffix(&src, &format!(".corrupt-{ts}")); - std::fs::rename(&src, &dst).with_context(|| { - format!( - "quarantine corrupt whatsapp_data file {} -> {}", - src.display(), - dst.display() - ) - })?; - log::warn!( - "[whatsapp_data] quarantined {} -> {}", - src.display(), - dst.display() - ); - quarantined += 1; - } - - // 3. Rebuild an empty schema by re-running init on a fresh file. The - // damaged rows are not silently dropped — they live on in the - // `.corrupt-` copy. - self.init_schema() - .context("rebuild whatsapp_data schema after quarantining corrupt DB")?; - - // 4. Confirm the rebuilt image is structurally sound. This result is - // load-bearing: `report_and_recover` only resets the process-wide - // `CORRUPT_REPORTED` latch (and logs "ingest will resume") on - // `Ok(true)`. If the rebuild still fails integrity_check — or the - // check itself can't run — we MUST surface `Err`, otherwise the - // latch resets, re-arming Sentry to page on the next scan tick and - // breaking the report-once-per-episode guarantee this recovery - // exists to protect. - match self.integrity_check_ok() { - Ok(true) => { - log::warn!( - "[whatsapp_data] corruption recovery complete: quarantined {quarantined} file(s), \ - rebuilt empty schema, integrity_check=ok at {}", - self.db_path.display() - ); - Ok(true) - } - Ok(false) => { - log::error!( - "[whatsapp_data] rebuilt DB still fails integrity_check at {}", - self.db_path.display() - ); - Err(anyhow::anyhow!( - "rebuilt whatsapp_data db still fails integrity_check at {}", - self.db_path.display() - )) - } - Err(e) => Err(e.context("integrity_check after rebuild could not run")), - } - } - - /// Run `PRAGMA quick_check(1)` on a fresh, short-lived connection. Returns - /// `Ok(true)` when the structural scan reports `"ok"`, `Ok(false)` on any - /// reported corruption, and `Err` when the check itself can't run (file - /// unopenable / header unreadable — itself a corruption signal the caller - /// treats as malformed). - fn quick_check_ok(&self) -> Result { - let conn = Connection::open(&self.db_path) - .with_context(|| format!("open for quick_check: {}", self.db_path.display()))?; - let _ = conn.busy_timeout(BUSY_TIMEOUT); - let result: String = conn - .query_row("PRAGMA quick_check(1)", [], |row| row.get(0)) - .context("running PRAGMA quick_check")?; - Ok(result.eq_ignore_ascii_case("ok")) - } - - /// Run `PRAGMA integrity_check(1)` against the (rebuilt) DB to confirm it is - /// structurally sound. Returns `Ok(true)` when it reports `"ok"`. - fn integrity_check_ok(&self) -> Result { - #[cfg(test)] - if FORCE_INTEGRITY_CHECK_FAIL.load(Ordering::Relaxed) { - return Ok(false); - } - let conn = self.open_conn()?; - let result: String = conn - .query_row("PRAGMA integrity_check(1)", [], |row| row.get(0)) - .context("running PRAGMA integrity_check")?; - Ok(result.eq_ignore_ascii_case("ok")) - } - - /// Upsert chat metadata rows. Returns the number of rows inserted or updated. - pub fn upsert_chats( - &self, - account_id: &str, - chats: &HashMap, - ) -> Result { - if chats.is_empty() { - return Ok(0); - } - let _write_guard = self - .write_lock - .lock() - .map_err(|e| anyhow::anyhow!("whatsapp_data write lock poisoned: {e}"))?; - self.write_with_corrupt_recovery("upsert_chats", || { - self.upsert_chats_inner(account_id, chats) - }) - } - - fn upsert_chats_inner( - &self, - account_id: &str, - chats: &HashMap, - ) -> Result { - let conn = self.open_conn()?; - let now = Self::now_secs(); - let mut count = 0usize; - for (chat_id, meta) in chats { - let name = meta.name.as_deref().unwrap_or(""); - let is_group = chat_id.ends_with("@g.us") as i64; - conn.execute( - "INSERT INTO wa_chats (account_id, chat_id, display_name, is_group, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(account_id, chat_id) DO UPDATE SET - display_name = CASE WHEN excluded.display_name != '' THEN excluded.display_name ELSE display_name END, - is_group = excluded.is_group, - updated_at = excluded.updated_at", - params![account_id, chat_id, name, is_group, now], - ) - .with_context(|| format!("upsert wa_chat {chat_id}"))?; - count += 1; - } - log::debug!( - "[whatsapp_data] upserted {} chats (account redacted)", - count - ); - Ok(count) - } - - /// Upsert message rows. Returns the number of rows inserted or updated. - pub fn upsert_messages(&self, account_id: &str, msgs: &[IngestMessage]) -> Result { - if msgs.is_empty() { - return Ok(0); - } - let _write_guard = self - .write_lock - .lock() - .map_err(|e| anyhow::anyhow!("whatsapp_data write lock poisoned: {e}"))?; - self.write_with_corrupt_recovery("upsert_messages", || { - self.upsert_messages_inner(account_id, msgs) - }) - } - - fn upsert_messages_inner(&self, account_id: &str, msgs: &[IngestMessage]) -> Result { - let conn = self.open_conn()?; - let now = Self::now_secs(); - let mut count = 0usize; - for m in msgs { - if m.message_id.is_empty() || m.chat_id.is_empty() { - continue; - } - // Persist all messages, including non-text ones (stickers, images, - // system events). Dropping empty-body rows biases message_count - // and last_message_ts to text-only messages, making active chats - // look stale whenever the latest event has no body. - let body = m.body.as_deref().unwrap_or(""); - let ts = m.timestamp.unwrap_or(0); - let from_me = m.from_me.unwrap_or(false) as i64; - conn.execute( - "INSERT INTO wa_messages - (account_id, chat_id, message_id, sender, sender_jid, from_me, - body, timestamp, message_type, source, ingested_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - ON CONFLICT(account_id, chat_id, message_id) DO UPDATE SET - sender = CASE WHEN excluded.sender != '' THEN excluded.sender ELSE sender END, - sender_jid = COALESCE(excluded.sender_jid, sender_jid), - from_me = excluded.from_me, - body = CASE WHEN excluded.body != '' THEN excluded.body ELSE body END, - timestamp = excluded.timestamp, - message_type = COALESCE(excluded.message_type, message_type), - source = excluded.source, - ingested_at = excluded.ingested_at", - params![ - account_id, - m.chat_id, - m.message_id, - m.sender.as_deref().unwrap_or(""), - m.sender_jid.as_deref(), - from_me, - body, - ts, - m.message_type.as_deref(), - m.source.as_deref().unwrap_or(""), - now, - ], - ) - .with_context(|| { - format!( - "upsert wa_message chat={} msg={}", - m.chat_id, m.message_id - ) - })?; - count += 1; - } - - // Refresh chat stats after message upsert. - if count > 0 { - conn.execute( - "UPDATE wa_chats - SET message_count = (SELECT COUNT(*) FROM wa_messages - WHERE wa_messages.account_id = wa_chats.account_id - AND wa_messages.chat_id = wa_chats.chat_id), - last_message_ts = COALESCE( - (SELECT MAX(timestamp) FROM wa_messages - WHERE wa_messages.account_id = wa_chats.account_id - AND wa_messages.chat_id = wa_chats.chat_id), - last_message_ts), - updated_at = ?1 - WHERE account_id = ?2", - rusqlite::params![now, account_id], - ) - .context("refresh wa_chats stats")?; - } - - log::debug!( - "[whatsapp_data] upserted {} messages (account redacted)", - count - ); - Ok(count) - } - - /// Delete messages older than `cutoff_ts` (Unix seconds). Returns the count removed. - /// - /// After the delete, refreshes `wa_chats.message_count` and - /// `last_message_ts` for every chat that lost rows, so `list_chats` - /// returns accurate counts and ordering immediately. - pub fn prune_old_messages(&self, cutoff_ts: i64) -> Result { - let _write_guard = self - .write_lock - .lock() - .map_err(|e| anyhow::anyhow!("whatsapp_data write lock poisoned: {e}"))?; - self.write_with_corrupt_recovery("prune_old_messages", || { - self.prune_old_messages_inner(cutoff_ts) - }) - } - - fn prune_old_messages_inner(&self, cutoff_ts: i64) -> Result { - let conn = self.open_conn()?; - let now = Self::now_secs(); - - // Collect affected (account_id, chat_id) pairs before deleting. - // Contextualized so a `SQLITE_CORRUPT` surfaced while compiling this - // scan still carries a `prune` frame marker the observability - // classifier keys on (otherwise a boot-time prune corruption would - // reach Sentry unfiltered — the prepare of a plain SELECT still reads - // the schema/root page where damage commonly lives). - let mut stmt = conn - .prepare( - "SELECT DISTINCT account_id, chat_id FROM wa_messages - WHERE timestamp > 0 AND timestamp < ?1", - ) - .context("prune old wa_messages: scan affected chats")?; - let affected: Vec<(String, String)> = stmt - .query_map(params![cutoff_ts], |row| Ok((row.get(0)?, row.get(1)?)))? - .collect::>() - .context("collect affected chats for prune")?; - - let changed = conn - .execute( - "DELETE FROM wa_messages WHERE timestamp > 0 AND timestamp < ?1", - params![cutoff_ts], - ) - .context("prune old wa_messages")?; - - // Refresh aggregate stats for every affected chat so list_chats - // reflects the post-prune state immediately. - if changed > 0 { - for (acct, chat_id) in &affected { - conn.execute( - "UPDATE wa_chats - SET message_count = (SELECT COUNT(*) FROM wa_messages - WHERE account_id = wa_chats.account_id - AND chat_id = wa_chats.chat_id), - last_message_ts = COALESCE( - (SELECT MAX(timestamp) FROM wa_messages - WHERE account_id = wa_chats.account_id - AND chat_id = wa_chats.chat_id), - last_message_ts), - updated_at = ?3 - WHERE account_id = ?1 AND chat_id = ?2", - params![acct, chat_id, now], - ) - .with_context(|| format!("refresh chat stats after prune: {chat_id}"))?; - } - log::debug!( - "[whatsapp_data] pruned {} messages (affected {} chats)", - changed, - affected.len() - ); - } - Ok(changed as u64) - } - - /// List chats, optionally filtered by account. Ordered by `last_message_ts` DESC. - pub fn list_chats(&self, req: &ListChatsRequest) -> Result> { - let conn = self.open_conn()?; - let limit = req.limit.unwrap_or(50) as i64; - let offset = req.offset.unwrap_or(0) as i64; - - let chats = if let Some(ref acct) = req.account_id { - let mut stmt = conn.prepare( - "SELECT account_id, chat_id, display_name, is_group, last_message_ts, - message_count, updated_at - FROM wa_chats - WHERE account_id = ?1 - ORDER BY last_message_ts DESC - LIMIT ?2 OFFSET ?3", - )?; - let rows = stmt - .query_map(params![acct, limit, offset], map_chat_row)? - .collect::>>() - .context("list chats (filtered)")?; - rows - } else { - let mut stmt = conn.prepare( - "SELECT account_id, chat_id, display_name, is_group, last_message_ts, - message_count, updated_at - FROM wa_chats - ORDER BY last_message_ts DESC - LIMIT ?1 OFFSET ?2", - )?; - let rows = stmt - .query_map(params![limit, offset], map_chat_row)? - .collect::>>() - .context("list chats (all)")?; - rows - }; - log::debug!("[whatsapp_data] list_chats returned {} rows", chats.len()); - Ok(chats) - } - - /// List messages for a chat, with optional time range and pagination. - pub fn list_messages(&self, req: &ListMessagesRequest) -> Result> { - let conn = self.open_conn()?; - let limit = req.limit.unwrap_or(100) as i64; - let offset = req.offset.unwrap_or(0) as i64; - let since_ts = req.since_ts.unwrap_or(0); - let until_ts = req.until_ts.unwrap_or(i64::MAX); - - let msgs = if let Some(ref acct) = req.account_id { - let mut stmt = conn.prepare( - "SELECT account_id, chat_id, message_id, sender, sender_jid, from_me, - body, timestamp, message_type, source - FROM wa_messages - WHERE account_id = ?1 - AND chat_id = ?2 - AND timestamp >= ?3 - AND timestamp <= ?4 - ORDER BY timestamp ASC - LIMIT ?5 OFFSET ?6", - )?; - let rows = stmt - .query_map( - params![acct, req.chat_id, since_ts, until_ts, limit, offset], - map_message_row, - )? - .collect::>>() - .context("list messages (filtered by account)")?; - rows - } else { - let mut stmt = conn.prepare( - "SELECT account_id, chat_id, message_id, sender, sender_jid, from_me, - body, timestamp, message_type, source - FROM wa_messages - WHERE chat_id = ?1 - AND timestamp >= ?2 - AND timestamp <= ?3 - ORDER BY timestamp ASC - LIMIT ?4 OFFSET ?5", - )?; - let rows = stmt - .query_map( - params![req.chat_id, since_ts, until_ts, limit, offset], - map_message_row, - )? - .collect::>>() - .context("list messages (all accounts)")?; - rows - }; - log::debug!( - "[whatsapp_data] list_messages returned {} rows (chat/account redacted)", - msgs.len() - ); - Ok(msgs) - } - - /// Full-text search over message bodies (case-insensitive LIKE). - pub fn search_messages(&self, req: &SearchMessagesRequest) -> Result> { - if req.query.trim().is_empty() { - return Ok(vec![]); - } - let conn = self.open_conn()?; - let limit = req.limit.unwrap_or(20) as i64; - let pattern = format!("%{}%", req.query.replace('%', "\\%").replace('_', "\\_")); - - // Match against both `body` and `sender` so person-name queries like - // "what did Alice say" surface Alice's messages even when "Alice" - // does not appear in any message body. Branches are kept explicit so - // the bind indices stay readable; each `pattern` bind is duplicated - // because rusqlite does not resolve same-named placeholders for us. - let msgs: Vec = match (&req.account_id, &req.chat_id) { - (Some(acct), Some(chat_id)) => { - let mut stmt = conn.prepare( - "SELECT account_id, chat_id, message_id, sender, sender_jid, from_me, - body, timestamp, message_type, source - FROM wa_messages - WHERE account_id = ?1 - AND chat_id = ?2 - AND (body LIKE ?3 ESCAPE '\\' OR sender LIKE ?3 ESCAPE '\\') - ORDER BY timestamp DESC - LIMIT ?4", - )?; - let rows = stmt - .query_map(params![acct, chat_id, pattern, limit], map_message_row)? - .collect::>>() - .context("search messages (account+chat)")?; - rows - } - (Some(acct), None) => { - let mut stmt = conn.prepare( - "SELECT account_id, chat_id, message_id, sender, sender_jid, from_me, - body, timestamp, message_type, source - FROM wa_messages - WHERE account_id = ?1 - AND (body LIKE ?2 ESCAPE '\\' OR sender LIKE ?2 ESCAPE '\\') - ORDER BY timestamp DESC - LIMIT ?3", - )?; - let rows = stmt - .query_map(params![acct, pattern, limit], map_message_row)? - .collect::>>() - .context("search messages (account)")?; - rows - } - (None, Some(chat_id)) => { - let mut stmt = conn.prepare( - "SELECT account_id, chat_id, message_id, sender, sender_jid, from_me, - body, timestamp, message_type, source - FROM wa_messages - WHERE chat_id = ?1 - AND (body LIKE ?2 ESCAPE '\\' OR sender LIKE ?2 ESCAPE '\\') - ORDER BY timestamp DESC - LIMIT ?3", - )?; - let rows = stmt - .query_map(params![chat_id, pattern, limit], map_message_row)? - .collect::>>() - .context("search messages (chat)")?; - rows - } - (None, None) => { - let mut stmt = conn.prepare( - "SELECT account_id, chat_id, message_id, sender, sender_jid, from_me, - body, timestamp, message_type, source - FROM wa_messages - WHERE body LIKE ?1 ESCAPE '\\' OR sender LIKE ?1 ESCAPE '\\' - ORDER BY timestamp DESC - LIMIT ?2", - )?; - let rows = stmt - .query_map(params![pattern, limit], map_message_row)? - .collect::>>() - .context("search messages (all)")?; - rows - } - }; - log::debug!( - "[whatsapp_data] search_messages returned {} rows (query/account redacted)", - msgs.len() - ); - Ok(msgs) - } -} - -/// Append `suffix` to the *file name* of `path` (so `whatsapp_data.db` + `-wal` -/// = `whatsapp_data.db-wal`, and `whatsapp_data.db` + `.corrupt-123` = -/// `whatsapp_data.db.corrupt-123`). SQLite names its side-files this way (not -/// as a new extension), and the quarantine keeps the corrupt image alongside -/// the original for inspection. -fn with_name_suffix(path: &Path, suffix: &str) -> PathBuf { - let mut p = path.to_path_buf(); - let name = p - .file_name() - .unwrap_or_default() - .to_string_lossy() - .into_owned(); - p.set_file_name(format!("{name}{suffix}")); - p -} - -fn map_chat_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(WhatsAppChat { - account_id: row.get(0)?, - chat_id: row.get(1)?, - display_name: row.get(2)?, - is_group: row.get::<_, i64>(3)? != 0, - last_message_ts: row.get(4)?, - message_count: row.get::<_, i64>(5)? as u32, - updated_at: row.get(6)?, - }) -} - -fn map_message_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(WhatsAppMessage { - account_id: row.get(0)?, - chat_id: row.get(1)?, - message_id: row.get(2)?, - sender: row.get(3)?, - sender_jid: row.get(4)?, - from_me: row.get::<_, i64>(5)? != 0, - body: row.get(6)?, - timestamp: row.get(7)?, - message_type: row.get(8)?, - source: row.get(9)?, - }) -} - -#[cfg(test)] -#[path = "store_tests.rs"] -mod store_tests; - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn make_store() -> (WhatsAppDataStore, tempfile::TempDir) { - let tmp = tempdir().expect("tempdir"); - let store = WhatsAppDataStore::new(tmp.path()).expect("store"); - (store, tmp) - } - - #[test] - fn upsert_and_list_chats() { - let (store, _tmp) = make_store(); - let mut chats = HashMap::new(); - chats.insert( - "chat1@c.us".to_string(), - ChatMeta { - name: Some("Alice".to_string()), - }, - ); - chats.insert( - "group1@g.us".to_string(), - ChatMeta { - name: Some("My Group".to_string()), - }, - ); - let count = store.upsert_chats("acct1", &chats).unwrap(); - assert_eq!(count, 2); - - let req = ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }; - let rows = store.list_chats(&req).unwrap(); - assert_eq!(rows.len(), 2); - - let group = rows.iter().find(|c| c.chat_id == "group1@g.us").unwrap(); - assert!(group.is_group); - let dm = rows.iter().find(|c| c.chat_id == "chat1@c.us").unwrap(); - assert!(!dm.is_group); - } - - #[test] - fn upsert_and_list_messages() { - let (store, _tmp) = make_store(); - let mut chats = HashMap::new(); - chats.insert( - "chat1@c.us".to_string(), - ChatMeta { - name: Some("Alice".to_string()), - }, - ); - store.upsert_chats("acct1", &chats).unwrap(); - - let msgs = vec![ - IngestMessage { - message_id: "msg1".to_string(), - chat_id: "chat1@c.us".to_string(), - sender: Some("Alice".to_string()), - sender_jid: None, - from_me: Some(false), - body: Some("Hello there".to_string()), - timestamp: Some(1_700_000_000), - message_type: Some("chat".to_string()), - source: Some("cdp-dom".to_string()), - }, - IngestMessage { - message_id: "msg2".to_string(), - chat_id: "chat1@c.us".to_string(), - sender: Some("me".to_string()), - sender_jid: None, - from_me: Some(true), - body: Some("Hey!".to_string()), - timestamp: Some(1_700_000_100), - message_type: Some("chat".to_string()), - source: Some("cdp-indexeddb".to_string()), - }, - ]; - let count = store.upsert_messages("acct1", &msgs).unwrap(); - assert_eq!(count, 2); - - let req = ListMessagesRequest { - chat_id: "chat1@c.us".to_string(), - account_id: Some("acct1".to_string()), - since_ts: None, - until_ts: None, - limit: None, - offset: None, - }; - let rows = store.list_messages(&req).unwrap(); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].body, "Hello there"); - assert_eq!(rows[1].body, "Hey!"); - } - - #[test] - fn search_messages_finds_match() { - let (store, _tmp) = make_store(); - let mut chats = HashMap::new(); - chats.insert( - "chat1@c.us".to_string(), - ChatMeta { - name: Some("Alice".to_string()), - }, - ); - store.upsert_chats("acct1", &chats).unwrap(); - - let msgs = vec![ - IngestMessage { - message_id: "m1".to_string(), - chat_id: "chat1@c.us".to_string(), - sender: Some("Alice".to_string()), - sender_jid: None, - from_me: Some(false), - body: Some("Can you bring the umbrella?".to_string()), - timestamp: Some(1_700_000_000), - message_type: None, - source: Some("cdp-dom".to_string()), - }, - IngestMessage { - message_id: "m2".to_string(), - chat_id: "chat1@c.us".to_string(), - sender: Some("me".to_string()), - sender_jid: None, - from_me: Some(true), - body: Some("Sure, no problem".to_string()), - timestamp: Some(1_700_000_200), - message_type: None, - source: Some("cdp-dom".to_string()), - }, - ]; - store.upsert_messages("acct1", &msgs).unwrap(); - - let req = SearchMessagesRequest { - query: "umbrella".to_string(), - chat_id: None, - account_id: None, - limit: None, - }; - let results = store.search_messages(&req).unwrap(); - assert_eq!(results.len(), 1); - assert!(results[0].body.contains("umbrella")); - } - - #[test] - fn search_messages_matches_sender_name() { - // Person-name queries ("what did Alice say") only return rows when - // search also looks at the `sender` column, because the sender's own - // name almost never appears in the message body. - let (store, _tmp) = make_store(); - let mut chats = HashMap::new(); - chats.insert( - "chat-alice@c.us".to_string(), - ChatMeta { - name: Some("Alice Q".to_string()), - }, - ); - store.upsert_chats("acct1", &chats).unwrap(); - - let msgs = vec![ - IngestMessage { - message_id: "alice-1".to_string(), - chat_id: "chat-alice@c.us".to_string(), - sender: Some("Alice".to_string()), - sender_jid: Some("alice@c.us".to_string()), - from_me: Some(false), - // Body has no "Alice" — match must come from the sender column. - body: Some("running 5 minutes late".to_string()), - timestamp: Some(1_700_001_000), - message_type: None, - source: Some("cdp-dom".to_string()), - }, - IngestMessage { - message_id: "me-1".to_string(), - chat_id: "chat-alice@c.us".to_string(), - sender: Some("me".to_string()), - sender_jid: None, - from_me: Some(true), - body: Some("no problem".to_string()), - timestamp: Some(1_700_001_100), - message_type: None, - source: Some("cdp-dom".to_string()), - }, - ]; - store.upsert_messages("acct1", &msgs).unwrap(); - - let req = SearchMessagesRequest { - query: "Alice".to_string(), - chat_id: None, - account_id: None, - limit: None, - }; - let results = store.search_messages(&req).unwrap(); - assert_eq!(results.len(), 1, "expected sender-name match: {results:?}"); - assert_eq!(results[0].sender, "Alice"); - } - - #[test] - fn prune_removes_old_messages() { - let (store, _tmp) = make_store(); - let mut chats = HashMap::new(); - chats.insert("chat1@c.us".to_string(), ChatMeta { name: None }); - store.upsert_chats("acct1", &chats).unwrap(); - - let msgs = vec![ - IngestMessage { - message_id: "old".to_string(), - chat_id: "chat1@c.us".to_string(), - sender: None, - sender_jid: None, - from_me: Some(false), - body: Some("Old message".to_string()), - timestamp: Some(1_000_000), - message_type: None, - source: None, - }, - IngestMessage { - message_id: "new".to_string(), - chat_id: "chat1@c.us".to_string(), - sender: None, - sender_jid: None, - from_me: Some(false), - body: Some("New message".to_string()), - timestamp: Some(2_000_000_000), - message_type: None, - source: None, - }, - ]; - store.upsert_messages("acct1", &msgs).unwrap(); - - let pruned = store.prune_old_messages(1_500_000_000).unwrap(); - assert_eq!(pruned, 1); - - let req = ListMessagesRequest { - chat_id: "chat1@c.us".to_string(), - account_id: None, - since_ts: None, - until_ts: None, - limit: None, - offset: None, - }; - let remaining = store.list_messages(&req).unwrap(); - assert_eq!(remaining.len(), 1); - assert_eq!(remaining[0].message_id, "new"); - } -} diff --git a/app/src-tauri/src/whatsapp_data/store_tests.rs b/app/src-tauri/src/whatsapp_data/store_tests.rs deleted file mode 100644 index 2dd85473241..00000000000 --- a/app/src-tauri/src/whatsapp_data/store_tests.rs +++ /dev/null @@ -1,731 +0,0 @@ -//! Additional unit tests for WhatsApp data store — account isolation and dedup. -//! -//! These tests sit alongside the inline `#[cfg(test)] mod tests` block in -//! `store.rs`. They focus on cross-account scoping guarantees: data written -//! for one `account_id` must never appear in queries for a different account. - -use super::super::sqlite_retry::BUSY_TIMEOUT; -use super::WhatsAppDataStore; -use openhuman_core::openhuman::channels::whatsapp_data::types::{ - ChatMeta, IngestMessage, ListChatsRequest, ListMessagesRequest, SearchMessagesRequest, -}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::mpsc; -use std::thread; -use std::time::Duration; -use tempfile::tempdir; - -fn make_store() -> (WhatsAppDataStore, tempfile::TempDir) { - let tmp = tempdir().expect("tempdir"); - let store = WhatsAppDataStore::new(tmp.path()).expect("store"); - (store, tmp) -} - -fn db_path_for(tmp: &tempfile::TempDir) -> PathBuf { - tmp.path().join("whatsapp_data").join("whatsapp_data.db") -} - -/// Hold an immediate write transaction until the returned sender fires, then commit. -fn spawn_write_blocker(db_path: PathBuf) -> mpsc::Sender<()> { - let (hold_tx, hold_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - thread::spawn(move || { - let conn = rusqlite::Connection::open(&db_path).expect("blocker open"); - conn.busy_timeout(BUSY_TIMEOUT) - .expect("blocker busy_timeout"); - conn.execute_batch("BEGIN IMMEDIATE") - .expect("blocker BEGIN IMMEDIATE"); - hold_tx.send(()).expect("blocker ready signal"); - let _ = release_rx.recv(); - conn.execute_batch("COMMIT").expect("blocker COMMIT"); - }); - hold_rx.recv().expect("blocker must acquire write lock"); - release_tx -} - -fn chat_meta(name: &str) -> ChatMeta { - ChatMeta { - name: Some(name.to_string()), - } -} - -fn simple_message(msg_id: &str, chat_id: &str, body: &str, ts: i64) -> IngestMessage { - IngestMessage { - message_id: msg_id.to_string(), - chat_id: chat_id.to_string(), - sender: Some("user".to_string()), - sender_jid: None, - from_me: Some(false), - body: Some(body.to_string()), - timestamp: Some(ts), - message_type: Some("chat".to_string()), - source: Some("cdp-dom".to_string()), - } -} - -// ── Account isolation ──────────────────────────────────────────────────────── - -/// Chats written for acct_a must not appear in a query filtered to acct_b. -#[test] -fn list_chats_account_filter_isolates_data() { - let (store, _tmp) = make_store(); - - let mut chats_a = HashMap::new(); - chats_a.insert("chat-a@c.us".to_string(), chat_meta("Alice")); - store.upsert_chats("acct_a", &chats_a).unwrap(); - - let mut chats_b = HashMap::new(); - chats_b.insert("chat-b@c.us".to_string(), chat_meta("Bob")); - store.upsert_chats("acct_b", &chats_b).unwrap(); - - // Querying with acct_a filter must only return acct_a's chats. - let rows_a = store - .list_chats(&ListChatsRequest { - account_id: Some("acct_a".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(rows_a.len(), 1); - assert_eq!(rows_a[0].chat_id, "chat-a@c.us"); - assert_eq!(rows_a[0].account_id, "acct_a"); - - // Querying with acct_b filter must only return acct_b's chats. - let rows_b = store - .list_chats(&ListChatsRequest { - account_id: Some("acct_b".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(rows_b.len(), 1); - assert_eq!(rows_b[0].chat_id, "chat-b@c.us"); - assert_eq!(rows_b[0].account_id, "acct_b"); -} - -/// Messages written for acct_a must not appear in a list_messages query -/// that is filtered to acct_b (same chat_id, different account). -#[test] -fn list_messages_account_filter_isolates_data() { - let (store, _tmp) = make_store(); - let shared_chat = "shared-chat@c.us"; - - // Seed both accounts with the same chat_id but different messages. - let mut chats = HashMap::new(); - chats.insert(shared_chat.to_string(), chat_meta("Shared")); - store.upsert_chats("acct_a", &chats).unwrap(); - store.upsert_chats("acct_b", &chats).unwrap(); - - store - .upsert_messages( - "acct_a", - &[simple_message( - "msg-a1", - shared_chat, - "Hello from A", - 1_700_000_001, - )], - ) - .unwrap(); - store - .upsert_messages( - "acct_b", - &[simple_message( - "msg-b1", - shared_chat, - "Hello from B", - 1_700_000_002, - )], - ) - .unwrap(); - - // acct_a query: must only return acct_a's message. - let msgs_a = store - .list_messages(&ListMessagesRequest { - chat_id: shared_chat.to_string(), - account_id: Some("acct_a".to_string()), - since_ts: None, - until_ts: None, - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(msgs_a.len(), 1); - assert_eq!(msgs_a[0].account_id, "acct_a"); - assert_eq!(msgs_a[0].body, "Hello from A"); - - // acct_b query: must only return acct_b's message. - let msgs_b = store - .list_messages(&ListMessagesRequest { - chat_id: shared_chat.to_string(), - account_id: Some("acct_b".to_string()), - since_ts: None, - until_ts: None, - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(msgs_b.len(), 1); - assert_eq!(msgs_b[0].account_id, "acct_b"); - assert_eq!(msgs_b[0].body, "Hello from B"); -} - -/// search_messages with account_id filter must not surface messages from -/// the other account even when the query body text matches. -#[test] -fn search_messages_account_filter_isolates_results() { - let (store, _tmp) = make_store(); - - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("Chat")); - store.upsert_chats("acct_a", &chats).unwrap(); - store.upsert_chats("acct_b", &chats).unwrap(); - - store - .upsert_messages( - "acct_a", - &[simple_message( - "m-a", - "chat@c.us", - "umbrella search term", - 1_700_000_001, - )], - ) - .unwrap(); - store - .upsert_messages( - "acct_b", - &[simple_message( - "m-b", - "chat@c.us", - "umbrella search term", - 1_700_000_002, - )], - ) - .unwrap(); - - // Filtered to acct_a — must return exactly 1 result for acct_a. - let results = store - .search_messages(&SearchMessagesRequest { - query: "umbrella".to_string(), - chat_id: None, - account_id: Some("acct_a".to_string()), - limit: None, - }) - .unwrap(); - assert_eq!(results.len(), 1); - assert_eq!(results[0].account_id, "acct_a"); -} - -// ── Upsert / dedup ─────────────────────────────────────────────────────────── - -/// Re-upserting the same chat_id for the same account must not create a -/// duplicate row — the row count stays at 1. -#[test] -fn upsert_chat_deduplicates_on_same_account_and_chat_id() { - let (store, _tmp) = make_store(); - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("First Name")); - store.upsert_chats("acct1", &chats).unwrap(); - - // Second upsert with an updated display name. - let mut chats2 = HashMap::new(); - chats2.insert("chat@c.us".to_string(), chat_meta("Updated Name")); - store.upsert_chats("acct1", &chats2).unwrap(); - - let rows = store - .list_chats(&ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!( - rows.len(), - 1, - "duplicate upsert must not create an extra row" - ); - assert_eq!(rows[0].display_name, "Updated Name"); -} - -/// Re-upserting the same message_id for the same (account, chat) must not -/// create a duplicate row — message_count on the parent chat stays consistent. -#[test] -fn upsert_message_deduplicates_on_same_account_chat_message_id() { - let (store, _tmp) = make_store(); - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("Chat")); - store.upsert_chats("acct1", &chats).unwrap(); - - let msg = simple_message("msg1", "chat@c.us", "Original body", 1_700_000_001); - store.upsert_messages("acct1", &[msg]).unwrap(); - - // Re-upsert the same message_id with updated body. - let msg_updated = IngestMessage { - message_id: "msg1".to_string(), - chat_id: "chat@c.us".to_string(), - sender: Some("user".to_string()), - sender_jid: None, - from_me: Some(false), - body: Some("Updated body".to_string()), - timestamp: Some(1_700_000_001), - message_type: Some("chat".to_string()), - source: Some("cdp-dom".to_string()), - }; - store.upsert_messages("acct1", &[msg_updated]).unwrap(); - - let rows = store - .list_messages(&ListMessagesRequest { - chat_id: "chat@c.us".to_string(), - account_id: Some("acct1".to_string()), - since_ts: None, - until_ts: None, - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!( - rows.len(), - 1, - "re-upsert must not create duplicate message row" - ); - assert_eq!( - rows[0].body, "Updated body", - "body must be updated in place" - ); -} - -/// chat message_count stays in sync after multiple upserts of distinct messages. -#[test] -fn upsert_messages_updates_chat_message_count() { - let (store, _tmp) = make_store(); - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("Chat")); - store.upsert_chats("acct1", &chats).unwrap(); - - store - .upsert_messages( - "acct1", - &[ - simple_message("m1", "chat@c.us", "first", 1_700_000_001), - simple_message("m2", "chat@c.us", "second", 1_700_000_002), - simple_message("m3", "chat@c.us", "third", 1_700_000_003), - ], - ) - .unwrap(); - - let chats = store - .list_chats(&ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(chats[0].message_count, 3); - assert_eq!(chats[0].last_message_ts, 1_700_000_003); -} - -/// Pruning old messages refreshes chat stats so list_chats returns accurate counts. -#[test] -fn prune_old_messages_refreshes_chat_stats() { - let (store, _tmp) = make_store(); - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("Chat")); - store.upsert_chats("acct1", &chats).unwrap(); - - store - .upsert_messages( - "acct1", - &[ - simple_message("old", "chat@c.us", "old message", 1_000_000), - simple_message("new", "chat@c.us", "new message", 2_000_000_000), - ], - ) - .unwrap(); - - // Prune everything below 1.5 billion (keeps "new" only). - let pruned = store.prune_old_messages(1_500_000_000).unwrap(); - assert_eq!(pruned, 1); - - let chats_after = store - .list_chats(&ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!( - chats_after[0].message_count, 1, - "message_count must be refreshed after prune" - ); - assert_eq!( - chats_after[0].last_message_ts, 2_000_000_000, - "last_message_ts must reflect the surviving message" - ); -} - -/// Concurrent external write lock must not fail ingest — busy_timeout + -/// retry_on_sqlite_busy should wait for the blocker to commit. -#[test] -fn upsert_chats_succeeds_after_sqlite_busy_contention() { - let (store, tmp) = make_store(); - let workspace = tmp.path().to_path_buf(); - let db_path = db_path_for(&tmp); - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("Alice")); - - let release = spawn_write_blocker(db_path); - let upsert_handle = thread::spawn(move || store.upsert_chats("acct1", &chats)); - // Let upsert reach SQLite busy wait, then release the external write lock. - thread::sleep(Duration::from_millis(50)); - release.send(()).expect("release blocker"); - - let count = upsert_handle - .join() - .expect("upsert thread") - .expect("upsert must succeed once blocker releases"); - assert_eq!(count, 1); - - let store = WhatsAppDataStore::new(&workspace).expect("reopen store"); - let rows = store - .list_chats(&ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(rows.len(), 1); -} - -#[test] -fn prune_old_messages_succeeds_after_sqlite_busy_contention() { - let (store, tmp) = make_store(); - let workspace = tmp.path().to_path_buf(); - let db_path = db_path_for(&tmp); - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("Chat")); - store.upsert_chats("acct1", &chats).unwrap(); - store - .upsert_messages( - "acct1", - &[ - simple_message("old", "chat@c.us", "old", 1_000_000), - simple_message("new", "chat@c.us", "new", 2_000_000_000), - ], - ) - .unwrap(); - - let release = spawn_write_blocker(db_path); - let prune_handle = thread::spawn(move || store.prune_old_messages(1_500_000_000)); - thread::sleep(Duration::from_millis(50)); - release.send(()).expect("release blocker"); - - let pruned = prune_handle - .join() - .expect("prune thread") - .expect("prune must succeed once blocker releases"); - assert_eq!(pruned, 1); - - let store = WhatsAppDataStore::new(&workspace).expect("reopen store"); - let chats_after = store - .list_chats(&ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(chats_after[0].message_count, 1); -} - -#[test] -fn open_conn_configures_busy_timeout_and_wal() { - let (store, _tmp) = make_store(); - let conn = store.open_conn_for_test().expect("open"); - let busy_ms: i64 = conn - .pragma_query_value(None, "busy_timeout", |v| v.get(0)) - .expect("busy_timeout pragma"); - assert_eq!( - busy_ms, - BUSY_TIMEOUT.as_millis() as i64, - "busy_timeout must match configured window" - ); - let journal_mode: String = conn - .pragma_query_value(None, "journal_mode", |v| v.get(0)) - .expect("journal_mode pragma"); - assert_eq!( - journal_mode.to_ascii_lowercase(), - "wal", - "journal_mode must be WAL" - ); -} - -/// Messages with an empty message_id or chat_id must be silently skipped, -/// never causing a panic or spurious database error. -/// A malformed on-disk image must be detected on the upsert write path, -/// quarantined (never deleted), the schema rebuilt, and the *same* upsert call -/// must then SUCCEED against the fresh DB — proving ingest self-heals instead of -/// re-hitting the dead file on every scan tick (Sentry TAURI-RUST-KNH: 1,813 -/// events from a single host). The process-wide report latch must reset after a -/// successful recovery so it fires at most once per corruption episode. -#[test] -fn upsert_recovers_from_corrupt_database() { - use std::sync::atomic::Ordering; - - // Serialize against the other latch-touching recovery tests: the report - // latch + integrity-fail seam are process-wide statics. - let _guard = super::CORRUPT_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - - let (store, tmp) = make_store(); - let workspace = tmp.path().to_path_buf(); - let db_path = db_path_for(&tmp); - - // Reset the process-wide latch so this test observes a clean episode. - super::CORRUPT_REPORTED.store(false, Ordering::Relaxed); - - // Corrupt the freshly-created DB: drop any WAL side-files, then overwrite the - // main file with garbage so the next open reads a malformed image. - for suffix in ["-wal", "-shm"] { - let side = db_path.with_file_name(format!("whatsapp_data.db{suffix}")); - let _ = std::fs::remove_file(&side); - } - std::fs::write( - &db_path, - b"this is not a sqlite database, just garbage bytes", - ) - .unwrap(); - - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("Alice")); - - // First upsert hits the malformed image → detect → quarantine → rebuild → - // retry against the fresh DB, so the call SUCCEEDS. - let count = store - .upsert_chats("acct1", &chats) - .expect("upsert must succeed after corrupt-DB recovery"); - assert_eq!(count, 1); - - // The corrupt bytes are preserved alongside, never silently dropped. - let quarantined: Vec<_> = std::fs::read_dir(db_path.parent().unwrap()) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| { - e.file_name() - .to_string_lossy() - .contains("whatsapp_data.db.corrupt-") - }) - .collect(); - assert_eq!( - quarantined.len(), - 1, - "exactly one quarantined copy of the corrupt image should exist" - ); - - // The rebuilt DB is healthy and queryable — the chat we just wrote is there. - let rows = store - .list_chats(&ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].chat_id, "chat@c.us"); - - // A fresh store over the same workspace also sees the rebuilt, healthy DB. - let reopened = WhatsAppDataStore::new(&workspace).expect("reopen store"); - let rows2 = reopened - .list_chats(&ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(rows2.len(), 1); - - // Recovery settled, so the report latch was reset: a genuinely-new later - // corruption can page once more (the report fired at most once this episode). - assert!( - !super::CORRUPT_REPORTED.load(Ordering::Relaxed), - "report latch must reset after a successful recovery" - ); -} - -/// Finding 1 regression: when the quarantine + rebuild leaves a DB that STILL -/// fails its integrity check, `recover_corrupt_db` must return `Err` (not -/// swallow it as `Ok(true)`). Consequently `report_and_recover` must NOT reset -/// the process-wide report latch — preserving the report-once-per-episode -/// guarantee (a spurious reset would re-arm Sentry to page on the next scan -/// tick against a DB that never actually recovered). -#[test] -fn recover_corrupt_db_errors_and_keeps_latch_when_rebuild_fails_integrity() { - use std::sync::atomic::Ordering; - - let _guard = super::CORRUPT_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - - let (store, tmp) = make_store(); - let db_path = db_path_for(&tmp); - - // Force the post-rebuild integrity_check to report failure via the test seam. - super::FORCE_INTEGRITY_CHECK_FAIL.store(true, Ordering::Relaxed); - - let corrupt = |path: &std::path::Path| { - for suffix in ["-wal", "-shm"] { - let side = path.with_file_name(format!("whatsapp_data.db{suffix}")); - let _ = std::fs::remove_file(&side); - } - std::fs::write(path, b"not a sqlite database, just garbage bytes").unwrap(); - }; - - // (a) Direct call: recovery quarantines + rebuilds, but the forced - // integrity_check failure makes it return Err instead of Ok(true). - corrupt(&db_path); - let direct = store.recover_corrupt_db(); - - // (b) report_and_recover path: on that recovery failure the report latch, - // set by the initial report, must remain set (not reset to false). - corrupt(&db_path); - super::CORRUPT_REPORTED.store(false, Ordering::Relaxed); - let err = anyhow::anyhow!( - "[whatsapp_data] ingest failed: upsert wa_chat 1@lid: database disk image is malformed" - ); - store.report_and_recover("upsert_chats", &err); - let latch_after = super::CORRUPT_REPORTED.load(Ordering::Relaxed); - - // Clear the seam + latch BEFORE asserting so a failing assert cannot leak - // the forced-fail state into other tests. - super::FORCE_INTEGRITY_CHECK_FAIL.store(false, Ordering::Relaxed); - super::CORRUPT_REPORTED.store(false, Ordering::Relaxed); - - assert!( - direct.is_err(), - "recover_corrupt_db must return Err when the rebuilt DB still fails integrity_check" - ); - assert!( - latch_after, - "report latch must stay set when recovery fails (report-once-per-episode must hold)" - ); -} - -/// Finding 2 regression: a `whatsapp_data.db` that is already malformed at -/// process startup must self-heal during store construction. Before the fix, -/// `WhatsAppDataStore::new()` propagated the `init_schema` corruption error, the -/// store singleton was never set, and every ingest RPC failed forever — the -/// corruption survived restarts and re-paged Sentry on every scanner tick. -#[test] -fn new_recovers_from_corruption_at_startup() { - use std::sync::atomic::Ordering; - - let _guard = super::CORRUPT_TEST_GUARD - .lock() - .unwrap_or_else(|e| e.into_inner()); - super::CORRUPT_REPORTED.store(false, Ordering::Relaxed); - - // Pre-create the whatsapp_data dir and plant a malformed DB file BEFORE any - // store exists, simulating a corrupt image left behind by a prior run. - let tmp = tempdir().expect("tempdir"); - let workspace = tmp.path().to_path_buf(); - let db_path = db_path_for(&tmp); - std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); - std::fs::write( - &db_path, - b"this is not a sqlite database, just garbage bytes", - ) - .unwrap(); - - // Construction must succeed by quarantining + rebuilding, not error out. - let store = WhatsAppDataStore::new(&workspace) - .expect("new() must self-heal a boot-time corrupt DB, not propagate the error"); - - // The corrupt bytes are preserved alongside, never silently dropped. - let quarantined = std::fs::read_dir(db_path.parent().unwrap()) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| { - e.file_name() - .to_string_lossy() - .contains("whatsapp_data.db.corrupt-") - }) - .count(); - assert_eq!( - quarantined, 1, - "boot-time corrupt image must be quarantined once" - ); - - // The rebuilt DB is fully usable — a subsequent upsert lands and reads back. - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("Alice")); - let count = store - .upsert_chats("acct1", &chats) - .expect("upsert must succeed against the rebuilt DB"); - assert_eq!(count, 1); - - let rows = store - .list_chats(&ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].chat_id, "chat@c.us"); - - super::CORRUPT_REPORTED.store(false, Ordering::Relaxed); -} - -/// A *healthy* DB must never be quarantined even if `recover_corrupt_db` is -/// invoked — `quick_check` passes, so good data is preserved and recovery is a -/// no-op returning `Ok(false)`. -#[test] -fn recover_corrupt_db_is_noop_on_healthy_db() { - let (store, tmp) = make_store(); - let db_path = db_path_for(&tmp); - // Seed a real row so there is genuine data that must survive. - let mut chats = HashMap::new(); - chats.insert("chat@c.us".to_string(), chat_meta("Alice")); - store.upsert_chats("acct1", &chats).unwrap(); - - let recovered = store - .recover_corrupt_db() - .expect("recovery on a healthy DB must not error"); - assert!(!recovered, "healthy DB must not be quarantined"); - - let quarantined = std::fs::read_dir(db_path.parent().unwrap()) - .unwrap() - .filter_map(|e| e.ok()) - .any(|e| e.file_name().to_string_lossy().contains(".corrupt-")); - assert!(!quarantined, "no quarantine file should be created"); - - // Data survives untouched. - let rows = store - .list_chats(&ListChatsRequest { - account_id: Some("acct1".to_string()), - limit: None, - offset: None, - }) - .unwrap(); - assert_eq!(rows.len(), 1); -} - -#[test] -fn upsert_messages_skips_rows_with_empty_ids() { - let (store, _tmp) = make_store(); - - let bad = IngestMessage { - message_id: "".to_string(), - chat_id: "chat@c.us".to_string(), - sender: None, - sender_jid: None, - from_me: None, - body: Some("will be skipped".to_string()), - timestamp: Some(1_700_000_001), - message_type: None, - source: None, - }; - let count = store.upsert_messages("acct1", &[bad]).unwrap(); - assert_eq!(count, 0, "message with empty message_id must be skipped"); -} diff --git a/src/core/legacy_aliases.rs b/src/core/legacy_aliases.rs index bbc08888a42..ac725bdb66f 100644 --- a/src/core/legacy_aliases.rs +++ b/src/core/legacy_aliases.rs @@ -377,14 +377,12 @@ mod tests { #[cfg(not(feature = "channels"))] fn channels_method_compiled_out(method: &str) -> bool { - // `channels` feature OFF ⇒ the channels + webview_apis + - // webview_notifications + whatsapp_data controllers are unregistered - // (#4801). NOTE: the in-app web chat (`openhuman.channel_*`) is NOT - // gated (core product surface, #5002) — do not add that prefix here. + // `channels` feature OFF ⇒ the channels + webview_notifications + // controllers are unregistered (#4801). NOTE: the in-app web chat + // (`openhuman.channel_*`) is NOT gated (core product surface, #5002) — + // do not add that prefix here. method.starts_with("openhuman.channels_") - || method.starts_with("openhuman.webview_apis_") || method.starts_with("openhuman.webview_notifications_") - || method.starts_with("openhuman.whatsapp_data_") } #[test] diff --git a/src/openhuman/channels/mod.rs b/src/openhuman/channels/mod.rs index 85d4a984e85..174bd3bea57 100644 --- a/src/openhuman/channels/mod.rs +++ b/src/openhuman/channels/mod.rs @@ -12,7 +12,7 @@ //! //! Everything else (`providers`, `host`, `controllers`, `runtime`, `bus`, //! `proactive`, `commands`, `context`, `routes`, `relay_runtime`, -//! `whatsapp_data`, the provider re-exports, +//! the provider re-exports, //! `doctor_channels`, `start_channels`, the `build_system_prompt` re-export and //! the `test_support` re-export) is `#[cfg(feature = "channels")]`. //! Nothing INSIDE those submodules changes when the gate flips. @@ -41,11 +41,6 @@ pub mod proactive; pub mod providers; #[cfg(feature = "channels")] pub(crate) mod relay_runtime; -/// Read-only WhatsApp chat/message store fed by the desktop scanner (formerly -/// `openhuman::whatsapp_data`). -#[cfg(feature = "channels")] -pub mod whatsapp_data; - #[cfg(feature = "channels")] mod commands; #[cfg(feature = "channels")] diff --git a/src/openhuman/channels/whatsapp_data/README.md b/src/openhuman/channels/whatsapp_data/README.md deleted file mode 100644 index f4f5c7b6efd..00000000000 --- a/src/openhuman/channels/whatsapp_data/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# whatsapp_data (core: shared DTOs + agent tools) - -Local-only, structured WhatsApp Web data for the agent. **The SQLite store, the -scanner ingest write path, and the list/search business logic live in the Tauri -shell** (`app/src-tauri/src/whatsapp_data/`) — the core keeps only the shared -serde DTOs and the three read-only agent query tools. **All data stays -on-device — nothing is transmitted to any external service.** - -## Architecture (in-process bridge) - -The core runs in-process inside the Tauri shell (sidecar removed). The agent -tools reach the shell-owned store over the **native request bus** -(`core::event_bus::{register_native_global, request_native_global}`) — a typed, -zero-serialization, in-process request/response registry keyed by a method -string. No HTTP, no JSON-RPC controller, no WebSocket loopback. - -``` -agent tool (core) shell store (app/src-tauri) - request_native_global(method, req) ──▶ register_native_global(method, handler) - "whatsapp_data.list_chats" -> ops::list_chats(&store, req) - "whatsapp_data.list_messages" -> ops::list_messages(&store, req) - "whatsapp_data.search_messages" -> ops::search_messages(&store, req) - scanner: - "whatsapp_data.ingest" -> ops::ingest(&store, req) (90-day prune) -``` - -Both sides share **one** DTO definition (`types.rs`), so the native-request -`TypeId` checks line up. Method-name constants live in `mod.rs` (`methods::*`). - -**Graceful degradation.** In a headless / CLI / docker build there is no shell, -so no handler is registered. The tools then return an empty, well-formed result -with a `"note": "WhatsApp data unavailable (desktop only)"` rather than erroring. - -## Key files (core) - -| File | Role | -| --- | --- | -| `mod.rs` | Module docstring + `methods::*` native-request keys; re-exports `tools` + `types`. | -| `types.rs` | Shared serde DTOs: `WhatsAppChat`, `WhatsAppMessage`, ingest/list/search request + result structs. Consumed by both the core tools and the shell store. | -| `tools.rs` | Re-exports the three tools + the degradation helpers (`UNAVAILABLE_NOTE`, `is_handler_absent`). | -| `tools/list_chats.rs` | `WhatsAppDataListChatsTool` — dispatches `whatsapp_data.list_chats`. | -| `tools/list_messages.rs` | `WhatsAppDataListMessagesTool` — dispatches `whatsapp_data.list_messages`. | -| `tools/search_messages.rs` | `WhatsAppDataSearchMessagesTool` — dispatches `whatsapp_data.search_messages`. | - -## Shell side (`app/src-tauri/src/whatsapp_data/`) - -`store.rs` (SQLite persistence + corruption quarantine/rebuild), `ops.rs` -(ingest + 90-day prune + list/search), `sqlite_retry.rs` (busy/corrupt -detection + backoff), `global.rs` (process-global store singleton), and `mod.rs` -(native-handler registration + `ensure_store` + the `whatsapp_data_list_chats` / -`_list_messages` / `_search_messages` Tauri commands the frontend invokes). The -DB lives at `/whatsapp_data/whatsapp_data.db` — the same -workspace the core resolves, so it stays on the agent-write denylist -(`security::policy` `WORKSPACE_INTERNAL_DIRS`). diff --git a/src/openhuman/channels/whatsapp_data/mod.rs b/src/openhuman/channels/whatsapp_data/mod.rs deleted file mode 100644 index 0bfb17155ee..00000000000 --- a/src/openhuman/channels/whatsapp_data/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Structured WhatsApp Web data — shared DTOs + agent query tools. -//! -//! **Storage lives in the Tauri shell, not the core.** The SQLite store, the -//! scanner-side ingest write path, and the list/search business logic were -//! relocated to `app/src-tauri/src/whatsapp_data/` (desktop-only). The core -//! keeps only: -//! -//! - [`types`] — the shared serde DTOs (chat / message rows, list / search / -//! ingest request + response types). Both the core agent tools and the shell -//! store reference this single definition so the two sides never drift. -//! - [`tools`] — the three read-only agent tools. Their bodies dispatch over -//! the in-process native request bus -//! (`openhuman_core::core::bus::BUS.native().request`) keyed by -//! `whatsapp_data.list_chats` / `.list_messages` / `.search_messages`. The -//! shell registers the matching handlers at startup. -//! -//! When no shell handler is registered (headless / CLI / docker — no desktop), -//! the tools degrade gracefully to an empty result with a "WhatsApp data -//! unavailable (desktop only)" note rather than erroring. -//! -//! **Data locality**: all data remains on-device; it is never transmitted to -//! any external service. - -pub mod tools; -pub mod types; - -/// Native-request method names bridging the core agent tools to the shell store. -/// -/// The shell registers a handler for each of these via -/// `register_native_global`; the core tools dispatch to them via -/// `request_native_global`. Kept here as the single source of truth so the two -/// sides never disagree on the string key. -pub mod methods { - /// List chats — req [`super::types::ListChatsRequest`], resp `Vec<`[`super::types::WhatsAppChat`]`>`. - pub const LIST_CHATS: &str = "whatsapp_data.list_chats"; - /// List messages — req [`super::types::ListMessagesRequest`], resp `Vec<`[`super::types::WhatsAppMessage`]`>`. - pub const LIST_MESSAGES: &str = "whatsapp_data.list_messages"; - /// Search messages — req [`super::types::SearchMessagesRequest`], resp `Vec<`[`super::types::WhatsAppMessage`]`>`. - pub const SEARCH_MESSAGES: &str = "whatsapp_data.search_messages"; - /// Ingest a scanner snapshot — req [`super::types::IngestRequest`], resp [`super::types::IngestResult`]. - pub const INGEST: &str = "whatsapp_data.ingest"; -} diff --git a/src/openhuman/channels/whatsapp_data/tools.rs b/src/openhuman/channels/whatsapp_data/tools.rs deleted file mode 100644 index be1a6174c7c..00000000000 --- a/src/openhuman/channels/whatsapp_data/tools.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! LLM-callable wrappers for the WhatsApp data store (issue #1341). -//! -//! The store itself lives in the Tauri shell. Each tool dispatches its query -//! over the in-process native request bus -//! ([`crate::core::bus::BUS.native().request`]) to the shell-registered -//! handler, unwraps the typed response, and emits a compact JSON object that -//! includes a `"provider": "whatsapp"` provenance tag so replies can cite -//! WhatsApp as the source. -//! -//! **Graceful degradation.** In a headless / CLI / docker build there is no -//! desktop shell, so no handler is registered. In that case the native -//! dispatch returns [`NativeRequestError::NotInitialized`] or -//! [`NativeRequestError::UnregisteredHandler`]; the tools treat that as -//! "WhatsApp data unavailable (desktop only)" and return an empty, well-formed -//! result rather than surfacing an error to the agent. A genuine handler-side -//! failure ([`NativeRequestError::HandlerFailed`] / [`NativeRequestError::TypeMismatch`]) -//! still propagates as a tool error. -//! -//! The write-path `whatsapp_data.ingest` is intentionally NOT wrapped here — -//! it is a scanner-only write path, dispatched by the Tauri shell scanner -//! directly. Exposing it as an agent tool would reopen the read-only boundary -//! this module exists to preserve. - -mod list_chats; -mod list_messages; -mod search_messages; - -pub use list_chats::WhatsAppDataListChatsTool; -pub use list_messages::WhatsAppDataListMessagesTool; -pub use search_messages::WhatsAppDataSearchMessagesTool; - -use tinybus::NativeRequestError; - -/// Note surfaced when the WhatsApp data store is unavailable because no desktop -/// shell handler is registered (headless / CLI / docker builds). -pub(crate) const UNAVAILABLE_NOTE: &str = "WhatsApp data unavailable (desktop only)"; - -/// True when `err` means "no shell handler is wired" — which maps to graceful -/// degradation (an empty result) rather than a tool error. -/// -/// The old bus also had an `Init` case for "the registry itself was never -/// initialised". `tinybus::NativeRegistry` is created on first access, so that -/// state no longer exists: an absent handler is the only way to be absent. -pub(crate) fn is_handler_absent(err: &NativeRequestError) -> bool { - matches!(err, NativeRequestError::UnregisteredHandler { .. }) -} diff --git a/src/openhuman/channels/whatsapp_data/tools/list_chats.rs b/src/openhuman/channels/whatsapp_data/tools/list_chats.rs deleted file mode 100644 index bf00a53a4f8..00000000000 --- a/src/openhuman/channels/whatsapp_data/tools/list_chats.rs +++ /dev/null @@ -1,154 +0,0 @@ -use crate::core::bus::BUS; -use crate::openhuman::channels::whatsapp_data::methods; -use crate::openhuman::channels::whatsapp_data::tools::{is_handler_absent, UNAVAILABLE_NOTE}; -use crate::openhuman::channels::whatsapp_data::types::{ListChatsRequest, WhatsAppChat}; -use crate::openhuman::tools::traits::{Tool, ToolResult}; -use async_trait::async_trait; -use serde_json::json; - -pub struct WhatsAppDataListChatsTool; - -#[async_trait] -impl Tool for WhatsAppDataListChatsTool { - fn name(&self) -> &str { - "whatsapp_data_list_chats" - } - - fn description(&self) -> &str { - "List WhatsApp chats stored locally on this device, sorted by \ - `last_message_ts` DESC (most recent activity first). USE THIS for \ - intents about recent WhatsApp activity, identifying who the user \ - spoke to recently, or resolving a contact/group name to a `chat_id`. \ - Examples: 'who did I talk to on WhatsApp in the last 3 hours', \ - 'find my chat with Alice', 'which WhatsApp groups are active'. \ - Each chat carries `chat_id`, `display_name`, `is_group`, \ - `last_message_ts`, and `message_count`. After getting the chat \ - list, drive `whatsapp_data_list_messages` (with the `chat_id`, \ - optionally bounded by `since_ts`) for the message contents. \ - Returns provider provenance so replies can cite WhatsApp." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "account_id": { - "type": "string", - "description": "Optional WhatsApp account JID. Omit to span every connected WhatsApp account." - }, - "limit": { - "type": "integer", - "minimum": 1, - "description": "Maximum chats to return (default 50)." - }, - "offset": { - "type": "integer", - "minimum": 0, - "description": "Pagination offset (default 0)." - } - } - }) - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - log::debug!("[tool][whatsapp_data] list_chats invoked"); - let req: ListChatsRequest = serde_json::from_value(args).map_err(|e| { - log::debug!("[tool][whatsapp_data] list_chats invalid_args error={e}"); - anyhow::anyhow!("invalid arguments for whatsapp_data_list_chats: {e}") - })?; - log::debug!( - "[tool][whatsapp_data] list_chats args has_account={} limit={:?} offset={:?}", - req.account_id.is_some(), - req.limit, - req.offset, - ); - let chats: Vec = match BUS.native().request(methods::LIST_CHATS, req).await { - Ok(chats) => chats, - Err(e) if is_handler_absent(&e) => { - // Headless / CLI / docker: no desktop shell handler is - // registered. Degrade gracefully to an empty result. - log::debug!("[tool][whatsapp_data] list_chats handler_absent — degrading ({e})"); - let body = serde_json::to_string(&json!({ - "provider": "whatsapp", - "count": 0, - "chats": [], - "note": UNAVAILABLE_NOTE, - }))?; - return Ok(ToolResult::success(body)); - } - Err(e) => { - log::warn!("[tool][whatsapp_data] list_chats bridge_error error={e}"); - return Err(anyhow::anyhow!("whatsapp_data_list_chats: {e}")); - } - }; - log::debug!( - "[tool][whatsapp_data] list_chats returning count={}", - chats.len() - ); - let body = serde_json::to_string(&json!({ - "provider": "whatsapp", - "count": chats.len(), - "chats": chats, - }))?; - Ok(ToolResult::success(body)) - } - - fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { - true - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; - - #[test] - fn metadata_advertises_whatsapp() { - let tool = WhatsAppDataListChatsTool; - assert_eq!(tool.name(), "whatsapp_data_list_chats"); - assert!(tool.description().contains("WhatsApp")); - assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); - assert_eq!(tool.scope(), ToolScope::All); - assert!(tool.is_concurrency_safe(&serde_json::Value::Null)); - } - - #[test] - fn parameters_schema_is_object_with_optional_fields() { - let schema = WhatsAppDataListChatsTool.parameters_schema(); - assert_eq!(schema["type"], "object"); - let props = &schema["properties"]; - for key in ["account_id", "limit", "offset"] { - assert!(props.get(key).is_some(), "missing property {key}"); - } - // No `required` array — every parameter is optional. - assert!(schema.get("required").is_none()); - } - - #[tokio::test] - async fn execute_rejects_invalid_args() { - let tool = WhatsAppDataListChatsTool; - let err = tool - .execute(json!({ "limit": "not-a-number" })) - .await - .expect_err("expected invalid-args error"); - assert!(err.to_string().contains("whatsapp_data_list_chats")); - } - - #[tokio::test] - async fn execute_degrades_gracefully_without_shell_handler() { - // No shell handler registered in-crate → the native dispatch reports - // the handler absent and the tool returns an empty, well-formed result - // carrying the desktop-only note rather than erroring. - let tool = WhatsAppDataListChatsTool; - let result = tool - .execute(json!({})) - .await - .expect("degradation must succeed, not error"); - let body: serde_json::Value = - serde_json::from_str(&result.text()).expect("tool body is JSON"); - assert_eq!(body["provider"], "whatsapp"); - assert_eq!(body["count"], 0); - assert_eq!(body["note"], UNAVAILABLE_NOTE); - } -} diff --git a/src/openhuman/channels/whatsapp_data/tools/list_messages.rs b/src/openhuman/channels/whatsapp_data/tools/list_messages.rs deleted file mode 100644 index 204352efde5..00000000000 --- a/src/openhuman/channels/whatsapp_data/tools/list_messages.rs +++ /dev/null @@ -1,168 +0,0 @@ -use crate::core::bus::BUS; -use crate::openhuman::channels::whatsapp_data::methods; -use crate::openhuman::channels::whatsapp_data::tools::{is_handler_absent, UNAVAILABLE_NOTE}; -use crate::openhuman::channels::whatsapp_data::types::{ListMessagesRequest, WhatsAppMessage}; -use crate::openhuman::tools::traits::{Tool, ToolResult}; -use async_trait::async_trait; -use serde_json::json; - -pub struct WhatsAppDataListMessagesTool; - -#[async_trait] -impl Tool for WhatsAppDataListMessagesTool { - fn name(&self) -> &str { - "whatsapp_data_list_messages" - } - - fn description(&self) -> &str { - "Return WhatsApp messages for one chat, ordered oldest-first within \ - the requested time window. USE THIS for any WhatsApp request scoped \ - to a specific chat — summarisation, action-item extraction, \ - quoting, or 'show me the last N messages with '. ALSO use \ - this (paired with `since_ts` from `current_time` minus N hours) for \ - time-window reads like 'what did message me in the last 3 \ - hours' AFTER resolving the chat via `whatsapp_data_list_chats`. The \ - `chat_id` arg is required — get it from `whatsapp_data_list_chats` \ - (or `whatsapp_data_search_messages`); optionally bound the range \ - with `since_ts` / `until_ts` (Unix seconds). Do NOT use \ - `whatsapp_data_search_messages` for time-only queries — that tool \ - is keyword-based, not time-based. Returns provider provenance so \ - replies can cite WhatsApp." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "chat_id": { - "type": "string", - "description": "WhatsApp chat JID, e.g. `1234567890@c.us` for a contact or `@g.us` for a group. Required." - }, - "account_id": { - "type": "string", - "description": "Optional WhatsApp account JID. Omit to span every connected account." - }, - "since_ts": { - "type": "integer", - "description": "Lower bound (Unix seconds, inclusive) on message timestamp." - }, - "until_ts": { - "type": "integer", - "description": "Upper bound (Unix seconds, inclusive) on message timestamp." - }, - "limit": { - "type": "integer", - "minimum": 1, - "description": "Maximum messages to return (default 100)." - }, - "offset": { - "type": "integer", - "minimum": 0, - "description": "Pagination offset (default 0)." - } - }, - "required": ["chat_id"] - }) - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - log::debug!("[tool][whatsapp_data] list_messages invoked"); - let req: ListMessagesRequest = serde_json::from_value(args).map_err(|e| { - log::debug!("[tool][whatsapp_data] list_messages invalid_args error={e}"); - anyhow::anyhow!("invalid arguments for whatsapp_data_list_messages: {e}") - })?; - log::debug!( - "[tool][whatsapp_data] list_messages args has_account={} has_chat=true limit={:?} offset={:?} has_since={} has_until={}", - req.account_id.is_some(), - req.limit, - req.offset, - req.since_ts.is_some(), - req.until_ts.is_some(), - ); - let messages: Vec = - match BUS.native().request(methods::LIST_MESSAGES, req).await { - Ok(messages) => messages, - Err(e) if is_handler_absent(&e) => { - log::debug!( - "[tool][whatsapp_data] list_messages handler_absent — degrading ({e})" - ); - let body = serde_json::to_string(&json!({ - "provider": "whatsapp", - "count": 0, - "messages": [], - "note": UNAVAILABLE_NOTE, - }))?; - return Ok(ToolResult::success(body)); - } - Err(e) => { - log::warn!("[tool][whatsapp_data] list_messages bridge_error error={e}"); - return Err(anyhow::anyhow!("whatsapp_data_list_messages: {e}")); - } - }; - log::debug!( - "[tool][whatsapp_data] list_messages returning count={}", - messages.len() - ); - let body = serde_json::to_string(&json!({ - "provider": "whatsapp", - "count": messages.len(), - "messages": messages, - }))?; - Ok(ToolResult::success(body)) - } - - fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { - true - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; - - #[test] - fn metadata_advertises_whatsapp() { - let tool = WhatsAppDataListMessagesTool; - assert_eq!(tool.name(), "whatsapp_data_list_messages"); - assert!(tool.description().contains("WhatsApp")); - assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); - assert_eq!(tool.scope(), ToolScope::All); - assert!(tool.is_concurrency_safe(&serde_json::Value::Null)); - } - - #[test] - fn parameters_schema_requires_chat_id() { - let schema = WhatsAppDataListMessagesTool.parameters_schema(); - assert_eq!(schema["type"], "object"); - let required = schema["required"] - .as_array() - .expect("required array present"); - let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); - assert_eq!(names, vec!["chat_id"]); - } - - #[tokio::test] - async fn execute_rejects_missing_chat_id() { - let tool = WhatsAppDataListMessagesTool; - let err = tool - .execute(json!({})) - .await - .expect_err("expected missing chat_id error"); - assert!(err.to_string().contains("whatsapp_data_list_messages")); - } - - #[tokio::test] - async fn execute_degrades_gracefully_without_shell_handler() { - let tool = WhatsAppDataListMessagesTool; - let result = tool - .execute(json!({ "chat_id": "alice@c.us" })) - .await - .expect("degradation must succeed, not error"); - let body: serde_json::Value = - serde_json::from_str(&result.text()).expect("tool body is JSON"); - assert_eq!(body["provider"], "whatsapp"); - assert_eq!(body["count"], 0); - assert_eq!(body["note"], UNAVAILABLE_NOTE); - } -} diff --git a/src/openhuman/channels/whatsapp_data/tools/search_messages.rs b/src/openhuman/channels/whatsapp_data/tools/search_messages.rs deleted file mode 100644 index a1e5435720c..00000000000 --- a/src/openhuman/channels/whatsapp_data/tools/search_messages.rs +++ /dev/null @@ -1,156 +0,0 @@ -use crate::core::bus::BUS; -use crate::openhuman::channels::whatsapp_data::methods; -use crate::openhuman::channels::whatsapp_data::tools::{is_handler_absent, UNAVAILABLE_NOTE}; -use crate::openhuman::channels::whatsapp_data::types::{SearchMessagesRequest, WhatsAppMessage}; -use crate::openhuman::tools::traits::{Tool, ToolResult}; -use async_trait::async_trait; -use serde_json::json; - -pub struct WhatsAppDataSearchMessagesTool; - -#[async_trait] -impl Tool for WhatsAppDataSearchMessagesTool { - fn name(&self) -> &str { - "whatsapp_data_search_messages" - } - - fn description(&self) -> &str { - "Case-insensitive substring search across stored WhatsApp messages, \ - newest-first. Matches BOTH the message body AND the sender name, so \ - a query of 'Alice' returns Alice's own messages even when the body \ - does not contain the word 'Alice'. USE THIS for keyword lookups \ - (specific words, phrases, project names, URLs in messages) and for \ - 'what did say about ' style intents. \ - Do NOT use this for time-window queries like 'what did say \ - in the last 3 hours' — those go through `whatsapp_data_list_chats` \ - to resolve the chat, then `whatsapp_data_list_messages` with \ - `since_ts`. Optionally narrow with `chat_id` and/or `account_id`. \ - Returns provider provenance so replies can cite WhatsApp." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "query": { - "type": "string", - "minLength": 1, - "description": "Substring to match against message bodies (case-insensitive). Required." - }, - "chat_id": { - "type": "string", - "description": "Optional WhatsApp chat JID to scope the search." - }, - "account_id": { - "type": "string", - "description": "Optional WhatsApp account JID. Omit to span every connected account." - }, - "limit": { - "type": "integer", - "minimum": 1, - "description": "Maximum messages to return (default 20)." - } - }, - "required": ["query"] - }) - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - log::debug!("[tool][whatsapp_data] search_messages invoked"); - let req: SearchMessagesRequest = serde_json::from_value(args).map_err(|e| { - log::debug!("[tool][whatsapp_data] search_messages invalid_args error={e}"); - anyhow::anyhow!("invalid arguments for whatsapp_data_search_messages: {e}") - })?; - log::debug!( - "[tool][whatsapp_data] search_messages args has_account={} has_chat={} limit={:?} query_len={}", - req.account_id.is_some(), - req.chat_id.is_some(), - req.limit, - req.query.len(), - ); - let messages: Vec = - match BUS.native().request(methods::SEARCH_MESSAGES, req).await { - Ok(messages) => messages, - Err(e) if is_handler_absent(&e) => { - log::debug!( - "[tool][whatsapp_data] search_messages handler_absent — degrading ({e})" - ); - let body = serde_json::to_string(&json!({ - "provider": "whatsapp", - "count": 0, - "messages": [], - "note": UNAVAILABLE_NOTE, - }))?; - return Ok(ToolResult::success(body)); - } - Err(e) => { - log::warn!("[tool][whatsapp_data] search_messages bridge_error error={e}"); - return Err(anyhow::anyhow!("whatsapp_data_search_messages: {e}")); - } - }; - log::debug!( - "[tool][whatsapp_data] search_messages returning count={}", - messages.len() - ); - let body = serde_json::to_string(&json!({ - "provider": "whatsapp", - "count": messages.len(), - "messages": messages, - }))?; - Ok(ToolResult::success(body)) - } - - fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { - true - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; - - #[test] - fn metadata_advertises_whatsapp() { - let tool = WhatsAppDataSearchMessagesTool; - assert_eq!(tool.name(), "whatsapp_data_search_messages"); - assert!(tool.description().contains("WhatsApp")); - assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); - assert_eq!(tool.scope(), ToolScope::All); - assert!(tool.is_concurrency_safe(&serde_json::Value::Null)); - } - - #[test] - fn parameters_schema_requires_query() { - let schema = WhatsAppDataSearchMessagesTool.parameters_schema(); - let required = schema["required"] - .as_array() - .expect("required array present"); - let names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect(); - assert_eq!(names, vec!["query"]); - } - - #[tokio::test] - async fn execute_rejects_missing_query() { - let tool = WhatsAppDataSearchMessagesTool; - let err = tool - .execute(json!({})) - .await - .expect_err("expected missing query error"); - assert!(err.to_string().contains("whatsapp_data_search_messages")); - } - - #[tokio::test] - async fn execute_degrades_gracefully_without_shell_handler() { - let tool = WhatsAppDataSearchMessagesTool; - let result = tool - .execute(json!({ "query": "hello" })) - .await - .expect("degradation must succeed, not error"); - let body: serde_json::Value = - serde_json::from_str(&result.text()).expect("tool body is JSON"); - assert_eq!(body["provider"], "whatsapp"); - assert_eq!(body["count"], 0); - assert_eq!(body["note"], UNAVAILABLE_NOTE); - } -} diff --git a/src/openhuman/channels/whatsapp_data/types.rs b/src/openhuman/channels/whatsapp_data/types.rs deleted file mode 100644 index a8b50039669..00000000000 --- a/src/openhuman/channels/whatsapp_data/types.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! Normalized WhatsApp data structures — local-only, never transmitted externally. -//! -//! These types represent the structured data extracted from WhatsApp Web via CDP and -//! persisted in a local SQLite database. All data remains local; nothing is sent to -//! any remote service. - -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; - -/// A WhatsApp chat (conversation) record stored locally. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WhatsAppChat { - /// JID e.g. "123456@c.us" or "group@g.us" - pub chat_id: String, - /// Human-readable display name from WhatsApp contacts/group metadata. - pub display_name: String, - /// True if this chat is a group conversation. - pub is_group: bool, - /// The connected WhatsApp account identifier. - pub account_id: String, - /// Unix timestamp (seconds) of the most recent message stored. - pub last_message_ts: i64, - /// Number of messages stored for this chat. - pub message_count: u32, - /// Unix timestamp (seconds) when this record was last updated. - pub updated_at: i64, -} - -/// A single WhatsApp message record stored locally. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WhatsAppMessage { - /// WhatsApp message identifier (compound or bare form). - pub message_id: String, - /// JID of the chat this message belongs to. - pub chat_id: String, - /// Display name of the sender (stored as-is from WhatsApp). - pub sender: String, - /// JID of the sender, when available from IDB metadata. - pub sender_jid: Option, - /// True if the message was sent by the account owner. - pub from_me: bool, - /// Decrypted message body text. - pub body: String, - /// Unix timestamp (seconds) of the message. - pub timestamp: i64, - /// WhatsApp message type (e.g. "chat", "image", "sticker"). - pub message_type: Option, - /// The connected WhatsApp account identifier. - pub account_id: String, - /// Data source: "cdp-dom" or "cdp-indexeddb". - pub source: String, -} - -/// Metadata about a single chat in an ingest payload. -#[derive(Debug, Deserialize)] -pub struct ChatMeta { - /// Display name for the chat, if available. - pub name: Option, -} - -/// A single message entry in an ingest payload. -#[derive(Debug, Deserialize)] -pub struct IngestMessage { - pub message_id: String, - pub chat_id: String, - pub sender: Option, - pub sender_jid: Option, - pub from_me: Option, - pub body: Option, - pub timestamp: Option, - pub message_type: Option, - pub source: Option, -} - -/// Request payload for `openhuman.whatsapp_data_ingest`. -#[derive(Debug, Deserialize)] -pub struct IngestRequest { - /// The WhatsApp account identifier (usually the phone JID). - pub account_id: String, - /// Map of chat JID → chat metadata (display name, etc.). - pub chats: HashMap, - /// Messages to upsert into the local store. - pub messages: Vec, -} - -/// Summary result returned after an ingest operation. -#[derive(Debug, Serialize)] -pub struct IngestResult { - pub chats_upserted: usize, - pub messages_upserted: usize, - pub messages_pruned: u64, -} - -/// Request payload for `openhuman.whatsapp_data_list_chats`. -#[derive(Debug, Deserialize)] -pub struct ListChatsRequest { - /// Optional filter by account. When absent, all accounts are returned. - pub account_id: Option, - /// Maximum number of results (default: 50). - pub limit: Option, - /// Pagination offset (default: 0). - pub offset: Option, -} - -/// Request payload for `openhuman.whatsapp_data_list_messages`. -#[derive(Debug, Deserialize)] -pub struct ListMessagesRequest { - /// JID of the chat to retrieve messages for. - pub chat_id: String, - /// Optional filter by account. When absent, all accounts are searched. - pub account_id: Option, - /// Only return messages at or after this Unix timestamp (seconds). - pub since_ts: Option, - /// Only return messages at or before this Unix timestamp (seconds). - pub until_ts: Option, - /// Maximum number of results (default: 100). - pub limit: Option, - /// Pagination offset (default: 0). - pub offset: Option, -} - -/// Request payload for `openhuman.whatsapp_data_search_messages`. -#[derive(Debug, Deserialize)] -pub struct SearchMessagesRequest { - /// Full-text search query matched against message bodies (case-insensitive LIKE). - pub query: String, - /// Optional filter by chat JID. - pub chat_id: Option, - /// Optional filter by account. When absent, all accounts are searched. - pub account_id: Option, - /// Maximum number of results (default: 20). - pub limit: Option, -} diff --git a/src/openhuman/security/policy/types.rs b/src/openhuman/security/policy/types.rs index dd6c253d1db..73bb8ee69d7 100644 --- a/src/openhuman/security/policy/types.rs +++ b/src/openhuman/security/policy/types.rs @@ -189,6 +189,10 @@ pub(super) const WORKSPACE_INTERNAL_DIRS: &[&str] = &[ "subconscious", "vault", "task_sources", + // The whatsapp_data store was removed along with the scanner that wrote it, + // but an upgraded profile can still hold `whatsapp_data/whatsapp_data.db` + // (chat and message history) from an older version. Keep the directory on + // the internal denylist so agents with workspace access cannot read it. "whatsapp_data", // The redirect_links domain was removed (#5051), but an upgraded profile can // still hold a legacy `redirect_links/links.db` (stored URL history) written diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 6e31da5668b..db8fed9f02e 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -19,8 +19,6 @@ pub use crate::openhuman::agent::artifacts::tools::*; pub use crate::openhuman::agent::learning::tools::*; pub use crate::openhuman::agent::orchestration::tools::*; pub use crate::openhuman::agent::tools::*; -#[cfg(feature = "channels")] -pub use crate::openhuman::channels::whatsapp_data::tools::*; pub use crate::openhuman::config::tools::*; pub use crate::openhuman::config::workspace::tools::*; pub use crate::openhuman::cron::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index e126b9a9fe6..b19b7c13e0c 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -480,16 +480,6 @@ pub fn all_tools_with_runtime( // per-query recall). Written verbatim to user_pref_{general,situational}; // bypasses the inference/stability pipeline. Always registered. Box::new(SavePreferenceTool::new(security.clone())), - // WhatsApp data store — read-only agent surface (issue #1341). The - // store lives in the Tauri shell; these tools reach it over the - // in-process native request bus. The matching ingest write-path is - // scanner-only (dispatched by the shell) and intentionally NOT a tool. - #[cfg(feature = "channels")] - Box::new(WhatsAppDataListChatsTool), - #[cfg(feature = "channels")] - Box::new(WhatsAppDataListMessagesTool), - #[cfg(feature = "channels")] - Box::new(WhatsAppDataSearchMessagesTool), Box::new(ScheduleTool::new(security.clone(), root_config.clone())), Box::new(ProxyConfigTool::new(config.clone(), security.clone())), Box::new(UpdateCheckTool::new()), @@ -1325,12 +1315,6 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { if name.starts_with("media_") { return DomainGroup::Media; } - // Channels family agent tools: read-only WhatsApp data surface. Gated with - // the other channel/webview domains; without this they fall to Platform and - // stay callable when Channels is gated off (#4808 review). - if name.starts_with("whatsapp_data_") { - return DomainGroup::Channels; - } // Voice family: explicit audio_* podcast tools plus the defensive // voice_/tts_/stt_ prefixes for any future tool. if VOICE.contains(&name) From c77d3c05e6c0d31ba5cbcc331ad22543977f0634 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:34:14 +0300 Subject: [PATCH 07/44] chore(core): remove stale WhatsApp controller comments Remove outdated comments describing the former WhatsApp data controller path and simplify the test documentation to reflect the current controller surface. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/core/all.rs | 7 ------- src/core/all_tests.rs | 5 ++--- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/core/all.rs b/src/core/all.rs index e52d54c702c..961b80e7b77 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -969,11 +969,6 @@ fn build_registered_controllers() -> Vec { DomainGroup::Desktop, crate::openhuman::desktop::notifications::all_notifications_registered_controllers(), ); - // Structured WhatsApp Web data has NO core RPC controllers: the SQLite - // store + ingest + list/search moved to the Tauri shell - // (`app/src-tauri/src/whatsapp_data/`). The agent's read-only query tools - // live in `openhuman::channels::whatsapp_data::tools` and reach the shell store via - // the in-process native request bus, not the controller registry. // Mobile device pairing and management push( &mut controllers, @@ -1031,8 +1026,6 @@ fn build_registered_controllers() -> Vec { /// (e.g. the Tauri scanner ingest path) that should not appear in agent tool listings. fn build_internal_only_controllers() -> Vec { let mut controllers = Vec::new(); - // (whatsapp_data ingest is no longer a core RPC path — the scanner writes - // the shell-side store directly over the in-process native request bus.) // MCP write audit list: internal-only so the desktop UI/CLI can inspect // local write history without exposing cross-client history as an MCP tool. push( diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index feedebb4826..18b286bd50e 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -1153,9 +1153,8 @@ fn channels_controllers_registered_when_feature_on() { /// namespace) stays present, pinning the #5002 decoupling: turning off external /// messaging must NOT take down core in-app chat. /// -/// This is the half that proves the gate does something. The 3 `whatsapp_data` -/// agent tools are pinned separately in `tools::ops_tests` (that module has the -/// full-tool-list machinery); here we assert the controller surface. +/// This is the half that proves the gate does something: here we assert the +/// controller surface. #[cfg(not(feature = "channels"))] #[test] fn channels_controllers_absent_when_feature_off() { From a9df951670b878bd243748c533171ae6dffb8b6f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:34:32 +0300 Subject: [PATCH 08/44] docs(core): update internal caller terminology Clarify that internal RPC handlers are invoked by the desktop shell rather than referring to the former scanner terminology. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/core/all.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/all.rs b/src/core/all.rs index 961b80e7b77..ea1fdd5f27d 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -323,7 +323,7 @@ fn capability_allowed_in(caps: Capabilities, capability: Option) -> static REGISTRY: OnceLock> = OnceLock::new(); /// Internal-only controllers: registered for RPC dispatch but NOT in the agent-facing -/// schema catalog. These handlers are callable by trusted callers (e.g. the Tauri scanner) +/// schema catalog. These handlers are callable by trusted callers (e.g. the desktop shell) /// but should not be advertised to agents via tool listings or schema discovery. static INTERNAL_REGISTRY: OnceLock> = OnceLock::new(); @@ -1023,7 +1023,7 @@ fn build_registered_controllers() -> Vec { /// Aggregates controllers that are registered for RPC routing but NOT exposed to agents. /// /// These are write-path or internal-only handlers callable by trusted callers -/// (e.g. the Tauri scanner ingest path) that should not appear in agent tool listings. +/// (e.g. the desktop shell) that should not appear in agent tool listings. fn build_internal_only_controllers() -> Vec { let mut controllers = Vec::new(); // MCP write audit list: internal-only so the desktop UI/CLI can inspect From 69768d6762ffb524542f9c508f102d28529451a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:35:09 +0300 Subject: [PATCH 09/44] test(ops): update tests for removed WhatsApp data tools Replace feature-gated presence and absence checks with assertions that the WhatsApp data tools are removed from every build. Update registry, domain grouping, and capability expectations to reflect that the channels runtime remains while its agent tools no longer exist. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/tools/ops_tests.rs | 88 ++++++++------------------------ 1 file changed, 20 insertions(+), 68 deletions(-) diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 5af2649b28d..3dabb7b02d9 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -139,51 +139,15 @@ fn all_tools_includes_spawn_subagent() { ); } -/// The three read-only WhatsApp-data agent tools are registered when the -/// `channels` feature is on (#4801). Paired with the absent-variant below to -/// pin both directions of the compile-time gate. -#[cfg(feature = "channels")] -#[test] -fn whatsapp_data_tools_present_when_channels_on() { - let tmp = TempDir::new().unwrap(); - let security = Arc::new(SecurityPolicy::default()); - let browser = BrowserConfig { - enabled: false, - allowed_domains: vec![], - session_name: None, - ..BrowserConfig::default() - }; - let http = crate::openhuman::config::HttpRequestConfig::default(); - let cfg = test_config(&tmp); - let tools = all_tools( - Arc::new(Config::default()), - &security, - AuditLogger::disabled(), - &browser, - &http, - tmp.path(), - &HashMap::new(), - &cfg, - ); - let names = tool_names(&tools); - for expected in [ - "whatsapp_data_list_chats", - "whatsapp_data_list_messages", - "whatsapp_data_search_messages", - ] { - assert!( - names.iter().any(|n| n == expected), - "`{expected}` must be registered when the `channels` feature is on; got: {names:?}" - ); - } -} - -/// With `channels` compiled out the three WhatsApp-data agent tools are absent -/// from the registry (not degraded to an error) — the tool types live in the -/// gated `whatsapp_data` domain (#4801). -#[cfg(not(feature = "channels"))] +/// The three `whatsapp_data_*` agent tools are gone, in every build. +/// +/// They queried a shell-side SQLite store whose only writer was the CDP +/// `whatsapp_scanner`, deleted in #5478 when the app moved off Chromium — so +/// from that release the tools read a store nothing could write. This asserts +/// the removal in both directions of the `channels` gate at once, replacing the +/// present/absent pair that used to pin them. #[test] -fn whatsapp_data_tools_absent_when_channels_off() { +fn whatsapp_data_tools_are_gone_in_every_build() { let tmp = TempDir::new().unwrap(); let security = Arc::new(SecurityPolicy::default()); let browser = BrowserConfig { @@ -212,7 +176,7 @@ fn whatsapp_data_tools_absent_when_channels_off() { ] { assert!( !names.iter().any(|n| n == absent), - "`{absent}` must be absent when the `channels` feature is off; got: {names:?}" + "`{absent}` was removed with the store it read; got: {names:?}" ); } } @@ -684,15 +648,6 @@ fn all_tools_default_registry_contains_expected_baseline_surface() { if cfg!(feature = "runtime-node") { expected.extend(&["node_exec", "npm_exec"]); } - // WhatsApp tools are only registered when channels feature is on - if cfg!(feature = "channels") { - expected.extend(&[ - "whatsapp_data_list_chats", - "whatsapp_data_list_messages", - "whatsapp_data_search_messages", - ]); - } - assert_contains_all(&names, &expected); } @@ -2268,15 +2223,6 @@ fn tool_group_classifies_gate_and_harness_families() { tool_group("audio_generate_and_email_podcast"), DomainGroup::Voice ); - // Channels read-only WhatsApp data tools. - assert_eq!( - tool_group("whatsapp_data_list_chats"), - DomainGroup::Channels - ); - assert_eq!( - tool_group("whatsapp_data_search_messages"), - DomainGroup::Channels - ); // Harness-mapped families → kept under harness(). assert_eq!(tool_group("memory_store"), DomainGroup::Memory); @@ -2343,7 +2289,6 @@ fn tool_group_gate_families_dropped_under_harness_not_full() { assert!(!harness.allows(tool_group("shell"))); // The previously-misclassified gate-family tools now drop under harness. assert!(!harness.allows(tool_group("audio_generate_podcast"))); - assert!(!harness.allows(tool_group("whatsapp_data_list_chats"))); } #[test] @@ -2359,7 +2304,6 @@ fn no_gate_family_tool_silently_defaults_to_platform() { "x402_new_thing", "mcp_new_thing", "media_new_thing", - "whatsapp_data_new_thing", ] { assert_ne!( tool_group(name), @@ -2475,7 +2419,6 @@ const REPRESENTATIVE: &[(&str, crate::core::all::DomainGroup)] = { ("mcp_list_servers", G::Mcp), ("wallet_get_address", G::Web3), ("media_generate_image", G::Media), - ("whatsapp_data_list_chats", G::Channels), ("audio_generate_podcast", G::Voice), ("create_workflow", G::Flows), ("run_workflow", G::Skills), @@ -2497,8 +2440,17 @@ const TOOL_LESS: &[crate::core::all::DomainGroup] = { // document tools), so the family itself owns no agent tool. // `Relay` joined this list when the `tinyplace_*` agent-tool family was // removed: the domain still exists and still serves its controllers, it - // just advertises no agent tool any more. - &[G::Config, G::Security, G::Medulla, G::Modules, G::Relay] + // just advertises no agent tool any more. `Channels` joined it for the same + // reason when the three `whatsapp_data_*` tools went — the channel runtime, + // its controllers and its inbound dispatch are all still there. + &[ + G::Config, + G::Security, + G::Medulla, + G::Modules, + G::Relay, + G::Channels, + ] }; // ---- tool_capability() drift guard (M5.3) ---------------------------------- From 9bf0a995f5026e70a2a59fe36bfd7494daf87693 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:35:38 +0300 Subject: [PATCH 10/44] test(json-rpc): remove obsolete WhatsApp tools e2e test Remove the end-to-end test for WhatsApp agent tool wrappers and their canned native bus handlers. The coverage is obsolete after the store relocation to the Tauri shell. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/json_rpc_e2e.rs | 141 ------------------------------------------ 1 file changed, 141 deletions(-) diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 8660efa13a4..86d213126e5 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -9340,147 +9340,6 @@ async fn voice_cloud_transcribe_registered_e2e() { rpc_join.abort(); } -/// End-to-end coverage for the WhatsApp agent tool wrappers (issue #1341) -/// after the store's relocation to the Tauri shell. -/// -/// The SQLite store now lives shell-side; the core tools reach it over the -/// in-process native request bus. This test stands in for the shell by -/// registering canned native handlers, then verifies that: -/// -/// 1. Each read-only tool dispatches over the bus and forwards the handler's -/// typed rows, tagging every response with `"provider": "whatsapp"`. -/// 2. `list_messages` still requires `chat_id`. -/// 3. Tool metadata (names/descriptions) is intact. -#[tokio::test(flavor = "multi_thread")] -async fn whatsapp_data_agent_tools_e2e_1341() { - use openhuman_core::core::bus::BUS; - use openhuman_core::openhuman::channels::whatsapp_data::methods; - use openhuman_core::openhuman::channels::whatsapp_data::types::{ - ListChatsRequest, ListMessagesRequest, SearchMessagesRequest, WhatsAppChat, WhatsAppMessage, - }; - use openhuman_core::openhuman::tools::traits::Tool; - use openhuman_core::openhuman::tools::{ - WhatsAppDataListChatsTool, WhatsAppDataListMessagesTool, WhatsAppDataSearchMessagesTool, - }; - - fn sample_chat(chat_id: &str) -> WhatsAppChat { - WhatsAppChat { - chat_id: chat_id.to_string(), - display_name: "Alice".to_string(), - is_group: false, - account_id: "agent-tools-acct@c.us".to_string(), - last_message_ts: 1_700_000_000, - message_count: 2, - updated_at: 1_700_000_000, - } - } - fn sample_msg(body: &str) -> WhatsAppMessage { - WhatsAppMessage { - message_id: "m1".to_string(), - chat_id: "alice@c.us".to_string(), - sender: "Alice".to_string(), - sender_jid: Some("alice@c.us".to_string()), - from_me: false, - body: body.to_string(), - timestamp: 1_700_000_000, - message_type: Some("chat".to_string()), - account_id: "agent-tools-acct@c.us".to_string(), - source: "cdp-dom".to_string(), - } - } - - // Stand in for the shell store: register canned native handlers. - BUS.native() - .register::, _, _>( - methods::LIST_CHATS, - |_req| async move { Ok(vec![sample_chat("alice@c.us"), sample_chat("team@g.us")]) }, - ); - BUS.native() - .register::, _, _>( - methods::LIST_MESSAGES, - |_req| async move { Ok(vec![sample_msg("Send the umbrella report by Friday")]) }, - ); - BUS.native() - .register::, _, _>( - methods::SEARCH_MESSAGES, - |req| async move { - if req.query.to_lowercase().contains("umbrella") { - Ok(vec![sample_msg("Send the umbrella report by Friday")]) - } else { - Ok(vec![]) - } - }, - ); - - fn parse_tool_output(result: openhuman_core::openhuman::skills::types::ToolResult) -> Value { - assert!(!result.is_error, "tool returned error: {result:?}"); - serde_json::from_str(&result.output()).expect("tool output is valid JSON") - } - - // list_chats — forwards handler rows, provider tag set. - let chats_body = parse_tool_output( - WhatsAppDataListChatsTool - .execute(json!({ "account_id": "agent-tools-acct@c.us" })) - .await - .expect("list_chats execute"), - ); - assert_eq!(chats_body["provider"], "whatsapp"); - assert_eq!(chats_body["count"], 2); - - // list_messages — chat_id required, rows forwarded. - let alice_body = parse_tool_output( - WhatsAppDataListMessagesTool - .execute(json!({ "chat_id": "alice@c.us" })) - .await - .expect("list_messages execute"), - ); - assert_eq!(alice_body["provider"], "whatsapp"); - assert_eq!(alice_body["count"], 1); - assert!(alice_body["messages"][0]["body"] - .as_str() - .unwrap_or_default() - .contains("umbrella report")); - - let missing_chat = WhatsAppDataListMessagesTool - .execute(json!({})) - .await - .expect_err("expected missing chat_id error"); - assert!(missing_chat - .to_string() - .contains("whatsapp_data_list_messages")); - - // search_messages — hit + empty envelope shape. - let search_body = parse_tool_output( - WhatsAppDataSearchMessagesTool - .execute(json!({ "query": "umbrella" })) - .await - .expect("search_messages execute"), - ); - assert_eq!(search_body["provider"], "whatsapp"); - assert_eq!(search_body["count"], 1); - - let empty_body = parse_tool_output( - WhatsAppDataSearchMessagesTool - .execute(json!({ "query": "no-such-token-anywhere" })) - .await - .expect("search_messages empty execute"), - ); - assert_eq!(empty_body["provider"], "whatsapp"); - assert_eq!(empty_body["count"], 0); - - // Tool metadata reachable for downstream wiring. - assert_eq!(WhatsAppDataListChatsTool.name(), "whatsapp_data_list_chats"); - assert_eq!( - WhatsAppDataListMessagesTool.name(), - "whatsapp_data_list_messages" - ); - assert_eq!( - WhatsAppDataSearchMessagesTool.name(), - "whatsapp_data_search_messages" - ); - assert!(WhatsAppDataListChatsTool.description().contains("WhatsApp")); -} - // ── MCP Clients lifecycle ───────────────────────────────────────────────────── // // Tests the install → installed_list → uninstall flow over real JSON-RPC. From 4d350a880c36dc838d95dd3d79be2eacc5b35146 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:36:12 +0300 Subject: [PATCH 11/44] chore(intelligence): remove WhatsApp memory section Remove the WhatsApp memory UI, its tests, and associated translation keys because the feature is no longer supported. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../WhatsAppMemorySection.test.tsx | 149 ------------------ .../intelligence/WhatsAppMemorySection.tsx | 112 ------------- app/src/lib/i18n/ar.ts | 3 - app/src/lib/i18n/bn.ts | 3 - app/src/lib/i18n/de.ts | 3 - app/src/lib/i18n/en.ts | 3 - app/src/lib/i18n/es.ts | 3 - app/src/lib/i18n/fr.ts | 3 - app/src/lib/i18n/hi.ts | 3 - app/src/lib/i18n/id.ts | 3 - app/src/lib/i18n/it.ts | 3 - app/src/lib/i18n/ko.ts | 3 - app/src/lib/i18n/pl.ts | 3 - app/src/lib/i18n/pt.ts | 3 - app/src/lib/i18n/ru.ts | 3 - app/src/lib/i18n/zh-CN.ts | 3 - 16 files changed, 303 deletions(-) delete mode 100644 app/src/components/intelligence/WhatsAppMemorySection.test.tsx delete mode 100644 app/src/components/intelligence/WhatsAppMemorySection.tsx diff --git a/app/src/components/intelligence/WhatsAppMemorySection.test.tsx b/app/src/components/intelligence/WhatsAppMemorySection.test.tsx deleted file mode 100644 index 8cdf134d5e4..00000000000 --- a/app/src/components/intelligence/WhatsAppMemorySection.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { WhatsAppMemorySection } from './WhatsAppMemorySection'; - -const mockWhatsappListChats = vi.fn(); - -vi.mock('../../utils/tauriCommands/memory', () => ({ - whatsappListChats: (...args: unknown[]) => mockWhatsappListChats(...args), -})); - -function makeChat(overrides: Record = {}) { - return { - chat_id: 'chat-1', - display_name: 'Test Chat', - is_group: false, - account_id: 'acc-1', - last_message_ts: 1_700_000_000, - message_count: 5, - updated_at: 1_700_000_000, - ...overrides, - }; -} - -describe('', () => { - beforeEach(() => { - mockWhatsappListChats.mockReset(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('renders nothing when load returns an empty array', async () => { - mockWhatsappListChats.mockResolvedValueOnce([]); - const { container } = render(); - await waitFor(() => expect(mockWhatsappListChats).toHaveBeenCalled()); - expect(container.firstChild).toBeNull(); - }); - - it('stays hidden when load throws (error is swallowed silently)', async () => { - mockWhatsappListChats.mockRejectedValueOnce(new Error('scanner not ready')); - const { container } = render(); - await waitFor(() => expect(mockWhatsappListChats).toHaveBeenCalled()); - expect(container.firstChild).toBeNull(); - }); - - it('calls whatsappListChats with limit:200', async () => { - mockWhatsappListChats.mockResolvedValueOnce([]); - render(); - await waitFor(() => expect(mockWhatsappListChats).toHaveBeenCalledWith({ limit: 200 })); - }); - - it('renders section and plural "chats" when multiple chats load', async () => { - mockWhatsappListChats.mockResolvedValueOnce([ - makeChat({ chat_id: 'c1' }), - makeChat({ chat_id: 'c2' }), - ]); - render(); - await waitFor(() => screen.getByTestId('whatsapp-memory-section')); - expect(screen.getByText(/2 chats synced/)).toBeTruthy(); - }); - - it('renders singular "chat" for exactly 1 chat', async () => { - mockWhatsappListChats.mockResolvedValueOnce([makeChat()]); - render(); - await waitFor(() => screen.getByTestId('whatsapp-memory-section')); - const el = screen.getByText(/chat synced/); - expect(el.textContent).toMatch(/^1 chat synced/); - }); - - it('shows "just now" when delta < 60s', async () => { - const updatedAt = 1_700_000_000; - vi.spyOn(Date, 'now').mockReturnValue((updatedAt + 30) * 1000); - mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: updatedAt })]); - render(); - await waitFor(() => screen.getByTestId('whatsapp-memory-section')); - expect(screen.getByText(/just now/)).toBeTruthy(); - }); - - it('shows "Xm ago" for 60-3599s delta', async () => { - const updatedAt = 1_700_000_000; - vi.spyOn(Date, 'now').mockReturnValue((updatedAt + 120) * 1000); - mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: updatedAt })]); - render(); - await waitFor(() => screen.getByTestId('whatsapp-memory-section')); - expect(screen.getByText(/2m ago/)).toBeTruthy(); - }); - - it('shows "Xh ago" for 3600-86399s delta', async () => { - const updatedAt = 1_700_000_000; - vi.spyOn(Date, 'now').mockReturnValue((updatedAt + 7200) * 1000); - mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: updatedAt })]); - render(); - await waitFor(() => screen.getByTestId('whatsapp-memory-section')); - expect(screen.getByText(/2h ago/)).toBeTruthy(); - }); - - it('shows "Xd ago" for delta >= 86400s', async () => { - const updatedAt = 1_700_000_000; - vi.spyOn(Date, 'now').mockReturnValue((updatedAt + 86400 * 3) * 1000); - mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: updatedAt })]); - render(); - await waitFor(() => screen.getByTestId('whatsapp-memory-section')); - expect(screen.getByText(/3d ago/)).toBeTruthy(); - }); - - it('omits timestamp when all chats have updated_at = 0', async () => { - mockWhatsappListChats.mockResolvedValueOnce([makeChat({ updated_at: 0 })]); - render(); - await waitFor(() => screen.getByTestId('whatsapp-memory-section')); - const el = screen.getByText(/chat synced/); - expect(el.textContent).not.toMatch(/ago|just now/); - }); - - it('handleSync: clicking Sync reloads data and updates count', async () => { - mockWhatsappListChats - .mockResolvedValueOnce([makeChat()]) - .mockResolvedValueOnce([makeChat({ chat_id: 'c1' }), makeChat({ chat_id: 'c2' })]); - render(); - await waitFor(() => screen.getByTestId('whatsapp-memory-section')); - - fireEvent.click(screen.getByRole('button')); - await waitFor(() => expect(mockWhatsappListChats).toHaveBeenCalledTimes(2)); - await waitFor(() => screen.getByText(/2 chats synced/)); - }); - - it('handleSync: button shows "Syncing…" and is disabled while load is in flight', async () => { - mockWhatsappListChats.mockResolvedValueOnce([makeChat()]); - render(); - await waitFor(() => screen.getByTestId('whatsapp-memory-section')); - - let resolveSync!: (v: any) => void; - mockWhatsappListChats.mockReturnValueOnce( - new Promise(r => { - resolveSync = r; - }) - ); - fireEvent.click(screen.getByRole('button')); - - await waitFor(() => screen.getByText('Syncing…')); - expect(screen.getByRole('button')).toBeDisabled(); - - await act(async () => { - resolveSync([makeChat()]); - }); - await waitFor(() => screen.getByText('Sync')); - }); -}); diff --git a/app/src/components/intelligence/WhatsAppMemorySection.tsx b/app/src/components/intelligence/WhatsAppMemorySection.tsx deleted file mode 100644 index 1573ab653a4..00000000000 --- a/app/src/components/intelligence/WhatsAppMemorySection.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; -import { whatsappListChats } from '../../utils/tauriCommands/memory'; -import Button from '../ui/Button'; - -interface WhatsAppMemorySectionProps { - pollIntervalMs?: number; -} - -export function WhatsAppMemorySection({ pollIntervalMs = 30000 }: WhatsAppMemorySectionProps) { - const { t } = useT(); - const [chatCount, setChatCount] = useState(null); - const [lastSyncTs, setLastSyncTs] = useState(null); - const [syncing, setSyncing] = useState(false); - - const load = useCallback(async () => { - try { - const chats = await whatsappListChats({ limit: 200 }); - setChatCount(chats.length); - const latest = chats.reduce((max, c) => Math.max(max, c.updated_at), 0); - setLastSyncTs(latest > 0 ? latest : null); - } catch { - // Scanner may not have data yet — stay hidden. - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - if (!pollIntervalMs) return undefined; - const id = setInterval(() => void load(), pollIntervalMs); - return () => clearInterval(id); - }, [pollIntervalMs, load]); - - const handleSync = useCallback(async () => { - setSyncing(true); - try { - await load(); - } finally { - setSyncing(false); - } - }, [load]); - - if (chatCount === null || chatCount === 0) return null; - - return ( -
-
-
- - {t('whatsapp.title')} - - {chatCount.toLocaleString()}{' '} - {chatCount !== 1 ? t('whatsapp.chatsSynced') : t('whatsapp.chatSynced')} - {lastSyncTs !== null && <> · {relativeTime(lastSyncTs, t)}} - -
- -
-
- ); -} - -function relativeTime(secs: number, t: (key: string) => string): string { - const delta = Date.now() / 1000 - secs; - if (delta < 60) return t('notifications.justNow'); - if (delta < 3600) return t('notifications.minAgo').replace('{n}', String(Math.floor(delta / 60))); - if (delta < 86400) - return t('notifications.hrAgo').replace('{n}', String(Math.floor(delta / 3600))); - return t('notifications.dayAgo').replace('{n}', String(Math.floor(delta / 86400))); -} - -function WhatsAppIcon() { - return ( - - ); -} - -function RefreshIcon({ spinning }: { spinning: boolean }) { - return ( - - ); -} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 63de2d781c8..390f10ffa52 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2712,8 +2712,6 @@ const messages: TranslationMap = { 'reflections.act': 'تنفيذ', 'reflections.dismiss': 'تجاهل', 'reflections.viewConversation': 'عرض', - 'whatsapp.chatsSynced': 'محادثات مزامنة', - 'whatsapp.chatSynced': 'محادثة مزامنة', 'sync.active': 'نشط', 'sync.recent': 'الأخيرة', 'sync.idle': 'خامل', @@ -5922,7 +5920,6 @@ const messages: TranslationMap = { 'تكوين إعدادات فرز الذكاء الاصطناعي لمشغلات التكامل Composio', 'memory.sourceFilterAria': 'التصفية حسب المصدر', 'calls.comingSoonDescription': 'المكالمات بمساعدة الذكاء الاصطناعي قادمة قريباً. ابقَ على اطلاع.', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'المظهر', 'settings.appearanceDesc': 'اختر لونًا فاتحًا أو داكنًا أو قم بمطابقة سمة النظام لديك', 'settings.mascot': 'التميمة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 04b37813d20..19db89d303d 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2777,8 +2777,6 @@ const messages: TranslationMap = { 'reflections.act': 'কাজ করুন', 'reflections.dismiss': 'বাদ দিন', 'reflections.viewConversation': 'দেখুন', - 'whatsapp.chatsSynced': 'চ্যাট সিঙ্ক হয়েছে', - 'whatsapp.chatSynced': 'চ্যাট সিঙ্ক হয়েছে', 'sync.active': 'সক্রিয়', 'sync.recent': 'সাম্প্রতিক', 'sync.idle': 'নিষ্ক্রিয়', @@ -6059,7 +6057,6 @@ const messages: TranslationMap = { 'Composio ইন্টিগ্রেশন ট্রিগারের জন্য AI ট্রাইজ সেটিংস কনফিগার করুন', 'memory.sourceFilterAria': 'উত্স দ্বারা ফিল্টার', 'calls.comingSoonDescription': 'AI-সহায়তা কলগুলি শীঘ্রই আসছে৷ সাথে থাকুন।', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'চেহারা', 'settings.appearanceDesc': 'হালকা, অন্ধকার বাছুন বা আপনার সিস্টেম থিমের সাথে মেলে', 'settings.mascot': 'মাসকট', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index bb679f37b61..019bf80677f 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2853,8 +2853,6 @@ const messages: TranslationMap = { 'reflections.act': 'Handeln', 'reflections.dismiss': 'Entlassen', 'reflections.viewConversation': 'Ansehen', - 'whatsapp.chatsSynced': 'Chats synchronisiert', - 'whatsapp.chatSynced': 'Chat synchronisiert', 'sync.active': 'Aktiv', 'sync.recent': 'Neu', 'sync.idle': 'Leerlauf', @@ -6219,7 +6217,6 @@ const messages: TranslationMap = { 'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser', 'memory.sourceFilterAria': 'Nach Quelle filtern', 'calls.comingSoonDescription': 'KI-unterstützte Anrufe folgen in Kürze. Bleiben Sie dran.', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'Aussehen', 'settings.appearanceDesc': 'Wähle hell, dunkel oder passend zu deinem Systemthema', 'settings.mascot': 'Maskottchen', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index dd3774e19ad..f84f487301b 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3011,8 +3011,6 @@ const en: TranslationMap = { // Subconscious mode selector // WhatsApp - 'whatsapp.chatsSynced': 'chats synced', - 'whatsapp.chatSynced': 'chat synced', // Sync 'sync.active': 'Active', @@ -6676,7 +6674,6 @@ const en: TranslationMap = { 'Configure AI triage settings for Composio integration triggers', 'memory.sourceFilterAria': 'Filter by source', 'calls.comingSoonDescription': 'AI-assisted calls are coming soon. Stay tuned.', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'Appearance', 'settings.appearanceDesc': 'Pick light, dark, or match your system theme', 'settings.mascot': 'Mascot', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index eb71b792749..a35468b5ea1 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2828,8 +2828,6 @@ const messages: TranslationMap = { 'reflections.act': 'Actuar', 'reflections.dismiss': 'Descartar', 'reflections.viewConversation': 'Ver', - 'whatsapp.chatsSynced': 'chats sincronizados', - 'whatsapp.chatSynced': 'chat sincronizado', 'sync.active': 'Activo', 'sync.recent': 'Reciente', 'sync.idle': 'Inactivo', @@ -6179,7 +6177,6 @@ const messages: TranslationMap = { 'Configurar los ajustes de clasificación de IA para los activadores de integración Composio', 'memory.sourceFilterAria': 'Filtrar por fuente', 'calls.comingSoonDescription': 'Las llamadas asistidas por IA llegarán pronto. Mantente atento.', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'Apariencia', 'settings.appearanceDesc': 'Elija claro, oscuro o combine el tema de su sistema', 'settings.mascot': 'mascota', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index a626e6f3383..cee244c7834 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -2851,8 +2851,6 @@ const messages: TranslationMap = { 'reflections.act': 'Agir', 'reflections.dismiss': 'Ignorer', 'reflections.viewConversation': 'Voir', - 'whatsapp.chatsSynced': 'conversations synchronisées', - 'whatsapp.chatSynced': 'conversation synchronisée', 'sync.active': 'Actif', 'sync.recent': 'Récent', 'sync.idle': 'En veille', @@ -6206,7 +6204,6 @@ const messages: TranslationMap = { "Configurez les paramètres de triage IA pour les déclencheurs d'intégration Composio", 'memory.sourceFilterAria': 'Filtrer par source', 'calls.comingSoonDescription': "Les appels assistés par IA arrivent bientôt. Restez à l'écoute.", - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'Apparence', 'settings.appearanceDesc': 'Choisissez clair, sombre ou assorti à votre thème système', 'settings.mascot': 'Mascotte', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 825b0a54a2d..b031e793f88 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2774,8 +2774,6 @@ const messages: TranslationMap = { 'reflections.act': 'करें', 'reflections.dismiss': 'हटाएं', 'reflections.viewConversation': 'देखें', - 'whatsapp.chatsSynced': 'चैट्स सिंक हुईं', - 'whatsapp.chatSynced': 'चैट सिंक हुई', 'sync.active': 'एक्टिव', 'sync.recent': 'हाल का', 'sync.idle': 'आइडल', @@ -6059,7 +6057,6 @@ const messages: TranslationMap = { 'Composio एकीकरण ट्रिगर के लिए AI ट्राइएज सेटिंग्स कॉन्फ़िगर करें', 'memory.sourceFilterAria': 'स्रोत के अनुसार फ़िल्टर करें', 'calls.comingSoonDescription': 'एआई-सहायक कॉल जल्द ही आ रही हैं। बने रहें।', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'दिखावट', 'settings.appearanceDesc': 'प्रकाश, अंधेरा, या अपने सिस्टम थीम से मेल चुनें', 'settings.mascot': 'शुभंकर', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index ede8f922396..3223eedfee4 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2786,8 +2786,6 @@ const messages: TranslationMap = { 'reflections.act': 'Tindakan', 'reflections.dismiss': 'Abaikan', 'reflections.viewConversation': 'Lihat', - 'whatsapp.chatsSynced': 'obrolan disinkronkan', - 'whatsapp.chatSynced': 'obrolan disinkronkan', 'sync.active': 'Aktif', 'sync.recent': 'Terbaru', 'sync.idle': 'Siaga', @@ -6088,7 +6086,6 @@ const messages: TranslationMap = { 'Konfigurasikan pengaturan triase AI untuk pemicu integrasi Composio', 'memory.sourceFilterAria': 'Filter berdasarkan sumber', 'calls.comingSoonDescription': 'Panggilan dengan bantuan AI akan segera hadir. Pantau terus.', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'Tampilan', 'settings.appearanceDesc': 'Pilih terang, gelap, atau ikuti tema sistem Anda', 'settings.mascot': 'Maskot', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index cb0ff7dc3bd..7a8d6aa6c40 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2826,8 +2826,6 @@ const messages: TranslationMap = { 'reflections.act': 'Agisci', 'reflections.dismiss': 'Ignora', 'reflections.viewConversation': 'Visualizza', - 'whatsapp.chatsSynced': 'chat sincronizzate', - 'whatsapp.chatSynced': 'chat sincronizzata', 'sync.active': 'Attivo', 'sync.recent': 'Recenti', 'sync.idle': 'Inattivo', @@ -6163,7 +6161,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'Filtra per origine', 'calls.comingSoonDescription': "Le chiamate assistite dall'IA sono in arrivo. Resta sintonizzato.", - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'Aspetto', 'settings.appearanceDesc': 'Scegli la luce, scuro o abbina il tema del tuo sistema', 'settings.mascot': 'Mascotte', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 3f9a749fb7f..72ea64b9bed 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2742,8 +2742,6 @@ const messages: TranslationMap = { 'reflections.act': '실행', 'reflections.dismiss': '닫기', 'reflections.viewConversation': '보기', - 'whatsapp.chatsSynced': '채팅 동기화됨', - 'whatsapp.chatSynced': '채팅 동기화됨', 'sync.active': '활성', 'sync.recent': '최근', 'sync.idle': '유휴', @@ -5988,7 +5986,6 @@ const messages: TranslationMap = { 'devOptions.menuComposioTriggersDesc': 'Composio 통합 트리거에 대한 AI 심사 설정 구성', 'memory.sourceFilterAria': '소스별 필터링', 'calls.comingSoonDescription': 'AI 지원 통화가 곧 제공됩니다. 기대해 주세요.', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': '모양', 'settings.appearanceDesc': '밝은 색, 어두운 색 선택 또는 시스템 테마와 일치', 'settings.mascot': '마스코트', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index dc6649b9dd5..4df44479bce 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2805,8 +2805,6 @@ const messages: TranslationMap = { 'reflections.act': 'Wykonaj', 'reflections.dismiss': 'Odrzuć', 'reflections.viewConversation': 'Zobacz', - 'whatsapp.chatsSynced': 'rozmów zsynchronizowano', - 'whatsapp.chatSynced': 'rozmowa zsynchronizowana', 'sync.active': 'Aktywna', 'sync.recent': 'Ostatnia', 'sync.idle': 'Bezczynna', @@ -6145,7 +6143,6 @@ const messages: TranslationMap = { 'Konfiguruj ustawienia klasyfikacji AI dla wyzwalaczy integracji Composio', 'memory.sourceFilterAria': 'Filtruj po źródle', 'calls.comingSoonDescription': 'Połączenia wspierane AI pojawią się wkrótce. Bądź na bieżąco.', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'Wygląd', 'settings.appearanceDesc': 'Wybierz tryb jasny, ciemny lub zgodny z systemem', 'settings.mascot': 'Maskotka', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 06427b79595..5b7d75dc7fc 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2823,8 +2823,6 @@ const messages: TranslationMap = { 'reflections.act': 'Agir', 'reflections.dismiss': 'Dispensar', 'reflections.viewConversation': 'Ver', - 'whatsapp.chatsSynced': 'chats sincronizados', - 'whatsapp.chatSynced': 'chat sincronizado', 'sync.active': 'Ativo', 'sync.recent': 'Recente', 'sync.idle': 'Inativo', @@ -6155,7 +6153,6 @@ const messages: TranslationMap = { 'Definir configurações de triagem de IA para gatilhos de integração Composio', 'memory.sourceFilterAria': 'Filtrar por origem', 'calls.comingSoonDescription': 'Chamadas assistidas por IA chegam em breve. Fique ligado.', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'Aparência', 'settings.appearanceDesc': 'Escolha claro, escuro ou combine com o tema do seu sistema', 'settings.mascot': 'Mascote', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 38607343f30..1cfb9e36236 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2795,8 +2795,6 @@ const messages: TranslationMap = { 'reflections.act': 'Выполнить', 'reflections.dismiss': 'Закрыть', 'reflections.viewConversation': 'Просмотр', - 'whatsapp.chatsSynced': 'чатов синхронизировано', - 'whatsapp.chatSynced': 'чат синхронизирован', 'sync.active': 'Активно', 'sync.recent': 'Недавние', 'sync.idle': 'Ожидание', @@ -6119,7 +6117,6 @@ const messages: TranslationMap = { 'Настройка параметров сортировки AI для триггеров интеграции Composio', 'memory.sourceFilterAria': 'Фильтровать по источнику', 'calls.comingSoonDescription': 'Звонки с поддержкой ИИ скоро появятся. Следите за обновлениями.', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': 'Внешний вид', 'settings.appearanceDesc': 'Выберите светлую, темную или соответствующую теме вашей системы.', 'settings.mascot': 'Талисман', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index c2486840a8a..ca02493e019 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2624,8 +2624,6 @@ const messages: TranslationMap = { 'reflections.act': '执行', 'reflections.dismiss': '忽略', 'reflections.viewConversation': '查看', - 'whatsapp.chatsSynced': '个对话已同步', - 'whatsapp.chatSynced': '个对话已同步', 'sync.active': '活跃', 'sync.recent': '最近', 'sync.idle': '空闲', @@ -5729,7 +5727,6 @@ const messages: TranslationMap = { 'devOptions.menuComposioTriggersDesc': '为 Composio 集成触发器配置 AI 分级设置', 'memory.sourceFilterAria': '按来源过滤', 'calls.comingSoonDescription': '人工智能辅助通话即将推出。敬请关注。', - 'whatsapp.title': 'WhatsApp', 'settings.appearance': '外观', 'settings.appearanceDesc': '选择浅色、深色或跟随系统主题', 'settings.mascot': '吉祥物', From f7704023cd2385a2ded26f24eeb9ffe810d46939 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:36:20 +0300 Subject: [PATCH 12/44] refactor(memory): remove WhatsApp section from workspace Remove the WhatsApp memory section from the workspace and its test mock to reflect its absence from the interface. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/intelligence/MemoryWorkspace.test.tsx | 1 - app/src/components/intelligence/MemoryWorkspace.tsx | 5 ----- 2 files changed, 6 deletions(-) diff --git a/app/src/components/intelligence/MemoryWorkspace.test.tsx b/app/src/components/intelligence/MemoryWorkspace.test.tsx index 9d64b2210e8..45b4fa331e1 100644 --- a/app/src/components/intelligence/MemoryWorkspace.test.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.test.tsx @@ -11,7 +11,6 @@ vi.mock('./MemoryGraph', () => ({ MemoryGraph: () => null })); vi.mock('./MemorySourcesRegistry', () => ({ MemorySourcesRegistry: () => null })); vi.mock('./MemoryTreeStatusPanel', () => ({ MemoryTreeStatusPanel: () => null })); vi.mock('./ObsidianVaultSection', () => ({ ObsidianVaultSection: () => null })); -vi.mock('./WhatsAppMemorySection', () => ({ WhatsAppMemorySection: () => null })); vi.mock('../../utils/tauriCommands', () => ({ memoryTreeGraphExport: vi.fn().mockResolvedValue({ nodes: [], edges: [] }), memoryTreeFlushNow: vi.fn().mockResolvedValue(undefined), diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index 69f77449005..299871eba63 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -11,9 +11,6 @@ * │ Sync button, status chip, chunk count, freshness) │ * └───────────────────────────────────────────────────────┘ * ┌───────────────────────────────────────────────────────┐ - * │ WhatsAppMemorySection │ - * └───────────────────────────────────────────────────────┘ - * ┌───────────────────────────────────────────────────────┐ * │ ModeToggle · Reset Memory · Reset Tree · Build Trees │ * │ [ View vault in Obsidian ] (shown when vault set) │ * └───────────────────────────────────────────────────────┘ @@ -45,7 +42,6 @@ import { MemoryControls } from './MemoryControls'; import { MemoryGraph } from './MemoryGraph'; import { MemorySourcesRegistry } from './MemorySourcesRegistry'; import { MemoryTreeStatusPanel } from './MemoryTreeStatusPanel'; -import { WhatsAppMemorySection } from './WhatsAppMemorySection'; interface MemoryWorkspaceProps { onToast?: (toast: Omit) => void; @@ -124,7 +120,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
- Date: Sat, 29 Aug 2026 18:36:29 +0300 Subject: [PATCH 13/44] refactor(memory): remove WhatsApp store helpers Remove the WhatsApp chat and message types and their Tauri command wrappers from the memory utilities. This keeps WhatsApp store access out of the memory command module. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/utils/tauriCommands/memory.ts | 60 --------------------------- 1 file changed, 60 deletions(-) diff --git a/app/src/utils/tauriCommands/memory.ts b/app/src/utils/tauriCommands/memory.ts index 0708c9365d5..56bebd83a61 100644 --- a/app/src/utils/tauriCommands/memory.ts +++ b/app/src/utils/tauriCommands/memory.ts @@ -352,63 +352,3 @@ export async function memoryLearnAll(namespaces?: string[]): Promise { - if (!isTauri()) { - throw new Error('Not running in Tauri'); - } - const resp = await safeInvoke('whatsapp_data_list_chats', { req: params ?? {} }); - return resp ?? []; -} - -/** - * List messages for a chat from the local shell-side store via the - * `whatsapp_data_list_messages` Tauri command. - */ -export async function whatsappListMessages(params: { - chat_id: string; - account_id?: string; - limit?: number; - offset?: number; -}): Promise { - if (!isTauri()) { - throw new Error('Not running in Tauri'); - } - const resp = await safeInvoke('whatsapp_data_list_messages', { req: params }); - return resp ?? []; -} From ab44f221c653b13963d3a30b21d1eab45f26f247 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:36:41 +0300 Subject: [PATCH 14/44] test(memory): remove obsolete WhatsApp command tests Remove tests for listing WhatsApp chats and messages that are no longer applicable. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/utils/tauriCommands/memory.test.ts | 62 ---------------------- 1 file changed, 62 deletions(-) diff --git a/app/src/utils/tauriCommands/memory.test.ts b/app/src/utils/tauriCommands/memory.test.ts index 8a74c78306e..dc04c017ba3 100644 --- a/app/src/utils/tauriCommands/memory.test.ts +++ b/app/src/utils/tauriCommands/memory.test.ts @@ -10,8 +10,6 @@ import { memoryLearnAll, memorySyncAll, memorySyncChannel, - whatsappListChats, - whatsappListMessages, } from './memory'; vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); @@ -159,64 +157,4 @@ describe('aiListMemoryFiles', () => { }); }); -describe('whatsappListChats', () => { - test('throws when not in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(whatsappListChats()).rejects.toThrow('Not running in Tauri'); - expect(mockSafeInvoke).not.toHaveBeenCalled(); - }); - - test('invokes the shell command with provided params wrapped in req', async () => { - mockSafeInvoke.mockResolvedValueOnce([]); - await whatsappListChats({ limit: 10 }); - expect(mockSafeInvoke).toHaveBeenCalledWith('whatsapp_data_list_chats', { req: { limit: 10 } }); - }); - - test('uses empty req object when no params provided', async () => { - mockSafeInvoke.mockResolvedValueOnce([]); - await whatsappListChats(); - expect(mockSafeInvoke).toHaveBeenCalledWith('whatsapp_data_list_chats', { req: {} }); - }); - - test('returns the chat array from the command', async () => { - const chats = [{ chat_id: 'c1', display_name: 'Direct' }]; - mockSafeInvoke.mockResolvedValueOnce(chats); - const result = await whatsappListChats(); - expect(result).toBe(chats); - }); - - test('returns empty array when the command yields a nullish result', async () => { - mockSafeInvoke.mockResolvedValueOnce(undefined); - const result = await whatsappListChats(); - expect(result).toEqual([]); - }); -}); - -describe('whatsappListMessages', () => { - test('throws when not in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(whatsappListMessages({ chat_id: 'c1' })).rejects.toThrow('Not running in Tauri'); - expect(mockSafeInvoke).not.toHaveBeenCalled(); - }); - - test('invokes the shell command with required chat_id in req', async () => { - mockSafeInvoke.mockResolvedValueOnce([]); - await whatsappListMessages({ chat_id: 'c1', limit: 50 }); - expect(mockSafeInvoke).toHaveBeenCalledWith('whatsapp_data_list_messages', { - req: { chat_id: 'c1', limit: 50 }, - }); - }); - - test('returns the message array from the command', async () => { - const msgs = [{ message_id: 'm1', body: 'hello' }]; - mockSafeInvoke.mockResolvedValueOnce(msgs); - const result = await whatsappListMessages({ chat_id: 'c1' }); - expect(result).toBe(msgs); - }); - - test('returns empty array when the command yields a nullish result', async () => { - mockSafeInvoke.mockResolvedValueOnce(undefined); - const result = await whatsappListMessages({ chat_id: 'c1' }); - expect(result).toEqual([]); - }); }); From 06c45aeaa396cbb717493137524e8f1101a7159a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:36:54 +0300 Subject: [PATCH 15/44] fix(memory): remove extra closing brace from test Remove the stray closing brace so the memory command test has valid syntax and remains properly balanced. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/utils/tauriCommands/memory.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/utils/tauriCommands/memory.test.ts b/app/src/utils/tauriCommands/memory.test.ts index dc04c017ba3..19e80c2b4a1 100644 --- a/app/src/utils/tauriCommands/memory.test.ts +++ b/app/src/utils/tauriCommands/memory.test.ts @@ -156,5 +156,3 @@ describe('aiListMemoryFiles', () => { await expect(aiListMemoryFiles()).rejects.toThrow(/Not running in Tauri/); }); }); - -}); From e25657f9fed429e564951a52c52401d94c87a278 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:37:22 +0300 Subject: [PATCH 16/44] chore: files changed app/src-tauri/Cargo.toml,scripts/ci/product-features.txt Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src-tauri/Cargo.toml | 4 ---- scripts/ci/product-features.txt | 14 +++++++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index eb227078073..82db37908b4 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -164,10 +164,6 @@ openhuman_core = { path = "../..", package = "openhuman", default-features = fal "file-logging", "contacts", "runtime-node", - # Without this the memory_diff RPC namespace is unknown-method in the - # shipped app, the memory_diff agent tool is absent, and the embedded - # driver stops advertising Capability::Diff — the product keeps all three. - "memory-git", # Declared "Default-OFF, product-ON" by its own gate comment in the root # Cargo.toml, but it reached neither the product set nor this list, so the # family was compiled in no configuration at all. Registration stays diff --git a/scripts/ci/product-features.txt b/scripts/ci/product-features.txt index 872f3743866..d76ef2893f6 100644 --- a/scripts/ci/product-features.txt +++ b/scripts/ci/product-features.txt @@ -90,9 +90,17 @@ contacts # its static liblzma C build. runtime-node -# Git-backed memory diff (snapshots/checkpoints/read markers) and the git wiki -# mirror of summary nodes. Carries git2 + vendored libgit2. -memory-git +# NOTE: `memory-git` was REMOVED from the product set. It was the last gate +# carrying a native C build the product could shed: `git2` brings `libgit2-sys` +# and `libz-sys`, which were 2 of the 4 native builds in this profile (now 2 — +# `libsqlite3-sys` and `ring`, both load-bearing). Only 3 crates, but two C +# toolchain builds is disproportionate build time for a surface nothing in the +# desktop UI calls: `memory_diff` had no frontend caller. What the product +# loses: the `memory_diff` RPC namespace becomes unknown-method, the +# `memory_diff` agent tool is absent, the embedded driver stops advertising +# `Capability::Diff`, and summary nodes are still written to disk but no longer +# mirrored into git. Re-add this line (and the shell's forwarding entry) to +# bring it back. # The `hosting_*` agent tools: put a workspace on a real hosting provider and # manage its sites, databases, environment, domains and deployments over the From f2a1a604dcc6228b8ba3eca314ad1aa90fd5a96a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:41:31 +0300 Subject: [PATCH 17/44] chore: files changed AGENTS.md Auto-committed-on: dragonfly Co-authored-by: Medulla --- AGENTS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2f25a6ec240..66abcdd9f89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,7 @@ No `UserProvider`/`AIProvider`/`SkillProvider` — auth lives in `CoreStateProvi ## Tauri shell (`app/src-tauri/`) -Thin desktop host. Key modules: `core_process`, `core_rpc`, `cdp`, `dictation_hotkeys`, `file_logging`, `mascot_native_window`, `window_state`, `imessage_scanner`, `webview_apis`. +Thin desktop host. Key modules: `core_process`, `core_rpc`, `dictation_hotkeys`, `file_logging`, `mascot_native_window`, `window_state`, `imessage_scanner`. The CDP-driven provider scanners (`discord_scanner`, `slack_scanner`, `telegram_scanner`, `whatsapp_scanner`, `wechat_scanner`, `gmessages_scanner`), the `webview_accounts` surface they ran inside, and the in-app Meet call window (`meet_call`, `meet_audio`, `meet_video`, `meet_scanner`, `fake_camera`) were removed in #5478 — CDP only exists under a Chromium engine, and the app moved to Wry in #5456. `imessage_scanner` is unaffected: it reads `chat.db` natively and never used CDP. Meet has since been removed from the product entirely (see below), so the `src/openhuman/meet/` and `backend_bot` paths those notes referred to are gone. @@ -417,9 +417,9 @@ two paths' equivalence — keep that as call sites migrate. ### Domain layout (`src/openhuman/`) -~31 domain directories — authoritative list: `ls -d src/openhuman/*/`. Major families: agent (`agent` — with `agent/{artifacts,context,experience,file_state,harness_init,learning,orchestration,plan_review,profiles,registry,session_db,session_import,tinyagents}`), memory (`memory` — with `memory/{agent,conversations,diff,goals,people,queue,search,sources,store,sync,tinycortex,tool_memory,tree}`), skills/flows (`skills` — with `skills/{catalog,runtime,webhooks}` —, `flows` — with `flows/{tinyflows,rhai}`), inference/AI (`inference` — with `inference/{embeddings,tokenjuice}` —, `routing`), MCP (`mcp` — with `mcp/{server,registry,audit,config_servers,http_client}`), runtimes (`runtime` — with `runtime/{node,python,python_server,pool,javascript}` —, `sandbox` — with `sandbox/cwd_jail`), channels (`channels` — with `channels/whatsapp_data`), web3 (`web3` — with `web3/{wallet,x402}`), plus kernel domains (`platform` — with `platform/{about_app,connectivity,cost,doctor,health,proc_metrics,service,socket,startup,update}` —, `config` — with `config/{migrations,migration_helpers,workspace}` —, `cron` — with `cron/scheduler_gate` —, `integrations`, `security` — with `security/{approval,credentials,keyring,keyring_consent,encryption,prompt_injection,devices}` —, `threads` — with `threads/{goals,todos}` —, `tools` — with `tools/{registry,status,timeout,agent_policy}` —, `util` — with `util/{text,retry,tls,types}` —, `voice`, …). +~31 domain directories — authoritative list: `ls -d src/openhuman/*/`. Major families: agent (`agent` — with `agent/{artifacts,context,experience,file_state,harness_init,learning,orchestration,plan_review,profiles,registry,session_db,session_import,tinyagents}`), memory (`memory` — with `memory/{agent,conversations,diff,goals,people,queue,search,sources,store,sync,tinycortex,tool_memory,tree}`), skills/flows (`skills` — with `skills/{catalog,runtime,webhooks}` —, `flows` — with `flows/{tinyflows,rhai}`), inference/AI (`inference` — with `inference/{embeddings,tokenjuice}` —, `routing`), MCP (`mcp` — with `mcp/{server,registry,audit,config_servers,http_client}`), runtimes (`runtime` — with `runtime/{node,python,python_server,pool,javascript}` —, `sandbox` — with `sandbox/cwd_jail`), channels (`channels`), web3 (`web3` — with `web3/{wallet,x402}`), plus kernel domains (`platform` — with `platform/{about_app,connectivity,cost,doctor,health,proc_metrics,service,socket,startup,update}` —, `config` — with `config/{migrations,migration_helpers,workspace}` —, `cron` — with `cron/scheduler_gate` —, `integrations`, `security` — with `security/{approval,credentials,keyring,keyring_consent,encryption,prompt_injection,devices}` —, `threads` — with `threads/{goals,todos}` —, `tools` — with `tools/{registry,status,timeout,agent_policy}` —, `util` — with `util/{text,retry,tls,types}` —, `voice`, …). -**Family directories (in progress).** The flat tree is being collapsed so that **one directory equals one feature gate**: a capability spread across sibling top-level dirs costs a `#[cfg]` per dir plus five parallel registries to keep in sync. Landed so far (124 → 28 top-level dirs, 0 root-level `*.rs`): `util/` (incl. `util/sanitize`), `mcp/{server,registry,audit,config_servers,http_client}`, `sandbox/cwd_jail`, `cron/scheduler_gate`, `runtime/`, `media/`, `voice/audio_toolkit`, `web3/{wallet,x402}`, `medulla/chat`, `flows/{tinyflows,rhai}`, `channels/whatsapp_data`, `desktop/` (accessibility, app_state, dashboard, notifications, overlay, provider_surfaces), `hosted/` (announcements, billing, orchestration, referral, team — all thin proxies to the TinyHumans backend), `threads/{goals,todos}`, `tools/{registry,status,timeout,agent_policy}`, `platform/` (about_app, connectivity, cost, doctor, health, proc_metrics, service, socket, startup, update), `config/{migrations,migration_helpers,workspace}`, `integrations/{composio,file_storage,task_sources}`, `skills/{catalog,runtime,webhooks}`, `inference/{embeddings,tokenjuice}`, `security/{approval,credentials,keyring,keyring_consent,encryption,prompt_injection,devices}` (the kernel security family — never gated), and `agent/{experience,orchestration,registry,harness_init,session_db,session_import,context,profiles,learning,plan_review,file_state,artifacts,tinyagents}` (the agent harness is kernel and is never gated; `agent/` stayed put as the parent rather than becoming `agent/core`, which would have cost ~999 extra import rewrites for no gate benefit), and `memory/{store,sync,tree,search,sources,queue,diff,goals,conversations,tool_memory,tinycortex,agent,people}` (the largest family, moved last; `memory/` stayed put as the parent — a `memory → memory/core` rename would have cost ~545 extra rewrites — with the pre-existing `memory/sync.rs` renamed to `memory/sync_events.rs` to free the name for `memory_sync`, and `memory_tools` landing as `memory/tool_memory` to avoid the pre-existing `memory/tools/` agent-tool directory). Plan, target tree, and move-PR rules: [`docs/specs/2026-08-02-core-kernel-domain-reorg.md`](docs/specs/2026-08-02-core-kernel-domain-reorg.md). +**Family directories (in progress).** The flat tree is being collapsed so that **one directory equals one feature gate**: a capability spread across sibling top-level dirs costs a `#[cfg]` per dir plus five parallel registries to keep in sync. Landed so far (124 → 28 top-level dirs, 0 root-level `*.rs`): `util/` (incl. `util/sanitize`), `mcp/{server,registry,audit,config_servers,http_client}`, `sandbox/cwd_jail`, `cron/scheduler_gate`, `runtime/`, `media/`, `voice/audio_toolkit`, `web3/{wallet,x402}`, `medulla/chat`, `flows/{tinyflows,rhai}`, `desktop/` (accessibility, app_state, dashboard, notifications, overlay, provider_surfaces), `hosted/` (announcements, billing, orchestration, referral, team — all thin proxies to the TinyHumans backend), `threads/{goals,todos}`, `tools/{registry,status,timeout,agent_policy}`, `platform/` (about_app, connectivity, cost, doctor, health, proc_metrics, service, socket, startup, update), `config/{migrations,migration_helpers,workspace}`, `integrations/{composio,file_storage,task_sources}`, `skills/{catalog,runtime,webhooks}`, `inference/{embeddings,tokenjuice}`, `security/{approval,credentials,keyring,keyring_consent,encryption,prompt_injection,devices}` (the kernel security family — never gated), and `agent/{experience,orchestration,registry,harness_init,session_db,session_import,context,profiles,learning,plan_review,file_state,artifacts,tinyagents}` (the agent harness is kernel and is never gated; `agent/` stayed put as the parent rather than becoming `agent/core`, which would have cost ~999 extra import rewrites for no gate benefit), and `memory/{store,sync,tree,search,sources,queue,diff,goals,conversations,tool_memory,tinycortex,agent,people}` (the largest family, moved last; `memory/` stayed put as the parent — a `memory → memory/core` rename would have cost ~545 extra rewrites — with the pre-existing `memory/sync.rs` renamed to `memory/sync_events.rs` to free the name for `memory_sync`, and `memory_tools` landing as `memory/tool_memory` to avoid the pre-existing `memory/tools/` agent-tool directory). Plan, target tree, and move-PR rules: [`docs/specs/2026-08-02-core-kernel-domain-reorg.md`](docs/specs/2026-08-02-core-kernel-domain-reorg.md). A move never changes the wire surface — RPC namespaces are string literals in `ControllerSchema`, not derived from module paths — so **do not rename namespace strings to match new paths**. @@ -732,8 +732,8 @@ Two columns because there are two sets (see above): **Contrib** is `[features] d | `flows` | ON | ON | `openhuman::flows` (saved automation graphs — create/run/schedule, the `workflow_builder` + `flow_discovery` agents), `openhuman::flows::tinyflows` (engine seam), `openhuman::flows::rhai` (`.ragsh` language-workflow tool) | `tinyflows`, `jaq-core`, `jaq-std`, `jaq-json`, `rhai` | | `mcp` | ON | ON | `openhuman::mcp::server` (the `openhuman mcp` stdio/HTTP server), `openhuman::mcp::registry` (dynamic Smithery installs — `mcp_clients` RPC namespace, SQLite, boot spawn, supervisor, OAuth), `openhuman::mcp::audit` (write-audit log), and the static config-declared server set in `openhuman::mcp::config_servers`. ~19 agent tools, ~20k LOC | **none** — and the `tinymcp` module extraction does not change that either; see the scope note | | `tui` | OFF | — | `openhuman::tui` — the tabbed ratatui/crossterm CLI UI (Logs, Chat, Config, Settings), auto-opened by bare `openhuman` on interactive non-container hosts and forced with `openhuman tui` (alias `chat`). Runs the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | -| `channels` | ON | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `channels::webview_accounts` / `webview_apis` / `webview_notifications` / `channels::whatsapp_data` webview-bridge domains (incl. the 3 `whatsapp_data_*` agent tools). **Carve-outs `channels::{traits, cli}` stay ungated.** | **28** via `tinychannels/{email,lark}` — the crate itself stays (load-bearing), its two heavy providers do not | -| `memory-git` | OFF | ON | `openhuman::memory::diff` (git-backed snapshots/checkpoints/read markers, the `memory_diff` RPC namespace + agent tool) and the git wiki mirror in `memory::store::content::wiki_git`. **Type carve-out**: `memory::diff::types` compiles in BOTH builds — the always-on memory profile renders `CrossSourceDiff`/`ChangeKind` into prompts, and tinycortex makes the matching split (its `memory::diff::{types,source}` are ungated, only the `Ledger`/`DiffEngine` half sits behind `git-diff`). Off ⇒ `memory_diff` is unknown-method, the tool is absent, the embedded driver drops `Capability::Diff` **and** `as_diff()` returns `None` in lockstep (`audit_provider` fails on either half alone), and summary nodes are still written to disk but not mirrored into git. **This crate declares no `git2`** — tinycortex owns every libgit2 call in the stack (the diff ledger, the wiki mirror, the persona git-history reader), and the gate reaches the cohort by forwarding `tinycortex/git-diff` + `tinycortex/wiki-git`; `tinymemory-core/memory-git` forwards the same pair. Do not re-add a direct `git2` dependency to this crate or to `tinymemory-core`: it would buy no crates and invite a second major pin, which `links = "git2"` makes a hard cargo error. Test code that must read a ledger back goes through the `tinycortex::git2` re-export (`tests/memory_artifacts_e2e.rs`). | **3**: `git2`, `libgit2-sys`, `libz-sys` — two of the five native C builds in the kernel profile, the largest native shed in the program | +| `channels` | ON | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `webview_notifications` bridge domain. **Carve-outs `channels::{traits, cli}` stay ungated.** The family now owns **no agent tool** — the three `whatsapp_data_*` tools were its only ones and went with the store (see below) — which is why `DomainGroup::Channels` is in `TOOL_LESS` in `tools/ops_tests.rs`, alongside `Relay`. | **28** via `tinychannels/{email,lark}` — the crate itself stays (load-bearing), its two heavy providers do not | +| `memory-git` | OFF | **OFF** | `openhuman::memory::diff` (git-backed snapshots/checkpoints/read markers, the `memory_diff` RPC namespace + agent tool) and the git wiki mirror in `memory::store::content::wiki_git`. **Type carve-out**: `memory::diff::types` compiles in BOTH builds — the always-on memory profile renders `CrossSourceDiff`/`ChangeKind` into prompts, and tinycortex makes the matching split (its `memory::diff::{types,source}` are ungated, only the `Ledger`/`DiffEngine` half sits behind `git-diff`). Off ⇒ `memory_diff` is unknown-method, the tool is absent, the embedded driver drops `Capability::Diff` **and** `as_diff()` returns `None` in lockstep (`audit_provider` fails on either half alone), and summary nodes are still written to disk but not mirrored into git. **This crate declares no `git2`** — tinycortex owns every libgit2 call in the stack (the diff ledger, the wiki mirror, the persona git-history reader), and the gate reaches the cohort by forwarding `tinycortex/git-diff` + `tinycortex/wiki-git`; `tinymemory-core/memory-git` forwards the same pair. Do not re-add a direct `git2` dependency to this crate or to `tinymemory-core`: it would buy no crates and invite a second major pin, which `links = "git2"` makes a hard cargo error. Test code that must read a ledger back goes through the `tinycortex::git2` re-export (`tests/memory_artifacts_e2e.rs`). | **3**: `git2`, `libgit2-sys`, `libz-sys` — two of the five native C builds in the kernel profile, the largest native shed in the program. **Also removed from the product set**: those same two were 2 of the 4 native builds in the product profile, so the shipped app now needs only `libsqlite3-sys` + `ring`. Three crates is a small shed; two C toolchain builds is not, and `memory_diff` had no frontend caller. | | `contacts` | OFF | ON | `memory::people::address_book`'s macOS CNContactStore reader — the address-book seeding path for the people domain. Leaf gate over a **pre-existing** off-state: the module already shipped a non-macOS `imp` stub returning an empty contact list, so the gate only widens that stub's cfg. `read`/`read_with`/`AddressBookError`/`SystemContactsSource` and the whole `people` RPC surface stay compiled in every build; off ⇒ a refresh seeds nothing instead of failing. | **6** on macOS (`objc2`, `objc2-foundation`, `objc2-contacts`, `block2` + 2 transitive). **No-op on Linux/Windows** — never in those graphs, so the kernel-floor ratchet does not move. Verify cross-target: `cargo tree --target aarch64-apple-darwin -e normal -i objc2-contacts --no-default-features` (294 → 288 packages). | | `runtime-node` | OFF | ON | `runtime::node` (the client that asks the `tinyruntime` module for a Node.js toolchain), the `runtime::javascript` language slot, `runtime::pool::node`, the `node_exec` / `npm_exec` agent tools, and the `node_runtime` harness-init step. **Facade + stub** — `ShellTool` holds `Option>` and `shell.rs` is kernel, so the module cannot simply vanish; `runtime/node/stub.rs` carries the `NodeBootstrap` type surface while registration sites are leaf-gated. **The generic native-tool dispatcher (`runtime::node::ops` / `runtime::node::types`) is NOT gated** — it backs both the gated `javascript.*` controllers and the ungated `flows` `oh:` `NativeToolBackend`, so native flow tools (`memory_search`, file, shell, …) keep working when the managed Node runtime is off. Off ⇒ `try_cached`/`probe_installed` return `None` and the shell never prepends a managed bin dir, identical to today's `node.enabled = false` path. | **Nothing any more.** This gate used to shed `xz2` and its static liblzma C build; download and extraction moved into the `tinyruntime` module, so that native build left the manifest for **every** configuration rather than only for slim ones. The gate still buys the absence of the tools and controllers. | @@ -1019,10 +1019,10 @@ Leaf-gate pattern with **two ungated carve-outs and no stub file** — the reach - **Two ungated carve-outs.** `pub mod traits;` (a one-line `tinychannels` `Channel`/`SendMessage` re-export) and `pub mod cli;` (`CliChannel`, a dependency-free local stdin/stdout REPL) stay compiled in **all** builds — both are reached by the always-on agent-harness interactive loop (`agent::harness::session::runtime::run_interactive`). Same shape as the other ungated carve-outs. `channels::mod.rs` `#[cfg(feature = "channels")]`s everything else; nothing inside the gated submodules changes. - **The in-app web chat is NOT gated.** `openhuman::web_chat` (RPC namespace `channel`, decoupled from `channels/` in #5002 + #5003 which also moved `learning` out) is core product surface and stays always-compiled even though its runtime tag is `DomainGroup::Channels`. Its registration push in `src/core/all.rs` is deliberately left ungated; the both-ways test pins `channel` present with the feature OFF. - **Three mis-housed imports were retargeted to `tinychannels` (no stub needed).** `cron/bus.rs` (`Channel`/`SendMessage`/`ChannelMessage`), `memory_conversations/bus.rs` (`ChannelMessage` + `context::conversation_history_key`), and `voice/audio_toolkit/ops.rs` (`providers::email_channel::EmailChannel`) reached the gated domain only to pick up symbols that actually live in `tinychannels`; pointing them straight at the crate removes the always-on → gated edge (and the voice→channels cross-gate edge). The old `channels::` paths were 1-line delegations / `pub use` re-exports of exactly these. -- **Leaf-gated call sites** (each carries its own `#[cfg]`): the 5 controller-registration pushes in `src/core/all.rs` (channels controllers, `webview_apis`, `webview_notifications`, public + internal `whatsapp_data`), the `ChannelInboundSubscriber` + web-only-proactive block in `src/core/jsonrpc.rs`, `spawn_channels_service` in `src/core/runtime/services.rs`, the `whatsapp_data::global::init` block in `src/core/runtime/context.rs`, and the `whatsapp_data::tools::*` glob + 3 `WhatsAppData*Tool` registrations in `src/openhuman/tools/{mod,ops}.rs`. The `whatsapp_data` `pub mod` declaration now lives in `channels/mod.rs` (still `#[cfg(feature = "channels")]`, because the parent stays ungated for the `traits`/`cli` carve-outs); `webview_apis` / `webview_notifications` moved under `desktop/` in the family reorg and stay leaf-gated there. String-match arms (`"channels" =>` descriptions, `whatsapp_data_` in `group_for_namespace`) stay **ungated** — they are data. +- **Leaf-gated call sites** (each carries its own `#[cfg]`): the controller-registration pushes in `src/core/all.rs` (channels controllers, `webview_notifications`), the `ChannelInboundSubscriber` + web-only-proactive block in `src/core/jsonrpc.rs`, and `spawn_channels_service` in `src/core/runtime/services.rs`. `webview_notifications` moved under `desktop/` in the family reorg and stays leaf-gated there. String-match arms (`"channels" =>` descriptions) stay **ungated** — they are data. - **`start_bootstrap_jobs`' `services.channels` block keeps running slim** — it drives composio sync / workspace-memory sync / orchestration drain and names **no** `channels::` symbol, so it stays ungated by design. - **No CLI change.** There is no `openhuman channels` subcommand; generic namespace resolution yields "unknown namespace" when off (the `flows` precedent — acceptable). -- **Both-ways tests.** `channels_controllers_{registered_when_feature_on,absent_when_feature_off}` in `src/core/all_tests.rs` pin the controller surface (the OFF half also asserts `channel`/web_chat survives), and `whatsapp_data_tools_{present_when_channels_on,absent_when_channels_off}` in `src/openhuman/tools/ops_tests.rs` pin the 3 agent tools (that module has the full-tool-list machinery). CI's smoke lane runs `cargo check` only, so run `cargo test --lib --no-default-features core::all::tests` locally after touching any gated surface. +- **Both-ways tests.** `channels_controllers_{registered_when_feature_on,absent_when_feature_off}` in `src/core/all_tests.rs` pin the controller surface (the OFF half also asserts `channel`/web_chat survives), and `whatsapp_data_tools_are_gone_in_every_build` in `src/openhuman/tools/ops_tests.rs` pins that the removed tool family stays removed in both directions of the gate. CI's smoke lane runs `cargo check` only, so run `cargo test --lib --no-default-features core::all::tests` locally after touching any gated surface. ### Event bus (`src/core/event_bus/`) From 2099752396fc8962f33962ea3e82ca1d633132a9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:42:01 +0300 Subject: [PATCH 18/44] docs(tools): remove stale whatsapp_data references Update the tools README to stop describing whatsapp_data as re-exported and registered through the tools module. This keeps the documentation aligned with the current tool ownership and registration structure. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/tools/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/openhuman/tools/README.md b/src/openhuman/tools/README.md index 5f4763f141f..247c7891acb 100644 --- a/src/openhuman/tools/README.md +++ b/src/openhuman/tools/README.md @@ -20,7 +20,7 @@ The agent tool layer. Defines the core [`Tool`] trait every agent-callable capab | File | Role | | --- | --- | -| `src/openhuman/tools/mod.rs` | Export hub. Declares submodules, re-exports built-in impls and the domain-owned tool sets (`agent`, `voice::audio_toolkit`, `codegraph`, `composio`, `cron`, `integrations`, `memory`, `wallet`, `whatsapp_data`), and the `all_tools_*` controller pair. | +| `src/openhuman/tools/mod.rs` | Export hub. Declares submodules, re-exports built-in impls and the domain-owned tool sets (`agent`, `voice::audio_toolkit`, `codegraph`, `composio`, `cron`, `integrations`, `memory`, `wallet`), and the `all_tools_*` controller pair. | | `src/openhuman/tools/traits.rs` | The `Tool` trait + `ToolResult`/`ToolContent` re-export, `ToolSpec`, `PermissionLevel`, `ToolScope`, `ToolCategory`, `ToolCallOptions`. Defines per-tool hooks: permission level (incl. args-aware), scope, category, concurrency safety, `external_effect[_with_args]` (drives approval gating), `max_result_size_chars`, markdown preference, generated-runtime context. | | `src/openhuman/tools/ops.rs` | Registry assembly: `default_tools`, `default_tools_with_runtime`, `all_tools`, `all_tools_with_runtime`, `browser_allowed_domains`. All config-gating logic lives here. | | `src/openhuman/tools/schemas.rs` | JSON-RPC `tools` namespace controllers + `handle_*` fns. `all_controller_schemas` / `all_registered_controllers` (re-exported as `all_tools_*`). | @@ -73,7 +73,7 @@ This module **owns** the cross-cutting built-in tools (the only ones that belong - **Generic network**: `http_request`, `web_fetch`, `curl`, `gitbooks_search`/`gitbooks_get_page`, MCP bridge (`mcp` list/call), `mcp_setup` tools, `gmail_unsubscribe`. - **Search**: `web_search` and provider-specific search families are registered by `openhuman::search::registry`; `search.engine = "disabled"` suppresses this surface entirely. -Domain-owned tools (memory, cron, wallet, composio, codegraph, integrations, whatsapp_data, voice::audio_toolkit, agent sub-dispatch like `spawn_subagent`/`spawn_async_subagent`/`delegate`/`todo`/`plan_exit`/`run_skill`) are **registered** in `all_tools` but implemented in their respective domains and only re-exported through this module. +Domain-owned tools (memory, cron, wallet, composio, codegraph, integrations, voice::audio_toolkit, agent sub-dispatch like `spawn_subagent`/`spawn_async_subagent`/`delegate`/`todo`/`plan_exit`/`run_skill`) are **registered** in `all_tools` but implemented in their respective domains and only re-exported through this module. ## Events @@ -96,7 +96,7 @@ None. No `store.rs`; the module holds no persisted state. Tools that persist (me - `openhuman::mcp::config_servers` / `openhuman::mcp::registry` — generic remote MCP server registry + bridge tools. - `openhuman::skills` — `skills::types::{ToolResult, ToolContent}` (the unified result type) + skill-run spawning. - `openhuman::agent::learning` — LinkedIn enrichment scrape/render for the Apify RPC handler. -- `openhuman::web3::wallet`, `openhuman::cron`, `openhuman::codegraph`, `openhuman::voice::audio_toolkit`, `openhuman::channels::whatsapp_data` — domain-owned tools re-exported and registered. +- `openhuman::web3::wallet`, `openhuman::cron`, `openhuman::codegraph`, `openhuman::voice::audio_toolkit` — domain-owned tools re-exported and registered. - `openhuman::security::approval`, `openhuman::agent::context`, `openhuman::security::credentials`, `openhuman::platform::update`, `openhuman::util` — supporting types used by individual tools. - `core::all` — `ControllerSchema`, `FieldSchema`, `TypeSchema`, `RegisteredController`, `ControllerFuture` for the RPC controller surface. @@ -107,7 +107,7 @@ None. No `store.rs`; the module holds no persisted state. Tools that persist (me - `openhuman::channels`, `openhuman::routing`, `openhuman::inference::provider` — build tool sets / clean schemas per provider. - `openhuman::tools::agent_policy`, `openhuman::security::approval` — read tool metadata (category, external-effect) for policy/approval decisions. - `openhuman::tools::registry`, `openhuman::runtime::node`, `openhuman::mcp::server` — registry/exposure consumers. -- Many domains re-export their own tools through this module (cron, memory, wallet, composio, integrations, codegraph, whatsapp_data, voice::audio_toolkit). +- Many domains re-export their own tools through this module (cron, memory, wallet, composio, integrations, codegraph, voice::audio_toolkit). ## Notes / gotchas From f635174a82f517cba45bdfa72ca62810a521e87c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:42:35 +0300 Subject: [PATCH 19/44] chore(deps): refresh Rust dependency lockfile Remove obsolete transitive crates and dependency references while normalizing the remaining lockfile entries. This keeps the Rust dependency graph aligned with the current project requirements. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src-tauri/Cargo.lock | 190 +-------------------------------------- 1 file changed, 1 insertion(+), 189 deletions(-) diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index f9ff74144ba..99bd65d4ca6 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -8,12 +8,9 @@ version = "0.63.18" dependencies = [ "anyhow", "async-trait", - "base64 0.22.1", "block2 0.6.2", "chrono", - "cpal", "directories 5.0.1", - "futures-util", "hex", "log", "mac-notification-sys", @@ -28,7 +25,6 @@ dependencies = [ "parking_lot", "rand 0.9.5", "reqwest 0.12.28", - "rfd", "rusqlite", "rustls", "sentry", @@ -48,11 +44,9 @@ dependencies = [ "tinybox-host", "tinybox-ssh", "tokio", - "tokio-tungstenite 0.24.0", "tokio-util", "toml 0.8.2", "url", - "uuid", "windows-sys 0.59.0", ] @@ -193,27 +187,6 @@ dependencies = [ "password-hash", ] -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.5", - "raw-window-handle", - "serde", - "serde_repr", - "tokio", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -1589,15 +1562,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading 0.8.9", -] - [[package]] name = "dlopen2" version = "0.8.2" @@ -1651,12 +1615,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dpi" version = "0.1.2" @@ -2359,18 +2317,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "git2" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" -dependencies = [ - "bitflags 2.13.1", - "libc", - "libgit2-sys", - "log", -] - [[package]] name = "glib" version = "0.18.5" @@ -3388,18 +3334,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "libgit2-sys" -version = "0.18.8+1.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" -dependencies = [ - "cc", - "libc", - "libz-sys", - "pkg-config", -] - [[package]] name = "libloading" version = "0.7.4" @@ -3440,18 +3374,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-sys" -version = "1.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-keyutils" version = "0.2.5" @@ -4771,7 +4693,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.38.4", + "quick-xml", "serde", "time", ] @@ -4816,12 +4738,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - [[package]] name = "poly1305" version = "0.8.0" @@ -4996,15 +4912,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.11" @@ -5361,30 +5268,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rfd" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" -dependencies = [ - "ashpd", - "block2 0.6.2", - "dispatch2", - "js-sys", - "log", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "pollster", - "raw-window-handle", - "urlencoding", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.59.0", -] - [[package]] name = "ring" version = "0.17.14" @@ -5646,12 +5529,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -7155,8 +7032,6 @@ dependencies = [ "chrono", "dirs 5.0.1", "futures", - "git2", - "hex", "log", "objc2 0.6.4", "objc2-contacts", @@ -7497,7 +7372,6 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] @@ -8278,66 +8152,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wayland-backend" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" -dependencies = [ - "cc", - "downcast-rs", - "rustix", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" -dependencies = [ - "bitflags 2.13.1", - "rustix", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" -dependencies = [ - "bitflags 2.13.1", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" -dependencies = [ - "proc-macro2", - "quick-xml 0.41.0", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "pkg-config", -] - [[package]] name = "web-sys" version = "0.3.104" @@ -9302,7 +9116,6 @@ dependencies = [ "rustix", "serde", "serde_repr", - "tokio", "tracing", "uds_windows", "uuid", @@ -9513,7 +9326,6 @@ dependencies = [ "endi", "enumflags2", "serde", - "url", "winnow 1.0.4", "zcheapstr", "zvariant_derive", From f2284d9b2892e18783574e4a748acffdc4eb81a0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:43:27 +0300 Subject: [PATCH 20/44] chore: remove obsolete whatsapp data integration remnants Remove the stale WhatsApp data permission and end-to-end scenario now that the shell store and its scanner are gone. Update dependency and observability comments to reflect the remaining iMessage SQLite usage and current domain boundaries. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 3 +-- app/src-tauri/Cargo.toml | 8 +++--- .../permissions/allow-core-process.toml | 9 ------- app/test/e2e/specs/mega-flow.spec.ts | 26 ------------------- src/core/observability.rs | 7 +++-- src/openhuman/tools/ops.rs | 2 +- 6 files changed, 9 insertions(+), 46 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2195381a94c..ad793845130 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1112,8 +1112,7 @@ medulla = [] # Channels domain: `openhuman::channels` (external-messaging providers — Telegram, # Discord, Slack, Signal, WhatsApp, iMessage, IRC, … — plus the channel runtime, # controllers, host, proactive messaging and inbound dispatch) together with the -# `webview_accounts` / `webview_apis` / `webview_notifications` / `whatsapp_data` -# webview-bridge domains. Default-ON — the desktop app always ships with +# `webview_notifications` bridge domain. Default-ON — the desktop app always ships with # channels. Slim / headless builds opt out via `--no-default-features --features # ""`. Composes with the runtime # `DomainSet::Channels` flag (#4796): this feature narrows the compile-time diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 82db37908b4..02f69fba6dc 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -89,10 +89,10 @@ sentry = { version = "0.47.0", default-features = false, features = ["backtrace" # Used by the imessage_scanner module. anyhow = "1.0" -# SQLite for the shell-side whatsapp_data store (relocated from the core). All -# platforms — the store persists on Windows/Linux/macOS. Pinned to match the -# core crate so a single bundled SQLite is linked. (The macOS-only iMessage -# scanner also uses rusqlite; this general dep covers it too.) +# SQLite for the macOS iMessage scanner, which reads ~/Library/Messages/chat.db +# read-only. Declared in the general table rather than under the macOS target so +# `cargo check` on Linux/Windows resolves the same graph. Pinned to match the +# core crate so a single bundled SQLite is linked. rusqlite = { version = "=0.40.2", features = ["bundled"] } parking_lot = "0.12" chrono = "0.4" diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index 6d9f7774f98..d0be181855f 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -100,15 +100,6 @@ allow = [ "companion_config_get", "companion_config_set", - # ========================= - # STRUCTURED WHATSAPP DATA - # ========================= - # The SQLite store moved from the core into the Tauri shell. Intelligence - # views now query it through these read-only invoke commands. - "whatsapp_data_list_chats", - "whatsapp_data_list_messages", - "whatsapp_data_search_messages", - # ========================= # ACCOUNT WEBVIEW # ========================= diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts index c5845f414d5..48431f55556 100644 --- a/app/test/e2e/specs/mega-flow.spec.ts +++ b/app/test/e2e/specs/mega-flow.spec.ts @@ -430,32 +430,6 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { console.log(`${LOG} post-reset login proves config.toml survives reset`); }); - // ------------------------------------------------------------------------- - // Scenario 7 — WhatsApp native read flow. - // Storage and frontend reads now live in the Tauri shell, so verify the - // renderer-to-native command and its list-shaped response. - // ------------------------------------------------------------------------- - it('WhatsApp read-only: native list_chats returns expected shape', async () => { - await resetEverything('after Scenario 6'); - - await triggerDeepLink('openhuman://auth?token=mega-whatsapp-token'); - await waitForMockRequest('POST', '/auth/login-token/consume', 15_000); - clearRequestLog(); - - // WhatsApp storage moved from core controllers to shell-native handlers. - // Exercise the renderer's production IPC boundary and assert the native - // list shape; an empty store is valid before a scanner has ingested data. - const list = await invokeTauri('whatsapp_data_list_chats', { req: {} }); - expect(list.__error).toBeUndefined(); - const chats = list.__ok ?? []; - expect(Array.isArray(chats)).toBe(true); - console.log(`${LOG} whatsapp list_chats returned ${chats.length} chat(s)`); - - // Session must still be healthy. - const ping = await callOpenhumanRpc('core.ping', {}); - expect(ping.ok).toBe(true); - }); - // ------------------------------------------------------------------------- // Scenario 8 — Spawn-depth limit. // SKIPPED: `openhuman.agent_run` does not exist; the closest RPC methods diff --git a/src/core/observability.rs b/src/core/observability.rs index 436e7c3ed2d..3ddd8ce095c 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -141,10 +141,9 @@ pub enum ExpectedErrorKind { /// MemoryStoreBreakerOpen, // (WhatsApp structured-ingest SQLite busy/corrupt classifiers were removed - // when the whatsapp_data store moved to the Tauri shell: the core no longer - // runs the ingest write path, so the `[whatsapp_data] ingest failed:` - // envelope these matched is never produced core-side. The shell store keeps - // its own once-per-episode corruption report + quarantine/rebuild recovery.) + // when that store moved to the Tauri shell; the store itself is gone now — + // its only writer was the CDP scanner deleted in #5478 — so no build + // produces the envelope these matched.) /// Host disk is full — the filesystem returned `ENOSPC` to a write, /// `mkdir`, or `open` syscall. The user cannot recover from this without /// freeing space on their machine, and Sentry has no remediation path diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index b19b7c13e0c..154d28096e7 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -1371,7 +1371,7 @@ fn tool_group(name: &str) -> crate::core::all::DomainGroup { // ── Families carved out of Platform by the DomainGroup realignment ────── // Each of these previously fell through to Platform, which meant the tool // stayed callable when its family was gated off under a custom DomainSet — - // the leak the #4808 review flagged for whatsapp_data. Keep these in + // leak the #4808 review flagged. Keep these in // lockstep with the `push(...)` tags in `core::all`. // // Automation: scheduled jobs (`cron_*`) plus the subconscious monitor + From 124357c52549ad3e2639d6880a891fc5400ba59e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:43:34 +0300 Subject: [PATCH 21/44] test(e2e): correct account switch reset scenario reference Update the reset label to match the preceding Scenario 6 setup, keeping the end-to-end test's account-switch flow accurately described. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/test/e2e/specs/mega-flow.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts index 48431f55556..dc68465cc08 100644 --- a/app/test/e2e/specs/mega-flow.spec.ts +++ b/app/test/e2e/specs/mega-flow.spec.ts @@ -455,7 +455,7 @@ describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { // and that `threads_list` returns a valid (non-error) array. // ------------------------------------------------------------------------- it('account switch: user A threads invisible to user B and still present after restore', async () => { - await resetEverything('after Scenario 7'); + await resetEverything('after Scenario 6'); // ── User A login ────────────────────────────────────────────────────── await triggerDeepLink('openhuman://auth?token=mega-acct-switch-user-a'); From 9424d18d6ee77e29dfc02edf0bf50461429112b5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:43:47 +0300 Subject: [PATCH 22/44] docs: update WhatsApp agent retrieval coverage Mark WhatsApp agent retrieval as removed because its tools queried a SQLite store no longer written after the scanner was deleted. Document the remaining test that verifies the tools stay absent. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/TEST-COVERAGE-MATRIX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index d301122e2fe..5196d97f643 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -427,7 +427,7 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | ------ | ------------------------- | ----- | ---------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 10.3.1 | Incoming Message Sync | RU+WD | `src/openhuman/channels/tests/`, `gmail-flow.spec.ts` | ✅ | | | 10.3.2 | Message Deduplication | RU | `src/openhuman/channels/tests/` | ✅ | | -| 10.3.3 | WhatsApp Agent Retrieval | RU | `src/openhuman/channels/whatsapp_data/tools/`, `tests/json_rpc_e2e.rs::whatsapp_data_agent_tools_e2e_1341` | ✅ | Three read-only agent tools wrap the local SQLite store; ingest stays internal-only. See [`src/openhuman/channels/whatsapp_data/README.md`](../src/openhuman/channels/whatsapp_data/README.md). | +| 10.3.3 | WhatsApp Agent Retrieval | RU | `src/openhuman/tools/ops_tests.rs::whatsapp_data_tools_are_gone_in_every_build` | ➖ | **Removed.** The three read-only agent tools queried a shell-side SQLite store whose only writer was the CDP `whatsapp_scanner`, deleted in #5478 — so from that release they read a store nothing could write. The surviving test pins that the tools stay absent. | | 10.3.4 | Real-Time vs Delayed Sync | RU | `src/openhuman/channels/tests/runtime_dispatch.rs` | ✅ | | ### 10.4 Messaging Operations From 7ee84c04657536f8115c14bda227849173f3ec72 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:44:23 +0300 Subject: [PATCH 23/44] chore(docs): remove obsolete WhatsApp coverage entry Remove the obsolete WhatsApp Agent Retrieval coverage entry and feature ID because the associated tools are no longer available. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/TEST-COVERAGE-MATRIX.md | 1 - scripts/feature-ids.json | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 5196d97f643..c70e437a1f4 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -427,7 +427,6 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an | ------ | ------------------------- | ----- | ---------------------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 10.3.1 | Incoming Message Sync | RU+WD | `src/openhuman/channels/tests/`, `gmail-flow.spec.ts` | ✅ | | | 10.3.2 | Message Deduplication | RU | `src/openhuman/channels/tests/` | ✅ | | -| 10.3.3 | WhatsApp Agent Retrieval | RU | `src/openhuman/tools/ops_tests.rs::whatsapp_data_tools_are_gone_in_every_build` | ➖ | **Removed.** The three read-only agent tools queried a shell-side SQLite store whose only writer was the CDP `whatsapp_scanner`, deleted in #5478 — so from that release they read a store nothing could write. The surviving test pins that the tools stay absent. | | 10.3.4 | Real-Time vs Delayed Sync | RU | `src/openhuman/channels/tests/runtime_dispatch.rs` | ✅ | | ### 10.4 Messaging Operations diff --git a/scripts/feature-ids.json b/scripts/feature-ids.json index 30fbea7016a..3bc60b6354d 100644 --- a/scripts/feature-ids.json +++ b/scripts/feature-ids.json @@ -97,7 +97,6 @@ "10.2.3", "10.3.1", "10.3.2", - "10.3.3", "10.4.1", "10.4.2", "10.4.3", From 17a2580a7bd21caea0081a5772e4b4386a0babcb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:44:52 +0300 Subject: [PATCH 24/44] docs(architecture): update Tauri shell module listing Align the architecture documentation with the current tree after removing Chromium-based webview infrastructure. Document the remaining iMessage scanner and clarify artifact export behavior and removed modules. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../developing/architecture/tauri-shell.md | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/gitbooks/developing/architecture/tauri-shell.md b/gitbooks/developing/architecture/tauri-shell.md index 04f3d965b19..8ee034f539b 100644 --- a/gitbooks/developing/architecture/tauri-shell.md +++ b/gitbooks/developing/architecture/tauri-shell.md @@ -49,18 +49,11 @@ app/src-tauri/src/ │ # core at a URL, or one this app provisions in a container / │ # over SSH / both (tinybox). types · store · ops · registry · │ # commands -├── cdp/ # Chrome DevTools Protocol plumbing for child webviews -├── cef_preflight.rs / cef_profile.rs / cef_singleton_wait.rs / cef_stale_reap.rs # CEF cache/profile management -├── webview_accounts/ # Embedded provider account webviews (open/close/bounds/notifications) -├── webview_apis/ # WS bridge for webview-side APIs -├── discord_scanner/ … whatsapp_scanner/ … # Per-provider scanners (slack, telegram, wechat, -│ # gmessages, imessage, meet, …) driving CDP -├── meet_audio/ meet_call/ meet_video/ # Google Meet call window + media integration -├── fake_camera/ # Virtual camera support +├── imessage_scanner/ # macOS-only: reads ~/Library/Messages/chat.db directly (never used CDP) ├── mascot_native_window.rs / notch_window.rs / window_state.rs -├── dictation_hotkeys.rs / ptt_hotkeys.rs / ptt_overlay.rs / companion_commands.rs +├── dictation_hotkeys.rs / ptt_hotkeys.rs / ptt_overlay.rs ├── native_notifications/ notification_settings/ -├── artifact_commands.rs # Artifact export (save dialog / Downloads) +├── artifact_commands.rs # Artifact export (copy into Downloads) ├── workspace_paths.rs # Safe workspace-relative file open/reveal/preview ├── app_update.rs # Updater support (commands live in lib.rs) ├── loopback_oauth.rs # Localhost OAuth redirect listener @@ -72,6 +65,13 @@ app/src-tauri/src/ └── stderr_panic_hook.rs / reset_reboot_schedule.rs ``` +This listing was rewritten against the real tree after #5478 / #5456. Gone with +the move off Chromium: `cdp/`, the `cef_*` preflight modules, `webview_accounts/`, +every `*_scanner/` but `imessage_scanner/`, the `meet_*` call window, +`fake_camera/` and `companion_commands.rs`. `webview_apis/` went later — it was +the WS bridge those scanners called, and once they were gone its router +dispatched nothing while still binding a loopback listener at boot. + There is **no** `src-tauri/src/services/session_service.rs` in this tree; session semantics are handled in the web layer + backend + core as applicable. ### Data flow: UI → core From 22231bb0829a426c1e8e428e5dc3a98c4ee02615 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:48:19 +0300 Subject: [PATCH 25/44] chore(memory): remove unused safeInvoke imports Remove stale safeInvoke imports and test references from the memory command module. This keeps the implementation and tests aligned with their current dependencies. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/utils/tauriCommands/memory.test.ts | 3 +-- app/src/utils/tauriCommands/memory.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/utils/tauriCommands/memory.test.ts b/app/src/utils/tauriCommands/memory.test.ts index 19e80c2b4a1..db74f52777d 100644 --- a/app/src/utils/tauriCommands/memory.test.ts +++ b/app/src/utils/tauriCommands/memory.test.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest'; import { callCoreRpc } from '../../services/coreRpcClient'; -import { isTauri, safeInvoke } from './common'; +import { isTauri } from './common'; import { aiListMemoryFiles, memoryLearnAll, @@ -17,7 +17,6 @@ vi.mock('./common', () => ({ isTauri: vi.fn(() => true), safeInvoke: vi.fn() })) const mockCallCoreRpc = callCoreRpc as Mock; const mockIsTauri = isTauri as Mock; -const mockSafeInvoke = safeInvoke as Mock; beforeEach(() => { vi.clearAllMocks(); diff --git a/app/src/utils/tauriCommands/memory.ts b/app/src/utils/tauriCommands/memory.ts index 56bebd83a61..659e5b3de41 100644 --- a/app/src/utils/tauriCommands/memory.ts +++ b/app/src/utils/tauriCommands/memory.ts @@ -2,7 +2,7 @@ * Memory subsystem commands. */ import { callCoreRpc } from '../../services/coreRpcClient'; -import { isTauri, safeInvoke } from './common'; +import { isTauri } from './common'; export interface MemoryDebugDocument { documentId: string; From 0034ae13600234af3b6352aa59f6c0949deb2f09 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:49:51 +0300 Subject: [PATCH 26/44] test(e2e): remove unused Tauri invocation helper Remove the unused browser-side Tauri invocation helper from the mega-flow test to simplify the test setup. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/test/e2e/specs/mega-flow.spec.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/app/test/e2e/specs/mega-flow.spec.ts b/app/test/e2e/specs/mega-flow.spec.ts index dc68465cc08..f8f1cf1011b 100644 --- a/app/test/e2e/specs/mega-flow.spec.ts +++ b/app/test/e2e/specs/mega-flow.spec.ts @@ -113,28 +113,6 @@ async function resetEverything(label: string): Promise { clearRequestLog(); } -async function invokeTauri( - command: string, - payload: Record = {} -): Promise<{ __ok?: T; __error?: string }> { - return (await browser.executeAsync( - (cmd, args, done) => { - const invoke = (window as any).__TAURI_INTERNALS__?.invoke; - if (typeof invoke !== 'function') { - done({ __error: 'window.__TAURI_INTERNALS__.invoke not available' }); - return; - } - invoke(cmd, args) - .then((result: unknown) => done({ __ok: result })) - .catch((err: unknown) => - done({ __error: err instanceof Error ? err.message : String(err) }) - ); - }, - command, - payload - )) as { __ok?: T; __error?: string }; -} - describe('Mega flow — login + Gmail OAuth + Composio in one session', () => { before(async function beforeSuite() { this.timeout(90_000); From 02599842ced92110c0722b8e31b72ef165552da0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:51:03 +0300 Subject: [PATCH 27/44] refactor(channels): reorder module declarations Reorder channel module declarations without changing runtime behavior. Clean up related TypeScript formatting and file termination. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/utils/tauriCommands/memory.test.ts | 7 +------ app/src/utils/tauriCommands/memory.ts | 1 - src/openhuman/channels/mod.rs | 8 ++++---- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/app/src/utils/tauriCommands/memory.test.ts b/app/src/utils/tauriCommands/memory.test.ts index db74f52777d..b8515c2febd 100644 --- a/app/src/utils/tauriCommands/memory.test.ts +++ b/app/src/utils/tauriCommands/memory.test.ts @@ -5,12 +5,7 @@ import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest'; import { callCoreRpc } from '../../services/coreRpcClient'; import { isTauri } from './common'; -import { - aiListMemoryFiles, - memoryLearnAll, - memorySyncAll, - memorySyncChannel, -} from './memory'; +import { aiListMemoryFiles, memoryLearnAll, memorySyncAll, memorySyncChannel } from './memory'; vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); vi.mock('./common', () => ({ isTauri: vi.fn(() => true), safeInvoke: vi.fn() })); diff --git a/app/src/utils/tauriCommands/memory.ts b/app/src/utils/tauriCommands/memory.ts index 659e5b3de41..2ce269d3d26 100644 --- a/app/src/utils/tauriCommands/memory.ts +++ b/app/src/utils/tauriCommands/memory.ts @@ -351,4 +351,3 @@ export async function memoryLearnAll(namespaces?: string[]): Promise Date: Sat, 29 Aug 2026 18:53:42 +0300 Subject: [PATCH 28/44] chore: update application entry point Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src-tauri/src/lib.rs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 77aafb8ef47..17bb6dcc871 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2941,11 +2941,6 @@ pub fn run() { let builder = builder.manage(std::sync::Arc::new(imessage_scanner::ScannerRegistry::new())); builder .setup(move |app| { - // Structured WhatsApp Web data store lives shell-side. Register the - // in-process native handlers so the core agent tools (list/search) - // and the scanner ingest path can reach the SQLite store over the - // native request bus. No handler = graceful degradation core-side. - #[cfg(windows)] { // `register_all` writes HKCU\Software\Classes\openhuman so the @@ -3332,12 +3327,11 @@ pub fn run() { core_rpc::relay_http_rpc, overlay_parent_rpc_url, process_diagnostics_list_owned, - // Artifact export commands — both cross-platform (#3162). The - // Downloads command was previously macOS/Linux-gated, but the - // `directories` + `tokio::fs::copy` flow compiles on Windows too, - // and the Save-As fallback needs it there (CodeRabbit on #4127). + // Artifact export — cross-platform. Previously macOS/Linux-gated, + // but the `directories` + `tokio::fs::copy` flow compiles on Windows + // too (CodeRabbit on #4127). The Save-As dialog that used to sit in + // front of this went with the shell's `rfd` dependency. artifact_commands::download_artifact_to_downloads, - // Structured WhatsApp data (store lives shell-side). check_core_update, apply_core_update, check_app_update, From b7cd583c57c86ec484bc3c7511c672bd818df582 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 18:55:26 +0300 Subject: [PATCH 29/44] chore: update tauri library setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the Tauri library configuration to support the application’s current setup. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src-tauri/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 17bb6dcc871..3f2c2faa7ce 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -33,8 +33,8 @@ const _: () = assert!( ); mod app_update; -// Artifact export commands (#2779, #3162) — both cross-platform -// (macOS/Windows/Linux): native Save-As dialog (rfd) + Downloads copy. +// Artifact export command (#2779) — cross-platform Downloads copy. The `rfd` +// Save-As dialog that used to sit in front of it was removed with the crate. mod artifact_commands; mod claude_code; mod core_process; From 3b034257ec9efe14db2752df203a92cf73b7ba94 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:21:47 +0300 Subject: [PATCH 30/44] fix(json-rpc): gate memory diff test on feature Run the memory diff end-to-end test only when the `memory-git` feature is enabled, preventing unknown-method failures without skipping the other JSON-RPC tests. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/json_rpc_e2e.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 86d213126e5..69c8f9504ef 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -4614,6 +4614,16 @@ async fn json_rpc_memory_tree_end_to_end() { /// later snapshot), mark_read, and cross-source checkpoints. This is the /// turn-to-turn world-diff assertion. (Per-item add/remove/modify detection /// and text diffs are covered exhaustively by the ops unit tests.) +/// +/// Gated on `memory-git`: the `memory_diff` controllers only register when the +/// git-backed diff domain is compiled in, so without the gate every call here +/// answers `unknown method`. This is a `#[cfg]` on the one symbol rather than a +/// `required-features` line on the target, deliberately — `json_rpc_e2e` is +/// otherwise gate-free, and gating the whole target would silently skip its +/// other ~100 tests in every lane that does not enable `memory-git`. That is +/// the distinction the `required-features` comment in `Cargo.toml` draws, and +/// the per-symbol `#[cfg]` cleanup tracked in #5021. +#[cfg(feature = "memory-git")] #[tokio::test] async fn json_rpc_memory_diff_snapshot_diff_and_read_marker_lifecycle() { let _env_lock = json_rpc_e2e_env_lock(); From 8eb29e4e399208ea5ab1d2c18e24c0fbc04fe722 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:14:51 +0300 Subject: [PATCH 31/44] fix(ci): skip tests with unsatisfied required features Parse test target requirements from Cargo.toml and skip explicitly selected targets whose feature gates are absent from the product set. This prevents the coverage lane from failing when a required feature is removed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/ci/rust-coverage-changed.sh | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/scripts/ci/rust-coverage-changed.sh b/scripts/ci/rust-coverage-changed.sh index 2a5173e8aeb..402a3c752e5 100755 --- a/scripts/ci/rust-coverage-changed.sh +++ b/scripts/ci/rust-coverage-changed.sh @@ -127,8 +127,64 @@ raw_coverage_modules() { sort } +# `required-features` of each `[[test]]` target in Cargo.toml, as +# "". Targets without the key are omitted. +# +# Parsed from Cargo.toml rather than `cargo metadata` so this stays a +# dependency-free awk/bash script (no jq, no python) on bash 3.2 and 5.x alike. +test_target_required_features() { + awk ' + /^\[\[test\]\]/ { if (name != "" && req != "") print name "\t" req; name=""; req=""; inblk=1; next } + /^\[/ { if (name != "" && req != "") print name "\t" req; name=""; req=""; inblk=0 } + inblk && /^name[ \t]*=/ { + line=$0; sub(/^name[ \t]*=[ \t]*"/, "", line); sub(/".*$/, "", line); name=line; next + } + inblk && /^required-features[ \t]*=/ { + line=$0 + sub(/^required-features[ \t]*=[ \t]*\[/, "", line); sub(/\].*$/, "", line) + gsub(/[" ]/, "", line); req=line; next + } + END { if (name != "" && req != "") print name "\t" req } + ' Cargo.toml +} + +TEST_TARGET_REQS="$(test_target_required_features)" + +# True when every `required-features` gate of ${1} is enabled in PRODUCT_FEATURES. +# +# **Why this guard exists.** `cargo` only SKIPS a target for unsatisfied +# `required-features` when the target is selected IMPLICITLY (a bare +# `cargo test`). Every call site here names the target explicitly +# (`--test `), and naming an unsatisfiable target is a hard ERROR: +# +# error: target `memory_artifacts_e2e` in package `openhuman` +# requires the features: `memory-git` +# +# That never fired while every `required-features` gate happened to be in the +# product set. Dropping `memory-git` from the product set made +# `memory_artifacts_e2e` the first unsatisfiable one and took this whole lane +# down — on a PR that had nothing wrong with it. Skipping here restores the +# behaviour the `required-features` line was written to express, and keeps the +# next gate removal from breaking the lane the same way. +target_features_satisfied() { + local target="$1" req f + req="$(printf '%s\n' "${TEST_TARGET_REQS}" | awk -F'\t' -v t="${target}" '$1 == t { print $2 }')" + [ -n "${req}" ] || return 0 + for f in $(printf '%s' "${req}" | tr ',' ' '); do + case ",${PRODUCT_FEATURES}," in + *",${f},"*) ;; + *) return 1 ;; + esac + done + return 0 +} + run_integration_target() { local target="$1" + if ! target_features_satisfied "${target}"; then + log "skipping ${target}: required-features not in the product set" + return 0 + fi if [ "${target}" = "raw_coverage_all" ]; then # These suites used to be separate integration-test binaries. Aggregating # them removes repeated full-crate links, but many still exercise process From d849b3ced2e8d3f565f1dcdf914e20ba7f0ddc21 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:14:54 +0300 Subject: [PATCH 32/44] chore(memory): remove memory diff subsystem Remove the memory diff RPC handlers, schemas, agent tool, feature wiring, and end-to-end test. The git-backed memory diff capability is no longer included in the application. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 8 - src/openhuman/memory/diff/mod.rs | 87 ------ src/openhuman/memory/diff/rpc.rs | 337 ---------------------- src/openhuman/memory/diff/schemas.rs | 408 --------------------------- src/openhuman/memory/diff/stub.rs | 23 -- src/openhuman/memory/diff/types.rs | 10 - src/openhuman/memory/tools/diff.rs | 402 -------------------------- tests/memory_artifacts_e2e.rs | 245 ---------------- 8 files changed, 1520 deletions(-) delete mode 100644 src/openhuman/memory/diff/mod.rs delete mode 100644 src/openhuman/memory/diff/rpc.rs delete mode 100644 src/openhuman/memory/diff/schemas.rs delete mode 100644 src/openhuman/memory/diff/stub.rs delete mode 100644 src/openhuman/memory/diff/types.rs delete mode 100644 src/openhuman/memory/tools/diff.rs delete mode 100644 tests/memory_artifacts_e2e.rs diff --git a/Cargo.toml b/Cargo.toml index ad793845130..eec2289b126 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,13 +91,6 @@ path = "tests/observability_smoke.rs" # The `is_*_event` Sentry filters are all `#[cfg(feature = "crash-reporting")]`. required-features = ["crash-reporting"] -[[test]] -name = "memory_artifacts_e2e" -path = "tests/memory_artifacts_e2e.rs" -# Opens the wiki ledger with `tinycortex::git2::Repository::open` and asserts on -# `content::wiki_git` artifacts — both of which exist only behind this gate. -required-features = ["memory-git"] - [[test]] name = "x402_twit_sh_live" path = "tests/x402_twit_sh_live.rs" @@ -788,7 +781,6 @@ default = ["media", "skills", "flows", "mcp", "channels", "medulla", "http-serve # `None`; and the three `ops` entry points always-on code calls return a # build-fact error, so a post-sync snapshot or a subconscious diff is logged and # skipped rather than silently reported as "nothing changed". -memory-git = ["tinycortex/git-diff", "tinycortex/wiki-git", "tinymemory-core/memory-git"] http-server = ["dep:axum", "dep:socketioxide"] # Local audio-device access: the `cpal` capture stack behind voice recording # and the accessibility microphone-permission probe. Default-ON. Slim / diff --git a/src/openhuman/memory/diff/mod.rs b/src/openhuman/memory/diff/mod.rs deleted file mode 100644 index 869ca87d79f..00000000000 --- a/src/openhuman/memory/diff/mod.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! Host layer over [`tinymemory_core::diff`]. -//! -//! The domain itself lives in the extracted crate; what stays here is its -//! JSON-RPC surface — handlers and controller schemas name OpenHuman's -//! `RpcOutcome` and `ControllerSchema`, which the engine crate cannot see. -//! The glob re-export keeps every historical `memory::diff::…` path resolving. -//! -//! # What the glob actually carries, and what blocks removing it (#5560) -//! -//! Three things, and only one of them is engine-owned in a way that matters: -//! -//! - the eight serde wire types (`ChangeKind`, `Checkpoint`, `CrossSourceDiff`, -//! `DiffResult`, `DiffSummary`, `ItemChange`, `Snapshot`, `SnapshotTrigger`) -//! — these are `tinycortex::memory::diff::types`, which `rpc.rs` and -//! `tools/diff.rs` already name directly, so they would repoint for free; -//! - `ops` (or its `memory-git`-off stub) — 312 lines of async wrappers over -//! `tinycortex::memory::diff::DiffEngine`, reached from `rpc.rs` as -//! `super::ops` and from the `memory_diff` agent tool; -//! - `source` — the `SnapshotItemSource` seam `ops` hands the engine. -//! -//! `ops` is what pins this. It reads the source registry -//! (`sources::registry::list_sources`, `sources::types::MemorySourceEntry`, -//! `sources::status::source_id_prefix`), so it cannot come home ahead of -//! `memory::sources` — see that module's docs for the two blockers there — and -//! `source` builds its snapshot items through -//! `store::chunks::store::with_connection`, a raw SQLite handle. Moving that -//! pair into the host would relocate the unpoliced door rather than close it. -//! -//! `MemoryDiff` is a real capability family — `capture_snapshot`, `snapshots`, -//! `diff` — and it is the right destination for three of `ops`' entry points. -//! It is not a drop-in for the module, for two reasons worth stating before -//! anyone tries: -//! -//! - **It covers three of `ops`' ten entry points.** `auto_snapshot_after_sync`, -//! `diff_since_last`, `diff_since_read`, `mark_read`, `create_checkpoint`, -//! `diff_since_checkpoint` and `cleanup` are checkpoints, read markers, the -//! post-sync trigger and ledger retention; the family has no member for any -//! of them, and `capture_snapshot` takes no `SnapshotTrigger`. -//! - **The wire shapes are different types.** The family answers `SnapshotRef` -//! and `DiffReport`; `memory_diff.*` publishes `Snapshot`, `DiffResult` and -//! `CrossSourceDiff`, which the Memory tab reads. Swapping the call without -//! translating at the handler would change the RPC payloads, which is a -//! behaviour change, not a migration. - -// The eight wire types, named at the crate that defines them. They are -// `tinycortex::memory::diff::types` — `tinymemory_core::diff` re-exports them -// out of its `engine::backend::diff::types`, which is that same module — so -// this is the identical item under a different crate alias, and an explicit -// `use` shadows the glob below with itself. `rpc.rs` and `tools/diff.rs` -// already spell them this way; saying it here too means the flat -// `memory::diff::ChangeKind` paths keep resolving when `tinymemory-core` -// leaves the build, with no edit at any call site. -// -// Ungated on both sides of `memory-git`, mirroring tinycortex's own carve-out: -// these are serde-only values that always-on callers (the subconscious memory -// profile renders `CrossSourceDiff` and `ChangeKind` into prompts) name in a -// slim build too. -pub use tinycortex::memory::diff::types::{ - ChangeKind, Checkpoint, CrossSourceDiff, DiffResult, DiffSummary, ItemChange, Snapshot, - SnapshotTrigger, -}; - -// What is left of the glob, and all that pins it: `ops` (or its `memory-git`-off -// stub) and `source`. Both are `tinymemory-core`'s own — see the header above -// for why neither can come home ahead of `memory::sources`, and why -// `MemoryDiff` covers three of `ops`' ten entry points rather than replacing it. -pub use tinymemory_core::diff::*; - -#[cfg(feature = "memory-git")] -pub mod rpc; -#[cfg(feature = "memory-git")] -pub mod schemas; - -#[cfg(feature = "memory-git")] -pub use schemas::{ - all_controller_schemas as all_memory_diff_controller_schemas, - all_registered_controllers as all_memory_diff_registered_controllers, -}; -// The agent tool came back with the rest of them; re-exported here so the -// historical `memory::diff::MemoryDiffTool` path keeps resolving. -#[cfg(feature = "memory-git")] -pub use crate::openhuman::memory::tools::diff::MemoryDiffTool; -#[cfg(not(feature = "memory-git"))] -mod stub; - -#[cfg(not(feature = "memory-git"))] -pub use stub::{all_memory_diff_controller_schemas, all_memory_diff_registered_controllers}; diff --git a/src/openhuman/memory/diff/rpc.rs b/src/openhuman/memory/diff/rpc.rs deleted file mode 100644 index 3fdf97b27fa..00000000000 --- a/src/openhuman/memory/diff/rpc.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! RPC request/response types and handler implementations. - -use log::debug; -use serde::{Deserialize, Serialize}; - -use crate::openhuman::config::rpc as config_rpc; -use crate::rpc::RpcOutcome; - -use tinycortex::memory::diff::Ledger; - -use super::ops; -use tinycortex::memory::diff::types::*; - -// ── Request / Response types ────────────────────────────────────────── - -#[derive(Debug, Deserialize)] -pub struct TakeSnapshotRequest { - pub source_id: String, -} - -#[derive(Debug, Serialize)] -pub struct TakeSnapshotResponse { - pub snapshot: Snapshot, -} - -#[derive(Debug, Deserialize)] -pub struct ListSnapshotsRequest { - #[serde(default)] - pub source_id: Option, - #[serde(default)] - pub limit: Option, -} - -#[derive(Debug, Serialize)] -pub struct ListSnapshotsResponse { - pub snapshots: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct DiffRequest { - #[serde(default)] - pub from_snapshot_id: Option, - pub to_snapshot_id: String, - #[serde(default)] - pub include_text_diff: Option, -} - -#[derive(Debug, Serialize)] -pub struct DiffResponse { - pub diff: DiffResult, -} - -#[derive(Debug, Deserialize)] -pub struct DiffSinceLastRequest { - pub source_id: String, - #[serde(default)] - pub include_text_diff: Option, -} - -#[derive(Debug, Serialize)] -pub struct DiffSinceLastResponse { - pub diff: DiffResult, -} - -#[derive(Debug, Deserialize)] -pub struct DiffSinceReadRequest { - pub source_id: String, - #[serde(default)] - pub include_text_diff: Option, - /// Advance the read marker to the head snapshot after computing the diff. - /// Defaults to true so reading acknowledges the changes as consumed. - #[serde(default)] - pub commit: Option, -} - -#[derive(Debug, Serialize)] -pub struct DiffSinceReadResponse { - pub diff: DiffResult, -} - -#[derive(Debug, Deserialize)] -pub struct MarkReadRequest { - /// Sources to mark read. Omit to mark all enabled sources with a snapshot. - #[serde(default)] - pub source_ids: Option>, -} - -#[derive(Debug, Serialize)] -pub struct MarkReadResponse { - pub marked: u64, -} - -#[derive(Debug, Deserialize)] -pub struct CreateCheckpointRequest { - pub label: String, -} - -#[derive(Debug, Serialize)] -pub struct CreateCheckpointResponse { - pub checkpoint: Checkpoint, -} - -#[derive(Debug, Deserialize)] -pub struct ListCheckpointsRequest { - #[serde(default)] - pub limit: Option, -} - -#[derive(Debug, Serialize)] -pub struct ListCheckpointsResponse { - pub checkpoints: Vec, -} - -#[derive(Debug, Deserialize)] -pub struct DiffSinceCheckpointRequest { - pub checkpoint_id: String, - #[serde(default)] - pub include_text_diff: Option, -} - -#[derive(Debug, Serialize)] -pub struct DiffSinceCheckpointResponse { - pub diff: CrossSourceDiff, -} - -#[derive(Debug, Deserialize)] -pub struct CleanupRequest { - pub older_than_days: u64, -} - -#[derive(Debug, Serialize)] -pub struct CleanupResponse { - pub deleted_snapshots: u64, -} - -// ── Handlers ────────────────────────────────────────────────────────── - -pub async fn take_snapshot_rpc( - req: TakeSnapshotRequest, -) -> Result, String> { - debug!( - "[memory_diff][rpc] take_snapshot source_id={}", - req.source_id - ); - let config = config_rpc::load_config_with_timeout().await?; - let source = crate::openhuman::memory::sources::get_source(&req.source_id) - .await? - .ok_or_else(|| format!("source not found: {}", req.source_id))?; - - let snapshot = ops::take_snapshot(&source, &config, SnapshotTrigger::Manual).await?; - debug!( - "[memory_diff][rpc] take_snapshot done snapshot_id={} item_count={}", - snapshot.id, snapshot.item_count - ); - Ok(RpcOutcome::new(TakeSnapshotResponse { snapshot }, vec![])) -} - -pub async fn list_snapshots_rpc( - req: ListSnapshotsRequest, -) -> Result, String> { - debug!( - "[memory_diff][rpc] list_snapshots source_id={:?} limit={:?}", - req.source_id, req.limit - ); - let config = config_rpc::load_config_with_timeout().await?; - let limit = req.limit.unwrap_or(50) as u32; - - let snapshots = ops::list_snapshots(&config, req.source_id.as_deref(), limit).await?; - - debug!( - "[memory_diff][rpc] list_snapshots returned {} snapshots", - snapshots.len() - ); - Ok(RpcOutcome::new(ListSnapshotsResponse { snapshots }, vec![])) -} - -pub async fn diff_rpc(req: DiffRequest) -> Result, String> { - debug!( - "[memory_diff][rpc] diff from={:?} to={}", - req.from_snapshot_id, req.to_snapshot_id - ); - let config = config_rpc::load_config_with_timeout().await?; - let diff = ops::compute_diff( - &config, - req.from_snapshot_id.as_deref(), - &req.to_snapshot_id, - req.include_text_diff.unwrap_or(false), - ) - .await?; - debug!( - "[memory_diff][rpc] diff done added={} removed={} modified={}", - diff.summary.added, diff.summary.removed, diff.summary.modified - ); - Ok(RpcOutcome::new(DiffResponse { diff }, vec![])) -} - -pub async fn diff_since_last_rpc( - req: DiffSinceLastRequest, -) -> Result, String> { - debug!( - "[memory_diff][rpc] diff_since_last source_id={}", - req.source_id - ); - let config = config_rpc::load_config_with_timeout().await?; - let source = crate::openhuman::memory::sources::get_source(&req.source_id) - .await? - .ok_or_else(|| format!("source not found: {}", req.source_id))?; - - let diff = - ops::diff_since_last(&source, &config, req.include_text_diff.unwrap_or(false)).await?; - debug!( - "[memory_diff][rpc] diff_since_last done added={} removed={} modified={}", - diff.summary.added, diff.summary.removed, diff.summary.modified - ); - Ok(RpcOutcome::new(DiffSinceLastResponse { diff }, vec![])) -} - -pub async fn diff_since_read_rpc( - req: DiffSinceReadRequest, -) -> Result, String> { - let commit = req.commit.unwrap_or(true); - debug!( - "[memory_diff][rpc] diff_since_read source_id={} commit={}", - req.source_id, commit - ); - let config = config_rpc::load_config_with_timeout().await?; - let source = crate::openhuman::memory::sources::get_source(&req.source_id) - .await? - .ok_or_else(|| format!("source not found: {}", req.source_id))?; - - let diff = ops::diff_since_read( - &source, - &config, - req.include_text_diff.unwrap_or(false), - commit, - ) - .await?; - debug!( - "[memory_diff][rpc] diff_since_read done added={} removed={} modified={}", - diff.summary.added, diff.summary.removed, diff.summary.modified - ); - Ok(RpcOutcome::new(DiffSinceReadResponse { diff }, vec![])) -} - -pub async fn mark_read_rpc(req: MarkReadRequest) -> Result, String> { - debug!( - "[memory_diff][rpc] mark_read source_ids={:?}", - req.source_ids - ); - let config = config_rpc::load_config_with_timeout().await?; - let marked = ops::mark_read(&config, req.source_ids).await?; - debug!("[memory_diff][rpc] mark_read done marked={}", marked); - Ok(RpcOutcome::new(MarkReadResponse { marked }, vec![])) -} - -pub async fn create_checkpoint_rpc( - req: CreateCheckpointRequest, -) -> Result, String> { - debug!("[memory_diff][rpc] create_checkpoint label={}", req.label); - let config = config_rpc::load_config_with_timeout().await?; - let checkpoint = ops::create_checkpoint(&req.label, &config).await?; - debug!( - "[memory_diff][rpc] create_checkpoint done id={} snapshots={}", - checkpoint.id, - checkpoint.snapshot_ids.len() - ); - Ok(RpcOutcome::new( - CreateCheckpointResponse { checkpoint }, - vec![], - )) -} - -pub async fn list_checkpoints_rpc( - req: ListCheckpointsRequest, -) -> Result, String> { - debug!("[memory_diff][rpc] list_checkpoints limit={:?}", req.limit); - let config = config_rpc::load_config_with_timeout().await?; - let workspace_dir = config.workspace_dir.clone(); - let limit = req.limit.unwrap_or(20) as u32; - - let checkpoints = tokio::task::spawn_blocking(move || -> anyhow::Result> { - let ledger = Ledger::open(&workspace_dir)?; - ledger.list_checkpoints(limit) - }) - .await - .map_err(|e| format!("list_checkpoints join: {e}"))? - .map_err(|e: anyhow::Error| format!("list_checkpoints: {e:#}"))?; - - debug!( - "[memory_diff][rpc] list_checkpoints returned {} checkpoints", - checkpoints.len() - ); - Ok(RpcOutcome::new( - ListCheckpointsResponse { checkpoints }, - vec![], - )) -} - -pub async fn diff_since_checkpoint_rpc( - req: DiffSinceCheckpointRequest, -) -> Result, String> { - debug!( - "[memory_diff][rpc] diff_since_checkpoint checkpoint_id={}", - req.checkpoint_id - ); - let config = config_rpc::load_config_with_timeout().await?; - let diff = ops::diff_since_checkpoint( - &req.checkpoint_id, - &config, - req.include_text_diff.unwrap_or(false), - ) - .await?; - debug!( - "[memory_diff][rpc] diff_since_checkpoint done sources={}", - diff.per_source.len() - ); - Ok(RpcOutcome::new( - DiffSinceCheckpointResponse { diff }, - vec![], - )) -} - -pub async fn cleanup_rpc(req: CleanupRequest) -> Result, String> { - debug!( - "[memory_diff][rpc] cleanup older_than_days={}", - req.older_than_days - ); - let config = config_rpc::load_config_with_timeout().await?; - let deleted = ops::cleanup(&config, req.older_than_days as u32).await?; - debug!("[memory_diff][rpc] cleanup done deleted={}", deleted); - Ok(RpcOutcome::new( - CleanupResponse { - deleted_snapshots: deleted, - }, - vec![], - )) -} diff --git a/src/openhuman/memory/diff/schemas.rs b/src/openhuman/memory/diff/schemas.rs deleted file mode 100644 index c5fa4ecedeb..00000000000 --- a/src/openhuman/memory/diff/schemas.rs +++ /dev/null @@ -1,408 +0,0 @@ -//! Controller-registry schemas for `openhuman.memory_diff_*`. - -use serde::de::DeserializeOwned; -use serde_json::{Map, Value}; - -use crate::core::all::{ControllerFuture, RegisteredController}; -use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::rpc::RpcOutcome; - -use super::rpc; - -const NAMESPACE: &str = "memory_diff"; - -pub fn all_controller_schemas() -> Vec { - vec![ - schemas("take_snapshot"), - schemas("list_snapshots"), - schemas("diff"), - schemas("diff_since_last"), - schemas("diff_since_read"), - schemas("mark_read"), - schemas("create_checkpoint"), - schemas("list_checkpoints"), - schemas("diff_since_checkpoint"), - schemas("cleanup"), - ] -} - -pub fn all_registered_controllers() -> Vec { - vec![ - RegisteredController { - schema: schemas("take_snapshot"), - handler: handle_take_snapshot, - }, - RegisteredController { - schema: schemas("list_snapshots"), - handler: handle_list_snapshots, - }, - RegisteredController { - schema: schemas("diff"), - handler: handle_diff, - }, - RegisteredController { - schema: schemas("diff_since_last"), - handler: handle_diff_since_last, - }, - RegisteredController { - schema: schemas("diff_since_read"), - handler: handle_diff_since_read, - }, - RegisteredController { - schema: schemas("mark_read"), - handler: handle_mark_read, - }, - RegisteredController { - schema: schemas("create_checkpoint"), - handler: handle_create_checkpoint, - }, - RegisteredController { - schema: schemas("list_checkpoints"), - handler: handle_list_checkpoints, - }, - RegisteredController { - schema: schemas("diff_since_checkpoint"), - handler: handle_diff_since_checkpoint, - }, - RegisteredController { - schema: schemas("cleanup"), - handler: handle_cleanup, - }, - ] -} - -fn schemas(function: &str) -> ControllerSchema { - match function { - "take_snapshot" => ControllerSchema { - namespace: NAMESPACE, - function: "take_snapshot", - description: "Manually capture a snapshot of a memory source's current chunk state.", - inputs: vec![FieldSchema { - name: "source_id", - ty: TypeSchema::String, - comment: "Memory source id to snapshot.", - required: true, - }], - outputs: vec![FieldSchema { - name: "snapshot", - ty: TypeSchema::Ref("Snapshot"), - comment: "The captured snapshot.", - required: true, - }], - }, - "list_snapshots" => ControllerSchema { - namespace: NAMESPACE, - function: "list_snapshots", - description: "List snapshots, optionally filtered by source, newest first.", - inputs: vec![ - FieldSchema { - name: "source_id", - ty: TypeSchema::Option(Box::new(TypeSchema::String)), - comment: "Filter to a specific source.", - required: false, - }, - FieldSchema { - name: "limit", - ty: TypeSchema::Option(Box::new(TypeSchema::U64)), - comment: "Max snapshots to return (default 50).", - required: false, - }, - ], - outputs: vec![FieldSchema { - name: "snapshots", - ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Snapshot"))), - comment: "Snapshots in reverse chronological order.", - required: true, - }], - }, - "diff" => ControllerSchema { - namespace: NAMESPACE, - function: "diff", - description: "Compute the diff between two snapshots of the same source.", - inputs: vec![ - FieldSchema { - name: "from_snapshot_id", - ty: TypeSchema::Option(Box::new(TypeSchema::String)), - comment: - "Base snapshot id. Omit to diff against empty (all items show as added).", - required: false, - }, - FieldSchema { - name: "to_snapshot_id", - ty: TypeSchema::String, - comment: "Head snapshot id.", - required: true, - }, - FieldSchema { - name: "include_text_diff", - ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), - comment: "Include line-level text diffs for modified items.", - required: false, - }, - ], - outputs: vec![FieldSchema { - name: "diff", - ty: TypeSchema::Ref("DiffResult"), - comment: "Computed diff with change summary and per-item changes.", - required: true, - }], - }, - "diff_since_last" => ControllerSchema { - namespace: NAMESPACE, - function: "diff_since_last", - description: "Diff a source's latest snapshot against its previous one. \ - Shows what changed in the most recent sync.", - inputs: vec![ - FieldSchema { - name: "source_id", - ty: TypeSchema::String, - comment: "Memory source id.", - required: true, - }, - FieldSchema { - name: "include_text_diff", - ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), - comment: "Include line-level text diffs for modified items.", - required: false, - }, - ], - outputs: vec![FieldSchema { - name: "diff", - ty: TypeSchema::Ref("DiffResult"), - comment: "Diff between the two most recent snapshots.", - required: true, - }], - }, - "diff_since_read" => ControllerSchema { - namespace: NAMESPACE, - function: "diff_since_read", - description: "Diff a source's latest snapshot against the read marker — what \ - changed since the agent last read this source's diff. By default \ - commits the read marker so the next call returns only newer changes.", - inputs: vec![ - FieldSchema { - name: "source_id", - ty: TypeSchema::String, - comment: "Memory source id.", - required: true, - }, - FieldSchema { - name: "include_text_diff", - ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), - comment: "Include line-level text diffs for modified items.", - required: false, - }, - FieldSchema { - name: "commit", - ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), - comment: "Advance the read marker after diffing (default true). \ - Set false to preview without acknowledging.", - required: false, - }, - ], - outputs: vec![FieldSchema { - name: "diff", - ty: TypeSchema::Ref("DiffResult"), - comment: "Diff between the read marker and the latest snapshot.", - required: true, - }], - }, - "mark_read" => ControllerSchema { - namespace: NAMESPACE, - function: "mark_read", - description: "Commit read markers, advancing each source to its current head \ - snapshot so prior changes are acknowledged as consumed.", - inputs: vec![FieldSchema { - name: "source_ids", - ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::String)))), - comment: "Sources to mark read. Omit to mark all enabled sources with a snapshot.", - required: false, - }], - outputs: vec![FieldSchema { - name: "marked", - ty: TypeSchema::U64, - comment: "Number of read markers committed.", - required: true, - }], - }, - "create_checkpoint" => ControllerSchema { - namespace: NAMESPACE, - function: "create_checkpoint", - description: - "Create a named checkpoint grouping the latest snapshot per enabled source. \ - Use for cross-source 'what changed since X' queries.", - inputs: vec![FieldSchema { - name: "label", - ty: TypeSchema::String, - comment: "Human-readable checkpoint label.", - required: true, - }], - outputs: vec![FieldSchema { - name: "checkpoint", - ty: TypeSchema::Ref("Checkpoint"), - comment: "The created checkpoint with its snapshot ids.", - required: true, - }], - }, - "list_checkpoints" => ControllerSchema { - namespace: NAMESPACE, - function: "list_checkpoints", - description: "List named checkpoints, newest first.", - inputs: vec![FieldSchema { - name: "limit", - ty: TypeSchema::Option(Box::new(TypeSchema::U64)), - comment: "Max checkpoints to return (default 20).", - required: false, - }], - outputs: vec![FieldSchema { - name: "checkpoints", - ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Checkpoint"))), - comment: "Checkpoints in reverse chronological order.", - required: true, - }], - }, - "diff_since_checkpoint" => ControllerSchema { - namespace: NAMESPACE, - function: "diff_since_checkpoint", - description: - "Cross-source diff: compute changes across all sources since a checkpoint.", - inputs: vec![ - FieldSchema { - name: "checkpoint_id", - ty: TypeSchema::String, - comment: "Checkpoint id to diff against.", - required: true, - }, - FieldSchema { - name: "include_text_diff", - ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), - comment: "Include line-level text diffs for modified items.", - required: false, - }, - ], - outputs: vec![FieldSchema { - name: "diff", - ty: TypeSchema::Ref("CrossSourceDiff"), - comment: "Aggregated diff across all sources with per-source breakdown.", - required: true, - }], - }, - "cleanup" => ControllerSchema { - namespace: NAMESPACE, - function: "cleanup", - description: "Delete snapshots older than N days.", - inputs: vec![FieldSchema { - name: "older_than_days", - ty: TypeSchema::U64, - comment: "Delete snapshots older than this many days.", - required: true, - }], - outputs: vec![FieldSchema { - name: "deleted_snapshots", - ty: TypeSchema::U64, - comment: "Number of snapshots deleted.", - required: true, - }], - }, - other => panic!("unknown memory_diff schema function: {other}"), - } -} - -// ── Handlers ────────────────────────────────────────────────────────── - -fn handle_take_snapshot(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::take_snapshot_rpc(req).await?) - }) -} - -fn handle_list_snapshots(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::list_snapshots_rpc(req).await?) - }) -} - -fn handle_diff(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::diff_rpc(req).await?) - }) -} - -fn handle_diff_since_last(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::diff_since_last_rpc(req).await?) - }) -} - -fn handle_diff_since_read(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::diff_since_read_rpc(req).await?) - }) -} - -fn handle_mark_read(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::mark_read_rpc(req).await?) - }) -} - -fn handle_create_checkpoint(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::create_checkpoint_rpc(req).await?) - }) -} - -fn handle_list_checkpoints(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::list_checkpoints_rpc(req).await?) - }) -} - -fn handle_diff_since_checkpoint(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::diff_since_checkpoint_rpc(req).await?) - }) -} - -fn handle_cleanup(params: Map) -> ControllerFuture { - Box::pin(async move { - let req = parse_value::(Value::Object(params))?; - to_json(rpc::cleanup_rpc(req).await?) - }) -} - -fn parse_value(v: Value) -> Result { - serde_json::from_value(v).map_err(|e| format!("invalid params: {e}")) -} - -fn to_json(outcome: RpcOutcome) -> Result { - outcome.into_cli_compatible_json() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn all_controller_schemas_and_registered_controllers_stay_in_sync() { - let schemas = all_controller_schemas(); - let controllers = all_registered_controllers(); - assert_eq!(schemas.len(), controllers.len()); - assert!(schemas.iter().all(|s| s.namespace == NAMESPACE)); - } - - #[test] - #[should_panic(expected = "unknown memory_diff schema function")] - fn schemas_panics_on_unknown_function() { - schemas("nope"); - } -} diff --git a/src/openhuman/memory/diff/stub.rs b/src/openhuman/memory/diff/stub.rs deleted file mode 100644 index 65870276917..00000000000 --- a/src/openhuman/memory/diff/stub.rs +++ /dev/null @@ -1,23 +0,0 @@ -//! The `memory-git`-disabled controller surface of `memory::diff`. -//! -//! The *ops* mirror lives with the domain in `tinymemory_core::diff::stub` — -//! it is the feature-off arm of core code, and always-on core callers -//! (`sources::sync`) reach it without any feature awareness, which is the whole -//! point of a stub. -//! -//! What stays here is the registration half. Registration sites want -//! **absence**: the aggregators return empty vecs so the `memory_diff.*` -//! controllers become unknown-method rather than known-and-always-failing. - -/// No controllers: the `memory_diff` namespace answers unknown-method. -/// -/// Empty rather than a set of always-erroring handlers, so `/schema` does not -/// advertise a surface this build cannot serve. -pub fn all_memory_diff_controller_schemas() -> Vec { - Vec::new() -} - -/// No controllers to register. See [`all_memory_diff_controller_schemas`]. -pub fn all_memory_diff_registered_controllers() -> Vec { - Vec::new() -} diff --git a/src/openhuman/memory/diff/types.rs b/src/openhuman/memory/diff/types.rs deleted file mode 100644 index 2ae8f7c0101..00000000000 --- a/src/openhuman/memory/diff/types.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Stable wire types for memory-source diffs. -//! -//! TinyCortex keeps these serde-only values available independently of its -//! git-backed diff engine, so hosts can describe a diff even in a slim build -//! without the `memory-git` feature. - -pub use tinycortex::memory::diff::types::{ - ChangeKind, Checkpoint, CrossSourceDiff, DiffResult, DiffSummary, ItemChange, Snapshot, - SnapshotTrigger, -}; diff --git a/src/openhuman/memory/tools/diff.rs b/src/openhuman/memory/tools/diff.rs deleted file mode 100644 index eb94335dde9..00000000000 --- a/src/openhuman/memory/tools/diff.rs +++ /dev/null @@ -1,402 +0,0 @@ -//! Agent-facing `memory_diff` tool. -//! -//! Lets agents query what changed in memory sources since the last sync -//! or a named checkpoint, formatted as concise markdown. - -use async_trait::async_trait; -use log::debug; -use serde_json::{json, Value}; - -use crate::openhuman::config::rpc as config_rpc; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; - -use crate::openhuman::memory::diff::ops; -// The host path, matching the six other call sites that read this -// registry (`memory::diff::rpc`, `memory::sync::composio::bus`, -// `integrations::composio::ops::connections`). This tool was the one -// place that named the engine crate for it; the registry itself is -// still `tinymemory_core::sources` behind `memory::sources` — see that -// module's docs for what has to move before the name goes away (#5560). -use crate::openhuman::memory::sources; -use tinycortex::memory::diff::types::*; - -pub struct MemoryDiffTool; - -#[async_trait] -impl Tool for MemoryDiffTool { - fn name(&self) -> &str { - "memory_diff" - } - - fn description(&self) -> &str { - "Check what changed in memory sources since you last looked, the last sync, or a named \ - checkpoint. Returns a structured summary of added, removed, and modified items. By \ - default, reading a single source's diff commits a read marker so the next call only \ - surfaces newer changes (set commit=false to preview without acknowledging)." - } - - fn parameters_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "source_id": { - "type": "string", - "description": "Memory source id. If omitted and checkpoint_id is also omitted, \ - lists available sources with snapshot counts." - }, - "checkpoint_id": { - "type": "string", - "description": "Checkpoint id to diff against. If provided, computes cross-source \ - diff since that checkpoint." - }, - "include_text_diff": { - "type": "boolean", - "description": "If true, include line-level text diffs for modified items (truncated).", - "default": false - }, - "since_read": { - "type": "boolean", - "description": "When diffing a single source, show changes since you last read \ - this source's diff (vs. since the previous sync). Default true.", - "default": true - }, - "commit": { - "type": "boolean", - "description": "When using since_read, advance the read marker so the next call \ - only surfaces newer changes. Default true; set false to preview.", - "default": true - } - }, - "additionalProperties": false - }) - } - - fn permission_level(&self) -> PermissionLevel { - // Read-only with respect to the user's data: the only write this tool - // performs is advancing the read marker in the module's own diff.db - // (internal bookkeeping under workspace state, never `action_dir`). - PermissionLevel::ReadOnly - } - - async fn execute(&self, args: Value) -> anyhow::Result { - let source_id = args.get("source_id").and_then(|v| v.as_str()); - let checkpoint_id = args.get("checkpoint_id").and_then(|v| v.as_str()); - let include_text_diff = args - .get("include_text_diff") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let since_read = args - .get("since_read") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let commit = args.get("commit").and_then(|v| v.as_bool()).unwrap_or(true); - - debug!( - "[memory_diff][tool] execute source_id={:?} checkpoint_id={:?} include_text_diff={} \ - since_read={} commit={}", - source_id, checkpoint_id, include_text_diff, since_read, commit - ); - - let config = config_rpc::load_config_with_timeout() - .await - .map_err(|e| anyhow::anyhow!(e))?; - - if let Some(ckpt_id) = checkpoint_id { - debug!("[memory_diff][tool] branch=checkpoint_diff checkpoint_id={ckpt_id}"); - let diff = ops::diff_since_checkpoint(ckpt_id, &config, include_text_diff) - .await - .map_err(|e| anyhow::anyhow!(e))?; - let md = format_cross_source_diff(&diff); - return Ok(ToolResult::success(md)); - } - - if let Some(sid) = source_id { - debug!("[memory_diff][tool] branch=source_diff source_id={sid}"); - let source = sources::get_source(sid) - .await - .map_err(|e| anyhow::anyhow!(e))? - .ok_or_else(|| anyhow::anyhow!("source not found: {sid}"))?; - - let diff = if since_read { - ops::diff_since_read(&source, &config, include_text_diff, commit) - .await - .map_err(|e| anyhow::anyhow!(e))? - } else { - ops::diff_since_last(&source, &config, include_text_diff) - .await - .map_err(|e| anyhow::anyhow!(e))? - }; - let md = format_diff_result(&diff); - return Ok(ToolResult::success(md)); - } - - debug!("[memory_diff][tool] branch=list_sources"); - // No source_id or checkpoint_id: list sources with snapshot counts - let sources = sources::list_sources() - .await - .map_err(|e| anyhow::anyhow!(e))?; - - let workspace_dir = config.workspace_dir.clone(); - let source_ids: Vec<(String, String, String)> = sources - .iter() - .filter(|s| s.enabled) - .map(|s| (s.id.clone(), s.label.clone(), s.kind.as_str().to_string())) - .collect(); - - let counts: Vec<(String, String, String, usize)> = - tokio::task::spawn_blocking(move || -> anyhow::Result<_> { - let ledger = tinycortex::memory::diff::Ledger::open(&workspace_dir)?; - let mut out = Vec::new(); - for (sid, label, kind) in &source_ids { - let count = ledger.snapshot_count_for_source(sid)?; - out.push((sid.clone(), label.clone(), kind.clone(), count)); - } - Ok(out) - }) - .await - .map_err(|e| anyhow::anyhow!("join: {e}"))? - .map_err(|e: anyhow::Error| anyhow::anyhow!("{e:#}"))?; - - let mut md = String::from("## Memory Sources (snapshot status)\n\n"); - if counts.is_empty() { - md.push_str("No enabled memory sources configured.\n"); - } else { - for (sid, label, kind, count) in &counts { - md.push_str(&format!( - "- **{label}** ({kind}) — {count} snapshot(s) | source_id: `{sid}`\n" - )); - } - md.push_str( - "\nCall with `source_id` to see what changed since the last sync, \ - or `checkpoint_id` for cross-source diffs.\n", - ); - } - - Ok(ToolResult::success(md)) - } -} - -fn format_diff_result(diff: &DiffResult) -> String { - let mut md = format!( - "## Memory Changes ({})\n\n**{} added, {} modified, {} removed** ({} unchanged)\n", - diff.source_label, - diff.summary.added, - diff.summary.modified, - diff.summary.removed, - diff.summary.unchanged, - ); - - let added: Vec<_> = diff - .changes - .iter() - .filter(|c| c.kind == ChangeKind::Added) - .collect(); - let modified: Vec<_> = diff - .changes - .iter() - .filter(|c| c.kind == ChangeKind::Modified) - .collect(); - let removed: Vec<_> = diff - .changes - .iter() - .filter(|c| c.kind == ChangeKind::Removed) - .collect(); - - if !added.is_empty() { - md.push_str("\n### Added\n"); - for c in &added { - let label = if c.title.is_empty() { - &c.item_id - } else { - &c.title - }; - md.push_str(&format!("- {label}\n")); - } - } - - if !modified.is_empty() { - md.push_str("\n### Modified\n"); - for c in &modified { - let label = if c.title.is_empty() { - &c.item_id - } else { - &c.title - }; - md.push_str(&format!("- {label}\n")); - if let Some(diff_text) = &c.text_diff { - md.push_str(" ```diff\n"); - for line in diff_text.lines() { - md.push_str(&format!(" {line}\n")); - } - md.push_str(" ```\n"); - } - } - } - - if !removed.is_empty() { - md.push_str("\n### Removed\n"); - for c in &removed { - let label = if c.title.is_empty() { - &c.item_id - } else { - &c.title - }; - md.push_str(&format!("- {label}\n")); - } - } - - if diff.changes.is_empty() { - md.push_str("\nNo changes detected.\n"); - } - - md -} - -fn format_cross_source_diff(diff: &CrossSourceDiff) -> String { - let mut md = format!( - "## Cross-Source Memory Changes\n\n\ - **Total: {} added, {} modified, {} removed** ({} unchanged)\n", - diff.summary.added, diff.summary.modified, diff.summary.removed, diff.summary.unchanged, - ); - - if diff.per_source.is_empty() { - md.push_str("\nNo changes across any source since the checkpoint.\n"); - return md; - } - - for source_diff in &diff.per_source { - md.push_str(&format!( - "\n### {} ({})\n", - source_diff.source_label, source_diff.source_kind - )); - md.push_str(&format!( - "{} added, {} modified, {} removed\n", - source_diff.summary.added, source_diff.summary.modified, source_diff.summary.removed, - )); - for c in &source_diff.changes { - let label = if c.title.is_empty() { - &c.item_id - } else { - &c.title - }; - let prefix = match c.kind { - ChangeKind::Added => "+", - ChangeKind::Modified => "~", - ChangeKind::Removed => "-", - }; - md.push_str(&format!(" {prefix} {label}\n")); - } - } - - md -} - -#[cfg(test)] -mod tests { - use super::*; - - fn change(item_id: &str, title: &str, kind: ChangeKind, text_diff: Option<&str>) -> ItemChange { - ItemChange { - item_id: item_id.to_string(), - title: title.to_string(), - kind, - old_content_hash: None, - new_content_hash: None, - text_diff: text_diff.map(str::to_string), - } - } - - #[test] - fn format_diff_result_groups_changes_and_renders_text_diff() { - let diff = DiffResult { - source_id: "src_a".into(), - source_kind: "folder".into(), - source_label: "Docs".into(), - from_snapshot_id: Some("s1".into()), - to_snapshot_id: "s2".into(), - summary: DiffSummary { - added: 1, - removed: 1, - modified: 1, - unchanged: 2, - }, - changes: vec![ - change("new.md", "New Doc", ChangeKind::Added, None), - change( - "edit.md", - "Edited Doc", - ChangeKind::Modified, - Some("@@ -1 +1 @@\n-old\n+new"), - ), - // Empty title falls back to the item id. - change("gone.md", "", ChangeKind::Removed, None), - ], - }; - - let md = format_diff_result(&diff); - assert!(md.contains("1 added, 1 modified, 1 removed")); - assert!(md.contains("### Added\n- New Doc")); - assert!(md.contains("### Modified\n- Edited Doc")); - assert!(md.contains("```diff"), "text diff should be fenced: {md}"); - assert!(md.contains("+new")); - assert!( - md.contains("### Removed\n- gone.md"), - "title falls back to id" - ); - } - - #[test] - fn format_diff_result_reports_no_changes() { - let diff = DiffResult { - source_id: "src_a".into(), - source_kind: "folder".into(), - source_label: "Docs".into(), - from_snapshot_id: Some("s1".into()), - to_snapshot_id: "s2".into(), - summary: DiffSummary::default(), - changes: vec![], - }; - assert!(format_diff_result(&diff).contains("No changes detected.")); - } - - #[test] - fn format_cross_source_diff_breaks_down_per_source() { - let cross = CrossSourceDiff { - checkpoint_id: Some("ckpt_1".into()), - computed_at_ms: 0, - summary: DiffSummary { - added: 1, - modified: 0, - removed: 0, - unchanged: 0, - }, - per_source: vec![DiffResult { - source_id: "src_a".into(), - source_kind: "folder".into(), - source_label: "Docs".into(), - from_snapshot_id: Some("s1".into()), - to_snapshot_id: "s2".into(), - summary: DiffSummary { - added: 1, - ..Default::default() - }, - changes: vec![change("new.md", "New Doc", ChangeKind::Added, None)], - }], - }; - let md = format_cross_source_diff(&cross); - assert!(md.contains("Total: 1 added")); - assert!(md.contains("### Docs (folder)")); - assert!(md.contains("+ New Doc")); - } - - #[test] - fn format_cross_source_diff_empty_is_explicit() { - let cross = CrossSourceDiff { - checkpoint_id: Some("ckpt_1".into()), - computed_at_ms: 0, - summary: DiffSummary::default(), - per_source: vec![], - }; - assert!(format_cross_source_diff(&cross).contains("No changes across any source")); - } -} diff --git a/tests/memory_artifacts_e2e.rs b/tests/memory_artifacts_e2e.rs deleted file mode 100644 index 6202ed9b01d..00000000000 --- a/tests/memory_artifacts_e2e.rs +++ /dev/null @@ -1,245 +0,0 @@ -//! Integration coverage for on-disk memory artifacts. -//! -//! Uses a real Slack ingest to create raw/source artifacts, then stages a -//! mocked summary record to verify the Obsidian-compatible vault layout and -//! frontmatter contract without requiring a live summarizer model. - -use std::sync::{Arc, OnceLock}; -use tempfile::tempdir; - -use chrono::{TimeZone, Utc}; - -use openhuman_core::openhuman::config::Config; -use openhuman_core::openhuman::memory::tree::ingest::{ingest_summary, SummaryIngestInput}; -use tinycortex::memory::ingest::canonicalize::chat::{ChatBatch, ChatMessage}; -use tinymemory_core::ingest_pipeline::ingest_chat; -use tinymemory_core::queue::drain_until_idle; -use tinymemory_core::store::content::atomic::stage_summary; -use tinymemory_core::store::content::obsidian::ensure_obsidian_defaults; -use tinymemory_core::store::content::raw::{write_raw_items, RawItem, RawKind}; -use tinymemory_core::store::content::wiki_git::{get_read_pointer_tag, set_read_pointer_tag}; -use tinymemory_core::store::content::{SummaryComposeInput, SummaryTreeKind}; -use tinymemory_core::tree_source::registry::get_or_create_source_tree; - -static MEMORY_SEAMS_INIT: OnceLock<()> = OnceLock::new(); - -fn ensure_memory_seams() { - MEMORY_SEAMS_INIT.get_or_init(|| { - std::thread::Builder::new() - .name("memory-artifacts-e2e-seams".to_string()) - .stack_size(8 * 1024 * 1024) - .spawn(|| { - openhuman_core::openhuman::memory::host_impls::install_memory_host_seams(Arc::new( - Config::default(), - )); - }) - .expect("spawn memory artifact seam installer") - .join() - .expect("memory artifact seam installer panicked"); - }); -} - -fn make_config(workspace_dir: &std::path::Path) -> Config { - ensure_memory_seams(); - let mut config = Config::default(); - config.workspace_dir = workspace_dir.to_path_buf(); - config.config_path = workspace_dir.join("config.toml"); - config.embeddings_provider = Some("none".to_string()); - config -} - -#[tokio::test] -async fn sync_raw_artifacts_and_mocked_summary_match_obsidian_contract() { - let tmp = tempdir().expect("tempdir"); - let workspace_dir = tmp.path().join("workspace"); - std::fs::create_dir_all(&workspace_dir).expect("workspace dir"); - let config = make_config(&workspace_dir); - - let ts = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(); - write_raw_items( - &config.memory_tree_content_root(), - "slack:conn-slack-1", - &[RawItem { - uid: "1700000000.000100", - created_at_ms: ts.timestamp_millis(), - markdown: "**Channel:** #engineering\n**Author:** alice\n\nPhoenix migration launch window is Friday at 22:00 UTC.", - kind: RawKind::Chat, - }], - ) - .expect("seed raw Slack artifact"); - let batch = ChatBatch { - platform: "slack".into(), - channel_label: "#engineering".into(), - messages: vec![ChatMessage { - author: "alice".into(), - timestamp: ts, - text: "Phoenix migration launch window is Friday at 22:00 UTC.".into(), - source_ref: Some("https://slack.example.test/archives/C123/p1700000000000100".into()), - }], - }; - ingest_chat( - &config, - "slack:conn-slack-1", - "alice", - vec!["slack".into(), "ingested".into()], - batch, - ) - .await - .expect("seed slack sync"); - drain_until_idle(&config).await.expect("drain sync jobs"); - - let content_root = config.memory_tree_content_root(); - let raw_file = content_root - .join("raw") - .join("slack-conn-slack-1") - .join("chats") - .join("1700000000000_1700000000.000100.md"); - let source_file = content_root - .join("raw") - .join("slack-conn-slack-1") - .join("_source.md"); - assert!(raw_file.exists(), "expected raw markdown artifact"); - assert!(source_file.exists(), "expected source registry mirror"); - - let raw_body = std::fs::read_to_string(&raw_file).expect("read raw artifact"); - assert!(raw_body.contains("**Channel:** #engineering")); - assert!(raw_body.contains("**Author:** alice")); - assert!(raw_body.contains("Phoenix migration launch window")); - - ensure_obsidian_defaults(&content_root).expect("stage obsidian defaults"); - - let sealed_at = Utc.with_ymd_and_hms(2026, 5, 24, 22, 0, 0).unwrap(); - let child_ids = vec!["chunk-1".to_string()]; - let child_basenames = vec![Some("1700000000000_1700000000.000100".to_string())]; - let staged = stage_summary( - &content_root, - &SummaryComposeInput { - summary_id: "summary:1760000000000:L1-phoenix-window", - tree_kind: SummaryTreeKind::Source, - tree_id: "source:slack", - tree_scope: "slack:conn-slack-1", - level: 1, - child_ids: &child_ids, - child_basenames: Some(&child_basenames), - child_count: 1, - time_range_start: ts, - time_range_end: ts, - sealed_at, - body: "Phoenix migration launch window confirmed for Friday 22:00 UTC.", - }, - "slack-conn-slack-1", - ) - .expect("stage mocked summary"); - - let summary_path = content_root.join(&staged.content_path); - assert!(summary_path.exists(), "summary markdown should be written"); - let summary_body = std::fs::read_to_string(&summary_path).expect("read summary markdown"); - assert!(summary_body.contains("tree_kind: source")); - assert!(summary_body.contains("tree_scope: \"slack:conn-slack-1\"")); - assert!(summary_body.contains("time_range_start: 2023-11-14T22:13:20+00:00")); - assert!(summary_body.contains("time_range_end: 2023-11-14T22:13:20+00:00")); - assert!(summary_body.contains("sealed_at: 2026-05-24T22:00:00+00:00")); - assert!(summary_body.contains("[[1700000000000_1700000000.000100]]")); - - let graph_json = content_root.join(".obsidian").join("graph.json"); - let types_json = content_root.join(".obsidian").join("types.json"); - assert!(graph_json.exists(), "graph defaults should exist"); - assert!(types_json.exists(), "type hints should exist"); - let types_body = std::fs::read_to_string(types_json).expect("read types.json"); - assert!(types_body.contains("\"time_range_start\": \"date\"")); - assert!(types_body.contains("\"sealed_at\": \"datetime\"")); -} - -#[tokio::test] -async fn summary_ingest_records_summary_only_git_history_and_timestamped_read_tags() { - let tmp = tempdir().expect("tempdir"); - let workspace_dir = tmp.path().join("workspace"); - std::fs::create_dir_all(&workspace_dir).expect("workspace dir"); - let config = make_config(&workspace_dir); - let content_root = config.memory_tree_content_root(); - let raw_path = content_root.join("wiki/raw/not-tracked.md"); - std::fs::create_dir_all(raw_path.parent().unwrap()).expect("raw dir"); - std::fs::write(&raw_path, "should stay out of git history").expect("seed raw file"); - - let tree = get_or_create_source_tree(&config, "github:tinyhumansai/openhuman") - .expect("create source tree"); - let start = Utc.with_ymd_and_hms(2026, 6, 26, 9, 0, 0).unwrap(); - let end = Utc.with_ymd_and_hms(2026, 6, 26, 9, 30, 0).unwrap(); - let outcome = ingest_summary( - &config, - &tree, - SummaryIngestInput { - content: "Memory wiki git history now records summary-node seals.".to_string(), - token_count: 64, - entities: Vec::new(), - topics: vec!["memory".to_string()], - time_range_start: start, - time_range_end: end, - score: 0.8, - child_labels: vec!["commit:abc123".to_string(), "issue:4142".to_string()], - child_basenames: Vec::new(), - }, - ) - .await - .expect("ingest summary"); - - let wiki_root = content_root.join("wiki"); - // Through tinycortex's re-export, not a `git2` dependency of this crate: - // tinycortex writes this ledger and owns the only libgit2 link in the - // graph, so the assertion reads it back with the very same binding. - let repo = tinycortex::git2::Repository::open(&wiki_root) - .expect("wiki git repo should be initialized"); - let head = repo.head().expect("wiki head").peel_to_commit().unwrap(); - let tree_obj = head.tree().expect("wiki commit tree"); - - let repo_summary_path = outcome - .content_path - .strip_prefix("wiki/") - .expect("summary path should live under wiki/"); - tree_obj - .get_path(std::path::Path::new(repo_summary_path)) - .expect("summary markdown should be tracked"); - assert!( - tree_obj - .get_path(std::path::Path::new("raw/not-tracked.md")) - .is_err(), - "git history should remain scoped to summaries" - ); - - let message = head.message().expect("commit message"); - assert!(message.contains("Seal memory tree github:tinyhumansai/openhuman L1 summaries")); - assert!(message.contains("Reason: summary_ingest")); - assert!(message.contains("Summary-Count: 1")); - assert!(message.contains("Child-Count: 2")); - assert!(message.contains("Token-Count: 64")); - assert!(message.contains(&outcome.summary_id)); - assert!(message.contains(repo_summary_path)); - - let head_id = head.id().to_string(); - let tagged = set_read_pointer_tag(&content_root, "agent:e2e", None) - .expect("set timestamped read pointer tag"); - assert_eq!(tagged, head_id); - assert_eq!( - get_read_pointer_tag(&content_root, "agent:e2e") - .expect("read latest pointer") - .as_deref(), - Some(head_id.as_str()) - ); - - let tag_prefix = format!("refs/tags/read/{}/", hex::encode("agent:e2e".as_bytes())); - let tags = repo.references().unwrap().fold(Vec::new(), |mut acc, r| { - let r = r.unwrap(); - let name = r.name().unwrap(); - if name.starts_with(&tag_prefix) { - acc.push(name.to_string()); - } - acc - }); - assert!(tags.iter().any(|name| name.ends_with("/latest"))); - assert!(tags.iter().any(|name| { - let suffix = name.strip_prefix(&tag_prefix).unwrap_or_default(); - suffix.len() == "20260626T090000.000000000Z".len() - && suffix.ends_with('Z') - && suffix.contains('T') - })); -} From d90208e550322359f4f9bd864e844768e05759c3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:15:17 +0300 Subject: [PATCH 33/44] docs: clarify libgit2 feature configuration Update Cargo manifest comments to reflect that the memory-git feature and its git dependencies are no longer enabled. This documents why libgit2 is now absent from the dependency graph. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index eec2289b126..90843a6c16f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -178,11 +178,12 @@ tinyagents = { version = "2.1", features = ["sqlite", "multimodal"] } # (=0.40) so one bundled SQLite links. The submodule intentionally tracks # reviewed upstream main commits; keep this semver requirement compatible with # the vendored crate version. -# `git-diff` and `wiki-git` are NOT here: they are pulled in by the -# `memory-git` gate below, which is where the git2/libgit2-sys/libz-sys cohort -# enters the graph. This crate holds no `git2` pin of its own to align — the -# gate turns tinycortex's on, and tinycortex is the sole libgit2 link in the -# graph. Everything else tinycortex needs is unconditional. +# `git-diff` and `wiki-git` are NOT here, and there is no longer a gate that +# turns them on: the `memory-git` feature and the `memory::diff` RPC/tool +# surface it guarded were deleted, taking the git2/libgit2-sys/libz-sys cohort +# out of every configuration. tinycortex remains the sole libgit2 link in the +# graph, and nothing in this crate enables it. Everything else tinycortex needs +# is unconditional. tinycortex = { version = "0.1", features = [ "obsidian", "persona", @@ -362,15 +363,14 @@ rand = "0.10" dirs = "5" sha2 = "0.10" # NO `git2` HERE, DELIBERATELY — do not add it back. Every use of libgit2 in -# the memory stack lives in tinycortex: the diff ledger (`memory::diff`), the -# wiki mirror (`memory::store::content::wiki_git`), and the persona git-history -# reader. This crate holds the RPC surface, the agent tool and the gate around -# them, and never touches a repository itself, so a direct dependency here was -# a declaration with no `use` behind it. Re-declaring it costs nothing in -# crates (the `memory-git` gate pulls tinycortex's copy either way) but invites -# a second major pin, and `git2` sets `links = "git2"` — two majors in one -# graph is a hard cargo error, not a warning. Test code that must inspect a -# ledger goes through `tinycortex::git2`. +# the memory stack lives in tinycortex: the diff ledger, the wiki mirror +# (`memory::store::content::wiki_git`), and the persona git-history reader. +# This crate never touches a repository itself, so a direct dependency here +# would be a declaration with no `use` behind it. It also invites a second +# major pin, and `git2` sets `links = "git2"` — two majors in one graph is a +# hard cargo error, not a warning. Nothing here now enables tinycortex's +# `git-diff` / `wiki-git` features either, so libgit2 is out of the graph +# entirely; re-adding a gate for it means re-adding the cohort. hmac = "0.12" # Archive handling for the Piper voice installer and the document tools. The # Node.js and Python toolchain archives are no longer unpacked here — the From 0560fa11d0f9806c9812a68aa21a73e4166c13b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:15:43 +0300 Subject: [PATCH 34/44] chore(memory): remove memory diff tool registration Remove memory diff controllers, exports, and runtime registration so the unsupported snapshot-based capability is no longer exposed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/core/all.rs | 10 ---------- src/openhuman/memory/tools.rs | 6 ------ src/openhuman/tools/mod.rs | 2 -- src/openhuman/tools/ops.rs | 9 --------- 4 files changed, 27 deletions(-) diff --git a/src/core/all.rs b/src/core/all.rs index ea1fdd5f27d..8d1e6c8444a 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -849,13 +849,6 @@ fn build_registered_controllers() -> Vec { Some(Capability::Sources), crate::openhuman::memory::sources::all_memory_sources_registered_controllers(), ); - // Memory diff — snapshot-based change tracking for memory sources - push_cap( - &mut controllers, - DomainGroup::Memory, - Some(Capability::Diff), - crate::openhuman::memory::diff::all_memory_diff_registered_controllers(), - ); // Referral and growth tracking push( &mut controllers, @@ -1169,9 +1162,6 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> { "memory_sources" => Some( "User-configured data connectors (Composio, folders, GitHub repos, RSS, web pages) that feed memory.", ), - "memory_diff" => Some( - "Snapshot-based change tracking for memory sources — capture state, compute diffs, and surface changes to agents.", - ), "referral" => Some("Referral codes, stats, and apply flows via the hosted backend API."), "run_ledger" => Some( "Durable agent and workflow run state, child lineage, events, telemetry, and checkpoint references.", diff --git a/src/openhuman/memory/tools.rs b/src/openhuman/memory/tools.rs index 5b5962eced3..05388e0f92f 100644 --- a/src/openhuman/memory/tools.rs +++ b/src/openhuman/memory/tools.rs @@ -16,10 +16,6 @@ mod store; // `raw_store` was `store/tools/`, `search` was `search/tools/`, `tool_memory` // was `tool_memory/tools/` — and `diff` / `goals` / `people` were each that // domain's `tools.rs`. -// Git-backed diff snapshots only exist under `memory-git`; upstream gated this -// at `diff::tools`, and the move here has to carry that with it. -#[cfg(feature = "memory-git")] -pub mod diff; pub mod goals; pub mod raw_store; pub mod search; @@ -35,8 +31,6 @@ pub use store::MemoryStoreTool; // The tools that came back from the extracted crate, re-exported flat so // `openhuman::tools`'s glob keeps every historical name in scope — the // registration sites in `tools/ops.rs` name them unqualified. -#[cfg(feature = "memory-git")] -pub use diff::MemoryDiffTool; pub use goals::{GoalsAddTool, GoalsDeleteTool, GoalsEditTool, GoalsListTool}; pub use raw_store::{MemoryStoreKindsTool, MemoryStoreRawChunksTool, MemoryStoreRawSearchTool}; pub use search::{MemoryChunkContextTool, MemoryHybridSearchTool, MemoryVectorSearchTool}; diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index db8fed9f02e..82430c569f9 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -39,8 +39,6 @@ pub use crate::openhuman::integrations::tools::*; #[cfg(feature = "mcp")] pub use crate::openhuman::mcp::registry::tools::*; pub use crate::openhuman::memory::agent::tools::*; -#[cfg(feature = "memory-git")] -pub use crate::openhuman::memory::tools::diff::*; pub use crate::openhuman::memory::tools::goals::*; pub use crate::openhuman::memory::tools::*; pub use crate::openhuman::platform::cost::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 154d28096e7..bbe20fbb031 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -691,15 +691,6 @@ pub fn all_tools_with_runtime( memory_hybrid_search, memory_store_raw_search, memory_store_raw_chunks, memory_store_kinds" ); - // Memory diff — structured "what changed in the agent's world since a - // checkpoint/last sync". Drives the subconscious tick's first stage and is - // available to any agent that lists it. Unit struct, no runtime deps. - // Absent rather than erroring when `memory-git` is off: a registered tool - // that always fails is worse than no tool, because the model keeps - // choosing it and reporting the failure back to the user. - #[cfg(feature = "memory-git")] - tools.push(Box::new(crate::openhuman::memory::diff::MemoryDiffTool)); - // Presentation generation (#2778). Native-Rust engine (ppt-rs // backed) as of the #2780-follow-up rust-engine refactor — no // managed Python venv, no first-call install latency. Always From 79794bc880e8a835749d3c4a441deaece9c9eaa0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:16:22 +0300 Subject: [PATCH 35/44] test(memory): update expectations for removed memory_diff tool Adjust capability and registration tests to reflect that memory_diff was removed with the memory-git feature gate. Ensure all builds assert the tool remains absent. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/core/cli_tests.rs | 10 +++------ src/openhuman/tools/ops_tests.rs | 36 +++++++------------------------- 2 files changed, 10 insertions(+), 36 deletions(-) diff --git a/src/core/cli_tests.rs b/src/core/cli_tests.rs index ff4e258b1d2..ed6e067bb50 100644 --- a/src/core/cli_tests.rs +++ b/src/core/cli_tests.rs @@ -446,15 +446,11 @@ fn default_build_leaves_the_generic_namespace_path_unchanged() { for ns in ["memory", "memory_tree", "memory_goals"] { assert!(grouped.contains_key(ns), "`{ns}` must still be listed"); } - #[cfg(feature = "memory-git")] - assert!( - grouped.contains_key("memory_diff"), - "`memory_diff` must be listed when the memory-git feature is enabled" - ); - #[cfg(not(feature = "memory-git"))] + // `memory_diff` was removed with the `memory-git` gate; it must not come + // back as a listed namespace. assert!( !grouped.contains_key("memory_diff"), - "`memory_diff` must be absent when the memory-git feature is disabled" + "`memory_diff` was removed and must not be listed" ); } diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 3dabb7b02d9..58c0453e69b 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -2471,8 +2471,6 @@ const MEMORY_TOOL_CAPABILITIES: &[(&str, tinymemory_api::capabilities::Capabilit ("memory_tree", C::Tree), ("memory_flavour", C::Tree), ("memory_store_raw_search", C::Entities), - #[cfg(feature = "memory-git")] - ("memory_diff", C::Diff), ("memory_doctor", C::Maintenance), ("tool_stats", C::ToolMemory), ("goals_list", C::Goals), @@ -2559,9 +2557,8 @@ fn memory_capability_table_names_are_real() { .chain(MEMORY_TOOLS_NOT_DRIVER_BACKED.iter().copied()) // `tool_stats` is registered only when `learning.tool_tracking_enabled`, // so it is config-dependent and asserted by the function-level guard - // above instead. `memory_diff` is registered only when the - // `memory-git` feature is compiled in; no CI lane enables it. - .filter(|n| *n != "tool_stats" && (*n != "memory_diff" || cfg!(feature = "memory-git"))) + // above instead. + .filter(|n| *n != "tool_stats") { assert!( names.iter().any(|n| n == name), @@ -2596,11 +2593,6 @@ fn null_driver_memory_cfg() -> crate::openhuman::config::schema::MemorySubsystem /// The optional-family tools that must vanish under a driver advertising /// nothing optional. /// -/// `memory_diff` is deliberately NOT in this list even though it is one of -/// these optional-family tools: it only registers at all when the -/// `memory-git` feature is compiled in (no CI lane enables it), so its -/// presence is asserted separately, conditioned on that feature, rather than -/// unconditionally here. const OPTIONAL_FAMILY_MEMORY_TOOLS: &[&str] = &[ "memory_tree", "memory_flavour", @@ -2631,12 +2623,6 @@ fn memory_tools_all_present_with_no_ambient_context() { "`{name}` must be present with no ambient context; got: {names:?}" ); } - if cfg!(feature = "memory-git") { - assert!( - names.iter().any(|n| n == "memory_diff"), - "`memory_diff` must be present with no ambient context when `memory-git` is on; got: {names:?}" - ); - } } /// Under the default binding the TinyMemory module @@ -2663,24 +2649,17 @@ async fn memory_tools_all_present_under_the_module_driver() { "`{name}` must survive the module driver; got: {names:?}" ); } - if cfg!(feature = "memory-git") { - assert!( - names.iter().any(|n| n == "memory_diff"), - "`memory_diff` must survive the module driver when `memory-git` is on; got: {names:?}" - ); - } } -/// The git-backed diff tool must not advertise an implementation that cannot -/// run when the `memory-git` feature is compiled out. -#[cfg(not(feature = "memory-git"))] +/// The git-backed diff tool was deleted along with the `memory-git` gate. +/// This pins that it stays gone in every build, not merely unregistered. #[test] -fn memory_diff_tool_is_absent_when_memory_git_is_disabled() { +fn memory_diff_tool_is_absent_in_every_build() { let tmp = TempDir::new().unwrap(); let names = tool_names(&expansion_tools_for(&tmp)); assert!( !names.iter().any(|name| name == "memory_diff"), - "memory_diff must be absent when the memory-git feature is disabled; got: {names:?}" + "memory_diff was removed with the memory-git gate; got: {names:?}" ); } @@ -2704,8 +2683,7 @@ async fn optional_family_memory_tools_absent_under_the_null_driver() { "`{absent}` must be ABSENT under the null driver; got: {names:?}" ); } - // Absent either way: the null driver disables it (when `memory-git` is - // on) or the feature gate already dropped it entirely (when it's off). + // Removed outright with the `memory-git` gate. assert!( !names.iter().any(|n| n == "memory_diff"), "`memory_diff` must be ABSENT under the null driver; got: {names:?}" From 1faeb8189b87b4207a4805fe8b89e406a9ea3a28 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:16:59 +0300 Subject: [PATCH 36/44] test(core): update coverage for removed memory diff controllers Adjust core registry tests to reflect the removal of the memory-git diff surface while preserving the memory namespace and capability accounting. The tests now verify that memory_diff stays absent and memory remains registered. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/core/all_tests.rs | 66 ++++++++++--------------------------------- 1 file changed, 15 insertions(+), 51 deletions(-) diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 18b286bd50e..0d924566e17 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -1652,8 +1652,6 @@ const MEMORY_NAMESPACE_CAPABILITY: &[(&str, Option)] = &[ ("slack_memory", Some(Capability::Sources)), ("memory_sync", Some(Capability::Sources)), ("memory_sources", Some(Capability::Sources)), - #[cfg(feature = "memory-git")] - ("memory_diff", Some(Capability::Diff)), ]; /// The `memory` namespace, function by function. Core and recall share the @@ -1762,11 +1760,6 @@ fn memory_capability_map_has_no_stale_entries() { .collect(); for (ns, _) in MEMORY_NAMESPACE_CAPABILITY { - // `memory_diff` only registers when `memory-git` is compiled in; no CI - // lane enables it, so it would otherwise read as a stale table entry. - if *ns == "memory_diff" && !cfg!(feature = "memory-git") { - continue; - } assert!( live.iter().any(|(n, _)| n == ns), "MEMORY_NAMESPACE_CAPABILITY names `{ns}`, which registers no Memory controller" @@ -1806,9 +1799,10 @@ fn every_capability_family_is_accounted_for_in_the_rpc_surface() { | Capability::Goals | Capability::ToolMemory | Capability::Sources => true, - #[cfg(feature = "memory-git")] - Capability::Diff => true, - #[cfg(not(feature = "memory-git"))] + // The `memory_diff` controllers were deleted with the + // `memory-git` gate, so this capability owns no RPC surface. The + // bus contract still defines the variant, and a driver may still + // advertise it — there is simply nothing here to register. Capability::Diff => false, // `Core` gates the combined core + recall controller partition so // a null driver removes the entire driver-backed surface. Recall @@ -1988,13 +1982,6 @@ async fn memory_families_registered_when_capabilities_advertised() { "`{present}` must be present under a full-capability driver" ); } - // `memory_diff` only registers when `memory-git` is compiled in. - if cfg!(feature = "memory-git") { - assert!( - ns.contains("memory_diff"), - "`memory_diff` must be present under a full-capability driver when `memory-git` is on" - ); - } for present in [ "doc_put", "doc_ingest", @@ -2430,11 +2417,6 @@ fn sole_capability_for_namespace_reports_a_single_family_namespace() { sole_capability_for_namespace("memory_tree"), Some(Capability::Tree) ); - #[cfg(feature = "memory-git")] - assert_eq!( - sole_capability_for_namespace("memory_diff"), - Some(Capability::Diff) - ); } #[test] @@ -2476,46 +2458,28 @@ fn javascript_controllers_absent_when_feature_off() { ); } -// ---- memory-git gate ------------------------------------------------------- +// ---- memory_diff removal --------------------------------------------------- -/// `memory-git` ON: the git-backed diff surface is registered. -#[cfg(feature = "memory-git")] -#[test] -fn memory_diff_controllers_registered_when_feature_on() { - let namespaces: Vec<&str> = all_controller_schemas() - .iter() - .map(|s| s.namespace) - .collect(); - assert!( - namespaces.contains(&"memory_diff"), - "with the `memory-git` feature ON the `memory_diff` controllers must be registered" - ); -} - -/// `memory-git` OFF: `memory_diff` leaves no trace in the registry, while the -/// rest of the memory surface stays. -/// -/// This is the half that proves the gate does something. The stub's schema -/// aggregators return empty vecs rather than always-erroring handlers, so the -/// namespace must be genuinely unknown-method — not present-but-broken, which -/// would still advertise itself on `/schema`. +/// The `memory_diff` controllers were deleted along with the `memory-git` +/// feature, and must stay gone — while the rest of the memory surface stays. /// -/// `memory` is asserted present in the same test on purpose: the gate is -/// supposed to remove the git ledger, not the memory domain. Splitting that -/// into a separate test would let one pass while the other silently regressed. -#[cfg(not(feature = "memory-git"))] +/// `memory` is asserted present in the same test on purpose: the removal took +/// the git ledger, not the memory domain. Splitting that into a separate test +/// would let one pass while the other silently regressed. This replaces the +/// `{registered_when_feature_on,absent_when_feature_off}` pair that pinned the +/// gate while it existed. #[test] -fn memory_diff_controllers_absent_when_feature_off() { +fn memory_diff_controllers_are_gone_and_memory_survives() { let namespaces: Vec<&str> = all_controller_schemas() .iter() .map(|s| s.namespace) .collect(); assert!( !namespaces.contains(&"memory_diff"), - "with `memory-git` OFF the `memory_diff` controllers must not be registered, got: {namespaces:?}" + "`memory_diff` was removed and must not be registered, got: {namespaces:?}" ); assert!( namespaces.contains(&"memory"), - "the `memory-git` gate must remove the git ledger, not the memory domain" + "removing the git ledger must not remove the memory domain" ); } From ffe746760ba49eafc0e96e681a5b4505981b9cfc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:17:30 +0300 Subject: [PATCH 37/44] test(json-rpc): remove obsolete memory diff e2e coverage Remove the memory diff lifecycle test and stop treating memory_diff as an absent family in capability checks. This aligns the test suite with the current memory diff registration behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/core/all_tests.rs | 1 - tests/json_rpc_e2e.rs | 243 ------------------------------------------ 2 files changed, 244 deletions(-) diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index 0d924566e17..856d269d8ba 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -2014,7 +2014,6 @@ async fn memory_families_absent_when_capabilities_not_advertised() { "tree_summarizer", "memory_sync", "memory_sources", - "memory_diff", "slack_memory", ] { assert!( diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 69c8f9504ef..3a6ca98cd34 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -4605,249 +4605,6 @@ async fn json_rpc_memory_tree_end_to_end() { let _ = mock_join.await; } -/// `openhuman.memory_diff_*` full lifecycle over JSON-RPC. -/// -/// Drives the snapshot-based change tracker end to end: register a folder -/// source, ingest chunks under its `mem_src::%` prefix across several -/// snapshots, then exercise take_snapshot, diff_since_last, the read-marker -/// watermark (diff_since_read + commit → empty on re-read → only-new after a -/// later snapshot), mark_read, and cross-source checkpoints. This is the -/// turn-to-turn world-diff assertion. (Per-item add/remove/modify detection -/// and text diffs are covered exhaustively by the ops unit tests.) -/// -/// Gated on `memory-git`: the `memory_diff` controllers only register when the -/// git-backed diff domain is compiled in, so without the gate every call here -/// answers `unknown method`. This is a `#[cfg]` on the one symbol rather than a -/// `required-features` line on the target, deliberately — `json_rpc_e2e` is -/// otherwise gate-free, and gating the whole target would silently skip its -/// other ~100 tests in every lane that does not enable `memory-git`. That is -/// the distinction the `required-features` comment in `Cargo.toml` draws, and -/// the per-symbol `#[cfg]` cleanup tracked in #5021. -#[cfg(feature = "memory-git")] -#[tokio::test] -async fn json_rpc_memory_diff_snapshot_diff_and_read_marker_lifecycle() { - let _env_lock = json_rpc_e2e_env_lock(); - let tmp = tempdir().expect("tempdir"); - let home = tmp.path(); - let openhuman_home = home.join(".openhuman"); - - let _home_guard = EnvVarGuard::set_to_path("HOME", home); - // Share the module's workspace: this case ingests through the memory - // contract and reads back through direct SQLite, so the two must name - // one store. See `json_rpc_e2e_shared_workspace`. - let _workspace_guard = - EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", json_rpc_e2e_shared_workspace()); - let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); - let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); - // Fall back to the inert (zero-vector) embedder; CI has no local Ollama. - let _embed_strict_guard = EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_STRICT", "false"); - let _embed_endpoint_guard = EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_ENDPOINT", ""); - let _embed_model_guard = EnvVarGuard::set("OPENHUMAN_MEMORY_EMBED_MODEL", ""); - - let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; - let mock_origin = format!("http://{}", mock_addr); - write_min_config(&openhuman_home, &mock_origin); - - let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; - let rpc_base = format!("http://{rpc_addr}"); - - tokio::time::sleep(Duration::from_millis(100)).await; - - // ── Register a folder memory source; capture its generated id ───────── - let add = post_json_rpc( - &rpc_base, - 9001, - "openhuman.memory_sources_add", - json!({ - "kind": "folder", - "label": "Diff E2E Docs", - "path": home.join("docs").to_string_lossy(), - }), - ) - .await; - let add_result = assert_no_jsonrpc_error(&add, "memory_sources_add"); - let add_result = add_result.get("result").unwrap_or(add_result); - let source_id = add_result - .pointer("/source/id") - .and_then(Value::as_str) - .expect("source id") - .to_string(); - - // Chunks belonging to a reader-backed source live under `mem_src::%`. - let ingest = |id: i64, item: &str, body: &str| { - let composite = format!("mem_src:{source_id}:{item}"); - post_json_rpc( - &rpc_base, - id, - "openhuman.memory_tree_ingest", - json!({ - "source_kind": "document", - "source_id": composite, - "owner": "alice@example.com", - "payload": { - "provider": "folder", - "title": item, - "body": body, - "modified_at": 1700000000000_i64, - "source_ref": format!("file://{item}"), - } - }), - ) - }; - - // ── Generation 1: one doc, then snapshot ────────────────────────────── - let g1 = ingest(9002, "doc1.md", "First version of the launch plan.").await; - assert_no_jsonrpc_error(&g1, "ingest g1"); - - let snap1 = post_json_rpc( - &rpc_base, - 9003, - "openhuman.memory_diff_take_snapshot", - json!({ "source_id": source_id }), - ) - .await; - let snap1 = assert_no_jsonrpc_error(&snap1, "take_snapshot 1"); - let snap1 = snap1.get("result").unwrap_or(snap1); - assert_eq!( - snap1.pointer("/snapshot/item_count"), - Some(&json!(1)), - "first snapshot has one item: {snap1}" - ); - - // ── Generation 2: add doc2, then snapshot ───────────────────────────── - let g2b = ingest(9005, "doc2.md", "Rollout checklist and staging notes.").await; - assert_no_jsonrpc_error(&g2b, "ingest g2b"); - - let snap2 = post_json_rpc( - &rpc_base, - 9006, - "openhuman.memory_diff_take_snapshot", - json!({ "source_id": source_id }), - ) - .await; - let snap2 = assert_no_jsonrpc_error(&snap2, "take_snapshot 2"); - let snap2 = snap2.get("result").unwrap_or(snap2); - assert_eq!( - snap2.pointer("/snapshot/item_count"), - Some(&json!(2)), - "second snapshot has two items: {snap2}" - ); - - // ── diff_since_last: doc2 added, doc1 unchanged ─────────────────────── - let dsl = post_json_rpc( - &rpc_base, - 9007, - "openhuman.memory_diff_diff_since_last", - json!({ "source_id": source_id, "include_text_diff": true }), - ) - .await; - let dsl = assert_no_jsonrpc_error(&dsl, "diff_since_last"); - let dsl = dsl.get("result").unwrap_or(dsl); - assert_eq!(dsl.pointer("/diff/summary/added"), Some(&json!(1)), "{dsl}"); - assert_eq!( - dsl.pointer("/diff/summary/unchanged"), - Some(&json!(1)), - "{dsl}" - ); - - // ── diff_since_read (commit) then re-read → empty (marker advanced) ─── - let read1 = post_json_rpc( - &rpc_base, - 9008, - "openhuman.memory_diff_diff_since_read", - json!({ "source_id": source_id }), - ) - .await; - let read1 = assert_no_jsonrpc_error(&read1, "diff_since_read 1"); - let read1 = read1.get("result").unwrap_or(read1); - // First read with no prior marker reports everything as added. - assert_eq!( - read1.pointer("/diff/summary/added"), - Some(&json!(2)), - "{read1}" - ); - - let read2 = post_json_rpc( - &rpc_base, - 9009, - "openhuman.memory_diff_diff_since_read", - json!({ "source_id": source_id }), - ) - .await; - let read2 = assert_no_jsonrpc_error(&read2, "diff_since_read 2"); - let read2 = read2.get("result").unwrap_or(read2); - assert_eq!( - read2.pointer("/diff/summary/added"), - Some(&json!(0)), - "{read2}" - ); - assert_eq!( - read2.pointer("/diff/summary/modified"), - Some(&json!(0)), - "second read after commit is empty: {read2}" - ); - - // ── mark_read is idempotent on an already-acknowledged source ───────── - let mark = post_json_rpc( - &rpc_base, - 9010, - "openhuman.memory_diff_mark_read", - json!({ "source_ids": [source_id] }), - ) - .await; - let mark = assert_no_jsonrpc_error(&mark, "mark_read"); - let mark = mark.get("result").unwrap_or(mark); - assert_eq!(mark.get("marked"), Some(&json!(1)), "{mark}"); - - // ── Checkpoint + cross-source diff after a further change ───────────── - let ckpt = post_json_rpc( - &rpc_base, - 9011, - "openhuman.memory_diff_create_checkpoint", - json!({ "label": "baseline" }), - ) - .await; - let ckpt = assert_no_jsonrpc_error(&ckpt, "create_checkpoint"); - let ckpt = ckpt.get("result").unwrap_or(ckpt); - let checkpoint_id = ckpt - .pointer("/checkpoint/id") - .and_then(Value::as_str) - .expect("checkpoint id") - .to_string(); - - // Add doc3, snapshot, then diff since the checkpoint. - let g3 = ingest(9012, "doc3.md", "Post-launch retro and metrics.").await; - assert_no_jsonrpc_error(&g3, "ingest g3"); - let snap3 = post_json_rpc( - &rpc_base, - 9013, - "openhuman.memory_diff_take_snapshot", - json!({ "source_id": source_id }), - ) - .await; - assert_no_jsonrpc_error(&snap3, "take_snapshot 3"); - - let since_ckpt = post_json_rpc( - &rpc_base, - 9014, - "openhuman.memory_diff_diff_since_checkpoint", - json!({ "checkpoint_id": checkpoint_id }), - ) - .await; - let since_ckpt = assert_no_jsonrpc_error(&since_ckpt, "diff_since_checkpoint"); - let since_ckpt = since_ckpt.get("result").unwrap_or(since_ckpt); - assert_eq!( - since_ckpt.pointer("/diff/summary/added"), - Some(&json!(1)), - "doc3 added since checkpoint: {since_ckpt}" - ); - - rpc_join.abort(); - let _ = rpc_join.await; - mock_join.abort(); - let _ = mock_join.await; -} - /// `openhuman.memory_tree_cover_window` over RPC: ingest a chunk, then assert /// the windowed minimum-cover returns it raw inside the window and nothing /// outside it. One ingested chunk doesn't reach the seal fanout, so this also From 330e0679616c61b6db069dd72e4c8e609d8fb380 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:18:08 +0300 Subject: [PATCH 38/44] chore: files changed src/openhuman/memory/direct_engine_refs_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/memory/direct_engine_refs_tests.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/openhuman/memory/direct_engine_refs_tests.rs b/src/openhuman/memory/direct_engine_refs_tests.rs index c053e9cb56b..a98a28e4f81 100644 --- a/src/openhuman/memory/direct_engine_refs_tests.rs +++ b/src/openhuman/memory/direct_engine_refs_tests.rs @@ -339,11 +339,6 @@ const ALLOWED: &[(&str, Verdict, &str)] = &[ Verdict::HostSide, "re-export shim for the thread-id task-local", ), - ( - "src/openhuman/memory/diff/mod.rs", - Verdict::HostSide, - "re-export shim: pub use tinymemory_core::diff::*", - ), ( "src/openhuman/memory/mod.rs", Verdict::HostSide, From 13620c8187db948142ccdcba3c819a82c6e56622 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:20:01 +0300 Subject: [PATCH 39/44] chore(deps): remove unused git dependencies Remove git2 and its native library dependencies from the lockfile, along with the stale hex dependency reference. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e63d1dfb556..4d59635c059 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2353,18 +2353,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "git2" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" -dependencies = [ - "bitflags 2.13.1", - "libc", - "libgit2-sys", - "log", -] - [[package]] name = "glob" version = "0.3.3" @@ -3264,18 +3252,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libgit2-sys" -version = "0.18.7+1.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23c7391e4b9f4ffab1a624223cc1d7385ff9a678f490768add717de7ea2f4d89" -dependencies = [ - "cc", - "libc", - "libz-sys", - "pkg-config", -] - [[package]] name = "libloading" version = "0.8.9" @@ -3312,18 +3288,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-sys" -version = "1.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "line-clipping" version = "0.3.7" @@ -6454,8 +6418,6 @@ dependencies = [ "chrono", "dirs 5.0.1", "futures", - "git2", - "hex", "log", "objc2 0.6.4", "objc2-contacts", From ebbc04d40f6051ee04fe5771679a9ec892fcb01a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:20:18 +0300 Subject: [PATCH 40/44] refactor(memory): stop exporting the diff module Remove the public diff module export from the memory module to keep the exposed API limited to supported components. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/memory/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/openhuman/memory/mod.rs b/src/openhuman/memory/mod.rs index c5bc8648da7..d0bff190a50 100644 --- a/src/openhuman/memory/mod.rs +++ b/src/openhuman/memory/mod.rs @@ -76,7 +76,6 @@ pub mod tools; // plus the handler/schema modules that name `RpcOutcome` and // `ControllerSchema`. See the module docs on each for the split. pub mod conversations; -pub mod diff; pub mod goals; pub mod people; pub mod schema; From 6a519c80e1c308cf4b096976f92be4daee18a02d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:32:05 +0300 Subject: [PATCH 41/44] docs(ci): clarify permanent removal of memory-git Update the product feature notes to explain that memory-git and its associated surfaces were deleted rather than merely disabled. Document the remaining libgit2 ownership and clarify that the TinyMemory bus contract still permits diff capability. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/ci/product-features.txt | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/ci/product-features.txt b/scripts/ci/product-features.txt index d76ef2893f6..1180fe7b736 100644 --- a/scripts/ci/product-features.txt +++ b/scripts/ci/product-features.txt @@ -90,17 +90,19 @@ contacts # its static liblzma C build. runtime-node -# NOTE: `memory-git` was REMOVED from the product set. It was the last gate -# carrying a native C build the product could shed: `git2` brings `libgit2-sys` -# and `libz-sys`, which were 2 of the 4 native builds in this profile (now 2 — -# `libsqlite3-sys` and `ring`, both load-bearing). Only 3 crates, but two C -# toolchain builds is disproportionate build time for a surface nothing in the -# desktop UI calls: `memory_diff` had no frontend caller. What the product -# loses: the `memory_diff` RPC namespace becomes unknown-method, the -# `memory_diff` agent tool is absent, the embedded driver stops advertising -# `Capability::Diff`, and summary nodes are still written to disk but no longer -# mirrored into git. Re-add this line (and the shell's forwarding entry) to -# bring it back. +# NOTE: `memory-git` no longer exists. The gate, the `memory::diff` RPC surface +# and schemas, the `memory_diff` agent tool and the `memory_artifacts_e2e` +# target were all deleted — not merely un-shipped — so there is nothing here to +# re-enable. It carried `git2`, which brought `libgit2-sys` and `libz-sys`: 2 of +# the 4 native C builds in this profile, now down to `libsqlite3-sys` and `ring`, +# both load-bearing. `git2` is absent from the resolved graph entirely +# (`cargo tree -i git2` finds no package), so the shed holds by construction +# rather than by a flag someone can flip. +# +# tinycortex still owns the only libgit2 code in the stack and keeps its +# `git-diff` / `wiki-git` features; nothing in this repository enables them. +# `Capability::Diff` remains in the TinyMemory bus contract and a driver may +# still advertise it — there is simply no RPC surface here behind it. # The `hosting_*` agent tools: put a workspace on a real hosting provider and # manage its sites, databases, environment, domains and deployments over the From 1577115af3faeae7e6d21d905a25913ec5d797a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:32:17 +0300 Subject: [PATCH 42/44] docs: clarify removal of the memory-git feature Update the architecture guidance to state that the memory-git gate and its diff surface were deleted outright. Remove the obsolete feature row and clarify that libgit2 is no longer present in any profile. Auto-committed-on: dragonfly Co-authored-by: Medulla --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 66abcdd9f89..ec22aa2cf73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -676,7 +676,7 @@ second host would embed to get workflow execution and nothing else. It is measur and ratcheted, because unmeasured it grows — `rusqlite`/bundled and `tokio-tungstenite` remain unconditional today (`git2`/vendored-libgit2 left the kernel profile with the `libgit2-sys` + `libz-sys` shed below, once it moved -behind the `memory-git` gate), and none would likely have landed that way had a +behind the `memory-git` gate, since deleted outright), and none would likely have landed that way had a number moved in CI when they did. ```bash @@ -692,7 +692,8 @@ builds** (`libsqlite3-sys`, `ring`). **This is the target** — MIGRATION-PLAN G set 2 native builds as the goal, and the profile is there, down from 418 names / 6 native when the program started. The four that left: `aws-lc-sys` (the tinychannels rustls pin), `lzma-sys` (the `runtime-node` gate), and -`libgit2-sys` + `libz-sys` together (the `memory-git` gate). The macOS graph +`libgit2-sys` + `libz-sys` together (the `memory-git` gate, now deleted along +with the `memory::diff` surface it guarded — libgit2 is out of every profile). The macOS graph resolves a few packages higher because of target-specific edges; the CI ratchet is intentionally calibrated on Linux. @@ -733,7 +734,6 @@ Two columns because there are two sets (see above): **Contrib** is `[features] d | `mcp` | ON | ON | `openhuman::mcp::server` (the `openhuman mcp` stdio/HTTP server), `openhuman::mcp::registry` (dynamic Smithery installs — `mcp_clients` RPC namespace, SQLite, boot spawn, supervisor, OAuth), `openhuman::mcp::audit` (write-audit log), and the static config-declared server set in `openhuman::mcp::config_servers`. ~19 agent tools, ~20k LOC | **none** — and the `tinymcp` module extraction does not change that either; see the scope note | | `tui` | OFF | — | `openhuman::tui` — the tabbed ratatui/crossterm CLI UI (Logs, Chat, Config, Settings), auto-opened by bare `openhuman` on interactive non-container hosts and forced with `openhuman tui` (alias `chat`). Runs the core in-process. No controllers, no agent tools. **Intentionally NOT forwarded to the desktop shell** (allowlisted in `check-feature-forwarding.mjs`). | `ratatui`, `crossterm` | | `channels` | ON | ON | `openhuman::channels` (external-messaging providers — Telegram/Discord/Slack/Signal/WhatsApp/iMessage/IRC/… — plus the channel runtime, controllers, host, proactive messaging + inbound dispatch) and the `webview_notifications` bridge domain. **Carve-outs `channels::{traits, cli}` stay ungated.** The family now owns **no agent tool** — the three `whatsapp_data_*` tools were its only ones and went with the store (see below) — which is why `DomainGroup::Channels` is in `TOOL_LESS` in `tools/ops_tests.rs`, alongside `Relay`. | **28** via `tinychannels/{email,lark}` — the crate itself stays (load-bearing), its two heavy providers do not | -| `memory-git` | OFF | **OFF** | `openhuman::memory::diff` (git-backed snapshots/checkpoints/read markers, the `memory_diff` RPC namespace + agent tool) and the git wiki mirror in `memory::store::content::wiki_git`. **Type carve-out**: `memory::diff::types` compiles in BOTH builds — the always-on memory profile renders `CrossSourceDiff`/`ChangeKind` into prompts, and tinycortex makes the matching split (its `memory::diff::{types,source}` are ungated, only the `Ledger`/`DiffEngine` half sits behind `git-diff`). Off ⇒ `memory_diff` is unknown-method, the tool is absent, the embedded driver drops `Capability::Diff` **and** `as_diff()` returns `None` in lockstep (`audit_provider` fails on either half alone), and summary nodes are still written to disk but not mirrored into git. **This crate declares no `git2`** — tinycortex owns every libgit2 call in the stack (the diff ledger, the wiki mirror, the persona git-history reader), and the gate reaches the cohort by forwarding `tinycortex/git-diff` + `tinycortex/wiki-git`; `tinymemory-core/memory-git` forwards the same pair. Do not re-add a direct `git2` dependency to this crate or to `tinymemory-core`: it would buy no crates and invite a second major pin, which `links = "git2"` makes a hard cargo error. Test code that must read a ledger back goes through the `tinycortex::git2` re-export (`tests/memory_artifacts_e2e.rs`). | **3**: `git2`, `libgit2-sys`, `libz-sys` — two of the five native C builds in the kernel profile, the largest native shed in the program. **Also removed from the product set**: those same two were 2 of the 4 native builds in the product profile, so the shipped app now needs only `libsqlite3-sys` + `ring`. Three crates is a small shed; two C toolchain builds is not, and `memory_diff` had no frontend caller. | | `contacts` | OFF | ON | `memory::people::address_book`'s macOS CNContactStore reader — the address-book seeding path for the people domain. Leaf gate over a **pre-existing** off-state: the module already shipped a non-macOS `imp` stub returning an empty contact list, so the gate only widens that stub's cfg. `read`/`read_with`/`AddressBookError`/`SystemContactsSource` and the whole `people` RPC surface stay compiled in every build; off ⇒ a refresh seeds nothing instead of failing. | **6** on macOS (`objc2`, `objc2-foundation`, `objc2-contacts`, `block2` + 2 transitive). **No-op on Linux/Windows** — never in those graphs, so the kernel-floor ratchet does not move. Verify cross-target: `cargo tree --target aarch64-apple-darwin -e normal -i objc2-contacts --no-default-features` (294 → 288 packages). | | `runtime-node` | OFF | ON | `runtime::node` (the client that asks the `tinyruntime` module for a Node.js toolchain), the `runtime::javascript` language slot, `runtime::pool::node`, the `node_exec` / `npm_exec` agent tools, and the `node_runtime` harness-init step. **Facade + stub** — `ShellTool` holds `Option>` and `shell.rs` is kernel, so the module cannot simply vanish; `runtime/node/stub.rs` carries the `NodeBootstrap` type surface while registration sites are leaf-gated. **The generic native-tool dispatcher (`runtime::node::ops` / `runtime::node::types`) is NOT gated** — it backs both the gated `javascript.*` controllers and the ungated `flows` `oh:` `NativeToolBackend`, so native flow tools (`memory_search`, file, shell, …) keep working when the managed Node runtime is off. Off ⇒ `try_cached`/`probe_installed` return `None` and the shell never prepends a managed bin dir, identical to today's `node.enabled = false` path. | **Nothing any more.** This gate used to shed `xz2` and its static liblzma C build; download and extraction moved into the `tinyruntime` module, so that native build left the manifest for **every** configuration rather than only for slim ones. The gate still buys the absence of the tools and controllers. | From cccfa7764eb9781756028c4d2896c2bc3d25e2ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:32:43 +0300 Subject: [PATCH 43/44] docs: update git2 minimal recipe guidance Clarify that the git-backed memory diff functionality and related dependencies were removed entirely rather than made optional. Note that libgit2 remains only in tinycortex behind its existing features. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/library-minimal-recipe.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/library-minimal-recipe.md b/docs/library-minimal-recipe.md index 97bc830e2b8..6dada72a528 100644 --- a/docs/library-minimal-recipe.md +++ b/docs/library-minimal-recipe.md @@ -219,10 +219,14 @@ prioritization. into its own sub-gate would reclaim most of that 12.7 MiB while keeping the flows graph engine. Currently all-or-nothing. -3. **`git2` (vendored libgit2).** Always-on native dependency of the `memory_diff` - change-ledger (git-backed snapshots/checkpoints/diffs). A large vendored C lib. - If a library host does not need git-backed memory diffs, this is a candidate for - a future gate. +3. ~~**`git2` (vendored libgit2).**~~ — **no longer applicable.** This entry + proposed gating the git-backed `memory_diff` change ledger. That went further: + the `memory-git` gate, the `memory::diff` RPC surface and the `memory_diff` + agent tool were deleted outright, so `git2` — with `libgit2-sys` and + `libz-sys` — is absent from every profile rather than merely gateable. + `cargo tree -i git2` finds no package. tinycortex still owns the only libgit2 + code in the stack and keeps its `git-diff` / `wiki-git` features; nothing in + this repository enables them. 4. **`reqwest` dual TLS backends.** The root `reqwest` enables both `rustls-tls` **and** `native-tls` — two full TLS stacks linked simultaneously. A headless From 4d181f9187c6d4a4248fc7c20b75cd3e5e9078bb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:30:16 +0300 Subject: [PATCH 44/44] docs(memory): remove diff module from documentation Remove the outdated diff module entry from the memory module overview. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/openhuman/memory/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/src/openhuman/memory/README.md b/src/openhuman/memory/README.md index e24891a2f92..be7dd9b6f87 100644 --- a/src/openhuman/memory/README.md +++ b/src/openhuman/memory/README.md @@ -46,7 +46,6 @@ tinymemory_core::::*;` plus the handler/schema modules that name | Module | Role | | -------------------------------- | --------------------------------------------------------- | | [`conversations/`](conversations/) | Conversation-scoped memory RPC. | -| [`diff/`](diff/) | Git-backed diff RPC (gated by the `memory-git` feature). | | [`goals/`](goals/) | Goal tracking RPC. | | [`people/`](people/) | People/contacts RPC. | | [`sources/`](sources/) | Source-registration RPC. |