From 687acc9502b413565c2dfb541b36c4cdf5b7fbad Mon Sep 17 00:00:00 2001 From: Michael Libio Date: Mon, 23 Feb 2026 13:39:50 +0100 Subject: [PATCH 1/2] Implement AppServices container for hot-swappable database connections (#894) Refactor Tauri backend from write-once app.manage() state to a centralized AppServices container with Arc> interior mutability, enabling database switching at runtime without app restart. - Add app_services.rs with AppServices container, accessor methods, and drain-then-replace switch_database() protocol - Update all 13 command files to use State<'_, AppServices> with async accessor pattern - Update db.rs to populate AppServices instead of individual app.manage() - Update settings.rs with hot-swap flow (select_new_database now switches live instead of requiring restart) - Update frontend to handle database-changed events without restart dialog - Document embedding sync boundaries in sync-protocol.md (local commit before sync, subtree atomic sync units) Co-Authored-By: Claude Opus 4.6 --- docs/architecture/data/sync-protocol.md | 58 ++++ .../desktop-app/src-tauri/src/app_services.rs | 251 ++++++++++++++++++ .../src-tauri/src/commands/collections.rs | 66 +++-- .../desktop-app/src-tauri/src/commands/db.rs | 121 ++++----- .../src-tauri/src/commands/diagnostics.rs | 14 +- .../src-tauri/src/commands/embeddings.rs | 75 +++--- .../src-tauri/src/commands/import.rs | 14 +- .../src-tauri/src/commands/nodes.rs | 58 ++-- .../src-tauri/src/commands/schemas.rs | 19 +- .../src-tauri/src/commands/settings.rs | 190 +++++++++++-- packages/desktop-app/src-tauri/src/lib.rs | 134 ++++------ .../lib/components/layout/app-shell.svelte | 23 +- .../sections/database-settings.svelte | 24 +- 13 files changed, 740 insertions(+), 307 deletions(-) create mode 100644 packages/desktop-app/src-tauri/src/app_services.rs diff --git a/docs/architecture/data/sync-protocol.md b/docs/architecture/data/sync-protocol.md index 35983146a..17243f1c7 100644 --- a/docs/architecture/data/sync-protocol.md +++ b/docs/architecture/data/sync-protocol.md @@ -446,6 +446,64 @@ class OperationalTransformer { ## Vector Embedding Synchronization +### Embedding Sync Boundaries — Local Commit Before Sync + +**Core principle: Sync operates on committed state, not in-progress state.** + +A node is not considered "committed" until all its derived data (including embeddings) +has been computed. This mirrors database transaction semantics — partial writes are +never exposed to other readers. The sync outbox only contains fully resolved nodes. + +**Rules:** + +1. **A node does not enter the sync outbox until its embedding is complete.** When + content changes, the node is marked stale locally and held back from sync until + re-embedding finishes. The sync layer never sees intermediate state. + +2. **Stale markers are local-only.** They drive the local embedding pipeline and are + never transmitted to other clients. No client should ever receive a node in a + stale/pending state. + +3. **Each synced node is an atomic unit:** content + embedding vector + content hash + (`sha256(content)`) + model ID. Receiving clients can trust that the embedding + matches the content. + +``` +Sync outbox eligibility: + ✅ Root node with completed embedding (content_hash covers root + all children) + → entire subtree (root + all descendants) syncs as one atomic unit + ❌ Root node with stale/pending embedding → entire subtree held back + ❌ Stale markers (never synced) +``` + +**Subtree as atomic sync unit**: Embeddings are computed only at the root node +level, incorporating the content of all descendant nodes. Therefore the sync +unit is the **entire subtree** — root node + all children. If a child node's +content changes, the root's embedding becomes stale, and the whole subtree is +held from the sync outbox until re-embedding completes. When it does, the root +and all its descendants sync together as one atomic operation. + +**Edge cases:** + +- **Database switch before re-embedding completes**: The pending nodes remain in the + local database but never enter the sync outbox. If the database is never opened + again, those nodes simply don't sync — which is correct, since no client is + actively using that database. + +- **Embedding model failure**: If embeddings cannot be computed (GPU error, model + won't load), affected nodes are held from sync. This is surfaced as a user-visible + error. The sync layer does not attempt to work around broken local state. + +- **Node update with pending re-embedding**: When an existing node's content changes, + the sync outbox retains the last committed version (previous content + its matching + embedding) until the new embedding completes. Once re-embedding finishes, the update + becomes the new committed state and replaces the previous version in the outbox. + This is analogous to offline edits — the user keeps working locally, but the sync + layer only transmits fully resolved state. + +- **Receiving a synced node**: Because only committed nodes are synced, the receiver + accepts the node + embedding as-is. No receiver-side audit or re-embedding needed. + ### Efficient Vector Sync ```rust pub struct VectorSyncOptimizer { diff --git a/packages/desktop-app/src-tauri/src/app_services.rs b/packages/desktop-app/src-tauri/src/app_services.rs new file mode 100644 index 000000000..9c6f24ae7 --- /dev/null +++ b/packages/desktop-app/src-tauri/src/app_services.rs @@ -0,0 +1,251 @@ +//! Centralized application services container with interior mutability. +//! +//! `AppServices` wraps all runtime services (database, node service, embeddings) +//! behind `Arc>` so they can be hot-swapped at runtime when the user +//! switches databases — without restarting the entire application. +//! +//! Registered as a single Tauri managed state via `app.manage(AppServices::new())`. +//! All commands access services through `State<'_, AppServices>`. + +use crate::commands::nodes::CommandError; +use crate::config::AppConfig; +use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService}; +use nodespace_core::{NodeService, SurrealStore}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +/// Application state containing embedding service and processor. +/// +/// Moved from `commands/embeddings.rs` to centralize in AppServices. +pub struct EmbeddingState { + pub service: Arc, + pub processor: Arc, +} + +/// Active services that are initialized after database connection. +/// +/// All fields are populated during `init_services()` and can be +/// replaced atomically during `switch_database()`. +struct ActiveServices { + store: Arc, + node_service: Arc, + embedding_state: Option, + config: AppConfig, +} + +/// Centralized services container with interior mutability for hot-swapping. +/// +/// Registered as Tauri managed state. Commands access services via accessor methods +/// that return `Result, CommandError>` — returning a clear error if services +/// aren't initialized yet. +pub struct AppServices { + inner: Arc>>, + /// Per-session cancellation token for background tasks (MCP, domain event forwarder). + /// Cancelled and replaced on each `switch_database()` call. + session_token: Arc>>, +} + +impl Default for AppServices { + fn default() -> Self { + Self::new() + } +} + +impl AppServices { + /// Create an empty container. Services are populated later via `initialize()`. + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(None)), + session_token: Arc::new(RwLock::new(None)), + } + } + + /// Get the NodeService, or error if not yet initialized. + pub async fn node_service(&self) -> Result, CommandError> { + let guard = self.inner.read().await; + guard + .as_ref() + .map(|s| s.node_service.clone()) + .ok_or_else(|| CommandError { + message: "Database not initialized. Please wait for startup to complete." + .to_string(), + code: "NOT_INITIALIZED".to_string(), + details: None, + }) + } + + /// Get the SurrealStore, or error if not yet initialized. + pub async fn store(&self) -> Result, CommandError> { + let guard = self.inner.read().await; + guard + .as_ref() + .map(|s| s.store.clone()) + .ok_or_else(|| CommandError { + message: "Database not initialized. Please wait for startup to complete." + .to_string(), + code: "NOT_INITIALIZED".to_string(), + details: None, + }) + } + + /// Get the EmbeddingState, or error if not initialized or embeddings unavailable. + pub async fn embedding_state( + &self, + ) -> Result<(Arc, Arc), CommandError> { + let guard = self.inner.read().await; + let active = guard.as_ref().ok_or_else(|| CommandError { + message: "Database not initialized. Please wait for startup to complete.".to_string(), + code: "NOT_INITIALIZED".to_string(), + details: None, + })?; + + match &active.embedding_state { + Some(es) => Ok((es.service.clone(), es.processor.clone())), + None => Err(CommandError { + message: "Embedding service not available. Model may have failed to load." + .to_string(), + code: "EMBEDDINGS_UNAVAILABLE".to_string(), + details: None, + }), + } + } + + /// Get just the embedding service Arc (for MCP integration). + pub async fn embedding_service(&self) -> Result, CommandError> { + self.embedding_state().await.map(|(svc, _)| svc) + } + + /// Get the AppConfig, or error if not yet initialized. + pub async fn config(&self) -> Result { + let guard = self.inner.read().await; + guard + .as_ref() + .map(|s| s.config.clone()) + .ok_or_else(|| CommandError { + message: "Database not initialized. Please wait for startup to complete." + .to_string(), + code: "NOT_INITIALIZED".to_string(), + details: None, + }) + } + + /// Check whether services have been initialized. + pub async fn is_initialized(&self) -> bool { + self.inner.read().await.is_some() + } + + /// Populate the container with initialized services. + /// + /// Called from `db.rs::init_services()` after database and services are ready. + pub async fn initialize( + &self, + store: Arc, + node_service: Arc, + embedding_state: Option, + config: AppConfig, + session_cancel_token: CancellationToken, + ) { + { + let mut guard = self.inner.write().await; + *guard = Some(ActiveServices { + store, + node_service, + embedding_state, + config, + }); + } + { + let mut token_guard = self.session_token.write().await; + *token_guard = Some(session_cancel_token); + } + } + + /// Hot-swap database: cancel session tasks, replace services, restart background tasks. + /// + /// The drain-then-replace protocol: + /// 1. Cancel the current session token (stops MCP server, domain event forwarder) + /// 2. Brief pause for background tasks to exit + /// 3. Release GPU resources from old embedding state + /// 4. Replace inner services with new ones + /// 5. Set new session token + /// 6. Caller restarts background tasks with new services + pub async fn switch_database( + &self, + store: Arc, + node_service: Arc, + embedding_state: Option, + config: AppConfig, + new_session_token: CancellationToken, + ) { + // Step 1: Cancel current session token + { + let token_guard = self.session_token.read().await; + if let Some(token) = token_guard.as_ref() { + token.cancel(); + } + } + + // Step 2: Drain old embedding processor before releasing GPU. + // The processor's background task shuts down when its Arc is dropped + // (dropping the shutdown channel sender). We take ownership here so the + // old processor stops before we release the GPU context it depends on. + let old_embedding_state = { + let mut guard = self.inner.write().await; + guard + .as_mut() + .and_then(|active| active.embedding_state.take()) + }; + + // Brief pause for background tasks (MCP, forwarder, processor) to exit + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Step 3: Release GPU resources from old embedding state. + // The processor Arc should be the last reference — dropping it shuts down + // the background task. Then we can safely release the GPU context. + if let Some(old_es) = old_embedding_state { + // Drop processor first to stop its background task + let old_service = old_es.service; + drop(old_es.processor); + // Brief pause for processor task to exit + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + tracing::info!("Releasing GPU context from old embedding state..."); + old_service.nlp_engine().release_gpu_context(); + tracing::info!("GPU context released successfully"); + } + + // Step 4: Replace services + { + let mut guard = self.inner.write().await; + *guard = Some(ActiveServices { + store, + node_service, + embedding_state, + config, + }); + } + + // Step 5: Set new session token + { + let mut token_guard = self.session_token.write().await; + *token_guard = Some(new_session_token); + } + } + + /// Get the current session cancellation token (for background task coordination). + pub async fn session_token(&self) -> Option { + self.session_token.read().await.clone() + } + + /// Release GPU resources. Called during graceful shutdown. + pub async fn release_gpu_resources(&self) { + let guard = self.inner.read().await; + if let Some(active) = guard.as_ref() { + if let Some(ref es) = active.embedding_state { + tracing::info!("Releasing GPU context to prevent Metal crash..."); + es.service.nlp_engine().release_gpu_context(); + tracing::info!("GPU context released successfully"); + } + } + } +} diff --git a/packages/desktop-app/src-tauri/src/commands/collections.rs b/packages/desktop-app/src-tauri/src/commands/collections.rs index eaef6b900..1e8c3c066 100644 --- a/packages/desktop-app/src-tauri/src/commands/collections.rs +++ b/packages/desktop-app/src-tauri/src/commands/collections.rs @@ -6,13 +6,14 @@ //! - Path-based collection operations use nodespace_core::services::CollectionService; -use nodespace_core::{models, Node, NodeService}; +use nodespace_core::{models, Node}; use serde::Serialize; use serde_json::Value; use tauri::State; use super::nodes::CommandError; +use crate::app_services::AppServices; use crate::constants::TAURI_CLIENT_ID; /// Convert a Node to its strongly-typed JSON representation @@ -55,7 +56,7 @@ pub struct CollectionInfo { /// avoiding N+1 query pattern for better performance. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// /// # Returns /// * `Ok(Vec)` - All collection nodes with member counts @@ -68,8 +69,9 @@ pub struct CollectionInfo { /// ``` #[tauri::command] pub async fn get_all_collections( - service: State<'_, NodeService>, + services: State<'_, AppServices>, ) -> Result, CommandError> { + let service = services.node_service().await?; let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -105,7 +107,7 @@ pub async fn get_all_collections( /// Single query that traverses the relationship and returns full Node data. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `collection_id` - ID of the collection to get members for /// /// # Returns @@ -120,9 +122,10 @@ pub async fn get_all_collections( /// ``` #[tauri::command] pub async fn get_collection_members( - service: State<'_, NodeService>, + services: State<'_, AppServices>, collection_id: String, ) -> Result, CommandError> { + let service = services.node_service().await?; let store = service.store(); // Single query: traverse relationship and get full node data @@ -144,7 +147,7 @@ pub async fn get_collection_members( /// descendant collections. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `collection_id` - ID of the collection /// /// # Returns @@ -152,9 +155,10 @@ pub async fn get_collection_members( /// * `Err(CommandError)` - Error if query fails #[tauri::command] pub async fn get_collection_members_recursive( - service: State<'_, NodeService>, + services: State<'_, AppServices>, collection_id: String, ) -> Result, CommandError> { + let service = services.node_service().await?; let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -191,7 +195,7 @@ pub async fn get_collection_members_recursive( /// Returns collection node IDs that the specified node is a member of. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `node_id` - ID of the node to query /// /// # Returns @@ -206,9 +210,10 @@ pub async fn get_collection_members_recursive( /// ``` #[tauri::command] pub async fn get_node_collections( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node_id: String, ) -> Result, CommandError> { + let service = services.node_service().await?; let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -227,7 +232,7 @@ pub async fn get_node_collections( /// Creates a member_of edge from the node to the collection. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `node_id` - ID of the node to add /// * `collection_id` - ID of the collection to add to /// @@ -244,10 +249,11 @@ pub async fn get_node_collections( /// ``` #[tauri::command] pub async fn add_node_to_collection( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node_id: String, collection_id: String, ) -> Result<(), CommandError> { + let service = services.node_service().await?; let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -266,7 +272,7 @@ pub async fn add_node_to_collection( /// Resolves the path, creates any missing collections, and adds the node. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `node_id` - ID of the node to add /// * `collection_path` - Path like "hr:policy:vacation" /// @@ -283,10 +289,11 @@ pub async fn add_node_to_collection( /// ``` #[tauri::command] pub async fn add_node_to_collection_path( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node_id: String, collection_path: String, ) -> Result { + let service = services.node_service().await?; let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -307,7 +314,7 @@ pub async fn add_node_to_collection_path( /// Deletes the member_of edge from the node to the collection. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `node_id` - ID of the node to remove /// * `collection_id` - ID of the collection to remove from /// @@ -324,10 +331,11 @@ pub async fn add_node_to_collection_path( /// ``` #[tauri::command] pub async fn remove_node_from_collection( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node_id: String, collection_id: String, ) -> Result<(), CommandError> { + let service = services.node_service().await?; let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -346,7 +354,7 @@ pub async fn remove_node_from_collection( /// Searches for an existing collection matching the given path. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `collection_path` - Path like "hr:policy:vacation" /// /// # Returns @@ -355,9 +363,10 @@ pub async fn remove_node_from_collection( /// * `Err(CommandError)` - Error if query fails #[tauri::command] pub async fn find_collection_by_path( - service: State<'_, NodeService>, + services: State<'_, AppServices>, collection_path: String, ) -> Result, CommandError> { + let service = services.node_service().await?; let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -379,7 +388,7 @@ pub async fn find_collection_by_path( /// Get collection by name (case-insensitive) /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `name` - Collection name to find /// /// # Returns @@ -388,9 +397,10 @@ pub async fn find_collection_by_path( /// * `Err(CommandError)` - Error if query fails #[tauri::command] pub async fn get_collection_by_name( - service: State<'_, NodeService>, + services: State<'_, AppServices>, name: String, ) -> Result, CommandError> { + let service = services.node_service().await?; let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -414,7 +424,7 @@ pub async fn get_collection_by_name( /// Creates a collection node with the given name. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `name` - Name for the new collection /// * `description` - Optional description /// @@ -423,13 +433,15 @@ pub async fn get_collection_by_name( /// * `Err(CommandError)` - Error if collection already exists or creation fails #[tauri::command] pub async fn create_collection( - service: State<'_, NodeService>, + services: State<'_, AppServices>, name: String, description: Option, ) -> Result { use nodespace_core::services::CreateNodeParams; use serde_json::json; + let service = services.node_service().await?; + // Check if collection with this name already exists let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -482,7 +494,7 @@ pub async fn create_collection( /// Updates the collection's content (name) field. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `collection_id` - ID of the collection to rename /// * `version` - Expected version for OCC /// * `new_name` - New name for the collection @@ -492,13 +504,15 @@ pub async fn create_collection( /// * `Err(CommandError)` - Error if rename fails #[tauri::command] pub async fn rename_collection( - service: State<'_, NodeService>, + services: State<'_, AppServices>, collection_id: String, version: i64, new_name: String, ) -> Result { use nodespace_core::NodeUpdate; + let service = services.node_service().await?; + // Check if name is already taken by another collection let store = service.store(); let collection_service = CollectionService::new(store, &service); @@ -546,7 +560,7 @@ pub async fn rename_collection( /// membership edges are removed. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// * `collection_id` - ID of the collection to delete /// * `version` - Expected version for OCC /// @@ -555,10 +569,12 @@ pub async fn rename_collection( /// * `Err(CommandError)` - Error if delete fails #[tauri::command] pub async fn delete_collection( - service: State<'_, NodeService>, + services: State<'_, AppServices>, collection_id: String, version: i64, ) -> Result<(), CommandError> { + let service = services.node_service().await?; + // Delete the collection node (member_of edges will be cleaned up by cascade) service .with_client(TAURI_CLIENT_ID) diff --git a/packages/desktop-app/src-tauri/src/commands/db.rs b/packages/desktop-app/src-tauri/src/commands/db.rs index d6c7c73c8..40ca4cf45 100644 --- a/packages/desktop-app/src-tauri/src/commands/db.rs +++ b/packages/desktop-app/src-tauri/src/commands/db.rs @@ -2,8 +2,9 @@ //! //! As of Issue #676, NodeOperations layer is removed - NodeService contains all business logic. //! As of Issue #690, SchemaService is removed - schema operations use NodeService directly. +//! As of Issue #894, services are registered via AppServices container for hot-swappable DB. -use crate::commands::embeddings::EmbeddingState; +use crate::app_services::{AppServices, EmbeddingState}; use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService}; use nodespace_core::{NodeService, SurrealStore}; use nodespace_nlp_engine::{EmbeddingConfig, EmbeddingService}; @@ -20,13 +21,6 @@ use crate::constants::EMBEDDING_MODEL_FILENAME; /// Checks multiple locations in order: /// 1. Bundled resources (for production builds) /// 2. User's ~/.nodespace/models/ directory (fallback for dev) -/// -/// # Arguments -/// * `app` - Tauri application handle for resource resolution -/// -/// # Returns -/// * `Ok(PathBuf)` - Path to the GGUF model file -/// * `Err(String)` - Error if model not found anywhere fn resolve_bundled_model_path(app: &AppHandle) -> Result { // Try bundled resources first (production builds) if let Ok(resource_path) = app.path().resolve( @@ -57,30 +51,25 @@ fn resolve_bundled_model_path(app: &AppHandle) -> Result { )) } -/// Initialize database services using AppConfig from Tauri state. +/// Initialize database services and populate AppServices container. /// -/// Reads database path, model path, and client ID from AppConfig which -/// must be registered as Tauri state before calling this function. -/// -/// # State Management -/// Uses Tauri's state management via `app.manage()`. Once initialized, -/// services persist for the application lifetime. To change database location, -/// the application must be restarted. -async fn init_services(app: &AppHandle) -> Result<(), String> { +/// Reads database path, model path, and client ID from AppConfig. +/// Populates AppServices with store, node_service, and embedding state. +/// Starts background tasks (MCP server, domain event forwarder). +async fn init_services(app: &AppHandle, config: &crate::config::AppConfig) -> Result<(), String> { eprintln!("🔧 [init_services] Starting service initialization..."); tracing::info!("🔧 [init_services] Starting service initialization..."); - // Read config from Tauri state (registered during app startup) - let config: tauri::State = app.state(); let db_path = config.database_path.clone(); let model_path = config.model_path.clone(); let client_id = config.tauri_client_id.clone(); - // Check if state already exists to prevent reinitialization - if app.try_state::().is_some() { + // Check if already initialized via AppServices + let services: tauri::State = app.state(); + if services.is_initialized().await { eprintln!("⚠️ [init_services] Database already initialized"); return Err( - "Database already initialized. Restart the app to change location.".to_string(), + "Database already initialized. Use switch_database for hot-swapping.".to_string(), ); } @@ -96,7 +85,6 @@ async fn init_services(app: &AppHandle) -> Result<(), String> { tracing::info!("✅ [init_services] SurrealDB store initialized"); // Initialize node service with SurrealStore - // NodeService::new() takes &mut Arc to enable cache updates during seeding (Issue #704) tracing::info!("🔧 [init_services] Initializing NodeService..."); let mut node_service = NodeService::new(&mut store) .await @@ -132,53 +120,58 @@ async fn init_services(app: &AppHandle) -> Result<(), String> { .map_err(|e| format!("Failed to initialize embedding processor: {}", e))?; // Wire up NodeService to wake processor on embedding changes (Issue #729) - // This enables event-driven embedding processing without polling node_service.set_embedding_waker(processor.waker()); tracing::info!("✅ [init_services] EmbeddingProcessor waker connected to NodeService"); // Wake processor on startup to process any existing stale embeddings - // This handles cases where stale markers exist from previous sessions processor.wake(); tracing::info!("🔔 [init_services] EmbeddingProcessor woken to process stale embeddings"); let node_service_arc = Arc::new(node_service); let processor_arc = Arc::new(processor); - // Manage all services - eprintln!("🔧 [init_services] Registering services with Tauri app.manage()..."); - tracing::info!("🔧 [init_services] Registering services with Tauri app.manage()..."); - app.manage(store.clone()); - app.manage(node_service_arc.as_ref().clone()); - // NOTE: NodeOperations removed (Issue #676) - commands use NodeService directly - // NOTE: SchemaService removed (Issue #690) - schema commands use NodeService directly - app.manage(EmbeddingState { - service: embedding_service_arc, - processor: processor_arc.clone(), - }); - app.manage(processor_arc); - eprintln!("✅ [init_services] All services registered with Tauri"); - tracing::info!("✅ [init_services] All services registered with Tauri"); - - // Retrieve the shutdown token from Tauri state for background task coordination + // Retrieve the shutdown token for background task coordination let shutdown_token: tauri::State = app.state(); + let session_token = shutdown_token.child_token(); + + // Populate AppServices container (Issue #894) + eprintln!("🔧 [init_services] Populating AppServices container..."); + tracing::info!("🔧 [init_services] Populating AppServices container..."); + services + .initialize( + store.clone(), + node_service_arc.clone(), + Some(EmbeddingState { + service: embedding_service_arc.clone(), + processor: processor_arc.clone(), + }), + config.clone(), + session_token.clone(), + ) + .await; + eprintln!("✅ [init_services] AppServices container populated"); + tracing::info!("✅ [init_services] AppServices container populated"); // Initialize MCP server now that NodeService is available - // MCP will use the same NodeService as Tauri commands - if let Err(e) = crate::initialize_mcp_server(app.clone(), shutdown_token.child_token()) { + // Pass services directly instead of reading from Tauri state + if let Err(e) = crate::initialize_mcp_server( + app.clone(), + node_service_arc.clone(), + embedding_service_arc.clone(), + session_token.clone(), + ) { tracing::error!("❌ Failed to initialize MCP server: {}", e); // Don't fail database init if MCP fails - MCP is optional } // Initialize domain event forwarding with client filtering (#665) - // Events that originated from this Tauri client are filtered out to prevent feedback loops if let Err(e) = crate::initialize_domain_event_forwarder( app.clone(), node_service_arc.clone(), client_id, - shutdown_token.child_token(), + session_token.clone(), ) { tracing::error!("❌ Failed to initialize domain event forwarder: {}", e); - // Don't fail database init if event forwarding fails - it's not critical } let _ = store; // Store still available for direct access if needed @@ -192,29 +185,6 @@ async fn init_services(app: &AppHandle) -> Result<(), String> { /// Checks for previously saved database location preference. If found, /// uses that path. Otherwise, uses unified ~/.nodespace/database/ location /// across all platforms. -/// -/// This command should be called during application startup before any -/// database operations are attempted. -/// -/// # Arguments -/// * `app` - Tauri application handle -/// -/// # Returns -/// * `Ok(String)` - Path to the initialized database file -/// * `Err(String)` - Error if initialization fails -/// -/// # Default Location (New Unified Path) -/// - All platforms: ~/.nodespace/database/nodespace.db -/// -/// # Migration -/// Automatically migrates existing databases from old platform-specific -/// locations on first run. -/// -/// # Errors -/// Returns error if: -/// - Database services are already initialized -/// - Cannot determine home directory -/// - Database initialization fails #[tauri::command] pub async fn initialize_database(app: AppHandle) -> Result { // Attempt migration from old location @@ -245,12 +215,15 @@ pub async fn initialize_database(app: AppHandle) -> Result { .and_then(|p| p.parse::().ok()) .unwrap_or(3100); - // Build and register AppConfig as Tauri state + // Build AppConfig let config = crate::config::AppConfig::from_preferences(&prefs, model_path, mcp_port)?; - app.manage(config); - // Initialize services (reads config from Tauri state) - init_services(&app).await?; + // Show database path on startup + let db_path_str = db_path.to_string_lossy().to_string(); + eprintln!("📂 Database path: {}", db_path_str); + + // Initialize services (populates AppServices container) + init_services(&app, &config).await?; - Ok(db_path.to_string_lossy().to_string()) + Ok(db_path_str) } diff --git a/packages/desktop-app/src-tauri/src/commands/diagnostics.rs b/packages/desktop-app/src-tauri/src/commands/diagnostics.rs index a9d9b186d..a5a977379 100644 --- a/packages/desktop-app/src-tauri/src/commands/diagnostics.rs +++ b/packages/desktop-app/src-tauri/src/commands/diagnostics.rs @@ -3,12 +3,12 @@ //! These commands provide insight into the database state for debugging //! issues where nodes don't persist on some machines. +use crate::app_services::AppServices; use nodespace_core::services::CreateNodeParams; -use nodespace_core::{NodeQuery, NodeService, SurrealStore}; +use nodespace_core::NodeQuery; use serde::Serialize; use std::fs; use std::path::PathBuf; -use std::sync::Arc; use tauri::State; /// Diagnostic info about the database state @@ -63,7 +63,7 @@ fn get_directory_size(path: &PathBuf) -> Option { /// persistence issues on different machines. /// /// # Arguments -/// * `store` - SurrealStore instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// /// # Returns /// * `DatabaseDiagnostics` - Struct with all diagnostic info @@ -76,8 +76,9 @@ fn get_directory_size(path: &PathBuf) -> Option { /// ``` #[tauri::command] pub async fn get_database_diagnostics( - store: State<'_, Arc>, + services: State<'_, AppServices>, ) -> Result { + let store = services.store().await.map_err(|e| e.message)?; let mut errors: Vec = Vec::new(); // Get database path from environment or default @@ -188,7 +189,7 @@ pub struct TestPersistenceResult { /// diagnostic info about the operation. Useful for testing if persistence works. /// /// # Arguments -/// * `service` - NodeService instance from Tauri state +/// * `services` - AppServices instance from Tauri state /// /// # Returns /// * Result with creation and verification details @@ -201,8 +202,9 @@ pub struct TestPersistenceResult { /// ``` #[tauri::command] pub async fn test_node_persistence( - service: State<'_, NodeService>, + services: State<'_, AppServices>, ) -> Result { + let service = services.node_service().await.map_err(|e| e.message)?; let test_id = format!("diagnostic-test-{}", uuid::Uuid::new_v4()); let test_content = format!("Diagnostic test node created at {}", chrono::Utc::now()); diff --git a/packages/desktop-app/src-tauri/src/commands/embeddings.rs b/packages/desktop-app/src-tauri/src/commands/embeddings.rs index 2a7db6d06..62167a2c0 100644 --- a/packages/desktop-app/src-tauri/src/commands/embeddings.rs +++ b/packages/desktop-app/src-tauri/src/commands/embeddings.rs @@ -5,22 +5,14 @@ //! - Searching topics by semantic similarity //! - Updating embeddings on content changes +use crate::app_services::AppServices; use crate::commands::nodes::CommandError; use nodespace_core::models::Node; -use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService}; -use nodespace_core::NodeService; use serde::{Deserialize, Serialize}; -use std::sync::Arc; use tauri::State; use crate::constants::TAURI_CLIENT_ID; -/// Application state containing embedding service and processor -pub struct EmbeddingState { - pub service: Arc, - pub processor: Arc, -} - /// Helper to create CommandError instances fn command_error(message: impl Into, code: impl Into) -> CommandError { CommandError { @@ -67,10 +59,12 @@ fn command_error_with_details( /// ``` #[tauri::command] pub async fn generate_root_embedding( - state: State<'_, EmbeddingState>, - node_service: State<'_, NodeService>, + services: State<'_, AppServices>, root_id: String, ) -> Result<(), CommandError> { + let (embedding_service, _) = services.embedding_state().await?; + let node_service = services.node_service().await?; + // Get the node from the database let node = node_service .get_node(&root_id) @@ -85,8 +79,7 @@ pub async fn generate_root_embedding( .ok_or_else(|| command_error(format!("Node not found: {}", root_id), "NOT_FOUND"))?; // Queue node's root for embedding via root-aggregate model (Issue #729) - state - .service + embedding_service .queue_for_embedding(&node.id) .await .map_err(|e| { @@ -163,10 +156,12 @@ pub struct SearchRootsParams { /// ``` #[tauri::command] pub async fn search_roots( - state: State<'_, EmbeddingState>, - node_service: State<'_, NodeService>, + services: State<'_, AppServices>, params: SearchRootsParams, ) -> Result, CommandError> { + let (embedding_service, _) = services.embedding_state().await?; + let node_service = services.node_service().await?; + // Validate query parameter if params.query.trim().is_empty() { return Err(command_error( @@ -186,8 +181,7 @@ pub async fn search_roots( } // Generate embedding for search query - let query_embedding = state - .service + let query_embedding = embedding_service .nlp_engine() .generate_embedding(¶ms.query) .map_err(|e| { @@ -249,10 +243,12 @@ pub async fn search_roots( /// ``` #[tauri::command] pub async fn update_root_embedding( - state: State<'_, EmbeddingState>, - node_service: State<'_, NodeService>, + services: State<'_, AppServices>, root_id: String, ) -> Result<(), CommandError> { + let (embedding_service, _) = services.embedding_state().await?; + let node_service = services.node_service().await?; + // Get the node from the database let node = node_service .get_node(&root_id) @@ -267,8 +263,7 @@ pub async fn update_root_embedding( .ok_or_else(|| command_error(format!("Node not found: {}", root_id), "NOT_FOUND"))?; // Queue node's root for embedding via root-aggregate model (Issue #729) - state - .service + embedding_service .queue_for_embedding(&node.id) .await .map_err(|e| { @@ -302,10 +297,12 @@ pub async fn update_root_embedding( /// ``` #[tauri::command] pub async fn on_root_closed( - state: State<'_, EmbeddingState>, - node_service: State<'_, NodeService>, + services: State<'_, AppServices>, root_id: String, ) -> Result<(), CommandError> { + let (embedding_service, _) = services.embedding_state().await?; + let node_service = services.node_service().await?; + // Get the node from the database let node = node_service .get_node(&root_id) @@ -320,8 +317,7 @@ pub async fn on_root_closed( .ok_or_else(|| command_error(format!("Node not found: {}", root_id), "NOT_FOUND"))?; // Queue node's root for embedding via root-aggregate model (Issue #729) - state - .service + embedding_service .queue_for_embedding(&node.id) .await .map_err(|e| { @@ -362,10 +358,12 @@ pub async fn on_root_closed( /// ``` #[tauri::command] pub async fn on_root_idle( - state: State<'_, EmbeddingState>, - node_service: State<'_, NodeService>, + services: State<'_, AppServices>, root_id: String, ) -> Result { + let (embedding_service, _) = services.embedding_state().await?; + let node_service = services.node_service().await?; + // Get the node from the database let node = node_service .get_node(&root_id) @@ -380,8 +378,7 @@ pub async fn on_root_idle( .ok_or_else(|| command_error(format!("Node not found: {}", root_id), "NOT_FOUND"))?; // Queue node's root for embedding via root-aggregate model (Issue #729) - state - .service + embedding_service .queue_for_embedding(&node.id) .await .map_err(|e| { @@ -418,9 +415,11 @@ pub async fn on_root_idle( /// console.log('Sync triggered (processing in background)'); /// ``` #[tauri::command] -pub async fn sync_embeddings(state: State<'_, EmbeddingState>) -> Result<(), CommandError> { +pub async fn sync_embeddings(services: State<'_, AppServices>) -> Result<(), CommandError> { + let (_, processor) = services.embedding_state().await?; + // Trigger batch embedding (fire-and-forget, wakes the processor) - state.processor.trigger_batch_embed().map_err(|e| { + processor.trigger_batch_embed().map_err(|e| { command_error_with_details( format!("Failed to trigger sync: {}", e), "EMBEDDING_ERROR", @@ -446,9 +445,9 @@ pub async fn sync_embeddings(state: State<'_, EmbeddingState>) -> Result<(), Com /// // Display badge: "${count} topics need indexing" /// ``` #[tauri::command] -pub async fn get_stale_root_count( - node_service: State<'_, NodeService>, -) -> Result { +pub async fn get_stale_root_count(services: State<'_, AppServices>) -> Result { + let node_service = services.node_service().await?; + // Use new embedding table model (Issue #729) // Pass debounce_secs=0 to get ALL stale embeddings (for reporting purposes) // This shows the user the true count of pending work, including items still in debounce window @@ -491,10 +490,12 @@ pub async fn get_stale_root_count( /// ``` #[tauri::command] pub async fn batch_generate_embeddings( - state: State<'_, EmbeddingState>, - node_service: State<'_, NodeService>, + services: State<'_, AppServices>, root_ids: Vec, ) -> Result { + let (embedding_service, _) = services.embedding_state().await?; + let node_service = services.node_service().await?; + let mut success_count = 0; let mut failed_embeddings = Vec::new(); @@ -507,7 +508,7 @@ pub async fn batch_generate_embeddings( { Ok(Some(node)) => { // Queue node's root for embedding via root-aggregate model (Issue #729) - match state.service.queue_for_embedding(&node.id).await { + match embedding_service.queue_for_embedding(&node.id).await { Ok(_) => { success_count += 1; tracing::debug!("Queued embedding for node: {}", root_id); diff --git a/packages/desktop-app/src-tauri/src/commands/import.rs b/packages/desktop-app/src-tauri/src/commands/import.rs index 3296dd7ba..e4b5e7147 100644 --- a/packages/desktop-app/src-tauri/src/commands/import.rs +++ b/packages/desktop-app/src-tauri/src/commands/import.rs @@ -27,6 +27,7 @@ //! //! This returns immediately to the UI while heavy database work happens in background. +use crate::app_services::AppServices; use nodespace_core::mcp::handlers::markdown::{ prepare_nodes_from_markdown, transform_links_in_nodes_with_mentions, PreparedNode, }; @@ -362,10 +363,11 @@ fn to_title_case(s: &str) -> String { /// parses markdown, and creates nodes using bulk_create_hierarchy. #[tauri::command] pub async fn import_markdown_file( - node_service: State<'_, NodeService>, + services: State<'_, AppServices>, file_path: String, options: Option, ) -> Result { + let node_service = services.node_service().await.map_err(|e| e.message)?; let path = PathBuf::from(&file_path); let options = options.unwrap_or_default(); @@ -443,7 +445,6 @@ pub async fn import_markdown_file( Ok((root_id, nodes_created)) => { // For single file import, do collection assignment immediately if let Some(ref coll) = collection { - use nodespace_core::services::CollectionService; let collection_service = CollectionService::new(node_service.store(), &node_service); if let Err(e) = collection_service @@ -504,10 +505,11 @@ pub async fn import_markdown_file( #[tauri::command] pub async fn import_markdown_files( app: AppHandle, - node_service: State<'_, NodeService>, + services: State<'_, AppServices>, file_paths: Vec, options: Option, ) -> Result { + let node_service = services.node_service().await.map_err(|e| e.message)?; let start = std::time::Instant::now(); let total_files = file_paths.len(); let options = options.unwrap_or_default(); @@ -786,7 +788,7 @@ pub async fn import_markdown_files( // 2. Computing lengths/counts here avoids borrowing issues in the async context // 3. Cloning Arc/handles is cheap and allows the original references to be dropped let store = Arc::clone(node_service.store()); - let node_service_clone = node_service.inner().clone(); + let node_service_clone = (*node_service).clone(); let app_clone = app.clone(); let total_files_clone = total_files; let successful_files_clone = prepared_files.len(); @@ -1009,7 +1011,7 @@ pub async fn import_markdown_files( #[tauri::command] pub async fn import_markdown_directory( app: AppHandle, - node_service: State<'_, NodeService>, + services: State<'_, AppServices>, directory_path: String, options: Option, ) -> Result { @@ -1047,7 +1049,7 @@ pub async fn import_markdown_directory( } // Import all files - import_markdown_files(app, node_service, md_files, Some(import_options)).await + import_markdown_files(app, services, md_files, Some(import_options)).await } /// Recursively collect markdown files from a directory with exclusion patterns diff --git a/packages/desktop-app/src-tauri/src/commands/nodes.rs b/packages/desktop-app/src-tauri/src/commands/nodes.rs index 4db203928..31c27b01f 100644 --- a/packages/desktop-app/src-tauri/src/commands/nodes.rs +++ b/packages/desktop-app/src-tauri/src/commands/nodes.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use tauri::State; +use crate::app_services::AppServices; use crate::constants::TAURI_CLIENT_ID; /// Input for creating a node - timestamps generated server-side @@ -154,9 +155,10 @@ fn nodes_to_typed_values(nodes: Vec) -> Result, CommandError> { /// ``` #[tauri::command] pub async fn create_node( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node: CreateNodeInput, ) -> Result { + let service = services.node_service().await?; validate_node_type(&node.node_type, &service).await?; // Use NodeService to create node with business rule enforcement @@ -228,9 +230,10 @@ pub struct SaveNodeWithParentInput { /// ``` #[tauri::command] pub async fn create_root_node( - service: State<'_, NodeService>, + services: State<'_, AppServices>, input: CreateRootNodeInput, ) -> Result { + let service = services.node_service().await?; validate_node_type(&input.node_type, &service).await?; // Create root node with NodeService (parent_id = None means root) @@ -280,10 +283,11 @@ pub async fn create_root_node( /// ``` #[tauri::command] pub async fn create_node_mention( - service: State<'_, NodeService>, + services: State<'_, AppServices>, mentioning_node_id: String, mentioned_node_id: String, ) -> Result<(), CommandError> { + let service = services.node_service().await?; service .with_client(TAURI_CLIENT_ID) .create_mention(&mentioning_node_id, &mentioned_node_id) @@ -316,9 +320,10 @@ pub async fn create_node_mention( /// ``` #[tauri::command] pub async fn get_node( - service: State<'_, NodeService>, + services: State<'_, AppServices>, id: String, ) -> Result, CommandError> { + let service = services.node_service().await?; let node = service .with_client(TAURI_CLIENT_ID) .get_node(&id) @@ -364,11 +369,12 @@ pub async fn get_node( /// ``` #[tauri::command] pub async fn update_node( - service: State<'_, NodeService>, + services: State<'_, AppServices>, id: String, version: i64, update: NodeUpdate, ) -> Result { + let service = services.node_service().await?; // DEBUG: Log incoming update to trace @mention persistence issue let content_preview = update.content.as_ref().map(|c| { if c.len() > 50 { @@ -433,10 +439,11 @@ pub async fn update_node( /// ``` #[tauri::command] pub async fn delete_node( - service: State<'_, NodeService>, + services: State<'_, AppServices>, id: String, version: i64, ) -> Result { + let service = services.node_service().await?; service .with_client(TAURI_CLIENT_ID) .delete_node(&id, version) @@ -478,12 +485,13 @@ pub async fn delete_node( /// ``` #[tauri::command] pub async fn move_node( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node_id: String, version: i64, new_parent_id: Option, insert_after_node_id: Option, ) -> Result { + let service = services.node_service().await?; let node = service .with_client(TAURI_CLIENT_ID) .move_node( @@ -525,11 +533,12 @@ pub async fn move_node( /// ``` #[tauri::command] pub async fn reorder_node( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node_id: String, version: i64, insert_after_node_id: Option, ) -> Result<(), CommandError> { + let service = services.node_service().await?; service .with_client(TAURI_CLIENT_ID) .reorder_node(&node_id, version, insert_after_node_id.as_deref()) @@ -569,9 +578,10 @@ pub async fn reorder_node( /// ``` #[tauri::command] pub async fn get_children( - service: State<'_, NodeService>, + services: State<'_, AppServices>, parent_id: String, ) -> Result, CommandError> { + let service = services.node_service().await?; let nodes = service .with_client(TAURI_CLIENT_ID) .get_children(&parent_id) @@ -618,9 +628,10 @@ pub async fn get_children( /// ``` #[tauri::command] pub async fn get_children_tree( - service: State<'_, NodeService>, + services: State<'_, AppServices>, parent_id: String, ) -> Result { + let service = services.node_service().await?; service .get_children_tree(&parent_id) .await @@ -652,9 +663,10 @@ pub async fn get_children_tree( /// ``` #[tauri::command] pub async fn get_nodes_by_root_id( - service: State<'_, NodeService>, + services: State<'_, AppServices>, root_id: String, ) -> Result, CommandError> { + let service = services.node_service().await?; // Phase 5 (Issue #511): Redirect to get_children (graph-native) let nodes = service .with_client(TAURI_CLIENT_ID) @@ -708,9 +720,10 @@ pub async fn get_nodes_by_root_id( /// ``` #[tauri::command] pub async fn query_nodes_simple( - service: State<'_, NodeService>, + services: State<'_, AppServices>, query: NodeQuery, ) -> Result, CommandError> { + let service = services.node_service().await?; let nodes = service .with_client(TAURI_CLIENT_ID) .query_nodes_simple(query) @@ -754,10 +767,11 @@ pub async fn query_nodes_simple( /// ``` #[tauri::command] pub async fn mention_autocomplete( - service: State<'_, NodeService>, + services: State<'_, AppServices>, query: String, limit: Option, ) -> Result, CommandError> { + let service = services.node_service().await?; let nodes = service .mention_autocomplete(&query, limit) .await @@ -814,9 +828,10 @@ pub async fn mention_autocomplete( /// to enforce business rules within the transaction semantics (tracked in follow-up issue). #[tauri::command] pub async fn save_node_with_parent( - service: State<'_, NodeService>, + services: State<'_, AppServices>, input: SaveNodeWithParentInput, ) -> Result<(), CommandError> { + let service = services.node_service().await?; validate_node_type(&input.node_type, &service).await?; // Use single-transaction upsert method (bypasses NodeOperations for transactional reasons) @@ -854,9 +869,10 @@ pub async fn save_node_with_parent( /// ``` #[tauri::command] pub async fn get_outgoing_mentions( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node_id: String, ) -> Result, CommandError> { + let service = services.node_service().await?; service .with_client(TAURI_CLIENT_ID) .get_mentions(&node_id) @@ -885,9 +901,10 @@ pub async fn get_outgoing_mentions( /// ``` #[tauri::command] pub async fn get_incoming_mentions( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node_id: String, ) -> Result, CommandError> { + let service = services.node_service().await?; service .with_client(TAURI_CLIENT_ID) .get_mentioned_by(&node_id) @@ -927,9 +944,10 @@ pub async fn get_incoming_mentions( /// ``` #[tauri::command] pub async fn get_mentioning_roots( - service: State<'_, NodeService>, + services: State<'_, AppServices>, node_id: String, ) -> Result, CommandError> { + let service = services.node_service().await?; service .get_mentioning_containers(&node_id) .await @@ -972,11 +990,12 @@ pub async fn get_mentioning_roots( /// ``` #[tauri::command] pub async fn update_task_node( - service: State<'_, NodeService>, + services: State<'_, AppServices>, id: String, version: i64, update: models::TaskNodeUpdate, ) -> Result { + let service = services.node_service().await?; let task = service .update_task_node(&id, version, update) .await @@ -1012,10 +1031,11 @@ pub async fn update_task_node( /// ``` #[tauri::command] pub async fn delete_node_mention( - service: State<'_, NodeService>, + services: State<'_, AppServices>, mentioning_node_id: String, mentioned_node_id: String, ) -> Result<(), CommandError> { + let service = services.node_service().await?; service .with_client(TAURI_CLIENT_ID) .remove_mention(&mentioning_node_id, &mentioned_node_id) diff --git a/packages/desktop-app/src-tauri/src/commands/schemas.rs b/packages/desktop-app/src-tauri/src/commands/schemas.rs index 709655353..14d205fbe 100644 --- a/packages/desktop-app/src-tauri/src/commands/schemas.rs +++ b/packages/desktop-app/src-tauri/src/commands/schemas.rs @@ -8,8 +8,9 @@ //! - `get_all_schemas` - List all schema nodes (returns SchemaNode[] with typed fields) //! - `get_schema_definition` - Get a specific schema by ID (returns SchemaNode with typed fields) +use crate::app_services::AppServices; use nodespace_core::services::NodeServiceError; -use nodespace_core::{NodeQuery, NodeService, SchemaNode}; +use nodespace_core::{NodeQuery, SchemaNode}; use serde::Serialize; use tauri::State; @@ -43,8 +44,14 @@ impl From for CommandError { /// * `Err(CommandError)` - Error if retrieval fails #[tauri::command] pub async fn get_all_schemas( - service: State<'_, NodeService>, + services: State<'_, AppServices>, ) -> Result, CommandError> { + let service = services.node_service().await.map_err(|e| CommandError { + message: e.message, + code: e.code, + details: e.details, + })?; + // Query all schema nodes and wrap in SchemaNode for typed serialization let query = NodeQuery { node_type: Some("schema".to_string()), @@ -77,9 +84,15 @@ pub async fn get_all_schemas( /// * `Err(CommandError)` - Error if schema not found #[tauri::command] pub async fn get_schema_definition( - service: State<'_, NodeService>, + services: State<'_, AppServices>, schema_id: String, ) -> Result { + let service = services.node_service().await.map_err(|e| CommandError { + message: e.message, + code: e.code, + details: e.details, + })?; + let schema_node = service .get_schema_node(&schema_id) .await diff --git a/packages/desktop-app/src-tauri/src/commands/settings.rs b/packages/desktop-app/src-tauri/src/commands/settings.rs index 48d4c78fe..9b0451eab 100644 --- a/packages/desktop-app/src-tauri/src/commands/settings.rs +++ b/packages/desktop-app/src-tauri/src/commands/settings.rs @@ -2,8 +2,9 @@ //! //! These commands expose the preferences system to the frontend. //! Display settings (theme, markdown rendering) take effect immediately. -//! Database settings require an app restart. +//! Database settings now hot-swap services without requiring a restart. +use crate::app_services::AppServices; use tauri::{AppHandle, Manager}; /// Settings response sent to the frontend @@ -25,11 +26,22 @@ pub struct DisplaySettingsResponse { pub theme: String, } +/// Result of a database switch operation +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DatabaseSwitchResult { + pub new_path: String, + pub success: bool, +} + /// Get current app settings for the Settings UI #[tauri::command] -pub async fn get_settings(app: AppHandle) -> Result { +pub async fn get_settings( + app: AppHandle, + services: tauri::State<'_, AppServices>, +) -> Result { let prefs = crate::preferences::load_preferences(&app).await?; - let config: tauri::State = app.state(); + let config = services.config().await.map_err(|e| e.message)?; Ok(SettingsResponse { active_database_path: config.database_path.to_string_lossy().to_string(), @@ -84,18 +96,13 @@ pub async fn update_display_settings( Ok(()) } -/// Result of selecting a new database location -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PendingDatabaseChange { - pub new_path: String, - pub requires_restart: bool, -} - -/// Open native folder picker and save chosen database path to preferences. -/// Does NOT reinitialize services — app must restart for new database. +/// Open native folder picker, save chosen database path, and hot-swap services. #[tauri::command] -pub async fn select_new_database(app: tauri::AppHandle) -> Result { +pub async fn select_new_database( + app: tauri::AppHandle, + services: tauri::State<'_, AppServices>, +) -> Result { + use tauri::Emitter; use tauri_plugin_dialog::{DialogExt, FilePath}; let folder = app @@ -115,9 +122,16 @@ pub async fn select_new_database(app: tauri::AppHandle) -> Result Result { +pub async fn reset_database_to_default( + app: tauri::AppHandle, + services: tauri::State<'_, AppServices>, +) -> Result { + use tauri::Emitter; + let mut prefs = crate::preferences::load_preferences(&app).await?; prefs.database_path = None; crate::preferences::save_preferences(&app, &prefs).await?; let default_path = crate::preferences::get_default_database_path()?; - Ok(default_path.to_string_lossy().to_string()) + + // Hot-swap to default database + switch_database_services(&app, &services, default_path.clone()).await?; + + let path_str = default_path.to_string_lossy().to_string(); + let _ = app.emit("database-changed", &path_str); + + Ok(path_str) +} + +/// Hot-swap database services: create new store, node service, and embeddings, +/// then atomically replace the running services and restart background tasks. +async fn switch_database_services( + app: &AppHandle, + services: &AppServices, + new_db_path: std::path::PathBuf, +) -> Result<(), String> { + use crate::app_services::EmbeddingState; + use nodespace_core::services::{EmbeddingProcessor, NodeEmbeddingService}; + use nodespace_core::{NodeService, SurrealStore}; + use nodespace_nlp_engine::{EmbeddingConfig, EmbeddingService}; + use std::sync::Arc; + + // Get current config for model path etc. + let old_config = services.config().await.map_err(|e| e.message)?; + + tracing::info!("🔧 Switching database to: {:?}", new_db_path); + + // Ensure directory exists + if let Some(parent) = new_db_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("Failed to create database directory: {}", e))?; + } + + // Create new store + let mut store = Arc::new( + SurrealStore::new(new_db_path.clone()) + .await + .map_err(|e| format!("Failed to initialize new database: {}", e))?, + ); + + // Create new NodeService + let mut node_service = NodeService::new(&mut store) + .await + .map_err(|e| format!("Failed to initialize node service: {}", e))?; + + // Create new embedding engine + let embedding_config = EmbeddingConfig { + model_path: Some(old_config.model_path.clone()), + ..Default::default() + }; + + let embedding_state = match EmbeddingService::new(embedding_config) { + Ok(mut nlp_engine) => match nlp_engine.initialize() { + Ok(()) => { + let nlp_arc = Arc::new(nlp_engine); + let emb_service = NodeEmbeddingService::new(nlp_arc.clone(), store.clone()); + let emb_service_arc = Arc::new(emb_service); + let processor = EmbeddingProcessor::new(emb_service_arc.clone()) + .map_err(|e| format!("Failed to init embedding processor: {}", e))?; + node_service.set_embedding_waker(processor.waker()); + processor.wake(); + let processor_arc = Arc::new(processor); + Some(EmbeddingState { + service: emb_service_arc, + processor: processor_arc, + }) + } + Err(e) => { + tracing::warn!("Failed to load NLP model during switch: {}", e); + None + } + }, + Err(e) => { + tracing::warn!("Failed to create NLP engine during switch: {}", e); + None + } + }; + + let node_service_arc = Arc::new(node_service); + + // Build new config + let new_config = crate::config::AppConfig { + database_path: new_db_path, + model_path: old_config.model_path, + mcp_port: old_config.mcp_port, + tauri_client_id: old_config.tauri_client_id.clone(), + }; + + // Create new session token + let shutdown_token: tauri::State = app.state(); + let new_session_token = shutdown_token.child_token(); + + // Hot-swap services + let embedding_service_arc = embedding_state.as_ref().map(|es| es.service.clone()); + services + .switch_database( + store.clone(), + node_service_arc.clone(), + embedding_state, + new_config.clone(), + new_session_token.clone(), + ) + .await; + + // Restart background services with new session token + if let Some(emb_svc) = embedding_service_arc { + if let Err(e) = crate::initialize_mcp_server( + app.clone(), + node_service_arc.clone(), + emb_svc, + new_session_token.clone(), + ) { + tracing::error!("Failed to restart MCP server after switch: {}", e); + } + } + + if let Err(e) = crate::initialize_domain_event_forwarder( + app.clone(), + node_service_arc.clone(), + new_config.tauri_client_id.clone(), + new_session_token, + ) { + tracing::error!( + "Failed to restart domain event forwarder after switch: {}", + e + ); + } + + tracing::info!("✅ Database switch complete"); + Ok(()) } diff --git a/packages/desktop-app/src-tauri/src/lib.rs b/packages/desktop-app/src-tauri/src/lib.rs index c04f5ce0f..1761ffa56 100644 --- a/packages/desktop-app/src-tauri/src/lib.rs +++ b/packages/desktop-app/src-tauri/src/lib.rs @@ -10,6 +10,9 @@ pub mod constants; // Runtime application configuration pub mod config; +// Centralized services container (Issue #894) +pub mod app_services; + // MCP Tauri integration (wraps core MCP with event emissions) pub mod mcp_integration; @@ -84,41 +87,27 @@ pub fn initialize_domain_event_forwarder( Ok(()) } -/// Initialize MCP server with shared services from Tauri state +/// Initialize MCP server with shared services /// -/// This must be called AFTER the database is initialized and services -/// are available in Tauri's managed state. It retrieves the shared NodeService -/// and NodeEmbeddingService and spawns the MCP server task with them, -/// ensuring MCP and Tauri commands operate on the same database. +/// Takes Arc and Arc directly rather than +/// reading from Tauri state. This supports hot-swapping via AppServices. /// /// The `cancel_token` is used for graceful shutdown - when cancelled, the MCP /// server task will be aborted before the Tokio runtime drops. -/// -/// As of Issue #715, uses McpServerService from nodespace-core for managed lifecycle. pub fn initialize_mcp_server( app: tauri::AppHandle, + node_service: std::sync::Arc, + embedding_service: std::sync::Arc, cancel_token: tokio_util::sync::CancellationToken, ) -> anyhow::Result<()> { - use crate::commands::embeddings::EmbeddingState; use futures::FutureExt; - use nodespace_core::NodeService; - use std::sync::Arc; - use tauri::Manager; tracing::info!("🔧 Initializing MCP server service..."); - // Get shared services from Tauri state - // This ensures MCP uses the same database and embedding service as Tauri commands - let node_service: tauri::State = app.state(); - let node_service_arc = Arc::new(node_service.inner().clone()); - - let embedding_state: tauri::State = app.state(); - let embedding_service_arc = embedding_state.service.clone(); - // Create MCP service with Tauri event callback let (mcp_service, callback) = mcp_integration::create_mcp_service_with_events( - node_service_arc, - embedding_service_arc, + node_service, + embedding_service, app.clone(), ); @@ -127,16 +116,10 @@ pub fn initialize_mcp_server( mcp_service.port() ); - // Register MCP service as managed state for potential future access - app.manage(mcp_service.clone()); - // Spawn MCP server task with Tauri event emissions // Uses panic protection to prevent silent background task failures // Monitors cancel_token for graceful shutdown before runtime drops tauri::async_runtime::spawn(async move { - // Use FutureExt::catch_unwind for proper async panic catching - // This avoids the deadlock risk of using block_on inside an async task - // AssertUnwindSafe is needed because services contain non-UnwindSafe types let result = std::panic::AssertUnwindSafe(async { tokio::select! { res = mcp_service.start_with_callback(callback) => res, @@ -291,9 +274,9 @@ pub fn run() { // when spawning background tasks (MCP server, domain event forwarder) app.manage(shutdown_token_for_setup); - // Note: MCP server initialization is deferred until database is initialized - // See commands/db.rs::init_services() which calls initialize_mcp_server() - // after NodeService is available in Tauri state + // Register AppServices container as managed state (Issue #894) + // Services are populated later via commands/db.rs::init_services() + app.manage(app_services::AppServices::new()); Ok(()) }) @@ -406,54 +389,35 @@ pub fn run() { .expect("error while building tauri application"); // Run with event handler for graceful shutdown - // This allows proper cleanup of Metal/GPU resources and background tasks before exit - // - // Note: On macOS, RunEvent::ExitRequested may not fire reliably (Tauri issue #9198) - // We handle cleanup in multiple places to ensure resources are released: - // 1. WindowEvent::CloseRequested - when user clicks X or Cmd+Q - // 2. RunEvent::ExitRequested - when app is about to exit - // 3. RunEvent::Exit - final cleanup before process termination - // - // The shutdown_token is cancelled to signal background tasks (MCP server, - // domain event forwarder) to exit their loops before the Tokio runtime drops. let shutdown_token_for_events = shutdown_token.clone(); - app.run(move |app_handle, event| { - match event { - RunEvent::WindowEvent { - label, - event: tauri::WindowEvent::CloseRequested { .. }, - .. - } => { - // Window close requested - most reliable cleanup point on macOS - tracing::info!( - "Window '{}' close requested, performing graceful shutdown...", - label - ); - graceful_shutdown(app_handle); - } - RunEvent::ExitRequested { code, .. } => { - // App exit requested - may not fire on macOS (Tauri issue #9198) - tracing::info!( - "App exit requested (code: {:?}), performing graceful shutdown...", - code - ); - graceful_shutdown(app_handle); - } - RunEvent::Exit => { - // Final exit - ensure shutdown signal is sent (idempotent) - tracing::info!("App exiting, ensuring shutdown signal sent..."); - shutdown_token_for_events.cancel(); - } - _ => {} + app.run(move |app_handle, event| match event { + RunEvent::WindowEvent { + label, + event: tauri::WindowEvent::CloseRequested { .. }, + .. + } => { + tracing::info!( + "Window '{}' close requested, performing graceful shutdown...", + label + ); + graceful_shutdown(app_handle); + } + RunEvent::ExitRequested { code, .. } => { + tracing::info!( + "App exit requested (code: {:?}), performing graceful shutdown...", + code + ); + graceful_shutdown(app_handle); } + RunEvent::Exit => { + tracing::info!("App exiting, ensuring shutdown signal sent..."); + shutdown_token_for_events.cancel(); + } + _ => {} }); } /// Perform graceful shutdown: cancel background tasks, wait for them to exit, then release GPU. -/// -/// This is the canonical shutdown sequence used before any process exit (quit, restart, etc.). -/// The 50ms pause lets background tasks (MCP server, domain event forwarder) exit their loops -/// before we release GPU resources they may still reference. pub(crate) fn graceful_shutdown(app_handle: &tauri::AppHandle) { use tauri::Manager; @@ -467,26 +431,18 @@ pub(crate) fn graceful_shutdown(app_handle: &tauri::AppHandle) { /// Release GPU resources (Metal context and backend) to prevent SIGABRT crash on exit. /// -/// This must be called before the app exits to properly clean up Metal/GPU -/// resources. The crash occurs when ggml_metal_rsets_free is called during -/// static destruction (__cxa_finalize_ranges) while resources are still in use. -/// -/// Cleanup order is critical: -/// 1. Release LlamaState (context + model) - frees Metal residency sets -/// 2. Release global LLAMA_BACKEND - frees the Metal backend itself -/// -/// This prevents __cxa_finalize_ranges from encountering Metal resources during -/// static destruction, which would cause SIGABRT in production builds. +/// Now accesses embedding state through AppServices container. pub(crate) fn release_gpu_resources(app_handle: &tauri::AppHandle) { use tauri::Manager; - if let Some(embedding_state) = - app_handle.try_state::() - { - tracing::info!("Releasing GPU context to prevent Metal crash..."); - // Step 1: Release the LlamaState (context + model) which holds Metal residency sets - embedding_state.service.nlp_engine().release_gpu_context(); - tracing::info!("GPU context released successfully"); + if let Some(services) = app_handle.try_state::() { + // Use blocking approach since this is called from sync shutdown code + let services_clone = services.inner(); + // We need to block on the async release - this is safe during shutdown + // since the tokio runtime is still alive at this point + tauri::async_runtime::block_on(async { + services_clone.release_gpu_resources().await; + }); } // Step 2: Release the global llama backend itself diff --git a/packages/desktop-app/src/lib/components/layout/app-shell.svelte b/packages/desktop-app/src/lib/components/layout/app-shell.svelte index 6c8fd4108..73e926ea0 100644 --- a/packages/desktop-app/src/lib/components/layout/app-shell.svelte +++ b/packages/desktop-app/src/lib/components/layout/app-shell.svelte @@ -158,6 +158,7 @@ let unlistenStatusBar: Promise<() => void> | null = null; let unlistenImport: Promise<() => void> | null = null; let unlistenDatabase: Promise<() => void> | null = null; + let unlistenDatabaseChanged: Promise<() => void> | null = null; let unlistenSettings: Promise<() => void> | null = null; let cleanupMCP: (() => Promise) | null = null; let staleNodesInterval: ReturnType | null = null; @@ -278,19 +279,14 @@ } }); - // Listen for database selection from menu + // Listen for database selection from menu (hot-swap, no restart needed) unlistenDatabase = listen('menu-select-database', async () => { try { - const result = await invoke<{ newPath: string; requiresRestart: boolean }>( + const result = await invoke<{ newPath: string; success: boolean }>( 'select_new_database' ); - if (result.requiresRestart) { - const confirmed = window.confirm( - `Database location changed to:\n${result.newPath}\n\nNodeSpace needs to restart to use the new database. Restart now?` - ); - if (confirmed) { - await invoke('restart_app'); - } + if (result.success) { + log.info('Database switched to:', result.newPath); } } catch (err) { if (err !== 'No folder selected') { @@ -299,6 +295,12 @@ } }); + // Listen for database-changed event after hot-swap (Issue #894) + // Frontend views will refresh automatically via domain event forwarder restart + unlistenDatabaseChanged = listen('database-changed', (event) => { + log.info('Database changed to:', event.payload); + }); + // Listen for settings menu — open or focus settings tab unlistenSettings = listen('menu-open-settings', () => { const state = get(tabState); @@ -449,6 +451,9 @@ if (unlistenDatabase) { (await unlistenDatabase)(); } + if (unlistenDatabaseChanged) { + (await unlistenDatabaseChanged)(); + } if (unlistenSettings) { (await unlistenSettings)(); } diff --git a/packages/desktop-app/src/lib/components/settings/sections/database-settings.svelte b/packages/desktop-app/src/lib/components/settings/sections/database-settings.svelte index 0f8bea4d8..20e6c5e56 100644 --- a/packages/desktop-app/src/lib/components/settings/sections/database-settings.svelte +++ b/packages/desktop-app/src/lib/components/settings/sections/database-settings.svelte @@ -19,16 +19,9 @@