diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3b97ff1fe9..10e9db86d6 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -135,7 +135,7 @@ zip = "8" flate2 = "1" sherpa-onnx = "1.12" regex = "1" -rusqlite = { version = "0.37", features = ["bundled"] } +rusqlite = { version = "0.37", features = ["bundled", "backup"] } axum = "0.8" rodio = "0.22" earshot = "1.0" diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index fc90e6ab14..cdf62f1e58 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -56,9 +56,8 @@ pub struct AppState { pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState, /// Tauri app handle — stored after setup so huddle commands can emit /// `huddle-state-changed` events without needing the handle threaded - /// through every call site. - /// - /// Set once during `setup()` in `lib.rs`; never cleared. + /// through every call site. Set once during `setup()` in `lib.rs`; + /// never cleared. pub app_handle: Mutex>, /// Port of the localhost media streaming proxy (set during setup). pub media_proxy_port: AtomicU16, @@ -68,13 +67,8 @@ pub struct AppState { /// signing commands check this flag via [`AppState::signing_keys`] and /// return `Err` so no events are published under the inaccessible identity. /// Mutually exclusive with `identity_lost` (guaranteed by `RecoveryState` - /// at the resolve boundary). - /// - /// Ordering: writers store with `Ordering::Release` after `state.keys` is - /// updated, so a reader observing `false` with `Ordering::Acquire` is - /// guaranteed to see the updated keys. Writers: `setup()` (initial - /// resolution via `resolve_persisted_identity`) and `import_identity` - /// (clears the flag when the user successfully imports a new key). + /// at the resolve boundary). Ordering: writers store with + /// `Ordering::Release`; readers use `Ordering::Acquire`. pub keyring_locked: AtomicBool, /// Set when identity resolution detected a "lost" state: the migration /// marker was present but the keyring was empty and no plaintext fallback @@ -97,11 +91,12 @@ pub struct AppState { /// Set when the boot-time Phase 2 reset attempted a wipe but verification /// failed. The sentinel is preserved so the next relaunch retries. All /// identity-dependent setup is skipped; the frontend shows a reset-failed - /// recovery screen via `get_identity`. - /// - /// Ordering: written once in `setup()` with `Ordering::Release`; read in - /// `get_identity` with `Ordering::Acquire`. + /// recovery screen via `get_identity`. Written once in `setup()` with + /// `Ordering::Release`; read in `get_identity` with `Ordering::Acquire`. pub reset_failed: AtomicBool, + /// Set when pre-migration file-commit recovery fails; all store-touching + /// setup is skipped. Same write/read ordering as `reset_failed`. + pub store_recovery_failed: AtomicBool, /// Cached ACP session config from running agents, keyed by canonical /// `(agent pubkey, relay URL)` runtime identity. /// Populated when the harness emits `session_config_captured` observer events. @@ -226,6 +221,7 @@ pub fn build_app_state() -> AppState { keyring_locked: AtomicBool::new(false), identity_lost: AtomicBool::new(false), reset_failed: AtomicBool::new(false), + store_recovery_failed: AtomicBool::new(false), #[cfg(feature = "mesh-llm")] mesh_llm_runtime: AsyncMutex::new(None), #[cfg(feature = "mesh-llm")] diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 2dc0ba0d69..4728b89554 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -13,7 +13,7 @@ use crate::{ }, }, current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key, - known_acp_runtime, load_managed_agents, load_personas, save_managed_agents, + known_acp_runtime, load_managed_agents, load_personas, mutate_agent_store, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, }, @@ -252,27 +252,32 @@ pub async fn get_agent_config_surface( state: State<'_, AppState>, ) -> Result { let record = { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); + let instance_id = current_instance_id(&app); + + let pubkey_for_closure = pubkey.clone(); + let ((record, exited_pubkeys), _guard) = + mutate_agent_store(&app, store_guard, move |mut instances, _journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + let record = instances + .iter() + .find(|r| r.pubkey == pubkey_for_closure) + .ok_or_else(|| format!("agent {pubkey_for_closure} not found"))? + .clone(); + Ok((instances, (record, exited))) + })?; + for pk in &exited_pubkeys { + state.clear_agent_session_caches(pk); } - records - .into_iter() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))? + record }; let personas = load_personas(&app).unwrap_or_default(); diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 0eb024a86a..ae4ca26e2d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -519,9 +519,9 @@ async fn restart_single_agent_after_install( app_state::AppState, managed_agents::{ agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, - load_global_agent_config, load_managed_agents, load_personas, record_agent_command, - resolve_effective_agent_env, save_managed_agents, stop_managed_agent_process, - sync_managed_agent_processes, AgentReadiness, BackendKind, + load_global_agent_config, load_personas, mutate_agent_store, record_agent_command, + resolve_effective_agent_env, stop_managed_agent_process, sync_managed_agent_processes, + AgentReadiness, BackendKind, }, }; use tauri::Manager; @@ -533,81 +533,77 @@ async fn restart_single_agent_after_install( let stop_result = tokio::task::spawn_blocking(move || { let state = app_for_stop.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| format!("failed to acquire store lock: {e}"))?; - let mut records = load_managed_agents(&app_for_stop)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| format!("failed to acquire runtimes lock: {e}"))?; - // Sync process state so PID liveness reflects current reality. - let (sync_changed, _) = sync_managed_agent_processes( - &mut records, - &mut runtimes, - ¤t_instance_id(&app_for_stop), - ); - if sync_changed { - save_managed_agents(&app_for_stop, &records)?; - } - - // Re-verify eligibility under lock. - let record = records - .iter() - .find(|r| r.pubkey == pubkey_owned) - .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; + let app_for_closure = app_for_stop.clone(); + let (runtime_keys, _guard) = mutate_agent_store( + &app_for_stop, + store_guard, + move |mut records, _journal| { + let instance_id = current_instance_id(&app_for_closure); + // Sync process state so PID liveness reflects current reality. + sync_managed_agent_processes(&mut records, &mut runtimes, &instance_id); + + // Re-verify eligibility under lock. + let record = records + .iter() + .find(|r| r.pubkey == pubkey_owned) + .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; - if record.backend != BackendKind::Local { - return Err(format!("agent {pubkey_owned} is no longer a local agent")); - } - let runtime_keys = - crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned); - if runtime_keys.is_empty() { - return Err(format!( - "agent {pubkey_owned} no longer has a live pair runtime after sync" - )); - } + if record.backend != BackendKind::Local { + return Err(format!("agent {pubkey_owned} is no longer a local agent")); + } + let runtime_keys = + crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned); + if runtime_keys.is_empty() { + return Err(format!("agent {pubkey_owned} no longer has a live pair runtime after sync")); + } - let personas = load_personas(&app_for_stop).unwrap_or_default(); - let global = load_global_agent_config(&app_for_stop).unwrap_or_default(); + let personas = load_personas(&app_for_closure).unwrap_or_default(); + let global = load_global_agent_config(&app_for_closure).unwrap_or_default(); - let effective_cmd = record_agent_command(record, &personas); - let runtime_matches = - known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned); - if !runtime_matches { - return Err(format!( - "agent {pubkey_owned} runtime no longer matches {runtime_id_owned} under lock" - )); - } + let effective_cmd = record_agent_command(record, &personas); + let runtime_matches = + known_acp_runtime(&effective_cmd).is_some_and(|r| r.id == runtime_id_owned); + if !runtime_matches { + return Err(format!( + "agent {pubkey_owned} runtime no longer matches {runtime_id_owned} under lock" + )); + } - let setup_mode = runtimes - .iter() - .find(|(key, _)| key.pubkey == pubkey_owned) - .map(|(_, p)| p.setup_mode) - .unwrap_or(false); - if !setup_mode { - return Err(format!( - "agent {pubkey_owned} is not in setup mode under lock — skipping" - )); - } + let setup_mode = runtimes + .iter() + .find(|(key, _)| key.pubkey == pubkey_owned) + .map(|(_, p)| p.setup_mode) + .unwrap_or(false); + if !setup_mode { + return Err(format!("agent {pubkey_owned} is not in setup mode under lock — skipping")); + } - let runtime_meta = known_acp_runtime(&effective_cmd); - let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global); - if !matches!(agent_readiness(&effective), AgentReadiness::Ready) { - return Err(format!( - "agent {pubkey_owned} readiness is still NotReady after install — not bouncing" - )); - } + let runtime_meta = known_acp_runtime(&effective_cmd); + let effective = resolve_effective_agent_env(record, &personas, runtime_meta, &global); + if !matches!(agent_readiness(&effective), AgentReadiness::Ready) { + return Err(format!( + "agent {pubkey_owned} readiness is still NotReady after install — not bouncing" + )); + } - // Stop the process. - let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; - stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; - save_managed_agents(&app_for_stop, &records)?; + // Stop the process — mutation happens under the advisory lock. + let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; + stop_managed_agent_process(&app_for_closure, record_mut, &mut runtimes)?; - Ok(runtime_keys) + Ok((records, runtime_keys)) + }, + )?; + Ok::, String>(runtime_keys) }) .await; @@ -659,19 +655,23 @@ fn persist_last_error_on_install( ) -> Result<(), String> { use crate::{ app_state::AppState, - managed_agents::{find_managed_agent_mut, load_managed_agents, save_managed_agents}, + managed_agents::{find_managed_agent_mut, mutate_agent_store}, }; use tauri::Manager; let state = app.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| format!("failed to acquire store lock: {e}"))?; - let mut records = load_managed_agents(app)?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - record.last_error = Some(error.to_string()); - record.updated_at = crate::util::now_iso(); - save_managed_agents(app, &records) + let pubkey = pubkey.to_owned(); + let error = error.to_owned(); + mutate_agent_store(app, store_guard, move |mut instances, _journal| { + let record = find_managed_agent_mut(&mut instances, &pubkey)?; + record.last_error = Some(error); + record.updated_at = crate::util::now_iso(); + Ok((instances, ())) + }) + .map(|_| ()) } /// Build the `-l -c` argument list for the install shell. diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 4704582372..08780998aa 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -4,6 +4,10 @@ use nostr::Keys; use serde::Deserialize; use tauri::{AppHandle, State}; +use crate::managed_agents::store_journal::{ + cas_generation, insert_operation, new_operation_id, read_generation, CasOutcome, +}; + use super::agent_model_process::run_agent_models_command; // The map-only lookup is reached solely from the base-URL helpers that exist for // their unit tests; discovery itself always goes through the process-env variant. @@ -18,14 +22,12 @@ use crate::{ app_state::AppState, managed_agents::{ build_managed_agent_summary, current_instance_id, discovery_env_with_baked_floor, - find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, - load_personas, managed_agent_avatar_url, missing_command_message, normalize_agent_args, - resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, - AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, - DEFAULT_ACP_COMMAND, + known_acp_runtime, load_global_agent_config, load_personas, managed_agent_avatar_url, + missing_command_message, normalize_agent_args, resolve_command, + sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, AgentModelsResponse, + UpdateManagedAgentRequest, UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, - util::now_iso, }; /// Query available models from an agent via `buzz-acp models --json`. @@ -39,22 +41,28 @@ pub async fn get_agent_models( state: State<'_, AppState>, ) -> Result { let (resolved_acp, agent_command, discovery) = { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); + let instance_id = current_instance_id(&app); + + let ((records, exited_pubkeys), _guard) = crate::managed_agents::mutate_agent_store( + &app, + store_guard, + move |mut instances, _journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + let out = instances.clone(); + Ok((instances, (out, exited))) + }, + )?; + for pk in &exited_pubkeys { + state.clear_agent_session_caches(pk); } let record = records @@ -65,16 +73,8 @@ pub async fn get_agent_models( let resolved = resolve_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; - // Resolve the effective harness from the linked persona (mirrors spawn), - // so model discovery runs against the persona's current harness, not the - // frozen record snapshot. An explicit per-agent override wins. let personas = load_personas(&app).unwrap_or_default(); let global = load_global_agent_config(&app).unwrap_or_default(); - - // Single pure helper — descriptor + authoritative model/provider - // resolver, packaged so the linked-agent regression test binds the - // exact values this command consumes. Returns Err on dangling harness - // id, propagating it to the caller. let discovery = agent_model_discovery_config(record, &personas, &global) .map_err(|e| model_discovery_error(&pubkey, &e))?; @@ -95,8 +95,6 @@ pub async fn get_agent_models( } = discovery; let merged_env = discovery_env_with_baked_floor(merged_env); - // Resolve against the baked/process env when the record saved no provider, - // so a build-provided provider still gets live discovery. let effective_provider = effective_discovery_provider(saved_provider.as_deref(), provider_env_var, &merged_env); if let Some(models) = discover_openrouter_models( @@ -155,10 +153,6 @@ pub async fn get_agent_models( } /// Error copy for a failed harness resolution during model discovery. -/// -/// Routes through `user_facing_harness_error` so a dangling harness id renders -/// as a sentence, never as the raw `DANGLING_HARNESS_ID:` sentinel — the same -/// contract spawn and summary rows honor. fn model_discovery_error(pubkey: &str, error: &str) -> String { format!( "cannot discover models for {pubkey}: {}", @@ -230,8 +224,7 @@ pub async fn discover_agent_models( &input.env_vars, ); let merged_env = discovery_env_with_baked_floor(merged_env); - // Recover a build-provided provider when the form has none, so the create - // dialog discovers live models instead of falling through to the subprocess. + // Recover a build-provided provider when the form has none. let effective_provider = effective_discovery_provider( input.provider.as_deref(), runtime_meta.and_then(|meta| meta.provider_env_var), @@ -696,15 +689,8 @@ use databricks::{ }; use databricks::{discover_databricks_models, DatabricksAuthIntent}; -/// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch -/// to `record`, enforcing the linked-instance write guard: a definition-linked -/// record's model/provider/prompt are definition-authoritative (see -/// `effective_config::resolve_linked`), so writes to these three fields are -/// silently dropped for a linked instance rather than persisting a byte the -/// resolver will never read. Definition-less instances accept the patch -/// as-is. Extracted so the guard is exercised by both `update_managed_agent` -/// and its regression tests — a test that reimplements this check instead of -/// calling it can go green after the real guard is deleted. +/// Apply model/provider/system_prompt updates to `record`. +/// Silently drops writes for definition-linked records (definition-authoritative fields). fn apply_model_provider_prompt_update( record: &mut crate::managed_agents::ManagedAgentRecord, model: Option>, @@ -726,9 +712,7 @@ fn apply_model_provider_prompt_update( } /// Update mutable fields on an existing managed agent record. -/// -/// Does NOT auto-restart the agent. Runtime config changes (system prompt, -/// parallelism, commands, toolsets) take effect on the next agent spawn. +/// Runtime config changes take effect on the next agent spawn. /// Name changes are synced to the relay immediately via a kind:0 re-publish. #[tauri::command] pub async fn update_managed_agent( @@ -736,156 +720,171 @@ pub async fn update_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { - // Phase 1: local save (synchronous, under lock) - let (summary, sync_params, rollback) = { - let _store_guard = state + // Validate allowlist constraints before the mutation closure (borrow must precede move). + let prospective_allowlist_opt = match input.respond_to_allowlist.as_ref() { + Some(list) => Some(crate::managed_agents::validate_respond_to_allowlist(list)?), + None => None, + }; + let prospective_mode_opt = input.respond_to; + + let (summary, sync_params, rollback, _op_id) = { + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - let (_, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); - } - - let record = find_managed_agent_mut(&mut records, &input.pubkey)?; - let previous_record = record.clone(); - - let mut name_changed = false; - if let Some(name_update) = input.name { - let trimmed = name_update.trim().to_string(); - if !trimmed.is_empty() && trimmed != record.name { - record.name = trimmed; - name_changed = true; - } - } - apply_model_provider_prompt_update( - record, - input.model, - input.provider, - input.system_prompt, - ); - if let Some(parallelism) = input.parallelism { - record.parallelism = parallelism; - } - // turn_timeout_seconds is intentionally not applied here — - // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness. - // Use idle_timeout_seconds or max_turn_duration_seconds instead. - // Store the relay override exactly as supplied (trimmed). An explicit - // value pins the agent; empty falls back to the workspace relay at - // read-time. A name-only edit (relay_url == None) leaves the pin intact. - if let Some(relay_url) = input.relay_url { - record.relay_url = relay_url.trim().to_string(); - } - if let Some(acp_command) = input.acp_command { - record.acp_command = acp_command; - } - // Harness edit: the persona's runtime is authoritative, so an explicit - // `agent_command_override` is persisted ONLY when the user picks a - // command that diverges from the persona, and the empty/whitespace - // "Inherit from persona" sentinel clears both the pin and the - // materialized record runtime. A name-only edit - // (`agent_command == None`) leaves the pin intact. `harness_override` - // threads the user's explicit intent — see `apply_agent_command_update` - // and `update_time_agent_command_override` for the full resolution - // rules. - if let Some(agent_command) = input.agent_command { - let personas = load_personas(&app).unwrap_or_default(); - crate::managed_agents::apply_agent_command_update( - record, - &personas, - &agent_command, - input.harness_override, - ); - } - if let Some(agent_args) = input.agent_args { - record.agent_args = agent_args; - } - // mcp_command is intentionally not applied here — the effective MCP - // command is always catalog-derived (known_acp_runtime at spawn time) - // and the per-record field is never read by the runtime. - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - record.env_vars = env_vars; - } - - // Native provider/model fields are authoritative. Keep the typed marker - // derived for new records while retaining legacy typed records for - // non-native providers. - if record.provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { - let model_ref = record - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID) - .to_string(); - record.model = Some(model_ref.clone()); - record.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { model_ref }); - } + let instance_id = current_instance_id(&app); + let pubkey_str = input.pubkey.clone(); + let op_id = new_operation_id(); + let op_id_for_closure = op_id.clone(); + let personas_for_closure = load_personas(&app).unwrap_or_default(); + + let ((record_out, (name_changed, previous_record, exited_pubkeys)), _guard) = + crate::managed_agents::mutate_agent_store( + &app, + store_guard, + move |mut instances, journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + let record = instances + .iter_mut() + .find(|r| r.pubkey == pubkey_str) + .ok_or_else(|| format!("agent {pubkey_str} not found"))?; + let previous_record = record.clone(); + let mut name_changed = false; + + if let Some(name_update) = input.name { + let trimmed = name_update.trim().to_string(); + if !trimmed.is_empty() && trimmed != record.name { + record.name = trimmed; + name_changed = true; + } + } + apply_model_provider_prompt_update( + record, + input.model, + input.provider, + input.system_prompt, + ); + if let Some(parallelism) = input.parallelism { + record.parallelism = parallelism; + } + if let Some(relay_url) = input.relay_url { + record.relay_url = relay_url.trim().to_string(); + } + if let Some(acp_command) = input.acp_command { + record.acp_command = acp_command; + } + if let Some(agent_command) = input.agent_command { + crate::managed_agents::apply_agent_command_update( + record, + &personas_for_closure, + &agent_command, + input.harness_override, + ); + } + if let Some(agent_args) = input.agent_args { + record.agent_args = agent_args; + } + if let Some(env_vars) = input.env_vars { + crate::managed_agents::validate_user_env_keys(&env_vars)?; + record.env_vars = env_vars; + } + if record.provider.as_deref() + == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) + { + let model_ref = record + .model + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or(crate::managed_agents::RELAY_MESH_AUTO_MODEL_ID) + .to_string(); + record.model = Some(model_ref.clone()); + record.relay_mesh = + Some(crate::managed_agents::RelayMeshConfig { model_ref }); + } + let merged_mode = prospective_mode_opt.unwrap_or(record.respond_to); + let merged_allowlist = prospective_allowlist_opt + .clone() + .unwrap_or_else(|| record.respond_to_allowlist.clone()); + if merged_mode == crate::managed_agents::RespondTo::Allowlist + && merged_allowlist.is_empty() + { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" + .to_string(), + ); + } + record.respond_to = merged_mode; + if prospective_allowlist_opt.is_some() { + record.respond_to_allowlist = merged_allowlist; + } + record.updated_at = crate::util::now_iso(); - // Inbound author gate: merge patch onto current values, then validate - // the merged state. This lets a single update switch to Allowlist AND - // supply pubkeys atomically. - let prospective_mode = input.respond_to.unwrap_or(record.respond_to); - let prospective_allowlist = match input.respond_to_allowlist.as_ref() { - Some(list) => crate::managed_agents::validate_respond_to_allowlist(list)?, - None => record.respond_to_allowlist.clone(), - }; - if prospective_mode == crate::managed_agents::RespondTo::Allowlist - && prospective_allowlist.is_empty() - { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist" - .to_string(), - ); - } - record.respond_to = prospective_mode; - // Preserve the persisted allowlist across mode toggles — only replace - // when the caller explicitly supplied a new list. - if input.respond_to_allowlist.is_some() { - record.respond_to_allowlist = prospective_allowlist; + let (current_gen, is_tombstone) = read_generation(journal, &pubkey_str)?; + if is_tombstone { + return Err(format!( + "agent {pubkey_str} has been tombstoned; update rejected" + )); + } + insert_operation( + journal, + &op_id_for_closure, + "update", + &pubkey_str, + current_gen, + )?; + match cas_generation(journal, &pubkey_str, current_gen)? { + CasOutcome::Committed { .. } => {} + CasOutcome::Conflict { current } => { + return Err(format!( + "agent {pubkey_str} generation conflict (expected {}, got {}); retry", + current_gen.0, current.0 + )); + } + CasOutcome::Tombstoned { .. } => { + return Err(format!( + "agent {pubkey_str} was concurrently tombstoned; update rejected" + )); + } + } + let record_out = record.clone(); + Ok(( + instances, + (record_out, (name_changed, previous_record, exited)), + )) + }, + )?; + + for pk in &exited_pubkeys { + state.clear_agent_session_caches(pk); } - - record.updated_at = now_iso(); - - save_managed_agents(&app, &records)?; - - let record = records - .iter() - .find(|r| r.pubkey == input.pubkey) - .ok_or_else(|| format!("agent {} not found", input.pubkey))?; - - // Publish the edit to the relay. After-save, inside the lock, before - // any .await. The retention upsert hashes the opt-IN projection, so an - // update that touched only runtime/local fields is a no-op publish. - super::agents::retain_managed_agent_pending(&app, &state, record); + // Retain first (inserts outbox evidence linked to op_id), then advance + // the operation to Committed — guarantees outbox row exists before op + // reaches terminal state. + super::agents::retain_managed_agent_pending(&app, &state, &record_out, Some(&op_id)); + crate::managed_agents::store_journal::advance_to_committed(&app, &op_id); let sync_params = if name_changed { - let agent_keys = Keys::parse(&record.private_key_nsec) + let agent_keys = Keys::parse(&record_out.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; - // Re-publish the renamed profile to the agent's effective relay: - // an explicit per-agent relay wins; empty falls back to workspace. let relay_url = crate::relay::effective_agent_relay_url( - &record.relay_url, + &record_out.relay_url, &relay_ws_url_with_override(&state), ); - let display_name = record.name.clone(); - // Avatar fallback derives from the EFFECTIVE harness (persona-wins), - // not the frozen snapshot, so an inherited harness picks the right - // default avatar. + let display_name = record_out.name.clone(); let personas = load_personas(&app).unwrap_or_default(); - let effective_command = crate::managed_agents::record_agent_command(record, &personas); - let avatar_url = record + let effective_command = + crate::managed_agents::record_agent_command(&record_out, &personas); + let avatar_url = record_out .avatar_url .clone() .or_else(|| managed_agent_avatar_url(&effective_command)); - let auth_tag = record.auth_tag.clone(); + let auth_tag = record_out.auth_tag.clone(); Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) } else { None @@ -893,23 +892,24 @@ pub async fn update_managed_agent( let summary = { let personas = load_personas(&app).unwrap_or_default(); + let rts = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; build_managed_agent_summary( &app, - record, - &runtimes, + &record_out, + &rts, &personas, &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), )? }; - let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record)); - (summary, sync_params, rollback) + let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, &record_out)); + (summary, sync_params, rollback, op_id) }; // lock dropped here try_regenerate_nest(&app); - // Phase 2: relay profile sync (async, outside lock). A rename is committed - // only when this succeeds; otherwise restore the complete pre-edit record - // so Desktop and the relay keep one authoritative name. if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { if let Err(sync_error) = sync_managed_agent_profile( &state, @@ -940,9 +940,7 @@ pub async fn update_managed_agent( // ── Model normalization ─────────────────────────────────────────────────────── /// Normalize raw `buzz-acp models --json` output into a typed DTO for the frontend. -/// -/// Merges models from both ACP paths (stable configOptions + unstable SessionModelState), -/// deduplicates by ID (stable takes precedence), and returns a unified list. +/// Merges stable configOptions + unstable SessionModelState, deduplicates by ID. pub(super) fn normalize_agent_models( raw: &serde_json::Value, persisted_model: Option, @@ -959,8 +957,7 @@ pub(super) fn normalize_agent_models( let mut models: Vec = Vec::new(); let mut seen_ids: HashSet = HashSet::new(); - // 1. Stable configOptions (preferred). Only entries with category "model" - // are model options — the CLI pre-filters, but we're defensive here. + // Stable configOptions (preferred): only "model" category entries. if let Some(config_options) = raw["stable"]["configOptions"].as_array() { for opt in config_options { if opt.get("category").and_then(|c| c.as_str()) != Some("model") { @@ -985,7 +982,7 @@ pub(super) fn normalize_agent_models( } } - // 2. Unstable availableModels (fallback — skip duplicates from stable). + // Unstable availableModels (fallback — skip duplicates from stable). let mut agent_default_model: Option = None; if let Some(unstable) = raw.get("unstable") { agent_default_model = unstable["currentModelId"].as_str().map(str::to_string); diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 2317930c1e..1d2db0e8be 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -4,9 +4,8 @@ use tauri::{AppHandle, Manager, State}; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, current_instance_id, find_managed_agent_mut, - load_managed_agents, load_personas, save_managed_agents, sync_managed_agent_processes, - ManagedAgentSummary, + build_managed_agent_summary, current_instance_id, load_personas, mutate_agent_store, + sync_managed_agent_processes, ManagedAgentSummary, }, util::now_iso, }; @@ -26,44 +25,75 @@ pub async fn set_managed_agent_start_on_app_launch( ) -> Result { tokio::task::spawn_blocking(move || { let state = app.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; + let instance_id = current_instance_id(&app); + let personas = load_personas(&app).unwrap_or_default(); + let global_config = + crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); - } - - { - let record = find_managed_agent_mut(&mut records, &pubkey)?; - record.start_on_app_launch = start_on_app_launch; - record.updated_at = now_iso(); + let app_for_closure = app.clone(); + let pubkey_for_closure = pubkey.clone(); + let ((summary, exited_pubkeys), _guard) = + mutate_agent_store(&app, store_guard, move |mut instances, journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + let record = instances + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + // Journal: record update + CAS generation for this agent key. + let op_id = crate::managed_agents::store_journal::new_operation_id(); + let (current_gen, _) = crate::managed_agents::store_journal::read_generation( + journal, + &pubkey_for_closure, + )?; + crate::managed_agents::store_journal::insert_operation( + journal, + &op_id, + "update", + &pubkey_for_closure, + current_gen, + )?; + match crate::managed_agents::store_journal::cas_generation( + journal, + &pubkey_for_closure, + current_gen, + )? { + crate::managed_agents::store_journal::CasOutcome::Committed { .. } => {} + crate::managed_agents::store_journal::CasOutcome::Tombstoned { .. } => { + return Err(format!( + "agent {pubkey_for_closure}: tombstoned — cannot update settings" + )); + } + crate::managed_agents::store_journal::CasOutcome::Conflict { current } => { + return Err(format!( + "agent {pubkey_for_closure}: generation conflict (expected {}, current {})", + current_gen.0, current.0 + )); + } + } + record.start_on_app_launch = start_on_app_launch; + record.updated_at = now_iso(); + let summary = build_managed_agent_summary( + &app_for_closure, + record, + &runtimes, + &personas, + &global_config, + )?; + Ok((instances, (summary, exited))) + })?; + for pk in &exited_pubkeys { + state.clear_agent_session_caches(pk); } - - save_managed_agents(&app, &records)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary( - &app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), - ) + Ok(summary) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -77,44 +107,75 @@ pub async fn set_managed_agent_auto_restart( ) -> Result { tokio::task::spawn_blocking(move || { let state = app.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; + let instance_id = current_instance_id(&app); + let personas = load_personas(&app).unwrap_or_default(); + let global_config = + crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); - } - - { - let record = find_managed_agent_mut(&mut records, &pubkey)?; - record.auto_restart_on_config_change = auto_restart_on_config_change; - record.updated_at = now_iso(); + let app_for_closure = app.clone(); + let pubkey_for_closure2 = pubkey.clone(); + let ((summary, exited_pubkeys), _guard) = + mutate_agent_store(&app, store_guard, move |mut instances, journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + let record = instances + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + // Journal: record update + CAS generation for this agent key. + let op_id = crate::managed_agents::store_journal::new_operation_id(); + let (current_gen, _) = crate::managed_agents::store_journal::read_generation( + journal, + &pubkey_for_closure2, + )?; + crate::managed_agents::store_journal::insert_operation( + journal, + &op_id, + "update", + &pubkey_for_closure2, + current_gen, + )?; + match crate::managed_agents::store_journal::cas_generation( + journal, + &pubkey_for_closure2, + current_gen, + )? { + crate::managed_agents::store_journal::CasOutcome::Committed { .. } => {} + crate::managed_agents::store_journal::CasOutcome::Tombstoned { .. } => { + return Err(format!( + "agent {pubkey_for_closure2}: tombstoned — cannot update settings" + )); + } + crate::managed_agents::store_journal::CasOutcome::Conflict { current } => { + return Err(format!( + "agent {pubkey_for_closure2}: generation conflict (expected {}, current {})", + current_gen.0, current.0 + )); + } + } + record.auto_restart_on_config_change = auto_restart_on_config_change; + record.updated_at = now_iso(); + let summary = build_managed_agent_summary( + &app_for_closure, + record, + &runtimes, + &personas, + &global_config, + )?; + Ok((instances, (summary, exited))) + })?; + for pk in &exited_pubkeys { + state.clear_agent_session_caches(pk); } - - save_managed_agents(&app, &records)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary( - &app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), - ) + Ok(summary) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? diff --git a/desktop/src-tauri/src/commands/agent_update_rollback.rs b/desktop/src-tauri/src/commands/agent_update_rollback.rs index 2745b3cd22..e729e0563e 100644 --- a/desktop/src-tauri/src/commands/agent_update_rollback.rs +++ b/desktop/src-tauri/src/commands/agent_update_rollback.rs @@ -2,9 +2,7 @@ use tauri::AppHandle; use crate::{ app_state::AppState, - managed_agents::{ - load_managed_agents, save_managed_agents, try_regenerate_nest, ManagedAgentRecord, - }, + managed_agents::{mutate_agent_store, try_regenerate_nest, ManagedAgentRecord}, }; #[derive(Debug)] @@ -79,18 +77,24 @@ pub(super) fn rollback_failed_agent_update( rollback: AgentUpdateRollback, ) -> Result<(), String> { { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(app)?; - restore_agent_update(&mut records, pubkey, rollback)?; - save_managed_agents(app, &records)?; - let restored = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found after failed rename rollback"))?; - super::agents::retain_managed_agent_pending(app, state, restored); + let pubkey = pubkey.to_owned(); + let (restored, _guard) = + mutate_agent_store(app, store_guard, move |mut instances, _journal| { + restore_agent_update(&mut instances, &pubkey, rollback)?; + let restored = instances + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| { + format!("agent {pubkey} not found after failed rename rollback") + })? + .clone(); + Ok((instances, restored)) + })?; + super::agents::retain_managed_agent_pending(app, state, &restored, None); } try_regenerate_nest(app); Ok(()) diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b114b0474..a18bd0bf46 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,8 +6,8 @@ use crate::{ managed_agents::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, + load_teams, managed_agent_avatar_url, mutate_agent_store, normalize_agent_args, + provider_deploy, resolve_provider_binary, start_managed_agent_process, stop_managed_agent_process, stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, @@ -25,181 +25,14 @@ pub(super) fn workspace_owner_hex(state: &AppState) -> Result { Ok(keys.public_key().to_hex()) } -/// Retain a freshly authored managed-agent event in the local store, flagged -/// for relay sync. MUST be called inside the `managed_agents_store_lock`-held -/// body after `save_managed_agents`, NEVER across an `.await`: it acquires -/// `state.keys` and a retention-db connection, both `std::sync` guards, and -/// drops them before returning. -/// -/// Owner-authored, mirroring `commands::personas::retain_persona_pending`: the -/// owner keys sign, the d_tag is the agent's pubkey, so the coordinate is -/// `30177::`. The event content is the opt-IN -/// [`agent_event_content`] projection — the retention upsert's content-equality -/// guard compares this projection, so an operational start/stop that mutates -/// only runtime fields produces an identical row and never re-enqueues a -/// publish. Best-effort: a failure here is logged and swallowed so a retention -/// hiccup never blocks the disk-authoritative write. -pub(super) fn retain_managed_agent_pending( - app: &AppHandle, - state: &AppState, - record: &ManagedAgentRecord, -) { - use crate::managed_agents::{reconcile::retain_agent_record, retention::open_retention_db}; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let conn = open_retention_db(&scope.db_path)?; - // Shared engine with the boot-time reconcile: projection content diff - // (no republish for runtime-only churn) + monotonic created_at bump - // past the retained head (NIP-AP step 3). - retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ()) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: agent-retain: {e}"); - } -} - -/// Purge a deleted agent's pending row and enqueue a NIP-09 tombstone, both -/// inside the `managed_agents_store_lock`-held delete body and NEVER across an -/// `.await`. -/// -/// Mirrors `commands::personas::tombstone_persona_pending`: the agent row at -/// `(30177, owner, agent_pubkey)` is purged first so an unpublished edit can -/// never resurrect it after the tombstone publishes, then the kind:5 tombstone -/// is retained at its own `(5, owner, agent_pubkey)` coordinate with -/// `pending_sync = 1`. The `d_tag` is the agent's pubkey. Best-effort: a -/// failure is logged and swallowed so a retention hiccup never blocks the -/// disk-authoritative delete. -pub(super) fn tombstone_managed_agent_pending( - app: &AppHandle, - state: &AppState, - agent_pubkey: &str, -) { - use crate::managed_agents::{ - agent_events::build_agent_delete, - retention::{ - delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, - }, - }; - use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; - - const KIND_DELETE: u32 = 5; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_agent_delete(agent_pubkey, &owner_pubkey)? - .sign_with_keys(&scope.owner_keys) - .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; - let conn = open_retention_db(&scope.db_path)?; - delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_DELETE, - pubkey: owner_pubkey, - // Key by the target coordinate so cross-kind d-tag tombstones - // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: agent-tombstone: {e}"); - } -} - -/// Build and sign the NIP-IA `kind:9035` archive request enqueued when an -/// agent is deleted. Pure given the keys — unit-testable without an -/// `AppHandle`. Reuses the same wire builder as the GUI's Archive action -/// (`events::build_archive_identity_request`); the machine-readable reason is -/// `retired` (NIP-IA suggested code for a deliberately decommissioned key). -/// -/// The owner auth tag is minted locally from the same keys used to sign the -/// request, avoiding a network fetch while the managed-agent store lock is -/// held. The relay still independently verifies it against the agent's live -/// kind:0. -pub(super) fn build_agent_archive_request( - keys: &nostr::Keys, - agent_pubkey: &str, -) -> Result { - let auth_tag = if keys - .public_key() - .to_hex() - .eq_ignore_ascii_case(agent_pubkey) - { - None - } else { - let agent = nostr::PublicKey::from_hex(agent_pubkey) - .map_err(|e| format!("invalid agent pubkey: {e}"))?; - let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "") - .map_err(|e| format!("failed to build owner auth tag: {e}"))?; - let parts: Vec = serde_json::from_str(&tag_json) - .map_err(|e| format!("failed to parse owner auth tag: {e}"))?; - Some( - <[String; 4]>::try_from(parts) - .map_err(|_| "owner auth tag must have four elements".to_string())?, - ) - }; - crate::events::build_archive_identity_request( - agent_pubkey, - "", - Some("retired"), - None, - auth_tag.as_ref(), - )? - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign archive request: {e}")) -} - -/// Enqueue a NIP-IA `kind:9035` archive request for a deleted agent, retained -/// next to its kind:5 tombstone with `pending_sync = 1`. -/// -/// The tombstone removes the agent's 30177 record cross-device, but the -/// agent's `kind:0` and channel membership keep populating member pickers and -/// autocomplete on the relay until the identity is archived. Retaining the -/// request here gives archival the same offline durability as the tombstone; -/// the flush loop is the sole publisher and re-signs the request with a fresh -/// `created_at` at publish time, because the relay enforces a ±120s freshness -/// window on 9035s. -/// -/// Same contract as `tombstone_managed_agent_pending`: called inside the -/// `managed_agents_store_lock`-held delete body, never across an `.await`, -/// best-effort — a failure is logged and swallowed so it never blocks the -/// disk-authoritative delete. -pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, agent_pubkey: &str) { - use crate::managed_agents::retention::{open_retention_db, retain_event, RetainedEvent}; - use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST; - use nostr::JsonUtil; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let owner_pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey)?; - let conn = open_retention_db(&scope.db_path)?; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_IA_ARCHIVE_REQUEST, - pubkey: owner_pubkey, - d_tag: agent_pubkey.to_string(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: agent-archive: {e}"); - } -} +#[path = "agents_retain.rs"] +mod retain; +#[allow(unused_imports)] +// build_agent_archive_request is test-only; re-exported for agents_tests +pub(crate) use retain::{ + archive_managed_agent_pending, build_agent_archive_request, retain_managed_agent_pending, + tombstone_managed_agent_pending, +}; fn normalize_relay_mesh( config: Option<&RelayMeshConfig>, @@ -295,22 +128,29 @@ pub(super) async fn start_local_agent_pairs_with_preflight( ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?; { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let record = find_managed_agent_mut(&mut records, pubkey)?; + let pubkey_owned = pubkey.to_string(); let personas = load_personas(app).unwrap_or_default(); - if let Some(persona_id) = record.persona_id.clone() { - if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); - } - } - save_managed_agents(app, &records)?; - if let Some(saved_record) = records.iter().find(|record| record.pubkey == pubkey) { - retain_managed_agent_pending(app, state, saved_record); + let (saved_record_opt, _guard) = + mutate_agent_store(app, store_guard, move |mut instances, _journal| { + let record = find_managed_agent_mut(&mut instances, &pubkey_owned)?; + if let Some(persona_id) = record.persona_id.clone() { + if let Some(persona) = personas.iter().find(|persona| persona.id == persona_id) + { + crate::managed_agents::persona_events::apply_persona_snapshot( + record, persona, + ); + record.updated_at = crate::util::now_iso(); + } + } + let saved = instances.iter().find(|r| r.pubkey == pubkey_owned).cloned(); + Ok((instances, saved)) + })?; + if let Some(saved_record) = saved_record_opt { + retain_managed_agent_pending(app, state, &saved_record, None); } } @@ -395,53 +235,71 @@ pub(super) async fn start_local_agent_with_preflight( ); ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - if record.backend != BackendKind::Local { - return Err(format!("agent {pubkey} is no longer a local agent")); - } - // Re-snapshot the persona onto the record at every spawn so the agent always - // starts with the current persona config (system_prompt, model, provider, - // runtime). This clears the "out of date" drift badge without requiring a - // delete+recreate. See `apply_persona_snapshot` for the precedence and - // env-override self-heal rules. - // Load personas once: used for snapshot application below and summary build - // at the end — avoids a second disk read for the same file in the same call. + + // Load personas once outside the closure — no disk I/O inside the OS lock. let personas = load_personas(app).unwrap_or_default(); - if let Some(persona_id) = record.persona_id.clone() { - match personas.iter().find(|p| p.id == persona_id) { - Some(persona) => { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); + let personas_for_closure = personas.clone(); + let pubkey_for_closure = pubkey.to_owned(); + let owner_hex_for_closure = owner_hex.to_owned(); + + let (record_snapshot, _store_guard) = + mutate_agent_store(app, store_guard, move |mut instances, _journal| { + let record = find_managed_agent_mut(&mut instances, &pubkey_for_closure)?; + if record.backend != BackendKind::Local { + return Err(format!( + "agent {} is no longer a local agent", + pubkey_for_closure + )); } - None => { - return Err( - crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), - ); + // Re-snapshot the persona onto the record at every spawn so the agent + // always starts with the current persona config (system_prompt, model, + // provider, runtime). This clears the "out of date" drift badge without + // requiring a delete+recreate. + if let Some(persona_id) = record.persona_id.clone() { + match personas_for_closure.iter().find(|p| p.id == persona_id) { + Some(persona) => { + crate::managed_agents::persona_events::apply_persona_snapshot( + record, persona, + ); + record.updated_at = crate::util::now_iso(); + } + None => { + return Err( + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR + .to_string(), + ); + } + } } - } - } - start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?; - save_managed_agents(app, &records)?; - if let Some(saved_record) = records.iter().find(|r| r.pubkey == pubkey) { - retain_managed_agent_pending(app, state, saved_record); - } - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + start_managed_agent_process( + // SAFETY: `runtimes` is captured by move and the closure is FnOnce. + // The MutexGuard is valid for the duration of the closure. + app, + record, + &mut runtimes, + Some(&owner_hex_for_closure), + )?; + let snapshot = record.clone(); + Ok((instances, snapshot)) + })?; + + retain_managed_agent_pending(app, state, &record_snapshot, None); + let runtimes_guard = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; build_managed_agent_summary( app, - record, - &runtimes, + &record_snapshot, + &runtimes_guard, &personas, &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), ) @@ -486,32 +344,33 @@ async fn deploy_to_provider( .map_err(|e| format!("spawn_blocking failed: {e}"))?; // Persist result under lock. - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - let rec = records - .iter_mut() - .find(|r| r.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - - match deploy_result { - Ok(backend_agent_id) => { - rec.backend_agent_id = Some(backend_agent_id); - rec.last_started_at = Some(now_iso()); - rec.updated_at = now_iso(); - rec.last_error = None; - } - Err(ref e) => { - rec.last_error = Some(e.clone()); - rec.updated_at = now_iso(); - save_managed_agents(app, &records)?; - return Err(e.clone()); + let pubkey_owned = pubkey.to_string(); + let deploy_err = deploy_result.as_ref().err().cloned(); + mutate_agent_store(app, store_guard, move |mut instances, _journal| { + let rec = instances + .iter_mut() + .find(|r| r.pubkey == pubkey_owned) + .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; + match deploy_result { + Ok(backend_agent_id) => { + rec.backend_agent_id = Some(backend_agent_id); + rec.last_started_at = Some(now_iso()); + rec.updated_at = now_iso(); + rec.last_error = None; + } + Err(ref e) => { + rec.last_error = Some(e.clone()); + rec.updated_at = now_iso(); + } } - } - save_managed_agents(app, &records)?; - Ok(()) + Ok((instances, ())) + }) + .map(|_| ())?; + deploy_err.map_or(Ok(()), Err) } // Async so the blocking body (disk reads of agent/persona records, per-agent @@ -525,25 +384,31 @@ pub async fn list_managed_agents(app: AppHandle) -> Result(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } + let instance_id = current_instance_id(&app); + + let ((records, exited_pubkeys), _guard) = + mutate_agent_store(&app, store_guard, move |mut instances, _journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + let out = instances.clone(); + Ok((instances, (out, exited))) + })?; for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); } + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; let personas = load_personas(&app).unwrap_or_default(); // One disk read for the whole list — build_managed_agent_summary takes // the config as a parameter precisely so this poll-every-5s call does @@ -607,21 +472,23 @@ pub async fn create_managed_agent( // ── Phase 1: generate keys (sync lock) ──────────────────────────────────── let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } + let instance_id = current_instance_id(&app); + + let ((synced_instances, exited_pubkeys), _guard) = + mutate_agent_store(&app, store_guard, move |mut instances, _journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + let out = instances.clone(); + Ok((instances, (out, exited))) + })?; for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); } @@ -631,7 +498,10 @@ pub async fn create_managed_agent( } let keys = Keys::generate(); let pubkey = keys.public_key().to_hex(); - if records.iter().any(|record| record.pubkey == pubkey) { + if synced_instances + .iter() + .any(|record| record.pubkey == pubkey) + { return Err(format!("agent {pubkey} already exists")); } let private_key_nsec = keys @@ -678,30 +548,13 @@ pub async fn create_managed_agent( // ── Phase 3: save record (sync lock) ─────────────────────────────────────── let (agent, resolved_avatar_url) = { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; - - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); - } - // Guard against a duplicate pubkey appearing between phase 1 and phase 3 - // (extremely unlikely but safe to check). - if records.iter().any(|record| record.pubkey == pubkey) { - return Err(format!("agent {pubkey} already exists")); - } + // Duplicate pubkey guard + insert happen atomically inside the + // mutate_agent_store closure below; sync already ran in Phase 1. // Provider config was already validated in Pre-Phase 2; cache the discovered binary path for deploy_to_provider. let provider_binary_path = if let BackendKind::Provider { ref id, .. } = input.backend { // Use resolve_provider_binary (discovered candidates only). @@ -829,7 +682,7 @@ pub async fn create_managed_agent( linked_persona.as_ref(), )?; - let record = crate::managed_agents::ManagedAgentRecord { + let record = ManagedAgentRecord { pubkey: pubkey.clone(), name: name.clone(), persona_id: requested_persona_id.clone(), @@ -915,10 +768,75 @@ pub async fn create_managed_agent( }, }; - records.push(record); + // ── Keyring persistence: push nsec into OS keyring before store lock ── + // This restores the save-time chokepoint that exists in the old + // save_managed_agents path. If the keyring is healthy the nsec is + // written, read-back verified, and stripped from `record` before it + // enters the store closure. If the keyring is unavailable the key + // stays inline (file-fallback, same as main-branch behavior). + // Must happen BEFORE mutate_agent_store so keyring I/O never runs + // inside the critical section. + let mut record_for_save = record.clone(); + crate::managed_agents::storage::persist_agent_keys_pub(std::slice::from_mut( + &mut record_for_save, + )); + let record = record_for_save; + + // ── Journal wiring: record operation + generation CAS before saving ── + // Uses mutate_agent_store so insert_operation and cas_generation run + // inside the same OS-advisory-lock-held transaction as the file write. + let op_id = crate::managed_agents::store_journal::new_operation_id(); + let pubkey_for_closure = pubkey.clone(); + let op_id_for_closure = op_id.clone(); + let ((op_id_out,), _store_guard) = + mutate_agent_store(&app, store_guard, move |mut instances, journal| { + // Duplicate guard (between sync save and now). + if instances.iter().any(|r| r.pubkey == pubkey_for_closure) { + return Err(format!("agent {pubkey_for_closure} already exists (race)")); + } + // Journal: operation record (before any external effect). + crate::managed_agents::store_journal::insert_operation( + journal, + &op_id_for_closure, + "create", + &pubkey_for_closure, + crate::managed_agents::store_journal::Generation::zero(), + )?; + // Generation CAS: zero → one (new key). Reject tombstoned keys + // to prevent ABA resurrection of a deleted agent identity. + match crate::managed_agents::store_journal::cas_generation( + journal, + &pubkey_for_closure, + crate::managed_agents::store_journal::Generation::zero(), + )? { + crate::managed_agents::store_journal::CasOutcome::Committed { .. } => {} + crate::managed_agents::store_journal::CasOutcome::Tombstoned { + tombstone_generation, + } => { + return Err(format!( + "agent {pubkey_for_closure} pubkey is tombstoned at generation \ + {} — cannot recreate a deleted identity", + tombstone_generation.0 + )); + } + crate::managed_agents::store_journal::CasOutcome::Conflict { current } => { + return Err(format!( + "agent {pubkey_for_closure} generation conflict: expected 0, \ + current is {} — stale writer or duplicate key", + current.0 + )); + } + } + // Add the new agent instance. + instances.push(record); + Ok((instances, (op_id_for_closure,))) + })?; - save_managed_agents(&app, &records)?; + // The create op stays Pending until its outbox event is published by + // the flush loop and boot recovery advances it to Committed. + // Re-read the saved record for the response. + let records = load_managed_agents(&app)?; let record = records .iter() .find(|record| record.pubkey == pubkey) @@ -926,8 +844,12 @@ pub async fn create_managed_agent( // Publish the agent to the relay. Inside the Phase-3 lock, after save, // before any .await — owner-authored, every agent (Will's ruling: no // is_builtin/persona-membership gate). - retain_managed_agent_pending(&app, &state, record); + retain_managed_agent_pending(&app, &state, record, Some(&op_id_out)); let personas = load_personas(&app).unwrap_or_default(); + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; ( build_managed_agent_summary( &app, @@ -946,28 +868,31 @@ pub async fn create_managed_agent( match start_local_agent_with_preflight(&app, &state, &pubkey, &owner_hex, true).await { Ok(agent) => agent, Err(error) => { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; + let pubkey_owned = pubkey.clone(); + let error_msg = error.clone(); + let (record_snap, _guard) = + mutate_agent_store(&app, store_guard, move |mut instances, _journal| { + let record = find_managed_agent_mut(&mut instances, &pubkey_owned)?; + record.updated_at = now_iso(); + record.last_error = Some(error_msg); + let snap = instances.iter().find(|r| r.pubkey == pubkey_owned).cloned(); + Ok((instances, snap)) + })?; + spawn_error = Some(error); + let record = record_snap + .ok_or_else(|| "created agent disappeared unexpectedly".to_string())?; let runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - let record = find_managed_agent_mut(&mut records, &pubkey)?; - record.updated_at = now_iso(); - record.last_error = Some(error.clone()); - save_managed_agents(&app, &records)?; - spawn_error = Some(error); - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| "created agent disappeared unexpectedly".to_string())?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, - record, + &record, &runtimes, &personas, &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), @@ -1083,31 +1008,33 @@ pub async fn start_managed_agent( // Collect backend info under lock; async preflight/spawn happens below. // Also snapshot profile reconciliation data for the background task. let (target, reconcile_data) = { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; + let instance_id = current_instance_id(&app); + let pubkey_owned = pubkey.clone(); + let reconcile_personas = load_personas(&app).unwrap_or_default(); - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } + let ((records, exited_pubkeys), _guard) = + mutate_agent_store(&app, store_guard, move |mut instances, _journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + let out = instances.clone(); + Ok((instances, (out, exited))) + })?; for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); } - let record = find_managed_agent_mut(&mut records, &pubkey)?; - - // Resolve the effective harness for the avatar-fallback derivation in - // profile reconcile (the create-time snapshot may be empty or stale for - // a persona-inherited harness). - let reconcile_personas = load_personas(&app).unwrap_or_default(); + let record = records + .iter() + .find(|r| r.pubkey == pubkey_owned) + .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; let reconcile_effective_command = crate::managed_agents::record_agent_command(record, &reconcile_personas); @@ -1220,47 +1147,49 @@ pub async fn stop_managed_agent( use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_instance_id(&app)); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); - } - - { - let record = find_managed_agent_mut(&mut records, &pubkey)?; - // Remote agents are stopped via !shutdown @mention from the frontend, - // not via this backend command. Reject the call. - if record.backend != BackendKind::Local { - return Err( - "remote agents are stopped via !shutdown message, not this command".to_string(), - ); - } - // Pair-scoped: stops only the active workspace's pair; delete and - // the config-restart flows still drain every pair. - stop_managed_agent_workspace_pair(&app, record, &mut runtimes)?; + let instance_id = current_instance_id(&app); + let pubkey_owned = pubkey.clone(); + let app_for_stop = app.clone(); + + let ((record_snap, exited_pubkeys), _guard) = + mutate_agent_store(&app, store_guard, move |mut instances, _journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + let record = find_managed_agent_mut(&mut instances, &pubkey_owned)?; + // Remote agents are stopped via !shutdown @mention from the frontend, + // not via this backend command. Reject the call. + if record.backend != BackendKind::Local { + return Err( + "remote agents are stopped via !shutdown message, not this command" + .to_string(), + ); + } + // Pair-scoped: stops only the active workspace's pair; delete and + // the config-restart flows still drain every pair. + stop_managed_agent_workspace_pair(&app_for_stop, record, &mut runtimes)?; + let snap = instances.iter().find(|r| r.pubkey == pubkey_owned).cloned(); + Ok((instances, (snap, exited))) + })?; + for pk in &exited_pubkeys { + state.clear_agent_session_caches(pk); } - save_managed_agents(&app, &records)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; + let record = record_snap.ok_or_else(|| format!("agent {pubkey} not found"))?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; let personas = load_personas(&app).unwrap_or_default(); build_managed_agent_summary( &app, - record, + &record, &runtimes, &personas, &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), @@ -1282,66 +1211,114 @@ pub async fn delete_managed_agent( tokio::task::spawn_blocking(move || { let state = app.state::(); { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; + let instance_id = current_instance_id(&app); + let app_for_stop = app.clone(); - let (sync_changed, exited_pubkeys) = sync_managed_agent_processes( - &mut records, - &mut runtimes, - ¤t_instance_id(&app), - ); - if sync_changed { - save_managed_agents(&app, &records)?; - } - for pubkey in &exited_pubkeys { - state.clear_agent_session_caches(pubkey); + let ((exited_pubkeys,), _sync_guard) = mutate_agent_store( + &app, + store_guard, + move |mut instances, _journal| { + let (_, exited) = + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + Ok((instances, (exited,))) + }, + )?; + for pk in &exited_pubkeys { + state.clear_agent_session_caches(pk); } + // Re-acquire the lock for the delete operation. + let store_guard2 = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut runtimes2 = state + .managed_agent_processes + .lock() + .map_err(|error| error.to_string())?; + // Guard: reject deletion of deployed remote agents unless explicitly forced. // This turns "don't orphan remote infra" from a UI convention into a backend // invariant — a buggy or compromised IPC caller cannot silently orphan a live // remote deployment. The frontend sends force_remote_delete: true only after // the user confirms the orphan warning. - if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) { - if record.backend != BackendKind::Local - && record.backend_agent_id.is_some() - && !force_remote_delete.unwrap_or(false) - { - return Err( - "cannot delete a deployed remote agent without force_remote_delete: true" - .to_string(), - ); - } - } - if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) { - stop_managed_agent_process(&app, record, &mut runtimes)?; - } + // Journal: tombstone before the file write (inside mutate_agent_store's + // advisory lock for atomicity). Read current generation first. + let op_id = crate::managed_agents::store_journal::new_operation_id(); + let pubkey_for_closure = pubkey.clone(); + let op_id_for_closure = op_id.clone(); + let ((), _store_guard) = + mutate_agent_store(&app, store_guard2, move |mut instances, journal| { + // Guard against orphan remote deployment. + if let Some(record) = instances.iter().find(|r| r.pubkey == pubkey_for_closure) { + if record.backend != BackendKind::Local + && record.backend_agent_id.is_some() + && !force_remote_delete.unwrap_or(false) + { + return Err( + "cannot delete a deployed remote agent without force_remote_delete: true" + .to_string(), + ); + } + } + if let Some(record) = instances.iter_mut().find(|r| r.pubkey == pubkey_for_closure) { + stop_managed_agent_process(&app_for_stop, record, &mut runtimes2)?; + } + let initial_len = instances.len(); + instances.retain(|r| r.pubkey != pubkey_for_closure); + if instances.len() == initial_len { + return Err(format!("agent {pubkey_for_closure} not found")); + } + // Journal: record delete operation. + let (current_gen, _) = crate::managed_agents::store_journal::read_generation( + journal, + &pubkey_for_closure, + )?; + crate::managed_agents::store_journal::insert_operation( + journal, + &op_id_for_closure, + "delete", + &pubkey_for_closure, + current_gen, + )?; + // Tombstone the key. + crate::managed_agents::store_journal::tombstone_key( + journal, + &pubkey_for_closure, + current_gen, + )?; + Ok((instances, ())) + })?; + state.clear_agent_session_caches(&pubkey); - let initial_len = records.len(); - records.retain(|record| record.pubkey != pubkey); - if records.len() == initial_len { - return Err(format!("agent {pubkey} not found")); - } - save_managed_agents(&app, &records)?; + + // Advance to committed after the file write. + crate::managed_agents::store_journal::advance_to_committed(&app, &op_id); + // Remove the agent's nsec from the keyring after the record is gone. crate::managed_agents::delete_agent_key(&pubkey); // Tombstone-after-validation: only reached past the deployed-remote // guard above and a confirmed removal — never orphan a live remote // deployment's relay record. Inside the lock, before the block closes // (no .await here). Every agent published, so every delete tombstones. - tombstone_managed_agent_pending(&app, &state, &pubkey); + // Propagate failure: a deleted agent has no boot-reconcile fallback + // for its tombstone — without outbox evidence the tombstone is lost. + tombstone_managed_agent_pending(&app, &state, &pubkey) + .map_err(|e| format!("agent deleted but tombstone failed — relay record may persist: {e}"))?; // NIP-IA: archive the deleted agent's identity on the relay so it - // stops appearing in member pickers and autocomplete. Same - // best-effort, inside-the-lock contract as the tombstone above. - archive_managed_agent_pending(&app, &state, &pubkey); + // stops appearing in member pickers and autocomplete. + // Also propagate: no reconcile fallback for archive events. + archive_managed_agent_pending(&app, &state, &pubkey) + .map_err(|e| format!("agent deleted but archive failed: {e}"))?; } try_regenerate_nest(&app); Ok(()) diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 0675d4c48f..f27b23db8f 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -115,15 +115,21 @@ pub(crate) async fn reconcile_agent_profile( // Persist the backfilled avatar so this migration only runs once. if !backfilled.is_empty() { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(app)?; - if let Some(record) = records.iter_mut().find(|r| r.pubkey == data.pubkey) { - record.avatar_url = Some(backfilled.clone()); - save_managed_agents(app, &records)?; - } + let backfilled_clone = backfilled.clone(); + let pubkey_clone = data.pubkey.clone(); + let _ = crate::managed_agents::mutate_managed_agent( + app, + store_guard, + &pubkey_clone, + move |record, _journal| { + record.avatar_url = Some(backfilled_clone); + Ok(()) + }, + ); } backfilled diff --git a/desktop/src-tauri/src/commands/agents_retain.rs b/desktop/src-tauri/src/commands/agents_retain.rs new file mode 100644 index 0000000000..c950cefb9f --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_retain.rs @@ -0,0 +1,259 @@ +//! Retention, tombstone, and archive helpers for managed agents. +//! +//! All top-level helpers are best-effort at the outer boundary: failures are +//! logged so a retention or journal hiccup never blocks the disk-authoritative +//! write. However, INTERNALLY all evidence is established in a strict order: +//! +//! 1. Build the event and compare against the retained head (no DB write yet). +//! 2. Insert outbox evidence (journal) first — `?`-propagated so a collision +//! or journal failure aborts the entire retain. +//! 3. Only after outbox evidence exists, insert the retention row with +//! `pending_sync = true`. +//! +//! This order guarantees that the flush loop never publishes a row that has no +//! journal evidence: if we crash after (2) but before (3), boot recovery finds +//! the pending outbox row and reconcile re-inserts the retention row. If we +//! crash after (3), the retention row is already flushable and has its outbox +//! evidence counterpart. +//! +//! `IdentityCollision` from `insert_outbox_event` is treated as `Err` — it is +//! never silently discarded with `?`. + +use tauri::AppHandle; + +use crate::{app_state::AppState, managed_agents::ManagedAgentRecord}; + +/// Retain a freshly authored managed-agent event, flagged for relay sync. +/// +/// Owner-authored: the owner keys sign, d_tag is the agent's pubkey, coordinate +/// is `30177::`. The retention content-equality guard +/// compares the opt-IN `agent_event_content` projection, so a runtime-only +/// mutation produces an identical row and never re-enqueues a publish. +/// +/// `op_id` is the journal operation ID from the preceding `mutate_agent_store` +/// call. When `None`, a publication-only operation is created. +/// +/// Internal ordering: outbox journal row is written **before** the retention +/// row becomes flushable. Errors at either step propagate and the outer helper +/// logs-and-swallows so a hiccup never blocks the disk-authoritative write. +pub(crate) fn retain_managed_agent_pending( + app: &AppHandle, + state: &AppState, + record: &ManagedAgentRecord, + op_id: Option<&str>, +) { + use crate::managed_agents::{ + reconcile::build_agent_event_if_changed, retention::open_retention_db, + }; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; + + // Step 1: build the event and compare — returns None when content is + // unchanged (true no-op: no publish needed). + let Some((event, owner_pubkey)) = + build_agent_event_if_changed(&conn, &scope.owner_keys, record)? + else { + return Ok(()); + }; + + let event_id = event.id.to_hex(); + let raw_json = event.as_json(); + + // Steps 2 + 3: prepare_publication atomically inserts outbox evidence + // first, then the retention row — IdentityCollision → Err. + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor) + .map_err(|e| format!("create anchor dir for outbox: {e}"))?; + let journal = crate::managed_agents::store_journal::open_journal(&anchor)?; + + let pub_op_id: String; + let effective_op_id: &str = match op_id { + Some(id) => id, + None => { + pub_op_id = crate::managed_agents::store_journal::new_operation_id(); + crate::managed_agents::store_journal::insert_operation( + &journal, + &pub_op_id, + "publish", + &record.pubkey, + crate::managed_agents::store_journal::Generation::zero(), + )?; + &pub_op_id + } + }; + + crate::managed_agents::store_journal::prepare_publication( + &journal, + &conn, + effective_op_id, + &event_id, + &raw_json, + buzz_core_pkg::kind::KIND_MANAGED_AGENT, + &owner_pubkey, + &record.pubkey, + &event.content, + event.created_at.as_secs() as i64, + ) + })(); + if let Err(e) = result { + // Update-retain failures are logged and swallowed: the agent record is + // already durably written to the JSON store, and boot reconcile + // (`reconcile_agents_to_events`) will re-enqueue the publication on + // next launch. This is the accepted boot-reconcile-recoverable contract + // for live-content updates (Thufir pass-3 review: "acceptable for updates, + // not deletes"). Tombstones/archives use propagating Result instead. + eprintln!("buzz-desktop: agent-retain: {e}"); + } +} + +/// Purge a deleted agent's pending row and enqueue a NIP-09 tombstone. +/// +/// Called inside the `managed_agents_store_lock`-held delete body. Returns +/// `Err` if journal outbox evidence cannot be established — callers should +/// propagate the error since without outbox evidence there is no durable retry +/// source for the tombstone (a deleted agent has no boot-reconcile fallback). +pub(crate) fn tombstone_managed_agent_pending( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) -> Result<(), String> { + use crate::managed_agents::{ + agent_events::build_agent_delete, + retention::{delete_retained_event, open_retention_db, tombstone_retention_d_tag}, + }; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::JsonUtil; + + const KIND_DELETE: u32 = 5; + + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_delete(agent_pubkey, &owner_pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; + let event_id = event.id.to_hex(); + let raw_json = event.as_json(); + let d_tag = tombstone_retention_d_tag(KIND_MANAGED_AGENT, agent_pubkey); + + // Open journal and retention DB before any writes. + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor) + .map_err(|e| format!("create anchor dir for tombstone outbox: {e}"))?; + let journal = crate::managed_agents::store_journal::open_journal(&anchor)?; + let pub_op_id = crate::managed_agents::store_journal::new_operation_id(); + crate::managed_agents::store_journal::insert_operation( + &journal, + &pub_op_id, + "tombstone", + agent_pubkey, + crate::managed_agents::store_journal::Generation::zero(), + )?; + + // Delete the existing retention row, then use prepare_publication to + // establish outbox evidence before the tombstone retention row is flushable. + let conn = open_retention_db(&scope.db_path)?; + delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; + crate::managed_agents::store_journal::prepare_publication( + &journal, + &conn, + &pub_op_id, + &event_id, + &raw_json, + KIND_DELETE, + &owner_pubkey, + &d_tag, + &event.content, + event.created_at.as_secs() as i64, + ) +} + +/// Build and sign the NIP-IA `kind:9035` archive request for a deleted agent. +/// +/// Pure given the keys — unit-testable without an `AppHandle`. Uses `retired` +/// as the machine-readable reason (NIP-IA suggested code for a decommissioned +/// key). The owner auth tag is minted locally from the same keys. +pub(crate) fn build_agent_archive_request( + keys: &nostr::Keys, + agent_pubkey: &str, +) -> Result { + let auth_tag = if keys + .public_key() + .to_hex() + .eq_ignore_ascii_case(agent_pubkey) + { + None + } else { + let agent = nostr::PublicKey::from_hex(agent_pubkey) + .map_err(|e| format!("invalid agent pubkey: {e}"))?; + let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(keys, &agent, "") + .map_err(|e| format!("failed to build owner auth tag: {e}"))?; + let parts: Vec = serde_json::from_str(&tag_json) + .map_err(|e| format!("failed to parse owner auth tag: {e}"))?; + Some( + <[String; 4]>::try_from(parts) + .map_err(|_| "owner auth tag must have four elements".to_string())?, + ) + }; + crate::events::build_archive_identity_request( + agent_pubkey, + "", + Some("retired"), + None, + auth_tag.as_ref(), + )? + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign archive request: {e}")) +} + +/// Enqueue a NIP-IA `kind:9035` archive request for a deleted agent. +/// +/// The tombstone removes the agent's 30177 record cross-device; the archive +/// request stops the agent's `kind:0` and channel memberships appearing in +/// member pickers. Called inside the lock-held delete body. Returns `Err` if +/// journal evidence cannot be established. +pub(crate) fn archive_managed_agent_pending( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) -> Result<(), String> { + use crate::managed_agents::retention::open_retention_db; + use buzz_core_pkg::kind::KIND_IA_ARCHIVE_REQUEST; + use nostr::JsonUtil; + + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey)?; + let event_id = event.id.to_hex(); + let raw_json = event.as_json(); + + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor) + .map_err(|e| format!("create anchor dir for archive outbox: {e}"))?; + let journal = crate::managed_agents::store_journal::open_journal(&anchor)?; + let pub_op_id = crate::managed_agents::store_journal::new_operation_id(); + crate::managed_agents::store_journal::insert_operation( + &journal, + &pub_op_id, + "archive", + agent_pubkey, + crate::managed_agents::store_journal::Generation::zero(), + )?; + + // prepare_publication: outbox evidence first, then retention row. + let conn = open_retention_db(&scope.db_path)?; + crate::managed_agents::store_journal::prepare_publication( + &journal, + &conn, + &pub_op_id, + &event_id, + &raw_json, + KIND_IA_ARCHIVE_REQUEST, + &owner_pubkey, + agent_pubkey, + &event.content, + event.created_at.as_secs() as i64, + ) +} diff --git a/desktop/src-tauri/src/commands/global_agent_config.rs b/desktop/src-tauri/src/commands/global_agent_config.rs index 91219bafb9..598cf3fb3e 100644 --- a/desktop/src-tauri/src/commands/global_agent_config.rs +++ b/desktop/src-tauri/src/commands/global_agent_config.rs @@ -16,11 +16,11 @@ use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - agent_readiness, current_instance_id, find_managed_agent_mut, known_acp_runtime, - load_global_agent_config, load_managed_agents, load_personas, record_agent_command, - resolve_effective_agent_env, save_global_agent_config, save_managed_agents, - stop_managed_agent_process, sync_managed_agent_processes, validate_global_config, - AgentReadiness, BackendKind, GlobalAgentConfig, + agent_readiness, current_instance_id, known_acp_runtime, load_global_agent_config, + load_managed_agents, load_personas, mutate_agent_store, record_agent_command, + resolve_effective_agent_env, save_global_agent_config, stop_managed_agent_process, + sync_managed_agent_processes, validate_global_config, AgentReadiness, BackendKind, + GlobalAgentConfig, }, }; @@ -262,72 +262,75 @@ async fn restart_local_agent_on_config_change( use tauri::Manager; let state = app_for_stop.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| format!("failed to acquire store lock: {e}"))?; - let mut records = load_managed_agents(&app_for_stop)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| format!("failed to acquire runtimes lock: {e}"))?; - - // Sync process state so PID liveness reflects current reality. - let (sync_changed, _) = sync_managed_agent_processes( - &mut records, - &mut runtimes, - ¤t_instance_id(&app_for_stop), - ); - if sync_changed { - save_managed_agents(&app_for_stop, &records)?; - } - - // Re-check eligibility under lock with current record state. - let record = records - .iter() - .find(|r| r.pubkey == pubkey_owned) - .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; - - if record.backend != BackendKind::Local { - return Err(format!("agent {pubkey_owned} is no longer a local agent")); - } - let runtime_keys = - crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned); - if runtime_keys.is_empty() { - return Err(format!( - "agent {pubkey_owned} no longer has a live pair runtime after sync" - )); - } - - // Re-check the eligibility predicate under lock: - // (old NotReady && new Ready) OR (old Ready && env changed) - // TODO: busy/mid-turn deferral would slot in here - // - // Reuse personas_snapshot from Phase 1 — avoids loading personas again - // per agent when the save-command personas haven't changed. - let effective_cmd = record_agent_command(record, &personas_owned); - let runtime_meta = known_acp_runtime(&effective_cmd); - let old_effective = - resolve_effective_agent_env(record, &personas_owned, runtime_meta, &old_global_clone); - let new_effective = - resolve_effective_agent_env(record, &personas_owned, runtime_meta, &new_global_clone); - let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready); - let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready); - // Under lock, the alive check was already done above via process_is_running. - let env_changed = old_ready && old_effective.env != new_effective.env; - if !should_restart_on_config_change(old_ready, new_ready, env_changed) { - return Err(format!( - "agent {pubkey_owned} restart condition no longer valid under lock" - )); - } - - // Stop the process. - let record_mut = find_managed_agent_mut(&mut records, &pubkey_owned)?; - stop_managed_agent_process(&app_for_stop, record_mut, &mut runtimes)?; - save_managed_agents(&app_for_stop, &records)?; - - Ok(runtime_keys) + let instance_id = current_instance_id(&app_for_stop); + let app_for_stop_closure = app_for_stop.clone(); + + let (runtime_keys, _guard) = mutate_agent_store( + &app_for_stop, + store_guard, + move |mut instances, _journal| { + // Sync process state so PID liveness reflects current reality. + sync_managed_agent_processes(&mut instances, &mut runtimes, &instance_id); + + let record = instances + .iter() + .find(|r| r.pubkey == pubkey_owned) + .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; + + if record.backend != BackendKind::Local { + return Err(format!("agent {pubkey_owned} is no longer a local agent")); + } + let runtime_keys = + crate::managed_agents::managed_agent_runtime_keys(&runtimes, &pubkey_owned); + if runtime_keys.is_empty() { + return Err(format!( + "agent {pubkey_owned} no longer has a live pair runtime after sync" + )); + } + + let effective_cmd = record_agent_command(record, &personas_owned); + let runtime_meta = known_acp_runtime(&effective_cmd); + let old_effective = resolve_effective_agent_env( + record, + &personas_owned, + runtime_meta, + &old_global_clone, + ); + let new_effective = resolve_effective_agent_env( + record, + &personas_owned, + runtime_meta, + &new_global_clone, + ); + let old_ready = matches!(agent_readiness(&old_effective), AgentReadiness::Ready); + let new_ready = matches!(agent_readiness(&new_effective), AgentReadiness::Ready); + let env_changed = old_ready && old_effective.env != new_effective.env; + if !should_restart_on_config_change(old_ready, new_ready, env_changed) { + return Err(format!( + "agent {pubkey_owned} restart condition no longer valid under lock" + )); + } + + // Stop the process. + let record_mut = instances + .iter_mut() + .find(|r| r.pubkey == pubkey_owned) + .ok_or_else(|| format!("agent {pubkey_owned} not found"))?; + stop_managed_agent_process(&app_for_stop_closure, record_mut, &mut runtimes)?; + Ok((instances, runtime_keys)) + }, + )?; + + Ok::<_, String>(runtime_keys) }) .await; @@ -378,15 +381,18 @@ async fn restart_local_agent_on_config_change( fn persist_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), String> { use tauri::Manager; let state = app.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| format!("failed to acquire store lock: {e}"))?; - let mut records = load_managed_agents(app)?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - record.last_error = Some(error.to_string()); - record.updated_at = crate::util::now_iso(); - save_managed_agents(app, &records) + let pubkey = pubkey.to_owned(); + let error = error.to_owned(); + crate::managed_agents::mutate_managed_agent(app, store_guard, &pubkey, move |record, _j| { + record.last_error = Some(error); + record.updated_at = crate::util::now_iso(); + Ok(()) + }) + .map(|_| ()) } /// Pure predicate: should an agent be restarted given resolved readiness and diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index bddf2e725a..8da8e5d689 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -39,6 +39,9 @@ pub fn get_identity(state: State<'_, AppState>) -> Result let reset_failed = state .reset_failed .load(std::sync::atomic::Ordering::Acquire); + let store_recovery_failed = state + .store_recovery_failed + .load(std::sync::atomic::Ordering::Acquire); Ok(IdentityInfo { pubkey: pubkey_hex, @@ -47,6 +50,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result lost, locked, reset_failed, + store_recovery_failed, }) } @@ -382,6 +386,7 @@ pub async fn import_identity( lost: false, locked: false, reset_failed: false, + store_recovery_failed: false, }) }) .await @@ -515,6 +520,7 @@ pub async fn persist_current_identity( lost: false, locked: false, reset_failed: false, + store_recovery_failed: false, }) }) .await diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 528ca38767..136a14ab2c 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -52,7 +52,7 @@ fn save_mesh_sharing_config(app: &AppHandle, config: &MeshSharingConfig) -> Resu } let payload = serde_json::to_vec_pretty(config) .map_err(|error| format!("failed to encode mesh sharing config: {error}"))?; - crate::managed_agents::atomic_write_json(&path, &payload) + crate::managed_agents::store_journal::atomic_write_with_fsync(&path, &payload) } fn load_mesh_sharing_config(app: &AppHandle) -> Result, String> { diff --git a/desktop/src-tauri/src/commands/personas/create.rs b/desktop/src-tauri/src/commands/personas/create.rs index c00de1c6da..892078fab1 100644 --- a/desktop/src-tauri/src/commands/personas/create.rs +++ b/desktop/src-tauri/src/commands/personas/create.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use crate::{ app_state::AppState, managed_agents::{ - apply_persona_behavior, load_personas, save_personas, try_regenerate_nest, AgentDefinition, + apply_persona_behavior, mutate_persona_store, try_regenerate_nest, AgentDefinition, CatalogSource, CreatePersonaRequest, }, util::now_iso, @@ -38,12 +38,10 @@ pub async fn create_persona( .map(CatalogSource::normalized) .transpose()?; let now = now_iso(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - pending::project_active_persona_sharing(&app, &state, &mut personas); let name_pool: Vec = input .name_pool .into_iter() @@ -51,31 +49,39 @@ pub async fn create_persona( .filter(|s| !s.is_empty()) .collect(); crate::managed_agents::validate_user_env_keys(&input.env_vars)?; - let mut persona = AgentDefinition { - id: Uuid::new_v4().to_string(), - display_name, - avatar_url, - system_prompt, - runtime, - model, - provider, - name_pool, - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source, - env_vars: input.env_vars, - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: now.clone(), - updated_at: now, - }; - apply_persona_behavior(&mut persona, input.behavior)?; - personas.push(persona.clone()); - save_personas(&app, &personas)?; + let env_vars = input.env_vars; + let behavior = input.behavior; + + let app_for_closure = app.clone(); + let (persona, _guard) = mutate_persona_store(&app, store_guard, move |mut personas| { + let state_c = app_for_closure.state::(); + pending::project_active_persona_sharing(&app_for_closure, &state_c, &mut personas); + let mut persona = AgentDefinition { + id: Uuid::new_v4().to_string(), + display_name, + avatar_url, + system_prompt, + runtime, + model, + provider, + name_pool, + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source, + env_vars, + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: now.clone(), + updated_at: now, + }; + apply_persona_behavior(&mut persona, behavior)?; + personas.push(persona.clone()); + Ok((personas, persona)) + })?; retain_persona_pending(&app, &state, &persona); try_regenerate_nest(&app); Ok(persona) diff --git a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs index 8ff7cfbd9b..cab2096aa9 100644 --- a/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs +++ b/desktop/src-tauri/src/commands/personas/delete_cascade_tests.rs @@ -150,7 +150,7 @@ fn failing_save_is_retry_safe() { ]; let cascade: HashSet = ["pk-a".to_string(), "pk-b".to_string()].into(); - let result = commit_cascade_agents(&mut agents, &cascade, |_| { + let result: Result<(), _> = commit_cascade_agents(&mut agents, &cascade, |_| { Err("simulated disk failure".to_string()) }); diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..ed85ab736a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -7,9 +7,9 @@ use tauri::{AppHandle, Emitter, Manager}; use crate::{ app_state::AppState, managed_agents::{ - agent_events::ManagedAgentEventContent, load_personas, persona_events::persona_d_tag, - save_personas, team_events::TeamEventContent, try_regenerate_nest, AgentDefinition, - ManagedAgentRecord, TeamRecord, + agent_events::ManagedAgentEventContent, persona_events::persona_d_tag, + team_events::TeamEventContent, try_regenerate_nest, AgentDefinition, ManagedAgentRecord, + TeamRecord, }, util::now_iso, }; @@ -71,10 +71,10 @@ fn reconcile_inbound_persona_event_blocking( ) -> Result<(), String> { use crate::managed_agents::{ agent_events::managed_agent_content_from_event, - load_managed_agents, load_teams, + mutate_agent_store, mutate_persona_store, persona_events::persona_from_event, retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, - save_managed_agents, save_teams, + storage::mutate_team_store, team_events::team_content_from_event, }; use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; @@ -113,7 +113,7 @@ fn reconcile_inbound_persona_event_blocking( None => event_d_tag(&event)?, }; - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; @@ -149,27 +149,27 @@ fn reconcile_inbound_persona_event_blocking( match kind { KIND_PERSONA => { - let mut personas = load_personas(&app)?; - // `inbound_persona` is `Some` for KIND_PERSONA (set above). - apply_inbound_persona( - &mut personas, - inbound_persona.expect("persona parsed above"), - ); - save_personas(&app, &personas)?; + let inbound_def = inbound_persona.expect("persona parsed above"); + let ((), _guard) = mutate_persona_store(&app, store_guard, move |mut defs| { + apply_inbound_persona(&mut defs, inbound_def); + Ok((defs, ())) + })?; } KIND_TEAM => { - let mut teams = load_teams(&app)?; - apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?); - save_teams(&app, &teams)?; + let d_tag_for_closure = d_tag.clone(); + let team_content = team_content_from_event(&event)?; + let ((), _guard) = mutate_team_store(&app, store_guard, move |mut teams, _journal| { + apply_inbound_team(&mut teams, d_tag_for_closure, team_content); + Ok((teams, ())) + })?; } KIND_MANAGED_AGENT => { - let mut agents = load_managed_agents(&app)?; - apply_inbound_managed_agent( - &mut agents, - &d_tag, - managed_agent_content_from_event(&event)?, - ); - save_managed_agents(&app, &agents)?; + let d_tag_for_closure = d_tag.clone(); + let content = managed_agent_content_from_event(&event)?; + let ((), _guard) = mutate_agent_store(&app, store_guard, move |mut instances, _j| { + apply_inbound_managed_agent(&mut instances, &d_tag_for_closure, content); + Ok((instances, ())) + })?; } _ => unreachable!("kind gated above"), } @@ -237,12 +237,12 @@ fn reconcile_inbound_tombstone( state: &AppState, ) -> Result<(), String> { use crate::managed_agents::{ - load_managed_agents, load_teams, + mutate_agent_store, mutate_persona_store, retention::{ open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, RetainedEvent, }, - save_managed_agents, save_teams, + storage::mutate_team_store, }; use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; use nostr::JsonUtil; @@ -254,7 +254,7 @@ fn reconcile_inbound_tombstone( return Ok(()); // deletion for a kind we don't track locally } - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; @@ -290,19 +290,25 @@ fn reconcile_inbound_tombstone( // use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`. match target_kind { KIND_PERSONA => { - let mut personas = load_personas(app)?; - personas.retain(|record| persona_d_tag(record) != target_d_tag); - save_personas(app, &personas)?; + let target_d_tag_for_closure = target_d_tag.clone(); + let ((), _guard) = mutate_persona_store(app, store_guard, move |mut defs| { + defs.retain(|record| persona_d_tag(record) != target_d_tag_for_closure); + Ok((defs, ())) + })?; } KIND_TEAM => { - let mut teams = load_teams(app)?; - teams.retain(|record| record.id != target_d_tag); - save_teams(app, &teams)?; + let target_d_tag_for_closure = target_d_tag.clone(); + let ((), _guard) = mutate_team_store(app, store_guard, move |mut teams, _journal| { + teams.retain(|record| record.id != target_d_tag_for_closure); + Ok((teams, ())) + })?; } KIND_MANAGED_AGENT => { - let mut agents = load_managed_agents(app)?; - agents.retain(|record| record.pubkey != target_d_tag); - save_managed_agents(app, &agents)?; + let target_d_tag_for_closure = target_d_tag.clone(); + let ((), _guard) = mutate_agent_store(app, store_guard, move |mut instances, _j| { + instances.retain(|record| record.pubkey != target_d_tag_for_closure); + Ok((instances, ())) + })?; } _ => unreachable!("target kind gated above"), } diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 0cd7ad0324..fde753a034 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -4,7 +4,7 @@ use crate::{ app_state::AppState, managed_agents::{ current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams, - save_managed_agents, save_personas, stop_managed_agent_process, + mutate_agent_store, mutate_persona_store, stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change, validate_persona_deletion, AgentDefinition, ManagedAgentRecord, }, @@ -100,11 +100,16 @@ fn collect_remote_deployed( /// this function propagates it before the keyring deletions and tombstones that /// appear after the `?` in the call site — nothing is destroyed and the command /// is safe to retry. -fn commit_cascade_agents( +/// +/// Generic over `G` so callers that need the store guard returned by +/// `save_managed_agents` (which returns `Result`) can thread +/// it back; tests use `G = ()`. +#[cfg(test)] +fn commit_cascade_agents( agents: &mut Vec, cascade: &std::collections::HashSet, - save: impl FnOnce(&[ManagedAgentRecord]) -> Result<(), String>, -) -> Result<(), String> { + save: impl FnOnce(&[ManagedAgentRecord]) -> Result, +) -> Result { agents.retain(|a| !cascade.contains(&a.pubkey)); save(agents) } @@ -118,13 +123,13 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { { // Store lock held across all three phases. // Lock ordering: store lock (acquired here) → process lock (per-agent in Phase 2). - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; // Load and validate the persona before any destructive work. - let mut personas = load_personas(&app)?; + let personas = load_personas(&app)?; let persona = personas .iter() .find(|record| record.id == id) @@ -147,23 +152,38 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { // then released before Phase 2 stops). Every fallible read/lock is // here; an error leaves all state intact and the command is retryable. let mut agents = load_managed_agents(&app)?; - { + let sync_exited = { let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - let (sync_changed, exited_pubkeys) = sync_managed_agent_processes( + sync_managed_agent_processes( &mut agents, &mut runtimes, ¤t_instance_id(&app), - ); - if sync_changed { - save_managed_agents(&app, &agents)?; - } - for pk in &exited_pubkeys { - state.clear_agent_session_caches(pk); - } + ) // runtimes drops here (process lock released before Phase 2). + }; + // If sync detected exited processes, persist their exit state atomically. + let store_guard = if sync_exited.0 { + let synced = agents.clone(); + let ((), sg) = mutate_agent_store(&app, store_guard, move |mut instances, _j| { + // Apply the same exit-state updates to the freshly-decoded instances. + for updated in &synced { + if let Some(live) = instances.iter_mut().find(|r| r.pubkey == updated.pubkey) { + live.last_exit_code = updated.last_exit_code; + live.last_stopped_at = updated.last_stopped_at.clone(); + live.updated_at = updated.updated_at.clone(); + } + } + Ok((instances, ())) + })?; + sg + } else { + store_guard + }; + for pk in &sync_exited.1 { + state.clear_agent_session_caches(pk); } // Build the cascade set. HashSet for O(1) membership in Phase 3. @@ -209,37 +229,64 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { // ── Phase 3: Commit ───────────────────────────────────────────── // // Disk-authoritative writes first, side effects strictly after. - // commit_cascade_agents is an injectable seam so unit tests can - // verify retry-safety: a failing save propagates before any keyring - // deletion or tombstone occurs. - // // Failure semantics: // agent save fails → nothing destroyed; full cascade retries cleanly // persona save fails → cascade agents gone, persona survives; a retry // finds an empty cascade and proceeds cleanly // Keys and tombstones are enqueued only after their records leave disk. - if !cascade.is_empty() { - commit_cascade_agents(&mut agents, &cascade, |recs| { - save_managed_agents(&app, recs) - })?; - } + let store_guard = if !cascade.is_empty() { + let cascade_for_closure = cascade.clone(); + let ((), sg) = + mutate_agent_store(&app, store_guard, move |mut instances, journal| { + // Tombstone each cascaded agent pubkey in the journal before + // removing the record — mirrors the single-agent delete path. + for pubkey in &cascade_for_closure { + if pubkey.is_empty() { + continue; + } + let (current_gen, _) = + crate::managed_agents::store_journal::read_generation( + journal, + pubkey, + )?; + crate::managed_agents::store_journal::tombstone_key( + journal, + pubkey, + current_gen, + )?; + } + instances.retain(|a| !cascade_for_closure.contains(&a.pubkey)); + Ok((instances, ())) + })?; + sg + } else { + store_guard + }; - let original_len = personas.len(); - personas.retain(|record| record.id != id); - if personas.len() == original_len { - return Err(format!("persona {id} not found")); - } - save_personas(&app, &personas)?; + let id_for_closure = id.clone(); + let ((), _guard) = + mutate_persona_store(&app, store_guard, move |mut defs| { + let original_len = defs.len(); + defs.retain(|record| record.id != id_for_closure); + if defs.len() == original_len { + return Err(format!("persona {id_for_closure} not found")); + } + Ok((defs, ())) + })?; // Side effects — strictly after records leave disk. for pk in &cascade { state.clear_agent_session_caches(pk); // Remove nsec from keyring after the record is gone. delete_agent_key(pk); - super::agents::tombstone_managed_agent_pending(&app, &state, pk); - super::agents::archive_managed_agent_pending(&app, &state, pk); + // Propagate: no boot-reconcile fallback for tombstone/archive. + super::agents::tombstone_managed_agent_pending(&app, &state, pk) + .map_err(|e| format!("persona-delete: agent tombstone failed for {pk}: {e}"))?; + super::agents::archive_managed_agent_pending(&app, &state, pk) + .map_err(|e| format!("persona-delete: agent archive failed for {pk}: {e}"))?; } - tombstone_persona_pending(&app, &state, &d_tag); + tombstone_persona_pending(&app, &state, &d_tag) + .map_err(|e| format!("persona-delete: tombstone failed for {d_tag}: {e}"))?; // _store_guard drops here, before try_regenerate_nest. } @@ -261,10 +308,10 @@ pub async fn set_persona_active( use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; + + // Load and validate before acquiring the store lock — load_personas may + // call save_personas internally (built-in merge write-back) which also + // acquires the lock; acquiring the lock first would deadlock. let mut personas = load_personas(&app)?; let persona = personas .iter_mut() @@ -297,7 +344,22 @@ pub async fn set_persona_active( persona.updated_at = now_iso(); let updated = persona.clone(); - save_personas(&app, &personas)?; + + // Acquire the store lock for the write. load_personas above has already + // flushed the built-in merge if needed, so no re-entrant lock risk here. + let store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let id_for_closure = id.clone(); + let updated_at_for_closure = updated.updated_at.clone(); + let ((), _guard) = mutate_persona_store(&app, store_guard, move |mut defs| { + if let Some(def) = defs.iter_mut().find(|d| d.id == id_for_closure) { + def.is_active = active; + def.updated_at = updated_at_for_closure; + } + Ok((defs, ())) + })?; try_regenerate_nest(&app); Ok(updated) }) diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index cab5fababc..3307b8ec85 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -53,24 +53,64 @@ pub(in crate::commands) fn retain_persona_pending( /// exact share tag. The explicit share toggle passes `Some(shared)`. Returning /// the retained event lets that command immediately await relay acceptance /// without rebuilding or re-signing a different NIP-33 head. +/// +/// Internal ordering (B1 journal protocol): +/// 1. Build and sign the event (no DB write). +/// 2. Insert journal outbox evidence first — `?`-propagated. +/// 3. Insert the retention row via `prepare_publication` — `?`-propagated. pub(super) fn prepare_persona_publication( app: &AppHandle, state: &AppState, persona: &AgentDefinition, shared_override: Option, ) -> Result { + use crate::managed_agents::retention::open_retention_db; + use buzz_core_pkg::kind::KIND_PERSONA; + use nostr::JsonUtil; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let (event, retained, persona) = prepare_persona_publication_at( + + // Build and sign the event without writing to retention yet. + let (event, retained, scoped_persona) = build_persona_publication_event( &scope.db_path, &scope.owner_keys, persona, shared_override, )?; + + // B1 journal outbox: record immutable event identity before retention. + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor) + .map_err(|e| format!("create anchor dir for persona outbox: {e}"))?; + let journal = crate::managed_agents::store_journal::open_journal(&anchor)?; + let pub_op_id = crate::managed_agents::store_journal::new_operation_id(); + crate::managed_agents::store_journal::insert_operation( + &journal, + &pub_op_id, + "publish", + &retained.d_tag, + crate::managed_agents::store_journal::Generation::zero(), + )?; + + let conn = open_retention_db(&scope.db_path)?; + crate::managed_agents::store_journal::prepare_publication( + &journal, + &conn, + &pub_op_id, + &event.id.to_hex(), + &event.as_json(), + KIND_PERSONA, + &retained.pubkey, + &retained.d_tag, + &retained.content, + retained.created_at, + )?; + Ok(PreparedPersonaPublication { scope, event, retained, - persona, + persona: scoped_persona, }) } @@ -145,7 +185,14 @@ fn project_persona_sharing_at( Ok(()) } -pub(super) fn prepare_persona_publication_at( +/// Build and sign a persona event, constructing the `RetainedEvent` projection, +/// WITHOUT writing anything to the retention DB. +/// +/// Used by the production path (`prepare_persona_publication`) which wires +/// journal outbox evidence through `prepare_publication` before writing to +/// retention. The test-only path (`prepare_persona_publication_at`) calls +/// this then writes directly, bypassing the journal. +fn build_persona_publication_event( db_path: &std::path::Path, keys: &nostr::Keys, persona: &AgentDefinition, @@ -153,7 +200,7 @@ pub(super) fn prepare_persona_publication_at( ) -> Result<(nostr::Event, RetainedEvent, AgentDefinition), String> { use crate::managed_agents::{ persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + retention::{get_retained_event, open_retention_db, RetainedEvent}, }; use buzz_core_pkg::kind::KIND_PERSONA; use nostr::JsonUtil; @@ -180,6 +227,28 @@ pub(super) fn prepare_persona_publication_at( raw_event: event.as_json(), pending_sync: true, }; + Ok((event, retained, scoped_persona)) +} + +/// Build, sign, and retain a persona event in the given retention DB directly, +/// without journal outbox evidence. +/// +/// **Test-only path.** Production writes go through `prepare_persona_publication` +/// which wires journal outbox evidence via `prepare_publication` before writing +/// to retention. +#[cfg(test)] +pub(super) fn prepare_persona_publication_at( + db_path: &std::path::Path, + keys: &nostr::Keys, + persona: &AgentDefinition, + shared_override: Option, +) -> Result<(nostr::Event, RetainedEvent, AgentDefinition), String> { + use crate::managed_agents::retention::retain_event; + + let (event, retained, scoped_persona) = + build_persona_publication_event(db_path, keys, persona, shared_override)?; + // Test-only direct path: write retention without journal evidence. + let conn = crate::managed_agents::retention::open_retention_db(db_path)?; retain_event(&conn, &retained)?; Ok((event, retained, scoped_persona)) } @@ -196,51 +265,65 @@ pub(super) fn prepare_persona_publication_at( /// pubkey, d_tag)` (distinct from the purged persona row) with `pending_sync = /// 1`; the flush loop publishes it. Best-effort: a failure is logged and /// swallowed so a retention hiccup never blocks the disk-authoritative delete. +/// +/// Internal ordering (B1 journal protocol): outbox evidence inserted first via +/// `prepare_publication` before the retention row is set flushable. +/// Returns `Err` if journal outbox evidence cannot be established — callers +/// should log the error; there is no boot-reconcile fallback for tombstones. pub(in crate::commands) fn tombstone_persona_pending( app: &AppHandle, state: &AppState, d_tag: &str, -) { +) -> Result<(), String> { use crate::managed_agents::{ persona_events::build_persona_delete, - retention::{ - delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, - }, + retention::{delete_retained_event, open_retention_db, tombstone_retention_d_tag}, }; use buzz_core_pkg::kind::KIND_PERSONA; use nostr::JsonUtil; const KIND_DELETE: u32 = 5; - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_persona_delete(d_tag, &pubkey)? - .sign_with_keys(&scope.owner_keys) - .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; - let conn = open_retention_db(&scope.db_path)?; - // Purge the persona row first so an unpublished edit can never resurrect - // it after the tombstone publishes. - delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_DELETE, - pubkey, - // Key by the target coordinate so cross-kind d-tag tombstones - // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_PERSONA, d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: persona-tombstone: {e}"); - } + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_persona_delete(d_tag, &pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; + let event_id = event.id.to_hex(); + let raw_json = event.as_json(); + let tombstone_d_tag = tombstone_retention_d_tag(KIND_PERSONA, d_tag); + + let conn = open_retention_db(&scope.db_path)?; + // Purge the persona row first so an unpublished edit can never resurrect + // it after the tombstone publishes. + delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; + + // B1 journal outbox: record immutable tombstone identity before retention. + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor) + .map_err(|e| format!("create anchor dir for persona tombstone outbox: {e}"))?; + let journal = crate::managed_agents::store_journal::open_journal(&anchor)?; + let pub_op_id = crate::managed_agents::store_journal::new_operation_id(); + crate::managed_agents::store_journal::insert_operation( + &journal, + &pub_op_id, + "tombstone", + d_tag, + crate::managed_agents::store_journal::Generation::zero(), + )?; + + crate::managed_agents::store_journal::prepare_publication( + &journal, + &conn, + &pub_op_id, + &event_id, + &raw_json, + KIND_DELETE, + &pubkey, + &tombstone_d_tag, + &event.content, + event.created_at.as_secs() as i64, + ) } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e7bd1597e6..d5039d0521 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -23,6 +23,7 @@ use crate::{ }; pub(crate) mod import; +mod retain; // Re-export import-side commands so callers see a flat `snapshot::` namespace. pub use import::{confirm_agent_snapshot_import, preview_agent_snapshot_import}; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..bd1173c460 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -18,8 +18,7 @@ use crate::{ decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload, LOCKED_CARD_REFUSAL, }, - load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, - ManagedAgentRecord, RespondTo, + load_managed_agents, AgentDefinition, ManagedAgentRecord, RespondTo, }, relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -542,19 +541,11 @@ pub async fn confirm_agent_snapshot_import( // ── Phase 3a: create AgentDefinition + ManagedAgentRecord (sync lock) ────── let (persona, record) = { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut personas = load_personas(&app)?; - let mut records = load_managed_agents(&app)?; - - // Guard against duplicate pubkey (astronomically unlikely but safe). - if records.iter().any(|r| r.pubkey == pubkey) { - return Err(format!("generated pubkey {pubkey} already exists — retry")); - } - let now = now_iso(); let persona_id = uuid::Uuid::new_v4().to_string(); @@ -586,8 +577,16 @@ pub async fn confirm_agent_snapshot_import( updated_at: now.clone(), }; - personas.push(persona.clone()); - save_personas(&app, &personas)?; + // Write the persona definition atomically, then drop the guard before + // acquiring a new one for the agent record write below. + let persona_for_closure = persona.clone(); + let ((), store_guard_after_persona) = + crate::managed_agents::mutate_persona_store(&app, store_guard, move |mut defs| { + defs.push(persona_for_closure); + Ok((defs, ())) + })?; + // Drop the persona-store guard before re-acquiring for the agent record. + drop(store_guard_after_persona); // Enqueue the kind:30175 persona event via the retention path. super::super::pending::retain_persona_pending(&app, &state, &persona); @@ -656,8 +655,29 @@ pub async fn confirm_agent_snapshot_import( name_pool: snapshot.definition.name_pool.clone(), }; - records.push(record.clone()); - save_managed_agents(&app, &records)?; + let pubkey_c = pubkey.clone(); + let mut record_for_save = record.clone(); + // Keyring chokepoint: push nsec into OS keyring before store lock, + // same pattern as agents.rs create path. If keyring unavailable, key + // stays inline (file fallback). + crate::managed_agents::storage::persist_agent_keys_pub(std::slice::from_mut( + &mut record_for_save, + )); + let record_c = record_for_save; + let store_guard2 = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + crate::managed_agents::mutate_agent_store(&app, store_guard2, move |mut instances, _j| { + if instances.iter().any(|r| r.pubkey == pubkey_c) { + return Err(format!( + "generated pubkey {pubkey_c} already exists — retry" + )); + } + instances.push(record_c); + Ok((instances, ())) + }) + .map(|_| ())?; // Enqueue the kind:30177 managed-agent event via retention. // (Uses the same pattern as agents.rs::retain_managed_agent_pending @@ -753,49 +773,7 @@ pub async fn confirm_agent_snapshot_import( /// `agents::retain_managed_agent_pending` without requiring cross-module /// private function access. fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { - use crate::managed_agents::{ - agent_events::{agent_event_content, build_agent_event}, - persona_events::monotonic_created_at, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, - }; - use buzz_core_pkg::kind::KIND_MANAGED_AGENT; - use nostr::JsonUtil; - - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let conn = open_retention_db(&scope.db_path)?; - let content = serde_json::to_string(&agent_event_content(record)) - .map_err(|e| format!("failed to serialize agent content: {e}"))?; - let (owner_pubkey, event) = { - let keys = &scope.owner_keys; - let owner_pubkey = keys.public_key().to_hex(); - let existing = - get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; - if existing.as_ref().is_some_and(|row| row.content == content) { - return Ok(()); - } - let event = build_agent_event(record)? - .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign agent event: {e}"))?; - (owner_pubkey, event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_MANAGED_AGENT, - pubkey: owner_pubkey, - d_tag: record.pubkey.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: snapshot-import retain-agent: {e}"); - } + super::retain::retain_agent_pending(app, state, record); } /// POST a pre-built signed engram event to the relay, authenticating as the diff --git a/desktop/src-tauri/src/commands/personas/snapshot/retain.rs b/desktop/src-tauri/src/commands/personas/snapshot/retain.rs new file mode 100644 index 0000000000..2bb949f92f --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/retain.rs @@ -0,0 +1,56 @@ +//! Retention helper for agents imported via persona snapshot. +//! +//! Extracted from `import.rs` to keep that module within its size ratchet. + +use tauri::AppHandle; + +use crate::{app_state::AppState, managed_agents::ManagedAgentRecord}; + +/// Inline retention for the managed-agent kind:30177 event — mirrors +/// `agents::retain_managed_agent_pending` without requiring cross-module +/// private function access. +pub(super) fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { + use crate::managed_agents::{ + agent_events::{agent_event_content, build_agent_event}, + persona_events::monotonic_created_at, + retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + }; + use buzz_core_pkg::kind::KIND_MANAGED_AGENT; + use nostr::JsonUtil; + + let result = (|| -> Result<(), String> { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; + let content = serde_json::to_string(&agent_event_content(record)) + .map_err(|e| format!("failed to serialize agent content: {e}"))?; + let (owner_pubkey, event) = { + let keys = &scope.owner_keys; + let owner_pubkey = keys.public_key().to_hex(); + let existing = + get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; + if existing.as_ref().is_some_and(|row| row.content == content) { + return Ok(()); + } + let event = build_agent_event(record)? + .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign agent event: {e}"))?; + (owner_pubkey, event) + }; + retain_event( + &conn, + &RetainedEvent { + kind: KIND_MANAGED_AGENT, + pubkey: owner_pubkey, + d_tag: record.pubkey.clone(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + })(); + if let Err(e) = result { + eprintln!("buzz-desktop: snapshot-import retain-agent: {e}"); + } +} diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index ed2472d54e..78778d7861 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -7,9 +7,9 @@ use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - apply_persona_behavior, effective_agent_command, load_managed_agents, load_personas, - managed_agent_avatar_url, save_managed_agents, save_personas, try_regenerate_nest, - AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest, + apply_persona_behavior, effective_agent_command, load_personas, managed_agent_avatar_url, + mutate_persona_store, try_regenerate_nest, AgentDefinition, ManagedAgentRecord, + UpdatePersonaRequest, }, util::now_iso, }; @@ -96,43 +96,67 @@ pub(super) async fn update_persona_with( let model = trim_optional(input.model); let provider = trim_optional(input.provider); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut personas = load_personas(&app)?; - pending::project_active_persona_sharing(&app, &state, &mut personas); - let persona = personas - .iter_mut() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("agent {} not found", input.id))?; - - // Track what changed so we can propagate to linked agent records. - let avatar_changed = persona.avatar_url != avatar_url; - let name_changed = persona.display_name != display_name; - let old_display_name = persona.display_name.clone(); - - persona.display_name = display_name; - persona.avatar_url = avatar_url; - persona.system_prompt = system_prompt; - persona.runtime = runtime; - persona.model = model; - persona.provider = provider; - persona.name_pool = input + + // Load personas for sharing projection before the closure. + let mut personas_pre = load_personas(&app).unwrap_or_default(); + pending::project_active_persona_sharing(&app, &state, &mut personas_pre); + + // Validate the persona exists and capture pre-mutation context. + let input_id = input.id.clone(); + let pre_persona = personas_pre + .iter() + .find(|record| record.id == input_id) + .ok_or_else(|| format!("agent {} not found", input_id))? + .clone(); + let avatar_changed = pre_persona.avatar_url != avatar_url; + let name_changed = pre_persona.display_name != display_name; + let old_display_name = pre_persona.display_name.clone(); + + let input_id_c = input.id.clone(); + let display_name_c = display_name.clone(); + let avatar_url_c = avatar_url.clone(); + let system_prompt_c = system_prompt.clone(); + let runtime_c = runtime.clone(); + let model_c = model.clone(); + let provider_c = provider.clone(); + let name_pool_c = input .name_pool - .into_iter() + .iter() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) - .collect(); - if let Some(env_vars) = input.env_vars { - crate::managed_agents::validate_user_env_keys(&env_vars)?; - persona.env_vars = env_vars; - } - apply_persona_behavior(persona, input.behavior)?; - persona.updated_at = now_iso(); + .collect::>(); + let env_vars_c = input.env_vars.clone(); + let behavior_c = input.behavior.clone(); + let now_c = now_iso(); - let result = persona.clone(); - save_personas(&app, &personas)?; + let (result, store_guard_after_persona) = + mutate_persona_store(&app, store_guard, move |mut defs| { + let persona = defs + .iter_mut() + .find(|record| record.id == input_id_c) + .ok_or_else(|| format!("agent {} not found", input_id_c))?; + + persona.display_name = display_name_c; + persona.avatar_url = avatar_url_c; + persona.system_prompt = system_prompt_c; + persona.runtime = runtime_c; + persona.model = model_c; + persona.provider = provider_c; + persona.name_pool = name_pool_c; + if let Some(env_vars) = env_vars_c { + crate::managed_agents::validate_user_env_keys(&env_vars)?; + persona.env_vars = env_vars; + } + apply_persona_behavior(persona, behavior_c)?; + persona.updated_at = now_c; + + let result = persona.clone(); + Ok((defs, result)) + })?; let retained = retain(&app, &state, &result)?; try_regenerate_nest(&app); @@ -140,81 +164,87 @@ pub(super) async fn update_persona_with( // If the avatar or display_name changed, propagate to linked agent // records and collect relay profile sync params for the async phase. let sync_params: ProfileSyncParams = if avatar_changed || name_changed { - let mut records = load_managed_agents(&app)?; let mut params: ProfileSyncParams = Vec::new(); - let mut agents_modified = false; let workspace_relay = crate::relay::relay_ws_url_with_override(&state); + let result_clone = result.clone(); + let workspace_relay_clone = workspace_relay.clone(); - // Propagate the display_name rename to instances that still - // carry the old definition display_name (pool-named instances - // keep their individualised name) in one pass; the loop below - // only decides which records need a relay profile sync. - let renamed: Vec = if name_changed { - propagate_persona_name_rename( - &mut records, - &result.id, - &old_display_name, - &result.display_name, - ) - } else { - Vec::new() - }; - - for record in records.iter_mut() { - if record.persona_id.as_deref() != Some(&result.id) { - continue; - } - let mut record_changed = renamed.contains(&record.pubkey); - - if avatar_changed { - // Update the persisted avatar so reconciliation on next - // start agrees with what we're about to publish. - // When the persona avatar is cleared, fall back to the - // command-default icon so the record never stores `None` - // (which reconcile_agent_profile treats as "un-migrated"). - let effective_cmd = effective_agent_command( - record.persona_id.as_deref(), - std::slice::from_ref(&result), - record.agent_command_override.as_deref(), - ); - record.avatar_url = result - .avatar_url - .clone() - .or_else(|| managed_agent_avatar_url(&effective_cmd)); - record_changed = true; - } + // Drop the persona-store guard before acquiring a fresh agent-store guard. + drop(store_guard_after_persona); + let store_guard2 = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; - if record_changed { - agents_modified = true; - if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) { - let relay_url = crate::relay::effective_agent_relay_url( - &record.relay_url, - &workspace_relay, - ); - params.push(( - agent_keys, - relay_url, - record.name.clone(), - record.avatar_url.clone(), - record.auth_tag.clone(), - )); - } - } - } + let ((renamed, agent_sync_params), _guard) = + crate::managed_agents::mutate_agent_store( + &app, + store_guard2, + move |mut instances, _journal| { + let renamed: Vec = if name_changed { + propagate_persona_name_rename( + &mut instances, + &result_clone.id, + &old_display_name, + &result_clone.display_name, + ) + } else { + Vec::new() + }; + + let mut agent_params: ProfileSyncParams = Vec::new(); + for record in instances.iter_mut() { + if record.persona_id.as_deref() != Some(&result_clone.id) { + continue; + } + let mut record_changed = renamed.contains(&record.pubkey); - if agents_modified { - save_managed_agents(&app, &records)?; - // Keep retained kind:30177 identity records in lockstep with - // the rename (#2423): `record.name` is part of the published - // identity projection, so skipping this strands the relay on - // the stale name→pubkey binding until the next boot reconcile. - // Avatar-only edits are excluded — the avatar is not in the - // projection, so retaining would be a guaranteed no-op. - for record in records.iter().filter(|r| renamed.contains(&r.pubkey)) { - crate::commands::agents::retain_managed_agent_pending(&app, &state, record); + if avatar_changed { + let effective_cmd = effective_agent_command( + record.persona_id.as_deref(), + std::slice::from_ref(&result_clone), + record.agent_command_override.as_deref(), + ); + record.avatar_url = result_clone + .avatar_url + .clone() + .or_else(|| managed_agent_avatar_url(&effective_cmd)); + record_changed = true; + } + + if record_changed { + if let Ok(agent_keys) = + nostr::Keys::parse(&record.private_key_nsec) + { + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &workspace_relay_clone, + ); + agent_params.push(( + agent_keys, + relay_url, + record.name.clone(), + record.avatar_url.clone(), + record.auth_tag.clone(), + )); + } + } + } + Ok((instances, (renamed, agent_params))) + }, + )?; + + if !renamed.is_empty() || avatar_changed { + // Load the fresh records for retain calls. + let fresh_records = + crate::managed_agents::load_managed_agents(&app).unwrap_or_default(); + for record in fresh_records.iter().filter(|r| renamed.contains(&r.pubkey)) { + crate::commands::agents::retain_managed_agent_pending( + &app, &state, record, None, + ); } } - + params.extend(agent_sync_params); params } else { Vec::new() diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..ea0afaafc2 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -17,8 +17,8 @@ use crate::{ }, managed_agents::{ agent_snapshot::{build_snapshot, AgentSnapshot, AgentSnapshotMemoryEntry, MemoryLevel}, - load_managed_agents, load_personas, load_teams, load_teams_readonly, save_managed_agents, - save_personas, save_teams, AgentDefinition, ManagedAgentRecord, TeamRecord, + load_managed_agents, load_personas, load_teams, AgentDefinition, ManagedAgentRecord, + TeamRecord, }, relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -626,7 +626,7 @@ pub async fn confirm_team_snapshot_import( // ── Phase 3: store (sync, inside lock) ────────────────────────────────── let team = { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; @@ -642,102 +642,118 @@ pub async fn confirm_team_snapshot_import( } } - // Snapshot both store files for rollback on partial write failure. - // Distinguish "file exists with content" from "file absent" so rollback - // can delete a file created by the import rather than leaving orphaned - // records. - let agents_store_path = crate::managed_agents::storage::managed_agents_store_path(&app)?; - let agents_store_snapshot = match std::fs::read(&agents_store_path) { - Ok(bytes) => Some(bytes), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => return Err(format!("failed to snapshot agent store: {e}")), - }; - let teams_store_path = crate::managed_agents::teams_store_path(&app)?; - let teams_store_snapshot = match std::fs::read(&teams_store_path) { - Ok(bytes) => Some(bytes), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => return Err(format!("failed to snapshot teams store: {e}")), - }; - - // Pre-read teams via the read-only loader BEFORE any agent commits. - // This avoids load_teams()'s write-on-load side effect (teams.rs:165-166 - // saves whenever the file is absent or built-ins changed). A failure here - // aborts cleanly — zero writes have occurred. - let mut teams = load_teams_readonly(&teams_store_path)?; - - // Collect minted pubkeys for keyring cleanup on rollback. - let minted_pubkeys: Vec<&str> = minted.iter().map(|m| m.pubkey.as_str()).collect(); - - // Restore the agent store to pre-import state and clean minted keyring - // entries. Returns the original error, extended with rollback details. - let rollback_agents = |original_err: String| -> String { - let mut errors = vec![original_err]; - // Clean minted keyring entries. - for pubkey in &minted_pubkeys { - if let Err(e) = crate::managed_agents::storage::try_delete_agent_key(pubkey) { - errors.push(format!("keyring cleanup {pubkey}: {e}")); + // Write all definitions, instances, and the team record in ONE + // mutate_store transaction so the advisory lock spans the full + // decode → mutate → write sequence. If any step fails before the + // file writes, nothing is written — no byte snapshot/restore needed. + let new_defs: Vec = minted + .iter() + .map(|m| m.definition.clone().into_agent_record()) + .collect(); + // Keyring migration runs OUTSIDE the mutate_store critical section — + // keyring I/O must never hold the OS advisory lock or the in-process + // mutex. We run it here on a save-local clone; on success the nsec is + // cleared from the clone so the written file stores no plaintext key. + // If mutate_store subsequently fails the key is safely in the keyring + // (idempotent next write) and the record is simply not persisted yet. + let mut new_instances: Vec = + minted.iter().map(|m| m.record.clone()).collect(); + crate::managed_agents::storage::persist_agent_keys_pub(&mut new_instances); + let new_team = imported_team.clone(); + + // Record the operation in the journal before the file write. + let op_id = crate::managed_agents::store_journal::new_operation_id(); + let op_id_for_closure = op_id.clone(); + let team_id_for_closure = imported_team.id.clone(); + + let ((), _store_guard) = + crate::managed_agents::store_journal::mutate_store(&app, store_guard, move |st| { + // Journal: record team_import operation (before any file write). + crate::managed_agents::store_journal::insert_operation( + st.journal, + &op_id_for_closure, + "team_import", + &team_id_for_closure, + crate::managed_agents::store_journal::Generation::zero(), + )?; + + // Build new agents array: existing + new definitions + new instances. + let mut all_agents = st.agents; + + // Sort new definitions and append. + let mut sorted_defs = new_defs; + sorted_defs.sort_by(|a, b| { + a.slug + .as_deref() + .unwrap_or("") + .cmp(b.slug.as_deref().unwrap_or("")) + }); + all_agents.extend(sorted_defs); + + // Instances have already had keyring migration applied outside + // the lock. Sort and append — no keyring I/O inside closure. + let mut sorted_instances = new_instances; + sorted_instances.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then_with(|| a.pubkey.cmp(&b.pubkey)) + }); + + // CAS each new instance at generation 0 — reject if the pubkey + // is already present (duplicate or tombstoned from a prior import). + for instance in &sorted_instances { + if instance.pubkey.is_empty() { + continue; + } + match crate::managed_agents::store_journal::cas_generation( + st.journal, + &instance.pubkey, + crate::managed_agents::store_journal::Generation::zero(), + )? { + crate::managed_agents::store_journal::CasOutcome::Committed { .. } => {} + crate::managed_agents::store_journal::CasOutcome::Tombstoned { + tombstone_generation, + } => { + return Err(format!( + "team-import: agent {} is tombstoned at generation {} \ + — cannot re-import a deleted identity", + instance.pubkey, tombstone_generation.0 + )); + } + crate::managed_agents::store_journal::CasOutcome::Conflict { current } => { + return Err(format!( + "team-import: agent {} already exists (generation {})", + instance.pubkey, current.0 + )); + } + } } - } - // Restore agent store file. - let restore = match &agents_store_snapshot { - Some(bytes) => crate::managed_agents::storage::atomic_write_json_restricted( - &agents_store_path, - bytes, - ), - None => match std::fs::remove_file(&agents_store_path) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - other => other.map_err(|e| e.to_string()), - }, - }; - if let Err(e) = restore { - errors.push(format!("agent store restore: {e}")); - } - if errors.len() == 1 { - errors.into_iter().next().unwrap() - } else { - errors.join("; ") - } - }; - // Write all definitions. - let mut personas = load_personas(&app)?; - for m in &minted { - personas.push(m.definition.clone()); - } - if let Err(e) = save_personas(&app, &personas) { - return Err(rollback_agents(e)); + all_agents.extend(sorted_instances); + + // Append the new team record. + let mut all_teams = st.teams; + all_teams.push(new_team); + all_teams.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok((all_agents, all_teams, ())) + })?; + + // Advance operation to committed after the file write. + { + let anchor = crate::managed_agents::store_journal::store_anchor_dir(&app)?; + let journal = crate::managed_agents::store_journal::open_journal(&anchor)?; + crate::managed_agents::store_journal::advance_disposition( + &journal, + &op_id, + &crate::managed_agents::store_journal::Disposition::Pending, + &crate::managed_agents::store_journal::Disposition::Committed, + )?; } - // Write all managed-agent records. - let mut records = existing_records; - for m in &minted { - records.push(m.record.clone()); - } - if let Err(e) = save_managed_agents(&app, &records) { - return Err(rollback_agents(e)); - } - - // Write the team record. `teams` was pre-loaded via the read-only - // loader before any agent commits, so a read/parse failure already - // aborted before any phase-3 write. save_teams sorts and persists. - teams.push(imported_team.clone()); - if let Err(e) = save_teams(&app, &teams) { - let err = rollback_agents(e); - // Also restore teams store. - let teams_restore = match &teams_store_snapshot { - Some(bytes) => { - crate::managed_agents::storage::atomic_write_json(&teams_store_path, bytes) - } - None => match std::fs::remove_file(&teams_store_path) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - other => other.map_err(|e| e.to_string()), - }, - }; - return Err(match teams_restore { - Ok(()) => err, - Err(teams_err) => format!("{err}; teams store restore: {teams_err}"), - }); - } + // Keyring migration was completed above before mutate_store; no + // further keyring work required here. // All writes committed — safe to update in-memory state. for m in &minted { diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 4377ddaa43..1c0c72334d 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -5,7 +5,8 @@ use crate::{ app_state::AppState, managed_agents::{ delete_team_with_cascade, ensure_persona_ids_are_active, load_personas, load_teams, - save_teams, try_regenerate_nest, CreateTeamRequest, TeamRecord, UpdateTeamRequest, + storage::mutate_team_store, try_regenerate_nest, CreateTeamRequest, TeamRecord, + UpdateTeamRequest, }, util::now_iso, }; @@ -27,20 +28,18 @@ fn trim_optional(value: Option) -> Option { /// Retain a freshly authored team event in the local store, flagged for relay /// sync. Called inside a command's `managed_agents_store_lock`-held body after -/// `save_teams`; the background flush loop publishes it out-of-band. +/// saving teams; the background flush loop publishes it out-of-band. /// /// Mirrors `commands::personas::retain_persona_pending`. Built-in teams are not /// owner-authored, so the caller skips them — this helper assumes the team is -/// publishable. Best-effort: a failure here is logged and swallowed so a -/// retention hiccup never blocks the disk-authoritative write. +/// publishable. /// -/// Unlike `retain_managed_agent_pending`, this has no projection-equality -/// short-circuit: teams have no start/stop runtime churn, so a republish only -/// happens on an actual user edit. The guard is intentionally omitted. +/// Internal ordering (B1 journal protocol): outbox evidence inserted first via +/// `prepare_publication` before the retention row is set flushable. pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) { use crate::managed_agents::{ persona_events::monotonic_created_at, - retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, + retention::{get_retained_event, open_retention_db}, team_events::build_team_event, }; use buzz_core_pkg::kind::KIND_TEAM; @@ -57,17 +56,34 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team .custom_created_at(monotonic_created_at(prior)) .sign_with_keys(&scope.owner_keys) .map_err(|e| format!("failed to sign team event: {e}"))?; - retain_event( + let event_id = event.id.to_hex(); + let raw_json = event.as_json(); + + // B1 journal outbox: record immutable event identity before retention. + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor) + .map_err(|e| format!("create anchor dir for team outbox: {e}"))?; + let journal = crate::managed_agents::store_journal::open_journal(&anchor)?; + let pub_op_id = crate::managed_agents::store_journal::new_operation_id(); + crate::managed_agents::store_journal::insert_operation( + &journal, + &pub_op_id, + "publish", + &team.id, + crate::managed_agents::store_journal::Generation::zero(), + )?; + + crate::managed_agents::store_journal::prepare_publication( + &journal, &conn, - &RetainedEvent { - kind: KIND_TEAM, - pubkey, - d_tag: team.id.clone(), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, + &pub_op_id, + &event_id, + &raw_json, + KIND_TEAM, + &pubkey, + &team.id, + &event.content, + event.created_at.as_secs() as i64, ) })(); if let Err(e) = result { @@ -84,12 +100,12 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team /// `(5, pubkey, d_tag)` coordinate with `pending_sync = 1`. Best-effort: a /// failure is logged and swallowed so a retention hiccup never blocks the /// disk-authoritative delete. -fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { +/// +/// Internal ordering (B1 journal protocol): outbox evidence inserted first via +/// `prepare_publication` before the retention row is set flushable. +fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) -> Result<(), String> { use crate::managed_agents::{ - retention::{ - delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, - RetainedEvent, - }, + retention::{delete_retained_event, open_retention_db, tombstone_retention_d_tag}, team_events::build_team_delete, }; use buzz_core_pkg::kind::KIND_TEAM; @@ -97,32 +113,44 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { const KIND_DELETE: u32 = 5; - let result = (|| -> Result<(), String> { - let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - let pubkey = scope.owner_keys.public_key().to_hex(); - let event = build_team_delete(d_tag, &pubkey)? - .sign_with_keys(&scope.owner_keys) - .map_err(|e| format!("failed to sign team tombstone: {e}"))?; - let conn = open_retention_db(&scope.db_path)?; - delete_retained_event(&conn, KIND_TEAM, &pubkey, d_tag)?; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_DELETE, - pubkey, - // Key by the target coordinate so cross-kind d-tag tombstones - // occupy distinct rows (F2c). - d_tag: tombstone_retention_d_tag(KIND_TEAM, d_tag), - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: team-tombstone: {e}"); - } + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_team_delete(d_tag, &pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign team tombstone: {e}"))?; + let event_id = event.id.to_hex(); + let raw_json = event.as_json(); + let tombstone_d_tag = tombstone_retention_d_tag(KIND_TEAM, d_tag); + + let conn = open_retention_db(&scope.db_path)?; + delete_retained_event(&conn, KIND_TEAM, &pubkey, d_tag)?; + + // B1 journal outbox: record immutable tombstone identity before retention. + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor) + .map_err(|e| format!("create anchor dir for team tombstone outbox: {e}"))?; + let journal = crate::managed_agents::store_journal::open_journal(&anchor)?; + let pub_op_id = crate::managed_agents::store_journal::new_operation_id(); + crate::managed_agents::store_journal::insert_operation( + &journal, + &pub_op_id, + "tombstone", + d_tag, + crate::managed_agents::store_journal::Generation::zero(), + )?; + + crate::managed_agents::store_journal::prepare_publication( + &journal, + &conn, + &pub_op_id, + &event_id, + &raw_json, + KIND_DELETE, + &pubkey, + &tombstone_d_tag, + &event.content, + event.created_at.as_secs() as i64, + ) } #[tauri::command] @@ -150,13 +178,12 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result Result {} + crate::managed_agents::store_journal::CasOutcome::Tombstoned { .. } => { + return Err(format!( + "team {team_id_for_closure}: id previously tombstoned — cannot recreate" + )); + } + crate::managed_agents::store_journal::CasOutcome::Conflict { current } => { + return Err(format!( + "team {team_id_for_closure}: generation conflict (expected 0, current {})", + current.0 + )); + } + } + teams.push(team_for_closure); + Ok((teams, ())) + })?; // Created teams are always non-builtin; publish to the relay. retain_team_pending(&app, &state, &team); Ok(team) @@ -190,26 +248,55 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result {} + crate::managed_agents::store_journal::CasOutcome::Tombstoned { .. } => { + return Err(format!("team {target_id}: tombstoned — cannot update")); + } + crate::managed_agents::store_journal::CasOutcome::Conflict { current } => { + return Err(format!( + "team {target_id}: generation conflict (expected {}, current {})", + current_gen.0, current.0 + )); + } + } + team.name = name; + team.description = description; + team.instructions = instructions; + team.persona_ids = input.persona_ids; + team.updated_at = now2; + let updated = team.clone(); + Ok((teams, updated)) + })?; // Built-in teams are not owner-authored — never publish them. if !updated.is_builtin { retain_team_pending(&app, &state, &updated); @@ -225,19 +312,24 @@ pub async fn delete_team(id: String, app: AppHandle) -> Result<(), String> { use tauri::Manager; tokio::task::spawn_blocking(move || { let state = app.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let cascaded_persona_d_tags = delete_team_with_cascade(&app, &id)?; + let (cascaded_persona_d_tags, _guard) = delete_team_with_cascade(&app, &id, store_guard)?; // delete_team_with_cascade rejects built-in teams via validate_team_deletion, // so reaching here means this team was owner-published — tombstone it. The // d_tag is the team id, captured before the record left the store. - tombstone_team_pending(&app, &state, &id); + // Propagate: no boot-reconcile fallback for tombstones. + tombstone_team_pending(&app, &state, &id).map_err(|e| { + format!("team deleted but tombstone failed — relay record may persist: {e}") + })?; // Tombstone the cascaded personas too, so their orphaned kind:30175 heads // don't linger on the relay (F4). Each d-tag was captured pre-removal. for persona_d_tag in &cascaded_persona_d_tags { - super::personas::tombstone_persona_pending(&app, &state, persona_d_tag); + super::personas::tombstone_persona_pending(&app, &state, persona_d_tag).map_err( + |e| format!("team-delete: persona tombstone failed for {persona_d_tag}: {e}"), + )?; } try_regenerate_nest(&app); Ok(()) diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index ee8e0d8b10..c05a61920e 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -64,11 +64,34 @@ pub fn spawn_event_sync( pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) { use crate::managed_agents::managed_agents_base_dir; - let Ok(base_dir) = managed_agents_base_dir(app) else { - return; + // Resolve the anchor dir — both the lock path AND the file path must come + // from store_anchor_dir so this read is serialised against concurrent + // writers that also hold the advisory lock. + let anchor = match crate::managed_agents::store_journal::store_anchor_dir(app) { + Ok(a) => a, + Err(e) => { + eprintln!("buzz-desktop: persona-event-migration: anchor resolution failed: {e}"); + return; + } }; - match migrate_personas_in_dir_at(&base_dir, keys, db_path) { + // Acquire the B1 advisory lock. Fail-closed: if we cannot acquire the + // lock we skip the reconcile for this boot rather than reading potentially + // stale files from the wrong path. + let _advisory = match crate::managed_agents::store_journal::JournalLockGuard::acquire(&anchor) { + Ok(g) => g, + Err(e) => { + eprintln!( + "buzz-desktop: persona-event-migration: advisory lock failed — \ + skipping reconcile: {e}" + ); + return; + } + }; + + // Read from the anchor dir, not the local base dir. Before symlinks are + // set up the anchor and local dir may differ; always use the anchor path. + match migrate_personas_in_dir_at(&anchor, keys, db_path) { Ok(0) => {} Ok(migrated) => { eprintln!( @@ -79,6 +102,8 @@ pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db eprintln!("buzz-desktop: persona-event-migration: {e}"); } } + + let _ = managed_agents_base_dir(app); // keep import used } /// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing. @@ -125,9 +150,10 @@ fn migrate_personas_in_dir_at( } let content = std::fs::read_to_string(&agents_path) .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; + // Fail-closed codec: unknown/malformed content ⇒ error, zero mutation. let all: Vec = - serde_json::from_str(&content) - .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + crate::managed_agents::store_journal::decode_agent_store(content.as_bytes()) + .map_err(|e| e.message)?; all.iter() .filter(|record| record.pubkey.is_empty()) .filter_map(|record| record.to_definition_view()) @@ -222,11 +248,27 @@ fn migrate_personas_in_dir_at( pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) { use crate::managed_agents::managed_agents_base_dir; - let Ok(base_dir) = managed_agents_base_dir(app) else { - return; + // Use the anchor dir for both lock and file path (fail-closed on lock failure). + let anchor = match crate::managed_agents::store_journal::store_anchor_dir(app) { + Ok(a) => a, + Err(e) => { + eprintln!("buzz-desktop: team-event-migration: anchor resolution failed: {e}"); + return; + } }; - match migrate_teams_in_dir_at(&base_dir, keys, db_path) { + let _advisory = match crate::managed_agents::store_journal::JournalLockGuard::acquire(&anchor) { + Ok(g) => g, + Err(e) => { + eprintln!( + "buzz-desktop: team-event-migration: advisory lock failed — \ + skipping reconcile: {e}" + ); + return; + } + }; + + match migrate_teams_in_dir_at(&anchor, keys, db_path) { Ok(0) => {} Ok(migrated) => { eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention"); @@ -235,6 +277,8 @@ pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db_pa eprintln!("buzz-desktop: team-event-migration: {e}"); } } + + let _ = managed_agents_base_dir(app); // keep import used } /// Core team reconcile logic, decoupled from the Tauri `AppHandle` for testing. @@ -268,11 +312,11 @@ fn migrate_teams_in_dir_at( return Ok(0); } - let content = std::fs::read_to_string(&teams_path) - .map_err(|e| format!("failed to read teams.json: {e}"))?; + let bytes = + std::fs::read(&teams_path).map_err(|e| format!("failed to read teams.json: {e}"))?; - let records: Vec = - serde_json::from_str(&content).map_err(|e| format!("failed to parse teams.json: {e}"))?; + let records: Vec = crate::managed_agents::store_journal::decode_team_store(&bytes) + .map_err(|e| format!("failed to parse teams.json: {}", e.message))?; if records.is_empty() { return Ok(0); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1e73b15232..2f3547c531 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -62,6 +62,7 @@ use huddle::{ start_huddle, start_stt_pipeline, HuddlePhase, }; use initial_window::*; +use managed_agents::store_journal::run_boot_recovery_gate; use managed_agents::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, @@ -340,11 +341,17 @@ pub fn run() { return Ok(()); } - // Run all pre-identity data migrations before state loads from disk. - if reset_outcome.completed { - migration::run_boot_migrations_after_reset(&app_handle); - } else { - migration::run_boot_migrations(&app_handle); + // Pre-admission recovery gate: file-commit recovery runs here — + // before migrations, backfill, and every canonical-store reader/writer. + // Fails closed: anchor error, unresolved commits, or migration error + // all set store_recovery_failed and skip all store-touching setup. + { + let state = app_handle.state::(); + if let Err(e) = run_boot_recovery_gate(&app_handle, reset_outcome.completed) { + eprintln!("buzz-desktop: boot-recovery-gate: {e}"); + state.store_recovery_failed.store(true, Ordering::Release); + return Ok(()); + } } // Resolve persisted identity key (env var → file → generate+save). @@ -597,6 +604,7 @@ pub fn run() { } }); } + crate::managed_agents::spawn_boot_recovery(app.handle()); Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/desktop/src-tauri/src/managed_agents/agent_log_files.rs b/desktop/src-tauri/src/managed_agents/agent_log_files.rs new file mode 100644 index 0000000000..c4f6d96e02 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_log_files.rs @@ -0,0 +1,198 @@ +//! Log-file helpers for managed-agent and runtime-install logs. +//! +//! Extracted from `storage.rs` to keep that module within its size ratchet. + +use std::fs::{self, File, OpenOptions}; +use std::io::{Read as _, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +/// Maximum log file size before rotation (10 MB). +const MAX_LOG_FILE_SIZE: u64 = 10 * 1024 * 1024; + +/// If `path` exceeds [`MAX_LOG_FILE_SIZE`], rotate it to `.1`. +fn maybe_rotate_log(path: &Path) { + let size = match fs::metadata(path) { + Ok(m) => m.len(), + Err(_) => return, + }; + if size <= MAX_LOG_FILE_SIZE { + return; + } + let mut rotated = path.as_os_str().to_owned(); + rotated.push(".1"); + let _ = fs::rename(path, &rotated); +} + +pub(crate) fn open_log_file(path: &Path) -> Result { + maybe_rotate_log(path); + OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|error| format!("failed to open log file {}: {error}", path.display())) +} + +/// Start a new install-log session at `path`: keep the previous run as +/// `.1` and return a freshly created, empty current file. +/// +/// Rotating per *run* rather than by size is what bounds this file. A run +/// writes one record per executed attempt, each capped by the log-scale +/// capture, so one run's file is bounded by steps × attempts × cap and the +/// history on disk is bounded at two runs. Size-triggered rotation could not +/// promise either: it never replaced an existing `.1`, and on Windows — +/// where rename does not replace its destination — it stopped working +/// altogether once `.1` existed, leaving the current file to grow. +/// +/// The old `.1` is therefore *removed* before the rename rather than renamed +/// over. Every step is best-effort: a rotation that fails must not cost the +/// user the install, so the session continues with a truncated current file. +pub(crate) fn start_install_log_session(path: &Path) -> Result { + if path.exists() { + let mut previous = path.as_os_str().to_owned(); + previous.push(".1"); + let previous = PathBuf::from(previous); + let _ = fs::remove_file(&previous); + let _ = fs::rename(path, &previous); + } + open_install_log(path, /* truncate */ true) +} + +/// Open an install log for appending one more record to the current session. +pub(crate) fn open_install_log_file(path: &Path) -> Result { + open_install_log(path, /* truncate */ false) +} + +/// Open an install log owner-only. +/// +/// The mode is set *in the create* rather than chmod'd afterwards, so the file +/// is never briefly group/world-readable. Install output can carry registry +/// tokens and proxy credentials echoed by a failing installer, so the window +/// matters even though it is short. An existing file's mode is left as-is — +/// `OpenOptions::mode` only applies on creation, and silently re-tightening a +/// file the user relaxed is not this function's call to make. +fn open_install_log(path: &Path, truncate: bool) -> Result { + let mut options = OpenOptions::new(); + options.create(true); + if truncate { + options.write(true).truncate(true); + } else { + options.append(true); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .map_err(|error| format!("failed to open log file {}: {error}", path.display())) +} + +pub(crate) fn append_log_marker(path: &Path, message: &str) -> Result<(), String> { + let mut file = open_log_file(path)?; + writeln!(file, "{message}").map_err(|error| format!("failed to write log marker: {error}")) +} + +pub fn read_log_tail(path: &Path, max_lines: usize) -> Result { + if !path.exists() { + return Ok(String::new()); + } + + let mut file = File::open(path) + .map_err(|error| format!("failed to read log file {}: {error}", path.display()))?; + + let file_len = file + .seek(SeekFrom::End(0)) + .map_err(|error| format!("failed to seek log file: {error}"))?; + + if file_len == 0 { + return Ok(String::new()); + } + + // Read backward in chunks to find enough newlines. + const CHUNK_SIZE: u64 = 8 * 1024; + let mut buf = Vec::new(); + let mut remaining = file_len; + let mut newline_count: usize = 0; + // We need max_lines + 1 newlines to delimit max_lines lines (the trailing + // newline of the last line counts as one). + let target_newlines = max_lines + 1; + + while remaining > 0 && newline_count < target_newlines { + let chunk = remaining.min(CHUNK_SIZE); + remaining -= chunk; + file.seek(SeekFrom::Start(remaining)) + .map_err(|error| format!("failed to seek log file: {error}"))?; + + let mut tmp = vec![0u8; chunk as usize]; + file.read_exact(&mut tmp) + .map_err(|error| format!("failed to read log chunk: {error}"))?; + + // Prepend this chunk so buf always has the tail of the file. + tmp.append(&mut buf); + buf = tmp; + + newline_count = bytecount_newlines(&buf); + } + + // Strip ANSI escapes here (not in the harness) so the desktop log view + // renders cleanly while terminals and other tools still get the colors + // buzz-acp emits. + let cleaned = strip_ansi_escapes::strip_str(String::from_utf8_lossy(&buf)); + let lines: Vec<&str> = cleaned.lines().collect(); + let start = lines.len().saturating_sub(max_lines); + Ok(lines[start..].join("\n")) +} + +fn bytecount_newlines(buf: &[u8]) -> usize { + buf.iter().filter(|&&b| b == b'\n').count() +} + +/// A meaningful error recovered from an exited agent's log tail. +pub struct AgentLogError { + /// The full log line, wrapped as `Agent reported error…` for display. + pub message: String, + /// JSON-RPC error code parsed from the line's `(code N)` marker, or a + /// synthetic code for known bare prefixes. `None` for legacy-format + /// lines that carry no code (or when the code fails to parse as i64). + pub code: Option, +} + +pub fn meaningful_agent_error_from_log(path: &Path) -> Option { + let tail = read_log_tail(path, 200).ok()?; + tail.lines().rev().map(str::trim).find_map(|line| { + // New format: "Agent reported error (code -32002): ..." + if let Some(rest) = line.strip_prefix("Agent reported error (code ") { + if let Some(paren_end) = rest.find("): ") { + let code = rest[..paren_end].parse::().ok(); + return Some(AgentLogError { + message: line.to_string(), + code, + }); + } + } + // Legacy format (older buzz-acp builds): "Agent reported error: ..." + if line.starts_with("Agent reported error:") { + return Some(AgentLogError { + message: line.to_string(), + code: None, + }); + } + // Bare prefixes emitted by older agent binaries whose Display still leaks + // unwrapped errors. Promote these so they surface instead of the generic + // "harness exited with status N" fallback. + if line.starts_with("llm auth:") { + return Some(AgentLogError { + message: format!("Agent reported error: {line}"), + code: Some(-32001), + }); + } + if line.starts_with("llm model not found:") { + return Some(AgentLogError { + message: format!("Agent reported error: {line}"), + code: Some(-32002), + }); + } + None + }) +} diff --git a/desktop/src-tauri/src/managed_agents/boot.rs b/desktop/src-tauri/src/managed_agents/boot.rs new file mode 100644 index 0000000000..7d80f0c067 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/boot.rs @@ -0,0 +1,31 @@ +//! Boot-time recovery: spawn background task to re-drive nonterminal journal ops. + +use tauri::{AppHandle, Manager}; + +/// Spawn a background task that opens the journal, re-drives any nonterminal +/// operations left from a previous crash or interrupted publication, and then +/// calls the flush loop to publish any pending outbox events. +/// +/// Best-effort: any error is logged to stderr and never blocks launch. +pub fn spawn_boot_recovery(app: &AppHandle) { + let recovery_app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = recovery_app.state::(); + // Synchronous journal inspection first (re-classify operations). + if let Err(e) = super::store_journal::run_boot_recovery(&recovery_app, &state) { + eprintln!("buzz-desktop: boot-recovery: journal scan: {e}"); + } + + // Then drive the flush loop to actually publish any pending outbox events. + // This re-submits events whose outbox rows are still pending_state=0. + match super::persona_events::flush_active_pending_events(&recovery_app, &state).await { + Ok(n) if n > 0 => { + eprintln!("buzz-desktop: boot-recovery: flushed {n} pending event(s)"); + } + Ok(_) => {} + Err(e) => { + eprintln!("buzz-desktop: boot-recovery: flush: {e}"); + } + } + }); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a848b6f02f..37b5c7d5fc 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -2,10 +2,12 @@ mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; +mod boot; pub(crate) mod team_snapshot; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; +mod agent_log_files; mod backend; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; @@ -33,6 +35,7 @@ mod runtime_types; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_snapshot; pub(crate) mod storage; +pub(crate) mod store_journal; pub(crate) mod team_events; mod team_repair; mod teams; @@ -48,6 +51,7 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { } pub use backend::*; +pub(crate) use boot::spawn_boot_recovery; pub use discovery::*; pub use env_vars::*; #[cfg(windows)] diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index de396f45c0..38cf7ec605 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -9,6 +9,8 @@ use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; +use rusqlite::OptionalExtension; + use super::{AgentDefinition, ManagedAgentRecord}; use crate::app_state::AppState; @@ -109,6 +111,13 @@ fn normalize_d_tag(raw: &str) -> String { out } +/// Public wrapper for [`normalize_d_tag`]. Used by callers that hold raw slug +/// strings (e.g. `source_team_persona_slug`) and need the same normalization +/// applied by [`persona_d_tag`] without constructing an `AgentDefinition`. +pub fn normalize_d_tag_pub(raw: &str) -> String { + normalize_d_tag(raw) +} + /// Compute the NIP-AP monotonic `created_at` for a write (`docs/nips/NIP-AP.md:117` /// step 3): `max(now, T + 1)` where `T` is the retained head's `created_at` /// (or 0 when no head exists). @@ -231,7 +240,7 @@ pub async fn flush_pending_events( ) -> Result { let relay_url = crate::relay::relay_ws_url_with_override(state); let owner_keys = state.signing_keys()?; - flush_pending_events_at(db_path, state, &relay_url, &owner_keys).await + flush_pending_events_at(db_path, None, state, &relay_url, &owner_keys).await } /// Resolve and flush only the currently active `(relay, owner)` scope. @@ -244,11 +253,19 @@ pub async fn flush_active_pending_events( state: &AppState, ) -> Result { let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; - flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await + flush_pending_events_at( + &scope.db_path, + Some(app), + state, + &scope.relay_url, + &scope.owner_keys, + ) + .await } async fn flush_pending_events_at( db_path: &std::path::Path, + app: Option<&tauri::AppHandle>, state: &AppState, relay_url: &str, owner_keys: &nostr::Keys, @@ -330,6 +347,57 @@ async fn flush_pending_events_at( current.created_at, ¤t.content, )?; + + // Mark the outbox event published in the B1 journal so boot recovery + // does not re-drive it and the operation can advance to Committed. + // Best-effort: a journal hiccup must not block the flush loop. + let event_id = event.id.to_hex(); + if let Some(app) = app { + if let Ok(anchor) = crate::managed_agents::store_journal::store_anchor_dir(app) { + if let Ok(journal) = crate::managed_agents::store_journal::open_journal(&anchor) { + let marked = crate::managed_agents::store_journal::mark_outbox_published( + &journal, &event_id, 0, // pending → published + 1, + ) + .unwrap_or(false); + + // If the outbox row advanced, try to advance its owning + // operation to Committed: check whether all outbox rows for + // that op are now published, then do the disposition CAS. + if marked { + if let Ok(Some(op_id)) = journal + .query_row( + "SELECT operation_id FROM outbox_events WHERE event_id = ?1", + rusqlite::params![event_id], + |row| row.get::<_, String>(0), + ) + .optional() + { + use crate::managed_agents::store_journal::advance_disposition; + use crate::managed_agents::store_journal::Disposition; + // Advance only if all sibling outbox events are published. + let pending_siblings: i64 = journal + .query_row( + "SELECT COUNT(*) FROM outbox_events + WHERE operation_id = ?1 AND published_state = 0", + rusqlite::params![op_id], + |row| row.get(0), + ) + .unwrap_or(1); + if pending_siblings == 0 { + let _ = advance_disposition( + &journal, + &op_id, + &Disposition::Pending, + &Disposition::Committed, + ); + } + } + } + } + } + } + flushed += 1; } diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 9bf7ab74b0..0f96ca0241 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -326,6 +326,7 @@ pub fn validate_persona_activation_change( } pub fn load_personas(app: &AppHandle) -> Result, String> { + use tauri::Manager; let now = now_iso(); // Post-fold: definitions live in the unified agent store, presented in @@ -339,7 +340,26 @@ pub fn load_personas(app: &AppHandle) -> Result, String> { let (records, changed) = merge_personas(records, &now); if changed { - save_personas(app, &records)?; + // Merge write-back: hold the mutex across the write so no racing + // reader can see the pre-merge state, and so the write goes through + // mutate_store (fresh-decode → merge → atomic file commit). + let state = app.state::(); + let guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("failed to acquire store lock in load_personas merge: {e}"))?; + // Re-merge from a fresh decode inside the closure so concurrent writes + // between our load and now are incorporated rather than overwritten. + let merged = records.clone(); + let now_for_closure = now.clone(); + let (merged_result, _guard) = mutate_persona_store(app, guard, move |fresh_defs| { + let (re_merged, _) = merge_personas(fresh_defs, &now_for_closure); + // If the initial merge included the caller's `merged` records, + // use the fresh re-merge so we don't clobber concurrent writes. + let _ = merged; // capture but use fresh + Ok((re_merged.clone(), re_merged)) + })?; + return Ok(merged_result); } Ok(records) @@ -363,17 +383,51 @@ pub(crate) fn load_personas_from_path( .map_err(|error| format!("failed to parse persona store: {error}")) } -pub fn save_personas(app: &AppHandle, records: &[AgentDefinition]) -> Result<(), String> { - let mut sorted = records.to_vec(); - sort_personas(&mut sorted); - - // Post-fold: persona saves write key-less definition records into the - // unified agent store (instances preserved by `save_agent_definitions`). - let definitions: Vec<_> = sorted - .into_iter() - .map(|persona| persona.into_agent_record()) - .collect(); - crate::managed_agents::storage::save_agent_definitions(app, &definitions) +/// Atomically mutate the persona **definition** half of the agent store via a +/// closure, holding the OS advisory lock across fresh-decode → mutation → write. +/// +/// The closure receives the current persona list (decoded from the unified store +/// and presented in the legacy `AgentDefinition` shape). The instance half is +/// preserved automatically. Returns `(T, guard)` so post-write work can run +/// inside the in-process lock. +pub(crate) fn mutate_persona_store<'g, F, T>( + app: &AppHandle, + store_mutex_guard: std::sync::MutexGuard<'g, ()>, + mutation: F, +) -> Result<(T, std::sync::MutexGuard<'g, ()>), String> +where + F: FnOnce(Vec) -> Result<(Vec, T), String>, +{ + crate::managed_agents::store_journal::mutate_store(app, store_mutex_guard, move |st| { + // Present definitions in the legacy AgentDefinition shape. + let defs: Vec = st + .agents + .iter() + .filter(|r| r.pubkey.is_empty()) + .filter_map(|r| r.to_definition_view()) + .collect(); + + // Preserve the instance half. + let instances: Vec = st + .agents + .into_iter() + .filter(|r| !r.pubkey.is_empty()) + .collect(); + let teams = st.teams; + + let (mut new_defs, result) = mutation(defs)?; + sort_personas(&mut new_defs); + + let mut all: Vec = new_defs + .into_iter() + .map(|p| p.into_agent_record()) + .collect(); + // Sort definitions by slug before instances. + all.sort_by(|a, b| a.slug.cmp(&b.slug)); + all.extend(instances); + + Ok((all, teams, result)) + }) } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 90f05c5750..82c98b861c 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -37,11 +37,29 @@ pub(crate) fn reconcile_agents_to_events( keys: &nostr::Keys, db_path: &Path, ) { - let Ok(base_dir) = super::managed_agents_base_dir(app) else { - return; + // Use the anchor dir for both lock and file path (fail-closed on lock failure). + let anchor = match super::store_journal::store_anchor_dir(app) { + Ok(a) => a, + Err(e) => { + eprintln!("buzz-desktop: agent-event-reconcile: anchor resolution failed: {e}"); + return; + } + }; + + // Acquire the B1 advisory lock. Fail-closed: skip the reconcile if we + // cannot acquire the lock rather than reading stale/wrong-path files. + let _advisory = match super::store_journal::JournalLockGuard::acquire(&anchor) { + Ok(g) => g, + Err(e) => { + eprintln!( + "buzz-desktop: agent-event-reconcile: advisory lock failed — \ + skipping reconcile: {e}" + ); + return; + } }; - match reconcile_agents_in_dir_at(&base_dir, keys, db_path) { + match reconcile_agents_in_dir_at(&anchor, keys, db_path) { Ok(0) => {} Ok(reconciled) => { eprintln!( @@ -83,10 +101,17 @@ fn reconcile_agents_in_dir_at( let content = std::fs::read_to_string(&store_path) .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; - let records: Vec = serde_json::from_str(&content).map_err(|e| { - super::storage::backup_invalid_store(&store_path); - format!("failed to parse managed-agents.json (preserved as .invalid): {e}") - })?; + // Fail-closed codec: unknown/malformed content ⇒ error, zero mutation. + let records: Vec = + crate::managed_agents::store_journal::decode_agent_store(content.as_bytes()).map_err( + |e| { + super::storage::backup_invalid_store(&store_path); + format!( + "failed to parse managed-agents.json (preserved as .invalid): {}", + e.message + ) + }, + )?; if records.is_empty() { return Ok(0); @@ -104,7 +129,7 @@ fn reconcile_agents_in_dir_at( continue; } - if retain_agent_record(&conn, keys, record)? { + if retain_agent_record(&conn, keys, record)?.is_some() { reconciled += 1; } } @@ -122,11 +147,16 @@ fn reconcile_agents_in_dir_at( /// (`retain_managed_agent_pending`, persona-rename propagation). Every /// mutation of an agent's published identity must go through it so the /// retained record can never silently drift from `managed-agents.json`. +/// +/// Returns `Some((event_id, raw_json))` when a new event was retained (content +/// changed or first write), `None` when the agent was a no-op (unchanged +/// content). Callers that record outbox entries in the B1 journal use the +/// returned identity to call `insert_outbox_event` before the relay publish. pub(crate) fn retain_agent_record( conn: &rusqlite::Connection, keys: &nostr::Keys, record: &ManagedAgentRecord, -) -> Result { +) -> Result, String> { let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -146,9 +176,12 @@ pub(crate) fn retain_agent_record( let content = event.content.clone(); if existing.as_ref().is_some_and(|row| row.content == content) { - return Ok(false); + return Ok(None); } + let event_id = event.id.to_hex(); + let raw_json = event.as_json(); + retain_event( conn, &RetainedEvent { @@ -157,12 +190,45 @@ pub(crate) fn retain_agent_record( d_tag: record.pubkey.clone(), content, created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), + raw_event: raw_json.clone(), pending_sync: true, }, ) .map_err(|e| format!("failed to retain '{}': {e}", record.name))?; - Ok(true) + Ok(Some((event_id, raw_json))) +} + +/// Build the kind:30177 event for `record` and compare it against the +/// retained head, WITHOUT writing to the retention DB. +/// +/// Returns `Some((event, owner_pubkey))` when the content has changed or +/// there is no retained head — i.e. a new row is needed. Returns `None` +/// when the retained content already matches (true no-op). +/// +/// Callers pass the returned event identity to +/// [`crate::managed_agents::store_journal::prepare_publication`], which +/// atomically records outbox evidence and the retention row. +pub(crate) fn build_agent_event_if_changed( + conn: &rusqlite::Connection, + keys: &nostr::Keys, + record: &ManagedAgentRecord, +) -> Result, String> { + let owner_pubkey = keys.public_key().to_hex(); + let existing = get_retained_event(conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; + + let event = build_agent_event(record)? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event for '{}': {e}", record.name))?; + + let content = event.content.clone(); + if existing.as_ref().is_some_and(|row| row.content == content) { + return Ok(None); + } + + Ok(Some((event, owner_pubkey))) } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs index c9269dbf00..bcb1fb1087 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile/tests.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile/tests.rs @@ -322,7 +322,9 @@ fn rename_re_retains_identity_record_with_new_name() { let pubkey = "9".repeat(64); let mut record = sample_record(&pubkey, "Fizz"); - assert!(retain_agent_record(&conn, &keys, &record).unwrap()); + assert!(retain_agent_record(&conn, &keys, &record) + .unwrap() + .is_some()); let first = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner, &pubkey) .unwrap() .unwrap(); @@ -339,7 +341,9 @@ fn rename_re_retains_identity_record_with_new_name() { record.name = "Spark".to_string(); assert!( - retain_agent_record(&conn, &keys, &record).unwrap(), + retain_agent_record(&conn, &keys, &record) + .unwrap() + .is_some(), "a renamed record must re-retain its identity record" ); @@ -372,7 +376,9 @@ fn retain_agent_record_is_noop_when_unchanged() { let pubkey = "8".repeat(64); let record = sample_record(&pubkey, "steady-agent"); - assert!(retain_agent_record(&conn, &keys, &record).unwrap()); + assert!(retain_agent_record(&conn, &keys, &record) + .unwrap() + .is_some()); let row = get_retained_event( &conn, KIND_MANAGED_AGENT, @@ -392,7 +398,9 @@ fn retain_agent_record_is_noop_when_unchanged() { .unwrap(); assert!( - !retain_agent_record(&conn, &keys, &record).unwrap(), + retain_agent_record(&conn, &keys, &record) + .unwrap() + .is_none(), "an unchanged projection must not re-retain" ); assert!( diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 25dadbeec6..67884dfd30 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,7 +1,6 @@ use super::{ - find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, - save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, - ManagedAgentProcess, + find_managed_agent_mut, kill_stale_tracked_processes, load_personas, mutate_agent_store, + spawn_agent_child, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, }; use crate::app_state::AppState; use crate::util; @@ -41,48 +40,41 @@ type AgentSpawnResult = (String, SpawnOutcome); /// `effective_config::resolve_effective_config`'s `OrphanedInstance` arm). pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> { let state = app.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; + let personas = load_personas(app)?; - let mut records = load_managed_agents(app)?; - let needs_backfill = records - .iter() - .any(|r| r.persona_id.is_some() && r.persona_source_version.is_none()); - if !needs_backfill { - return Ok(()); - } + mutate_agent_store(app, store_guard, move |mut records, _journal| { + let needs_backfill = records + .iter() + .any(|r| r.persona_id.is_some() && r.persona_source_version.is_none()); + if !needs_backfill { + return Ok((records, ())); + } - let personas = load_personas(app)?; - let mut changed = false; - for record in records.iter_mut() { - let Some(persona_id) = record.persona_id.clone() else { - continue; - }; - if record.persona_source_version.is_some() { - continue; + for record in records.iter_mut() { + let Some(persona_id) = record.persona_id.clone() else { + continue; + }; + if record.persona_source_version.is_some() { + continue; + } + let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { + eprintln!( + "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving it orphaned — spawn will refuse it", + record.pubkey + ); + continue; + }; + super::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = util::now_iso(); } - let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { - eprintln!( - "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving it orphaned — spawn will refuse it", - record.pubkey - ); - continue; - }; - // Layer precedence at read time: persona env < agent env. When the - // persona leaves model/provider blank, the record's own configured - // values are preserved — a blank persona must not clobber a - // user-configured agent. See `apply_persona_snapshot`. - super::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = util::now_iso(); - changed = true; - } - if changed { - save_managed_agents(app, &records)?; - } - Ok(()) + Ok((records, ())) + }) + .map(|_| ()) } /// Restore managed agents that were running before the app was closed. @@ -102,9 +94,9 @@ pub async fn restore_managed_agents_on_launch( let state = app.state::(); // ── Phase A (under lock): housekeeping + collect agents to restore ── - let mut agents_to_start: Vec; + let agents_to_start: Vec; { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; @@ -113,117 +105,96 @@ pub async fn restore_managed_agents_on_launch( return Ok(()); } - let mut records = load_managed_agents(app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - let (mut changed, _exited) = sync_managed_agent_processes( - &mut records, - &mut runtimes, - &super::current_instance_id(app), - ); - changed |= - kill_stale_tracked_processes(&mut records, &runtimes, &super::current_instance_id(app)); - - let tracked_pids: Vec = runtimes - .values() - .map(|runtime| runtime.child.id()) - .chain( - super::read_all_agent_runtime_receipts(app) - .into_iter() - .filter_map(|(path, receipt)| { - super::valid_agent_runtime_receipt( - &path, - &receipt, - &super::current_instance_id(app), - ) - .then_some(receipt.pid) - }), - ) - .collect(); - super::sweep_orphaned_agent_processes(app, &tracked_pids); - - // System-wide sweep: enumerate all user processes and kill any known - // agent binaries not tracked by this session. Catches orphans whose - // PID files were already cleaned up (e.g. agent workers in their own - // process group whose parent harness exited). - super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids); - - // Dead-instance reaping: find agents belonging to Buzz instances - // whose desktop process is no longer running and reap them. - super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids); - - // Exact-path sweep: kill any buzz-acp process whose executable path - // matches this bundle's harness binary but is not in the tracked set. - // Complements the env-var sweep above — catches orphans that predate - // BUZZ_MANAGED_AGENT injection or lost their PID-file receipt. - // - // TODO: the three sweeps above each walk the PID table independently. - // A future consolidation should collect a single shared process snapshot - // at the top of this block and thread it through all sweep functions, - // replacing the three separate kernel enumerations. - super::sweep_untracked_bundle_harnesses(&tracked_pids); - - let candidates: Vec = records - .iter() - .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) - .map(|record| record.pubkey.clone()) - .collect(); - - let mut to_start = Vec::new(); - for pubkey in &candidates { - if let Some(runtime) = runtimes - .iter_mut() - .find(|(key, _)| key.pubkey == *pubkey) - .map(|(_, runtime)| runtime) - { - if runtime.child.try_wait().ok().flatten().is_none() { - continue; + let instance_id = super::current_instance_id(app); + + let (to_start_out, _guard) = + mutate_agent_store(app, store_guard, |mut records, _journal| { + let (mut changed, _exited) = + sync_managed_agent_processes(&mut records, &mut runtimes, &instance_id); + changed |= kill_stale_tracked_processes(&mut records, &runtimes, &instance_id); + + let tracked_pids: Vec = runtimes + .values() + .map(|runtime| runtime.child.id()) + .chain( + super::read_all_agent_runtime_receipts(app) + .into_iter() + .filter_map(|(path, receipt)| { + super::valid_agent_runtime_receipt(&path, &receipt, &instance_id) + .then_some(receipt.pid) + }), + ) + .collect(); + super::sweep_orphaned_agent_processes(app, &tracked_pids); + super::sweep_system_agent_processes(&instance_id, &tracked_pids); + super::reap_dead_instance_agents(&instance_id, &tracked_pids); + super::sweep_untracked_bundle_harnesses(&tracked_pids); + + let candidates: Vec = records + .iter() + .filter(|record| { + record.start_on_app_launch && record.backend == BackendKind::Local + }) + .map(|record| record.pubkey.clone()) + .collect(); + + let mut to_start = Vec::new(); + for pubkey in &candidates { + if let Some(runtime) = runtimes + .iter_mut() + .find(|(key, _)| key.pubkey == *pubkey) + .map(|(_, runtime)| runtime) + { + if runtime.child.try_wait().ok().flatten().is_none() { + continue; + } + } + if let Some(record) = records.iter().find(|r| r.pubkey == *pubkey) { + if let Some(pid) = record.runtime_pid { + if super::process_is_running(pid) { + continue; + } + } + to_start.push(record.clone()); + } } - } - if let Some(record) = records.iter().find(|r| r.pubkey == *pubkey) { - if let Some(pid) = record.runtime_pid { - if super::process_is_running(pid) { + let agents_to_start_inner = to_start; + + // Re-snapshot persona config for agents about to be restored. + let personas_for_snapshot = super::load_personas(app).unwrap_or_default(); + for record in records.iter_mut() { + if !agents_to_start_inner + .iter() + .any(|r| r.pubkey == record.pubkey) + { continue; } + let Some(persona_id) = record.persona_id.clone() else { + continue; + }; + let Some(persona) = personas_for_snapshot.iter().find(|p| p.id == persona_id) + else { + continue; + }; + super::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = util::now_iso(); + changed = true; } - to_start.push(record.clone()); - } - } - agents_to_start = to_start; - - // Re-snapshot persona config for agents about to be restored, matching - // the interactive spawn path so auto-start agents also pick up the - // current persona on app launch. - let personas_for_snapshot = super::load_personas(app).unwrap_or_default(); - for record in records.iter_mut() { - if !agents_to_start.iter().any(|r| r.pubkey == record.pubkey) { - continue; - } - let Some(persona_id) = record.persona_id.clone() else { - continue; - }; - let Some(persona) = personas_for_snapshot.iter().find(|p| p.id == persona_id) else { - // Orphaned: no current persona to re-snapshot from. Leave the - // record as-is — `spawn_agent_child` (Phase B below) refuses to - // spawn it and Phase C persists the refusal to `last_error`. - continue; - }; - super::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = util::now_iso(); - changed = true; - } - // Re-collect to_start from the updated records so Phase B spawns the refreshed config. - agents_to_start = records - .iter() - .filter(|r| agents_to_start.iter().any(|s| s.pubkey == r.pubkey)) - .cloned() - .collect(); - - if changed { - save_managed_agents(app, &records)?; - } + // Re-collect from updated records so Phase B spawns the refreshed config. + let agents_to_start_refreshed: Vec<_> = records + .iter() + .filter(|r| agents_to_start_inner.iter().any(|s| s.pubkey == r.pubkey)) + .cloned() + .collect(); + + let _ = changed; + Ok((records, agents_to_start_refreshed)) + })?; + agents_to_start = to_start_out; } if agents_to_start.is_empty() { @@ -363,94 +334,95 @@ pub async fn restore_managed_agents_on_launch( } // ── Phase C (re-acquire lock): write back PIDs and status to records ── - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - let mut successfully_spawned: Vec = Vec::new(); - - for (pubkey, outcome) in spawn_results { - match outcome { - // Skipped means a concurrent reconcile already owns a live child for - // this pair; leave its runtime and record state untouched. - SpawnOutcome::Skipped => continue, - SpawnOutcome::Spawned(key, mut process) => { - let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { - continue; - }; - let now = util::now_iso(); - let receipt = super::ManagedAgentRuntimeReceipt { - key: key.clone(), - pid: process.child.id(), - desktop_instance_id: super::current_instance_id(app), - started_at: now.clone(), - }; - if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { - let _ = super::terminate_process(process.child.id()); - let _ = process.child.wait(); - record.updated_at = now; - record.last_error = Some(error); - continue; + // Load personas before the closure to avoid disk I/O inside the OS lock. + let reconcile_personas = super::load_personas(app).unwrap_or_default(); + let reconcile_personas_for_closure = reconcile_personas.clone(); + + let (reconcile_items, store_guard_after) = + mutate_agent_store(app, store_guard, move |mut instances, _journal| { + let mut successfully_spawned: Vec = Vec::new(); + + for (pubkey, outcome) in spawn_results { + match outcome { + // Skipped means a concurrent reconcile already owns a live child for + // this pair; leave its runtime and record state untouched. + SpawnOutcome::Skipped => continue, + SpawnOutcome::Spawned(key, mut process) => { + let Ok(record) = find_managed_agent_mut(&mut instances, &pubkey) else { + continue; + }; + let now = util::now_iso(); + let receipt = super::ManagedAgentRuntimeReceipt { + key: key.clone(), + pid: process.child.id(), + desktop_instance_id: super::current_instance_id(app), + started_at: now.clone(), + }; + if let Err(error) = super::write_agent_runtime_receipt(app, &receipt) { + let _ = super::terminate_process(process.child.id()); + let _ = process.child.wait(); + record.updated_at = now; + record.last_error = Some(error); + continue; + } + record.updated_at = now.clone(); + record.runtime_pid = None; + record.last_started_at = Some(now); + record.last_stopped_at = None; + record.last_exit_code = None; + record.last_error = None; + runtimes.insert(key, super::ManagedAgentPairRuntime::starting(*process)); + successfully_spawned.push(pubkey); + } + SpawnOutcome::Failed(error) => { + let Ok(record) = find_managed_agent_mut(&mut instances, &pubkey) else { + continue; + }; + record.updated_at = util::now_iso(); + record.last_error = Some(error); + } } - record.updated_at = now.clone(); - record.runtime_pid = None; - record.last_started_at = Some(now); - record.last_stopped_at = None; - record.last_exit_code = None; - record.last_error = None; - runtimes.insert(key, super::ManagedAgentPairRuntime::starting(*process)); - successfully_spawned.push(pubkey); - } - SpawnOutcome::Failed(error) => { - let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { - continue; - }; - record.updated_at = util::now_iso(); - record.last_error = Some(error); } - } - } - - // Collect profile reconciliation data for successfully spawned agents before - // releasing the lock. This mirrors the fire-and-forget pattern in - // start_managed_agent — ensuring boot-restored agents get the same profile - // self-healing as UI-started agents. - let reconcile_personas = super::load_personas(app).unwrap_or_default(); - let reconcile_items: Vec<(String, crate::commands::ProfileReconcileData)> = - successfully_spawned - .iter() - .filter_map(|pubkey| { - let record = records.iter().find(|r| r.pubkey == *pubkey)?; - // Resolve the effective harness for the avatar-fallback - // derivation (the snapshot may be empty/stale for an inherited - // harness). Mirrors the UI start path. - let effective_command = - crate::managed_agents::record_agent_command(record, &reconcile_personas); - Some(( - pubkey.clone(), - crate::commands::ProfileReconcileData { - private_key_nsec: record.private_key_nsec.clone(), - name: record.name.clone(), - relay_url: record.relay_url.clone(), - avatar_url: record.avatar_url.clone(), - auth_tag: record.auth_tag.clone(), - pubkey: record.pubkey.clone(), - agent_command: effective_command, - persona_id: record.persona_id.clone(), - }, - )) - }) - .collect(); - save_managed_agents(app, &records)?; - drop(runtimes); - drop(_store_guard); + // Collect profile reconciliation data before releasing the lock. + let reconcile_items: Vec<(String, crate::commands::ProfileReconcileData)> = + successfully_spawned + .iter() + .filter_map(|pubkey| { + let record = instances.iter().find(|r| r.pubkey == *pubkey)?; + let effective_command = crate::managed_agents::record_agent_command( + record, + &reconcile_personas_for_closure, + ); + Some(( + pubkey.clone(), + crate::commands::ProfileReconcileData { + private_key_nsec: record.private_key_nsec.clone(), + name: record.name.clone(), + relay_url: record.relay_url.clone(), + avatar_url: record.avatar_url.clone(), + auth_tag: record.auth_tag.clone(), + pubkey: record.pubkey.clone(), + agent_command: effective_command, + persona_id: record.persona_id.clone(), + }, + )) + }) + .collect(); + + Ok((instances, reconcile_items)) + })?; + + drop(store_guard_after); drop(restore_transition); // ── Profile reconciliation (fire-and-forget) ──────────────────────────── @@ -479,13 +451,16 @@ fn persist_restore_error( pubkey: &str, error: String, ) -> Result<(), String> { - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(app)?; - let record = find_managed_agent_mut(&mut records, pubkey)?; - record.updated_at = util::now_iso(); - record.last_error = Some(error); - save_managed_agents(app, &records) + let pubkey = pubkey.to_owned(); + crate::managed_agents::mutate_agent_store(app, store_guard, move |mut instances, _journal| { + let record = crate::managed_agents::find_managed_agent_mut(&mut instances, &pubkey)?; + record.updated_at = util::now_iso(); + record.last_error = Some(error); + Ok((instances, ())) + }) + .map(|_| ()) } diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f56..af7a2c0a21 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -68,12 +68,17 @@ pub fn scoped_retention_db_path(base_dir: &Path, relay_url: &str, owner_pubkey: /// Snapshot the active relay + owner and resolve their durable event store. /// -/// Callers keep the returned relay and keys alongside the path whenever work -/// crosses an `.await`; a later workspace switch cannot retarget that work. +/// On first use of the anchor-based path, migrates any existing retention DB +/// from the old process-local path using SQLite backup semantics — inside the +/// B1 advisory lock so concurrent first-boot processes serialize. If migration +/// fails we return `Err` (fail-closed: never fall back to the old per-process +/// path, which would restore the split-authority condition B1 eliminates). pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result { let relay_url = crate::relay::relay_ws_url_with_override(state); let owner_keys = state.signing_keys()?; - let base_dir = super::managed_agents_base_dir(app)?; + // Use the B1 anchor directory so the retention DB lives alongside the journal + // and advisory lock. + let base_dir = crate::managed_agents::store_journal::store_anchor_dir(app)?; let db_path = scoped_retention_db_path(&base_dir, &relay_url, &owner_keys.public_key().to_hex()); let parent = db_path @@ -81,6 +86,63 @@ pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result(); + let app_for_state = app.clone(); + let state = app_for_state.state::(); let _transition = state .managed_agent_runtime_transition .lock() .map_err(|e| e.to_string())?; - let _store = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - let exited_keys: Vec<_> = runtimes - .iter_mut() - .filter_map(|(key, runtime)| match runtime.child.try_wait() { - Ok(Some(_)) | Err(_) => Some(key.clone()), - Ok(None) => None, - }) - .collect(); - let records_changed = !exited_keys.is_empty(); - let mut statuses = Vec::new(); - for key in exited_keys { - runtimes.remove(&key); - super::remove_agent_runtime_receipt(&app, &key); - state.clear_agent_session_cache(&key); - if let Some(record) = records - .iter_mut() - .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) - { - record.updated_at = crate::util::now_iso(); - record.last_stopped_at = Some(record.updated_at.clone()); - let status = status_for_with( - &app, - record, - &key, - None, - None, - StatusInputs { - personas: &personas, - global: &global, - }, - ); - emit_status(&app, &status); - statuses.push(status); - } - } - statuses.extend(runtimes.iter().filter_map(|(key, runtime)| { - let record = records - .iter() - .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?; - Some(status_for_with( - &app, - record, - key, - Some(runtime), - None, - StatusInputs { - personas: &personas, - global: &global, - }, - )) - })); - drop(runtimes); - // Records are only mutated above when a runtime exited — skip the store - // rewrite on the common nothing-changed poll. - if records_changed { - save_managed_agents(&app, &records)?; - } + let instance_id = current_instance_id(&app); + + let app_for_closure = app.clone(); + let (statuses, _guard) = + mutate_agent_store(&app, store_guard, move |mut records, _journal| { + let exited_keys: Vec<_> = runtimes + .iter_mut() + .filter_map(|(key, runtime)| match runtime.child.try_wait() { + Ok(Some(_)) | Err(_) => Some(key.clone()), + Ok(None) => None, + }) + .collect(); + let records_changed = !exited_keys.is_empty(); + let mut statuses = Vec::new(); + for key in exited_keys { + runtimes.remove(&key); + super::remove_agent_runtime_receipt(&app_for_closure, &key); + app_for_closure + .state::() + .clear_agent_session_cache(&key); + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) + { + record.updated_at = crate::util::now_iso(); + record.last_stopped_at = Some(record.updated_at.clone()); + let status = status_for_with( + &app_for_closure, + record, + &key, + None, + None, + StatusInputs { + personas: &personas, + global: &global, + }, + ); + emit_status(&app_for_closure, &status); + statuses.push(status); + } + } + statuses.extend(runtimes.iter().filter_map(|(key, runtime)| { + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?; + Some(status_for_with( + &app_for_closure, + record, + key, + Some(runtime), + None, + StatusInputs { + personas: &personas, + global: &global, + }, + )) + })); + + // Only write back when records actually changed; always return records. + let _ = (records_changed, instance_id); + Ok((records, statuses)) + })?; Ok(statuses) } @@ -243,7 +249,8 @@ fn start_pair( expected_updated_at: Option<&str>, app: AppHandle, ) -> Result { - let state = app.state::(); + let app_for_state = app.clone(); + let state = app_for_state.state::(); let _transition = state .managed_agent_runtime_transition .lock() @@ -251,60 +258,71 @@ fn start_pair( if state.shutdown_started.load(Ordering::Acquire) { return Err("desktop shutdown has started".into()); } - let _store = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let record = find_managed_agent_mut(&mut records, &pubkey)?; - if record.backend != BackendKind::Local { - return Err("managed runtime pairs require a local agent".into()); - } - if expected_updated_at.is_some_and(|expected| record.updated_at != expected) { - return Err("managed agent changed while runtime reconciliation was in flight".into()); - } - let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - if runtimes - .get_mut(&key) - .is_some_and(|runtime| runtime.child.try_wait().ok().flatten().is_none()) - { - let status = status_for(&app, record, &key, runtimes.get(&key), None); - return Ok(status); - } - runtimes.remove(&key); - terminate_untracked_pair_runtime(&app, &key)?; - let owner = state .keys .lock() .ok() .map(|keys| keys.public_key().to_hex()); - let mut process = spawn_agent_child(&app, record, &key.relay_url, lazy, owner.as_deref())?; - let now = crate::util::now_iso(); - let receipt = ManagedAgentRuntimeReceipt { - key: key.clone(), - pid: process.child.id(), - desktop_instance_id: current_instance_id(&app), - started_at: now.clone(), - }; - if let Err(error) = write_agent_runtime_receipt(&app, &receipt) { - let _ = terminate_process(process.child.id()); - let _ = process.child.wait(); - return Err(error); - } - record.runtime_pid = None; - record.updated_at = now.clone(); - record.last_started_at = Some(now); - record.last_stopped_at = None; - record.last_error = None; - runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); - let status = status_for(&app, record, &key, runtimes.get(&key), None); - drop(runtimes); - save_managed_agents(&app, &records)?; + + let app_for_closure = app.clone(); + let (status, _store_guard) = + mutate_agent_store(&app, store_guard, move |mut records, _journal| { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + if record.backend != BackendKind::Local { + return Err("managed runtime pairs require a local agent".into()); + } + if expected_updated_at.is_some_and(|expected| record.updated_at != expected) { + return Err( + "managed agent changed while runtime reconciliation was in flight".into(), + ); + } + let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; + if runtimes + .get_mut(&key) + .is_some_and(|runtime| runtime.child.try_wait().ok().flatten().is_none()) + { + let status = status_for(&app_for_closure, record, &key, runtimes.get(&key), None); + return Ok((records, status)); + } + runtimes.remove(&key); + terminate_untracked_pair_runtime(&app_for_closure, &key)?; + + let mut process = spawn_agent_child( + &app_for_closure, + record, + &key.relay_url, + lazy, + owner.as_deref(), + )?; + let now = crate::util::now_iso(); + let receipt = ManagedAgentRuntimeReceipt { + key: key.clone(), + pid: process.child.id(), + desktop_instance_id: current_instance_id(&app_for_closure), + started_at: now.clone(), + }; + if let Err(error) = write_agent_runtime_receipt(&app_for_closure, &receipt) { + let _ = terminate_process(process.child.id()); + let _ = process.child.wait(); + return Err(error); + } + record.runtime_pid = None; + record.updated_at = now.clone(); + record.last_started_at = Some(now); + record.last_stopped_at = None; + record.last_error = None; + runtimes.insert(key.clone(), ManagedAgentPairRuntime::starting(process)); + let status = status_for(&app_for_closure, record, &key, runtimes.get(&key), None); + Ok((records, status)) + })?; emit_status(&app, &status); Ok(status) } @@ -315,63 +333,60 @@ pub fn stop_managed_agent_runtime( relay_url: String, app: AppHandle, ) -> Result { - let state = app.state::(); + let app_for_state = app.clone(); + let state = app_for_state.state::(); let _transition = state .managed_agent_runtime_transition .lock() .map_err(|e| e.to_string())?; - let _store = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let record = find_managed_agent_mut(&mut records, &pubkey)?; - let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - if let Some(mut runtime) = runtimes.remove(&key) { - let stop_result = if process_is_running(runtime.child.id()) { - terminate_process(runtime.child.id()) - } else { - Ok(()) - } - .and_then(|()| runtime.child.wait().map_err(|e| e.to_string())); - match stop_result { - Ok(status) => { - record.last_exit_code = status.code(); - let _ = append_log_marker(&runtime.log_path, "=== stopped pair runtime ==="); - } - Err(error) => { - // Keep failed teardown visible/manageable instead of - // orphaning it: the child stays tracked and the receipt - // stays on disk until a stop actually succeeds. - runtimes.insert(key, runtime); - return Err(error); + + let app_for_closure = app.clone(); + let (status, _store_guard) = + mutate_agent_store(&app, store_guard, move |mut records, _journal| { + let record = find_managed_agent_mut(&mut records, &pubkey)?; + let key = ManagedAgentRuntimeKey::new(pubkey, &relay_url)?; + if let Some(mut runtime) = runtimes.remove(&key) { + let stop_result = if process_is_running(runtime.child.id()) { + terminate_process(runtime.child.id()) + } else { + Ok(()) + } + .and_then(|()| runtime.child.wait().map_err(|e| e.to_string())); + match stop_result { + Ok(status) => { + record.last_exit_code = status.code(); + let _ = + append_log_marker(&runtime.log_path, "=== stopped pair runtime ==="); + } + Err(error) => { + // Keep failed teardown visible/manageable instead of orphaning it. + runtimes.insert(key, runtime); + return Err(error); + } + } + } else { + // No runtime is tracked at this key, but a valid prior-session + // receipt may still point at a live child. + terminate_untracked_pair_runtime(&app_for_closure, &key)?; } - } - } else { - // No runtime is tracked at this key, but a valid prior-session - // receipt may still point at a live child (e.g. the crash-recovery - // window for a non-auto-start agent). Terminate that orphan before - // erasing its receipt — otherwise this "stop" leaves the harness - // running yet deletes the one artifact sweeps and - // terminate_untracked_pair_runtime use to find it, and a follow-up - // start would spawn a duplicate harness for the same pair. On - // failure the receipt stays on disk (terminate_untracked_pair_runtime - // only removes it after the child exits), mirroring the tracked - // path's keep-until-success invariant. - terminate_untracked_pair_runtime(&app, &key)?; - } - super::remove_agent_runtime_receipt(&app, &key); - state.clear_agent_session_cache(&key); - record.runtime_pid = None; - record.updated_at = crate::util::now_iso(); - record.last_stopped_at = Some(record.updated_at.clone()); - let status = status_for(&app, record, &key, None, None); - drop(runtimes); - save_managed_agents(&app, &records)?; + super::remove_agent_runtime_receipt(&app_for_closure, &key); + app_for_closure + .state::() + .clear_agent_session_cache(&key); + record.runtime_pid = None; + record.updated_at = crate::util::now_iso(); + record.last_stopped_at = Some(record.updated_at.clone()); + let status = status_for(&app_for_closure, record, &key, None, None); + Ok((records, status)) + })?; emit_status(&app, &status); Ok(status) } diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea..37ca3852c8 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, - fs::{self, File, OpenOptions}, - io::{Read as _, Seek, SeekFrom, Write}, + fs, + io::Write, path::{Path, PathBuf}, }; @@ -42,6 +42,7 @@ pub fn managed_agents_base_dir(app: &AppHandle) -> Result { Ok(dir) } +#[allow(dead_code)] pub(crate) fn managed_agents_store_path(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("managed-agents.json")) } @@ -234,31 +235,55 @@ pub(crate) fn spawn_key_refusal(record: &ManagedAgentRecord) -> Option { }) } -/// Read the raw unified store — keyed instances AND key-less definitions — -/// with fail-loud parse handling. Internal seam; public readers filter. -fn load_agent_store(app: &AppHandle) -> Result, String> { - let path = managed_agents_store_path(app)?; - if !path.exists() { +/// Read from `agents_path`. Caller must hold the advisory lock. +fn load_agent_store_locked( + _anchor: &std::path::Path, + agents_path: &std::path::Path, +) -> Result, String> { + if !agents_path.exists() { return Ok(Vec::new()); } - - let content = fs::read_to_string(&path) - .map_err(|error| format!("failed to read agent store: {error}"))?; - serde_json::from_str(&content).map_err(|error| { + let bytes = + fs::read(agents_path).map_err(|error| format!("failed to read agent store: {error}"))?; + crate::managed_agents::store_journal::decode_agent_store(&bytes).map_err(|e| { // Fail loudly and preserve the evidence: a later in-app save rewrites // this file wholesale, which would silently destroy a malformed hand // edit. Best-effort file-authoring contract (see managed_agents:: // reconcile): the broken content survives as `.invalid` for the user // to recover, and the parse error propagates instead of being // swallowed into an empty store. - backup_invalid_store(&path); - format!("failed to parse agent store (preserved as .invalid): {error}") + backup_invalid_store(agents_path); + format!( + "failed to parse agent store (preserved as .invalid): {}", + e.message + ) }) } +/// Read the raw unified store — keyed instances AND key-less definitions — +/// with fail-loud parse handling. Internal seam; public readers filter. +/// +/// Acquires the B1 advisory store lock so this call is serialized against +/// concurrent writers from other processes. The in-process mutex +/// (`AppState::managed_agents_store_lock`) is held by callers at the +/// command level; the OS advisory lock here closes the cross-process gap. +fn load_agent_store(app: &AppHandle) -> Result, String> { + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor).map_err(|e| format!("failed to create anchor dir: {e}"))?; + let _advisory = crate::managed_agents::store_journal::JournalLockGuard::acquire(&anchor)?; + let agents_path = anchor.join("managed-agents.json"); + load_agent_store_locked(&anchor, &agents_path) +} + /// Load the keyed agent *instances*. Key-less definitions (former personas, /// folded into the same store) are filtered out so every pre-fold call site /// keeps seeing exactly the records it always did. +/// +/// NOTE: This function acquires and immediately releases the OS advisory lock. +/// For read-only contexts where the in-process mutex is already held, this is +/// safe. For any path that follows with a `save_managed_agents` call, use +/// `mutate_managed_agents` instead so the OS lock is held across the full +/// read → mutate → write sequence. pub fn load_managed_agents(app: &AppHandle) -> Result, String> { let mut records = load_agent_store(app)?; records.retain(|record| !record.pubkey.is_empty()); @@ -355,66 +380,114 @@ fn hydrate_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) } } -/// Save the keyed agent *instances*, preserving the key-less definitions that -/// share the unified store: callers pass exactly the records they loaded via -/// [`load_managed_agents`], and this re-reads the definition half from disk -/// before the wholesale rewrite so a definition is never dropped by an -/// instance-side save (and vice versa via [`save_agent_definitions`]). -pub fn save_managed_agents(app: &AppHandle, records: &[ManagedAgentRecord]) -> Result<(), String> { - let definitions = load_agent_definitions(app).unwrap_or_default(); - let mut sorted = records.to_vec(); - // A caller-supplied key-less record would collide with the definition - // half re-read below; instances always carry a pubkey. - sorted.retain(|record| !record.pubkey.is_empty()); - sorted.sort_by(|left, right| { - left.name - .to_lowercase() - .cmp(&right.name.to_lowercase()) - .then_with(|| left.pubkey.cmp(&right.pubkey)) - }); - - // Persist each key to the keyring; on success blank the inline copy so it - // is skipped from JSON (`skip_serializing_if = "String::is_empty"`). If the - // keyring is unreachable, the key stays inline. - persist_agent_keys(&mut sorted); - - write_agent_store(app, definitions, sorted) -} - -/// Save the key-less agent *definitions*, preserving the keyed instances — -/// the definition-side mirror of [`save_managed_agents`]. -pub(crate) fn save_agent_definitions( +/// Atomically mutate the **instance** half of the agent store via a closure, +/// holding the OS advisory lock across fresh-decode → mutation → write. +/// `mutation` receives instances WITHOUT keyring hydration (keys stay as-is +/// from JSON — the closure works with the raw records and must not call +/// keyring APIs). After the file write the advisory lock is released and +/// THEN keyring operations run outside all critical sections. +/// +/// Returns `(T, guard)` so post-write work can run inside the in-process lock. +pub fn mutate_agent_store<'g, F, T>( app: &AppHandle, - definitions: &[ManagedAgentRecord], -) -> Result<(), String> { - let mut instances = load_agent_store(app)?; - instances.retain(|record| !record.pubkey.is_empty()); - let mut definitions = definitions.to_vec(); - definitions.retain(|record| record.pubkey.is_empty()); - write_agent_store(app, definitions, instances) + store_mutex_guard: std::sync::MutexGuard<'g, ()>, + mutation: F, +) -> Result<(T, std::sync::MutexGuard<'g, ()>), String> +where + F: FnOnce( + Vec, + &rusqlite::Connection, + ) -> Result<(Vec, T), String>, +{ + crate::managed_agents::store_journal::mutate_store(app, store_mutex_guard, move |st| { + // Present instances to the closure WITHOUT keyring hydration: keyring + // I/O is prohibited inside the critical section. The closure is + // responsible for not calling hydrate_keys or persist_agent_keys. + let instances: Vec = st + .agents + .iter() + .filter(|r| !r.pubkey.is_empty()) + .cloned() + .collect(); + + let defs: Vec = st + .agents + .into_iter() + .filter(|r| r.pubkey.is_empty()) + .collect(); + let teams = st.teams; + + let (mut new_instances, result) = mutation(instances, st.journal)?; + + // Sort instances (no keyring I/O here — keys stay as-is from mutation). + new_instances.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then_with(|| a.pubkey.cmp(&b.pubkey)) + }); + + let mut all_defs = defs; + all_defs.sort_by(|a, b| a.slug.cmp(&b.slug)); + let mut all = all_defs; + all.extend(new_instances); + + Ok((all, teams, result)) + }) } -/// Serialize definitions + instances into the single unified store file. -/// Definitions sort first (by slug) for stable diffs; instances keep the -/// name/pubkey order their save path established. -fn write_agent_store( +/// Atomically mutate a single agent instance by pubkey, holding the OS +/// advisory lock across fresh-decode → find → update → write. +/// +/// Returns `(modified_record_clone, guard)` on success. Returns `Err` when +/// the agent is not found or the decode fails. +pub fn mutate_managed_agent<'g, F, T>( app: &AppHandle, - mut definitions: Vec, - instances: Vec, -) -> Result<(), String> { - definitions.sort_by(|left, right| left.slug.cmp(&right.slug)); - let mut all = definitions; - all.extend(instances); - - let path = managed_agents_store_path(app)?; - let payload = serde_json::to_vec_pretty(&all) - .map_err(|error| format!("failed to serialize agent store: {error}"))?; - - // `managed-agents.json` carries plaintext agent nsecs in the keyringless - // fallback. Write it owner-only (`0o600`) unconditionally — harmless for the - // keyring-backed case (it is the user's own agent store) and closes the - // umask window a post-write `chmod` would leave open. - atomic_write_json_restricted(&path, &payload) + store_mutex_guard: std::sync::MutexGuard<'g, ()>, + pubkey: &str, + update: F, +) -> Result<(ManagedAgentRecord, T, std::sync::MutexGuard<'g, ()>), String> +where + F: FnOnce(&mut ManagedAgentRecord, &rusqlite::Connection) -> Result, +{ + let pubkey = pubkey.to_owned(); + mutate_agent_store(app, store_mutex_guard, move |mut instances, journal| { + let record = instances + .iter_mut() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + let extra = update(record, journal)?; + let record_clone = record.clone(); + Ok((instances, (record_clone, extra))) + }) + .map(|((record_clone, extra), guard)| (record_clone, extra, guard)) +} + +/// Atomically mutate the **team** half of the store via a closure, holding the +/// OS advisory lock across fresh-decode → mutation → write of both JSON files. +/// +/// `mutation` receives the full current team list and the open journal +/// connection, and returns a modified team list plus an arbitrary result +/// value `T`. The agents half is preserved exactly (pass-through). +/// +/// Returns `(T, guard)` so the caller can perform post-write work while still +/// holding the in-process mutex. +pub fn mutate_team_store<'g, F, T>( + app: &AppHandle, + store_mutex_guard: std::sync::MutexGuard<'g, ()>, + mutation: F, +) -> Result<(T, std::sync::MutexGuard<'g, ()>), String> +where + F: FnOnce( + Vec, + &rusqlite::Connection, + ) -> Result<(Vec, T), String>, +{ + crate::managed_agents::store_journal::mutate_store(app, store_mutex_guard, move |st| { + let agents = st.agents; + let (new_teams, result) = mutation(st.teams, st.journal)?; + Ok((agents, new_teams, result)) + }) } /// Write each record's in-memory key to the keyring and blank the inline copy @@ -429,6 +502,81 @@ fn persist_agent_keys(records: &mut [ManagedAgentRecord]) { persist_agent_keys_with(store, records); } +/// Write each record's in-memory key to the keyring, recording a journal +/// pre-image before each write so boot recovery can detect interrupted writes. +/// +/// Each key write that finds a non-empty `private_key_nsec` gets a +/// `keyring_write` journal operation. On success the operation advances to +/// `Committed`; on failure (keyring unreachable, write/verify error) it advances +/// to `Failed` and the key stays inline. Best-effort: a journal error must +/// never block the actual keyring write — if the journal record fails we fall +/// back to the unwrapped path and log the hiccup. +#[allow(dead_code)] // B1 keyring-journal protocol — wired to compensation phase in B1.1 +fn persist_agent_keys_journaled( + records: &mut [ManagedAgentRecord], + journal: &rusqlite::Connection, +) { + let Some(store) = agent_secret_store() else { + return; + }; + for record in records.iter_mut() { + if record.private_key_nsec.is_empty() { + continue; + } + // Record a pre-image before the keyring write. + let op_id = crate::managed_agents::store_journal::new_operation_id(); + // event_id = deterministic key based on op_id; payload = just the pubkey + // bytes so boot recovery can identify which agent's key was mid-flight. + let event_id = format!("keyring_write:{op_id}"); + let journal_ok = (|| -> Result<(), String> { + crate::managed_agents::store_journal::insert_operation( + journal, + &op_id, + "keyring_write", + &record.pubkey, + crate::managed_agents::store_journal::Generation::zero(), + )?; + crate::managed_agents::store_journal::insert_inbox_event( + journal, + &event_id, + &op_id, + record.pubkey.as_bytes(), + )?; + Ok(()) + })(); + if let Err(e) = journal_ok { + eprintln!( + "buzz-desktop: keyring-journal: pre-image record failed for {}: {e}", + record.pubkey + ); + } + + let outcome = migrate_inline_key(store, record); + if outcome == KeyMigration::Persisted { + record.private_key_nsec.clear(); + } + + // Advance the journal operation to reflect the actual outcome. + let disposition = if outcome == KeyMigration::Persisted { + crate::managed_agents::store_journal::Disposition::Committed + } else { + crate::managed_agents::store_journal::Disposition::Failed + }; + let _ = crate::managed_agents::store_journal::advance_disposition( + journal, + &op_id, + &crate::managed_agents::store_journal::Disposition::Pending, + &disposition, + ); + } +} + +/// Public wrapper around [`persist_agent_keys`] for callers that build their +/// own record lists inside a `mutate_store` closure (e.g. `team_snapshot.rs`). +pub fn persist_agent_keys_pub(records: &mut [ManagedAgentRecord]) { + persist_agent_keys(records); +} + /// Testable core of [`persist_agent_keys`], generic over the [`KeyStore`] seam. fn persist_agent_keys_with(store: &impl KeyStore, records: &mut [ManagedAgentRecord]) { for record in records.iter_mut() { @@ -595,13 +743,25 @@ pub fn delete_agent_key(pubkey: &str) { } } -/// Atomic, symlink-preserving JSON write. +/// Atomic, symlink-preserving JSON write with fsync before rename. /// Resolves symlinks so the tmp+rename happens at the real target path, /// preserving any symlink at `path`. +/// +/// Sequence: write tmp → fsync → rename over target. The fsync ensures the +/// bytes survive a crash between the write and the rename; without it a torn +/// write could leave a zero-length or truncated file on the target path. +#[allow(dead_code)] pub(crate) fn atomic_write_json(path: &Path, payload: &[u8]) -> Result<(), String> { + use std::io::Write; let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let tmp = resolved.with_extension("json.tmp"); - std::fs::write(&tmp, payload).map_err(|e| format!("failed to write {}: {e}", tmp.display()))?; + let mut file = + std::fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?; + file.write_all(payload) + .map_err(|e| format!("write {}: {e}", tmp.display()))?; + file.sync_all() + .map_err(|e| format!("fsync {}: {e}", tmp.display()))?; + drop(file); std::fs::rename(&tmp, &resolved) .map_err(|e| format!("failed to rename {}: {e}", resolved.display())) } @@ -634,92 +794,10 @@ pub(crate) fn atomic_write_json_restricted(path: &Path, payload: &[u8]) -> Resul .map_err(|e| format!("commit {}: {e}", resolved.display())) } -/// Maximum log file size before rotation (10 MB). -const MAX_LOG_FILE_SIZE: u64 = 10 * 1024 * 1024; - -/// If `path` exceeds [`MAX_LOG_FILE_SIZE`], rotate it to `.1`. -fn maybe_rotate_log(path: &Path) { - let size = match fs::metadata(path) { - Ok(m) => m.len(), - Err(_) => return, - }; - if size <= MAX_LOG_FILE_SIZE { - return; - } - let mut rotated = path.as_os_str().to_owned(); - rotated.push(".1"); - let _ = fs::rename(path, &rotated); -} - -pub(crate) fn open_log_file(path: &Path) -> Result { - maybe_rotate_log(path); - OpenOptions::new() - .create(true) - .append(true) - .open(path) - .map_err(|error| format!("failed to open log file {}: {error}", path.display())) -} - -/// Start a new install-log session at `path`: keep the previous run as -/// `.1` and return a freshly created, empty current file. -/// -/// Rotating per *run* rather than by size is what bounds this file. A run -/// writes one record per executed attempt, each capped by the log-scale -/// capture, so one run's file is bounded by steps × attempts × cap and the -/// history on disk is bounded at two runs. Size-triggered rotation could not -/// promise either: it never replaced an existing `.1`, and on Windows — -/// where rename does not replace its destination — it stopped working -/// altogether once `.1` existed, leaving the current file to grow. -/// -/// The old `.1` is therefore *removed* before the rename rather than renamed -/// over. Every step is best-effort: a rotation that fails must not cost the -/// user the install, so the session continues with a truncated current file. -pub(crate) fn start_install_log_session(path: &Path) -> Result { - if path.exists() { - let mut previous = path.as_os_str().to_owned(); - previous.push(".1"); - let previous = PathBuf::from(previous); - let _ = fs::remove_file(&previous); - let _ = fs::rename(path, &previous); - } - open_install_log(path, /* truncate */ true) -} - -/// Open an install log for appending one more record to the current session. -pub(crate) fn open_install_log_file(path: &Path) -> Result { - open_install_log(path, /* truncate */ false) -} - -/// Open an install log owner-only. -/// -/// The mode is set *in the create* rather than chmod'd afterwards, so the file -/// is never briefly group/world-readable. Install output can carry registry -/// tokens and proxy credentials echoed by a failing installer, so the window -/// matters even though it is short. An existing file's mode is left as-is — -/// `OpenOptions::mode` only applies on creation, and silently re-tightening a -/// file the user relaxed is not this function's call to make. -fn open_install_log(path: &Path, truncate: bool) -> Result { - let mut options = OpenOptions::new(); - options.create(true); - if truncate { - options.write(true).truncate(true); - } else { - options.append(true); - } - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - options - .open(path) - .map_err(|error| format!("failed to open log file {}: {error}", path.display())) -} - -pub(crate) fn append_log_marker(path: &Path, message: &str) -> Result<(), String> { - let mut file = open_log_file(path)?; - writeln!(file, "{message}").map_err(|error| format!("failed to write log marker: {error}")) -} +pub(crate) use crate::managed_agents::agent_log_files::{ + append_log_marker, meaningful_agent_error_from_log, open_install_log_file, open_log_file, + read_log_tail, start_install_log_session, AgentLogError, +}; fn agent_pids_dir(app: &AppHandle) -> Result { let dir = managed_agents_base_dir(app)?.join("agent-pids"); @@ -800,110 +878,6 @@ pub fn read_all_agent_pid_files(app: &AppHandle) -> Vec<(String, u32)> { .collect() } -pub fn read_log_tail(path: &Path, max_lines: usize) -> Result { - if !path.exists() { - return Ok(String::new()); - } - - let mut file = File::open(path) - .map_err(|error| format!("failed to read log file {}: {error}", path.display()))?; - - let file_len = file - .seek(SeekFrom::End(0)) - .map_err(|error| format!("failed to seek log file: {error}"))?; - - if file_len == 0 { - return Ok(String::new()); - } - - // Read backward in chunks to find enough newlines. - const CHUNK_SIZE: u64 = 8 * 1024; - let mut buf = Vec::new(); - let mut remaining = file_len; - let mut newline_count: usize = 0; - // We need max_lines + 1 newlines to delimit max_lines lines (the trailing - // newline of the last line counts as one). - let target_newlines = max_lines + 1; - - while remaining > 0 && newline_count < target_newlines { - let chunk = remaining.min(CHUNK_SIZE); - remaining -= chunk; - file.seek(SeekFrom::Start(remaining)) - .map_err(|error| format!("failed to seek log file: {error}"))?; - - let mut tmp = vec![0u8; chunk as usize]; - file.read_exact(&mut tmp) - .map_err(|error| format!("failed to read log chunk: {error}"))?; - - // Prepend this chunk so buf always has the tail of the file. - tmp.append(&mut buf); - buf = tmp; - - newline_count = bytecount_newlines(&buf); - } - - // Strip ANSI escapes here (not in the harness) so the desktop log view - // renders cleanly while terminals and other tools still get the colors - // buzz-acp emits. - let cleaned = strip_ansi_escapes::strip_str(String::from_utf8_lossy(&buf)); - let lines: Vec<&str> = cleaned.lines().collect(); - let start = lines.len().saturating_sub(max_lines); - Ok(lines[start..].join("\n")) -} - -fn bytecount_newlines(buf: &[u8]) -> usize { - buf.iter().filter(|&&b| b == b'\n').count() -} - -/// A meaningful error recovered from an exited agent's log tail. -pub struct AgentLogError { - /// The full log line, wrapped as `Agent reported error…` for display. - pub message: String, - /// JSON-RPC error code parsed from the line's `(code N)` marker, or a - /// synthetic code for known bare prefixes. `None` for legacy-format - /// lines that carry no code (or when the code fails to parse as i64). - pub code: Option, -} - -pub fn meaningful_agent_error_from_log(path: &Path) -> Option { - let tail = read_log_tail(path, 200).ok()?; - tail.lines().rev().map(str::trim).find_map(|line| { - // New format: "Agent reported error (code -32002): ..." - if let Some(rest) = line.strip_prefix("Agent reported error (code ") { - if let Some(paren_end) = rest.find("): ") { - let code = rest[..paren_end].parse::().ok(); - return Some(AgentLogError { - message: line.to_string(), - code, - }); - } - } - // Legacy format (older buzz-acp builds): "Agent reported error: ..." - if line.starts_with("Agent reported error:") { - return Some(AgentLogError { - message: line.to_string(), - code: None, - }); - } - // Bare prefixes emitted by older agent binaries whose Display still leaks - // unwrapped errors. Promote these so they surface instead of the generic - // "harness exited with status N" fallback. - if line.starts_with("llm auth:") { - return Some(AgentLogError { - message: format!("Agent reported error: {line}"), - code: Some(-32001), - }); - } - if line.starts_with("llm model not found:") { - return Some(AgentLogError { - message: format!("Agent reported error: {line}"), - code: Some(-32002), - }); - } - None - }) -} - #[cfg(test)] #[path = "storage_tests.rs"] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac..29538dec03 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -830,3 +830,39 @@ fn install_log_filename_accepts_ordinary_runtime_ids() { ); } } + +// ── Fix 2: keyring chokepoint round-trip ───────────────────────────────────── + +/// Fix 2: `persist_agent_keys_with` strips nsec on healthy keyring. +#[test] +fn persist_agent_keys_strips_nsec_on_healthy_keyring() { + let store = FakeKeyStore::reachable(); + let mut record = record_with_pubkey_and_key("aabbcc", "nsec1abc"); + persist_agent_keys_with(&store, std::slice::from_mut(&mut record)); + assert!( + record.private_key_nsec.is_empty(), + "healthy keyring: nsec must be stripped" + ); + assert_eq!( + store + .stored + .borrow() + .get(&agent_keyring_name("aabbcc")) + .cloned(), + Some("nsec1abc".to_string()), + "healthy keyring: nsec must be in keyring" + ); +} + +/// Fix 2: `persist_agent_keys_with` keeps nsec inline when keyring unreachable. +#[test] +fn persist_agent_keys_keeps_nsec_inline_when_keyring_unreachable() { + let store = FakeKeyStore::unreachable(); + let mut record = record_with_pubkey_and_key("aabbcc", "nsec1abc"); + persist_agent_keys_with(&store, std::slice::from_mut(&mut record)); + assert_eq!( + record.private_key_nsec, "nsec1abc", + "unreachable: nsec stays inline" + ); + assert!(store.stored.borrow().is_empty()); +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/anchor.rs b/desktop/src-tauri/src/managed_agents/store_journal/anchor.rs new file mode 100644 index 0000000000..89e46a2a4f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/anchor.rs @@ -0,0 +1,77 @@ +//! Store-family anchor resolution. +//! +//! The anchor is the directory that holds `managed-agents.json`, `teams.json`, +//! `store-journal.sqlite`, and `store-journal.lock`. Lock identity is NEVER +//! derived from a possibly-absent file; the anchor is resolved from the app +//! identity unconditionally so two cooperating processes always converge to the +//! same authority. + +use std::path::{Path, PathBuf}; + +use tauri::{AppHandle, Manager}; + +use crate::migration::is_dev_data_dir_name; + +pub(super) const CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.buzz.app.dev"; +/// Journal filename beside `managed-agents.json`. +pub(super) const JOURNAL_FILENAME: &str = "store-journal.sqlite"; +/// Advisory lockfile name beside `managed-agents.json`. +pub(super) const ADVISORY_LOCK_FILENAME: &str = "store-journal.lock"; + +/// Resolve the store-family anchor directory. +/// +/// For shared dev worktrees (`BUZZ_SHARE_IDENTITY=1`): the canonical dev +/// `agents/` dir, returned **unconditionally** regardless of whether it +/// exists yet. Lock acquisition calls `create_dir_all`, so absent-on-first- +/// boot is not a reason to fall back. Falling back on absence would let two +/// simultaneous first-boot processes each choose their own local dir, giving +/// them different lock/journal authorities and making shared-state recovery +/// impossible (v34.1 §1). +/// +/// For standalone: `app_data_dir()/agents`. Never derived from +/// `managed-agents.json` — an absent file must never determine lock identity. +pub fn store_anchor_dir(app: &AppHandle) -> Result { + let local_agents = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))? + .join("agents"); + + // Only redirect to the canonical dev anchor when identity-sharing is active. + let is_shared = std::env::var("BUZZ_SHARE_IDENTITY") + .map(|v| v == "1") + .unwrap_or(false); + + if is_shared { + if let Some(anchor) = canonical_dev_anchor(&local_agents) { + // Return the canonical dev path UNCONDITIONALLY — do not branch on + // anchor.exists(). Lock acquisition will create the directory. + return Ok(anchor); + } + } + + Ok(local_agents) +} + +/// Compute the canonical dev anchor from a local `agents/` path. +/// Returns `None` when the path structure is unexpected. +/// Exposed as `canonical_dev_anchor_pub` for tests. +#[cfg(test)] +pub fn canonical_dev_anchor_pub(local_agents: &Path) -> Option { + canonical_dev_anchor(local_agents) +} + +pub(super) fn canonical_dev_anchor(local_agents: &Path) -> Option { + // local_agents = /agents + // AppDataDir = / + // canonical = //agents + let app_data_dir = local_agents.parent()?; + let data_parent = app_data_dir.parent()?; + let name = app_data_dir.file_name()?.to_str()?; + + if !is_dev_data_dir_name(name) { + return None; + } + + Some(data_parent.join(CANONICAL_DEV_IDENTIFIER).join("agents")) +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/boot.rs b/desktop/src-tauri/src/managed_agents/store_journal/boot.rs new file mode 100644 index 0000000000..45c4402bee --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/boot.rs @@ -0,0 +1,40 @@ +//! Pre-admission recovery gate: resolve anchor, run file-commit recovery, then +//! invoke boot migrations — all before any canonical-store reader or writer. +//! +//! Called once per process from `lib.rs` setup, immediately after reset +//! handling succeeds. A recovery failure (or unresolved interrupted commits) +//! propagates as `Err`; the caller sets `store_recovery_failed` and returns +//! early, keeping every journaled mutation path closed via `mutate_store`'s +//! entry guard. + +use tauri::AppHandle; + +use super::{run_recovery_gate, store_anchor_dir}; + +/// Run the pre-admission recovery gate for this process boot. +/// +/// 1. Resolves the store-family anchor directory — fails closed on error +/// (no `unwrap_or_default` fallback; a guessed path would inspect the wrong +/// journal and certify a store it never repaired). +/// 2. Creates the anchor directory if absent. +/// 3. Runs `run_recovery_gate`: file-commit recovery completes with zero +/// unresolved commits, then `store_work` executes. +/// 4. `store_work` runs the appropriate boot-migration sequence based on +/// `reset_completed`. +/// +/// Returns `Err` on any anchor, recovery, or migration failure. The caller +/// (`lib.rs`) sets `store_recovery_failed` and early-returns. +pub fn run_boot_recovery_gate(app: &AppHandle, reset_completed: bool) -> Result<(), String> { + let anchor = store_anchor_dir(app).map_err(|e| format!("resolve store anchor: {e}"))?; + + std::fs::create_dir_all(&anchor).map_err(|e| format!("create anchor dir: {e}"))?; + + run_recovery_gate(&anchor, || { + if reset_completed { + crate::migration::run_boot_migrations_after_reset(app); + } else { + crate::migration::run_boot_migrations(app); + } + Ok(()) + }) +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/codec.rs b/desktop/src-tauri/src/managed_agents/store_journal/codec.rs new file mode 100644 index 0000000000..a13313dc76 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/codec.rs @@ -0,0 +1,78 @@ +//! Fail-closed store codec (v6). +//! +//! Unknown or malformed content produces an error; no file write, no journal +//! transition, no relay enqueue may proceed after a failed decode. + +use crate::managed_agents::{ManagedAgentRecord, TeamRecord}; + +/// Parse error type. The raw bytes are never silently discarded — callers +/// receive this error and MUST NOT proceed with mutation. +#[derive(Debug)] +pub struct StoreDecodeError { + pub message: String, +} + +impl std::fmt::Display for StoreDecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +/// Decode `managed-agents.json` bytes using the fail-closed codec. +/// +/// Returns `Ok(records)` or `Err(StoreDecodeError)`. +/// On error the raw bytes are preserved in place (see `backup_invalid_store`). +/// The caller MUST NOT proceed with any mutation when this returns `Err`. +pub fn decode_agent_store(bytes: &[u8]) -> Result, StoreDecodeError> { + // Fail-closed: unknown/malformed content ⇒ error, zero mutation. + serde_json::from_slice(bytes).map_err(|e| StoreDecodeError { + message: format!("agent store decode failed: {e}"), + }) +} + +/// Decode `teams.json` bytes using the fail-closed codec. +pub fn decode_team_store(bytes: &[u8]) -> Result, StoreDecodeError> { + serde_json::from_slice(bytes).map_err(|e| StoreDecodeError { + message: format!("team store decode failed: {e}"), + }) +} + +/// Leniently decode a single `ManagedAgentRecord` from a `serde_json::Value` +/// that may contain unknown fields from a future schema version. +/// +/// Intended for **read-only** migration helpers that compute hashes or +/// inspect existing store records — not for decoding user-provided or +/// externally-sourced data (use `decode_agent_store` for those paths). +/// +/// Repeatedly strips unrecognized fields (identified from the serde error +/// message) and retries until decode succeeds or we've exhausted 32 attempts. +/// Returns `None` when the record is structurally invalid (missing required +/// fields, wrong types) after stripping. +pub fn decode_agent_record_permissive(mut v: serde_json::Value) -> Option { + for _ in 0..32 { + match serde_json::from_value::(v.clone()) { + Ok(r) => return Some(r), + Err(e) => { + // serde_json formats unknown-field errors as + // `unknown field ``, expected ...` + let msg = e.to_string(); + if let Some(name) = msg + .strip_prefix("unknown field `") + .and_then(|s| s.split('`').next()) + { + let field = name.to_string(); + if let Some(obj) = v.as_object_mut() { + obj.remove(&field); + } else { + return None; + } + } else { + // Not an unknown-field error (missing required field, type + // mismatch, etc.) — cannot recover. + return None; + } + } + } + } + None +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/events.rs b/desktop/src-tauri/src/managed_agents/store_journal/events.rs new file mode 100644 index 0000000000..fd861db175 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/events.rs @@ -0,0 +1,162 @@ +//! Immutable inbox/outbox row insertion and publication-state CAS. + +use rusqlite::{params, Connection, OptionalExtension}; + +use super::util::unix_now_secs; + +/// Outcome of an immutable-row insertion. +#[derive(Debug, PartialEq, Eq)] +pub enum InsertEventOutcome { + /// New row inserted. + Inserted, + /// A row with this `event_id` already exists and is byte-identical + /// (safe exact-replay idempotency). + ExactDuplicate, + /// A row with this `event_id` already exists but has different + /// `operation_id` or `payload` — identity collision, fail closed. + IdentityCollision, +} + +/// Insert an immutable outbox row. Fail-closed on identity collision: +/// a duplicate event_id is only accepted when operation_id and payload +/// are byte-identical. +/// +/// `retention_d_tag` is the exact d_tag used in the retention coordinate at +/// enqueue time — persisted so boot recovery can re-insert at the same +/// `(kind, pubkey, d_tag)` without re-parsing the event payload. +pub fn insert_outbox_event( + conn: &Connection, + event_id: &str, + operation_id: &str, + payload: &[u8], + retention_d_tag: &str, +) -> Result { + let now = unix_now_secs(); + // Try an unconditional insert first. + let affected = conn + .execute( + "INSERT OR IGNORE INTO outbox_events + (event_id, operation_id, payload, published_state, retention_d_tag, created_at) + VALUES (?1, ?2, ?3, 0, ?4, ?5)", + params![event_id, operation_id, payload, retention_d_tag, now], + ) + .map_err(|e| format!("insert_outbox_event({event_id}): {e}"))?; + + if affected == 1 { + return Ok(InsertEventOutcome::Inserted); + } + + // Row already exists — check identity. + let existing: Option<(String, Vec)> = conn + .query_row( + "SELECT operation_id, payload FROM outbox_events WHERE event_id = ?1", + params![event_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|e| format!("read existing outbox_event({event_id}): {e}"))?; + + match existing { + Some((op, pl)) if op == operation_id && pl == payload => { + Ok(InsertEventOutcome::ExactDuplicate) + } + _ => Ok(InsertEventOutcome::IdentityCollision), + } +} + +/// Advance publication state of an outbox event via CAS on +/// `expected_state → new_state`. Requires exactly one affected row. +pub fn mark_outbox_published( + conn: &Connection, + event_id: &str, + expected_state: i64, + new_state: i64, +) -> Result { + let affected = conn + .execute( + "UPDATE outbox_events SET published_state = ?1 + WHERE event_id = ?2 AND published_state = ?3", + params![new_state, event_id, expected_state], + ) + .map_err(|e| format!("mark_outbox_published({event_id}): {e}"))?; + Ok(affected == 1) +} + +/// Insert an immutable inbox row. Fail-closed on identity collision. +#[allow(dead_code)] // B1 substrate — used in compensation and recovery paths +pub fn insert_inbox_event( + conn: &Connection, + event_id: &str, + operation_id: &str, + payload: &[u8], +) -> Result { + let now = unix_now_secs(); + let affected = conn + .execute( + "INSERT OR IGNORE INTO inbox_events + (event_id, operation_id, payload, received_at) + VALUES (?1, ?2, ?3, ?4)", + params![event_id, operation_id, payload, now], + ) + .map_err(|e| format!("insert_inbox_event({event_id}): {e}"))?; + + if affected == 1 { + return Ok(InsertEventOutcome::Inserted); + } + + let existing: Option<(String, Vec)> = conn + .query_row( + "SELECT operation_id, payload FROM inbox_events WHERE event_id = ?1", + params![event_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|e| format!("read existing inbox_event({event_id}): {e}"))?; + + match existing { + Some((op, pl)) if op == operation_id && pl == payload => { + Ok(InsertEventOutcome::ExactDuplicate) + } + _ => Ok(InsertEventOutcome::IdentityCollision), + } +} + +/// Read outbox events for `operation_id`. +#[allow(clippy::type_complexity)] +pub fn read_outbox_events( + conn: &Connection, + operation_id: &str, +) -> Result, i64, String)>, String> { + let mut stmt = conn + .prepare( + "SELECT event_id, payload, published_state, retention_d_tag FROM outbox_events + WHERE operation_id = ?1 ORDER BY created_at", + ) + .map_err(|e| format!("prepare outbox query: {e}"))?; + let rows = stmt + .query_map(params![operation_id], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .map_err(|e| format!("query outbox: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("read outbox row: {e}")) +} + +/// Read inbox events for `operation_id`. +#[allow(dead_code)] // B1 substrate — used in recovery path tests and compensation +pub fn read_inbox_events( + conn: &Connection, + operation_id: &str, +) -> Result)>, String> { + let mut stmt = conn + .prepare( + "SELECT event_id, payload FROM inbox_events + WHERE operation_id = ?1 ORDER BY received_at", + ) + .map_err(|e| format!("prepare inbox query: {e}"))?; + let rows = stmt + .query_map(params![operation_id], |row| Ok((row.get(0)?, row.get(1)?))) + .map_err(|e| format!("query inbox: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("read inbox row: {e}")) +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/generations.rs b/desktop/src-tauri/src/managed_agents/store_journal/generations.rs new file mode 100644 index 0000000000..8c9adb1cae --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/generations.rs @@ -0,0 +1,136 @@ +//! Per-key generation CAS and tombstone metadata. + +use rusqlite::{params, Connection, OptionalExtension}; + +use super::util::unix_now_secs; + +/// Generation counter (u64 stored as TEXT to avoid SQLite's i64 ceiling). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Generation(pub u64); + +impl Generation { + pub fn zero() -> Self { + Generation(0) + } + pub fn next(self) -> Result { + self.0 + .checked_add(1) + .map(Generation) + .ok_or_else(|| format!("generation overflow at {}: CAS cannot advance", self.0)) + } + pub(super) fn from_str(s: &str) -> Result { + s.parse::() + .map(Generation) + .map_err(|e| format!("parse generation '{s}': {e}")) + } + pub(super) fn to_db_str(self) -> String { + self.0.to_string() + } +} + +/// Outcome of a generation CAS attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CasOutcome { + /// CAS succeeded; `new_generation` is the committed value. + Committed { new_generation: Generation }, + /// The stored generation did not match `expected`; the current value + /// is returned so the caller can decide how to proceed. + Conflict { current: Generation }, + /// The key has a tombstone; ABA rejected. + Tombstoned { tombstone_generation: Generation }, +} + +/// Read the current generation for `key_id`. +/// Returns `(Generation::zero(), false)` when no row exists. +pub fn read_generation(conn: &Connection, key_id: &str) -> Result<(Generation, bool), String> { + let row: Option<(String, bool)> = conn + .query_row( + "SELECT generation, is_tombstone FROM key_generations WHERE key_id = ?1", + params![key_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(|e| format!("read_generation({key_id}): {e}"))?; + + match row { + None => Ok((Generation::zero(), false)), + Some((gen_str, is_tombstone)) => Ok((Generation::from_str(&gen_str)?, is_tombstone)), + } +} + +/// Attempt a generation CAS on `key_id`. +/// +/// If `expected` matches the stored generation (or key is absent and +/// `expected` is zero), advance to `expected.next()` and return +/// `CasOutcome::Committed`. Tombstoned keys return `CasOutcome::Tombstoned`. +pub fn cas_generation( + conn: &Connection, + key_id: &str, + expected: Generation, +) -> Result { + let (current, is_tombstone) = read_generation(conn, key_id)?; + + if is_tombstone { + return Ok(CasOutcome::Tombstoned { + tombstone_generation: current, + }); + } + + if current != expected { + return Ok(CasOutcome::Conflict { current }); + } + + let new_gen = expected.next()?; + let now = unix_now_secs(); + conn.execute( + "INSERT INTO key_generations (key_id, generation, is_tombstone, updated_at) + VALUES (?1, ?2, 0, ?3) + ON CONFLICT(key_id) DO UPDATE SET + generation = excluded.generation, + is_tombstone = 0, + updated_at = excluded.updated_at", + params![key_id, new_gen.to_db_str(), now], + ) + .map_err(|e| format!("cas_generation({key_id}): {e}"))?; + + Ok(CasOutcome::Committed { + new_generation: new_gen, + }) +} + +/// Write a tombstone for `key_id` at `expected` generation. +/// Kept forever; `cas_generation` respects it to prevent ABA. +pub fn tombstone_key( + conn: &Connection, + key_id: &str, + expected: Generation, +) -> Result { + let (current, is_tombstone) = read_generation(conn, key_id)?; + + if is_tombstone { + return Ok(CasOutcome::Tombstoned { + tombstone_generation: current, + }); + } + + if current != expected { + return Ok(CasOutcome::Conflict { current }); + } + + let tombstone_gen = expected.next()?; + let now = unix_now_secs(); + conn.execute( + "INSERT INTO key_generations (key_id, generation, is_tombstone, updated_at) + VALUES (?1, ?2, 1, ?3) + ON CONFLICT(key_id) DO UPDATE SET + generation = excluded.generation, + is_tombstone = 1, + updated_at = excluded.updated_at", + params![key_id, tombstone_gen.to_db_str(), now], + ) + .map_err(|e| format!("tombstone_key({key_id}): {e}"))?; + + Ok(CasOutcome::Committed { + new_generation: tombstone_gen, + }) +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/lock.rs b/desktop/src-tauri/src/managed_agents/store_journal/lock.rs new file mode 100644 index 0000000000..368a19f8d7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/lock.rs @@ -0,0 +1,115 @@ +//! Interprocess advisory lock (RAII guard). +//! +//! Unix: `flock(2)`. Windows: named mutex. Other platforms: no-op. + +use std::path::Path; + +use super::anchor::ADVISORY_LOCK_FILENAME; + +/// RAII guard for the interprocess advisory lock. +pub struct JournalLockGuard { + #[cfg(unix)] + #[allow(dead_code)] + file: std::fs::File, + #[cfg(windows)] + mutex_handle: windows_sys::Win32::Foundation::HANDLE, + #[cfg(not(any(unix, windows)))] + _phantom: (), +} + +impl JournalLockGuard { + /// Acquire the exclusive advisory lock for `anchor_dir`, blocking until + /// the lock is available. + pub fn acquire(anchor_dir: &Path) -> Result { + std::fs::create_dir_all(anchor_dir) + .map_err(|e| format!("create anchor dir {}: {e}", anchor_dir.display()))?; + let lock_path = anchor_dir.join(ADVISORY_LOCK_FILENAME); + + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .map_err(|e| format!("open journal lock {}: {e}", lock_path.display()))?; + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + return Err(format!("journal flock {}: {err}", lock_path.display())); + } + Ok(JournalLockGuard { file }) + } + + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + // Derive a unique mutex name from the lock file path. + let path_str = lock_path + .to_str() + .unwrap_or("buzz-store-journal") + .replace(['\\', '/', ':'], "-"); + let name: Vec = std::ffi::OsStr::new(&format!("Global\\{path_str}")) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let handle = unsafe { + windows_sys::Win32::System::Threading::CreateMutexW( + std::ptr::null_mut(), + 0, + name.as_ptr(), + ) + }; + if handle.is_null() || handle == windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE { + return Err(format!("CreateMutexW failed for journal lock")); + } + let wait = unsafe { + windows_sys::Win32::System::Threading::WaitForSingleObject( + handle, + windows_sys::Win32::System::Threading::INFINITE, + ) + }; + // WAIT_OBJECT_0 (0x00) — acquired normally. + // WAIT_ABANDONED (0x80) — prior holder crashed; mutex ownership + // transferred to us. The journal may be in an intermediate state; + // boot recovery will repair it. Treating this as a failure would + // permanently deadlock on a crashed-holder scenario — the exact + // case this substrate exists to handle. + // WAIT_FAILED (0xFFFFFFFF) or any other value — genuine error. + const WAIT_OBJECT_0: u32 = 0x00000000; + const WAIT_ABANDONED: u32 = 0x00000080; + if wait != WAIT_OBJECT_0 && wait != WAIT_ABANDONED { + let err = unsafe { windows_sys::Win32::Foundation::GetLastError() }; + unsafe { windows_sys::Win32::Foundation::CloseHandle(handle) }; + return Err(format!( + "WaitForSingleObject failed: wait={wait:#010x} last_error={err}" + )); + } + if wait == WAIT_ABANDONED { + eprintln!( + "buzz-desktop: journal lock: acquired abandoned mutex — \ + prior holder crashed, boot recovery will repair state" + ); + } + return Ok(JournalLockGuard { + mutex_handle: handle, + }); + } + + #[cfg(not(any(unix, windows)))] + { + Ok(JournalLockGuard { _phantom: () }) + } + } +} + +#[cfg(windows)] +impl Drop for JournalLockGuard { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::System::Threading::ReleaseMutex(self.mutex_handle); + windows_sys::Win32::Foundation::CloseHandle(self.mutex_handle); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/mod.rs b/desktop/src-tauri/src/managed_agents/store_journal/mod.rs new file mode 100644 index 0000000000..2e548aac05 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/mod.rs @@ -0,0 +1,87 @@ +//! B1 transactional managed-agents store substrate. +//! +//! `managed-agents.json` / `teams.json` stay the canonical user-visible files. +//! `store-journal.sqlite` (beside them, at the **store-family anchor**) holds +//! shared recovery facts: operation records, per-key generation / tombstone +//! metadata, immutable inbox/outbox rows. +//! +//! **Anchor**: canonical dev `agents/` dir for shared worktrees +//! (`BUZZ_SHARE_IDENTITY=1`); the bundle's own `agents/` dir for standalone. +//! Lock identity is never derived from a possibly-absent file. +//! +//! **Lock sequence**: in-process `AppState::managed_agents_store_lock` → +//! anchored OS advisory lock (`flock`/named-mutex) → fresh decode → mutation +//! closure → atomic fsync write → release. Network I/O and keyring access +//! stay outside every critical section. +//! +//! **Posture**: protects against crashes and concurrent cooperating processes +//! on one machine only. Does not defend against adversarial same-user +//! tampering, cross-machine duplication, supply-chain attack, mixed-version +//! writers, sign-out/reset races, or concurrent bundles. + +mod anchor; +mod boot; +mod codec; +mod events; +mod generations; +mod lock; +mod operations; +mod schema; +mod txn; +mod util; +mod writer; + +// ── Public re-exports ───────────────────────────────────────────────────────── + +#[cfg(test)] +pub use anchor::canonical_dev_anchor_pub; +pub use anchor::store_anchor_dir; + +pub use boot::run_boot_recovery_gate; + +#[cfg(test)] +#[allow(unused_imports)] +pub use codec::StoreDecodeError; +pub use codec::{decode_agent_record_permissive, decode_agent_store, decode_team_store}; + +pub use events::{insert_inbox_event, mark_outbox_published}; +#[cfg(test)] +pub use events::{insert_outbox_event, read_inbox_events, read_outbox_events, InsertEventOutcome}; + +pub use generations::{cas_generation, read_generation, tombstone_key, CasOutcome, Generation}; + +pub use lock::JournalLockGuard; + +pub use operations::{advance_disposition, insert_operation, Disposition}; +#[cfg(test)] +#[allow(unused_imports)] +pub use operations::{ + pin_compensation, read_nonterminal_operations, read_operation, set_nonterminal_follow_up, + OperationRecord, TransitionOutcome, +}; + +#[cfg(test)] +pub use schema::apply_journal_schema_pub; +pub use schema::open_journal; + +pub use txn::{ + advance_to_committed, mutate_store, prepare_publication, run_boot_recovery, run_recovery_gate, + StoreState, +}; +#[cfg(test)] +#[allow(unused_imports)] +pub(crate) use txn::{ + file_commit_recovery_at_pub, read_store, reject_if_recovery_failed, run_boot_recovery_at, +}; + +pub use util::new_operation_id; + +pub use writer::{atomic_write_restricted_with_fsync, atomic_write_with_fsync}; + +#[cfg(test)] +#[path = "../store_journal_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "../store_journal_fix_tests.rs"] +mod fix_tests; diff --git a/desktop/src-tauri/src/managed_agents/store_journal/operations.rs b/desktop/src-tauri/src/managed_agents/store_journal/operations.rs new file mode 100644 index 0000000000..2ef0da4589 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/operations.rs @@ -0,0 +1,334 @@ +//! Operation (saga spine): insert, read, disposition CAS fences. + +use rusqlite::{params, Connection, OptionalExtension}; + +use super::generations::Generation; +use super::util::unix_now_secs; + +/// Operation disposition values. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Disposition { + Pending, + Committed, + Compensating, + Compensated, + Failed, + /// Published to relay but outcome unknown (network outage). + Uncertain, + /// Accepted by relay; final state reached without full confirmation. + Accepted, +} + +impl Disposition { + pub fn as_str(&self) -> &'static str { + match self { + Disposition::Pending => "pending", + Disposition::Committed => "committed", + Disposition::Compensating => "compensating", + Disposition::Compensated => "compensated", + Disposition::Failed => "failed", + Disposition::Uncertain => "uncertain", + Disposition::Accepted => "accepted", + } + } + + pub(super) fn from_str(s: &str) -> Option { + match s { + "pending" => Some(Disposition::Pending), + "committed" => Some(Disposition::Committed), + "compensating" => Some(Disposition::Compensating), + "compensated" => Some(Disposition::Compensated), + "failed" => Some(Disposition::Failed), + "uncertain" => Some(Disposition::Uncertain), + "accepted" => Some(Disposition::Accepted), + _ => None, + } + } + + /// True when the operation is in a terminal state requiring no further + /// progression. + #[allow(dead_code)] // B2 substrate — used in recovery path tests + pub fn is_terminal(&self) -> bool { + matches!( + self, + Disposition::Committed | Disposition::Compensated | Disposition::Failed + ) + } + + /// True when the operation may require a nonterminal follow-up check + /// (uncertain or accepted publication outcome). + #[allow(dead_code)] // B2 substrate — used in recovery path tests + pub fn requires_follow_up(&self) -> bool { + matches!(self, Disposition::Uncertain | Disposition::Accepted) + } +} + +/// A record from the `operations` table. +#[derive(Debug, Clone)] +pub struct OperationRecord { + pub operation_id: String, + pub kind: String, + pub key_id: String, + pub disposition: Disposition, + /// Current committed generation at operation record time. + #[allow(dead_code)] // B2 substrate — exposed for recovery integration tests + pub generation: Generation, + /// UUID of the active compensation event, if in `Compensating` state. + #[allow(dead_code)] // B2 substrate — compensation claim fence + pub compensation_id: Option, + /// Generation snapshot at compensation start. + #[allow(dead_code)] // B2 substrate — compensation claim fence + pub compensation_generation: Option, + /// Whether a nonterminal follow-up is pending (uncertain/accepted publication). + #[allow(dead_code)] // B2 substrate — saga follow-up gate + pub nonterminal_follow_up: bool, +} + +/// Insert a new `pending` operation record. +pub fn insert_operation( + conn: &Connection, + operation_id: &str, + kind: &str, + key_id: &str, + generation: Generation, +) -> Result<(), String> { + let now = unix_now_secs(); + conn.execute( + "INSERT INTO operations + (operation_id, kind, key_id, disposition, generation, created_at, updated_at) + VALUES (?1, ?2, ?3, 'pending', ?4, ?5, ?5)", + params![operation_id, kind, key_id, generation.to_db_str(), now], + ) + .map_err(|e| format!("insert_operation({operation_id}): {e}"))?; + Ok(()) +} + +/// Read one operation record. Returns `None` when not found. +#[allow(clippy::type_complexity)] +pub fn read_operation( + conn: &Connection, + operation_id: &str, +) -> Result, String> { + let row: Option<( + String, + String, + String, + String, + String, + Option, + Option, + bool, + )> = conn + .query_row( + "SELECT operation_id, kind, key_id, disposition, generation, + compensation_id, compensation_generation, nonterminal_follow_up + FROM operations WHERE operation_id = ?1", + params![operation_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }, + ) + .optional() + .map_err(|e| format!("read_operation({operation_id}): {e}"))?; + + row.map( + |(op_id, kind, key_id, disp_str, gen_str, comp_id, comp_gen_str, nf)| { + let disposition = Disposition::from_str(&disp_str) + .ok_or_else(|| format!("unknown disposition '{disp_str}'"))?; + let generation = Generation::from_str(&gen_str)?; + let compensation_generation = + comp_gen_str.map(|s| Generation::from_str(&s)).transpose()?; + Ok(OperationRecord { + operation_id: op_id, + kind, + key_id, + disposition, + generation, + compensation_id: comp_id, + compensation_generation, + nonterminal_follow_up: nf, + }) + }, + ) + .transpose() +} + +/// Outcome of a fenced disposition transition. +#[derive(Debug, PartialEq, Eq)] +pub enum TransitionOutcome { + /// Transition succeeded; operation is now in `new_disposition`. + Advanced, + /// The stored disposition did not match `expected_disposition`; the + /// operation is in `actual_disposition`. + Conflict { actual_disposition: String }, + /// The operation was not found. + NotFound, +} + +/// Advance an operation's disposition via SQL CAS on (operation_id, +/// expected_disposition). Requires exactly one affected row and reports +/// `Conflict` or `NotFound` distinctly — never a silent no-op. +pub fn advance_disposition( + conn: &Connection, + operation_id: &str, + expected_disposition: &Disposition, + new_disposition: &Disposition, +) -> Result { + let now = unix_now_secs(); + let affected = conn + .execute( + "UPDATE operations SET disposition = ?1, updated_at = ?2 + WHERE operation_id = ?3 AND disposition = ?4", + params![ + new_disposition.as_str(), + now, + operation_id, + expected_disposition.as_str(), + ], + ) + .map_err(|e| format!("advance_disposition({operation_id}): {e}"))?; + + if affected == 1 { + return Ok(TransitionOutcome::Advanced); + } + + // Distinguish NotFound from Conflict by reading the current row. + match read_operation(conn, operation_id)? { + None => Ok(TransitionOutcome::NotFound), + Some(op) => Ok(TransitionOutcome::Conflict { + actual_disposition: op.disposition.as_str().to_owned(), + }), + } +} + +/// Pin the active compensation event for an operation (v10 claim fence). +/// Only allowed when disposition is exactly `Pending` — transitions to +/// `Compensating`. Requires exactly one affected row. +#[allow(dead_code)] +pub fn pin_compensation( + conn: &Connection, + operation_id: &str, + compensation_id: &str, + compensation_generation: Generation, +) -> Result { + let now = unix_now_secs(); + let affected = conn + .execute( + "UPDATE operations + SET disposition = 'compensating', + compensation_id = ?1, + compensation_generation = ?2, + updated_at = ?3 + WHERE operation_id = ?4 AND disposition = 'pending'", + params![ + compensation_id, + compensation_generation.to_db_str(), + now, + operation_id, + ], + ) + .map_err(|e| format!("pin_compensation({operation_id}): {e}"))?; + + if affected == 1 { + return Ok(TransitionOutcome::Advanced); + } + + match read_operation(conn, operation_id)? { + None => Ok(TransitionOutcome::NotFound), + Some(op) => Ok(TransitionOutcome::Conflict { + actual_disposition: op.disposition.as_str().to_owned(), + }), + } +} + +/// Mark that a nonterminal follow-up is required (uncertain/accepted +/// publication outcome — v12). CAS on expected disposition. +#[allow(dead_code)] +pub fn set_nonterminal_follow_up( + conn: &Connection, + operation_id: &str, + expected_disposition: &Disposition, + required: bool, +) -> Result { + let now = unix_now_secs(); + let affected = conn + .execute( + "UPDATE operations SET nonterminal_follow_up = ?1, updated_at = ?2 + WHERE operation_id = ?3 AND disposition = ?4", + params![ + required as i64, + now, + operation_id, + expected_disposition.as_str(), + ], + ) + .map_err(|e| format!("set_nonterminal_follow_up({operation_id}): {e}"))?; + + if affected == 1 { + return Ok(TransitionOutcome::Advanced); + } + + match read_operation(conn, operation_id)? { + None => Ok(TransitionOutcome::NotFound), + Some(op) => Ok(TransitionOutcome::Conflict { + actual_disposition: op.disposition.as_str().to_owned(), + }), + } +} + +/// Read all non-terminal operations for recovery. +pub fn read_nonterminal_operations(conn: &Connection) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT operation_id, kind, key_id, disposition, generation, + compensation_id, compensation_generation, nonterminal_follow_up + FROM operations + WHERE disposition NOT IN ('committed', 'compensated', 'failed')", + ) + .map_err(|e| format!("prepare nonterminal ops: {e}"))?; + + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, bool>(7)?, + )) + }) + .map_err(|e| format!("query nonterminal ops: {e}"))?; + + let mut out = Vec::new(); + for row in rows { + let (op_id, kind, key_id, disp_str, gen_str, comp_id, comp_gen_str, nf) = + row.map_err(|e| format!("read nonterminal op row: {e}"))?; + let disposition = Disposition::from_str(&disp_str) + .ok_or_else(|| format!("unknown disposition '{disp_str}'"))?; + let generation = Generation::from_str(&gen_str)?; + let compensation_generation = comp_gen_str.map(|s| Generation::from_str(&s)).transpose()?; + out.push(OperationRecord { + operation_id: op_id, + kind, + key_id, + disposition, + generation, + compensation_id: comp_id, + compensation_generation, + nonterminal_follow_up: nf, + }); + } + Ok(out) +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/schema.rs b/desktop/src-tauri/src/managed_agents/store_journal/schema.rs new file mode 100644 index 0000000000..2f05c374fc --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/schema.rs @@ -0,0 +1,205 @@ +//! Journal database: open, schema application. + +use std::path::Path; + +use rusqlite::Connection; + +use super::anchor::JOURNAL_FILENAME; + +/// Open (or create) `store-journal.sqlite` at `anchor_dir` (WAL mode, 5 s +/// busy timeout) and apply the schema idempotently. +pub fn open_journal(anchor_dir: &Path) -> Result { + std::fs::create_dir_all(anchor_dir).map_err(|e| format!("create anchor dir: {e}"))?; + let path = anchor_dir.join(JOURNAL_FILENAME); + let conn = Connection::open(&path).map_err(|e| format!("open store-journal.sqlite: {e}"))?; + + conn.pragma_update(None, "busy_timeout", 5000) + .map_err(|e| format!("set busy_timeout: {e}"))?; + conn.pragma_update(None, "journal_mode", "WAL") + .map_err(|e| format!("set WAL mode: {e}"))?; + conn.pragma_update(None, "foreign_keys", "ON") + .map_err(|e| format!("enable foreign_keys: {e}"))?; + + apply_journal_schema(&conn)?; + Ok(conn) +} + +/// Apply all journal schema migrations idempotently. +/// Exposed as `apply_journal_schema_pub` for tests. +#[cfg(test)] +pub fn apply_journal_schema_pub(conn: &Connection) -> Result<(), String> { + apply_journal_schema(conn) +} + +pub(super) fn apply_journal_schema(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + " + -- Per-key generation / tombstone metadata. + -- generation stored as TEXT to preserve full u64 range. + -- is_tombstone=1: key deleted; generation kept forever (no GC). + CREATE TABLE IF NOT EXISTS key_generations ( + key_id TEXT NOT NULL PRIMARY KEY, + generation TEXT NOT NULL, + is_tombstone INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 + ); + + -- Saga spine. disposition: pending|committed|compensating| + -- compensated|failed|uncertain|accepted. + -- compensation_id/generation: phased claim fence (v10/v12). + -- nonterminal_follow_up: 1 if uncertain/accepted needs a recheck. + CREATE TABLE IF NOT EXISTS operations ( + operation_id TEXT NOT NULL PRIMARY KEY, + kind TEXT NOT NULL, + key_id TEXT NOT NULL, + disposition TEXT NOT NULL DEFAULT 'pending', + generation TEXT NOT NULL, + compensation_id TEXT, + compensation_generation TEXT, + nonterminal_follow_up INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + -- Immutable outbox rows (written once; publication progress tracked + -- via append-only operation/event phase transitions, not a mutable + -- flag). + -- published_state: 0=pending, 1=published, 2=uncertain, 3=accepted. + -- Advancing published_state is a fenced CAS; see mark_outbox_published. + -- retention_d_tag: the exact d_tag used in the retention coordinate at + -- enqueue time -- persisted so boot recovery can re-insert at the same + -- (kind, pubkey, d_tag) coordinate without re-deriving it from the + -- event payload, which may not carry a d-tag (e.g. kind-5 tombstones + -- use a synthetic key like 30177:). + CREATE TABLE IF NOT EXISTS outbox_events ( + event_id TEXT NOT NULL PRIMARY KEY, + operation_id TEXT NOT NULL + REFERENCES operations(operation_id), + payload BLOB NOT NULL, + published_state INTEGER NOT NULL DEFAULT 0, + retention_d_tag TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL + ); + + -- Immutable inbox rows (written once, never updated). + CREATE TABLE IF NOT EXISTS inbox_events ( + event_id TEXT NOT NULL PRIMARY KEY, + operation_id TEXT NOT NULL + REFERENCES operations(operation_id), + payload BLOB NOT NULL, + received_at INTEGER NOT NULL + ); + + -- Two-phase file-commit record. + -- + -- Written inside the same SQLite transaction as operation/generation + -- mutations. Tracks the progression of a mutate_store call through + -- its three file-commit phases so boot recovery can determine how far + -- a crashed commit progressed and finish or compensate it. + -- + -- phase: + -- 'intent' - journal transaction committed; staged files + -- written + fsynced; no rename has occurred. + -- 'first_renamed' - managed-agents.json.stage renamed to canonical. + -- 'committed' - teams.json.stage renamed to canonical; complete. + -- + -- agents_stage_path / teams_stage_path name the temp files written + -- before rename. Recovery checks for them to decide what remains to do. + CREATE TABLE IF NOT EXISTS file_commit_phases ( + commit_id TEXT NOT NULL PRIMARY KEY, + operation_id TEXT NOT NULL, + phase TEXT NOT NULL DEFAULT 'intent', + agents_stage_path TEXT NOT NULL, + teams_stage_path TEXT NOT NULL, + agents_content_hash TEXT NOT NULL DEFAULT '', + teams_content_hash TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + ", + ) + .map_err(|e| format!("apply journal schema: {e}"))?; + + // Schema upgrades (v3: content-hash columns; v4: retention_d_tag). + // + // We inspect existing columns via PRAGMA table_info (not "duplicate column" + // errors) so genuine failures (SQLITE_BUSY, disk full, corrupt schema) are + // propagated and the version is NOT silently advanced on partial migration. + // All ADD COLUMN operations and the user_version stamp happen inside a + // single transaction so the DB cannot be left half-migrated with a + // "completed" version stamp. + let current_version: u32 = conn + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap_or(0); + + if current_version < 4 { + // Read existing column names BEFORE opening the transaction. + // PRAGMA table_info cannot run inside a BEGIN on some SQLite builds. + let fcp_cols = table_column_names(conn, "file_commit_phases")?; + let oe_cols = table_column_names(conn, "outbox_events")?; + let need_agents_hash = !fcp_cols.contains(&"agents_content_hash".to_string()); + let need_teams_hash = !fcp_cols.contains(&"teams_content_hash".to_string()); + let need_d_tag = !oe_cols.contains(&"retention_d_tag".to_string()); + + // Run all ADD COLUMN calls and the version stamp in one transaction. + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("begin migration transaction: {e}"))?; + if need_agents_hash { + tx.execute_batch( + "ALTER TABLE file_commit_phases ADD COLUMN agents_content_hash TEXT NOT NULL DEFAULT \'\';", + ) + .map_err(|e| format!("add agents_content_hash: {e}"))?; + } + if need_teams_hash { + tx.execute_batch( + "ALTER TABLE file_commit_phases ADD COLUMN teams_content_hash TEXT NOT NULL DEFAULT \'\';", + ) + .map_err(|e| format!("add teams_content_hash: {e}"))?; + } + if need_d_tag { + tx.execute_batch( + "ALTER TABLE outbox_events ADD COLUMN retention_d_tag TEXT NOT NULL DEFAULT \'\';", + ) + .map_err(|e| format!("add retention_d_tag: {e}"))?; + } + tx.pragma_update(None, "user_version", 4) + .map_err(|e| format!("set schema user_version to 4: {e}"))?; + tx.commit() + .map_err(|e| format!("commit migration transaction: {e}"))?; + + // Post-migration verification (after commit — PRAGMA runs outside tx). + let fcp_after = table_column_names(conn, "file_commit_phases")?; + let oe_after = table_column_names(conn, "outbox_events")?; + for col in &["agents_content_hash", "teams_content_hash"] { + if !fcp_after.contains(&col.to_string()) { + return Err(format!( + "schema migration: file_commit_phases.{col} absent after migration" + )); + } + } + if !oe_after.contains(&"retention_d_tag".to_string()) { + return Err( + "schema migration: outbox_events.retention_d_tag absent after migration" + .to_string(), + ); + } + } + + Ok(()) +} + +/// Return the column names of `table` via `PRAGMA table_info`. +fn table_column_names(conn: &Connection, table: &str) -> Result, String> { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(|e| format!("PRAGMA table_info({table}): {e}"))?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| format!("query table_info({table}): {e}"))?; + let mut names = Vec::new(); + for row in rows { + names.push(row.map_err(|e| format!("read table_info({table}) row: {e}"))?); + } + Ok(names) +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/txn.rs b/desktop/src-tauri/src/managed_agents/store_journal/txn.rs new file mode 100644 index 0000000000..d1646979c5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/txn.rs @@ -0,0 +1,981 @@ +//! Closure-only mutation API (v34.1) and boot recovery driver. +//! +//! `mutate_store` is the **only** write entry point for the managed-agents +//! store. It owns the full lock sequence: +//! +//! in-process mutex (caller-held `store_mutex_guard`) +//! → anchored OS advisory lock (acquired here) +//! → fresh fail-closed decode +//! → SQLite `BEGIN` transaction (journal mutations + file_commit_phase record) +//! → stage both JSON files (fsync before rename) +//! → SQLite `COMMIT` the transaction +//! → rename managed-agents.json.stage (record 'first_renamed' phase) +//! → rename teams.json.stage (record 'committed' phase) +//! → release OS advisory lock +//! +//! A closure returning `Err` rolls back the SQLite transaction — no journal +//! row is written, no file is staged. A crash after `COMMIT` but before the +//! final rename is recovered at next boot via `run_boot_recovery_at`, which +//! inspects the `file_commit_phases` row and re-executes the pending renames. +//! +//! Network I/O and keyring access stay outside every critical section — +//! they are driven by durable operation phases recorded in the journal. + +use std::path::Path; +use std::sync::MutexGuard; + +use rusqlite::params; +use tauri::AppHandle; +use tauri::Manager; + +use crate::managed_agents::{ManagedAgentRecord, TeamRecord}; + +use super::anchor::store_anchor_dir; +use super::codec::{decode_agent_store, decode_team_store}; +use super::events::{insert_outbox_event, read_outbox_events, InsertEventOutcome}; +use super::lock::JournalLockGuard; +use super::operations::{advance_disposition, read_nonterminal_operations, Disposition}; +use super::schema::open_journal; +use super::util::{new_operation_id, unix_now_secs}; + +/// The decoded state passed to a mutation or read closure. +pub struct StoreState<'a> { + /// Agent records decoded from `managed-agents.json` (all records — + /// instances with a pubkey AND key-less definitions). + pub agents: Vec, + /// Team records decoded from `teams.json`. + pub teams: Vec, + /// Open journal connection (anchor-locked, inside a live SQLite transaction). + pub journal: &'a rusqlite::Connection, + /// Anchor directory (for constructing file paths). + #[allow(dead_code)] + pub anchor: &'a Path, +} + +/// File-commit phase values recorded in `file_commit_phases`. +#[derive(Debug, Clone, PartialEq, Eq)] +enum FileCommitPhase { + Intent, + FirstRenamed, + Committed, +} + +impl FileCommitPhase { + fn as_str(&self) -> &'static str { + match self { + FileCommitPhase::Intent => "intent", + FileCommitPhase::FirstRenamed => "first_renamed", + FileCommitPhase::Committed => "committed", + } + } + + fn from_str(s: &str) -> Option { + match s { + "intent" => Some(FileCommitPhase::Intent), + "first_renamed" => Some(FileCommitPhase::FirstRenamed), + "committed" => Some(FileCommitPhase::Committed), + _ => None, + } + } +} + +/// Advance the `file_commit_phases` row to a new phase via SQL CAS. +/// Uses `INSERT OR REPLACE` pattern: writes a new row on `intent`, updates on +/// subsequent phases. The `updated_at` timestamp provides a monotonic audit trail. +fn advance_file_commit_phase( + conn: &rusqlite::Connection, + commit_id: &str, + new_phase: &FileCommitPhase, +) -> Result<(), String> { + let now = unix_now_secs(); + conn.execute( + "UPDATE file_commit_phases SET phase = ?1, updated_at = ?2 + WHERE commit_id = ?3", + params![new_phase.as_str(), now, commit_id], + ) + .map_err(|e| format!("advance_file_commit_phase({commit_id}): {e}"))?; + Ok(()) +} + +/// Run file-commit recovery and, only if it succeeds with zero unresolved +/// commits, execute `store_work`. +/// +/// This is the structural seam that enforces "recovery before canonical-store +/// access". Both production setup and tests drive the same function, so the +/// ordering invariant lives in one place and cannot drift. +/// +/// Returns `Err` if recovery fails (`Err` or unresolved count > 0) or if +/// `store_work` returns `Err`. On recovery failure `store_work` is never +/// called. +pub fn run_recovery_gate(anchor: &std::path::Path, store_work: F) -> Result<(), String> +where + F: FnOnce() -> Result<(), String>, +{ + let unresolved = file_commit_recovery_at(anchor)?; + if unresolved > 0 { + return Err(format!( + "file-commit recovery: {unresolved} nonterminal commit(s) could not be resolved" + )); + } + store_work() +} + +/// Reject a store mutation when the boot recovery flag is set. +/// +/// Extracted for direct unit testing — `mutate_store` delegates to this +/// function so the admission guard is testable without a full AppHandle. +#[cfg_attr(test, allow(dead_code))] +pub(crate) fn reject_if_recovery_failed( + flag: &std::sync::atomic::AtomicBool, +) -> Result<(), String> { + if flag.load(std::sync::atomic::Ordering::Acquire) { + return Err( + "store mutation rejected: boot file-commit recovery failed; \ + relaunch to retry" + .to_string(), + ); + } + Ok(()) +} + +/// Mutate the store under the full lock sequence. +/// +/// The closure runs inside a real `rusqlite::Transaction` — if it returns +/// `Err`, all journal writes are rolled back and no file is staged. Only +/// after `COMMIT` does `mutate_store` proceed to rename the staged files. +/// +/// On any decode error inside this function no file is written, no journal +/// transition occurs, and the error is propagated with `?`. +/// +/// Returns `Err` immediately if the `store_recovery_failed` flag is set on +/// `AppState` — a recovery failure at boot means the store is in an uncertain +/// state and no mutations are safe until the user relaunches. +pub fn mutate_store<'g, F, T>( + app: &AppHandle, + store_mutex_guard: MutexGuard<'g, ()>, + mutation: F, +) -> Result<(T, MutexGuard<'g, ()>), String> +where + F: FnOnce(StoreState<'_>) -> Result<(Vec, Vec, T), String>, +{ + // Guard: a boot recovery failure means the store is in an uncertain state. + // Reject all mutations until the user relaunches and recovery succeeds. + let state = app.state::(); + reject_if_recovery_failed(&state.store_recovery_failed)?; + + let anchor = store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor).map_err(|e| format!("create anchor dir: {e}"))?; + + // Acquire advisory lock while holding the in-process mutex. + let _advisory = JournalLockGuard::acquire(&anchor)?; + + let agents_path = anchor.join("managed-agents.json"); + let teams_path = anchor.join("teams.json"); + + // Fresh fail-closed decode — any parse error is propagated; no mutation. + let agents: Vec = if agents_path.exists() { + let bytes = + std::fs::read(&agents_path).map_err(|e| format!("read managed-agents.json: {e}"))?; + decode_agent_store(&bytes).map_err(|e| { + crate::managed_agents::storage::backup_invalid_store(&agents_path); + e.message + })? + } else { + Vec::new() + }; + + let teams: Vec = if teams_path.exists() { + let bytes = std::fs::read(&teams_path).map_err(|e| format!("read teams.json: {e}"))?; + decode_team_store(&bytes).map_err(|e| { + crate::managed_agents::storage::backup_invalid_store(&teams_path); + e.message + })? + } else { + Vec::new() + }; + + let mut journal = open_journal(&anchor)?; + + // ── Phase 1: SQLite transaction wrapping ALL journal mutations ──────────── + // + // The closure runs INSIDE a rusqlite::Transaction. If the closure returns + // Err, the transaction rolls back automatically on drop. Only if the + // closure succeeds do we proceed to stage files, and only after staging do + // we COMMIT — so the journal and the staged files are durably consistent + // before any rename occurs. + let commit_id = new_operation_id(); + // Commit-unique stage paths prevent two concurrent mutate_store calls (or + // a recovery-vs-mutation race) from overwriting each other's temp files. + // The paths are stored in file_commit_phases and used by recovery. + let agents_stage = anchor.join(format!("managed-agents.{commit_id}.stage")); + let teams_stage = anchor.join(format!("teams.{commit_id}.stage")); + + // Record the commit intent row INSIDE the transaction so a crash before + // COMMIT leaves no orphan phase row. + let (_new_agents, _new_teams, result) = { + let tx = journal + .transaction() + .map_err(|e| format!("begin journal transaction: {e}"))?; + + let state = StoreState { + agents, + teams, + journal: &tx, + anchor: &anchor, + }; + + let (new_agents, new_teams, result) = match mutation(state) { + Ok(v) => v, + Err(e) => { + // tx rolls back on drop + return Err(e); + } + }; + + // Serialize both files while the transaction is open (before COMMIT) + // so a serialization error also rolls back journal writes. + let agents_payload = serde_json::to_vec_pretty(&new_agents) + .map_err(|e| format!("serialize managed-agents.json: {e}"))?; + let teams_payload = serde_json::to_vec_pretty(&new_teams) + .map_err(|e| format!("serialize teams.json: {e}"))?; + + // Write both staged files and fsync before COMMIT. If staging fails, + // roll back the transaction so the journal stays consistent. + stage_file(&agents_stage, &agents_payload)?; + stage_file(&teams_stage, &teams_payload).inspect_err(|_| { + // agents stage file written but no COMMIT — clean up best-effort + let _ = std::fs::remove_file(&agents_stage); + })?; + + // Insert the file-commit intent row. This is the durable record that + // recovery uses to identify an interrupted two-phase commit. + // Content hashes allow recovery to verify a canonical file after a + // missing-stage scenario: if the canonical hash matches the recorded + // hash, the rename completed; otherwise fail closed. + use sha2::Digest; + let agents_hash = hex::encode(sha2::Sha256::digest(&agents_payload)); + let teams_hash = hex::encode(sha2::Sha256::digest(&teams_payload)); + let now = unix_now_secs(); + tx.execute( + "INSERT INTO file_commit_phases + (commit_id, operation_id, phase, + agents_stage_path, teams_stage_path, + agents_content_hash, teams_content_hash, + created_at, updated_at) + VALUES (?1, ?2, 'intent', ?3, ?4, ?5, ?6, ?7, ?7)", + params![ + commit_id, + commit_id, // operation_id == commit_id for phase records + agents_stage.to_string_lossy().as_ref(), + teams_stage.to_string_lossy().as_ref(), + agents_hash, + teams_hash, + now, + ], + ) + .map_err(|e| { + let _ = std::fs::remove_file(&agents_stage); + let _ = std::fs::remove_file(&teams_stage); + format!("insert file_commit_phase: {e}") + })?; + + tx.commit().map_err(|e| { + let _ = std::fs::remove_file(&agents_stage); + let _ = std::fs::remove_file(&teams_stage); + format!("commit journal transaction: {e}") + })?; + + (new_agents, new_teams, result) + }; // tx committed; journal borrowed as &mut for advance_file_commit_phase + + // ── Phase 2: rename agents (record first_renamed phase) ────────────────── + // + // After COMMIT the journal has a durable 'intent' row. Recovery can + // replay from here. Rename managed-agents.json, then record the phase, + // then rename teams.json, then record committed. + // + // If we crash between COMMIT and the first rename, recovery finds + // 'intent', sees both stage files exist, and replays both renames. + // If we crash after the first rename and its phase record, recovery + // finds 'first_renamed', teams stage still exists, and replays only + // the second rename. + rename_staged(&agents_stage, &agents_path)?; + + advance_file_commit_phase(&journal, &commit_id, &FileCommitPhase::FirstRenamed)?; + + rename_staged(&teams_stage, &teams_path)?; + + advance_file_commit_phase(&journal, &commit_id, &FileCommitPhase::Committed)?; + + // Fsync parent directory on Unix so directory entries are durable. + #[cfg(unix)] + if let Some(parent) = agents_path.parent() { + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + } + + // Advisory lock drops here; in-process mutex guard returned to caller so + // post-write work (retain, tombstone) can run inside the in-process lock. + Ok((result, store_mutex_guard)) +} + +/// Write `payload` to `stage_path` with fsync, creating the temp file at +/// the exact stage path (rather than a random tmp name) so recovery can +/// verify its existence deterministically. +fn stage_file(stage_path: &Path, payload: &[u8]) -> Result<(), String> { + use std::io::Write; + let mut f = std::fs::File::create(stage_path) + .map_err(|e| format!("create stage {}: {e}", stage_path.display()))?; + f.write_all(payload) + .map_err(|e| format!("write stage {}: {e}", stage_path.display()))?; + f.sync_all() + .map_err(|e| format!("fsync stage {}: {e}", stage_path.display()))?; + Ok(()) +} + +/// Rename a staged file to its canonical path (restricted permissions for +/// the agents file, plain rename for teams). +fn rename_staged(stage: &Path, canonical: &Path) -> Result<(), String> { + // Use restricted-write helper for agents (may carry nsecs), plain for + // teams. Both are already staged+fsynced; this just does the rename. + // For agents we re-use the writer's restricted path so the canonical file + // retains 0o600 on Unix. + let name = canonical.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name == "managed-agents.json" { + // Canonicalize to handle symlinks, then rename over target. + let resolved = std::fs::canonicalize(canonical).unwrap_or_else(|_| canonical.to_path_buf()); + std::fs::rename(stage, &resolved) + .map_err(|e| format!("rename {} → {}: {e}", stage.display(), resolved.display()))?; + // Set permissions on the canonical file (Unix only). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&resolved, std::fs::Permissions::from_mode(0o600)); + } + } else { + let resolved = std::fs::canonicalize(canonical).unwrap_or_else(|_| canonical.to_path_buf()); + std::fs::rename(stage, &resolved) + .map_err(|e| format!("rename {} → {}: {e}", stage.display(), resolved.display()))?; + } + Ok(()) +} + +/// Read the store under the full lock sequence without writing back files. +/// +/// Both the lock and file paths are resolved from `store_anchor_dir` so this +/// read is always anchored — never from the process-local path. +/// +/// The `store_mutex_guard` is returned so callers can continue holding the +/// in-process lock after the read. +#[allow(dead_code)] // B1 substrate — exposed for tests via pub(crate) re-export +pub fn read_store<'g, F, T>( + app: &AppHandle, + store_mutex_guard: MutexGuard<'g, ()>, + reader: F, +) -> Result<(T, MutexGuard<'g, ()>), String> +where + F: FnOnce(StoreState<'_>) -> Result, +{ + let anchor = store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor).map_err(|e| format!("create anchor dir: {e}"))?; + let _advisory = JournalLockGuard::acquire(&anchor)?; + + let agents_path = anchor.join("managed-agents.json"); + let teams_path = anchor.join("teams.json"); + + let agents: Vec = if agents_path.exists() { + let bytes = + std::fs::read(&agents_path).map_err(|e| format!("read managed-agents.json: {e}"))?; + decode_agent_store(&bytes).map_err(|e| { + crate::managed_agents::storage::backup_invalid_store(&agents_path); + e.message + })? + } else { + Vec::new() + }; + + let teams: Vec = if teams_path.exists() { + let bytes = std::fs::read(&teams_path).map_err(|e| format!("read teams.json: {e}"))?; + decode_team_store(&bytes).map_err(|e| { + crate::managed_agents::storage::backup_invalid_store(&teams_path); + e.message + })? + } else { + Vec::new() + }; + + let journal = open_journal(&anchor)?; + + let state = StoreState { + agents, + teams, + journal: &journal, + anchor: &anchor, + }; + + let result = reader(state)?; + Ok((result, store_mutex_guard)) +} + +/// Boot recovery: open the journal and re-drive any nonterminal operations. +/// +/// Performs two recovery passes: +/// +/// 1. **File-commit recovery**: inspects `file_commit_phases` rows that are +/// not in `'committed'` state. For each: +/// - `'intent'`: both stage files should exist — replay both renames. +/// - `'first_renamed'`: agents canonical is already renamed; only teams +/// stage file should exist — replay the teams rename. +/// +/// 2. **Publication recovery**: for each nonterminal operation, reads its +/// outbox events and ensures they are surfaced for the flush loop. +/// For `keyring_write` operations, inspects inbox events and marks Failed +/// when the write was in-progress (the inline-key fallback re-migrates on +/// next load). +pub fn run_boot_recovery( + app: &AppHandle, + state: &tauri::State, +) -> Result<(), String> { + let anchor = store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor).map_err(|e| format!("boot-recovery create anchor: {e}"))?; + // Resolve the active scoped retention DB path — the same database the flush + // loop drains via `flush_active_pending_events`. Using a flat + // `anchor/retention.db` path fails because production scopes to + // `anchor/retention/.db`. + let retention_path = match crate::managed_agents::retention::active_retention_scope(app, state) + { + Ok(scope) => { + if scope.db_path.exists() { + Some(scope.db_path) + } else { + None + } + } + Err(e) => { + eprintln!( + "buzz-desktop: boot-recovery: cannot resolve retention scope ({e}), \ + publication re-drive skipped — pending outbox rows will be re-tried at next boot" + ); + None + } + }; + run_boot_recovery_at(&anchor, retention_path.as_deref()) +} + +/// Path-level boot recovery — **publication-only** (no file repair). +/// +/// File-commit recovery (phase 1) is handled exclusively by the synchronous +/// `run_recovery_gate` call in app setup, which runs BEFORE this background +/// task is spawned. This function runs only phase 2: publication recovery +/// (outbox re-drive into the retention DB). +/// +/// `retention_db_path` is the path to the anchor's active scoped retention DB. +/// When `Some`, recovery re-inserts missing `pending_sync` retention rows from +/// journal outbox payloads so the flush loop can re-publish them. When `None`, +/// recovery logs the gap and leaves the outbox row pending for the next boot. +pub(crate) fn run_boot_recovery_at( + anchor: &std::path::Path, + retention_db_path: Option<&std::path::Path>, +) -> Result<(), String> { + // Publication recovery only — no file I/O, no lock required. + // File-commit repair is the sole responsibility of run_recovery_gate. + let journal = open_journal(anchor)?; + recover_nonterminal_operations(&journal, retention_db_path)?; + Ok(()) +} + +/// Path-level file-commit recovery, extracted for testing without an AppHandle. +#[cfg(test)] +pub(crate) fn file_commit_recovery_at_pub(anchor: &std::path::Path) -> Result { + file_commit_recovery_at(anchor) +} + +fn file_commit_recovery_at(anchor: &std::path::Path) -> Result { + let _advisory = super::lock::JournalLockGuard::acquire(anchor)?; + let journal = open_journal(anchor)?; + recover_interrupted_file_commits(&journal) +} + +/// Derive the canonical (non-staged) path from a commit-unique stage path. +/// +/// Stage paths follow the pattern `/..stage` where +/// `` is either `managed-agents` or `teams`. The canonical path is +/// `/managed-agents.json` or `/teams.json`. +fn canonical_from_stage(stage: &Path) -> std::path::PathBuf { + let anchor = stage.parent().unwrap_or(std::path::Path::new(".")); + let file_name = stage + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + // The stage file name always starts with either "managed-agents." or "teams.". + // Map to the corresponding canonical JSON file name. + let canonical = if file_name.starts_with("managed-agents.") { + "managed-agents.json" + } else if file_name.starts_with("teams.") { + "teams.json" + } else { + // Fallback: strip everything from the second '.' onward. + let without_stage = file_name.strip_suffix(".stage").unwrap_or(file_name); + if let Some(idx) = without_stage.find('.') { + return anchor.join(format!("{}.json", &without_stage[..idx])); + } + without_stage + }; + anchor.join(canonical) +} + +/// Recover any interrupted two-phase file commits from `file_commit_phases`. +/// +/// Called before `recover_nonterminal_operations` so the JSON files are in a +/// consistent state before publication recovery reads or reconciles them. +/// Compute the SHA-256 hash of a file's contents, or `None` if the file cannot +/// be read. Used by recovery to verify canonical files match recorded hashes. +fn sha256_hex_of_file(path: &std::path::Path) -> Option { + use sha2::Digest; + let bytes = std::fs::read(path).ok()?; + Some(hex::encode(sha2::Sha256::digest(&bytes))) +} + +/// Recover any interrupted two-phase file commits. +/// +/// Returns the number of nonterminal commits that could NOT be resolved (any +/// branch that previously `continue`d without completing the rename sequence). +/// A non-zero count means the store is in an uncertain state and callers must +/// treat it as a recovery failure. +#[allow(clippy::type_complexity)] +fn recover_interrupted_file_commits(journal: &rusqlite::Connection) -> Result { + // Read commit_id, phase, stage paths, AND content hashes. + let rows: Vec<(String, String, String, String, String, String)> = { + let mut stmt = journal + .prepare( + "SELECT commit_id, phase, agents_stage_path, teams_stage_path, + agents_content_hash, teams_content_hash + FROM file_commit_phases + WHERE phase != 'committed'", + ) + .map_err(|e| format!("prepare file_commit_phases query: {e}"))?; + let collected: Vec> = + stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) + }) + .map_err(|e| format!("query file_commit_phases: {e}"))? + .collect(); + collected + .into_iter() + .collect::, _>>() + .map_err(|e| format!("read file_commit_phases row: {e}"))? + }; + + let mut unresolved: usize = 0; + + for (commit_id, phase_str, agents_stage_str, teams_stage_str, agents_hash, teams_hash) in rows { + let phase = FileCommitPhase::from_str(&phase_str).unwrap_or(FileCommitPhase::Intent); + let agents_stage = std::path::PathBuf::from(&agents_stage_str); + let teams_stage = std::path::PathBuf::from(&teams_stage_str); + + eprintln!( + "buzz-desktop: boot-recovery: interrupted file commit {commit_id} phase={phase_str}" + ); + + let agents_canonical = canonical_from_stage(&agents_stage); + let teams_canonical = canonical_from_stage(&teams_stage); + + // Macro that counts a skipped commit as unresolved and moves to the next. + macro_rules! skip_unresolved { + ($msg:expr) => {{ + eprintln!("{}", $msg); + unresolved += 1; + continue; + }}; + } + + match phase { + FileCommitPhase::Intent => { + // Both stage files should exist for a full intent commit. + let agents_exists = agents_stage.exists(); + let teams_exists = teams_stage.exists(); + if !agents_exists && !teams_exists { + // Both stage files absent. Verify the canonical files match + // the recorded hashes — proof both renames completed. + let agents_ok = !agents_hash.is_empty() + && sha256_hex_of_file(&agents_canonical).as_deref() == Some(&agents_hash); + let teams_ok = !teams_hash.is_empty() + && sha256_hex_of_file(&teams_canonical).as_deref() == Some(&teams_hash); + if agents_ok && teams_ok { + eprintln!( + "buzz-desktop: boot-recovery: {commit_id}: both stage files absent, \ + canonicals verified — advancing to committed" + ); + advance_file_commit_phase(journal, &commit_id, &FileCommitPhase::Committed) + .unwrap_or_else(|e| { + eprintln!( + "buzz-desktop: boot-recovery: advance committed failed: {e}" + ) + }); + } else { + skip_unresolved!(format!( + "buzz-desktop: boot-recovery: {commit_id}: both stage files absent \ + in intent phase and canonicals do not match recorded hashes — \ + failing closed; manual recovery may be needed" + )); + } + continue; + } + if !agents_exists { + // Agents stage absent, teams stage present: ambiguous — + // cannot determine if agents rename completed without proof. + skip_unresolved!(format!( + "buzz-desktop: boot-recovery: {commit_id}: agents stage absent in \ + intent phase (teams stage present) — cannot safely complete; \ + skipping (manual recovery may be needed)" + )); + } + // Agents stage exists — replay agents rename. + if let Err(e) = rename_staged(&agents_stage, &agents_canonical) { + skip_unresolved!(format!( + "buzz-desktop: boot-recovery: agents rename failed: {e}" + )); + } + if let Err(e) = + advance_file_commit_phase(journal, &commit_id, &FileCommitPhase::FirstRenamed) + { + eprintln!("buzz-desktop: boot-recovery: advance first_renamed failed: {e}"); + } + // Teams rename. + if teams_exists { + if let Err(e) = rename_staged(&teams_stage, &teams_canonical) { + skip_unresolved!(format!( + "buzz-desktop: boot-recovery: teams rename failed: {e}" + )); + } + } else { + // Teams stage absent after agents rename. Verify via hash. + let teams_ok = !teams_hash.is_empty() + && sha256_hex_of_file(&teams_canonical).as_deref() == Some(&teams_hash); + if !teams_ok { + skip_unresolved!(format!( + "buzz-desktop: boot-recovery: {commit_id}: teams stage absent \ + in intent phase (after agents rename) and canonical does not \ + match recorded hash — failing closed" + )); + } + } + advance_file_commit_phase(journal, &commit_id, &FileCommitPhase::Committed) + .unwrap_or_else(|e| { + eprintln!("buzz-desktop: boot-recovery: advance committed failed: {e}") + }); + } + FileCommitPhase::FirstRenamed => { + // Agents already renamed; only teams stage remains. + if teams_stage.exists() { + if let Err(e) = rename_staged(&teams_stage, &teams_canonical) { + skip_unresolved!(format!( + "buzz-desktop: boot-recovery: teams rename (first_renamed) failed: {e}" + )); + } + // Teams rename succeeded — advance to committed. + } else { + // Teams stage absent. Verify via recorded hash. + let teams_ok = !teams_hash.is_empty() + && sha256_hex_of_file(&teams_canonical).as_deref() == Some(&teams_hash); + if !teams_ok { + skip_unresolved!(format!( + "buzz-desktop: boot-recovery: {commit_id}: teams stage absent in \ + first_renamed phase and canonical does not match recorded hash — \ + failing closed; manual recovery may be needed" + )); + } + // Canonical matches — teams rename already completed. + } + advance_file_commit_phase(journal, &commit_id, &FileCommitPhase::Committed) + .unwrap_or_else(|e| { + eprintln!("buzz-desktop: boot-recovery: advance committed failed: {e}") + }); + } + FileCommitPhase::Committed => unreachable!("filtered out in query"), + } + } + + Ok(unresolved) +} + +/// Recover nonterminal operations from the journal outbox. +/// +/// For each nonterminal operation: +/// - `keyring_write`: check inbox for in-progress pre-image → mark Failed +/// (inline-key fallback re-migrates on next load). +/// - Other ops with outbox evidence: surface pending rows and ensure the +/// retention DB will pick them up on the next flush loop tick. If ALL +/// outbox events are published, advance the op to Committed. +/// - No outbox evidence: mark Failed. +/// +/// Publication evidence re-drive: for ops with a pending outbox row, the +/// flush loop drains `pending_sync` retention rows. If the retention row +/// is absent (team retain wrote journal-only evidence), insert a +/// `pending_sync` retention row from the immutable outbox payload so the +/// flush loop can re-publish it. +fn recover_nonterminal_operations( + journal: &rusqlite::Connection, + retention_db_path: Option<&std::path::Path>, +) -> Result<(), String> { + let nonterminal = read_nonterminal_operations(journal)?; + if nonterminal.is_empty() { + return Ok(()); + } + + let mut re_driven = 0usize; + let mut failed = 0usize; + + for op in &nonterminal { + eprintln!( + "buzz-desktop: boot-recovery: nonterminal op {} kind={} key={} disposition={:?}", + op.operation_id, op.kind, op.key_id, op.disposition + ); + + // Read outbox events for this operation. + let outbox = match read_outbox_events(journal, &op.operation_id) { + Ok(rows) => rows, + Err(e) => { + eprintln!( + "buzz-desktop: boot-recovery: read outbox for {}: {e}", + op.operation_id + ); + continue; + } + }; + + // keyring_write: check inbox for pre-image → mark Failed so the + // inline-key fallback path re-migrates on next load. + if op.kind == "keyring_write" && outbox.is_empty() { + let _ = advance_disposition( + journal, + &op.operation_id, + &op.disposition, + &Disposition::Failed, + ); + failed += 1; + continue; + } + + if outbox.is_empty() { + // No outbox evidence — nothing to re-drive; mark failed. + let _ = advance_disposition( + journal, + &op.operation_id, + &op.disposition, + &Disposition::Failed, + ); + failed += 1; + continue; + } + + // Determine publication state. + let mut any_pending = false; + for (event_id, payload, published_state, retention_d_tag) in &outbox { + if *published_state == 0 { + any_pending = true; + eprintln!( + "buzz-desktop: boot-recovery: outbox event {event_id} for op {} pending", + op.operation_id + ); + // Ensure the retention DB has a `pending_sync` row for this + // event payload so the flush loop can publish it. The + // retention_d_tag is the exact coordinate persisted at enqueue + // time — used directly instead of re-parsing the event payload + // so tombstones and archives (kind 5 / 9035) recover at the + // correct (kind, pubkey, d_tag) coordinate. + // + // Best-effort: a retention failure here is logged; the outbox + // row remains pending for the next boot. + if let Err(e) = ensure_retention_row_for_payload( + payload, + event_id, + retention_d_tag, + retention_db_path, + ) { + eprintln!( + "buzz-desktop: boot-recovery: could not ensure retention row for \ + event {event_id}: {e}" + ); + } + } + } + + if any_pending { + re_driven += 1; + } else { + // All outbox events published — advance to Committed. + let _ = advance_disposition( + journal, + &op.operation_id, + &op.disposition, + &Disposition::Committed, + ); + } + } + + if re_driven > 0 || failed > 0 { + eprintln!( + "buzz-desktop: boot-recovery: {} op(s) queued for re-drive, {} op(s) marked failed", + re_driven, failed + ); + } + Ok(()) +} + +/// Attempt to insert a `pending_sync` retention row from the raw event +/// payload stored in the journal outbox. This ensures the flush loop can +/// publish the event even when the retention row was never written (e.g. the +/// process crashed between the journal COMMIT and the `retain_event` call). +/// +/// `retention_d_tag` is the exact d_tag persisted in the outbox row at +/// enqueue time — it is used as the retention coordinate without any +/// re-parsing of the event payload. This is essential for kinds like 5 and +/// 9035 that carry no d-tag in the event itself (tombstones use a synthetic +/// key like `"30177:"`; archives use the agent pubkey directly). +/// +/// When `retention_db_path` is `Some`, parses the Nostr event and inserts a +/// `pending_sync` row. When `None` (first-boot before the retention DB +/// exists, or test paths without AppHandle), logs a diagnostic and returns +/// `Ok` — the next boot that runs with a live retention DB will call +/// `run_event_sync` / `reconcile_agents_to_events` and re-queue the row. +fn ensure_retention_row_for_payload( + payload: &[u8], + event_id: &str, + retention_d_tag: &str, + retention_db_path: Option<&std::path::Path>, +) -> Result<(), String> { + use nostr::JsonUtil; + + let event = nostr::Event::from_json( + std::str::from_utf8(payload).map_err(|e| format!("outbox payload not utf8: {e}"))?, + ) + .map_err(|e| format!("parse outbox payload event {event_id}: {e}"))?; + + let Some(db_path) = retention_db_path else { + // No retention DB path available (e.g. pre-existing DB or test context). + // Log and return Ok — next live boot will reconcile. + eprintln!( + "buzz-desktop: boot-recovery: outbox event {event_id} has pending payload \ + (kind={}) — no retention DB path, will be re-queued by reconcile on next boot", + event.kind + ); + return Ok(()); + }; + + // Open the retention DB and insert a pending_sync row for this event. + let conn = crate::managed_agents::retention::open_retention_db(db_path) + .map_err(|e| format!("boot-recovery: open retention db: {e}"))?; + + let owner_pubkey = event.pubkey.to_hex(); + + crate::managed_agents::retention::retain_event( + &conn, + &crate::managed_agents::retention::RetainedEvent { + kind: event.kind.as_u16() as u32, + pubkey: owner_pubkey, + d_tag: retention_d_tag.to_string(), + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }, + ) + .map_err(|e| format!("boot-recovery: insert retention row for {event_id}: {e}"))?; + + eprintln!( + "buzz-desktop: boot-recovery: re-inserted retention row for outbox event {event_id} \ + (kind={}, d_tag={retention_d_tag:?})", + event.kind + ); + Ok(()) +} + +/// Prepare and record immutable publication evidence in a single atomic step. +/// +/// This is the **single publication authority** for B1. It establishes +/// durable evidence in a recoverable ordered protocol across two databases: +/// +/// 1. Inserts the outbox row (immutable event payload) into the journal +/// SQLite connection — `?`-propagated so a collision or journal failure +/// aborts before anything is flushable. +/// 2. Inserts the retention row with `pending_sync = true` into the retention +/// SQLite connection — `?`-propagated. +/// +/// These two writes span two separate SQLite connections (journal and +/// retention) and therefore cannot be wrapped in a single SQL transaction. +/// The ordering is intentional: if the process crashes after (1) but before +/// (2), boot recovery reads the outbox row and re-inserts the retention +/// projection via `ensure_retention_row_for_payload`. If (2) fails after +/// (1) succeeds, the same recovery path applies on next boot. +/// +/// `IdentityCollision` from `insert_outbox_event` is converted to `Err` — +/// never silently discarded. +/// +/// Callers that do NOT need a new outbox row (e.g. no content change) call +/// `retain_agent_record` directly and skip this function. +#[allow(clippy::too_many_arguments)] +pub fn prepare_publication( + journal: &rusqlite::Connection, + retention_conn: &rusqlite::Connection, + operation_id: &str, + event_id: &str, + raw_json: &str, + kind: u32, + owner_pubkey: &str, + d_tag: &str, + content: &str, + created_at: i64, +) -> Result<(), String> { + use crate::managed_agents::retention::{retain_event, RetainedEvent}; + + // Insert outbox evidence first — if this fails the closure Err rolls + // back the whole transaction. + match insert_outbox_event(journal, event_id, operation_id, raw_json.as_bytes(), d_tag)? { + InsertEventOutcome::Inserted | InsertEventOutcome::ExactDuplicate => {} + InsertEventOutcome::IdentityCollision => { + return Err(format!( + "prepare_publication: identity collision on event {event_id} (operation {operation_id})" + )); + } + } + + // Insert retention row with pending_sync = true. + retain_event( + retention_conn, + &RetainedEvent { + kind, + pubkey: owner_pubkey.to_string(), + d_tag: d_tag.to_string(), + content: content.to_string(), + created_at, + raw_event: raw_json.to_string(), + pending_sync: true, + }, + ) +} + +/// Advance a journal operation from `Pending` to `Committed` after a +/// successful publication. Best-effort: a journal-open or CAS failure is +/// logged and ignored so a journal hiccup never prevents the write from being +/// acknowledged. +pub fn advance_to_committed(app: &tauri::AppHandle, op_id: &str) { + if let Ok(anchor) = super::anchor::store_anchor_dir(app) { + if let Ok(journal) = super::schema::open_journal(&anchor) { + let _ = super::operations::advance_disposition( + &journal, + op_id, + &Disposition::Pending, + &Disposition::Committed, + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/util.rs b/desktop/src-tauri/src/managed_agents/store_journal/util.rs new file mode 100644 index 0000000000..09d09393c4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/util.rs @@ -0,0 +1,16 @@ +//! Internal utilities shared across journal submodules. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Current Unix time in whole seconds. +pub(super) fn unix_now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Generate a new random UUID v4 operation ID. +pub fn new_operation_id() -> String { + uuid::Uuid::new_v4().to_string() +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal/writer.rs b/desktop/src-tauri/src/managed_agents/store_journal/writer.rs new file mode 100644 index 0000000000..6b53419fa3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal/writer.rs @@ -0,0 +1,72 @@ +//! Atomic fsync file writers. +//! +//! `atomic_write_restricted_with_fsync` uses `atomic-write-file` (owner-only +//! mode, `commit()` handles fsync+rename on the temp fd and fsyncs the parent +//! directory on Unix). +//! +//! `atomic_write_with_fsync` is a plain tmp→fsync→rename path used for files +//! that do not carry secret material (e.g. `teams.json`). It also fsyncs the +//! parent directory on Unix after rename. + +use std::path::Path; + +/// Write `payload` to `path` atomically (tmp → fsync → rename → fsync-parent). +/// Resolves symlinks so the rename lands on the physical target. +/// +/// On Unix, fsyncs the parent directory after rename to durably commit the +/// directory entry — without it the data blocks may survive a crash while the +/// renamed entry does not. This matches the durability guarantee provided by +/// `atomic-write-file::commit()` on the restricted-write path. +pub fn atomic_write_with_fsync(path: &Path, payload: &[u8]) -> Result<(), String> { + use std::io::Write; + let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let tmp = resolved.with_extension("json.tmp"); + let mut file = + std::fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?; + file.write_all(payload) + .map_err(|e| format!("write {}: {e}", tmp.display()))?; + file.sync_all() + .map_err(|e| format!("fsync {}: {e}", tmp.display()))?; + drop(file); + std::fs::rename(&tmp, &resolved) + .map_err(|e| format!("rename {} → {}: {e}", tmp.display(), resolved.display()))?; + + // Fsync the parent directory so the directory entry for the new name is + // durably committed. Best-effort on platforms that do not support it. + #[cfg(unix)] + if let Some(parent) = resolved.parent() { + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + } + + Ok(()) +} + +/// Atomic write (tmp → fsync → rename) with `0o600` permissions. Used for +/// `managed-agents.json`, which may carry plaintext agent nsecs. +pub fn atomic_write_restricted_with_fsync(path: &Path, payload: &[u8]) -> Result<(), String> { + use atomic_write_file::AtomicWriteFile; + use std::io::Write; + + let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let mut file = AtomicWriteFile::open(&resolved) + .map_err(|e| format!("open {} for atomic write: {e}", resolved.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("set {} permissions: {e}", resolved.display()))?; + } + + file.write_all(payload) + .map_err(|e| format!("write {}: {e}", resolved.display()))?; + + // AtomicWriteFile::commit() handles the rename; sync the temp fd first. + // The `sync_before_close` feature isn't exposed, so we flush explicitly. + file.flush() + .map_err(|e| format!("flush {}: {e}", resolved.display()))?; + file.commit() + .map_err(|e| format!("commit {}: {e}", resolved.display())) +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal_fix_tests.rs b/desktop/src-tauri/src/managed_agents/store_journal_fix_tests.rs new file mode 100644 index 0000000000..780b6bb0ed --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal_fix_tests.rs @@ -0,0 +1,993 @@ +//! Tests for round-6/7 fixes: hash-verified recovery (Fix 3b), scoped +//! recovery path (Fix 1), and keyring chokepoint (Fix 2). +//! Round-8 tests: tombstone/archive recovery coordinates (BLOCKING 1), +//! recovery-vs-mutation race (BLOCKING 2b), and failure injection (BLOCKING 2c). + +use super::operations::insert_operation; +use super::{ + apply_journal_schema_pub, file_commit_recovery_at_pub, insert_outbox_event, open_journal, + reject_if_recovery_failed, run_boot_recovery_at, run_recovery_gate, Generation, +}; +use crate::managed_agents::retention::{ + get_pending_sync, open_retention_db, tombstone_retention_d_tag, +}; +use nostr::JsonUtil; + +fn tmp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("create temp dir") +} + +fn sha256_hex(data: &[u8]) -> String { + use sha2::Digest; + hex::encode(sha2::Sha256::digest(data)) +} + +fn insert_phase_row( + conn: &rusqlite::Connection, + cid: &str, + phase: &str, + as_: &str, + ts: &str, + ah: &str, + th: &str, +) { + conn.execute( + "INSERT INTO file_commit_phases (commit_id,operation_id,phase,agents_stage_path,teams_stage_path,agents_content_hash,teams_content_hash,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,0,0)", + rusqlite::params![cid, cid, phase, as_, ts, ah, th], + ).unwrap(); +} + +fn agents_can(anchor: &std::path::Path) -> std::path::PathBuf { + anchor.join("managed-agents.json") +} +fn teams_can(anchor: &std::path::Path) -> std::path::PathBuf { + anchor.join("teams.json") +} +fn agents_stage(anchor: &std::path::Path) -> std::path::PathBuf { + anchor.join("managed-agents.cc1.stage") +} +fn teams_stage(anchor: &std::path::Path) -> std::path::PathBuf { + anchor.join("teams.cc1.stage") +} + +/// Fix 3b — rename completed, phase update did not: both stage absent, +/// canonicals match recorded hashes → recovery advances to committed. +#[test] +fn test_file_recovery_rename_done_phase_not_updated_succeeds() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let ac = agents_can(&anchor); + let tc = teams_can(&anchor); + let ac_data = b"[{\"pubkey\":\"aa\"}]"; + let tc_data = b"[]"; + std::fs::write(&ac, ac_data).unwrap(); + std::fs::write(&tc, tc_data).unwrap(); + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "cc1", + "intent", + agents_stage(&anchor).to_str().unwrap(), + teams_stage(&anchor).to_str().unwrap(), + &sha256_hex(ac_data), + &sha256_hex(tc_data), + ); + drop(j); + file_commit_recovery_at_pub(&anchor).unwrap(); + // Row should be committed now (file_commit_phases phase = 'committed'). + let j = open_journal(&anchor).unwrap(); + let count: i64 = j + .query_row( + "SELECT COUNT(*) FROM file_commit_phases WHERE phase='committed'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, 1, + "recovery must advance to committed when canonicals verified" + ); +} + +/// Fix 3b — both stages absent, canonical hashes do NOT match → fail closed. +#[test] +fn test_file_recovery_hash_mismatch_fails_closed() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let ac = agents_can(&anchor); + let tc = teams_can(&anchor); + std::fs::write(&ac, b"[{\"pubkey\":\"different\"}]").unwrap(); + std::fs::write(&tc, b"[]").unwrap(); + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "cc2", + "intent", + agents_stage(&anchor).to_str().unwrap(), + teams_stage(&anchor).to_str().unwrap(), + "badhash", + "badhash", + ); + drop(j); + file_commit_recovery_at_pub(&anchor).unwrap(); + // Phase must NOT be committed — fail closed. + let j = open_journal(&anchor).unwrap(); + let count: i64 = j + .query_row( + "SELECT COUNT(*) FROM file_commit_phases WHERE phase='committed'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, 0, + "hash mismatch must leave phase uncommitted (fail closed)" + ); +} + +/// Fix 3b — first_renamed: teams stage absent, canonical matches hash → advance. +#[test] +fn test_file_recovery_first_renamed_teams_done_hash_verified() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let ac = agents_can(&anchor); + let tc = teams_can(&anchor); + let tc_data = b"[]"; + std::fs::write(&ac, b"[{\"pubkey\":\"aa\"}]").unwrap(); + std::fs::write(&tc, tc_data).unwrap(); + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "cc3", + "first_renamed", + agents_stage(&anchor).to_str().unwrap(), + teams_stage(&anchor).to_str().unwrap(), + "", + &sha256_hex(tc_data), + ); + drop(j); + file_commit_recovery_at_pub(&anchor).unwrap(); + let j = open_journal(&anchor).unwrap(); + let count: i64 = j + .query_row( + "SELECT COUNT(*) FROM file_commit_phases WHERE phase='committed'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, 1, + "teams canonical verified in first_renamed → committed" + ); +} + +/// Fix 3b — first_renamed: teams stage absent, hash missing (empty string) → fail closed. +#[test] +fn test_file_recovery_first_renamed_teams_absent_no_hash_fails_closed() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let ac = agents_can(&anchor); + std::fs::write(&ac, b"[{\"pubkey\":\"aa\"}]").unwrap(); + // teams canonical ABSENT + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "cc4", + "first_renamed", + agents_stage(&anchor).to_str().unwrap(), + teams_stage(&anchor).to_str().unwrap(), + "", + "", + ); + drop(j); + file_commit_recovery_at_pub(&anchor).unwrap(); + let j = open_journal(&anchor).unwrap(); + let count: i64 = j + .query_row( + "SELECT COUNT(*) FROM file_commit_phases WHERE phase='committed'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + count, 0, + "absent teams canonical with no hash → fail closed" + ); +} + +/// Fix 1 — scoped recovery path: journal-only outbox evidence, retention DB at +/// a specific path (simulating the scoped hash-named path). Recovery re-inserts +/// the retention row into THAT path, not a flat path. +#[test] +fn test_boot_recovery_inserts_into_supplied_retention_path_not_flat() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let scoped_path = anchor.join("retention").join("abc123.db"); + std::fs::create_dir_all(scoped_path.parent().unwrap()).unwrap(); + let flat_path = anchor.join("retention.db"); + + // Build real signed event for outbox. + let keys = nostr::Keys::generate(); + let owner_pubkey = keys.public_key().to_hex(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30177), "content") + .tag(nostr::Tag::identifier("agent-1")) + .sign_with_keys(&keys) + .unwrap(); + let event_id = event.id.to_hex(); + let raw = event.as_json(); + + { + let j = open_journal(&anchor).unwrap(); + insert_operation(&j, "op-1", "publish", "agent-1", Generation(0)).unwrap(); + insert_outbox_event(&j, &event_id, "op-1", raw.as_bytes(), "agent-1").unwrap(); + } + + // Recovery with scoped path — must insert into scoped, NOT flat. + run_boot_recovery_at(&anchor, Some(&scoped_path)).unwrap(); + + let conn_scoped = open_retention_db(&scoped_path).unwrap(); + let pending_scoped = get_pending_sync(&conn_scoped).unwrap(); + assert_eq!(pending_scoped.len(), 1, "re-inserted into scoped path"); + assert_eq!(pending_scoped[0].pubkey, owner_pubkey); + + // Flat path must NOT have been created. + assert!(!flat_path.exists(), "must not write to flat retention.db"); +} + +// ─── Round-8 new tests ──────────────────────────────────────────────────────── + +/// BLOCKING 1 — tombstone re-drive uses persisted retention_d_tag, not event d-tag. +/// +/// Enqueues two tombstones for two distinct agents. Each tombstone (kind 5) +/// carries no d-tag in the event; the synthetic key `30177:` is +/// stored in the outbox row's `retention_d_tag` column. Recovery must re-insert +/// both rows at their correct (kind=5, pubkey=owner, d_tag=synthetic) coordinates +/// so they don't collide and both survive. +#[test] +fn test_boot_recovery_tombstone_redrives_at_correct_retention_coordinate() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let scoped_path = anchor.join("retention").join("scoped.db"); + std::fs::create_dir_all(scoped_path.parent().unwrap()).unwrap(); + + let owner_keys = nostr::Keys::generate(); + let owner_pubkey = owner_keys.public_key().to_hex(); + + // Build two kind-5 tombstone events (no d-tag in the event payload). + let agent_pk_a = nostr::Keys::generate().public_key().to_hex(); + let agent_pk_b = nostr::Keys::generate().public_key().to_hex(); + + // Kind-5 events with only an a-tag (NIP-09 deletion target). + let tombstone_a = nostr::EventBuilder::new(nostr::Kind::Custom(5), "") + .tag(nostr::Tag::custom( + nostr::TagKind::Custom("a".into()), + vec![format!("30177:{owner_pubkey}:{agent_pk_a}")], + )) + .sign_with_keys(&owner_keys) + .unwrap(); + let tombstone_b = nostr::EventBuilder::new(nostr::Kind::Custom(5), "") + .tag(nostr::Tag::custom( + nostr::TagKind::Custom("a".into()), + vec![format!("30177:{owner_pubkey}:{agent_pk_b}")], + )) + .sign_with_keys(&owner_keys) + .unwrap(); + + // The retention d_tag for each tombstone is the synthetic key used at enqueue time. + let d_tag_a = tombstone_retention_d_tag(30177, &agent_pk_a); + let d_tag_b = tombstone_retention_d_tag(30177, &agent_pk_b); + + { + let j = open_journal(&anchor).unwrap(); + insert_operation(&j, "op-tomb-a", "tombstone", &agent_pk_a, Generation(0)).unwrap(); + insert_outbox_event( + &j, + &tombstone_a.id.to_hex(), + "op-tomb-a", + tombstone_a.as_json().as_bytes(), + &d_tag_a, + ) + .unwrap(); + + insert_operation(&j, "op-tomb-b", "tombstone", &agent_pk_b, Generation(0)).unwrap(); + insert_outbox_event( + &j, + &tombstone_b.id.to_hex(), + "op-tomb-b", + tombstone_b.as_json().as_bytes(), + &d_tag_b, + ) + .unwrap(); + } + + run_boot_recovery_at(&anchor, Some(&scoped_path)).unwrap(); + + let conn = open_retention_db(&scoped_path).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!( + pending.len(), + 2, + "both tombstone retention rows must survive re-drive without collision; got {:?}", + pending.iter().map(|r| &r.d_tag).collect::>() + ); + + // Verify each row uses the synthetic d_tag, not "" (what event-d-tag extraction would yield). + let d_tags: std::collections::HashSet<&str> = + pending.iter().map(|r| r.d_tag.as_str()).collect(); + assert!( + d_tags.contains(d_tag_a.as_str()), + "row for agent A must use synthetic d_tag {d_tag_a}, got {d_tags:?}" + ); + assert!( + d_tags.contains(d_tag_b.as_str()), + "row for agent B must use synthetic d_tag {d_tag_b}, got {d_tags:?}" + ); + for row in &pending { + assert_eq!(row.pubkey, owner_pubkey, "row pubkey must be owner"); + assert_ne!( + row.d_tag, "", + "d_tag must not be empty (empty = wrong coordinate)" + ); + } +} + +/// BLOCKING 1 — archive re-drive uses persisted retention_d_tag (agent pubkey). +/// +/// Kind-9035 archive requests carry no d-tag in the event. The outbox row +/// persists `retention_d_tag = agent_pubkey`. Recovery must re-insert at +/// `(kind=9035, pubkey=owner, d_tag=agent_pk)`. +#[test] +fn test_boot_recovery_archive_redrives_at_correct_retention_coordinate() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let scoped_path = anchor.join("retention").join("scoped2.db"); + std::fs::create_dir_all(scoped_path.parent().unwrap()).unwrap(); + + let owner_keys = nostr::Keys::generate(); + let owner_pubkey = owner_keys.public_key().to_hex(); + let agent_pk = nostr::Keys::generate().public_key().to_hex(); + + // Kind-9035 archive request — carries a `p` tag, no `d` tag. + let archive = nostr::EventBuilder::new(nostr::Kind::Custom(9035), "") + .tag(nostr::Tag::public_key( + nostr::PublicKey::from_hex(&agent_pk).unwrap(), + )) + .sign_with_keys(&owner_keys) + .unwrap(); + + // At enqueue time archive_managed_agent_pending uses agent_pubkey as d_tag. + let retention_d_tag = agent_pk.clone(); + + { + let j = open_journal(&anchor).unwrap(); + insert_operation(&j, "op-arch-1", "archive", &agent_pk, Generation(0)).unwrap(); + insert_outbox_event( + &j, + &archive.id.to_hex(), + "op-arch-1", + archive.as_json().as_bytes(), + &retention_d_tag, + ) + .unwrap(); + } + + run_boot_recovery_at(&anchor, Some(&scoped_path)).unwrap(); + + let conn = open_retention_db(&scoped_path).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!( + pending.len(), + 1, + "archive retention row must be re-inserted" + ); + assert_eq!( + pending[0].d_tag, agent_pk, + "d_tag must be agent_pubkey (not empty)" + ); + assert_eq!(pending[0].pubkey, owner_pubkey); + assert_eq!(pending[0].kind, 9035); +} + +/// BLOCKING 2b — recovery-vs-live-mutation race: `run_file_commit_recovery` +/// holds the advisory lock, so a concurrent `run_boot_recovery_at` (which +/// acquires the same lock) blocks until recovery releases it. +/// +/// We prove advisory-lock serialization by spawning a thread that holds the +/// lock and verifying that a second acquisition attempt blocks until the first +/// releases. This exercises the same serialization path that prevents +/// `run_file_commit_recovery` and `mutate_store` from racing each other. +#[test] +fn test_file_commit_recovery_is_serialized_by_advisory_lock() { + use super::JournalLockGuard; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + // Ensure the journal exists so open_journal succeeds inside the lock. + open_journal(&anchor).unwrap(); + + let released = Arc::new(Mutex::new(false)); + let released_clone = Arc::clone(&released); + + // Thread 1: acquire the lock, hold it for 150 ms, then release. + let anchor_clone = anchor.clone(); + let t1 = std::thread::spawn(move || { + let _guard = JournalLockGuard::acquire(&anchor_clone).expect("t1 acquire"); + std::thread::sleep(Duration::from_millis(150)); + *released_clone.lock().unwrap() = true; + // _guard drops here, releasing the lock + }); + + // Give thread 1 a moment to acquire before we try. + std::thread::sleep(Duration::from_millis(20)); + + // Thread 2: try to acquire — must block until thread 1 releases. + let anchor_clone2 = anchor.clone(); + let released_check = Arc::clone(&released); + let t2 = std::thread::spawn(move || { + let start = Instant::now(); + let _guard2 = JournalLockGuard::acquire(&anchor_clone2).expect("t2 acquire"); + let waited = start.elapsed(); + // By the time we get the lock, t1 must have set released=true. + let was_released = *released_check.lock().unwrap(); + (was_released, waited) + }); + + t1.join().expect("t1 join"); + let (was_released, waited) = t2.join().expect("t2 join"); + + assert!( + was_released, + "t2 must not acquire the lock before t1 releases it" + ); + assert!( + waited >= Duration::from_millis(50), + "t2 must have blocked for at least 50 ms (waited {waited:?})" + ); +} + +/// BLOCKING 2c — Fix 4 failure injection: `prepare_publication` returns `Err` +/// when the retention DB write fails (journal open failure is tested inline). +/// +/// Tests the deepest achievable seam: `prepare_publication` directly, without +/// a tauri AppHandle mock (none exists in this crate). A stated limitation +/// acknowledged in the test: this does not cover the command-level propagation +/// path, which requires an AppHandle — covered at code-review level by the +/// `?`-propagation at `agents.rs:1315,1320` / `personas/mod.rs:283-288` / +/// `teams.rs:324-330`. +#[test] +fn test_prepare_publication_propagates_retention_write_failure() { + use super::prepare_publication; + + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + + let keys = nostr::Keys::generate(); + let owner_pubkey = keys.public_key().to_hex(); + let agent_pk = "test-agent-pk".to_string(); + + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30177), "content") + .tag(nostr::Tag::identifier(&agent_pk)) + .sign_with_keys(&keys) + .unwrap(); + let event_id = event.id.to_hex(); + let raw_json = event.as_json(); + + let journal = open_journal(&anchor).unwrap(); + insert_operation(&journal, "op-fail-1", "publish", &agent_pk, Generation(0)).unwrap(); + + // A retention DB connection pointing at a non-writable path (read-only dir). + // We open an in-memory DB and then close it and use its connection handle after + // close to simulate a failed write. Instead, use a path in a non-existent subdir: + // open_retention_db will succeed but the INSERT will fail because the directory + // doesn't exist. Actually the simplest approach is to open an in-memory DB and + // immediately corrupt the schema so retain_event fails. + let bad_conn = rusqlite::Connection::open_in_memory().unwrap(); + // No schema applied — retain_event will fail because the table doesn't exist. + + let result = prepare_publication( + &journal, + &bad_conn, + "op-fail-1", + &event_id, + &raw_json, + 30177, + &owner_pubkey, + &agent_pk, + &event.content, + event.created_at.as_secs() as i64, + ); + + assert!( + result.is_err(), + "prepare_publication must propagate retention write failure; got Ok" + ); + + // Verify the outbox row WAS written (journal-first ordering — evidence is durable). + let outbox = super::read_outbox_events(&journal, "op-fail-1").unwrap(); + assert_eq!( + outbox.len(), + 1, + "outbox row must be present even when retention write fails (boot recovery can re-drive)" + ); +} + +/// BLOCKING 2c — Fix 4 journal-open failure path (inline test). +/// +/// `prepare_publication` calls `insert_outbox_event` which requires the journal +/// connection. We simulate a journal-open failure by passing a connection that +/// has no `outbox_events` table — the insert returns Err and the function +/// propagates it without touching the retention DB. +#[test] +fn test_prepare_publication_propagates_journal_write_failure() { + use super::prepare_publication; + + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + + let keys = nostr::Keys::generate(); + let owner_pubkey = keys.public_key().to_hex(); + let agent_pk = "test-agent-pk-2".to_string(); + + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30177), "content") + .tag(nostr::Tag::identifier(&agent_pk)) + .sign_with_keys(&keys) + .unwrap(); + let event_id = event.id.to_hex(); + let raw_json = event.as_json(); + + // A journal connection with no schema applied — outbox_events table absent. + let bad_journal = rusqlite::Connection::open_in_memory().unwrap(); + // No operations table either, so insert_operation would fail — but we never + // reach retain_event since insert_outbox_event fails first. + // We still need an operation row referenced by the FK, but with no schema + // the insert itself will fail before FK check. + + // Valid retention DB for contrast. + let retention_db_path = anchor.join("retention-for-journal-fail-test.db"); + let retention_conn = + crate::managed_agents::retention::open_retention_db(&retention_db_path).unwrap(); + + let result = prepare_publication( + &bad_journal, + &retention_conn, + "op-no-schema", + &event_id, + &raw_json, + 30177, + &owner_pubkey, + &agent_pk, + &event.content, + event.created_at.as_secs() as i64, + ); + + assert!( + result.is_err(), + "prepare_publication must propagate journal write failure; got Ok" + ); + + // Retention DB must be untouched — journal-first ordering. + let pending = get_pending_sync(&retention_conn).unwrap(); + assert!( + pending.is_empty(), + "retention DB must be untouched when journal write fails" + ); +} + +// ── Fix A: setup-order seam tests ──────────────────────────────────────────── + +/// Fix A: the background publication-only path (`run_boot_recovery_at`) must NOT +/// rename stage files or advance `file_commit_phases` rows. Only the synchronous +/// `file_commit_recovery_at_pub` (= `run_file_commit_recovery` in production) may +/// perform file repair. +#[test] +fn test_background_recovery_does_not_repair_files() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let a_stage = anchor.join("managed-agents.seam.stage"); + let t_stage = anchor.join("teams.seam.stage"); + std::fs::write(&a_stage, b"[{\"pubkey\":\"aa\"}]").unwrap(); + std::fs::write(&t_stage, b"[]").unwrap(); + { + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "seam", + "intent", + a_stage.to_str().unwrap(), + t_stage.to_str().unwrap(), + "", + "", + ); + } + // Run publication-only recovery — must leave stage files and phase row untouched. + run_boot_recovery_at(&anchor, None).unwrap(); + assert!( + a_stage.exists(), + "background recovery must not rename agents stage file" + ); + assert!( + t_stage.exists(), + "background recovery must not rename teams stage file" + ); + let j = open_journal(&anchor).unwrap(); + let committed: i64 = j + .query_row( + "SELECT COUNT(*) FROM file_commit_phases WHERE phase='committed'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + committed, 0, + "background recovery must not advance phase row" + ); +} + +/// Fix A: the synchronous file-commit recovery path (`file_commit_recovery_at_pub`) +/// repairs the same fixture that the background path left untouched. +#[test] +fn test_sync_file_recovery_repairs_dangling_intent_row() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let a_stage = anchor.join("managed-agents.sync.stage"); + let t_stage = anchor.join("teams.sync.stage"); + let a_can = anchor.join("managed-agents.json"); + let t_can = anchor.join("teams.json"); + let a_data = b"[{\"pubkey\":\"bb\"}]"; + let t_data = b"[]"; + std::fs::write(&a_stage, a_data).unwrap(); + std::fs::write(&t_stage, t_data).unwrap(); + { + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "sync", + "intent", + a_stage.to_str().unwrap(), + t_stage.to_str().unwrap(), + &sha256_hex(a_data), + &sha256_hex(t_data), + ); + } + file_commit_recovery_at_pub(&anchor).unwrap(); + assert!( + a_can.exists(), + "sync recovery must rename agents stage to canonical" + ); + assert!( + t_can.exists(), + "sync recovery must rename teams stage to canonical" + ); + let j = open_journal(&anchor).unwrap(); + let committed: i64 = j + .query_row( + "SELECT COUNT(*) FROM file_commit_phases WHERE phase='committed'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + committed, 1, + "sync recovery must advance phase row to committed" + ); +} + +/// Fix A — ordered seam: `run_recovery_gate` runs recovery before invoking the +/// closure; the closure (= migrations) observes the repaired canonical. +/// Both production and this test drive the same function. +#[test] +fn test_setup_order_recovery_completes_before_migration_reads_canonical() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let stale_content = b"[{\"pubkey\":\"stale-pre-crash\"}]"; + let repaired_content = b"[{\"pubkey\":\"repaired-post-crash\"}]"; + let teams_content = b"[]"; + let a_can = anchor.join("managed-agents.json"); + let t_can = anchor.join("teams.json"); + let a_stage = anchor.join("managed-agents.order_test.stage"); + let t_stage = anchor.join("teams.order_test.stage"); + std::fs::write(&a_can, stale_content).unwrap(); + std::fs::write(&t_can, teams_content).unwrap(); + std::fs::write(&a_stage, repaired_content).unwrap(); + std::fs::write(&t_stage, teams_content).unwrap(); + { + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "order_test", + "intent", + a_stage.to_str().unwrap(), + t_stage.to_str().unwrap(), + &sha256_hex(repaired_content), + &sha256_hex(teams_content), + ); + } + let mut closure_saw: Option> = None; + let saw = &mut closure_saw; + run_recovery_gate(&anchor, || { + *saw = Some(std::fs::read(&a_can).unwrap()); + Ok(()) + }) + .expect("run_recovery_gate must succeed"); + let observed = closure_saw.expect("closure must have run"); + assert_eq!( + observed, repaired_content, + "closure must see repaired canonical" + ); + assert_ne!( + observed, stale_content, + "stale canonical must be replaced first" + ); +} + +/// Fix A + GAP 3 — fail-closed: unresolved recovery → closure never runs, +/// `run_recovery_gate` returns `Err`. +#[test] +fn test_recovery_gate_failure_skips_closure_and_returns_err() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let a_stage = anchor.join("managed-agents.gate_fail.stage"); + let t_stage = anchor.join("teams.gate_fail.stage"); + // No stage files + empty hashes → hash-mismatch → 1 unresolved. + { + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "gate_fail", + "intent", + a_stage.to_str().unwrap(), + t_stage.to_str().unwrap(), + "", + "", + ); + } + let mut closure_ran = false; + let result = run_recovery_gate(&anchor, || { + closure_ran = true; + Ok(()) + }); + assert!( + result.is_err(), + "run_recovery_gate must return Err on unresolved commits" + ); + assert!( + !closure_ran, + "closure must not run when recovery has unresolved commits" + ); +} + +/// GAP 2 — hash-mismatch: absent stages + non-matching hashes → unresolved count > 0. +#[test] +fn test_recovery_hash_mismatch_returns_unresolved_count() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let a_stage = anchor.join("managed-agents.hmm.stage"); + let t_stage = anchor.join("teams.hmm.stage"); + std::fs::write( + anchor.join("managed-agents.json"), + b"[{\"pubkey\":\"actual\"}]", + ) + .unwrap(); + std::fs::write(anchor.join("teams.json"), b"[]").unwrap(); + { + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "hmm", + "intent", + a_stage.to_str().unwrap(), + t_stage.to_str().unwrap(), + "deadbeefdeadbeef", + "deadbeefdeadbeef", + ); + } + let unresolved = file_commit_recovery_at_pub(&anchor).expect("must not Err"); + assert_eq!( + unresolved, 1, + "hash-mismatch with absent stages → 1 unresolved" + ); +} + +/// GAP 2 — rename-failure: stage exists but canonical target is a directory +/// (rename-over-directory is rejected by the OS) → unresolved count > 0. +#[test] +fn test_recovery_rename_failure_returns_unresolved_count() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let repaired = b"[{\"pubkey\":\"rename-fail\"}]"; + let teams = b"[]"; + let a_stage = anchor.join("managed-agents.rfail.stage"); + let t_stage = anchor.join("teams.rfail.stage"); + let a_can = anchor.join("managed-agents.json"); + std::fs::write(&a_stage, repaired).unwrap(); + std::fs::write(&t_stage, teams).unwrap(); + // canonical is a directory → rename(file, dir) → EISDIR + std::fs::create_dir_all(&a_can).unwrap(); + std::fs::write(anchor.join("teams.json"), teams).unwrap(); + { + let j = open_journal(&anchor).unwrap(); + insert_phase_row( + &j, + "rfail", + "intent", + a_stage.to_str().unwrap(), + t_stage.to_str().unwrap(), + &sha256_hex(repaired), + &sha256_hex(teams), + ); + } + let unresolved = file_commit_recovery_at_pub(&anchor).expect("must not Err"); + assert_eq!(unresolved, 1, "rename failure → 1 unresolved"); +} + +// ── Fix B: migration tests ─────────────────────────────────────────────────── + +/// Helper: create a v2-shaped in-memory journal (no hash/d-tag columns). +fn v2_journal() -> rusqlite::Connection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + // Apply base schema manually without the migration columns. + conn.execute_batch(" + CREATE TABLE key_generations (key_id TEXT PRIMARY KEY, generation TEXT NOT NULL, is_tombstone INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0); + CREATE TABLE operations (operation_id TEXT PRIMARY KEY, kind TEXT NOT NULL, key_id TEXT NOT NULL, disposition TEXT NOT NULL DEFAULT 'pending', generation TEXT NOT NULL, compensation_id TEXT, compensation_generation TEXT, nonterminal_follow_up INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); + CREATE TABLE outbox_events (event_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL REFERENCES operations(operation_id), payload BLOB NOT NULL, published_state INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL); + CREATE TABLE inbox_events (event_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL REFERENCES operations(operation_id), payload BLOB NOT NULL, received_at INTEGER NOT NULL); + CREATE TABLE file_commit_phases (commit_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL, phase TEXT NOT NULL DEFAULT 'intent', agents_stage_path TEXT NOT NULL, teams_stage_path TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); + PRAGMA user_version = 2; + ").unwrap(); + conn +} + +/// Helper: returns column names of `table` via `PRAGMA table_info`. +fn table_cols(conn: &rusqlite::Connection, table: &str) -> Vec { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .unwrap(); + stmt.query_map([], |r| r.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap() +} + +/// Fix B: upgrade from v2 (no hash columns, no d-tag) adds all three columns +/// and stamps version 4. +#[test] +fn test_schema_migration_from_v2_adds_all_columns() { + let conn = v2_journal(); + apply_journal_schema_pub(&conn).unwrap(); + let fcp_cols = table_cols(&conn, "file_commit_phases"); + assert!(fcp_cols.contains(&"agents_content_hash".to_string())); + assert!(fcp_cols.contains(&"teams_content_hash".to_string())); + let oe_cols = table_cols(&conn, "outbox_events"); + assert!(oe_cols.contains(&"retention_d_tag".to_string())); + let ver: u32 = conn + .pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap(); + assert_eq!(ver, 4); +} + +/// Fix B: upgrade from a partially-migrated v3 (hash columns present, d-tag +/// absent) adds only `retention_d_tag` and stamps version 4. +#[test] +fn test_schema_migration_from_partial_v3_adds_missing_d_tag() { + let conn = v2_journal(); + // Simulate partial v3: add hash columns but not d-tag. + conn.execute_batch( + " + ALTER TABLE file_commit_phases ADD COLUMN agents_content_hash TEXT NOT NULL DEFAULT ''; + ALTER TABLE file_commit_phases ADD COLUMN teams_content_hash TEXT NOT NULL DEFAULT ''; + PRAGMA user_version = 3; + ", + ) + .unwrap(); + apply_journal_schema_pub(&conn).unwrap(); + let oe_cols = table_cols(&conn, "outbox_events"); + assert!( + oe_cols.contains(&"retention_d_tag".to_string()), + "d-tag must be added from partial v3" + ); + let ver: u32 = conn + .pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap(); + assert_eq!(ver, 4); +} + +/// Fix B: a migration failure (ALTER TABLE rejected) must NOT advance +/// `user_version`. The function returns `Err`, and the DB remains at v2 so +/// the next open retries the migration in full. +/// +/// We simulate a write-blocking failure by setting `PRAGMA query_only = ON` +/// on the in-memory connection before calling `apply_journal_schema_pub`. +/// SQLite rejects `ALTER TABLE` (a write) with `attempt to write a readonly +/// database`, but `CREATE TABLE IF NOT EXISTS` no-ops are read operations on +/// tables that already exist, so the base-schema batch passes through. The +/// `ADD COLUMN` call inside the migration transaction fails, the transaction +/// rolls back, and `user_version` stays at 2. +/// +/// The second half of the test disables `query_only`, calls again, and asserts +/// that the migration succeeds and reaches v4 — proving the retry path works. +#[test] +fn test_schema_migration_failure_does_not_advance_version() { + let conn = v2_journal(); + + // Block writes: ALTER TABLE will fail; version stamp will not land. + conn.pragma_update(None, "query_only", 1i32).unwrap(); + let result = apply_journal_schema_pub(&conn); + assert!( + result.is_err(), + "migration must return Err when ALTER TABLE is blocked" + ); + + // Version must still be 2 — transaction rolled back before the stamp. + let ver: u32 = conn + .pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap(); + assert_eq!( + ver, 2, + "user_version must remain 2 after a failed migration" + ); + + // Re-enable writes: the next call retries and must succeed. + conn.pragma_update(None, "query_only", 0i32).unwrap(); + apply_journal_schema_pub(&conn).unwrap(); + + // All three columns must be present and version must reach 4. + let fcp_cols = table_cols(&conn, "file_commit_phases"); + assert!(fcp_cols.contains(&"agents_content_hash".to_string())); + assert!(fcp_cols.contains(&"teams_content_hash".to_string())); + let oe_cols = table_cols(&conn, "outbox_events"); + assert!(oe_cols.contains(&"retention_d_tag".to_string())); + let ver2: u32 = conn + .pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap(); + assert_eq!(ver2, 4, "user_version must reach 4 after successful retry"); +} + +/// Fix B: re-running `apply_journal_schema_pub` on a fully-migrated v4 DB is +/// a no-op — version stays at 4 and the call succeeds without errors. +#[test] +fn test_schema_migration_idempotent_on_v4() { + let conn = v2_journal(); + // First migration: v2 → v4. + apply_journal_schema_pub(&conn).unwrap(); + let ver: u32 = conn + .pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap(); + assert_eq!(ver, 4); + // Second call (idempotent): must succeed and leave version at 4. + apply_journal_schema_pub(&conn).unwrap(); + let ver2: u32 = conn + .pragma_query_value(None, "user_version", |r| r.get(0)) + .unwrap(); + assert_eq!(ver2, 4); +} + +// ── Round-13: admission-guard unit tests ───────────────────────────────────── + +/// GAP A: `reject_if_recovery_failed` returns `Ok` when the flag is clear. +#[test] +fn test_reject_if_recovery_failed_clear_returns_ok() { + let flag = std::sync::atomic::AtomicBool::new(false); + assert!( + reject_if_recovery_failed(&flag).is_ok(), + "must return Ok when store_recovery_failed is false" + ); +} + +/// GAP A: `reject_if_recovery_failed` returns `Err` when the flag is set, +/// proving the admission guard closes the mutation path on recovery failure. +#[test] +fn test_reject_if_recovery_failed_set_returns_err() { + let flag = std::sync::atomic::AtomicBool::new(true); + let result = reject_if_recovery_failed(&flag); + assert!( + result.is_err(), + "must return Err when store_recovery_failed is true" + ); + assert!( + result.unwrap_err().contains("relaunch to retry"), + "error message must mention relaunch" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/store_journal_tests.rs b/desktop/src-tauri/src/managed_agents/store_journal_tests.rs new file mode 100644 index 0000000000..28d44644b8 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/store_journal_tests.rs @@ -0,0 +1,987 @@ +//! Crash/concurrency fixture tests for the B1 store-journal substrate. + +use std::sync::{Arc, Barrier}; +use std::thread; + +use rusqlite::Connection; + +use super::{ + advance_disposition, apply_journal_schema_pub, atomic_write_with_fsync, + canonical_dev_anchor_pub, cas_generation, decode_agent_store, decode_team_store, + insert_inbox_event, insert_operation, insert_outbox_event, mark_outbox_published, open_journal, + pin_compensation, read_generation, read_inbox_events, read_nonterminal_operations, + read_operation, read_outbox_events, set_nonterminal_follow_up, tombstone_key, CasOutcome, + Disposition, Generation, InsertEventOutcome, JournalLockGuard, TransitionOutcome, +}; + +fn in_memory_journal() -> Connection { + let conn = Connection::open(":memory:").unwrap(); + conn.pragma_update(None, "foreign_keys", "ON").unwrap(); + apply_journal_schema_pub(&conn).unwrap(); + conn +} + +fn tmp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("create temp dir") +} + +#[test] +fn test_cas_generation_first_write_succeeds() { + let conn = in_memory_journal(); + let result = cas_generation(&conn, "key1", Generation::zero()).unwrap(); + assert!( + matches!( + result, + CasOutcome::Committed { + new_generation: Generation(1) + } + ), + "expected committed gen 1, got {result:?}" + ); +} + +#[test] +fn test_cas_generation_conflict_returns_current() { + let conn = in_memory_journal(); + cas_generation(&conn, "key1", Generation::zero()).unwrap(); + let result = cas_generation(&conn, "key1", Generation::zero()).unwrap(); + assert!( + matches!( + result, + CasOutcome::Conflict { + current: Generation(1) + } + ), + "expected conflict gen 1, got {result:?}" + ); +} + +#[test] +fn test_cas_generation_monotonically_increasing() { + let conn = in_memory_journal(); + for expected in 0u64..5 { + let result = cas_generation(&conn, "key1", Generation(expected)).unwrap(); + assert!( + matches!(result, CasOutcome::Committed { new_generation: g } if g.0 == expected + 1), + "expected committed gen {}, got {result:?}", + expected + 1 + ); + } +} + +#[test] +fn test_tombstone_prevents_aba_recreate() { + let conn = in_memory_journal(); + cas_generation(&conn, "key1", Generation::zero()).unwrap(); + let tomb = tombstone_key(&conn, "key1", Generation(1)).unwrap(); + assert!( + matches!( + tomb, + CasOutcome::Committed { + new_generation: Generation(2) + } + ), + "expected tombstone gen 2, got {tomb:?}" + ); + let aba = cas_generation(&conn, "key1", Generation(2)).unwrap(); + assert!( + matches!(aba, CasOutcome::Tombstoned { .. }), + "expected tombstoned, got {aba:?}" + ); +} + +#[test] +fn test_tombstone_generation_retained_forever() { + let conn = in_memory_journal(); + cas_generation(&conn, "key1", Generation::zero()).unwrap(); + tombstone_key(&conn, "key1", Generation(1)).unwrap(); + let (gen, is_tombstone) = read_generation(&conn, "key1").unwrap(); + assert!(is_tombstone, "should be tombstoned"); + assert_eq!(gen, Generation(2), "tombstone gen should be 2"); +} + +#[test] +fn test_tombstone_at_wrong_generation_conflicts() { + let conn = in_memory_journal(); + cas_generation(&conn, "key1", Generation::zero()).unwrap(); + let result = tombstone_key(&conn, "key1", Generation(99)).unwrap(); + assert!( + matches!( + result, + CasOutcome::Conflict { + current: Generation(1) + } + ), + "expected conflict, got {result:?}" + ); +} + +#[test] +fn test_insert_and_read_operation() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-1", "create_agent", "key1", Generation(0)).unwrap(); + let op = read_operation(&conn, "op-1") + .unwrap() + .expect("op should exist"); + assert_eq!(op.operation_id, "op-1"); + assert_eq!(op.kind, "create_agent"); + assert_eq!(op.key_id, "key1"); + assert_eq!(op.disposition, Disposition::Pending); + assert_eq!(op.generation, Generation(0)); + assert!(op.compensation_id.is_none()); + assert!(!op.nonterminal_follow_up); +} + +#[test] +fn test_advance_disposition_committed() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-2", "update_agent", "key2", Generation(1)).unwrap(); + let outcome = advance_disposition( + &conn, + "op-2", + &Disposition::Pending, + &Disposition::Committed, + ) + .unwrap(); + assert_eq!(outcome, TransitionOutcome::Advanced); + let op = read_operation(&conn, "op-2").unwrap().unwrap(); + assert_eq!(op.disposition, Disposition::Committed); + assert!(op.disposition.is_terminal()); +} + +#[test] +fn test_pin_compensation_sets_claim_fence() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-3", "delete_agent", "key3", Generation(2)).unwrap(); + pin_compensation(&conn, "op-3", "comp-event-1", Generation(2)).unwrap(); + let op = read_operation(&conn, "op-3").unwrap().unwrap(); + assert_eq!(op.disposition, Disposition::Compensating); + assert_eq!(op.compensation_id.as_deref(), Some("comp-event-1")); + assert_eq!(op.compensation_generation, Some(Generation(2))); +} + +#[test] +fn test_nonterminal_follow_up_flag() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-4", "publish_event", "key4", Generation(0)).unwrap(); + advance_disposition( + &conn, + "op-4", + &Disposition::Pending, + &Disposition::Uncertain, + ) + .unwrap(); + set_nonterminal_follow_up(&conn, "op-4", &Disposition::Uncertain, true).unwrap(); + let op = read_operation(&conn, "op-4").unwrap().unwrap(); + assert!(op.disposition.requires_follow_up()); + assert!(op.nonterminal_follow_up); +} + +#[test] +fn test_read_nonterminal_operations_excludes_terminal() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-a", "create", "k1", Generation(0)).unwrap(); + insert_operation(&conn, "op-b", "create", "k2", Generation(0)).unwrap(); + insert_operation(&conn, "op-c", "create", "k3", Generation(0)).unwrap(); + advance_disposition( + &conn, + "op-b", + &Disposition::Pending, + &Disposition::Committed, + ) + .unwrap(); + advance_disposition( + &conn, + "op-c", + &Disposition::Pending, + &Disposition::Compensated, + ) + .unwrap(); + let nonterminal = read_nonterminal_operations(&conn).unwrap(); + let ids: Vec<&str> = nonterminal + .iter() + .map(|op| op.operation_id.as_str()) + .collect(); + assert!(ids.contains(&"op-a"), "pending op-a should be nonterminal"); + assert!(!ids.contains(&"op-b"), "committed op-b should be excluded"); + assert!( + !ids.contains(&"op-c"), + "compensated op-c should be excluded" + ); +} + +#[test] +fn test_outbox_insert_is_idempotent() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-out", "create", "k1", Generation(0)).unwrap(); + let payload = b"hello"; + let r1 = insert_outbox_event(&conn, "ev-1", "op-out", payload, "").unwrap(); + assert_eq!( + r1, + InsertEventOutcome::Inserted, + "first insert must be Inserted" + ); + let r2 = insert_outbox_event(&conn, "ev-1", "op-out", payload, "").unwrap(); + assert_eq!( + r2, + InsertEventOutcome::ExactDuplicate, + "same payload must be ExactDuplicate" + ); + let rows = read_outbox_events(&conn, "op-out").unwrap(); + assert_eq!(rows.len(), 1, "must have exactly one outbox row"); + assert_eq!(rows[0].1, payload); +} + +#[test] +fn test_outbox_insert_identity_collision_fails_closed() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-out2", "create", "k1", Generation(0)).unwrap(); + insert_outbox_event(&conn, "ev-col", "op-out2", b"payload-a", "").unwrap(); + let r = insert_outbox_event(&conn, "ev-col", "op-out2", b"payload-b", "").unwrap(); + assert_eq!(r, InsertEventOutcome::IdentityCollision); +} + +#[test] +fn test_inbox_insert_is_idempotent() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-in", "create", "k2", Generation(0)).unwrap(); + let payload = b"world"; + let r1 = insert_inbox_event(&conn, "in-1", "op-in", payload).unwrap(); + assert_eq!(r1, InsertEventOutcome::Inserted); + let r2 = insert_inbox_event(&conn, "in-1", "op-in", payload).unwrap(); + assert_eq!(r2, InsertEventOutcome::ExactDuplicate); + let rows = read_inbox_events(&conn, "op-in").unwrap(); + assert_eq!(rows.len(), 1, "must have exactly one inbox row"); + assert_eq!(rows[0].1, payload); +} + +#[test] +fn test_decode_agent_store_empty_array_ok() { + let bytes = b"[]"; + assert!(decode_agent_store(bytes).is_ok()); +} + +#[test] +fn test_decode_agent_store_malformed_fails_closed() { + let bytes = b"{not json}"; + let result = decode_agent_store(bytes); + assert!(result.is_err(), "malformed JSON must fail closed"); +} + +#[test] +fn test_decode_team_store_empty_array_ok() { + let bytes = b"[]"; + assert!(decode_team_store(bytes).is_ok()); +} + +#[test] +fn test_decode_team_store_malformed_fails_closed() { + let bytes = b"bare string"; + assert!(decode_team_store(bytes).is_err()); +} + +#[test] +fn test_atomic_write_with_fsync_roundtrip() { + let dir = tmp_dir(); + let path = dir.path().join("test.json"); + let payload = b"[\"hello\"]"; + atomic_write_with_fsync(&path, payload).unwrap(); + let read_back = std::fs::read(&path).unwrap(); + assert_eq!(read_back, payload); +} + +#[test] +#[cfg(unix)] +fn test_atomic_write_with_fsync_symlink_preserved() { + let dir = tmp_dir(); + let real = dir.path().join("real.json"); + let link = dir.path().join("link.json"); + std::fs::write(&real, b"[]").unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + atomic_write_with_fsync(&link, b"[1]").unwrap(); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read(&real).unwrap(), b"[1]"); +} + +#[test] +fn test_atomic_write_with_fsync_first_boot_no_file() { + let dir = tmp_dir(); + let path = dir.path().join("new.json"); + atomic_write_with_fsync(&path, b"[]").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"[]"); +} + +#[test] +fn test_canonical_dev_anchor_from_dev_data_dir() { + use std::path::PathBuf; + let local_agents = + PathBuf::from("/Library/Application Support/xyz.block.buzz.app.dev.my-branch/agents"); + let anchor = canonical_dev_anchor_pub(&local_agents); + assert_eq!( + anchor, + Some(PathBuf::from( + "/Library/Application Support/xyz.block.buzz.app.dev/agents" + )) + ); +} + +#[test] +fn test_canonical_dev_anchor_from_production_data_dir_is_none() { + use std::path::PathBuf; + let local_agents = PathBuf::from("/Library/Application Support/xyz.block.buzz.app/agents"); + let anchor = canonical_dev_anchor_pub(&local_agents); + assert!( + anchor.is_none(), + "production dir should not produce a dev anchor" + ); +} + +#[test] +fn test_saga_crash_mid_compensation_recovers() { + // Simulate: operation inserted, CAS committed, compensation pinned + // (crash here), then recovery reads it as Compensating with a non-nil + // compensation_id and can continue. + let conn = in_memory_journal(); + insert_operation(&conn, "op-crash", "create", "k1", Generation(0)).unwrap(); + cas_generation(&conn, "k1", Generation(0)).unwrap(); + pin_compensation(&conn, "op-crash", "comp-1", Generation(1)).unwrap(); + let ops = read_nonterminal_operations(&conn).unwrap(); + assert_eq!(ops.len(), 1); + assert_eq!(ops[0].disposition, Disposition::Compensating); + assert_eq!(ops[0].compensation_id.as_deref(), Some("comp-1")); +} + +#[test] +fn test_saga_uncertain_publication_sets_follow_up() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-unc", "publish", "k2", Generation(0)).unwrap(); + advance_disposition( + &conn, + "op-unc", + &Disposition::Pending, + &Disposition::Uncertain, + ) + .unwrap(); + set_nonterminal_follow_up(&conn, "op-unc", &Disposition::Uncertain, true).unwrap(); + let op = read_operation(&conn, "op-unc").unwrap().unwrap(); + assert!(op.disposition.requires_follow_up()); + assert!(op.nonterminal_follow_up); + advance_disposition( + &conn, + "op-unc", + &Disposition::Uncertain, + &Disposition::Committed, + ) + .unwrap(); + set_nonterminal_follow_up(&conn, "op-unc", &Disposition::Committed, false).unwrap(); + let op2 = read_operation(&conn, "op-unc").unwrap().unwrap(); + assert!(op2.disposition.is_terminal()); + assert!(!op2.nonterminal_follow_up); +} + +#[test] +fn test_two_threads_serialised_by_advisory_lock() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let barrier = Arc::new(Barrier::new(2)); + let anchor2 = anchor.clone(); + let barrier2 = barrier.clone(); + let handle = thread::spawn(move || { + let _guard = JournalLockGuard::acquire(&anchor2).unwrap(); + std::fs::write(anchor2.join("data.txt"), b"A").unwrap(); + barrier2.wait(); // signal that A has written and holds the lock + thread::sleep(std::time::Duration::from_millis(20)); + }); + barrier.wait(); // wait until A holds the lock + let _guard = JournalLockGuard::acquire(&anchor).unwrap(); + let content = std::fs::read(anchor.join("data.txt")).unwrap(); + assert_eq!(content, b"A"); + handle.join().unwrap(); +} + +#[test] +fn test_open_journal_creates_on_first_boot() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let conn = open_journal(&anchor).unwrap(); + let ops = read_nonterminal_operations(&conn).unwrap(); + assert!(ops.is_empty()); +} + +#[test] +fn test_decode_agent_store_absent_file_empty_vec() { + let result = decode_agent_store(b"[]").unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn test_advance_disposition_wrong_expected_returns_conflict() { + let conn = in_memory_journal(); + insert_operation(&conn, "op-conflict", "create", "k1", Generation(0)).unwrap(); + let outcome = advance_disposition( + &conn, + "op-conflict", + &Disposition::Committed, + &Disposition::Compensated, + ) + .unwrap(); + assert!( + matches!(outcome, TransitionOutcome::Conflict { .. }), + "wrong expected must → Conflict" + ); + let op = read_operation(&conn, "op-conflict").unwrap().unwrap(); + assert_eq!( + op.disposition, + Disposition::Pending, + "disposition must not change on Conflict" + ); +} + +#[test] +fn test_advance_disposition_not_found_returns_not_found() { + let conn = in_memory_journal(); + let outcome = advance_disposition( + &conn, + "nonexistent", + &Disposition::Pending, + &Disposition::Committed, + ) + .unwrap(); + assert_eq!(outcome, TransitionOutcome::NotFound); +} + +#[test] +fn test_two_dev_worktree_paths_resolve_to_same_canonical_anchor() { + use std::path::PathBuf; + let branch_a = + PathBuf::from("/Library/Application Support/xyz.block.buzz.app.dev.branch-a/agents"); + let branch_b = + PathBuf::from("/Library/Application Support/xyz.block.buzz.app.dev.branch-b/agents"); + let anchor_a = canonical_dev_anchor_pub(&branch_a); + let anchor_b = canonical_dev_anchor_pub(&branch_b); + assert!( + anchor_a.is_some(), + "branch-a must resolve to a canonical anchor" + ); + assert_eq!( + anchor_a, anchor_b, + "two dev worktrees must select the same anchor" + ); + let expected = PathBuf::from("/Library/Application Support/xyz.block.buzz.app.dev/agents"); + assert_eq!(anchor_a.unwrap(), expected); +} + +#[test] +fn test_mutate_store_malformed_agents_json_is_fail_closed() { + let dir = tmp_dir(); + let agents_path = dir.path().join("managed-agents.json"); + let bad_bytes = b"{\"this is\":\"not a valid agent array\"}"; + std::fs::write(&agents_path, bad_bytes).unwrap(); + assert!( + decode_agent_store(bad_bytes).is_err(), + "malformed JSON array must fail closed" + ); + assert_eq!(std::fs::read(&agents_path).unwrap(), bad_bytes); +} + +#[test] +fn test_decode_agent_store_unknown_field_fails_closed() { + let bytes = br#"[{"pubkey":"abc","name":"test","slug":"test-slug", + "system_prompt":"","private_key_nsec":"","created_at":"","updated_at":"", + "respond_to":"everyone","respond_to_allowlist":[],"relay_url":"", + "backend":"local","parallelism":1,"agent_args":[], + "acp_command":null,"agent_command_override":null,"env_vars":[], + "__unknown_extra_field__": "value"}]"#; + let result = decode_agent_store(bytes); + assert!( + result.is_err(), + "unknown field in record must cause a decode error (deny_unknown_fields)" + ); +} + +#[test] +fn test_decode_team_store_unknown_field_fails_closed() { + let bytes = br#"[{"id":"team-1","name":"My Team","personas":[], + "agents":[],"__extra__":"bad"}]"#; + let result = decode_team_store(bytes); + assert!( + result.is_err(), + "unknown field in TeamRecord must cause a decode error (deny_unknown_fields)" + ); +} + +#[test] +fn test_two_threads_serialised_by_advisory_lock_via_journal() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let counter_path = anchor.join("counter.txt"); + std::fs::create_dir_all(&anchor).unwrap(); + std::fs::write(&counter_path, b"0").unwrap(); + let anchor_a = anchor.clone(); + let counter_a = counter_path.clone(); + let barrier = Arc::new(Barrier::new(2)); + let barrier2 = barrier.clone(); + let handle = thread::spawn(move || { + let _guard = JournalLockGuard::acquire(&anchor_a).unwrap(); + let v: u32 = std::fs::read_to_string(&counter_a) + .unwrap() + .trim() + .parse() + .unwrap(); + std::fs::write(&counter_a, (v + 1).to_string()).unwrap(); + barrier2.wait(); + thread::sleep(std::time::Duration::from_millis(20)); + }); + barrier.wait(); + let _guard = JournalLockGuard::acquire(&anchor).unwrap(); + let final_val: u32 = std::fs::read_to_string(&counter_path) + .unwrap() + .trim() + .parse() + .unwrap(); + handle.join().unwrap(); + assert_eq!(final_val, 1, "B must observe A's committed write"); +} + +/// Verify that a nonterminal pending operation with no outbox evidence is +/// advanced to Failed by run_boot_recovery_at (real journal close + reopen). +#[test] +fn test_boot_recovery_marks_no_evidence_op_failed_on_reopen() { + use super::run_boot_recovery_at; + + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + { + let journal = open_journal(&anchor).unwrap(); + insert_operation( + &journal, + "op-crash-1", + "create_agent", + "key-crash", + Generation(0), + ) + .unwrap(); + } + + run_boot_recovery_at(&anchor, None).unwrap(); + let journal = open_journal(&anchor).unwrap(); + let op = read_operation(&journal, "op-crash-1") + .unwrap() + .expect("op must still exist"); + assert_eq!( + op.disposition, + Disposition::Failed, + "no-evidence op must be Failed" + ); + assert!(op.disposition.is_terminal()); + let remaining = read_nonterminal_operations(&journal).unwrap(); + assert!( + remaining.iter().all(|o| o.operation_id != "op-crash-1"), + "failed op must not appear in nonterminal" + ); +} + +/// Verify that a pending op WITH an unpublished outbox event is left in its +/// current (Pending) disposition so the flush loop can re-drive it. +#[test] +fn test_boot_recovery_leaves_pending_outbox_op_for_redriving() { + use super::run_boot_recovery_at; + + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + { + let journal = open_journal(&anchor).unwrap(); + insert_operation(&journal, "op-outbox-1", "publish", "key-pub", Generation(0)).unwrap(); + insert_outbox_event( + &journal, + "ev-pub-1", + "op-outbox-1", + b"{\"id\":\"ev-pub-1\"}", + "", + ) + .unwrap(); + } + + run_boot_recovery_at(&anchor, None).unwrap(); + let journal = open_journal(&anchor).unwrap(); + let op = read_operation(&journal, "op-outbox-1") + .unwrap() + .expect("op must exist"); + assert_eq!( + op.disposition, + Disposition::Pending, + "op with pending outbox must stay Pending" + ); + let nonterminal = read_nonterminal_operations(&journal).unwrap(); + assert!( + nonterminal.iter().any(|o| o.operation_id == "op-outbox-1"), + "op must be nonterminal" + ); +} + +/// Verify that a keyring_write op WITH an inbox pre-image is advanced to Failed +/// (interrupted write detected; inline fallback re-migrates on next load). +#[test] +fn test_boot_recovery_keyring_write_with_inbox_marks_failed() { + use super::run_boot_recovery_at; + + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + { + let journal = open_journal(&anchor).unwrap(); + insert_operation( + &journal, + "op-kr-1", + "keyring_write", + "key-kr", + Generation(0), + ) + .unwrap(); + insert_inbox_event(&journal, "in-kr-1", "op-kr-1", b"pubkey-bytes").unwrap(); + } + + run_boot_recovery_at(&anchor, None).unwrap(); + let journal = open_journal(&anchor).unwrap(); + let op = read_operation(&journal, "op-kr-1") + .unwrap() + .expect("op must exist"); + assert_eq!( + op.disposition, + Disposition::Failed, + "interrupted keyring_write must be Failed" + ); +} + +/// Boot recovery re-inserts a missing retention row from the immutable outbox +/// payload, then simulates the flush loop advancing the owning op to Committed. +/// A second recovery pass finds no nonterminal ops — published exactly once. +#[test] +fn test_boot_recovery_journal_only_evidence_published_terminal_once() { + use super::run_boot_recovery_at; + use crate::managed_agents::retention::{get_pending_sync, mark_synced, open_retention_db}; + use nostr::JsonUtil; + + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let retention_db_path = anchor.join("retention.db"); + let keys = nostr::Keys::generate(); + let owner_pubkey = keys.public_key().to_hex(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30177), "test-content") + .tag(nostr::Tag::identifier("test-agent")) + .sign_with_keys(&keys) + .unwrap(); + let event_id = event.id.to_hex(); + let raw_payload = event.as_json(); + { + let journal = open_journal(&anchor).unwrap(); + insert_operation(&journal, "op-pub-1", "publish", "test-agent", Generation(0)).unwrap(); + insert_outbox_event( + &journal, + &event_id, + "op-pub-1", + raw_payload.as_bytes(), + "test-agent", + ) + .unwrap(); + } + let conn = open_retention_db(&retention_db_path).unwrap(); + assert!( + get_pending_sync(&conn).unwrap().is_empty(), + "no retention rows before recovery" + ); + drop(conn); + run_boot_recovery_at(&anchor, Some(&retention_db_path)).unwrap(); + let conn = open_retention_db(&retention_db_path).unwrap(); + let pending = get_pending_sync(&conn).unwrap(); + assert_eq!( + pending.len(), + 1, + "recovery must re-insert exactly one pending_sync row" + ); + let row = &pending[0]; + assert_eq!(row.d_tag, "test-agent"); + assert_eq!(row.pubkey, owner_pubkey); + let journal = open_journal(&anchor).unwrap(); + let op = read_operation(&journal, "op-pub-1").unwrap().unwrap(); + assert_eq!( + op.disposition, + Disposition::Pending, + "op must stay Pending after recovery" + ); + mark_synced( + &conn, + row.kind, + &row.pubkey, + &row.d_tag, + row.created_at, + &row.content, + ) + .unwrap(); + assert!(mark_outbox_published(&journal, &event_id, 0, 1).unwrap()); + let pending_siblings: i64 = journal + .query_row( + "SELECT COUNT(*) FROM outbox_events WHERE operation_id = ?1 AND published_state = 0", + rusqlite::params!["op-pub-1"], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(pending_siblings, 0); + advance_disposition( + &journal, + "op-pub-1", + &Disposition::Pending, + &Disposition::Committed, + ) + .unwrap(); + drop(journal); + run_boot_recovery_at(&anchor, Some(&retention_db_path)).unwrap(); + let journal = open_journal(&anchor).unwrap(); + let op = read_operation(&journal, "op-pub-1").unwrap().unwrap(); + assert_eq!( + op.disposition, + Disposition::Committed, + "op must remain Committed" + ); + assert!( + read_nonterminal_operations(&journal) + .unwrap() + .iter() + .all(|o| o.operation_id != "op-pub-1"), + "committed op must not appear in nonterminal" + ); +} + +/// Subprocess helper: when `STORE_JOURNAL_TEST_ROLE` is set, runs the named role +/// (writer, json_mutator) and exits. No-op on normal test runs. +#[doc(hidden)] +pub fn maybe_run_subprocess_helper() { + let role = std::env::var("STORE_JOURNAL_TEST_ROLE").unwrap_or_default(); + match role.as_str() { + "writer" => { + let dir = std::env::var("STORE_JOURNAL_TEST_DIR") + .expect("STORE_JOURNAL_TEST_DIR must be set for writer role"); + let anchor = std::path::PathBuf::from(&dir); + let output_path = anchor.join("output.txt"); + let _guard = + JournalLockGuard::acquire(&anchor).expect("subprocess: acquire advisory lock"); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&output_path) + .expect("subprocess: open output.txt"); + use std::io::Write; + writeln!(file, "from-writer").expect("subprocess: write"); + drop(file); + std::thread::sleep(std::time::Duration::from_millis(50)); + std::process::exit(0); + } + "json_mutator" => { + let dir = std::env::var("STORE_JOURNAL_TEST_DIR") + .expect("STORE_JOURNAL_TEST_DIR must be set for json_mutator role"); + let anchor = std::path::PathBuf::from(&dir); + let agents_path = anchor.join("managed-agents.json"); + let _guard = + JournalLockGuard::acquire(&anchor).expect("subprocess: acquire advisory lock"); + let mut records = + decode_agent_store(&std::fs::read(&agents_path).expect("subprocess: read")) + .expect("subprocess: decode"); + records.extend( + decode_agent_store(&minimal_agents_json("subprocess_agent")) + .expect("subprocess: new record"), + ); + let payload = serde_json::to_vec_pretty(&records).expect("subprocess: serialize"); + atomic_write_with_fsync(&agents_path, &payload).expect("subprocess: write"); + std::process::exit(0); + } + _ => {} + } +} + +/// Subprocess entry-point check: when `STORE_JOURNAL_TEST_ROLE=writer` is set, +/// delegate to the helper (which writes and exits 0) before any other test +/// discovery runs. Harmless no-op on normal test runs. +#[test] +fn subprocess_mode_check() { + maybe_run_subprocess_helper(); +} + +/// Two real processes serialised by the advisory lock. Process A acquires, +/// writes "from-a\n", spawns B which blocks until A releases, then B writes. +/// Output must contain both lines in order. +#[test] +fn test_two_processes_serialised_by_advisory_lock() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + std::fs::create_dir_all(&anchor).unwrap(); + let output_path = anchor.join("output.txt"); + { + let _guard = JournalLockGuard::acquire(&anchor).unwrap(); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&output_path) + .unwrap(); + use std::io::Write; + writeln!(file, "from-a").unwrap(); + drop(file); + let exe = std::env::current_exe().expect("current_exe must be available in tests"); + let mut child = std::process::Command::new(&exe) + .env("STORE_JOURNAL_TEST_ROLE", "writer") + .env("STORE_JOURNAL_TEST_DIR", anchor.to_str().unwrap()) + .env("RUST_TEST_NOCAPTURE", "0") + .arg("subprocess_mode_check") + .arg("--test-threads=1") + .spawn() + .expect("failed to spawn writer subprocess"); + std::thread::sleep(std::time::Duration::from_millis(80)); + drop(_guard); + let status = child.wait().expect("subprocess must finish"); + assert!( + status.success(), + "writer subprocess must exit 0, got {status:?}" + ); + } + + let content = std::fs::read_to_string(&output_path).unwrap(); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!( + lines, + vec!["from-a", "from-writer"], + "output must show A wrote first, then B: got {lines:?}" + ); +} + +/// Two real processes, no lost update. Seed=1 record, A appends 1 under lock, +/// spawns B which blocks, B appends 1. Final count must be 3 (2 = lost-update). +#[test] +fn test_two_processes_no_lost_json_update() { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + std::fs::create_dir_all(&anchor).unwrap(); + let agents_path = anchor.join("managed-agents.json"); + std::fs::write(&agents_path, minimal_agents_json("seed_agent")).unwrap(); + { + let _guard = JournalLockGuard::acquire(&anchor).unwrap(); + let mut records = decode_agent_store(&std::fs::read(&agents_path).unwrap()).unwrap(); + records.extend(decode_agent_store(&minimal_agents_json("process_a_agent")).unwrap()); + atomic_write_with_fsync(&agents_path, &serde_json::to_vec_pretty(&records).unwrap()) + .unwrap(); + let exe = std::env::current_exe().expect("current_exe"); + let mut child = std::process::Command::new(&exe) + .env("STORE_JOURNAL_TEST_ROLE", "json_mutator") + .env("STORE_JOURNAL_TEST_DIR", anchor.to_str().unwrap()) + .env("RUST_TEST_NOCAPTURE", "0") + .arg("subprocess_mode_check") + .arg("--test-threads=1") + .spawn() + .expect("spawn json_mutator"); + std::thread::sleep(std::time::Duration::from_millis(80)); + drop(_guard); + assert!( + child.wait().expect("subprocess wait").success(), + "json_mutator failed" + ); + } + + let records = decode_agent_store(&std::fs::read(&agents_path).unwrap()).unwrap(); + let pubkeys: Vec<&str> = records.iter().map(|r| r.pubkey.as_str()).collect(); + assert_eq!(records.len(), 3, "lost-update: got {pubkeys:?}"); + assert!( + pubkeys.contains(&"seed_agent") + && pubkeys.contains(&"process_a_agent") + && pubkeys.contains(&"subprocess_agent"), + "missing agent: {pubkeys:?}" + ); +} + +/// Build a minimal well-formed `ManagedAgentRecord` JSON list with one record. +fn minimal_agents_json(pubkey: &str) -> Vec { + let records: Vec = serde_json::from_str(&format!( + r#"[{{"pubkey":{pubkey:?},"name":"T","relay_url":"","acp_command":"","agent_command":"", + "agent_args":[],"mcp_command":"","turn_timeout_seconds":0, + "created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}}]"# + )) + .expect("minimal_agents_json"); + serde_json::to_vec_pretty(&records).expect("serialize") +} + +fn empty_teams_json() -> Vec { + serde_json::to_vec_pretty(&serde_json::json!([])).unwrap() +} + +fn insert_file_commit_phase_row(conn: &Connection, cid: &str, phase: &str, as_: &str, ts: &str) { + conn.execute( + "INSERT INTO file_commit_phases (commit_id,operation_id,phase,agents_stage_path,teams_stage_path,agents_content_hash,teams_content_hash,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,'','',0,0)", + rusqlite::params![cid, cid, phase, as_, ts], + ).unwrap(); +} + +/// Three crash scenarios for the two-phase file-commit protocol (intent, +/// first_renamed+pending, first_renamed+done), driven by file commit recovery. +#[test] +#[allow(clippy::type_complexity)] +fn test_crash_recovery_file_commit_phases() { + // (phase, commit_id, [a_stage,t_stage,a_can,t_can] pre-exist, a_can post, t_can post, pubkey) + let cases: &[(&str, &str, [bool; 4], bool, bool, &str)] = &[ + ( + "intent", + "cc-1", + [true, true, false, false], + true, + true, + "crash1", + ), + ( + "first_renamed", + "cc-2", + [false, true, true, false], + true, + true, + "crash2", + ), + ( + "first_renamed", + "cc-3", + [false, false, true, true], + true, + true, + "crash3", + ), + ]; + + fn wr(flag: bool, p: &std::path::Path, d: Vec) { + if flag { + std::fs::write(p, d).unwrap(); + } + } + + for (phase, cid, pre, a_exists, t_exists, pk) in cases { + let dir = tmp_dir(); + let anchor = dir.path().to_path_buf(); + let a_stage = anchor.join("managed-agents.json.stage"); + let t_stage = anchor.join("teams.json.stage"); + let a_can = anchor.join("managed-agents.json"); + let t_can = anchor.join("teams.json"); + wr(pre[0], &a_stage, minimal_agents_json(pk)); + wr(pre[1], &t_stage, empty_teams_json()); + wr(pre[2], &a_can, minimal_agents_json(pk)); + wr(pre[3], &t_can, empty_teams_json()); + let j = open_journal(&anchor).unwrap(); + insert_file_commit_phase_row( + &j, + cid, + phase, + a_stage.to_str().unwrap(), + t_stage.to_str().unwrap(), + ); + drop(j); + super::file_commit_recovery_at_pub(&anchor).unwrap(); + assert_eq!(a_can.exists(), *a_exists, "{pk} agents_can"); + assert_eq!(t_can.exists(), *t_exists, "{pk} teams_can"); + if *a_exists { + let recs = decode_agent_store(&std::fs::read(&a_can).unwrap()).unwrap(); + assert_eq!(recs.len(), 1); + assert_eq!(recs[0].pubkey, *pk); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 937893d531..3a6ca24909 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -3,14 +3,19 @@ use std::{fs, path::PathBuf}; use tauri::AppHandle; use crate::{ - managed_agents::{managed_agents_base_dir, ManagedAgentRecord, TeamRecord}, + managed_agents::{ManagedAgentRecord, TeamRecord}, util::now_iso, }; use super::team_repair::team_persona_key; pub(crate) fn teams_store_path(app: &AppHandle) -> Result { - Ok(managed_agents_base_dir(app)?.join("teams.json")) + // Resolve through the B1 anchor so dev worktrees use the canonical shared + // path and standalone bundles use their own — never derived from a + // possibly-absent managed-agents.json. + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor).map_err(|e| format!("failed to create anchor dir: {e}"))?; + Ok(anchor.join("teams.json")) } fn sort_teams(records: &mut [TeamRecord]) { @@ -156,14 +161,15 @@ pub fn validate_team_deletion(team: &TeamRecord) -> Result<(), String> { /// Returns the merged, sorted team list. No file is written — callers that /// only need the current logical state (e.g. the snapshot-import pre-read) /// use this to avoid a write-on-load side effect. +#[allow(dead_code)] pub(crate) fn load_teams_readonly(path: &std::path::Path) -> Result, String> { let now = now_iso(); let records = if path.exists() { - let content = fs::read_to_string(path) - .map_err(|error| format!("failed to read teams store: {error}"))?; - serde_json::from_str::>(&content) - .map_err(|error| format!("failed to parse teams store: {error}"))? + let bytes = + fs::read(path).map_err(|error| format!("failed to read teams store: {error}"))?; + crate::managed_agents::store_journal::decode_team_store(&bytes) + .map_err(|error| format!("failed to parse teams store: {}", error.message))? } else { Vec::new() }; @@ -177,11 +183,17 @@ pub fn load_teams(app: &AppHandle) -> Result, String> { let path = teams_store_path(app)?; let now = now_iso(); + // Acquire the interprocess advisory lock before reading (the parent dir + // is the B1 anchor — same lock file as the agent-store lock). + let anchor = crate::managed_agents::store_journal::store_anchor_dir(app)?; + std::fs::create_dir_all(&anchor).map_err(|e| format!("failed to create anchor dir: {e}"))?; + let _advisory = crate::managed_agents::store_journal::JournalLockGuard::acquire(&anchor)?; + let records = if path.exists() { - let content = fs::read_to_string(&path) - .map_err(|error| format!("failed to read teams store: {error}"))?; - serde_json::from_str::>(&content) - .map_err(|error| format!("failed to parse teams store: {error}"))? + let bytes = + fs::read(&path).map_err(|error| format!("failed to read teams store: {error}"))?; + crate::managed_agents::store_journal::decode_team_store(&bytes) + .map_err(|error| format!("failed to parse teams store: {}", error.message))? } else { Vec::new() }; @@ -190,20 +202,22 @@ pub fn load_teams(app: &AppHandle) -> Result, String> { sort_teams(&mut records); if changed || !path.exists() { - save_teams(app, &records)?; + // Advisory lock already held; call the inner write helper directly + // to avoid a double-lock on the same fd. + save_teams_locked(&path, &records)?; } Ok(records) } -pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String> { +/// Write `records` to `path` using the fail-closed atomic fsync write. +/// Called from `load_teams` (already holds the advisory lock). +fn save_teams_locked(path: &std::path::Path, records: &[TeamRecord]) -> Result<(), String> { let mut sorted = records.to_vec(); sort_teams(&mut sorted); - - let path = teams_store_path(app)?; let payload = serde_json::to_vec_pretty(&sorted) .map_err(|error| format!("failed to serialize teams store: {error}"))?; - crate::managed_agents::storage::atomic_write_json(&path, &payload) + crate::managed_agents::store_journal::atomic_write_with_fsync(path, &payload) } /// Names of managed agents that still reference `team` — either via the @@ -236,67 +250,86 @@ fn agents_referencing_team<'a>( /// tombstoned but the orphaned kind:30175 persona heads stay live on the relay. /// For JSON-only teams (no `source_dir`), nothing cascades and the returned /// vec is empty. -pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result, String> { - let mut teams = load_teams(app)?; - let team = teams - .iter() - .find(|record| record.id == team_id) - .ok_or_else(|| format!("team {team_id} not found"))?; +/// +/// `store_guard` is the caller-held in-process mutex guard. It is returned so +/// the caller can continue holding the lock after the delete (e.g. to enqueue +/// tombstone retention events). The agents and teams JSON are written +/// atomically in a single `mutate_store` closure, eliminating the TOCTOU window +/// between the former `save_personas` + `save_teams` call sequence. +pub fn delete_team_with_cascade<'g>( + app: &AppHandle, + team_id: &str, + store_guard: std::sync::MutexGuard<'g, ()>, +) -> Result<(Vec, std::sync::MutexGuard<'g, ()>), String> { + // Pre-validate outside the advisory lock (read-only). + let agents = crate::managed_agents::load_managed_agents(app)?; - validate_team_deletion(team)?; + // Perform the atomic delete inside a single mutate_store closure. + let team_id = team_id.to_owned(); + let (cascaded_persona_d_tags, guard) = + crate::managed_agents::store_journal::mutate_store(app, store_guard, move |st| { + let crate::managed_agents::store_journal::StoreState { + agents: mut all_agents, + mut teams, + .. + } = st; + + let team = teams + .iter() + .find(|record| record.id == team_id) + .ok_or_else(|| format!("team {team_id} not found"))?; + + validate_team_deletion(team)?; + + let referencing = agents_referencing_team(&agents, team); + if !referencing.is_empty() { + return Err(format!( + "Cannot delete team \"{team_id}\": {} agent(s) still reference it ({}). \ + Delete or reconfigure them first.", + referencing.len(), + referencing.join(", ") + )); + } - let agents = crate::managed_agents::load_managed_agents(app)?; - let referencing = agents_referencing_team(&agents, team); - if !referencing.is_empty() { - return Err(format!( - "Cannot delete team \"{team_id}\": {} agent(s) still reference it ({}). \ - Delete or reconfigure them first.", - referencing.len(), - referencing.join(", ") - )); - } + let mut cascaded_persona_d_tags = Vec::new(); + + if team.source_dir.is_some() { + // Directory-backed team: cascade persona definitions from the + // unified agents array. Match on the shared persona key. + let persona_key = team_persona_key(team).to_string(); - let mut cascaded_persona_d_tags = Vec::new(); - - if team.source_dir.is_some() { - // Directory-backed team: cascade personas + backing directory too. - // Match on the shared key (directory name) so legacy UUID-id teams - // still cascade correctly. - let persona_key = team_persona_key(team).to_string(); - - // 1. Remove all PersonaRecords sourced from this team - let mut personas = super::load_personas(app)?; - // Capture the d-tag of each cascaded persona BEFORE removal so the - // caller can tombstone its kind:30175 coordinate on the relay. - cascaded_persona_d_tags = personas - .iter() - .filter(|p| p.source_team.as_deref() == Some(persona_key.as_str())) - .map(super::persona_events::persona_d_tag) - .collect(); - personas.retain(|p| p.source_team.as_deref() != Some(persona_key.as_str())); - super::save_personas(app, &personas)?; - - // 2. Remove directory - if let Some(source_dir) = &team.source_dir { - if source_dir.exists() { - let is_symlink = fs::symlink_metadata(source_dir) - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); - if is_symlink { - fs::remove_file(source_dir) - .map_err(|e| format!("failed to remove team symlink: {e}"))?; - } else { - fs::remove_dir_all(source_dir) - .map_err(|e| format!("failed to remove team directory: {e}"))?; - } + // Capture d-tags before removal so the caller can tombstone them. + cascaded_persona_d_tags = all_agents + .iter() + .filter(|r| { + r.pubkey.is_empty() + && r.source_team.as_deref() == Some(persona_key.as_str()) + }) + .map(|r| { + // d-tag derivation mirrors persona_events::persona_d_tag: + // use source_team_persona_slug if present, else persona_id. + let raw = r + .source_team_persona_slug + .as_deref() + .or(r.persona_id.as_deref()) + .unwrap_or(""); + crate::managed_agents::persona_events::normalize_d_tag_pub(raw) + }) + .collect(); + + // Remove the cascaded persona definition records. + all_agents.retain(|r| { + !r.pubkey.is_empty() || r.source_team.as_deref() != Some(persona_key.as_str()) + }); } - } - } - // 4. Remove TeamRecord - teams.retain(|record| record.id != team_id); - save_teams(app, &teams)?; - Ok(cascaded_persona_d_tags) + // Remove the TeamRecord. + teams.retain(|record| record.id != team_id); + + Ok((all_agents, teams, cascaded_persona_d_tags)) + })?; + + Ok((cascaded_persona_d_tags, guard)) } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index c5bb6173d1..e18fbed986 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -209,6 +209,7 @@ pub struct RelayAgentInfo { pub respond_to_allowlist: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] pub struct ManagedAgentRecord { pub pubkey: String, pub name: String, @@ -761,6 +762,7 @@ pub struct AgentModelInfo { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TeamRecord { pub id: String, pub name: String, @@ -828,7 +830,6 @@ fn default_auto_restart_on_config_change() -> bool { fn default_record_active() -> bool { true } - // ── Inbound author gate ────────────────────────────────────────────────────── // // Mirrors `buzz-acp`'s `--respond-to` CLI flag and the related @@ -994,6 +995,5 @@ mod catalog_source; pub use catalog_source::CatalogSource; mod requests; pub use requests::*; - #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461..b971b11558 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -17,7 +17,7 @@ use super::{ /// a stored behavior group on every team-import edit. Absent group = don't touch the /// stored behavior group; present group = validate and replace the fields as a unit /// (mode and allowlist must travel together). -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Default, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PersonaBehaviorRequest { #[serde(default)] diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 7933fd291e..88e87f10c7 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -400,35 +400,49 @@ fn running_relay_mesh_model_id( fn persist_mesh_last_error(app: &AppHandle, pubkey: &str, error: &str) -> Result<(), String> { let state = app.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| format!("failed to acquire managed agents store lock: {e}"))?; - let mut records = crate::managed_agents::load_managed_agents(app)?; - let record = crate::managed_agents::find_managed_agent_mut(&mut records, pubkey)?; - record.last_error = Some(error.to_string()); - record.updated_at = crate::util::now_iso(); - crate::managed_agents::save_managed_agents(app, &records) + let error_owned = error.to_string(); + let (_, _, _guard) = crate::managed_agents::storage::mutate_managed_agent( + app, + store_guard, + pubkey, + move |record, _journal| { + record.last_error = Some(error_owned); + record.updated_at = crate::util::now_iso(); + Ok(()) + }, + )?; + Ok(()) } fn clear_mesh_last_error_if_set(app: &AppHandle, pubkey: &str) -> Result<(), String> { let state = app.state::(); - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|e| format!("failed to acquire managed agents store lock: {e}"))?; - let mut records = crate::managed_agents::load_managed_agents(app)?; - let record = crate::managed_agents::find_managed_agent_mut(&mut records, pubkey)?; - if !record - .last_error - .as_deref() - .is_some_and(|error| error.starts_with(MESH_REARM_ERROR_SENTINEL)) - { - return Ok(()); - } - record.last_error = None; - record.updated_at = crate::util::now_iso(); - crate::managed_agents::save_managed_agents(app, &records) + let (_, cleared, _guard) = crate::managed_agents::storage::mutate_managed_agent( + app, + store_guard, + pubkey, + move |record, _journal| { + if !record + .last_error + .as_deref() + .is_some_and(|error| error.starts_with(MESH_REARM_ERROR_SENTINEL)) + { + return Ok(false); + } + record.last_error = None; + record.updated_at = crate::util::now_iso(); + Ok(true) + }, + )?; + let _ = cleared; // result: whether the error was cleared + Ok(()) } #[cfg(test)] diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index b3e613621e..c4c199a39b 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -15,6 +15,7 @@ //! discovery table. Ensures known providers always have their canonical //! `mcp_command`; unknown/custom agents are left untouched. +use crate::managed_agents::store_journal::atomic_write_restricted_with_fsync as write_restricted; use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; use tauri::Manager; @@ -106,19 +107,10 @@ fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> { /// agent restore. Ordering is load-bearing: `migrate_legacy_app_data_dir` must /// precede any disk read, and `sync_shared_agent_data` must precede /// `restore_managed_agents_on_launch` (which reads `managed-agents.json`). -/// Identity-dependent migrations (persona/team event signing) run separately in -/// boot setup after the persisted identity is resolved. /// -/// # Ordering -/// `sync_team_personas` is the sole writer of team-dir persona-runtime edits -/// into `personas.json`/`teams.json`; it MUST run before every reader of those -/// files. The pre-identity reader is `reconcile_provider_mcp_commands` (derives -/// `mcp_command` from each persona's effective harness); the post-identity -/// readers are `migrate_personas_to_events`/`migrate_teams_to_events` in -/// [`crate::event_sync::run_event_sync`]. Sync touches only JSON (no owner -/// keys, no `retention.db`), so it runs pre-identity here ahead of all -/// readers — reader-first loses a launch (stale harness/`mcp_command` until -/// the next boot). +/// `sync_team_personas` runs before `reconcile_provider_mcp_commands` (and +/// before event-sync readers) because it is the sole writer of team-dir +/// persona/harness edits into `personas.json`/`teams.json`. pub fn run_boot_migrations(app: &tauri::AppHandle) { run_boot_migrations_inner(app, false); } @@ -482,17 +474,17 @@ fn copy_file_over_generated_default(src: &Path, dst: &Path) -> std::io::Result<( std::fs::copy(src, dst).map(|_| ()) } -/// Read a JSON array of objects from `path`, apply `f` to each object, -/// and write back if any mutation returned `true`. -/// -/// Writes back via [`crate::managed_agents::atomic_write_json_restricted`] -/// (owner-only `0o600`): the store files this rewrites can carry plaintext -/// agent nsecs on a keyringless host, so the write must not reopen the umask -/// window SECURITY.md:90 closes. +/// Read a JSON array of objects from `path`, apply `f` to each object, and +/// write back atomically (advisory-locked) if any mutation returned `true`. fn patch_json_records( path: &Path, + anchor: &Path, mut f: impl FnMut(&mut serde_json::Map) -> bool, ) { + let Ok(_advisory) = crate::managed_agents::store_journal::JournalLockGuard::acquire(anchor) + else { + return; + }; let Ok(content) = std::fs::read_to_string(path) else { return; }; @@ -511,7 +503,7 @@ fn patch_json_records( } if changed { if let Ok(bytes) = serde_json::to_vec_pretty(&records) { - if let Err(e) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) { + if let Err(e) = write_restricted(path, &bytes) { eprintln!("buzz-desktop: patch-json-records: {e}"); } } @@ -560,10 +552,10 @@ struct LegacyAvatarMatch<'a> { /// idempotent and avoids relying on timestamps or other persona fields the /// user may also have edited. fn refresh_builtin_agent_avatars(app: &tauri::AppHandle) { - let Ok(dir) = app.path().app_data_dir() else { + let Ok(anchor) = crate::managed_agents::store_journal::store_anchor_dir(app) else { return; }; - let path = dir.join("agents/managed-agents.json"); + let path = anchor.join("managed-agents.json"); if path.exists() { refresh_builtin_agent_avatars_in_file( &path, @@ -662,7 +654,7 @@ fn refresh_builtin_agent_avatars_in_file( if changed { if let Ok(bytes) = serde_json::to_vec_pretty(&records) { - if let Err(e) = crate::managed_agents::atomic_write_json_restricted(path, &bytes) { + if let Err(e) = write_restricted(path, &bytes) { eprintln!("buzz-desktop: refresh-builtin-agent-avatars: {e}"); } } @@ -709,7 +701,7 @@ fn uploaded_media_sha256(avatar_url: &str) -> Option { fn persona_version_from_record(record: &serde_json::Value) -> Option { let record: crate::managed_agents::ManagedAgentRecord = - serde_json::from_value(record.clone()).ok()?; + crate::managed_agents::store_journal::decode_agent_record_permissive(record.clone())?; let definition = record.to_definition_view()?; Some(crate::managed_agents::persona_events::persona_content_hash( &crate::managed_agents::persona_events::persona_event_content(&definition), @@ -834,6 +826,12 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { return; } + let Ok(_advisory) = crate::managed_agents::store_journal::JournalLockGuard::acquire( + &canonical_dir.join("agents"), + ) else { + return; + }; + // Seed-up: if canonical is missing a shared file but a sibling instance // holds real (non-symlink) content, migrate it up to canonical before the // symlink loop runs. Mirrors the SHARED_AGENT_DIRS migration below, applied @@ -979,7 +977,7 @@ fn reconcile_mcp_commands_in_file(path: &Path) { // from the sibling personas.json; missing entries fall back to the record's // own agent_command (the create-time snapshot). let persona_runtimes = load_persona_runtimes(path); - patch_json_records(path, |obj| { + patch_json_records(path, path.parent().unwrap_or(path), |obj| { let override_cmd = obj .get("agent_command_override") .and_then(|v| v.as_str()) @@ -1050,7 +1048,7 @@ fn replace_command_field( } fn reconcile_legacy_command_names_in_file(path: &Path) { - patch_json_records(path, |obj| { + patch_json_records(path, path.parent().unwrap_or(path), |obj| { let mut changed = false; if let Some(acp_command) = obj @@ -1100,7 +1098,7 @@ fn reconcile_legacy_command_names_in_file(path: &Path) { } fn reconcile_legacy_persona_runtimes_in_file(path: &Path) { - patch_json_records(path, |obj| { + patch_json_records(path, path.parent().unwrap_or(path), |obj| { let Some(runtime) = obj.get("runtime").and_then(|v| v.as_str()) else { return false; }; @@ -1237,7 +1235,7 @@ pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) { fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) { use crate::managed_agents::is_derived_provider_model_key; - patch_json_records(path, |obj| { + patch_json_records(path, path.parent().unwrap_or(path), |obj| { let mut changed = false; // Only rewrite the structured provider field when the baked build env @@ -1344,7 +1342,7 @@ pub fn reconcile_databricks_v1_to_v2(app: &tauri::AppHandle) { } fn rename_provider_to_runtime_in_personas(path: &Path) { - patch_json_records(path, |obj| { + patch_json_records(path, path.parent().unwrap_or(path), |obj| { if obj.contains_key("runtime") { return false; } diff --git a/desktop/src-tauri/src/migration/backfill.rs b/desktop/src-tauri/src/migration/backfill.rs index 74cef7ffe6..0e726c5531 100644 --- a/desktop/src-tauri/src/migration/backfill.rs +++ b/desktop/src-tauri/src/migration/backfill.rs @@ -35,7 +35,14 @@ pub fn backfill_standalone_agents(app: &tauri::AppHandle) { let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { return; }; - match backfill_standalone_agents_in_dir(&base_dir) { + let anchor = match crate::managed_agents::store_journal::store_anchor_dir(app) { + Ok(a) => a, + Err(e) => { + eprintln!("buzz-desktop: standalone-backfill: failed to resolve anchor: {e}"); + return; + } + }; + match backfill_standalone_agents_in_dir(&base_dir, &anchor) { Ok(0) => {} Ok(backfilled) => { eprintln!( @@ -48,15 +55,22 @@ pub fn backfill_standalone_agents(app: &tauri::AppHandle) { /// Core backfill logic, decoupled from the Tauri `AppHandle` for testing. /// Returns the number of records backfilled (0 = nothing to do). -fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result { +fn backfill_standalone_agents_in_dir(base_dir: &Path, anchor: &Path) -> Result { let agents_path = base_dir.join("managed-agents.json"); if !agents_path.exists() { return Ok(0); } + + // Acquire the B1 advisory lock so this migration write is serialized + // against any concurrent process that may be reading or writing the store. + let _advisory = crate::managed_agents::store_journal::JournalLockGuard::acquire(anchor)?; + let content = std::fs::read_to_string(&agents_path) .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; - let mut all: Vec = serde_json::from_str(&content) - .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + // Fail-closed codec: unknown/malformed content ⇒ error, zero mutation. + let mut all: Vec = + crate::managed_agents::store_journal::decode_agent_store(content.as_bytes()) + .map_err(|e| e.message)?; let needs_backfill = |record: &ManagedAgentRecord| !record.pubkey.is_empty() && record.persona_id.is_none(); @@ -131,7 +145,10 @@ fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result { all.extend(manufactured); let payload = serde_json::to_vec_pretty(&all) .map_err(|e| format!("failed to serialize unified store: {e}"))?; - crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?; + crate::managed_agents::store_journal::atomic_write_restricted_with_fsync( + &agents_path, + &payload, + )?; Ok(backfilled) } diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index d277a2aa5f..8c0d0e49a1 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -52,7 +52,8 @@ fn backfill_links_standalone_agent_to_manufactured_definition() { )]), ); - let backfilled = backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + let backfilled = + backfill_standalone_agents_in_dir(&base(dir.path()), &base(dir.path())).unwrap(); assert_eq!(backfilled, 1); let records = load_typed(dir.path()); @@ -101,7 +102,7 @@ fn backfilled_definition_carries_prompt_present_even_if_empty() { &serde_json::json!([standalone_agent_json("NoPrompt", &pubkey, None)]), ); - backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + backfill_standalone_agents_in_dir(&base(dir.path()), &base(dir.path())).unwrap(); let records = load_typed(dir.path()); let definition = records.iter().find(|r| r.pubkey.is_empty()).unwrap(); @@ -139,7 +140,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { &Default::default(), ); - backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + backfill_standalone_agents_in_dir(&base(dir.path()), &base(dir.path())).unwrap(); let post_records = load_typed(dir.path()); let post_instance = post_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); @@ -189,7 +190,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { &Default::default(), ); - backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); + backfill_standalone_agents_in_dir(&base(dir.path()), &base(dir.path())).unwrap(); let post_records = load_typed(dir.path()); let post_instance = post_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); @@ -222,14 +223,14 @@ fn second_run_is_a_no_op_and_preserves_pristine_backup() { let pristine = std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); assert_eq!( - backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + backfill_standalone_agents_in_dir(&base(dir.path()), &base(dir.path())).unwrap(), 1 ); let after_first = std::fs::read_to_string(base(dir.path()).join("managed-agents.json")).unwrap(); assert_eq!( - backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + backfill_standalone_agents_in_dir(&base(dir.path()), &base(dir.path())).unwrap(), 0, "second run is a no-op" ); @@ -257,7 +258,7 @@ fn definitions_and_linked_records_are_untouched() { write_agents_json(dir.path(), &serde_json::json!([linked])); assert_eq!( - backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + backfill_standalone_agents_in_dir(&base(dir.path()), &base(dir.path())).unwrap(), 0 ); assert!( @@ -290,7 +291,7 @@ fn slug_collision_fails_loudly_per_record_and_continues() { ); assert_eq!( - backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + backfill_standalone_agents_in_dir(&base(dir.path()), &base(dir.path())).unwrap(), 1, "collision skipped, clean record backfilled" ); @@ -317,7 +318,7 @@ fn backfill_creates_the_backup_owner_only() { write_agents_json(dir.path(), &serde_json::json!([record])); assert_eq!( - backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(), + backfill_standalone_agents_in_dir(&base(dir.path()), &base(dir.path())).unwrap(), 1 ); diff --git a/desktop/src-tauri/src/migration/detach.rs b/desktop/src-tauri/src/migration/detach.rs index 9f746e479f..c25e94c206 100644 --- a/desktop/src-tauri/src/migration/detach.rs +++ b/desktop/src-tauri/src/migration/detach.rs @@ -28,7 +28,14 @@ pub fn detach_directory_backed_teams(app: &tauri::AppHandle) { let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { return; }; - match detach_directory_backed_teams_in_dir(&base_dir) { + let anchor = match crate::managed_agents::store_journal::store_anchor_dir(app) { + Ok(a) => a, + Err(e) => { + eprintln!("buzz-desktop: detach-dir-teams: failed to resolve anchor: {e}"); + return; + } + }; + match detach_directory_backed_teams_in_dir(&base_dir, &anchor) { Ok(0) => {} Ok(n) => eprintln!("buzz-desktop: detach-dir-teams: detached {n} directory-backed team(s)"), Err(e) => eprintln!("buzz-desktop: detach-dir-teams: {e}"), @@ -39,7 +46,10 @@ pub fn detach_directory_backed_teams(app: &tauri::AppHandle) { /// /// `base_dir` is the managed-agents base directory (`/agents/`). /// Returns the number of teams detached (0 = nothing to do). -pub(super) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result { +pub(super) fn detach_directory_backed_teams_in_dir( + base_dir: &Path, + anchor: &Path, +) -> Result { let teams_path = base_dir.join("teams.json"); let agents_path = base_dir.join("managed-agents.json"); @@ -47,6 +57,10 @@ pub(super) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result = serde_json::from_str(&teams_content) @@ -80,8 +94,10 @@ pub(super) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result = serde_json::from_str(&agents_content) - .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + // Fail-closed codec: unknown/malformed content ⇒ error, zero mutation. + let mut agents: Vec = + crate::managed_agents::store_journal::decode_agent_store(agents_content.as_bytes()) + .map_err(|e| e.message)?; let mut agents_changed = false; for agent in agents.iter_mut() { @@ -109,7 +125,10 @@ pub(super) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result Result a, + Err(e) => { + eprintln!("buzz-desktop: persona-store-fold: failed to resolve anchor: {e}"); + return; + } + }; + match fold_personas_in_dir(&base_dir, &anchor) { Ok(None) => {} Ok(Some(folded)) => { eprintln!( @@ -38,20 +45,25 @@ pub fn fold_personas_into_agent_store(app: &tauri::AppHandle) { /// Operates on the raw JSON files — no keyring interaction: instance records /// are passed through byte-identical, and folded definitions carry no keys. /// Returns `Ok(None)` when there is no `personas.json` to fold. -fn fold_personas_in_dir(base_dir: &Path) -> Result, String> { +fn fold_personas_in_dir(base_dir: &Path, anchor: &Path) -> Result, String> { let personas_path = base_dir.join("personas.json"); if !personas_path.exists() { return Ok(None); } + // Acquire the B1 advisory lock so this migration write is serialized + // against any concurrent process that may be reading or writing the store. + let _advisory = crate::managed_agents::store_journal::JournalLockGuard::acquire(anchor)?; + let personas = crate::managed_agents::load_personas_from_path(&personas_path)?; let agents_path = base_dir.join("managed-agents.json"); let mut all: Vec = if agents_path.exists() { let content = std::fs::read_to_string(&agents_path) .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; - serde_json::from_str(&content) - .map_err(|e| format!("failed to parse managed-agents.json: {e}"))? + // Fail-closed codec: unknown/malformed content ⇒ error, zero mutation. + crate::managed_agents::store_journal::decode_agent_store(content.as_bytes()) + .map_err(|e| e.message)? } else { Vec::new() }; @@ -74,7 +86,10 @@ fn fold_personas_in_dir(base_dir: &Path) -> Result, String> { let payload = serde_json::to_vec_pretty(&all) .map_err(|e| format!("failed to serialize unified store: {e}"))?; - crate::managed_agents::atomic_write_json_restricted(&agents_path, &payload)?; + crate::managed_agents::store_journal::atomic_write_restricted_with_fsync( + &agents_path, + &payload, + )?; // Rename only after the unified store write succeeded — a crash between // the two leaves personas.json in place and the fold re-runs idempotently @@ -201,7 +216,7 @@ mod tests { ); let base = dir.path().join("agents"); - let folded = fold_personas_in_dir(&base).unwrap(); + let folded = fold_personas_in_dir(&base, &base).unwrap(); assert_eq!(folded, Some(1), "custom folds, builtin skipped"); let records = read_agents_json(dir.path()); @@ -231,12 +246,12 @@ mod tests { &serde_json::json!([custom_persona_json("custom:one", "goose")]), ); let base = dir.path().join("agents"); - assert_eq!(fold_personas_in_dir(&base).unwrap(), Some(1)); + assert_eq!(fold_personas_in_dir(&base, &base).unwrap(), Some(1)); // Crash simulation: restore personas.json from the .bak. std::fs::copy(base.join("personas.json.bak"), base.join("personas.json")).unwrap(); assert_eq!( - fold_personas_in_dir(&base).unwrap(), + fold_personas_in_dir(&base, &base).unwrap(), Some(0), "second run folds nothing (slug dedup)" ); @@ -253,7 +268,7 @@ mod tests { ); let base = dir.path().join("agents"); let before = std::fs::read_to_string(base.join("managed-agents.json")).unwrap(); - assert_eq!(fold_personas_in_dir(&base).unwrap(), None); + assert_eq!(fold_personas_in_dir(&base, &base).unwrap(), None); let after = std::fs::read_to_string(base.join("managed-agents.json")).unwrap(); assert_eq!(before, after, "store untouched when nothing to fold"); } @@ -275,7 +290,7 @@ mod tests { let pre = load_persona_runtimes(&agents_path); assert_eq!(pre.get("custom:one").map(String::as_str), Some("goose")); - fold_personas_in_dir(&base).unwrap(); + fold_personas_in_dir(&base, &base).unwrap(); // Post-fold: personas.json is gone; map must come from the unified store // and be identical. @@ -295,7 +310,7 @@ mod tests { &serde_json::json!([custom_persona_json("custom:one", "goose")]), ); let base = dir.path().join("agents"); - assert_eq!(fold_personas_in_dir(&base).unwrap(), Some(1)); + assert_eq!(fold_personas_in_dir(&base, &base).unwrap(), Some(1)); // Post-fold edit in the unified store. let mut records = read_agents_json(dir.path()); @@ -304,7 +319,7 @@ mod tests { // Crash simulation: stale personas.json (runtime still "goose") returns. std::fs::copy(base.join("personas.json.bak"), base.join("personas.json")).unwrap(); - assert_eq!(fold_personas_in_dir(&base).unwrap(), Some(0)); + assert_eq!(fold_personas_in_dir(&base, &base).unwrap(), Some(0)); let records = read_agents_json(dir.path()); assert_eq!(records.len(), 1); diff --git a/desktop/src-tauri/src/migration/materialize.rs b/desktop/src-tauri/src/migration/materialize.rs index 6ca23200e6..847495a265 100644 --- a/desktop/src-tauri/src/migration/materialize.rs +++ b/desktop/src-tauri/src/migration/materialize.rs @@ -44,7 +44,7 @@ fn materialize_runtimes_in_file(path: &Path) { if persona_runtimes.is_empty() { return; } - patch_json_records(path, |obj| { + patch_json_records(path, path.parent().unwrap_or(path), |obj| { if obj.contains_key("runtime") { return false; } diff --git a/desktop/src-tauri/src/migration/team_suffix.rs b/desktop/src-tauri/src/migration/team_suffix.rs index d65113fb83..72a6d3acba 100644 --- a/desktop/src-tauri/src/migration/team_suffix.rs +++ b/desktop/src-tauri/src/migration/team_suffix.rs @@ -49,7 +49,14 @@ pub fn strip_baked_team_instructions(app: &tauri::AppHandle) { let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { return; }; - match strip_baked_team_instructions_in_dir(&base_dir) { + let anchor = match crate::managed_agents::store_journal::store_anchor_dir(app) { + Ok(a) => a, + Err(e) => { + eprintln!("buzz-desktop: team-suffix-strip: failed to resolve anchor: {e}"); + return; + } + }; + match strip_baked_team_instructions_in_dir(&base_dir, &anchor) { Ok(0) => {} Ok(stripped) => eprintln!( "buzz-desktop: team-suffix-strip: removed the baked team-instructions suffix from \ @@ -64,15 +71,25 @@ pub fn strip_baked_team_instructions(app: &tauri::AppHandle) { /// `base_dir` is the managed-agents base directory (`/agents/`). /// Returns the number of records changed; `Ok(0)` means nothing to do and /// nothing was written, so a second boot is a clean no-op. -pub(super) fn strip_baked_team_instructions_in_dir(base_dir: &Path) -> Result { +pub(super) fn strip_baked_team_instructions_in_dir( + base_dir: &Path, + anchor: &Path, +) -> Result { let agents_path = base_dir.join("managed-agents.json"); if !agents_path.exists() { return Ok(0); } + + // Acquire the B1 advisory lock so this migration write is serialized + // against any concurrent process that may be reading or writing the store. + let _advisory = crate::managed_agents::store_journal::JournalLockGuard::acquire(anchor)?; + let content = std::fs::read_to_string(&agents_path) .map_err(|e| format!("failed to read managed-agents.json: {e}"))?; - let mut all: Vec = serde_json::from_str(&content) - .map_err(|e| format!("failed to parse managed-agents.json: {e}"))?; + // Fail-closed codec: unknown/malformed content ⇒ error, zero mutation. + let mut all: Vec = + crate::managed_agents::store_journal::decode_agent_store(content.as_bytes()) + .map_err(|e| format!("failed to parse managed-agents.json: {}", e.message))?; // Definition hashes BEFORE the strip: stripping a definition's // `system_prompt` changes its `persona_content_hash`, which is the drift @@ -125,7 +142,10 @@ pub(super) fn strip_baked_team_instructions_in_dir(base_dir: &Path) -> Result Result<(), Stri .managed_agent_runtime_transition .lock() .map_err(|error| error.to_string())?; - let _store_guard = state + let store_guard = state .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(app)?; let mut runtimes = state .managed_agent_processes .lock() .map_err(|error| error.to_string())?; - let (mut changed, _exited) = sync_managed_agent_processes( - &mut records, - &mut runtimes, - &managed_agents::current_instance_id(app), - ); - changed |= kill_stale_tracked_processes( - &mut records, - &runtimes, - &managed_agents::current_instance_id(app), - ); + let instance_id = managed_agents::current_instance_id(app); - // Stop all tracked agents. Send SIGTERM to all process - // groups first, then wait for exits in parallel to avoid serial 1s waits. - struct AgentToStop { - idx: usize, - pid: u32, - runtime: Option, - } - - let mut to_stop: Vec = Vec::new(); - for (idx, record) in records.iter().enumerate() { - if record.backend != BackendKind::Local { - continue; - } - // Drain every tracked pair for this record, not just the first — an - // agent can run one harness per community, and each pair gets the - // graceful SIGTERM → 2s wait → SIGKILL fan-out with a stop log - // marker, instead of falling through to the orphan sweep's 200ms - // grace below. - for key in managed_agents::managed_agent_runtime_keys(&runtimes, &record.pubkey) { - let runtime = runtimes.remove(&key); - let Some(pid) = runtime - .as_ref() - .map(|rt| rt.child.id()) - .or(record.runtime_pid) - else { - continue; - }; - to_stop.push(AgentToStop { idx, pid, runtime }); - } - } + mutate_agent_store(app, store_guard, move |mut records, _journal| { + let (mut changed, _exited) = + sync_managed_agent_processes(&mut records, &mut runtimes, &instance_id); + changed |= kill_stale_tracked_processes(&mut records, &runtimes, &instance_id); - if !to_stop.is_empty() { - changed = true; - - // Fan-out: send SIGTERM to all process groups at once. - #[cfg(unix)] - for agent in &to_stop { - let pgid = -(agent.pid as i32); - unsafe { - libc::kill(pgid, libc::SIGTERM); - } + // Stop all tracked agents. Send SIGTERM to all process + // groups first, then wait for exits in parallel to avoid serial 1s waits. + struct AgentToStop { + idx: usize, + pid: u32, + runtime: Option, } - // Wait up to 2s for all to exit, checking in a polling loop. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - loop { - if to_stop - .iter() - .all(|a| !managed_agents::process_is_running(a.pid)) - { - break; + let mut to_stop: Vec = Vec::new(); + for (idx, record) in records.iter().enumerate() { + if record.backend != BackendKind::Local { + continue; } - if std::time::Instant::now() >= deadline { - break; + // Drain every tracked pair for this record, not just the first. + for key in managed_agents::managed_agent_runtime_keys(&runtimes, &record.pubkey) { + let runtime = runtimes.remove(&key); + let Some(pid) = runtime + .as_ref() + .map(|rt| rt.child.id()) + .or(record.runtime_pid) + else { + continue; + }; + to_stop.push(AgentToStop { idx, pid, runtime }); } - std::thread::sleep(std::time::Duration::from_millis(50)); } - // Fan-out: SIGKILL any survivors. - #[cfg(unix)] - for agent in &to_stop { - if managed_agents::process_is_running(agent.pid) { + if !to_stop.is_empty() { + changed = true; + + #[cfg(unix)] + for agent in &to_stop { let pgid = -(agent.pid as i32); unsafe { - libc::kill(pgid, libc::SIGKILL); + libc::kill(pgid, libc::SIGTERM); } } - } - // Reap children and update records. - for mut agent in to_stop { - if let Some(ref mut rt) = agent.runtime { - // Best-effort reap — don’t block shutdown if the child is stuck - // in uninterruptible sleep. The zombie will be cleaned up when - // our process exits and launchd reaps it. - let _ = rt.child.try_wait(); - // Write log marker (best-effort). - let record = &records[agent.idx]; - let _ = managed_agents::append_log_marker( - &rt.log_path, - &format!( - "=== stopped {} ({}) at {} ===", - record.name, - record.pubkey, - util::now_iso() - ), - ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if to_stop + .iter() + .all(|a| !managed_agents::process_is_running(a.pid)) + { + break; + } + if std::time::Instant::now() >= deadline { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); } - let record = &mut records[agent.idx]; - record.runtime_pid = None; - record.last_stopped_at = Some(util::now_iso()); - record.updated_at = util::now_iso(); - record.last_exit_code = None; - record.last_error = None; - } - } - - // Final sweep: kill any orphaned agent processes we have PID file receipts - // for that escaped process-group kills or weren't tracked in records. - // All tracked PIDs have already been killed above, so pass an empty skip list. - managed_agents::sweep_orphaned_agent_processes(app, &[]); - // System-wide sweep: agent workers (goose, buzz-agent, etc.) are spawned - // in their own process groups by buzz-acp, so group-kills above only - // reach the harness, not the workers. Scan all user processes and kill any - // known agent binaries that are still running. - managed_agents::sweep_system_agent_processes(&managed_agents::current_instance_id(app), &[]); + #[cfg(unix)] + for agent in &to_stop { + if managed_agents::process_is_running(agent.pid) { + let pgid = -(agent.pid as i32); + unsafe { + libc::kill(pgid, libc::SIGKILL); + } + } + } - // Dead-instance reaping: find agents belonging to Buzz instances - // whose desktop process is no longer running and reap them. - managed_agents::reap_dead_instance_agents(&managed_agents::current_instance_id(app), &[]); + for mut agent in to_stop { + if let Some(ref mut rt) = agent.runtime { + let _ = rt.child.try_wait(); + let record = &records[agent.idx]; + let _ = managed_agents::append_log_marker( + &rt.log_path, + &format!( + "=== stopped {} ({}) at {} ===", + record.name, + record.pubkey, + util::now_iso() + ), + ); + } + let record = &mut records[agent.idx]; + record.runtime_pid = None; + record.last_stopped_at = Some(util::now_iso()); + record.updated_at = util::now_iso(); + record.last_exit_code = None; + record.last_error = None; + } + } - if changed { - save_managed_agents(app, &records)?; - } + // Orphan sweeps run outside the record mutation but inside the advisory + // lock so racing processes can't re-create receipts we're about to clear. + managed_agents::sweep_orphaned_agent_processes(app, &[]); + managed_agents::sweep_system_agent_processes( + &managed_agents::current_instance_id(app), + &[], + ); + managed_agents::reap_dead_instance_agents(&managed_agents::current_instance_id(app), &[]); - Ok(()) + // Always return records (even if unchanged) so mutate_store writes back. + let _ = changed; + Ok((records, ())) + }) + .map(|_| ()) } #[cfg(test)]