diff --git a/AGENTS.md b/AGENTS.md index f6de8d1a8..6784c65c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -317,7 +317,7 @@ Deep link plugin is registered where supported; behavior is platform-specific (s - **Controller schema contract**: Shared controller metadata types live in `src/core/types.rs` / `src/core/mod.rs` (`ControllerSchema`, `FieldSchema`, `TypeSchema`) and are consumed by adapters (RPC/CLI). - **Domain schema files**: For each domain, define controller schema metadata in a dedicated module inside the domain folder (example: `src/openhuman/cron/schemas.rs`) and export from the domain `mod.rs`. - **Controller-only exposure rule**: Expose domain functionality to **CLI and JSON-RPC through the controller registry** (`schemas.rs` + registered handlers wired into `src/core/all.rs`). Do **not** add domain-specific branches in `src/core/cli.rs` or `src/core/jsonrpc.rs`. -- **Light `mod.rs` rule**: Keep domain `mod.rs` files light and export-focused. Put operational code in sibling files (`ops.rs`, `store.rs`, `schedule.rs`, `types.rs`, `bus.rs`). +- **Light `mod.rs` rule**: Keep domain `mod.rs` files light and export-focused. Put operational code in sibling files (`ops.rs`, `store.rs`, `schedule.rs`, `types.rs`, `bus.rs`). See the **Canonical module shape** table in [`CLAUDE.md`](CLAUDE.md) for the full per-file contract (it is the single source of truth for module structure). - **`src/core/`** — Transport only: Axum/HTTP, JSON-RPC envelope, CLI parsing, **dispatch** (`src/core/dispatch.rs`), auth, observability, event bus. **No** heavy business logic here. (Older docs that say `core_server` mean this directory; there is no `src/core_server/`.) - **Layering**: Implementation in `openhuman::/`, controllers in `openhuman::/rpc.rs`, routes/dispatch in `src/core/`. diff --git a/CLAUDE.md b/CLAUDE.md index 01d45efc7..f94f46922 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -225,9 +225,28 @@ Watch out for Tauri plugins that inject JS by default. `tauri-plugin-opener` shi - **Controller schema contract**: shared types in `src/core/types.rs` / `src/core/mod.rs` (`ControllerSchema`, `FieldSchema`, `TypeSchema`). - **Domain schema files**: per-domain `schemas.rs` (e.g. `src/openhuman/cron/schemas.rs`), exported from domain `mod.rs`. - **Controller-only exposure**: expose features to CLI and JSON-RPC via the controller registry. **Do not** add domain branches in `src/core/cli.rs` / `src/core/jsonrpc.rs`. -- **Light `mod.rs`**: keep domain `mod.rs` export-focused. Operational code in `ops.rs`, `store.rs`, `types.rs`, etc. +- **Light `mod.rs`**: keep domain `mod.rs` export-focused. Operational code in `ops.rs`, `store.rs`, `types.rs`, etc. See **Canonical module shape** below for the full per-file contract. - **`src/core/`** — Transport only. Modules: `all`, `all_tests`, `auth`, `autocomplete_cli_adapter`, `cli`, `cli_tests`, `dispatch`, `event_bus/`, `jsonrpc`, `jsonrpc_tests`, `legacy_aliases`, `logging`, `memory_cli`, `observability`, `rpc_log`, `shutdown`, `socketio`, `types`, plus `agent_cli`. No heavy domain logic here. (There is no `src/core_server/` — older docs that reference `core_server` mean `src/core/`.) +### Canonical module shape + +Each high-level domain under `src/openhuman//` should follow this file contract. Only `mod.rs` and tests are universal; the rest exist **only when applicable** — do not create empty placeholder files (e.g. a stateless domain has no `store.rs`, a domain that exposes no agent tools has no `tools.rs`). + +| File | When | Role | +| --- | --- | --- | +| `mod.rs` | always | Export-focused **only**: module docstring + `mod`/`pub mod` decls + `pub use` re-exports, plus the `all__controller_schemas` / `all__registered_controllers` pair when RPC-facing. **No business logic, no domain-state statics, no domain `impl` blocks.** | +| `types.rs` | domain has its own types | Serde domain types. | +| `store.rs` | domain persists state | Persistence layer. | +| `ops.rs` | domain has logic / handlers | Business logic + entry points returning `RpcOutcome`. **Canonical handler file** (`ops.rs` is the majority convention; `rpc.rs` is legal only where a domain separates a pure-domain API from ops, e.g. `cron` does `pub use ops as rpc`). | +| `schemas.rs` | RPC-facing | Controller schemas + `handle_*` fns delegating to `ops.rs` (see **Controller migration checklist**). | +| `tools.rs` | domain owns agent tools | Domain-owned tool impls live here (+ optional `tools/` submodules), re-exported via `src/openhuman/tools/mod.rs` (see AGENTS.md "Tool ownership rule"). Only genuinely cross-cutting tool families (filesystem, browser/computer, generic system/network) stay in `src/openhuman/tools/impl/`. | +| `bus.rs` | domain has event subscribers | `EventHandler` impls (see **Event bus**). | +| tests | new/changed behavior | Inline `#[cfg(test)] mod tests` (small modules) **or** a sibling `_tests.rs` via `#[cfg(test)] #[path = "_tests.rs"] mod tests;` (large suites). Both are legal. | + +Two clarifications: +- **Inline tests do not count against "light `mod.rs`".** A `mod.rs` whose non-test body is pure re-exports is already compliant; moving a large inline suite into a sibling `mod_tests.rs` is tidiness, not a correctness requirement. +- **Narrow thin-facade exception:** pure dispatch forwarders (pick an implementation and forward — no domain state, no I/O of their own) MAY stay in `mod.rs` when being that facade is the module's whole purpose (e.g. `cwd_jail::spawn` / `spawn_with` / `default_backend`). Justify it in the module docstring. + ### Controller migration checklist - `src/openhuman//mod.rs`: add `mod schemas;`, re-export `all_controller_schemas as all__controller_schemas` and `all_registered_controllers as all__registered_controllers`. diff --git a/src/openhuman/cwd_jail/jail.rs b/src/openhuman/cwd_jail/jail.rs index 988dd5417..66afd7a66 100644 --- a/src/openhuman/cwd_jail/jail.rs +++ b/src/openhuman/cwd_jail/jail.rs @@ -72,6 +72,19 @@ impl Jail { } Ok(()) } + + /// Best-effort canonicalize that swallows errors and logs them. Most + /// callers should use the validating [`Jail::canonicalize`] path that + /// [`crate::openhuman::cwd_jail::spawn`] runs automatically. + pub fn canonicalize_or_log(&mut self) { + if let Err(e) = self.canonicalize() { + log::warn!( + "[cwd_jail] failed to canonicalize jail root {}: {}", + self.root.display(), + e + ); + } + } } /// OS-specific enforcement of a [`Jail`]. diff --git a/src/openhuman/cwd_jail/mod.rs b/src/openhuman/cwd_jail/mod.rs index 4b564497c..8d97ddbc7 100644 --- a/src/openhuman/cwd_jail/mod.rs +++ b/src/openhuman/cwd_jail/mod.rs @@ -92,21 +92,6 @@ pub fn spawn_with(backend: &dyn JailBackend, jail: &Jail, cmd: Command) -> std:: backend.spawn(&jail, cmd) } -impl Jail { - /// Best-effort canonicalize that swallows errors and logs them. Most - /// callers should use the validating [`Jail::canonicalize`] path that - /// [`jail`] runs automatically. - pub fn canonicalize_or_log(&mut self) { - if let Err(e) = self.canonicalize() { - log::warn!( - "[cwd_jail] failed to canonicalize jail root {}: {}", - self.root.display(), - e - ); - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/embeddings/mod.rs b/src/openhuman/embeddings/mod.rs index b990490dd..11db24a36 100644 --- a/src/openhuman/embeddings/mod.rs +++ b/src/openhuman/embeddings/mod.rs @@ -49,229 +49,5 @@ pub use schemas::{ }; #[cfg(test)] -mod tests { - use super::*; - - // ── Trait default method ───────────────────────────────── - - #[test] - fn noop_name_and_dims() { - let p = NoopEmbedding; - assert_eq!(p.name(), "none"); - assert_eq!(p.model_id(), "none"); - assert_eq!(p.dimensions(), 0); - assert_eq!(p.signature(), "provider=none;model=none;dims=0"); - } - - #[tokio::test] - async fn noop_embed_returns_empty() { - let p = NoopEmbedding; - let result = p.embed(&["hello"]).await.unwrap(); - assert!(result.is_empty()); - } - - #[tokio::test] - async fn noop_embed_one_returns_error() { - // embed returns empty vec → pop() returns None → error from default impl - let p = NoopEmbedding; - let err = p.embed_one("hello").await.unwrap_err(); - assert!(err.to_string().contains("Empty embedding result")); - } - - #[tokio::test] - async fn noop_embed_empty_batch() { - let p = NoopEmbedding; - let result = p.embed(&[]).await.unwrap(); - assert!(result.is_empty()); - } - - // ── Factory — success ──────────────────────────────────── - - #[test] - fn factory_ollama() { - let p = create_embedding_provider("ollama", DEFAULT_OLLAMA_MODEL, 768).unwrap(); - assert_eq!(p.name(), "ollama"); - assert_eq!(p.model_id(), DEFAULT_OLLAMA_MODEL); - assert_eq!(p.dimensions(), 768); - assert_eq!(p.signature(), "provider=ollama;model=bge-m3;dims=768"); - } - - #[test] - fn factory_openai() { - let p = create_embedding_provider("openai", "text-embedding-3-small", 1536).unwrap(); - assert_eq!(p.name(), "openai"); - assert_eq!(p.model_id(), "text-embedding-3-small"); - assert_eq!(p.dimensions(), 1536); - } - - #[test] - fn factory_custom_url() { - let p = create_embedding_provider("custom:http://localhost:1234", "model", 768).unwrap(); - assert_eq!(p.name(), "openai"); // OpenAI-compatible under the hood - assert_eq!(p.dimensions(), 768); - } - - #[test] - fn factory_custom_empty_url() { - let p = create_embedding_provider("custom:", "model", 768).unwrap(); - assert_eq!(p.name(), "openai"); - } - - #[test] - fn factory_none() { - let p = create_embedding_provider("none", "", 0).unwrap(); - assert_eq!(p.name(), "none"); - assert_eq!(p.dimensions(), 0); - } - - #[test] - fn factory_voyage() { - let p = create_embedding_provider("voyage", "voyage-3-large", 1024).unwrap(); - assert_eq!(p.name(), "voyage"); - assert_eq!(p.dimensions(), 1024); - } - - #[test] - fn factory_cohere() { - let p = create_embedding_provider("cohere", "embed-english-v3.0", 1024).unwrap(); - assert_eq!(p.name(), "cohere"); - assert_eq!(p.dimensions(), 1024); - } - - // ── Factory — errors ───────────────────────────────────── - - #[test] - fn factory_unknown_provider_errors() { - let result = create_embedding_provider("deepseek", "model", 1536); - let msg = result.err().expect("should be an error").to_string(); - assert!( - msg.contains("deepseek"), - "should include provider name: {msg}" - ); - assert!(msg.contains("unknown"), "should say unknown: {msg}"); - } - - #[test] - fn factory_empty_string_errors() { - let result = create_embedding_provider("", "model", 1536); - assert!(result - .err() - .expect("should error") - .to_string() - .contains("unknown")); - } - - #[test] - fn factory_fastembed_errors() { - let result = create_embedding_provider("fastembed", "BGESmallENV15", 384); - assert!(result - .err() - .expect("should error") - .to_string() - .contains("fastembed")); - } - - #[test] - fn factory_cloud() { - let p = create_embedding_provider( - "cloud", - DEFAULT_CLOUD_EMBEDDING_MODEL, - DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, - ) - .unwrap(); - assert_eq!(p.name(), "cloud"); - assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); - } - - #[test] - fn factory_managed() { - let p = create_embedding_provider( - "managed", - DEFAULT_CLOUD_EMBEDDING_MODEL, - DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, - ) - .unwrap(); - assert_eq!(p.name(), "cloud"); - assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); - } - - // ── Default provider ───────────────────────────────────── - - #[test] - fn default_provider_uses_cloud() { - let p = default_embedding_provider(); - assert_eq!(p.name(), "cloud"); - assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); - } - - #[test] - fn default_local_provider_uses_ollama() { - let p = default_local_embedding_provider(); - assert_eq!(p.name(), "ollama"); - assert_eq!(p.dimensions(), DEFAULT_OLLAMA_DIMENSIONS); - } - - // ── create_embedding_provider_with_credentials ─────────── - - #[test] - fn factory_with_credentials_voyage() { - let p = factory::create_embedding_provider_with_credentials( - "voyage", - "voyage-3-large", - 1024, - "voyage-test-key", - None, - ) - .expect("voyage with key"); - assert_eq!(p.name(), "voyage"); - assert_eq!(p.model_id(), "voyage-3-large"); - assert_eq!(p.dimensions(), 1024); - } - - #[test] - fn factory_with_credentials_cohere() { - let p = factory::create_embedding_provider_with_credentials( - "cohere", - "embed-english-v3.0", - 1024, - "cohere-test-key", - None, - ) - .expect("cohere with key"); - assert_eq!(p.name(), "cohere"); - assert_eq!(p.model_id(), "embed-english-v3.0"); - assert_eq!(p.dimensions(), 1024); - } - - #[test] - fn factory_with_credentials_custom() { - let p = factory::create_embedding_provider_with_credentials( - "custom", - "custom-model", - 768, - "custom-key", - Some("http://localhost:9999"), - ) - .expect("custom provider with endpoint"); - // Custom is backed by OpenAiEmbedding - assert_eq!(p.name(), "openai"); - assert_eq!(p.dimensions(), 768); - } - - #[test] - fn factory_with_credentials_managed_ignores_key() { - // Managed/cloud provider does not use the API key — it routes through - // the OpenHuman backend. Creating it with an arbitrary key must succeed - // and produce the cloud provider. - let p = factory::create_embedding_provider_with_credentials( - "managed", - DEFAULT_CLOUD_EMBEDDING_MODEL, - DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, - "should-be-ignored", - None, - ) - .expect("managed ignores key"); - assert_eq!(p.name(), "cloud"); - assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/src/openhuman/embeddings/mod_tests.rs b/src/openhuman/embeddings/mod_tests.rs new file mode 100644 index 000000000..a1151227d --- /dev/null +++ b/src/openhuman/embeddings/mod_tests.rs @@ -0,0 +1,230 @@ +//! Tests for the embeddings module root (provider factory + trait defaults). +//! +//! Split out of `mod.rs` to keep the module root export-focused. Declared via +//! `#[path = "mod_tests.rs"] mod tests;` so `super::*` still resolves to the +//! `embeddings` module. + +use super::*; + +// ── Trait default method ───────────────────────────────── + +#[test] +fn noop_name_and_dims() { + let p = NoopEmbedding; + assert_eq!(p.name(), "none"); + assert_eq!(p.model_id(), "none"); + assert_eq!(p.dimensions(), 0); + assert_eq!(p.signature(), "provider=none;model=none;dims=0"); +} + +#[tokio::test] +async fn noop_embed_returns_empty() { + let p = NoopEmbedding; + let result = p.embed(&["hello"]).await.unwrap(); + assert!(result.is_empty()); +} + +#[tokio::test] +async fn noop_embed_one_returns_error() { + // embed returns empty vec → pop() returns None → error from default impl + let p = NoopEmbedding; + let err = p.embed_one("hello").await.unwrap_err(); + assert!(err.to_string().contains("Empty embedding result")); +} + +#[tokio::test] +async fn noop_embed_empty_batch() { + let p = NoopEmbedding; + let result = p.embed(&[]).await.unwrap(); + assert!(result.is_empty()); +} + +// ── Factory — success ──────────────────────────────────── + +#[test] +fn factory_ollama() { + let p = create_embedding_provider("ollama", DEFAULT_OLLAMA_MODEL, 768).unwrap(); + assert_eq!(p.name(), "ollama"); + assert_eq!(p.model_id(), DEFAULT_OLLAMA_MODEL); + assert_eq!(p.dimensions(), 768); + assert_eq!(p.signature(), "provider=ollama;model=bge-m3;dims=768"); +} + +#[test] +fn factory_openai() { + let p = create_embedding_provider("openai", "text-embedding-3-small", 1536).unwrap(); + assert_eq!(p.name(), "openai"); + assert_eq!(p.model_id(), "text-embedding-3-small"); + assert_eq!(p.dimensions(), 1536); +} + +#[test] +fn factory_custom_url() { + let p = create_embedding_provider("custom:http://localhost:1234", "model", 768).unwrap(); + assert_eq!(p.name(), "openai"); // OpenAI-compatible under the hood + assert_eq!(p.dimensions(), 768); +} + +#[test] +fn factory_custom_empty_url() { + let p = create_embedding_provider("custom:", "model", 768).unwrap(); + assert_eq!(p.name(), "openai"); +} + +#[test] +fn factory_none() { + let p = create_embedding_provider("none", "", 0).unwrap(); + assert_eq!(p.name(), "none"); + assert_eq!(p.dimensions(), 0); +} + +#[test] +fn factory_voyage() { + let p = create_embedding_provider("voyage", "voyage-3-large", 1024).unwrap(); + assert_eq!(p.name(), "voyage"); + assert_eq!(p.dimensions(), 1024); +} + +#[test] +fn factory_cohere() { + let p = create_embedding_provider("cohere", "embed-english-v3.0", 1024).unwrap(); + assert_eq!(p.name(), "cohere"); + assert_eq!(p.dimensions(), 1024); +} + +// ── Factory — errors ───────────────────────────────────── + +#[test] +fn factory_unknown_provider_errors() { + let result = create_embedding_provider("deepseek", "model", 1536); + let msg = result.err().expect("should be an error").to_string(); + assert!( + msg.contains("deepseek"), + "should include provider name: {msg}" + ); + assert!(msg.contains("unknown"), "should say unknown: {msg}"); +} + +#[test] +fn factory_empty_string_errors() { + let result = create_embedding_provider("", "model", 1536); + assert!(result + .err() + .expect("should error") + .to_string() + .contains("unknown")); +} + +#[test] +fn factory_fastembed_errors() { + let result = create_embedding_provider("fastembed", "BGESmallENV15", 384); + assert!(result + .err() + .expect("should error") + .to_string() + .contains("fastembed")); +} + +#[test] +fn factory_cloud() { + let p = create_embedding_provider( + "cloud", + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + ) + .unwrap(); + assert_eq!(p.name(), "cloud"); + assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); +} + +#[test] +fn factory_managed() { + let p = create_embedding_provider( + "managed", + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + ) + .unwrap(); + assert_eq!(p.name(), "cloud"); + assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); +} + +// ── Default provider ───────────────────────────────────── + +#[test] +fn default_provider_uses_cloud() { + let p = default_embedding_provider(); + assert_eq!(p.name(), "cloud"); + assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); +} + +#[test] +fn default_local_provider_uses_ollama() { + let p = default_local_embedding_provider(); + assert_eq!(p.name(), "ollama"); + assert_eq!(p.dimensions(), DEFAULT_OLLAMA_DIMENSIONS); +} + +// ── create_embedding_provider_with_credentials ─────────── + +#[test] +fn factory_with_credentials_voyage() { + let p = factory::create_embedding_provider_with_credentials( + "voyage", + "voyage-3-large", + 1024, + "voyage-test-key", + None, + ) + .expect("voyage with key"); + assert_eq!(p.name(), "voyage"); + assert_eq!(p.model_id(), "voyage-3-large"); + assert_eq!(p.dimensions(), 1024); +} + +#[test] +fn factory_with_credentials_cohere() { + let p = factory::create_embedding_provider_with_credentials( + "cohere", + "embed-english-v3.0", + 1024, + "cohere-test-key", + None, + ) + .expect("cohere with key"); + assert_eq!(p.name(), "cohere"); + assert_eq!(p.model_id(), "embed-english-v3.0"); + assert_eq!(p.dimensions(), 1024); +} + +#[test] +fn factory_with_credentials_custom() { + let p = factory::create_embedding_provider_with_credentials( + "custom", + "custom-model", + 768, + "custom-key", + Some("http://localhost:9999"), + ) + .expect("custom provider with endpoint"); + // Custom is backed by OpenAiEmbedding + assert_eq!(p.name(), "openai"); + assert_eq!(p.dimensions(), 768); +} + +#[test] +fn factory_with_credentials_managed_ignores_key() { + // Managed/cloud provider does not use the API key — it routes through + // the OpenHuman backend. Creating it with an arbitrary key must succeed + // and produce the cloud provider. + let p = factory::create_embedding_provider_with_credentials( + "managed", + DEFAULT_CLOUD_EMBEDDING_MODEL, + DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, + "should-be-ignored", + None, + ) + .expect("managed ignores key"); + assert_eq!(p.name(), "cloud"); + assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS); +} diff --git a/src/openhuman/integrations/mod.rs b/src/openhuman/integrations/mod.rs index a7d66ab90..da78efeff 100644 --- a/src/openhuman/integrations/mod.rs +++ b/src/openhuman/integrations/mod.rs @@ -16,51 +16,5 @@ pub use types::{ }; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tool_scope_equality() { - assert_eq!(ToolScope::All, ToolScope::All); - assert_ne!(ToolScope::All, ToolScope::CliRpcOnly); - assert_ne!(ToolScope::AgentOnly, ToolScope::CliRpcOnly); - } - - #[test] - fn backend_response_deserializes() { - let json = r#"{"success": true, "data": {"foo": 42}}"#; - let resp: BackendResponse = serde_json::from_str(json).unwrap(); - assert!(resp.success); - assert_eq!(resp.data.unwrap()["foo"], 42); - } - - #[test] - fn backend_response_without_data() { - let json = r#"{"success": true}"#; - let resp: BackendResponse = serde_json::from_str(json).unwrap(); - assert!(resp.success); - assert!(resp.data.is_none()); - } - - #[test] - fn integration_pricing_defaults_on_missing_fields() { - let json = r#"{"integrations": {}}"#; - let pricing: IntegrationPricing = serde_json::from_str(json).unwrap(); - assert!(pricing.integrations.apify.is_none()); - assert!(pricing.integrations.twilio.is_none()); - assert!(pricing.integrations.google_places.is_none()); - assert!(pricing.integrations.parallel.is_none()); - assert!(pricing.integrations.tinyfish.is_none()); - } - - #[test] - fn build_client_returns_none_when_no_auth_token() { - let tmp = tempfile::tempdir().expect("tempdir"); - let config = crate::openhuman::config::Config { - workspace_dir: tmp.path().join("workspace"), - config_path: tmp.path().join("config.toml"), - ..crate::openhuman::config::Config::default() - }; - assert!(build_client(&config).is_none()); - } -} +#[path = "mod_tests.rs"] +mod tests; diff --git a/src/openhuman/integrations/mod_tests.rs b/src/openhuman/integrations/mod_tests.rs new file mode 100644 index 000000000..ca64e942c --- /dev/null +++ b/src/openhuman/integrations/mod_tests.rs @@ -0,0 +1,52 @@ +//! Tests for the integrations module root (types + client construction). +//! +//! Split out of `mod.rs` to keep the module root export-focused. Declared via +//! `#[path = "mod_tests.rs"] mod tests;` so `super::*` still resolves to the +//! `integrations` module. + +use super::*; + +#[test] +fn tool_scope_equality() { + assert_eq!(ToolScope::All, ToolScope::All); + assert_ne!(ToolScope::All, ToolScope::CliRpcOnly); + assert_ne!(ToolScope::AgentOnly, ToolScope::CliRpcOnly); +} + +#[test] +fn backend_response_deserializes() { + let json = r#"{"success": true, "data": {"foo": 42}}"#; + let resp: BackendResponse = serde_json::from_str(json).unwrap(); + assert!(resp.success); + assert_eq!(resp.data.unwrap()["foo"], 42); +} + +#[test] +fn backend_response_without_data() { + let json = r#"{"success": true}"#; + let resp: BackendResponse = serde_json::from_str(json).unwrap(); + assert!(resp.success); + assert!(resp.data.is_none()); +} + +#[test] +fn integration_pricing_defaults_on_missing_fields() { + let json = r#"{"integrations": {}}"#; + let pricing: IntegrationPricing = serde_json::from_str(json).unwrap(); + assert!(pricing.integrations.apify.is_none()); + assert!(pricing.integrations.twilio.is_none()); + assert!(pricing.integrations.google_places.is_none()); + assert!(pricing.integrations.parallel.is_none()); + assert!(pricing.integrations.tinyfish.is_none()); +} + +#[test] +fn build_client_returns_none_when_no_auth_token() { + let tmp = tempfile::tempdir().expect("tempdir"); + let config = crate::openhuman::config::Config { + workspace_dir: tmp.path().join("workspace"), + config_path: tmp.path().join("config.toml"), + ..crate::openhuman::config::Config::default() + }; + assert!(build_client(&config).is_none()); +} diff --git a/src/openhuman/memory_queue/mod.rs b/src/openhuman/memory_queue/mod.rs index c9d9b56df..c5521b683 100644 --- a/src/openhuman/memory_queue/mod.rs +++ b/src/openhuman/memory_queue/mod.rs @@ -31,6 +31,7 @@ //! `openhuman::memory` re-exports it as `memory::jobs` during the migration. pub(crate) mod handlers; +mod ops; mod redact; pub mod scheduler; pub mod store; @@ -38,82 +39,7 @@ pub mod testing; pub mod types; pub(crate) mod worker; -use std::sync::atomic::{AtomicBool, Ordering}; - -/// #1574 §6 / #1365: set while a re-embed backfill chain has work pending. -/// -/// Read by the first-person / subconscious retrieval layer so an empty -/// vector-search result during the backfill window is interpreted as -/// "not searched yet" rather than "no such memory" — preventing the agent -/// from confidently asserting false self-ignorance mid-re-embed. Set true -/// when a backfill is enqueued / still has rows; cleared when the chain -/// drains. Process-global (resets to false on restart; the worker re-sets -/// it on the next backfill tick — acceptable for v1). -static BACKFILL_IN_PROGRESS: AtomicBool = AtomicBool::new(false); - -/// Mark whether a re-embed backfill currently has pending work. -pub fn set_backfill_in_progress(v: bool) { - BACKFILL_IN_PROGRESS.store(v, Ordering::Relaxed); -} - -/// True while a re-embed backfill chain still has rows to process. The -/// #1365 absence-reasoning consumer checks this before treating an empty -/// semantic-recall result as "no memory exists". -pub fn backfill_in_progress() -> bool { - BACKFILL_IN_PROGRESS.load(Ordering::Relaxed) -} - -/// #1574 §4: ensure a re-embed backfill chain exists for the **current** -/// active signature, if (and only if) there is uncovered work. -/// -/// This is the switch-path trigger: call it after the embedder config -/// changes (a new signature → every prior row is missing at it). The §7 -/// migration is one-shot (`user_version`-gated) so it does NOT fire on a -/// later model switch — without this, switching silently blinds prior -/// memory. Standalone (own connection); the §7 migration keeps its own -/// in-tx enqueue (atomic with the copy). Idempotent + non-fatal: the -/// per-signature dedupe key means at most one chain per space, and a -/// covered space enqueues nothing. Errors are logged, never propagated — -/// a failed enqueue must not fail the user's settings save. -pub fn ensure_reembed_backfill(config: &crate::openhuman::config::Config) { - let sig = crate::openhuman::memory_store::chunks::store::tree_active_signature(config); - let result = crate::openhuman::memory_store::chunks::store::with_connection(config, |conn| { - Ok(crate::openhuman::memory_store::chunks::store::has_uncovered_reembed_work(conn, &sig)?) - }); - match result { - Ok(true) => { - let job = match types::NewJob::reembed_backfill(&types::ReembedBackfillPayload { - signature: sig.clone(), - }) { - Ok(j) => j, - Err(e) => { - log::warn!("[memory::jobs] ensure_reembed_backfill: build job failed: {e}"); - return; - } - }; - match store::enqueue(config, &job) { - Ok(_) => { - set_backfill_in_progress(true); - log::info!( - "[memory::jobs] ensure_reembed_backfill: enqueued chain for sig={sig}" - ); - } - Err(e) => log::warn!( - "[memory::jobs] ensure_reembed_backfill: enqueue failed for sig={sig}: {e}" - ), - } - } - Ok(false) => { - log::debug!( - "[memory::jobs] ensure_reembed_backfill: sig={sig} fully covered; nothing to do" - ); - } - Err(e) => log::warn!( - "[memory::jobs] ensure_reembed_backfill: coverage probe failed for sig={sig}: {e}" - ), - } -} - +pub use ops::{backfill_in_progress, ensure_reembed_backfill, set_backfill_in_progress}; pub use scheduler::{backfill_missing_digests, trigger_digest}; pub use store::{ claim_next, count_by_status, count_total, enqueue, enqueue_tx, get_job, mark_deferred, @@ -125,20 +51,3 @@ pub use types::{ Job, JobKind, JobOutcome, JobStatus, NewJob, NodeRef, SealPayload, TopicRoutePayload, }; pub use worker::{start, wake_workers}; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn backfill_flag_roundtrip() { - set_backfill_in_progress(false); - assert!(!backfill_in_progress()); - - set_backfill_in_progress(true); - assert!(backfill_in_progress()); - - set_backfill_in_progress(false); - assert!(!backfill_in_progress()); - } -} diff --git a/src/openhuman/memory_queue/ops.rs b/src/openhuman/memory_queue/ops.rs new file mode 100644 index 000000000..ec9dcb2a8 --- /dev/null +++ b/src/openhuman/memory_queue/ops.rs @@ -0,0 +1,101 @@ +//! Memory-queue operations: backfill-progress signalling and the re-embed +//! backfill switch-path trigger. +//! +//! Split out of `mod.rs` so the module root stays export-focused. Public paths +//! are preserved via re-exports in [`super`], so callers keep using +//! `crate::openhuman::memory_queue::`. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::{store, types}; + +/// #1574 §6 / #1365: set while a re-embed backfill chain has work pending. +/// +/// Read by the first-person / subconscious retrieval layer so an empty +/// vector-search result during the backfill window is interpreted as +/// "not searched yet" rather than "no such memory" — preventing the agent +/// from confidently asserting false self-ignorance mid-re-embed. Set true +/// when a backfill is enqueued / still has rows; cleared when the chain +/// drains. Process-global (resets to false on restart; the worker re-sets +/// it on the next backfill tick — acceptable for v1). +static BACKFILL_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + +/// Mark whether a re-embed backfill currently has pending work. +pub fn set_backfill_in_progress(v: bool) { + BACKFILL_IN_PROGRESS.store(v, Ordering::Relaxed); +} + +/// True while a re-embed backfill chain still has rows to process. The +/// #1365 absence-reasoning consumer checks this before treating an empty +/// semantic-recall result as "no memory exists". +pub fn backfill_in_progress() -> bool { + BACKFILL_IN_PROGRESS.load(Ordering::Relaxed) +} + +/// #1574 §4: ensure a re-embed backfill chain exists for the **current** +/// active signature, if (and only if) there is uncovered work. +/// +/// This is the switch-path trigger: call it after the embedder config +/// changes (a new signature → every prior row is missing at it). The §7 +/// migration is one-shot (`user_version`-gated) so it does NOT fire on a +/// later model switch — without this, switching silently blinds prior +/// memory. Standalone (own connection); the §7 migration keeps its own +/// in-tx enqueue (atomic with the copy). Idempotent + non-fatal: the +/// per-signature dedupe key means at most one chain per space, and a +/// covered space enqueues nothing. Errors are logged, never propagated — +/// a failed enqueue must not fail the user's settings save. +pub fn ensure_reembed_backfill(config: &crate::openhuman::config::Config) { + let sig = crate::openhuman::memory_store::chunks::store::tree_active_signature(config); + let result = crate::openhuman::memory_store::chunks::store::with_connection(config, |conn| { + Ok(crate::openhuman::memory_store::chunks::store::has_uncovered_reembed_work(conn, &sig)?) + }); + match result { + Ok(true) => { + let job = match types::NewJob::reembed_backfill(&types::ReembedBackfillPayload { + signature: sig.clone(), + }) { + Ok(j) => j, + Err(e) => { + log::warn!("[memory::jobs] ensure_reembed_backfill: build job failed: {e}"); + return; + } + }; + match store::enqueue(config, &job) { + Ok(_) => { + set_backfill_in_progress(true); + log::info!( + "[memory::jobs] ensure_reembed_backfill: enqueued chain for sig={sig}" + ); + } + Err(e) => log::warn!( + "[memory::jobs] ensure_reembed_backfill: enqueue failed for sig={sig}: {e}" + ), + } + } + Ok(false) => { + log::debug!( + "[memory::jobs] ensure_reembed_backfill: sig={sig} fully covered; nothing to do" + ); + } + Err(e) => log::warn!( + "[memory::jobs] ensure_reembed_backfill: coverage probe failed for sig={sig}: {e}" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backfill_flag_roundtrip() { + set_backfill_in_progress(false); + assert!(!backfill_in_progress()); + + set_backfill_in_progress(true); + assert!(backfill_in_progress()); + + set_backfill_in_progress(false); + assert!(!backfill_in_progress()); + } +}