diff --git a/Cargo.lock b/Cargo.lock index 7c2fdeb..e33cdac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -269,6 +269,7 @@ dependencies = [ "surrealdb", "thiserror 1.0.69", "tokio", + "tracing", "uuid", ] diff --git a/crates/abigail-core/src/vault/unlock.rs b/crates/abigail-core/src/vault/unlock.rs index 30b5c2c..b8470f6 100644 --- a/crates/abigail-core/src/vault/unlock.rs +++ b/crates/abigail-core/src/vault/unlock.rs @@ -21,6 +21,7 @@ const PASSPHRASE_ENV: &str = "ABIGAIL_VAULT_PASSPHRASE"; const RAW_KEK_ENV: &str = "ABIGAIL_VAULT_RAW_KEY"; const PASSPHRASE_SALT: &[u8] = b"abigail-vault-passphrase-salt-v1"; const KDF_METADATA_FILE: &str = "vault.kdf.json"; +const WINDOWS_KEK_FALLBACK_FILE: &str = "vault.kek.dpapi"; const ARGON2_MEMORY_COST_KIB: u32 = 64 * 1024; const ARGON2_TIME_COST: u32 = 3; const ARGON2_PARALLELISM: u32 = 1; @@ -97,6 +98,24 @@ impl UnlockProvider for HybridUnlockProvider { // paper Sections 22-27 runtime verification: // stable KEK + sentinel check must succeed before runtime unlock is accepted. if let Some(kek) = os_keyring_load_optional()? { + let sentinel_value = verify_or_create_sentinel(&data_root, &kek)?; + if let Err(error) = persist_windows_kek_fallback(&data_root, &kek) { + tracing::warn!( + "Stable KEK loaded from OS keyring but fallback persistence failed: {}", + error + ); + } + super::cache_session_root_kek(kek, sentinel_value); + return Ok(kek); + } + + if let Some(kek) = load_windows_kek_fallback_optional(&data_root)? { + tracing::warn!( + "Stable KEK recovered from DPAPI fallback file because OS keyring lookup failed" + ); + if let Err(error) = os_keyring_store_verified(&kek) { + tracing::warn!("Failed to repair OS keyring from fallback KEK: {}", error); + } let sentinel_value = verify_or_create_sentinel(&data_root, &kek)?; super::cache_session_root_kek(kek, sentinel_value); return Ok(kek); @@ -131,12 +150,29 @@ impl UnlockProvider for HybridUnlockProvider { // First boot only (no sentinel exists): create stable KEK once. tracing::info!("No stable vault KEK found; bootstrapping initial root key"); let kek = generate_random_kek(); - os_keyring_store(&kek).map_err(|e| { - CoreError::Vault(format!( - "Recovery Mode: failed to persist stable KEK '{}': {}", - KEYRING_ACCOUNT, e - )) - })?; + let keyring_persisted = match os_keyring_store_verified(&kek) { + Ok(()) => true, + Err(error) => { + tracing::warn!("Stable KEK could not be verified in OS keyring: {}", error); + false + } + }; + let fallback_persisted = match persist_windows_kek_fallback(&data_root, &kek) { + Ok(()) => true, + Err(error) => { + tracing::warn!( + "Stable KEK could not be persisted to DPAPI fallback file: {}", + error + ); + false + } + }; + if !keyring_persisted && !fallback_persisted { + return Err(CoreError::Vault(format!( + "Recovery Mode: failed to persist stable KEK '{}': Windows keyring verification failed and no DPAPI fallback could be written.", + KEYRING_ACCOUNT + ))); + } let sentinel_value = super::write_encrypted_sentinel(&data_root, &kek)?; super::cache_session_root_kek(kek, sentinel_value); Ok(kek) @@ -337,6 +373,51 @@ fn os_keyring_store(kek: &[u8; KEK_LEN]) -> Result<()> { Ok(()) } +fn os_keyring_store_verified(kek: &[u8; KEK_LEN]) -> Result<()> { + os_keyring_store(kek)?; + let reloaded = os_keyring_load()?; + if &reloaded != kek { + return Err(CoreError::Keyring( + "keyring verification failed: stored KEK did not round-trip".to_string(), + )); + } + Ok(()) +} + +fn windows_kek_fallback_path(data_root: &Path) -> PathBuf { + data_root.join(WINDOWS_KEK_FALLBACK_FILE) +} + +#[cfg(windows)] +fn persist_windows_kek_fallback(data_root: &Path, kek: &[u8; KEK_LEN]) -> Result<()> { + let encrypted = crate::dpapi::dpapi_encrypt(kek)?; + crate::secure_fs::write_bytes_atomic(&windows_kek_fallback_path(data_root), &encrypted) +} + +#[cfg(not(windows))] +fn persist_windows_kek_fallback(_data_root: &Path, _kek: &[u8; KEK_LEN]) -> Result<()> { + Ok(()) +} + +#[cfg(windows)] +fn load_windows_kek_fallback_optional(data_root: &Path) -> Result> { + let path = windows_kek_fallback_path(data_root); + if !path.exists() { + return Ok(None); + } + let encrypted = std::fs::read(&path)?; + let decrypted = crate::dpapi::dpapi_decrypt(&encrypted)?; + let kek: [u8; KEK_LEN] = decrypted + .try_into() + .map_err(|_| CoreError::Vault("DPAPI fallback KEK wrong length".to_string()))?; + Ok(Some(kek)) +} + +#[cfg(not(windows))] +fn load_windows_kek_fallback_optional(_data_root: &Path) -> Result> { + Ok(None) +} + fn verify_or_create_sentinel(data_root: &Path, kek: &[u8; KEK_LEN]) -> Result { let sentinel_path = super::sentinel_path(data_root); if sentinel_path.exists() { @@ -374,6 +455,22 @@ mod tests { assert_ne!(a.root_kek().unwrap(), b.root_kek().unwrap()); } + #[test] + fn windows_kek_fallback_roundtrips() { + let dir = std::env::temp_dir().join("abigail_windows_kek_fallback_roundtrip"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let mut kek = [0u8; KEK_LEN]; + kek.copy_from_slice(&[7u8; KEK_LEN]); + + persist_windows_kek_fallback(&dir, &kek).unwrap(); + let loaded = load_windows_kek_fallback_optional(&dir).unwrap().unwrap(); + assert_eq!(loaded, kek); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn fresh_passphrase_bootstrap_creates_argon2_metadata() { let dir = std::env::temp_dir().join("abigail_unlock_argon2_metadata"); diff --git a/crates/abigail-memory/src/store.rs b/crates/abigail-memory/src/store.rs index 63f2017..f1bc0e3 100644 --- a/crates/abigail-memory/src/store.rs +++ b/crates/abigail-memory/src/store.rs @@ -3,9 +3,7 @@ use crate::protected_topics::{ ProtectedTopicSummary, SecretKind, SecretMovePlan, TriangleEthicPreview, }; use abigail_core::{AppConfig, HybridUnlockProvider, PassphraseUnlockProvider, UnlockProvider}; -use abigail_persistence::{ - migrate_legacy_layout, EntityScope, PersistenceError, PersistenceHandle, QueryBinding, -}; +use abigail_persistence::{EntityScope, PersistenceError, PersistenceHandle, QueryBinding}; use chrono::Utc; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -198,13 +196,8 @@ impl MemoryStore { } pub fn open_with_config(config: &AppConfig) -> Result { - if uses_legacy_layout(config) { - migrate_legacy_layout(config) - .map_err(|error| StoreError::Migration(error.to_string()))?; - } - - let shared_path = shared_db_path(config); - if let Some(parent) = shared_path.parent() { + let db_path = config.db_path.clone(); + if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent) .map_err(|error| StoreError::Migration(error.to_string()))?; } @@ -218,14 +211,24 @@ impl MemoryStore { .and_then(|name| name.to_str()) .map(str::to_string) }; + tracing::info!( + "MemoryStore opening SurrealDB store: scope={} path={} data_dir={}", + if config.is_hive { "hive" } else { "entity" }, + db_path.display(), + config.data_dir.display() + ); Self::open_internal( - shared_path, + db_path, Arc::new(HybridUnlockProvider::new()), entity_id, None, ) } + pub fn path(&self) -> &Path { + self.persistence.path() + } + pub fn capture_secret_message( &self, entity_id: &str, @@ -829,44 +832,6 @@ struct PreparedProtectedSecret { created_at: chrono::DateTime, } -fn uses_legacy_layout(config: &AppConfig) -> bool { - legacy_primary_db_path(config).is_some() - || config.data_dir.join("jobs.db").exists() - || config.data_dir.join("calendar.db").exists() - || config.data_dir.join("kb.db").exists() -} - -fn shared_db_path(config: &AppConfig) -> PathBuf { - if is_shared_db_path(&config.db_path) { - return config.db_path.clone(); - } - - if uses_legacy_layout(config) { - config - .data_dir - .parent() - .and_then(|parent| parent.parent()) - .map(|root| root.join("memory.db")) - .unwrap_or_else(|| config.data_dir.join("memory.db")) - } else { - config.db_path.clone() - } -} - -fn legacy_primary_db_path(config: &AppConfig) -> Option<&Path> { - if is_shared_db_path(&config.db_path) || !config.db_path.exists() { - return None; - } - - Some(config.db_path.as_path()) -} - -fn is_shared_db_path(path: &Path) -> bool { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name == "memory.db") -} - fn infer_entity_id(path: &Path) -> Option { path.parent() .and_then(|parent| parent.file_name()) @@ -1015,27 +980,13 @@ mod tests { } #[test] - fn test_shared_db_path_honors_explicit_non_legacy_path() { + fn test_open_with_config_uses_configured_db_path() { let tmp = std::env::temp_dir().join(format!("abigail_store_path_{}", Uuid::new_v4())); let db_path = tmp.join("sandbox").join("test.db"); let config = test_config(&tmp, db_path.clone()); + let store = MemoryStore::open_with_config(&config).unwrap(); - assert!(!uses_legacy_layout(&config)); - assert_eq!(shared_db_path(&config), db_path); - } - - #[test] - fn test_shared_db_path_uses_hive_root_for_existing_legacy_db() { - let tmp = std::env::temp_dir().join(format!("abigail_store_legacy_{}", Uuid::new_v4())); - let agent_dir = tmp.join("identities").join(Uuid::new_v4().to_string()); - let legacy_db = agent_dir.join("abigail_memory.db"); - std::fs::create_dir_all(&agent_dir).unwrap(); - std::fs::write(&legacy_db, b"legacy").unwrap(); - - let config = test_config(&tmp, legacy_db); - - assert!(uses_legacy_layout(&config)); - assert_eq!(shared_db_path(&config), tmp.join("memory.db")); + assert_eq!(store.path(), db_path.as_path()); let _ = std::fs::remove_dir_all(&tmp); } diff --git a/crates/abigail-persistence/Cargo.toml b/crates/abigail-persistence/Cargo.toml index 2cb7c9b..5c53599 100644 --- a/crates/abigail-persistence/Cargo.toml +++ b/crates/abigail-persistence/Cargo.toml @@ -12,5 +12,6 @@ serde_json.workspace = true surrealdb.workspace = true thiserror.workspace = true tokio.workspace = true +tracing.workspace = true uuid.workspace = true sqlx = { version = "0.8.1", default-features = false, features = ["sqlite", "runtime-tokio-rustls", "chrono", "uuid"] } diff --git a/crates/abigail-persistence/src/client.rs b/crates/abigail-persistence/src/client.rs index 65ea273..1a5dddb 100644 --- a/crates/abigail-persistence/src/client.rs +++ b/crates/abigail-persistence/src/client.rs @@ -2,10 +2,10 @@ use crate::schema; use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::Value; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::OnceLock; -use surrealdb::engine::local::{Db, Mem, SurrealKv}; +use std::sync::{Arc, Mutex, OnceLock}; +use surrealdb::engine::local::{Db, Mem}; use surrealdb::types::{Number as SurrealNumber, RecordIdKey, Value as SurrealValue}; use surrealdb::Surreal; use thiserror::Error; @@ -105,14 +105,13 @@ impl PersistenceHandle { std::fs::create_dir_all(parent)?; } std::fs::create_dir_all(&path)?; + let path = normalize_store_path(&path)?; let runtime = persistence_runtime()?; - let init_path = path.clone(); + let base_db = shared_file_engine(&path)?; let init_scope = scope.clone(); let db = block_on_runtime(runtime, async move { - let db = Surreal::new::(init_path) - .await - .map_err(PersistenceError::from)?; + let db = base_db.as_ref().clone(); db.use_ns("abigail") .use_db(init_scope.database_name()) .await @@ -120,6 +119,11 @@ impl PersistenceHandle { schema::ensure_schema(&db, &init_scope).await?; Ok::<_, PersistenceError>(db) })?; + tracing::info!( + "Persistence scope ready: scope={} path={}", + scope.label(), + path.display() + ); Ok(Self { inner: Arc::new(PersistenceInner { @@ -315,6 +319,76 @@ fn persistence_runtime() -> Result<&'static tokio::runtime::Runtime> { } } +fn normalize_store_path(path: &Path) -> Result { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + + #[cfg(windows)] + { + let raw = path.to_string_lossy(); + if let Some(stripped) = raw.strip_prefix(r"\\?\") { + return Ok(PathBuf::from(stripped)); + } + } + + Ok(path) +} + +fn shared_file_engine(path: &Path) -> Result>> { + let cache = file_engine_cache(); + { + let cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(db) = cache.get(path) { + tracing::debug!("Persistence engine cache hit: {}", path.display()); + return Ok(db.clone()); + } + } + + tracing::info!( + "Persistence engine cache miss: opening embedded Surreal store at {}", + path.display() + ); + let runtime = persistence_runtime()?; + let endpoint_path = local_engine_open_path(path); + let opened = block_on_runtime(runtime, async move { + // Persist the embedded store with the local Mem engine. This keeps the + // runtime single-host and avoids SurrealKV's Windows write failures. + Surreal::new::(endpoint_path) + .sync("never") + .await + .map_err(PersistenceError::from) + })?; + let opened = Arc::new(opened); + + let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner()); + Ok(cache + .entry(path.to_path_buf()) + .or_insert_with(|| opened.clone()) + .clone()) +} + +fn file_engine_cache() -> &'static Mutex>>> { + static CACHE: OnceLock>>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn local_engine_open_path(path: &Path) -> String { + let mut raw = path.to_string_lossy().replace('\\', "/"); + + #[cfg(windows)] + { + let bytes = raw.as_bytes(); + if bytes.len() >= 2 && bytes[1] == b':' && !raw.starts_with('/') { + raw.insert(0, '/'); + } + } + + raw +} + fn surreal_value_to_json(value: SurrealValue) -> Value { match value { SurrealValue::None | SurrealValue::Null => Value::Null, @@ -382,3 +456,60 @@ fn record_id_to_json(key: RecordIdKey) -> Value { RecordIdKey::Range(value) => Value::String(format!("{value:?}")), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use uuid::Uuid; + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + struct TestDoc { + id: String, + value: String, + } + + #[test] + fn file_backed_handles_can_share_one_embedded_store_across_scopes() { + let root = std::env::temp_dir().join(format!("abigail-persistence-{}", Uuid::new_v4())); + let path = root.join("memory.db"); + + let hive = PersistenceHandle::open(&path, EntityScope::Hive).unwrap(); + let entity = + PersistenceHandle::open(&path, EntityScope::Entity("entity-a".to_string())).unwrap(); + + hive.upsert( + "hive_meta", + "primary", + &TestDoc { + id: "primary".to_string(), + value: "hive".to_string(), + }, + ) + .unwrap(); + entity + .upsert( + "memory_entry", + "entity-doc", + &TestDoc { + id: "entity-doc".to_string(), + value: "entity".to_string(), + }, + ) + .unwrap(); + + let hive_doc = hive + .select_record::("hive_meta", "primary") + .unwrap() + .unwrap(); + let entity_doc = entity + .select_record::("memory_entry", "entity-doc") + .unwrap() + .unwrap(); + + assert_eq!(hive_doc.value, "hive"); + assert_eq!(entity_doc.value, "entity"); + + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/tauri-app/src/commands/identity.rs b/tauri-app/src/commands/identity.rs index 84f50dd..5267d5d 100644 --- a/tauri-app/src/commands/identity.rs +++ b/tauri-app/src/commands/identity.rs @@ -244,9 +244,10 @@ pub async fn load_agent(state: State<'_, AppState>, agent_id: String) -> Result< let config = state.config.read().map_err(|e| e.to_string())?; let new_store = abigail_memory::MemoryStore::open_with_config(&config).map_err(|e| e.to_string())?; + let store_path = new_store.path().to_path_buf(); let mut memory = state.memory.write().map_err(|e| e.to_string())?; *memory = new_store; - tracing::info!("load_agent: reopened MemoryStore at {:?}", config.db_path); + tracing::info!("load_agent: reopened MemoryStore at {:?}", store_path); } crate::rebuild_router(&state).await?; diff --git a/tauri-app/src/commands/memory.rs b/tauri-app/src/commands/memory.rs index dd0b9c1..bd2c1d6 100644 --- a/tauri-app/src/commands/memory.rs +++ b/tauri-app/src/commands/memory.rs @@ -1,5 +1,6 @@ use crate::state::AppState; use serde::{Deserialize, Serialize}; +use std::path::Path; use tauri::State; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -31,11 +32,12 @@ pub struct ConversationTurnInfo { #[tauri::command] pub fn get_store_stats(state: State) -> Result { - let config = state.config.read().map_err(|e| e.to_string())?; - let db_path = config.db_path.clone(); - drop(config); + let db_path = { + let memory = state.memory.read().map_err(|e| e.to_string())?; + memory.path().to_path_buf() + }; - let size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + let size_bytes = store_size_bytes(&db_path); let mem = state.memory.read().map_err(|e| e.to_string())?; let memory_count = mem.count_memories().map_err(|e| e.to_string())?; @@ -56,11 +58,12 @@ pub fn get_sqlite_stats(state: State) -> Result { #[tauri::command] pub fn optimize_store(state: State) -> Result { - let config = state.config.read().map_err(|e| e.to_string())?; - let db_path = config.db_path.clone(); - drop(config); + let db_path = { + let memory = state.memory.read().map_err(|e| e.to_string())?; + memory.path().to_path_buf() + }; - let size_before = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + let size_before = store_size_bytes(&db_path); state .memory @@ -69,7 +72,7 @@ pub fn optimize_store(state: State) -> Result { .vacuum() .map_err(|e| e.to_string())?; - let size_after = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + let size_after = store_size_bytes(&db_path); let saved = size_before as i64 - size_after as i64; tracing::info!("Store optimized: {} bytes saved", saved); @@ -81,6 +84,19 @@ pub fn optimize_sqlite(state: State) -> Result { optimize_store(state) } +fn store_size_bytes(path: &Path) -> u64 { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => metadata.len(), + Ok(metadata) if metadata.is_dir() => std::fs::read_dir(path) + .into_iter() + .flatten() + .flatten() + .map(|entry| store_size_bytes(&entry.path())) + .sum(), + _ => 0, + } +} + #[tauri::command] pub fn reset_memories(state: State) -> Result { let deleted = state diff --git a/tauri-app/src/lib.rs b/tauri-app/src/lib.rs index 9951fe0..1aa648d 100644 --- a/tauri-app/src/lib.rs +++ b/tauri-app/src/lib.rs @@ -35,7 +35,7 @@ use crate::commands::skills::*; use crate::state::AppState; use abigail_auth::AuthManager; -use abigail_core::{validate_local_llm_url, AppConfig, SecretsVault}; +use abigail_core::{validate_local_llm_url, AppConfig, GlobalConfig, SecretsVault}; use abigail_hive::{Hive, ModelRegistry}; use abigail_memory::MemoryStore; use abigail_persistence::{EntityScope, PersistenceHandle}; @@ -58,11 +58,48 @@ use rate_limit::CooldownGuard; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, RwLock}; +use std::time::Instant; use tauri::{Emitter, Manager}; const KEK_RECOVERY_MESSAGE_SNIPPET: &str = "vault sentinel exists but stable KEK could not be loaded"; +struct StartupCycle { + started_at: Instant, + last_stage_at: Instant, +} + +impl StartupCycle { + fn begin() -> Self { + let now = Instant::now(); + tracing::info!("Startup cycle: begin"); + Self { + started_at: now, + last_stage_at: now, + } + } + + fn stage(&mut self, label: &str) { + let now = Instant::now(); + let stage_ms = now.duration_since(self.last_stage_at).as_millis(); + let total_ms = now.duration_since(self.started_at).as_millis(); + tracing::info!( + "Startup cycle: {} (+{} ms, total {} ms)", + label, + stage_ms, + total_ms + ); + self.last_stage_at = now; + } + + fn ready(&self) { + tracing::info!( + "Startup cycle: ready to enter event loop (total {} ms)", + self.started_at.elapsed().as_millis() + ); + } +} + fn startup_profile_root() -> PathBuf { profile_root_for_data_dir(&abigail_core::AppConfig::default_paths().data_dir) } @@ -82,6 +119,61 @@ fn is_clean_start_recovery_candidate(message: &str) -> bool { || (normalized.contains("stable kek") && normalized.contains("vault sentinel")) } +fn is_disposable_bootstrap_profile(data_dir: &Path) -> bool { + let global = match GlobalConfig::load(data_dir) { + Ok(global) => global, + Err(error) => { + tracing::warn!( + "KEK recovery: could not inspect global config at {}: {}", + data_dir.display(), + error + ); + return false; + } + }; + + if global.agents.iter().any(|agent| !agent.is_hive) { + return false; + } + + let Some(hive_agent) = global.agents.iter().find(|agent| agent.is_hive) else { + return false; + }; + + let hive_config_path = data_dir.join(&hive_agent.directory).join("config.json"); + let hive_config = match AppConfig::load(&hive_config_path) { + Ok(config) => config, + Err(error) => { + tracing::warn!( + "KEK recovery: could not inspect Hive config at {}: {}", + hive_config_path.display(), + error + ); + return false; + } + }; + + if !hive_config.is_hive || hive_config.birth_complete { + return false; + } + + const VAULT_PAYLOADS: &[&str] = &[ + "secrets.bin", + "secrets.vault", + "skills.bin", + "skills.vault", + "keys.bin", + "keys.vault", + "hive_secrets.bin", + "hive_secrets.vault", + ]; + + !VAULT_PAYLOADS + .iter() + .map(|name| data_dir.join(name)) + .any(|path| path.exists()) +} + fn archive_profile_for_clean_start() -> Result { let profile_root = startup_profile_root(); if !profile_root.exists() { @@ -187,6 +279,23 @@ fn maybe_offer_clean_start_recovery(message: &str) -> Result { return Ok(false); } + let data_dir = AppConfig::default_paths().data_dir; + if is_disposable_bootstrap_profile(&data_dir) { + tracing::warn!( + "Startup detected an unrecoverable disposable bootstrap profile at {}; auto-archiving without prompting", + data_dir.display() + ); + let backup_root = archive_profile_for_clean_start()?; + relaunch_current_executable().map_err(|e| { + format!( + "{}\nArchived disposable bootstrap profile backup: {}", + e, + backup_root.display() + ) + })?; + return Ok(true); + } + if !prompt_clean_start_for_kek_recovery(message) { return Ok(false); } @@ -539,10 +648,12 @@ fn try_run() -> Result<(), String> { let log_buffer = log_capture::new_log_buffer(); log_capture::init_tracing(log_buffer.clone()); + let mut startup = StartupCycle::begin(); let current_version = env!("CARGO_PKG_VERSION"); let hive_data_dir = abigail_core::AppConfig::default_paths().data_dir; install_upgrade::run_preflight(&hive_data_dir, current_version)?; + startup.stage("preflight complete"); let identity_manager = Arc::new( IdentityManager::new(hive_data_dir.clone()) @@ -550,6 +661,7 @@ fn try_run() -> Result<(), String> { ); let startup_agent_id = install_upgrade::run_identity_upgrade(&hive_data_dir, current_version, &identity_manager)?; + startup.stage("identity manager ready"); let mut config = get_config(); let mut active_agent_id = None; @@ -589,6 +701,14 @@ fn try_run() -> Result<(), String> { } } } + tracing::info!( + "Startup config resolved: data_dir={} db_path={} is_hive={} active_agent_id={:?}", + config.data_dir.display(), + config.db_path.display(), + config.is_hive, + active_agent_id + ); + startup.stage("active config resolved"); let data_dir = config.data_dir.clone(); let iggy_connection = config.iggy_connection.clone(); @@ -614,6 +734,7 @@ fn try_run() -> Result<(), String> { )); let hive = Arc::new(Hive::new(secrets.clone(), hive_secrets.clone())); + startup.stage("vaults and registries prepared"); let router = tauri::async_runtime::block_on(async { let built = hive @@ -622,6 +743,7 @@ fn try_run() -> Result<(), String> { .map_err(|e| format!("Failed to build LLM providers: {e}"))?; Ok::<_, String>(IdEgoRouter::from_built_providers(built)) })?; + startup.stage("provider router built"); // Initialize model registry from persisted catalog let model_registry = { @@ -668,12 +790,17 @@ fn try_run() -> Result<(), String> { MemoryStore::open_with_config(&config) .map_err(|e| format!("Failed to open MemoryStore: {e}"))?, )); + startup.stage("memory store opened"); let agentic_runtime = Arc::new(agentic_runtime::AgenticRuntime::new(&data_dir)); #[allow(deprecated)] let orchestration_scheduler = Arc::new(OrchestrationScheduler::new(data_dir.clone())); // Open job queue database for async task management. let job_queue = { + tracing::info!( + "Opening Surreal job queue store at {}", + config.db_path.display() + ); let queue_store = PersistenceHandle::open(&config.db_path, EntityScope::Hive).map_err(|e| { format!( @@ -686,9 +813,11 @@ fn try_run() -> Result<(), String> { stream_broker.clone(), )) }; + startup.stage("job queue opened"); // Seed skill instructions into data_dir when absent (first run / clean install). skill_instructions::bootstrap_if_needed(&data_dir); + startup.stage("skill instructions bootstrapped"); let state = AppState { config: RwLock::new(config), @@ -736,14 +865,16 @@ fn try_run() -> Result<(), String> { )), force_override: RwLock::new(crate::state::ForceOverride::default()), }; + startup.stage("app state assembled"); - tauri::Builder::default() + let app = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .setup(|app| { let handle = app.handle(); let state = handle.state::(); + tracing::info!("Startup setup: entering Tauri setup hook"); tauri::async_runtime::block_on(async { state @@ -752,12 +883,15 @@ fn try_run() -> Result<(), String> { .await .map_err(|e| e.to_string()) })?; + tracing::info!("Startup setup: agentic recovery initialized"); register_runtime_subagents(&state)?; + tracing::info!("Startup setup: runtime subagents registered"); // Register Hive Management Skill let hive_ops = Arc::new(crate::hive_ops::TauriHiveOps::new(handle.clone())); register_hive_management_skill(&state.registry, hive_ops); + tracing::info!("Startup setup: Hive management skill registered"); // Register Backup Management Skill let backup_ops = Arc::new(crate::backup_ops::TauriBackupOps::new(handle.clone())); @@ -807,6 +941,7 @@ fn try_run() -> Result<(), String> { // Register identity-bound skills that need the active entity data dir. install_identity_bound_skills(&state)?; + tracing::info!("Startup setup: identity-bound skills registered"); // Register native Rust skills (compiled into the binary). { @@ -824,6 +959,7 @@ fn try_run() -> Result<(), String> { state.skills_secrets.clone(), ); } + tracing::info!("Startup setup: native skills registered"); // Register configured MCP servers as skills (HTTP transport). { @@ -1243,21 +1379,23 @@ fn try_run() -> Result<(), String> { set_runtime_mode ]) .build(tauri::generate_context!()) - .map_err(|e| format!("Failed to build Tauri app: {e}"))? - .run(|app_handle, event| { - if let tauri::RunEvent::Exit = event { - // Gracefully shut down managed Ollama process - let state = app_handle.state::(); - let ollama = state.ollama.clone(); - tauri::async_runtime::block_on(async { - let mut guard = ollama.lock().await; - if let Some(ref mut mgr) = *guard { - tracing::info!("App exiting: shutting down managed Ollama"); - mgr.shutdown(); - } - }); - } - }); + .map_err(|e| format!("Failed to build Tauri app: {e}"))?; + startup.stage("tauri app built"); + startup.ready(); + app.run(|app_handle, event| { + if let tauri::RunEvent::Exit = event { + // Gracefully shut down managed Ollama process + let state = app_handle.state::(); + let ollama = state.ollama.clone(); + tauri::async_runtime::block_on(async { + let mut guard = ollama.lock().await; + if let Some(ref mut mgr) = *guard { + tracing::info!("App exiting: shutting down managed Ollama"); + mgr.shutdown(); + } + }); + } + }); Ok(()) } @@ -1279,6 +1417,81 @@ mod tests { )); } + #[test] + fn disposable_bootstrap_profile_detects_unborn_hive_only_state() { + let root = std::env::temp_dir().join(format!( + "abigail_disposable_profile_{}", + uuid::Uuid::new_v4() + )); + let data_dir = root.join("data"); + let hive_id = "726cde06-380a-40c9-a1bf-625cc9a4a659"; + let hive_dir = data_dir.join("identities").join(hive_id); + std::fs::create_dir_all(&hive_dir).unwrap(); + + let mut global = GlobalConfig::new(&data_dir); + global + .register_agent(abigail_core::AgentEntry { + id: hive_id.to_string(), + name: "Abigail Hive".to_string(), + is_hive: true, + directory: PathBuf::from("identities").join(hive_id), + }) + .unwrap(); + global.save(&data_dir).unwrap(); + + let mut hive_config = AppConfig::default_paths(); + hive_config.data_dir = hive_dir.clone(); + hive_config.models_dir = hive_dir.join("models"); + hive_config.docs_dir = hive_dir.join("docs"); + hive_config.db_path = data_dir.join("memory.db"); + hive_config.agent_name = Some("Abigail Hive".to_string()); + hive_config.is_hive = true; + hive_config.birth_complete = false; + hive_config.save(&hive_dir.join("config.json")).unwrap(); + + assert!(is_disposable_bootstrap_profile(&data_dir)); + + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn disposable_bootstrap_profile_rejects_existing_vault_payloads() { + let root = std::env::temp_dir().join(format!( + "abigail_non_disposable_profile_{}", + uuid::Uuid::new_v4() + )); + let data_dir = root.join("data"); + let hive_id = "726cde06-380a-40c9-a1bf-625cc9a4a659"; + let hive_dir = data_dir.join("identities").join(hive_id); + std::fs::create_dir_all(&hive_dir).unwrap(); + + let mut global = GlobalConfig::new(&data_dir); + global + .register_agent(abigail_core::AgentEntry { + id: hive_id.to_string(), + name: "Abigail Hive".to_string(), + is_hive: true, + directory: PathBuf::from("identities").join(hive_id), + }) + .unwrap(); + global.save(&data_dir).unwrap(); + + let mut hive_config = AppConfig::default_paths(); + hive_config.data_dir = hive_dir.clone(); + hive_config.models_dir = hive_dir.join("models"); + hive_config.docs_dir = hive_dir.join("docs"); + hive_config.db_path = data_dir.join("memory.db"); + hive_config.agent_name = Some("Abigail Hive".to_string()); + hive_config.is_hive = true; + hive_config.birth_complete = false; + hive_config.save(&hive_dir.join("config.json")).unwrap(); + std::fs::write(data_dir.join("hive_secrets.bin"), b"payload").unwrap(); + + assert!(!is_disposable_bootstrap_profile(&data_dir)); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn profile_root_for_data_dir_uses_parent_of_data_directory() { let data_dir = PathBuf::from(r"C:\Users\jbcup\AppData\Local\abigail\Abigail\data");