diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 99c1ea4b9..c6d89f170 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -339,18 +339,24 @@ pub async fn save_ncryptsec_copy( /// `managed_agent_runtime_transition` before calling this function. /// /// Returns: -/// - `Ok(())` when all runtimes were stopped (or there were none). -/// - `Err(msg)` when the drain failed; the caller must NOT proceed with -/// the identity persist and MUST compensate by restarting the stopped set. +/// - `Ok(stopped)` when all runtimes were stopped (or there were none). +/// - `Err((stopped, msg))` when the drain failed; `stopped` contains the +/// entries that were successfully killed before the failure — the caller +/// MUST compensate these and then drop `managed_agent_runtime_transition` +/// BEFORE calling `compensate_drain` (which re-acquires that lock via +/// `start_pair`). fn drain_managed_agent_runtimes_for_import( app: &tauri::AppHandle, state: &AppState, -) -> Result, String> { +) -> Result< + Vec, + (Vec, String), +> { let (stopped, _remaining, drain_error) = crate::managed_agents::drain_scope_runtimes(app, state); match drain_error { None => Ok(stopped), - Some(e) => Err(e), + Some(e) => Err((stopped, e)), } } @@ -383,6 +389,35 @@ pub async fn import_identity( None }; + // ── Layer 1 async: drain the Mesh client when switching away from an active + // scope — mirrors the pre-spawn_blocking Mesh drain in apply_workspace so + // the Mesh client is not left running against a scope that no longer exists + // after the identity import clears the active scope. Best-effort: a drain + // failure is logged and the import proceeds. + if has_active_scope { + #[cfg(feature = "mesh-llm")] + { + // Drain using the current active relay — the import clears the scope + // entirely, so any live Mesh client becomes stale regardless of relay. + let active_relay = lock_state + .capture_active_scope() + .map(|s| s.relay_url.clone()) + .unwrap_or_default(); + if let Err(error) = crate::commands::mesh_llm::scope_impl::drain_mesh_client_if_stale( + &app_handle, + // Pass an empty string so any client (any relay) is treated + // as stale and drained; identity import invalidates all scopes. + "", + ) + .await + { + eprintln!( + "buzz-desktop: Mesh client drain before identity import failed: {error} (active_relay={active_relay})" + ); + } + } + } + let result = tokio::task::spawn_blocking(move || { // NIP-49 backups require a passphrase and decrypt entirely in Rust. // Raw nsec/hex input follows the existing parser path unchanged. @@ -407,13 +442,17 @@ pub async fn import_identity( // new identity. This is the same drain-journal protocol used by // `apply_workspace`. // + // `managed_agents_store_lock` is acquired at Layer 2 start (same as + // apply_workspace) so a concurrent save_managed_agents cannot interleave + // with the drain or the scope clear. + // // On drain failure: compensate by restarting what was stopped, then // return Err — do NOT proceed with the identity persist. The caller // (frontend membership-denied flow) must handle the Err and retry. // - // The transition lock is held through the identity persist and scope - // clear so no concurrent start/reconcile can insert a runtime between - // drain and scope invalidation. + // The transition lock (and store lock) must be DROPPED before calling + // compensate_drain — compensate_drain calls start_pair which re-acquires + // both managed_agent_runtime_transition and managed_agents_store_lock. let _rt_transition_guard = if has_active_scope { Some( state @@ -425,13 +464,30 @@ pub async fn import_identity( None }; + let _store_guard = if has_active_scope { + Some( + state + .managed_agents_store_lock + .lock() + .map_err(|e| format!("managed_agents_store_lock poisoned: {e}"))?, + ) + } else { + None + }; + let stopped_entries = if has_active_scope { match drain_managed_agent_runtimes_for_import(&app_handle, &state) { Ok(stopped) => stopped, - Err(drain_err) => { - // Drain failed — compensate and abort. + Err((stopped, drain_err)) => { + // Drain failed — drop the transition lock AND store lock BEFORE + // compensating: compensate_drain calls start_pair which + // re-acquires both managed_agent_runtime_transition and + // managed_agents_store_lock. Holding either here deadlocks. + // Also restore the partial stopped set, not an empty slice. + drop(_store_guard); + drop(_rt_transition_guard); let comp_err = - crate::managed_agents::compensate_drain(&app_handle, &[]); + crate::managed_agents::compensate_drain(&app_handle, &stopped); let msg = match comp_err { Some(comp) => format!( "identity import drain failed: {drain_err}; compensation failed: {comp}" @@ -455,10 +511,14 @@ pub async fn import_identity( }); // If identity persist failed after a successful drain, compensate. + // Drop the transition lock AND store lock BEFORE calling compensate_drain + // for the same reason: start_pair re-acquires both. let (pubkey, storage) = match commit_result { Ok(result) => result, Err(e) => { if !stopped_entries.is_empty() { + drop(_store_guard); + drop(_rt_transition_guard); if let Some(comp_err) = crate::managed_agents::compensate_drain(&app_handle, &stopped_entries) { diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 44d19f14c..f744c4510 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -28,6 +28,7 @@ fn trim_optional(value: Option) -> Option { mod pending; pub(in crate::commands) use pending::retain_persona_pending; +pub(in crate::commands) use pending::retain_persona_pending_in_scope; pub(super) use pending::tombstone_persona_pending; mod create; pub use create::create_persona; diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index cab5fabab..f771fbab4 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -46,6 +46,21 @@ pub(in crate::commands) fn retain_persona_pending( } } +/// Retain a persona event using a pre-resolved [`RetentionScope`]. +/// +/// For snapshot-import callers that already hold a captured scope inside the +/// `managed_agents_store_lock` — avoids re-reading live state at a point where +/// the lock already prevents any workspace switch from succeeding. +pub(in crate::commands) fn retain_persona_pending_in_scope( + scope: &crate::managed_agents::retention::RetentionScope, + persona: &AgentDefinition, +) { + if let Err(e) = prepare_persona_publication_at(&scope.db_path, &scope.owner_keys, persona, None) + { + eprintln!("buzz-desktop: persona-retain: {e}"); + } +} + /// Build, sign, and durably retain a persona event in the active relay+owner /// scope. /// diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index fb453d6d1..4f1312573 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, @@ -124,33 +123,12 @@ pub struct AgentSnapshotImportResult { /// Resolve the behavioral defaults for an incoming agent snapshot. /// -/// This is the single authoritative selection path for all import-time -/// allowlist and behavioral decisions. It is extracted as a pure, testable -/// function so that unit tests exercise the exact production logic rather -/// than a reconstruction of it. +/// Single authoritative selection path for all import-time allowlist and +/// behavioral decisions. Extracted as a pure function for testability. /// -/// # UI contract -/// -/// The Keep/Clear toggle is shown whenever `has_source_allowlist` is true -/// (i.e. the raw allowlist is non-empty), regardless of the source mode. -/// The mode (`respond_to` wire string) and the list are independent axes. -/// -/// # Decision table -/// -/// | Source mode | Non-empty list | keep=true | keep=false | -/// |--------------|----------------|----------------------|-------------------------| -/// | allowlist | yes | preserve mode + list | owner-only + empty | -/// | allowlist | no | **Err** (reject) | **Err** (reject) | -/// | non-allowlist| yes | preserve mode + list | preserve mode + empty | -/// | non-allowlist| no | preserve mode | preserve mode | -/// -/// Allowlist-mode + empty list is always rejected: the UI showed no choice -/// and there is no coherent value to write. -/// -/// Non-allowlist + non-empty + Clear: preserve the source mode but empty the -/// list. Only allowlist-mode requires a mode downgrade on Clear, because -/// `allowlist` without entries is an invalid state. Non-allowlist modes -/// remain valid with an empty list. +/// Decision: `allowlist` mode + empty list is rejected (invalid state). +/// On `keep_allowlist=false` with `allowlist` mode, downgrades to owner-only. +/// On `keep_allowlist=false` with other modes, preserves mode, clears list. pub(crate) fn resolve_snapshot_import_behavior( raw_respond_to: Option<&str>, raw_allowlist: &[String], @@ -458,6 +436,13 @@ pub async fn confirm_agent_snapshot_import( app: AppHandle, state: State<'_, AppState>, ) -> Result { + // Capture the active scope at entry — all definition I/O targets this + // scope; the generation is re-validated before the first write in Phase 3a. + let captured_scope = state + .capture_active_scope() + .ok_or("confirm_agent_snapshot_import: no active workspace scope")?; + let definitions_dir = captured_scope.definitions_dir.clone(); + // ── Phase 1: validate (no writes) ──────────────────────────────────────── // Locked cards unlock only via this machine's exact key endpoints; // anything else fails closed here, before key generation. @@ -468,7 +453,7 @@ pub async fn confirm_agent_snapshot_import( .managed_agents_store_lock .lock() .map_err(|e| e.to_string())?; - load_managed_agents(&app)? + crate::managed_agents::storage::load_managed_agents_at(&definitions_dir)? }; decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0 }; @@ -547,8 +532,21 @@ pub async fn confirm_agent_snapshot_import( .lock() .map_err(|e| e.to_string())?; - let mut personas = load_personas(&app)?; - let mut records = load_managed_agents(&app)?; + // Re-validate the captured scope's generation before any write; abort + // if the workspace switched since Phase 1. + crate::managed_agents::scope::validate_scope_generation(&captured_scope) + .map_err(|e| format!("confirm_agent_snapshot_import: {e}"))?; + + // Resolve retention scope from the captured scope so retain calls + // write to the correct scope's DB even if live state diverged. + let owner_keys_for_retention = state.signing_keys()?; + let retention_scope = crate::managed_agents::retention::retention_scope_from_captured( + &captured_scope, + owner_keys_for_retention, + )?; + + let mut personas = crate::managed_agents::load_personas_at(&definitions_dir)?; + let mut records = crate::managed_agents::storage::load_managed_agents_at(&definitions_dir)?; // Guard against duplicate pubkey (astronomically unlikely but safe). if records.iter().any(|r| r.pubkey == pubkey) { @@ -587,10 +585,10 @@ pub async fn confirm_agent_snapshot_import( }; personas.push(persona.clone()); - save_personas(&app, &personas)?; + crate::managed_agents::save_personas_at(&definitions_dir, &personas)?; // Enqueue the kind:30175 persona event via the retention path. - super::super::pending::retain_persona_pending(&app, &state, &persona); + super::super::pending::retain_persona_pending_in_scope(&retention_scope, &persona); // Build the managed agent record — no machine-local commands, no // secrets, no lineage from the snapshot. @@ -657,12 +655,12 @@ pub async fn confirm_agent_snapshot_import( }; records.push(record.clone()); - save_managed_agents(&app, &records)?; + crate::managed_agents::storage::save_managed_agents_at(&definitions_dir, &records)?; // Enqueue the kind:30177 managed-agent event via retention. // (Uses the same pattern as agents.rs::retain_managed_agent_pending // inlined here to avoid cross-module private-fn access.) - retain_agent_pending(&app, &state, &record); + retain_agent_pending(&retention_scope, &record); crate::managed_agents::try_regenerate_nest(&app).ok(); @@ -752,7 +750,10 @@ pub async fn confirm_agent_snapshot_import( /// Inline retention for the managed-agent kind:30177 event — mirrors /// `agents::retain_managed_agent_pending` without requiring cross-module /// private function access. -fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { +fn retain_agent_pending( + scope: &crate::managed_agents::retention::RetentionScope, + record: &ManagedAgentRecord, +) { use crate::managed_agents::{ agent_events::{agent_event_content, build_agent_event}, persona_events::monotonic_created_at, @@ -762,7 +763,6 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent 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}"))?; diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 4f4f7ae51..340000541 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -17,8 +17,10 @@ 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_managed_agents_at, load_personas, load_personas_at, load_teams, + load_teams_readonly, managed_agents_store_path_at, save_managed_agents_at, + save_personas_at, save_teams_at, teams_store_path_at, AgentDefinition, ManagedAgentRecord, + TeamRecord, }, relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -502,6 +504,16 @@ pub async fn confirm_team_snapshot_import( app: AppHandle, state: State<'_, AppState>, ) -> Result { + // ── Scope capture: snapshot the active workspace at entry ───────────────── + // All store reads and writes in Phase 3 use `definitions_dir` derived from + // this captured scope so a concurrent workspace switch cannot redirect them + // to the wrong scope. The generation is re-validated inside the store lock + // before the first write (Phase 3). + let captured_scope = state + .capture_active_scope() + .ok_or("confirm_team_snapshot_import: no active workspace scope — cannot import")?; + let definitions_dir = captured_scope.definitions_dir.clone(); + // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?; let now = now_iso(); @@ -631,8 +643,22 @@ pub async fn confirm_team_snapshot_import( .lock() .map_err(|e| e.to_string())?; + // Re-validate the captured scope's generation before any write. + // If the workspace switched since Phase 1, abort — writing to the + // new scope would import into the wrong workspace. + crate::managed_agents::scope::validate_scope_generation(&captured_scope) + .map_err(|e| format!("confirm_team_snapshot_import: {e}"))?; + + // Resolve retention scope once from the captured workspace scope so + // all retain calls below write to the correct scope's database. + let owner_keys_for_retention = state.signing_keys()?; + let retention_scope = crate::managed_agents::retention::retention_scope_from_captured( + &captured_scope, + owner_keys_for_retention, + )?; + // Guard against duplicate pubkeys (astronomically unlikely). - let existing_records = load_managed_agents(&app)?; + let existing_records = load_managed_agents_at(&definitions_dir)?; for m in &minted { if existing_records.iter().any(|r| r.pubkey == m.pubkey) { return Err(format!( @@ -646,13 +672,13 @@ pub async fn confirm_team_snapshot_import( // 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_path = managed_agents_store_path_at(&definitions_dir); 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_path = teams_store_path_at(&definitions_dir); 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, @@ -700,11 +726,11 @@ pub async fn confirm_team_snapshot_import( }; // Write all definitions. - let mut personas = load_personas(&app)?; + let mut personas = load_personas_at(&definitions_dir)?; for m in &minted { personas.push(m.definition.clone()); } - if let Err(e) = save_personas(&app, &personas) { + if let Err(e) = save_personas_at(&definitions_dir, &personas) { return Err(rollback_agents(e)); } @@ -713,15 +739,15 @@ pub async fn confirm_team_snapshot_import( for m in &minted { records.push(m.record.clone()); } - if let Err(e) = save_managed_agents(&app, &records) { + if let Err(e) = save_managed_agents_at(&definitions_dir, &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. + // aborted before any phase-3 write. save_teams_at sorts and persists. teams.push(imported_team.clone()); - if let Err(e) = save_teams(&app, &teams) { + if let Err(e) = save_teams_at(&definitions_dir, &teams) { let err = rollback_agents(e); // Also restore teams store. let teams_restore = match &teams_store_snapshot { @@ -741,10 +767,13 @@ pub async fn confirm_team_snapshot_import( // All writes committed — safe to update in-memory state. for m in &minted { - crate::commands::personas::retain_persona_pending(&app, &state, &m.definition); + crate::commands::personas::retain_persona_pending_in_scope( + &retention_scope, + &m.definition, + ); } for m in &minted { - retain_agent_pending(&app, &state, &m.record); + retain_agent_pending(&retention_scope, &m.record); } crate::commands::teams::retain_team_pending(&app, &state, &imported_team); @@ -847,7 +876,10 @@ pub async fn confirm_team_snapshot_import( /// Inline retention for the managed-agent kind:30177 event — mirrors /// `commands::personas::snapshot::import::retain_agent_pending`. -fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { +fn retain_agent_pending( + scope: &crate::managed_agents::retention::RetentionScope, + record: &ManagedAgentRecord, +) { use crate::managed_agents::{ agent_events::{agent_event_content, build_agent_event}, persona_events::monotonic_created_at, @@ -857,7 +889,6 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent 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}"))?; diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index b1f849d4a..9fd3c8848 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -186,34 +186,23 @@ pub async fn apply_workspace( &target_scope_id, &scope_dir, &base_dir, + &effective_owner_pubkey, )?; - // ── Persona snapshot backfill (still in prepare stage) ──────────── - // Run before commit so auto-start agents in the newly-ready scope - // have valid persona snapshots available at the first restore pass. - // Best-effort: a failure is logged but does not block the transition. + // ── Legacy retention migration and persona snapshot backfill ───────── + // Both now run inside `ensure_scope_ready` above (as `run_pre_ready_family`), + // before the `_ready` marker is written. They remain here as + // best-effort guards for any pre-existing Ready scope that was + // initialized before these steps were added to the pipeline. if let Err(e) = crate::managed_agents::backfill_persona_snapshots_at( &scope_dir, &state, ) { - eprintln!("buzz-desktop: persona-snapshot backfill failed during prepare: {e}"); + eprintln!("buzz-desktop: persona-snapshot backfill guard failed: {e}"); } - // ── Legacy retention migration (still in prepare stage) ─────────── - // Migrate legacy retained events into this scope's retention DB BEFORE - // committing the scope as active. Both the retention and definition - // stores must be migrated together so event-sync sees consistent data. - // Best-effort: a failure is logged and does not block the transition. { - let effective_owner_pubkey_for_retention = match &parsed_keys { - Some(keys) => keys.public_key().to_hex(), - None => state - .keys - .lock() - .map_err(|e| e.to_string())? - .public_key() - .to_hex(), - }; + let effective_owner_pubkey_for_retention = effective_owner_pubkey.clone(); let retention_db_path = crate::managed_agents::retention::scoped_retention_db_path( &base_dir, &relay_url, @@ -232,7 +221,7 @@ pub async fn apply_workspace( "buzz-desktop: adopted {copied} legacy retained event(s) into this community" ), Err(error) => eprintln!( - "buzz-desktop: legacy retention migration failed during prepare: {error}" + "buzz-desktop: legacy retention migration guard failed: {error}" ), } } @@ -242,6 +231,11 @@ pub async fn apply_workspace( // through the end of the commit swap so no start/reconcile can insert // a new runtime into the gap between drain and scope publication. // + // `managed_agents_store_lock` is acquired immediately after + // `managed_agent_runtime_transition` and held through commit so that + // a concurrent save_managed_agents (e.g., a runtime status flush) + // cannot interleave with the drain or the scope swap. + // // All fallible guards (relay_url_override, keys, active_agent_scope) // are acquired BEFORE any field is mutated so a poison or other lock // failure cannot leave us half-committed with old processes drained. @@ -250,16 +244,24 @@ pub async fn apply_workspace( .lock() .map_err(|e| e.to_string())?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + // Build the journal and drain under the held transition lock. let (stopped_entries, _remaining, drain_error) = crate::managed_agents::drain_scope_runtimes(&app, &state); if let Some(drain_err) = drain_error { // Drain failed — compensate by restarting what we stopped. - // The rt_transition lock stays held during compensation so the - // runtime map is still protected. - let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries); + // Drop BOTH the transition lock AND the store lock BEFORE calling + // compensate_drain: start_pair re-acquires both + // managed_agent_runtime_transition and managed_agents_store_lock + // — holding either here would deadlock on the non-reentrant Mutex. + drop(_store); drop(rt_transition); + let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries); let degraded_msg = match comp_err { Some(comp) => { format!("drain failed ({drain_err}); compensation also failed: {comp}") @@ -274,8 +276,11 @@ pub async fn apply_workspace( let mut override_guard = match state.relay_url_override.lock() { Ok(g) => g, Err(e) => { - let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries); + // Drop _store and rt_transition BEFORE compensate_drain: + // start_pair re-acquires both; holding either here deadlocks. + drop(_store); drop(rt_transition); + let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries); let msg = format!( "commit failed (relay lock poisoned: {e}){}", comp_err @@ -287,9 +292,10 @@ pub async fn apply_workspace( let mut keys_guard = match state.keys.lock() { Ok(g) => g, Err(e) => { - let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries); drop(override_guard); + drop(_store); drop(rt_transition); + let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries); let msg = format!( "commit failed (keys lock poisoned: {e}){}", comp_err @@ -301,10 +307,11 @@ pub async fn apply_workspace( let mut scope_guard = match state.active_agent_scope.lock() { Ok(g) => g, Err(e) => { - let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries); drop(keys_guard); drop(override_guard); + drop(_store); drop(rt_transition); + let comp_err = crate::managed_agents::compensate_drain(&app, &stopped_entries); let msg = format!( "commit failed (scope lock poisoned: {e}){}", comp_err @@ -360,6 +367,25 @@ pub async fn apply_workspace( // If blocking returned a drain-failed result, surface it now. let apply_result = blocking_result?; if !apply_result.applied { + // The workspace switch failed (drain or commit error). The Mesh client + // may have been drained in the Layer-1 async stage before spawn_blocking + // was entered. Re-arm it so the old scope's sharing state is restored. + #[cfg(feature = "mesh-llm")] + { + let app = restore_app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Err(error) = + crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await + { + eprintln!( + "buzz-desktop: failed to re-arm Mesh after failed workspace switch: {error}" + ); + } + crate::mesh_llm::publish_current_status_once(&app, "workspace switch rollback") + .await; + }); + } return Ok(apply_result); } @@ -378,14 +404,12 @@ pub async fn apply_workspace( match crate::managed_agents::retention::active_retention_scope(&restore_app, &state) { Ok(scope) => { if let Some(agent_scope) = state.capture_active_scope() { - if let Err(error) = crate::event_sync::spawn_event_sync( + crate::event_sync::spawn_event_sync( restore_app.clone(), scope.owner_keys, scope.db_path, agent_scope.definitions_dir, - ) { - degraded.push(format!("event-sync dispatch failed: {error}")); - } + ); } else { degraded.push( "active agent scope unavailable after workspace apply — event sync skipped" @@ -402,7 +426,8 @@ pub async fn apply_workspace( // Per-transition restore: always restore the new scope's auto-start agents // (replaces the launch-only `managed_agent_restore_pending.swap` one-shot). - // Fire-and-forget spawn so the command returns promptly; failures are logged. + // Fire-and-forget spawn so the command returns promptly; restore failures + // are surfaced as a structured `workspace-degraded` event consumed by the UI. #[cfg(feature = "mesh-llm")] { let app = restore_app.clone(); @@ -418,7 +443,9 @@ pub async fn apply_workspace( if let Err(error) = restore_managed_agents_on_launch(&app, &state.shutdown_started).await { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + let msg = format!("agent restore failed: {error}"); + eprintln!("buzz-desktop: {msg}"); + let _ = app.emit("workspace-degraded", &msg); } }); } @@ -431,7 +458,9 @@ pub async fn apply_workspace( if let Err(error) = restore_managed_agents_on_launch(&app, &state.shutdown_started).await { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + let msg = format!("agent restore failed: {error}"); + eprintln!("buzz-desktop: {msg}"); + let _ = app.emit("workspace-degraded", &msg); } }); } diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index 0fcac7959..218645a92 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -39,16 +39,15 @@ pub fn run_event_sync( /// SQLite, and signing work, so it runs on the blocking pool rather than an /// async worker. /// -/// Returns `Err` when the `spawn_blocking` call itself fails to enqueue (the -/// thread pool is exhausted or the runtime is shutting down). Completion -/// failures from the reconcile task are logged internally and do not reach -/// the caller — only dispatch failure reaches here. +/// The dispatch always succeeds (fire-and-forget); completion failures are +/// logged internally. Callers that need observable failure should emit a +/// workspace degradation event via `emit_workspace_degradation`. pub fn spawn_event_sync( app: tauri::AppHandle, owner_keys: nostr::Keys, db_path: std::path::PathBuf, definitions_dir: std::path::PathBuf, -) -> Result<(), String> { +) { tauri::async_runtime::spawn(async move { if let Err(e) = tauri::async_runtime::spawn_blocking(move || { run_event_sync(&app, &owner_keys, &db_path, &definitions_dir); @@ -58,7 +57,6 @@ pub fn spawn_event_sync( eprintln!("buzz-desktop: event-sync: spawn_blocking failed: {e}"); } }); - Ok(()) } /// Reconcile `personas.json` into the persona-event retention store. diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 6e702e20e..1536b894d 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -66,6 +66,18 @@ pub fn backfill_persona_snapshots_at( backfill_persona_snapshots_in_dir(definitions_dir, state) } +/// Backfill persona snapshots without acquiring the store lock. +/// +/// For use during scope initialization (inside `ensure_scope_ready`), where the +/// scope directory is not yet published as `_ready` and no concurrent reader or +/// writer can legally access it. The lock-taking variant (`backfill_persona_snapshots_at`) +/// must be used in all other contexts. +pub(crate) fn backfill_persona_snapshots_pre_ready( + definitions_dir: &std::path::Path, +) -> Result<(), String> { + backfill_persona_snapshots_inner(definitions_dir) +} + fn backfill_persona_snapshots_in_dir( definitions_dir: &std::path::Path, state: &AppState, @@ -75,6 +87,10 @@ fn backfill_persona_snapshots_in_dir( .lock() .map_err(|error| error.to_string())?; + backfill_persona_snapshots_inner(definitions_dir) +} + +fn backfill_persona_snapshots_inner(definitions_dir: &std::path::Path) -> Result<(), String> { let mut records = load_managed_agents_at(definitions_dir)?; let needs_backfill = records .iter() @@ -440,6 +456,16 @@ pub async fn restore_managed_agents_on_launch( SpawnOutcome::Skipped => continue, SpawnOutcome::Spawned(key, mut process) => { let Ok(record) = find_managed_agent_mut(&mut records, &pubkey) else { + // Record was deleted between Phase B and Phase C — terminate + // the spawned child and remove its receipt to avoid a leaked + // process with no record to track it. + eprintln!( + "buzz-desktop: restore: record for {} was deleted during spawn; \ + terminating stale child", + pubkey + ); + let _ = super::terminate_process(process.child.id()); + super::remove_agent_runtime_receipt(app, &key); continue; }; let now = util::now_iso(); diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 13e25a2d5..5472411a7 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -115,6 +115,32 @@ pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result Result { + let base_dir = captured + .definitions_dir + .parent() + .and_then(|p| p.parent()) + .ok_or("retention_scope_from_captured: definitions_dir has fewer than two parent levels")?; + let db_path = scoped_retention_db_path(base_dir, &captured.relay_url, &captured.owner_pubkey); + std::fs::create_dir_all( + db_path + .parent() + .ok_or("retention scope path has no parent")?, + ) + .map_err(|e| format!("failed to create retention scope directory: {e}"))?; + Ok(RetentionScope { + db_path, + relay_url: captured.relay_url.clone(), + owner_keys, + }) +} + /// Snapshot the active relay + owner, but only when it is the scope that owns /// events delivered by `arrival_relay_url` from `arrival_owner_pubkey`. /// diff --git a/desktop/src-tauri/src/managed_agents/scope_init.rs b/desktop/src-tauri/src/managed_agents/scope_init.rs index 3c357d864..b4695ad93 100644 --- a/desktop/src-tauri/src/managed_agents/scope_init.rs +++ b/desktop/src-tauri/src/managed_agents/scope_init.rs @@ -91,7 +91,16 @@ pub fn scope_is_ready(scope_dir: &Path) -> bool { /// /// Idempotent: safe to call on every `apply_workspace`, even if the scope was /// already initialized. Returns `Ok(())` when the scope is ready to use. -pub fn ensure_scope_ready(scope_id: &str, scope_dir: &Path, base_dir: &Path) -> Result<(), String> { +/// +/// `owner_pubkey` is used for the legacy retention DB migration (copying +/// retained events for this owner from the legacy global DB into the scoped +/// DB). Pass the authenticated owner's hex pubkey. +pub fn ensure_scope_ready( + scope_id: &str, + scope_dir: &Path, + base_dir: &Path, + owner_pubkey: &str, +) -> Result<(), String> { if scope_is_ready(scope_dir) { return Ok(()); } @@ -121,6 +130,7 @@ pub fn ensure_scope_ready(scope_id: &str, scope_dir: &Path, base_dir: &Path) -> // have landed in the target after the rename. if scope_dir.exists() && scope_dir.join(MANIFEST_FILE).exists() { run_scoped_migrations(scope_dir)?; + run_pre_ready_family(scope_dir, base_dir, scope_id, owner_pubkey)?; write_ready_marker(scope_dir)?; return Ok(()); } @@ -131,11 +141,14 @@ pub fn ensure_scope_ready(scope_id: &str, scope_dir: &Path, base_dir: &Path) -> // Build the staged directory. install_staged(scope_id, scope_dir, base_dir, &init_kind)?; - // Run idempotent scoped migrations (currently a no-op placeholder — the - // boot-time migrations in migration.rs still run pre-scope; per-scope - // migration scheduling is a follow-on once the legacy data is stable). + // Run idempotent scoped migrations. run_scoped_migrations(scope_dir)?; + // Run pre-Ready family steps: legacy retention migration + persona backfill. + // These must complete before _ready is written so a crash leaves the scope + // in a retry-able state rather than permanently marking incomplete data Ready. + run_pre_ready_family(scope_dir, base_dir, scope_id, owner_pubkey)?; + // Write the ready marker. write_ready_marker(scope_dir)?; @@ -184,7 +197,9 @@ fn read_or_create_canonical_claim( } // No retention DB yet — use the fallback JSON claim file. - let claim_path = base_dir.join("agents").join(FALLBACK_CLAIM_FILE); + // `base_dir` is already `/agents`; the claim file lives + // at `/agents/legacy-claim.json` (no extra "agents" join). + let claim_path = base_dir.join(FALLBACK_CLAIM_FILE); read_or_create_fallback_claim(&claim_path, scope_id) } @@ -254,14 +269,17 @@ fn read_or_create_fallback_claim( } /// Check whether legacy (unscoped) definition files exist that need adoption. +/// +/// `base_dir` is `/agents` (not ``). Legacy files live +/// directly under `base_dir`; the new scoped layout puts them under +/// `base_dir/scopes//`. There is no extra "agents" join here. fn legacy_definitions_exist(base_dir: &Path) -> bool { - let agents_dir = base_dir.join("agents"); - // Legacy layout: `agents/managed-agents.json` at the unscoped root. - // New layout puts files under `agents/scopes//`. - agents_dir.join("managed-agents.json").exists() - || agents_dir.join("teams.json").exists() - || agents_dir.join("global-agent-config.json").exists() - || agents_dir.join("personas.json").exists() + // Legacy layout: files live directly in `/agents/`. + // New layout puts files under `/agents/scopes//`. + base_dir.join("managed-agents.json").exists() + || base_dir.join("teams.json").exists() + || base_dir.join("global-agent-config.json").exists() + || base_dir.join("personas.json").exists() } /// Build and atomically install the staged scope directory. @@ -283,14 +301,14 @@ fn install_staged( // For AdoptedLegacy: copy legacy files into staging. if matches!(init_kind, ScopeInitKind::AdoptedLegacy) { - let agents_dir = base_dir.join("agents"); + // `base_dir` is `/agents`; legacy files live directly in it. for filename in &[ "managed-agents.json", "teams.json", "global-agent-config.json", "personas.json", ] { - let src = agents_dir.join(filename); + let src = base_dir.join(filename); if src.exists() { let dst = staging.join(filename); std::fs::copy(&src, &dst) @@ -380,7 +398,8 @@ fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> { } // Step 3: refresh legacy builtin agent avatars. - crate::migration::refresh_builtin_agent_avatars_at(scope_dir); + crate::migration::refresh_builtin_agent_avatars_at(scope_dir) + .map_err(|e| format!("scope-init-avatars: {e}"))?; // Step 4: backfill standalone agents into definition-linked records. match crate::migration::backfill_standalone_agents_in_dir(scope_dir) { @@ -397,21 +416,24 @@ fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> { } // Step 6: reconcile legacy command names. - crate::migration::reconcile_legacy_command_names_at(scope_dir); + crate::migration::reconcile_legacy_command_names_at(scope_dir) + .map_err(|e| format!("scope-init-cmd-names: {e}"))?; // Step 7: reconcile provider mcp_command values. - crate::migration::reconcile_provider_mcp_commands_at(scope_dir); + crate::migration::reconcile_provider_mcp_commands_at(scope_dir) + .map_err(|e| format!("scope-init-mcp-cmds: {e}"))?; // Step 8: Databricks V1 → V2 provider migration. - crate::migration::reconcile_databricks_v1_to_v2_at(scope_dir); + crate::migration::reconcile_databricks_v1_to_v2_at(scope_dir) + .map_err(|e| format!("scope-init-databricks: {e}"))?; // Step 9: materialize runtime onto each record. - crate::migration::materialize_agent_runtimes_at(scope_dir); + crate::migration::materialize_agent_runtimes_at(scope_dir) + .map_err(|e| format!("scope-init-materialize: {e}"))?; // Step 10: validate the final managed-agents.json is parseable JSON before - // writing the Ready marker. Steps 6-9 use `patch_json_records` which logs - // and swallows parse failures internally; this final check ensures we never - // mark a scope Ready when its primary store is corrupt. + // writing the Ready marker. The step-10 backstop ensures the file is still + // valid JSON even after all migrations have run successfully. let agents_path = scope_dir.join("managed-agents.json"); if agents_path.exists() { let content = std::fs::read_to_string(&agents_path) @@ -426,6 +448,52 @@ fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> { Ok(()) } +/// Run the pre-Ready family steps: legacy retention migration and persona +/// snapshot backfill. These must complete before the `_ready` marker is +/// written so a crash between migration and marker leaves the scope in a +/// retry-able state rather than permanently marking incomplete data Ready. +/// +/// Both steps are idempotent: a second run after a crash is safe. +/// A failure aborts the pre-Ready sequence and propagates to `ensure_scope_ready`, +/// which withholds the `_ready` marker, enabling a clean retry on next launch. +fn run_pre_ready_family( + scope_dir: &Path, + base_dir: &Path, + scope_id: &str, + owner_pubkey: &str, +) -> Result<(), String> { + // Step A: legacy retention migration — copy owned retained events from + // the legacy global retention.db into this scope's scoped DB. + let scope_db_path = base_dir.join("retention").join(format!("{scope_id}.db")); + if let Some(parent) = scope_db_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("scope-init-retention: failed to create retention dir: {e}"))?; + } + match crate::managed_agents::retention::migrate_legacy_retention_db( + base_dir, + &scope_db_path, + owner_pubkey, + ) { + Ok(0) => {} + Ok(copied) => eprintln!( + "buzz-desktop: scope-init-retention: adopted {copied} legacy retained event(s)" + ), + Err(e) => return Err(format!("scope-init-retention: {e}")), + } + + // Step B: persona snapshot backfill — pre-populate `persona_source_version` + // on instances that link a persona but have no version pinned yet, so + // auto-start agents boot from a valid snapshot even on first activation. + // Runs without the store lock because the scope is not yet published as + // _ready and no concurrent reader or writer can legally access it. + if let Err(e) = crate::managed_agents::restore::backfill_persona_snapshots_pre_ready(scope_dir) + { + return Err(format!("scope-init-backfill: {e}")); + } + + Ok(()) +} + /// Write the `_ready` marker file inside the scope directory, signaling that /// all migrations are complete and the scope is available for use. fn write_ready_marker(scope_dir: &Path) -> Result<(), String> { @@ -488,22 +556,41 @@ mod tests { use super::*; use tempfile::TempDir; + /// Create a temporary directory and return it. The agents base dir + /// (`base_dir`) must be `tmp.path().join("agents")` — matching the + /// production layout where `managed_agents_base_dir` returns + /// `/agents`. Callers use `make_base_dir_pair` to get both. + #[allow(dead_code)] // Kept as documentation for the pair-based helpers. fn make_base_dir() -> TempDir { tempfile::tempdir().expect("tempdir") } + /// Returns `(TempDir, base_dir)` where `base_dir = tmp.path().join("agents")`. + /// + /// Production: `managed_agents_base_dir` returns `/agents`. + /// Tests must use that same layout so `legacy_definitions_exist`, + /// `install_staged`, and `read_or_create_canonical_claim` all see files + /// at the correct level. + fn make_base_dir_pair() -> (TempDir, std::path::PathBuf) { + let tmp = tempfile::tempdir().expect("tempdir"); + let base_dir = tmp.path().join("agents"); + std::fs::create_dir_all(&base_dir).unwrap(); + (tmp, base_dir) + } + + /// Write the legacy definition files directly into `base_dir` + /// (i.e. `/agents/managed-agents.json` etc.). fn make_legacy_files(base_dir: &Path) { - let agents_dir = base_dir.join("agents"); - std::fs::create_dir_all(&agents_dir).unwrap(); - std::fs::write(agents_dir.join("managed-agents.json"), b"[]").unwrap(); - std::fs::write(agents_dir.join("teams.json"), b"[]").unwrap(); + std::fs::create_dir_all(base_dir).unwrap(); + std::fs::write(base_dir.join("managed-agents.json"), b"[]").unwrap(); + std::fs::write(base_dir.join("teams.json"), b"[]").unwrap(); } #[test] fn test_fresh_no_legacy_scope_initializes_ready() { - let tmp = make_base_dir(); - let scope_dir = tmp.path().join("agents").join("scopes").join("testscope"); - ensure_scope_ready("testscope", &scope_dir, tmp.path()).unwrap(); + let (_tmp, base_dir) = make_base_dir_pair(); + let scope_dir = base_dir.join("scopes").join("testscope"); + ensure_scope_ready("testscope", &scope_dir, &base_dir, "test_owner").unwrap(); assert!(scope_is_ready(&scope_dir), "scope should be Ready"); // Manifest should indicate FreshNoLegacy. let manifest: ScopeManifest = @@ -513,11 +600,11 @@ mod tests { #[test] fn test_adopted_legacy_scope_copies_files() { - let tmp = make_base_dir(); - make_legacy_files(tmp.path()); + let (_tmp, base_dir) = make_base_dir_pair(); + make_legacy_files(&base_dir); let scope_id = "firstscope"; - let scope_dir = tmp.path().join("agents").join("scopes").join(scope_id); - ensure_scope_ready(scope_id, &scope_dir, tmp.path()).unwrap(); + let scope_dir = base_dir.join("scopes").join(scope_id); + ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap(); assert!(scope_is_ready(&scope_dir)); assert!( scope_dir.join("managed-agents.json").exists(), @@ -530,16 +617,16 @@ mod tests { #[test] fn test_second_scope_legacy_claimed_by_other() { - let tmp = make_base_dir(); - make_legacy_files(tmp.path()); + let (_tmp, base_dir) = make_base_dir_pair(); + make_legacy_files(&base_dir); // First scope claims. - let scope_a = tmp.path().join("agents").join("scopes").join("scope_a"); - ensure_scope_ready("scope_a", &scope_a, tmp.path()).unwrap(); + let scope_a = base_dir.join("scopes").join("scope_a"); + ensure_scope_ready("scope_a", &scope_a, &base_dir, "test_owner").unwrap(); // Second scope should see LegacyClaimedByOther. - let scope_b = tmp.path().join("agents").join("scopes").join("scope_b"); - ensure_scope_ready("scope_b", &scope_b, tmp.path()).unwrap(); + let scope_b = base_dir.join("scopes").join("scope_b"); + ensure_scope_ready("scope_b", &scope_b, &base_dir, "test_owner").unwrap(); assert!(scope_is_ready(&scope_b)); let manifest: ScopeManifest = serde_json::from_slice(&std::fs::read(scope_b.join(MANIFEST_FILE)).unwrap()).unwrap(); @@ -559,20 +646,20 @@ mod tests { #[test] fn test_idempotent_double_initialize() { - let tmp = make_base_dir(); - make_legacy_files(tmp.path()); - let scope_dir = tmp.path().join("agents").join("scopes").join("idempotent"); - ensure_scope_ready("idempotent", &scope_dir, tmp.path()).unwrap(); + let (_tmp, base_dir) = make_base_dir_pair(); + make_legacy_files(&base_dir); + let scope_dir = base_dir.join("scopes").join("idempotent"); + ensure_scope_ready("idempotent", &scope_dir, &base_dir, "test_owner").unwrap(); // Second call should be a fast no-op. - ensure_scope_ready("idempotent", &scope_dir, tmp.path()).unwrap(); + ensure_scope_ready("idempotent", &scope_dir, &base_dir, "test_owner").unwrap(); assert!(scope_is_ready(&scope_dir)); } #[test] fn test_staging_cleanup_on_retry() { - let tmp = make_base_dir(); - make_legacy_files(tmp.path()); - let scope_dir = tmp.path().join("agents").join("scopes").join("retry"); + let (_tmp, base_dir) = make_base_dir_pair(); + make_legacy_files(&base_dir); + let scope_dir = base_dir.join("scopes").join("retry"); let staging = staging_dir_for(&scope_dir); // Simulate an interrupted staging directory. @@ -580,18 +667,19 @@ mod tests { std::fs::write(staging.join("partial.json"), b"garbage").unwrap(); // ensure_scope_ready should clean it up and succeed. - ensure_scope_ready("retry", &scope_dir, tmp.path()).unwrap(); + ensure_scope_ready("retry", &scope_dir, &base_dir, "test_owner").unwrap(); assert!(scope_is_ready(&scope_dir)); assert!(!staging.exists(), "staging dir should be cleaned up"); } #[test] fn test_retention_db_claim_takes_precedence() { - let tmp = make_base_dir(); - make_legacy_files(tmp.path()); + let (_tmp, base_dir) = make_base_dir_pair(); + make_legacy_files(&base_dir); // Pre-plant a retention.db with scope_a's claim. - let retention_db_path = tmp.path().join("retention.db"); + // retention.db lives at `base_dir/retention.db` (i.e. `/agents/retention.db`). + let retention_db_path = base_dir.join("retention.db"); let conn = Connection::open(&retention_db_path).unwrap(); ensure_migration_table(&conn).unwrap(); conn.execute( @@ -602,8 +690,8 @@ mod tests { drop(conn); // scope_b activates first — retention.db says scope_a owns legacy. - let scope_b = tmp.path().join("agents").join("scopes").join("scope_b"); - ensure_scope_ready("scope_b", &scope_b, tmp.path()).unwrap(); + let scope_b = base_dir.join("scopes").join("scope_b"); + ensure_scope_ready("scope_b", &scope_b, &base_dir, "test_owner").unwrap(); let manifest: ScopeManifest = serde_json::from_slice(&std::fs::read(scope_b.join(MANIFEST_FILE)).unwrap()).unwrap(); assert!( @@ -617,8 +705,8 @@ mod tests { ); // scope_a now activates — should adopt legacy. - let scope_a = tmp.path().join("agents").join("scopes").join("scope_a"); - ensure_scope_ready("scope_a", &scope_a, tmp.path()).unwrap(); + let scope_a = base_dir.join("scopes").join("scope_a"); + ensure_scope_ready("scope_a", &scope_a, &base_dir, "test_owner").unwrap(); let manifest_a: ScopeManifest = serde_json::from_slice(&std::fs::read(scope_a.join(MANIFEST_FILE)).unwrap()).unwrap(); assert!(matches!(manifest_a.init_kind, ScopeInitKind::AdoptedLegacy)); @@ -631,19 +719,19 @@ mod tests { /// IGNORE is idempotent) and legacy files are copied correctly. #[test] fn test_crash_after_claim_before_staging_resumes_correctly() { - let tmp = make_base_dir(); - make_legacy_files(tmp.path()); + let (_tmp, base_dir) = make_base_dir_pair(); + make_legacy_files(&base_dir); // Simulate: claim was written into the fallback file but no staging dir exists yet. - let agents_dir = tmp.path().join("agents"); - let claim_path = agents_dir.join(FALLBACK_CLAIM_FILE); - std::fs::create_dir_all(&agents_dir).unwrap(); + // The fallback claim file lives at `base_dir/legacy-claim.json` + // (no extra "agents" join — base_dir is already `/agents`). + let claim_path = base_dir.join(FALLBACK_CLAIM_FILE); let claim = serde_json::json!({"scope_id": "scope_a"}); std::fs::write(&claim_path, serde_json::to_vec(&claim).unwrap()).unwrap(); // No staging dir exists — retry runs the full staged install from the claim. - let scope_a = tmp.path().join("agents").join("scopes").join("scope_a"); - ensure_scope_ready("scope_a", &scope_a, tmp.path()).unwrap(); + let scope_a = base_dir.join("scopes").join("scope_a"); + ensure_scope_ready("scope_a", &scope_a, &base_dir, "test_owner").unwrap(); assert!(scope_is_ready(&scope_a), "scope must be Ready after retry"); let manifest: ScopeManifest = @@ -667,10 +755,10 @@ mod tests { /// overwrite — staging is the only artifact). #[test] fn test_crash_during_staging_copy_is_cleaned_on_retry() { - let tmp = make_base_dir(); - make_legacy_files(tmp.path()); + let (_tmp, base_dir) = make_base_dir_pair(); + make_legacy_files(&base_dir); - let scope_dir = tmp.path().join("agents").join("scopes").join("scope_retry"); + let scope_dir = base_dir.join("scopes").join("scope_retry"); let staging = staging_dir_for(&scope_dir); // Simulate interrupted staging: directory exists with partial content. @@ -678,7 +766,7 @@ mod tests { std::fs::write(staging.join("managed-agents.json"), b"[\"partial\"]").unwrap(); // No manifest inside staging (write didn't complete). - ensure_scope_ready("scope_retry", &scope_dir, tmp.path()).unwrap(); + ensure_scope_ready("scope_retry", &scope_dir, &base_dir, "test_owner").unwrap(); assert!(scope_is_ready(&scope_dir)); assert!( @@ -698,14 +786,10 @@ mod tests { /// re-run; the claim is idempotent so the same scope adopts legacy again. #[test] fn test_crash_after_staging_manifest_before_rename_resumes_correctly() { - let tmp = make_base_dir(); - make_legacy_files(tmp.path()); + let (_tmp, base_dir) = make_base_dir_pair(); + make_legacy_files(&base_dir); - let scope_dir = tmp - .path() - .join("agents") - .join("scopes") - .join("scope_rename"); + let scope_dir = base_dir.join("scopes").join("scope_rename"); let staging = staging_dir_for(&scope_dir); // Simulate: staging complete with manifest, but rename never fired. @@ -723,7 +807,7 @@ mod tests { // Scope dir itself does not exist (rename didn't fire). assert!(!scope_dir.exists()); - ensure_scope_ready("scope_rename", &scope_dir, tmp.path()).unwrap(); + ensure_scope_ready("scope_rename", &scope_dir, &base_dir, "test_owner").unwrap(); assert!(scope_is_ready(&scope_dir)); assert!(!staging.exists(), "staging must be cleaned after retry"); @@ -746,14 +830,10 @@ mod tests { /// outside the scope of the crash-resume path. #[test] fn test_crash_after_rename_before_ready_resumes_migrations() { - let tmp = make_base_dir(); - make_legacy_files(tmp.path()); + let (_tmp, base_dir) = make_base_dir_pair(); + make_legacy_files(&base_dir); - let scope_dir = tmp - .path() - .join("agents") - .join("scopes") - .join("scope_pre_ready"); + let scope_dir = base_dir.join("scopes").join("scope_pre_ready"); // Simulate: rename already happened — target has manifest + files but no // _ready marker. Content is valid JSON so migrations can complete. @@ -772,7 +852,7 @@ mod tests { // No _ready marker. assert!(!scope_is_ready(&scope_dir)); - ensure_scope_ready("scope_pre_ready", &scope_dir, tmp.path()).unwrap(); + ensure_scope_ready("scope_pre_ready", &scope_dir, &base_dir, "test_owner").unwrap(); assert!( scope_is_ready(&scope_dir), @@ -794,21 +874,20 @@ mod tests { /// the defect corrected, migrations complete and `_ready` IS written. #[test] fn test_migration_failure_withholds_ready_and_retry_succeeds() { - let tmp = make_base_dir(); + let (_tmp, base_dir) = make_base_dir_pair(); // Create the legacy directory with a CORRUPT personas.json. - let agents_dir = tmp.path().join("agents"); - std::fs::create_dir_all(&agents_dir).unwrap(); - std::fs::write(agents_dir.join("managed-agents.json"), b"[]").unwrap(); + std::fs::create_dir_all(&base_dir).unwrap(); + std::fs::write(base_dir.join("managed-agents.json"), b"[]").unwrap(); // Corrupt personas.json: `fold_personas_in_dir` tries to parse it and // returns Err, which run_scoped_migrations propagates. - std::fs::write(agents_dir.join("personas.json"), b"not valid json").unwrap(); + std::fs::write(base_dir.join("personas.json"), b"not valid json").unwrap(); let scope_id = "scope_fail_retry"; - let scope_dir = tmp.path().join("agents").join("scopes").join(scope_id); + let scope_dir = base_dir.join("scopes").join(scope_id); // First attempt: migrations fail, _ready must NOT be written. - let result = ensure_scope_ready(scope_id, &scope_dir, tmp.path()); + let result = ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner"); assert!(result.is_err(), "corrupt personas.json must cause Err"); assert!( !scope_is_ready(&scope_dir), @@ -816,7 +895,7 @@ mod tests { ); // Repair the corrupt file. - std::fs::write(agents_dir.join("personas.json"), b"[]").unwrap(); + std::fs::write(base_dir.join("personas.json"), b"[]").unwrap(); // Also repair the scope_dir since ensure_scope_ready may have left it in // a partial state — remove it so the state machine reruns from staging. if scope_dir.exists() { @@ -824,10 +903,66 @@ mod tests { } // Second attempt: migrations succeed, _ready IS written. - ensure_scope_ready(scope_id, &scope_dir, tmp.path()).unwrap(); + ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap(); assert!( scope_is_ready(&scope_dir), "_ready must be written on successful retry" ); } + + /// Production-contract coverage: `base_dir` is `/agents` + /// (the real shape from `managed_agents_base_dir`). Legacy files live + /// at `base_dir/{managed-agents,teams}.json`; the scope dir lives at + /// `base_dir/scopes//`; the fallback claim file lives at + /// `base_dir/legacy-claim.json`. This test verifies the full adoption + /// path using the production layout so any future double-join regresses + /// visibly here rather than silently succeeding on a synthetic tree. + #[test] + fn test_production_shaped_adoption_finds_legacy_files() { + // `app_data_dir` is the synthetic `` root. + let tmp = tempfile::tempdir().expect("tempdir"); + let app_data_dir = tmp.path(); + + // Production: `managed_agents_base_dir` returns `/agents`. + let base_dir = app_data_dir.join("agents"); + std::fs::create_dir_all(&base_dir).unwrap(); + + // Legacy files sit directly under `base_dir`. + std::fs::write(base_dir.join("managed-agents.json"), b"[]").unwrap(); + std::fs::write(base_dir.join("teams.json"), b"[]").unwrap(); + + // Scope dir is `base_dir/scopes//`. + let scope_id = "prod-shape-scope"; + let scope_dir = base_dir.join("scopes").join(scope_id); + + ensure_scope_ready(scope_id, &scope_dir, &base_dir, "test_owner").unwrap(); + + assert!(scope_is_ready(&scope_dir), "scope must be Ready"); + + // With the correct layout the scope must adopt legacy (not start fresh). + let manifest: ScopeManifest = + serde_json::from_slice(&std::fs::read(scope_dir.join(MANIFEST_FILE)).unwrap()).unwrap(); + assert!( + matches!(manifest.init_kind, ScopeInitKind::AdoptedLegacy), + "production-layout scope must adopt legacy, got {:?}", + manifest.init_kind + ); + // Legacy files were copied into the scope. + assert!( + scope_dir.join("managed-agents.json").exists(), + "managed-agents.json must be present in adopted scope" + ); + + // Fallback claim file lives at `base_dir/legacy-claim.json`, NOT at + // `base_dir/agents/legacy-claim.json` (which would be the double-join + // path). Verify the correct location was used. + assert!( + base_dir.join(FALLBACK_CLAIM_FILE).exists(), + "fallback claim must be at base_dir/legacy-claim.json, not at a nested path" + ); + assert!( + !base_dir.join("agents").join(FALLBACK_CLAIM_FILE).exists(), + "double-join claim path must NOT exist" + ); + } } diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index cd401dd63..166469f39 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -278,11 +278,15 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu // that commits after this point is handled on the next watchdog cycle. // Both relay and definitions_dir come from the single captured scope so // all store reads below target the same workspace as the relay check. - let (scope_relay, scope_definitions_dir) = { + // The generation is captured alongside so writes to definitions_dir can + // detect a mid-pass workspace switch via validate_scope_generation before + // persisting any error/clear record. + let (scope_relay, scope_definitions_dir, captured_scope) = { let s = state.capture_active_scope(); ( s.as_ref().map(|scope| scope.relay_url.clone()), - s.map(|scope| scope.definitions_dir.clone()), + s.as_ref().map(|scope| scope.definitions_dir.clone()), + s, ) }; @@ -408,8 +412,17 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu { Ok(()) => { if let Some(dir) = scope_definitions_dir.as_deref() { - if let Err(error) = clear_mesh_last_error_if_set_at(app, dir, &record.pubkey) { - eprintln!("buzz-mesh: failed to clear recovery error: {error}"); + // Validate generation before writing to the captured scope's + // directory — if the workspace switched during this await, + // do not write the stale success into the new scope's store. + if captured_scope.as_ref().map_or(false, |s| { + crate::managed_agents::scope::validate_scope_generation(s).is_ok() + }) { + if let Err(error) = + clear_mesh_last_error_if_set_at(app, dir, &record.pubkey) + { + eprintln!("buzz-mesh: failed to clear recovery error: {error}"); + } } } } @@ -418,10 +431,18 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu "{MESH_REARM_ERROR_SENTINEL}Buzz shared compute offline — failed to re-arm local ingress for this agent: {error}" ); if let Some(dir) = scope_definitions_dir.as_deref() { - if let Err(persist_error) = - persist_mesh_last_error_at(app, dir, &record.pubkey, &message) - { - eprintln!("buzz-mesh: failed to persist recovery error: {persist_error}"); + // Validate generation before persisting the error — if the + // workspace switched during this await, skip the write. + if captured_scope.as_ref().map_or(false, |s| { + crate::managed_agents::scope::validate_scope_generation(s).is_ok() + }) { + if let Err(persist_error) = + persist_mesh_last_error_at(app, dir, &record.pubkey, &message) + { + eprintln!( + "buzz-mesh: failed to persist recovery error: {persist_error}" + ); + } } } first_error.get_or_insert(message); diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 2cc23880e..39540839b 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -471,17 +471,23 @@ fn copy_file_over_generated_default(src: &Path, dst: &Path) -> std::io::Result<( fn patch_json_records( path: &Path, mut f: impl FnMut(&mut serde_json::Map) -> bool, -) { - let Ok(content) = std::fs::read_to_string(path) else { - return; +) -> Result<(), String> { + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(format!( + "patch-json-records: failed to read {}: {e}", + path.display() + )) + } }; - let Ok(mut records) = serde_json::from_str::>(&content) else { - eprintln!( - "buzz-desktop: patch-json-records: failed to parse {}", + let mut records = serde_json::from_str::>(&content).map_err(|e| { + format!( + "patch-json-records: failed to parse {}: {e}", path.display() - ); - return; - }; + ) + })?; let mut changed = false; for record in &mut records { if let Some(obj) = record.as_object_mut() { @@ -489,12 +495,15 @@ 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) { - eprintln!("buzz-desktop: patch-json-records: {e}"); - } - } + let bytes = serde_json::to_vec_pretty(&records).map_err(|e| { + format!( + "patch-json-records: failed to serialize {}: {e}", + path.display() + ) + })?; + crate::managed_agents::atomic_write_json_restricted(path, &bytes)?; } + Ok(()) } struct LegacyBuiltInAvatar<'a> { @@ -533,41 +542,27 @@ struct LegacyAvatarMatch<'a> { was_uploaded: bool, } -/// Refresh the prior seeded avatar on built-in definitions and linked agent -/// instances while preserving any avatar the user customized. Matching by the -/// exact data URL or content-addressed upload digest makes the migration -/// idempotent and avoids relying on timestamps or other persona fields the -/// user may also have edited. -#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses refresh_builtin_agent_avatars_at. -fn refresh_builtin_agent_avatars(app: &tauri::AppHandle) { - let Ok(dir) = app.path().app_data_dir() else { - return; - }; - let path = dir.join("agents/managed-agents.json"); - if path.exists() { - refresh_builtin_agent_avatars_in_file( - &path, - LEGACY_BUILTIN_AVATARS, - &crate::util::now_iso(), - ); - } -} - fn refresh_builtin_agent_avatars_in_file( path: &Path, legacy_avatars: &[LegacyBuiltInAvatar<'_>], now: &str, -) { - let Ok(contents) = std::fs::read_to_string(path) else { - return; +) -> Result<(), String> { + let contents = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(format!( + "refresh-builtin-agent-avatars: failed to read {}: {e}", + path.display() + )) + } }; - let Ok(mut records) = serde_json::from_str::>(&contents) else { - eprintln!( - "buzz-desktop: refresh-builtin-agent-avatars: invalid JSON in {}", + let mut records = serde_json::from_str::>(&contents).map_err(|e| { + format!( + "refresh-builtin-agent-avatars: invalid JSON in {}: {e}", path.display() - ); - return; - }; + ) + })?; // Definitions must be migrated first so linked instances can advance from // the exact old persona hash to the exact new one. Only advance an instance @@ -641,12 +636,15 @@ 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) { - eprintln!("buzz-desktop: refresh-builtin-agent-avatars: {e}"); - } - } + let bytes = serde_json::to_vec_pretty(&records).map_err(|e| { + format!( + "refresh-builtin-agent-avatars: failed to serialize {}: {e}", + path.display() + ) + })?; + crate::managed_agents::atomic_write_json_restricted(path, &bytes)?; } + Ok(()) } fn legacy_avatar_match<'a>( @@ -952,7 +950,7 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { } } -fn reconcile_mcp_commands_in_file(path: &Path) { +fn reconcile_mcp_commands_in_file(path: &Path) -> Result<(), String> { // Resolve each record's EFFECTIVE harness (persona-wins, override-honored) // before deriving its mcp_command, so a persona-inherited harness switch // doesn't leave a stale persisted mcp_command. The persona runtime is read @@ -1005,7 +1003,8 @@ fn reconcile_mcp_commands_in_file(path: &Path) { serde_json::Value::String(expected.to_string()), ); true - }); + })?; + Ok(()) } fn replace_command_field( @@ -1029,7 +1028,7 @@ fn replace_command_field( true } -fn reconcile_legacy_command_names_in_file(path: &Path) { +fn reconcile_legacy_command_names_in_file(path: &Path) -> Result<(), String> { patch_json_records(path, |obj| { let mut changed = false; @@ -1076,151 +1075,13 @@ fn reconcile_legacy_command_names_in_file(path: &Path) { } changed - }); + }) } -#[allow(dead_code)] // Helper for reconcile_legacy_command_names (boot-migration shim). -fn reconcile_legacy_persona_runtimes_in_file(path: &Path) { - patch_json_records(path, |obj| { - let Some(runtime) = obj.get("runtime").and_then(|v| v.as_str()) else { - return false; - }; - if runtime != "sprout-agent" { - return false; - } - eprintln!( - "buzz-desktop: command-rename-reconcile: persona {:?}: runtime {:?} → {:?}", - obj.get("display_name") - .or_else(|| obj.get("displayName")) - .and_then(|v| v.as_str()) - .unwrap_or("?"), - runtime, - "buzz-agent", - ); - obj.insert( - "runtime".to_string(), - serde_json::Value::String("buzz-agent".to_string()), - ); - true - }); -} - -#[allow(dead_code)] // Helper for reconcile_legacy_command_names (boot-migration shim). -fn rewrite_legacy_persona_md_runtime(content: &str) -> Option { - let (frontmatter, body) = buzz_persona_pkg::persona::split_frontmatter(content).ok()?; - let mut value = serde_yaml::from_str::(frontmatter).ok()?; - let mapping = value.as_mapping_mut()?; - let runtime = mapping.get_mut(serde_yaml::Value::String("runtime".to_string()))?; - if runtime.as_str()? != "sprout-agent" { - return None; - } - *runtime = serde_yaml::Value::String("buzz-agent".to_string()); - let frontmatter = serde_yaml::to_string(&value).ok()?; - Some(format!("---\n{frontmatter}---\n{body}")) -} - -#[allow(dead_code)] // Helper for reconcile_legacy_command_names (boot-migration shim). -fn reconcile_legacy_team_persona_runtime_files(dir: &Path) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_dir() { - reconcile_legacy_team_persona_runtime_files(&path); - continue; - } - if !file_type.is_file() { - continue; - } - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if !name.ends_with(".persona.md") { - continue; - } - let Ok(content) = std::fs::read_to_string(&path) else { - continue; - }; - let Some(updated) = rewrite_legacy_persona_md_runtime(&content) else { - continue; - }; - if updated == content { - continue; - } - match std::fs::write(&path, updated) { - Ok(()) => { - eprintln!( - "buzz-desktop: command-rename-reconcile: updated {}", - path.display() - ); - } - Err(error) => { - eprintln!( - "buzz-desktop: command-rename-reconcile: failed to update {}: {error}", - path.display() - ); - } - } - } -} - -/// Reconcile exact built-in command values persisted before the Sprout→Buzz -/// rename. Custom commands and explicit paths are left untouched. -#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses reconcile_legacy_command_names_at. -pub fn reconcile_legacy_command_names(app: &tauri::AppHandle) { - let Ok(current_dir) = app.path().app_data_dir() else { - return; - }; - let mut dirs = vec![current_dir.clone()]; - if let Some(canonical) = canonical_dev_data_dir(¤t_dir) { - if canonical.exists() && canonical != current_dir { - dirs.push(canonical); - } - } - for dir in dirs { - let path = dir.join("agents/managed-agents.json"); - if path.exists() { - reconcile_legacy_command_names_in_file(&path); - } - let personas_path = dir.join("agents/personas.json"); - if personas_path.exists() { - reconcile_legacy_persona_runtimes_in_file(&personas_path); - } - let teams_dir = dir.join("agents/teams"); - if teams_dir.exists() && !teams_dir.is_symlink() { - reconcile_legacy_team_persona_runtime_files(&teams_dir); - } - } -} - -/// Reconcile `mcp_command` values in managed-agents.json against the -/// discovery table. Known runtimes get their canonical mcp_command; -/// unknown/custom agents are left untouched. Covers both the current -/// app data dir and the canonical dev data dir (for worktree instances). -#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses reconcile_provider_mcp_commands_at. -pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) { - let Ok(current_dir) = app.path().app_data_dir() else { - return; - }; - let mut dirs = vec![current_dir.clone()]; - if let Some(canonical) = canonical_dev_data_dir(¤t_dir) { - if canonical.exists() && canonical != current_dir { - dirs.push(canonical); - } - } - for dir in dirs { - let path = dir.join("agents/managed-agents.json"); - if path.exists() { - reconcile_mcp_commands_in_file(&path); - } - } -} - -fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) { +fn reconcile_databricks_v1_to_v2_in_file( + path: &Path, + rewrite_v1_provider: bool, +) -> Result<(), String> { use crate::managed_agents::is_derived_provider_model_key; patch_json_records(path, |obj| { let mut changed = false; @@ -1277,7 +1138,7 @@ fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) } changed - }); + }) } /// Strip stale derived provider/model keys from `env_vars` in all @@ -1301,36 +1162,9 @@ fn reconcile_databricks_v1_to_v2_in_file(path: &Path, rewrite_v1_provider: bool) /// Covers both the current app data dir and the canonical dev data dir /// (for worktree instances) — same dual-dir pattern as /// `reconcile_legacy_command_names` and `reconcile_provider_mcp_commands`. -#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses reconcile_databricks_v1_to_v2_at. -pub fn reconcile_databricks_v1_to_v2(app: &tauri::AppHandle) { - use crate::managed_agents::baked_build_env; - // On Block builds, the baked env contains BUZZ_AGENT_PROVIDER=databricks_v2. - // Use that as a reliable signal that this is a Block build and the V1 - // provider should be migrated. OSS builds have an empty baked env, so - // rewrite_v1_provider is false and the structured provider is preserved. - let rewrite_v1_provider = baked_build_env() - .get("BUZZ_AGENT_PROVIDER") - .map(|v| v == "databricks_v2") - .unwrap_or(false); - let Ok(current_dir) = app.path().app_data_dir() else { - return; - }; - let mut dirs = vec![current_dir.clone()]; - if let Some(canonical) = canonical_dev_data_dir(¤t_dir) { - if canonical.exists() && canonical != current_dir { - dirs.push(canonical); - } - } - for dir in dirs { - let path = dir.join("agents/managed-agents.json"); - if path.exists() { - reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider); - } - } -} fn rename_provider_to_runtime_in_personas(path: &Path) { - patch_json_records(path, |obj| { + if let Err(e) = patch_json_records(path, |obj| { if obj.contains_key("runtime") { return false; } @@ -1340,7 +1174,9 @@ fn rename_provider_to_runtime_in_personas(path: &Path) { } else { false } - }); + }) { + eprintln!("buzz-desktop: rename-provider-to-runtime: {e}"); + } } pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) { diff --git a/desktop/src-tauri/src/migration/backfill.rs b/desktop/src-tauri/src/migration/backfill.rs index f11ce553b..4b01c80fe 100644 --- a/desktop/src-tauri/src/migration/backfill.rs +++ b/desktop/src-tauri/src/migration/backfill.rs @@ -31,21 +31,6 @@ use crate::managed_agents::{ /// The manufactured definition's slug is the agent's pubkey: 64-hex passes /// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys /// are unique, so the coordinate is collision-free by construction. -#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses backfill_standalone_agents_in_dir. -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) { - Ok(0) => {} - Ok(backfilled) => { - eprintln!( - "buzz-desktop: standalone-backfill: {backfilled} agents linked to manufactured definitions" - ); - } - Err(e) => eprintln!("buzz-desktop: standalone-backfill: {e}"), - } -} /// Core backfill logic, decoupled from the Tauri `AppHandle` for testing. /// Returns the number of records backfilled (0 = nothing to do). diff --git a/desktop/src-tauri/src/migration/detach.rs b/desktop/src-tauri/src/migration/detach.rs index b121c14d9..a28841da0 100644 --- a/desktop/src-tauri/src/migration/detach.rs +++ b/desktop/src-tauri/src/migration/detach.rs @@ -24,18 +24,6 @@ use crate::managed_agents::{ManagedAgentRecord, TeamRecord}; /// `instructions` if the field is not already set. /// 4. Clear `source_dir`, `is_symlink`, `symlink_target`, `version` on each /// directory-backed `TeamRecord`. -#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses detach_directory_backed_teams_in_dir. -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) { - 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}"), - } -} - /// Core logic, decoupled from the Tauri `AppHandle` for testing. /// /// `base_dir` is the managed-agents base directory (`/agents/`). diff --git a/desktop/src-tauri/src/migration/fold.rs b/desktop/src-tauri/src/migration/fold.rs index fa9aee2fc..98650b4c1 100644 --- a/desktop/src-tauri/src/migration/fold.rs +++ b/desktop/src-tauri/src/migration/fold.rs @@ -3,38 +3,6 @@ use std::path::Path; -/// Fold `personas.json` into the unified agent store (Phase 1A.2). -/// -/// One-way, versioned by presence: runs only while `personas.json` exists. -/// Each persona becomes a key-less definition record -/// ([`AgentDefinition::into_agent_record`]) appended to `managed-agents.json` -/// via the definition-preserving save; the old file is renamed to -/// `personas.json.bak` so a second boot is a no-op and the data survives for -/// manual recovery. Built-ins are skipped — `merge_personas` regenerates them -/// from code on every load, exactly as before. -/// -/// Ordering (see `run_boot_migrations`): runs after the JSON-level -/// `personas.json` migrations (which must see the legacy file) and BEFORE -/// every consumer of the `load/save_personas` shims — `sync_team_personas`, -/// `reconcile_provider_mcp_commands`, and `materialize_agent_runtimes` all -/// read definitions post-fold via [`load_persona_runtimes`]'s unified-store -/// branch. -#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses fold_personas_in_dir. -pub fn fold_personas_into_agent_store(app: &tauri::AppHandle) { - let Ok(base_dir) = crate::managed_agents::managed_agents_base_dir(app) else { - return; - }; - match fold_personas_in_dir(&base_dir) { - Ok(None) => {} - Ok(Some(folded)) => { - eprintln!( - "buzz-desktop: persona-store-fold: {folded} definitions folded into the unified store" - ); - } - Err(e) => eprintln!("buzz-desktop: persona-store-fold: {e}"), - } -} - /// Core fold logic, decoupled from the Tauri `AppHandle` for testing. /// Operates on the raw JSON files — no keyring interaction: instance records /// are passed through byte-identical, and folded definitions carry no keys. diff --git a/desktop/src-tauri/src/migration/materialize.rs b/desktop/src-tauri/src/migration/materialize.rs index 6bf542503..2d5b56fc5 100644 --- a/desktop/src-tauri/src/migration/materialize.rs +++ b/desktop/src-tauri/src/migration/materialize.rs @@ -1,15 +1,9 @@ //! Phase 1A (unified agent model): boot-time materialization of each //! persona-linked agent record's `runtime` onto the record itself. -//! -//! Child module of `migration` so it reuses the parent's private JSON-patch -//! helpers (`patch_json_records`, `load_persona_runtimes`, -//! `canonical_dev_data_dir`). use std::path::Path; -use tauri::Manager as _; - -use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records}; +use super::{load_persona_runtimes, patch_json_records}; /// Materialize each persona-linked agent record's `runtime` from its linked /// persona (unified agent model, Phase 1A). After this, spawn resolution reads @@ -21,29 +15,10 @@ use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records}; /// Idempotent: records that already carry `runtime` are untouched, as are /// records with no linked persona or a persona without a runtime (both keep /// resolving through the legacy fallback path unchanged). -#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses materialize_agent_runtimes_at. -pub fn materialize_agent_runtimes(app: &tauri::AppHandle) { - let Ok(current_dir) = app.path().app_data_dir() else { - return; - }; - let mut dirs = vec![current_dir.clone()]; - if let Some(canonical) = canonical_dev_data_dir(¤t_dir) { - if canonical.exists() && canonical != current_dir { - dirs.push(canonical); - } - } - for dir in dirs { - let path = dir.join("agents/managed-agents.json"); - if path.exists() { - materialize_runtimes_in_file(&path); - } - } -} - -pub(crate) fn materialize_runtimes_in_file(path: &Path) { +pub(crate) fn materialize_runtimes_in_file(path: &Path) -> Result<(), String> { let persona_runtimes = load_persona_runtimes(path); if persona_runtimes.is_empty() { - return; + return Ok(()); } patch_json_records(path, |obj| { if obj.contains_key("runtime") { @@ -61,7 +36,7 @@ pub(crate) fn materialize_runtimes_in_file(path: &Path) { serde_json::Value::String(runtime.clone()), ); true - }); + }) } #[cfg(test)] @@ -82,7 +57,7 @@ mod tests { dir.path(), &serde_json::json!([{ "name": "Fizz", "persona_id": "persona-1" }]), ); - materialize_runtimes_in_file(&dir.path().join("agents/managed-agents.json")); + materialize_runtimes_in_file(&dir.path().join("agents/managed-agents.json")).unwrap(); let records = read_agents_json(dir.path()); assert_eq!(records[0]["runtime"], "goose"); } @@ -104,7 +79,7 @@ mod tests { ]), ); let agents_path = dir.path().join("agents/managed-agents.json"); - materialize_runtimes_in_file(&agents_path); + materialize_runtimes_in_file(&agents_path).unwrap(); let records = read_agents_json(dir.path()); assert_eq!( records[0]["runtime"], "claude", @@ -113,7 +88,7 @@ mod tests { assert_eq!(records[1]["runtime"], "goose"); let before = std::fs::read_to_string(&agents_path).unwrap(); - materialize_runtimes_in_file(&agents_path); + materialize_runtimes_in_file(&agents_path).unwrap(); let after = std::fs::read_to_string(&agents_path).unwrap(); assert_eq!(before, after, "second run must be a no-op"); } @@ -134,7 +109,7 @@ mod tests { ); let agents_path = dir.path().join("agents/managed-agents.json"); let before = std::fs::read_to_string(&agents_path).unwrap(); - materialize_runtimes_in_file(&agents_path); + materialize_runtimes_in_file(&agents_path).unwrap(); let after = std::fs::read_to_string(&agents_path).unwrap(); assert_eq!(before, after, "no linked runtime → untouched file"); } diff --git a/desktop/src-tauri/src/migration/team_suffix.rs b/desktop/src-tauri/src/migration/team_suffix.rs index c497b6512..f04f5084c 100644 --- a/desktop/src-tauri/src/migration/team_suffix.rs +++ b/desktop/src-tauri/src/migration/team_suffix.rs @@ -45,21 +45,6 @@ const TEAM_DELIMITER: &str = "\n\n---\n# Team Instructions\n"; /// `personas.json` are cleaned in the same boot, and BEFORE /// `backfill_standalone_agents` so a manufactured definition never snapshots /// a suffix this migration is about to remove. -#[allow(dead_code)] // Boot-migration shim; scoped pipeline now uses strip_baked_team_instructions_in_dir. -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) { - Ok(0) => {} - Ok(stripped) => eprintln!( - "buzz-desktop: team-suffix-strip: removed the baked team-instructions suffix from \ - {stripped} record(s)" - ), - Err(e) => eprintln!("buzz-desktop: team-suffix-strip: {e}"), - } -} - /// Core logic, decoupled from the Tauri `AppHandle` for testing. /// /// `base_dir` is the managed-agents base directory (`/agents/`). diff --git a/desktop/src-tauri/src/migration_command_tests.rs b/desktop/src-tauri/src/migration_command_tests.rs index d95188f1a..5b07d052b 100644 --- a/desktop/src-tauri/src/migration_command_tests.rs +++ b/desktop/src-tauri/src/migration_command_tests.rs @@ -82,104 +82,8 @@ fn reconcile_legacy_command_names_preserves_custom_commands() { assert_eq!(before, std::fs::read_to_string(&path).unwrap()); } -#[test] -fn reconcile_legacy_command_names_rewrites_persona_runtime() { - let dir = tempfile::tempdir().unwrap(); - write_personas_json( - dir.path(), - &serde_json::json!([{ - "id": "persona-1", - "display_name": "Brain", - "runtime": "sprout-agent" - }]), - ); - - reconcile_legacy_persona_runtimes_in_file(&dir.path().join("agents/personas.json")); - - let records = read_personas_json(dir.path()); - assert_eq!(records[0]["runtime"], "buzz-agent"); -} - -#[test] -fn reconcile_legacy_command_names_rewrites_runtime_after_provider_migration() { - let dir = tempfile::tempdir().unwrap(); - write_personas_json( - dir.path(), - &serde_json::json!([{ - "id": "persona-1", - "display_name": "Brain", - "provider": "sprout-agent" - }]), - ); - let path = dir.path().join("agents/personas.json"); - - rename_provider_to_runtime_in_personas(&path); - reconcile_legacy_persona_runtimes_in_file(&path); - - let records = read_personas_json(dir.path()); - assert_eq!(records[0]["runtime"], "buzz-agent"); - assert!(records[0].get("provider").is_none()); -} - -#[test] -fn reconcile_legacy_command_names_preserves_non_legacy_persona_runtime() { - let dir = tempfile::tempdir().unwrap(); - write_personas_json( - dir.path(), - &serde_json::json!([{ - "id": "persona-1", - "display_name": "Solo", - "runtime": "goose" - }]), - ); - let path = dir.path().join("agents/personas.json"); - let before = std::fs::read_to_string(&path).unwrap(); - - reconcile_legacy_persona_runtimes_in_file(&path); - - assert_eq!(before, std::fs::read_to_string(&path).unwrap()); -} - -#[test] -fn rewrite_legacy_persona_md_runtime_rewrites_frontmatter_only() { - let content = concat!( - "---\n", - "name: brain\n", - "display_name: Brain\n", - "description: Test persona\n", - "runtime: sprout-agent\n", - "---\n", - "Body mentions runtime: sprout-agent.\n", - ); - - let updated = rewrite_legacy_persona_md_runtime(content).unwrap(); - - assert!(updated.contains("runtime: buzz-agent\n")); - assert!(updated.contains("Body mentions runtime: sprout-agent.\n")); -} - -#[test] -fn reconcile_legacy_team_persona_runtime_files_rewrites_persona_md() { - let dir = tempfile::tempdir().unwrap(); - let teams_dir = dir.path().join("agents/teams/com.example.team/agents"); - std::fs::create_dir_all(&teams_dir).unwrap(); - let persona_path = teams_dir.join("brain.persona.md"); - std::fs::write( - &persona_path, - concat!( - "---\n", - "name: brain\n", - "display_name: Brain\n", - "description: Test persona\n", - "runtime: sprout-agent\n", - "---\n", - "Prompt\n", - ), - ) - .unwrap(); - - reconcile_legacy_team_persona_runtime_files(&dir.path().join("agents/teams")); - - let updated = std::fs::read_to_string(persona_path).unwrap(); - assert!(updated.contains("runtime: buzz-agent\n")); -} +// Tests for `reconcile_legacy_persona_runtimes_in_file`, +// `rewrite_legacy_persona_md_runtime`, and +// `reconcile_legacy_team_persona_runtime_files` were removed alongside those +// deleted functions. The scoped pipeline's `_in_dir` variants carry the +// equivalent coverage. diff --git a/desktop/src-tauri/src/migration_scope.rs b/desktop/src-tauri/src/migration_scope.rs index b3eb8918d..f9636f37f 100644 --- a/desktop/src-tauri/src/migration_scope.rs +++ b/desktop/src-tauri/src/migration_scope.rs @@ -5,15 +5,16 @@ pub(crate) use materialize::materialize_runtimes_in_file; pub(crate) use team_suffix::strip_baked_team_instructions_in_dir; /// Reconcile `mcp_command` values in a scoped `definitions_dir`. -pub(crate) fn reconcile_provider_mcp_commands_at(definitions_dir: &std::path::Path) { +pub(crate) fn reconcile_provider_mcp_commands_at(definitions_dir: &std::path::Path) -> Result<(), String> { let path = definitions_dir.join("managed-agents.json"); if path.exists() { - reconcile_mcp_commands_in_file(&path); + reconcile_mcp_commands_in_file(&path)?; } + Ok(()) } /// Reconcile Databricks V1 → V2 provider entries in a scoped `definitions_dir`. -pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path) { +pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path) -> Result<(), String> { use crate::managed_agents::baked_build_env; let rewrite_v1_provider = baked_build_env() .get("BUZZ_AGENT_PROVIDER") @@ -21,30 +22,34 @@ pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path .unwrap_or(false); let path = definitions_dir.join("managed-agents.json"); if path.exists() { - reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider); + reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider)?; } + Ok(()) } /// Refresh legacy built-in agent avatars in a scoped `definitions_dir`. -pub(crate) fn refresh_builtin_agent_avatars_at(definitions_dir: &std::path::Path) { +pub(crate) fn refresh_builtin_agent_avatars_at(definitions_dir: &std::path::Path) -> Result<(), String> { let path = definitions_dir.join("managed-agents.json"); if path.exists() { - refresh_builtin_agent_avatars_in_file(&path, LEGACY_BUILTIN_AVATARS, &crate::util::now_iso()); + refresh_builtin_agent_avatars_in_file(&path, LEGACY_BUILTIN_AVATARS, &crate::util::now_iso())?; } + Ok(()) } /// Reconcile legacy command names in a scoped `definitions_dir`. -pub(crate) fn reconcile_legacy_command_names_at(definitions_dir: &std::path::Path) { +pub(crate) fn reconcile_legacy_command_names_at(definitions_dir: &std::path::Path) -> Result<(), String> { let path = definitions_dir.join("managed-agents.json"); if path.exists() { - reconcile_legacy_command_names_in_file(&path); + reconcile_legacy_command_names_in_file(&path)?; } + Ok(()) } /// Materialize per-record runtimes in a scoped `definitions_dir`. -pub(crate) fn materialize_agent_runtimes_at(definitions_dir: &std::path::Path) { +pub(crate) fn materialize_agent_runtimes_at(definitions_dir: &std::path::Path) -> Result<(), String> { let path = definitions_dir.join("managed-agents.json"); if path.exists() { - materialize_runtimes_in_file(&path); + materialize_runtimes_in_file(&path)?; } + Ok(()) }