//! Worktree data sync and on-launch reconciliation for the Buzz desktop app. //! //! **Worktree sync** (`sync_shared_agent_data`): Per-launch symlink creation //! from the current worktree data directory to the canonical dev data //! directory (`xyz.block.buzz.app.dev`). Only runs when //! `BUZZ_SHARE_IDENTITY=1` and `BUZZ_PRIVATE_KEY` is set. All dev //! instances share the same physical files — edits in any worktree are //! immediately visible to all others. //! //! **Command reconciliation** (`reconcile_legacy_command_names`): Per-launch //! fix-up of persisted built-in command names from the Sprout→Buzz rename. //! //! **Provider reconciliation** (`reconcile_provider_mcp_commands`): Per-launch //! fix-up of `mcp_command` values in `managed-agents.json` against the //! discovery table. Ensures known providers always have their canonical //! `mcp_command`; unknown/custom agents are left untouched. use std::path::{Path, PathBuf}; use tauri::Manager; use crate::util::replace_with_symlink; const CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.buzz.app.dev"; const LEGACY_CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.sprout.app.dev"; const LEGACY_RELEASE_IDENTIFIER: &str = "xyz.block.sprout.app"; /// JSON files symlinked from worktree data directories to the canonical /// dev data directory. Only data files — never `agent-pids/` or `logs/`. /// `identity.key` is deliberately excluded because worktree instances /// receive their identity via the `BUZZ_PRIVATE_KEY` env var. const SHARED_AGENT_FILES: &[&str] = &[ "agents/managed-agents.json", "agents/personas.json", "agents/teams.json", ]; /// Directories symlinked from worktree data directories to the canonical /// dev data directory. Each entry becomes a single directory symlink. const SHARED_AGENT_DIRS: &[&str] = &["agents/teams"]; /// Returns `true` when `name` is a dev data dir name — i.e. it is exactly the /// canonical dev identifier or a worktree variant separated by a `.` (e.g. /// `xyz.block.buzz.app.dev.my-branch`). Rejects prefix-collisions such as /// `xyz.block.buzz.app.developer`. This is the authoritative dev/prod /// discriminator shared by `run_boot_migrations`, `sync_shared_agent_data`, /// and `reconcile_target_dir`. fn is_dev_data_dir_name(name: &str) -> bool { name == CANONICAL_DEV_IDENTIFIER || name .strip_prefix(CANONICAL_DEV_IDENTIFIER) .is_some_and(|rest| rest.starts_with('.')) } fn canonical_dev_data_dir(current: &Path) -> Option { current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER)) } fn legacy_app_data_dir(current: &Path) -> Option { let name = current.file_name()?.to_str()?; let legacy_name = if name.starts_with(CANONICAL_DEV_IDENTIFIER) { name.replacen(CANONICAL_DEV_IDENTIFIER, LEGACY_CANONICAL_DEV_IDENTIFIER, 1) } else if name.starts_with("xyz.block.buzz.app") { name.replacen("xyz.block.buzz.app", LEGACY_RELEASE_IDENTIFIER, 1) } else { return None; }; current.parent().map(|parent| parent.join(legacy_name)) } fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> { std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); let metadata = std::fs::symlink_metadata(&src_path)?; if metadata.file_type().is_symlink() { #[cfg(unix)] { let target = std::fs::read_link(&src_path)?; if dst_path.exists() || dst_path.is_symlink() { let _ = std::fs::remove_file(&dst_path); } crate::util::create_symlink(&target, &dst_path)?; } #[cfg(not(unix))] { continue; } } else if metadata.is_dir() { copy_dir_all(&src_path, &dst_path)?; } else if metadata.is_file() { if let Some(parent) = dst_path.parent() { std::fs::create_dir_all(parent)?; } if !dst_path.exists() { std::fs::copy(&src_path, &dst_path)?; } } } Ok(()) } /// Run every data migration that must complete before identity resolution and /// 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). pub fn run_boot_migrations(app: &tauri::AppHandle) { // Initialize the process-lifetime nest directory before any filesystem // operation that calls nest_dir(). The discriminator matches the existing // pattern used by reconcile_target_dir: dev instances have an app-data-dir // name starting with CANONICAL_DEV_IDENTIFIER. let is_dev = if let Ok(data_dir) = app.path().app_data_dir() { let dev = data_dir .file_name() .and_then(|n| n.to_str()) .is_some_and(is_dev_data_dir_name); crate::managed_agents::init_nest_dir(dev); dev } else { false }; // On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev BEFORE // control returns to lib.rs where resolve_repos_at_boot() reads it. This // ensures the dev nest boots with the correct workspace on its first launch, // matching what the prod nest had configured. Skip-if-dest-exists so it is // idempotent and never clobbers a value the dev nest already set explicitly. if is_dev { migrate_dev_repos_dir(); } migrate_legacy_app_data_dir(app); sync_shared_agent_data(app); // Dev-build-only: copy any agent keys that exist in the production // keyring ("buzz-desktop") into the dev service ("buzz-desktop-dev") // so existing agents don't lose their keys after the service-name split. // Must run after sync_shared_agent_data (JSON symlinked) and before // any load_managed_agents call (which runs hydrate_keys against the // dev service and would log "has no key" for un-migrated entries). #[cfg(debug_assertions)] if is_dev { crate::managed_agents::migrate_agent_keys_to_dev_service(app); } migrate_packs_to_teams(app); reconcile_persona_team_dirs(app); migrate_persona_provider_to_runtime(app); reconcile_legacy_command_names(app); // Fold personas.json into the unified store HERE: after the JSON-level // personas.json migrations above (which must see the legacy file), and // before every consumer of the load/save_personas shims below — // sync_team_personas would otherwise operate on an empty definition set. // Post-fold readers of the runtime map (`load_persona_runtimes`) fall // back to the unified store's definitions. fold_personas_into_agent_store(app); // B5: manufacture definitions for standalone agents AFTER the fold (so // pre-existing definition slugs are present for collision checks) and // before event sync republishes — the backfilled link is what flips the // 30177 projection to its slim shape. backfill_standalone_agents(app); if let Err(e) = crate::managed_agents::sync_team_personas(app) { eprintln!("buzz-desktop: sync-team-personas: {e}"); } reconcile_provider_mcp_commands(app); reconcile_databricks_v1_to_v2(app); materialize_agent_runtimes(app); } /// Copy one-time app state from the legacy app identifier directory to /// the current Buzz identifier directory. The Tauri identifier controls the app /// data path, so without this copy a product rename would look like a fresh /// install and users would lose their persisted identity and agent settings. pub fn migrate_legacy_app_data_dir(app: &tauri::AppHandle) { let current_dir = match app.path().app_data_dir() { Ok(dir) => dir, Err(e) => { eprintln!("buzz-desktop: app-data-migration: cannot resolve app data dir: {e}"); return; } }; let Some(legacy_dir) = legacy_app_data_dir(¤t_dir) else { return; }; if !legacy_dir.exists() { return; } match copy_dir_all(&legacy_dir, ¤t_dir) { Ok(()) => eprintln!( "buzz-desktop: app-data-migration: copied legacy data from {} to {}", legacy_dir.display(), current_dir.display() ), Err(error) => eprintln!( "buzz-desktop: app-data-migration: failed to copy {} to {}: {error}", legacy_dir.display(), current_dir.display() ), } } /// Knowledge directories and files carried from the legacy nest into the live /// nest. Deliberately excludes `REPOS/`: cloned repositories are re-clonable by /// definition (Will's stranded `REPOS/` measured 62 GB of checkouts plus build /// artifacts), so copying them would block desktop startup for minutes on every /// cold launch while recovering nothing the agent "remembers". Agents re-clone /// what they need into the live nest. The agent's accumulated knowledge — notes, /// plans, logs — is what must survive the rename, and it totals a few hundred KB. /// /// All entries are plain files or directories of plain files on the observed /// disk, so `copy_dir_all`'s symlink branch is not exercised. This is a /// content-dependent property, not a structural guarantee: `copy_dir_all` /// recurses with `symlink_metadata`, so a symlink later dropped into one of /// these dirs (e.g. by a skill writing into `.scratch/`) would hit that branch's /// clobber/abort hazard. The per-entry log-and-continue below bounds the blast /// radius of such a failure to the single offending entry. const LEGACY_NEST_KNOWLEDGE: &[&str] = &[ "AGENTS.md", "RESEARCH", "PLANS", "GUIDES", "WORK_LOGS", "OUTBOX", ".scratch", ]; /// Migrate the legacy agent nest (`~/.sprout`) into the current nest. /// /// PR #960 renamed the nest directory but shipped no migration, stranding the /// agent's accumulated knowledge in `~/.sprout` while `~/.buzz` booted empty — /// so agents searched `$HOME` for files they "remembered", triggering macOS TCC /// prompts. This copies only the knowledge directories (see /// [`LEGACY_NEST_KNOWLEDGE`]), never `REPOS/`. /// /// Non-fatal and idempotent, mirroring [`migrate_legacy_app_data_dir`]: a copy /// error is logged and never aborts startup. There is no completion sentinel — /// the migration re-runs on every launch while `~/.sprout` exists, which is /// cheap because the copy is tiny and `copy_dir_all` skips files that already /// exist in the destination. This relies on `REPOS/` being out of scope; if it /// is ever added back, a sentinel or off-thread copy becomes mandatory. /// /// Returns `true` when a legacy `~/.sprout` nest was present (migration ran), /// so the caller can emit a one-time hint inviting the user to delete it. The /// frontend dedupes the hint, so re-firing while `~/.sprout` lingers is benign. pub fn migrate_legacy_nest() -> bool { let Some(home) = dirs::home_dir() else { eprintln!("buzz-desktop: nest-migration: cannot resolve home directory"); return false; }; // Destination is the current build's nest dir (`.buzz` or `.buzz-dev`). let Some(current_nest) = crate::managed_agents::nest_dir() else { eprintln!("buzz-desktop: nest-migration: cannot resolve nest directory"); return false; }; migrate_legacy_nest_at(&home.join(".sprout"), ¤t_nest) } /// Copy the [`LEGACY_NEST_KNOWLEDGE`] entries from `legacy` to `current`. /// /// Each entry is copied independently with its own log-and-continue, so a /// failure on one entry never skips the rest. No-ops cleanly when `legacy` is /// absent or an entry does not exist. Returns `true` when `legacy` existed. fn migrate_legacy_nest_at(legacy: &Path, current: &Path) -> bool { if !legacy.exists() { return false; } for name in LEGACY_NEST_KNOWLEDGE { let src = legacy.join(name); if !src.exists() { continue; } let dst = current.join(name); let result = if src.is_dir() { copy_dir_all(&src, &dst) } else if *name == "AGENTS.md" { // `ensure_nest` writes a default `~/.buzz/AGENTS.md` before this // migration runs, so the plain absent-only guard would always skip // the legacy file and strand the user's instructions. Overwrite the // destination only when it is still the untouched generated default; // a user-edited file is left alone. copy_file_over_generated_default(&src, &dst) } else { copy_file_if_absent(&src, &dst) }; match result { Ok(()) => eprintln!( "buzz-desktop: nest-migration: migrated {} to {}", src.display(), dst.display() ), Err(error) => eprintln!( "buzz-desktop: nest-migration: failed to migrate {} to {}: {error}", src.display(), dst.display() ), } } true } /// Filename of the completion sentinel written after a successful dev-nest /// knowledge migration. Presence of this file means `~/.buzz` content has /// already been copied into `~/.buzz-dev` and subsequent boots can skip the /// copy. Using an explicit marker instead of checking for RESEARCH/PLANS /// content decouples the dev migration from the `.sprout` migration, which /// also copies into `~/.buzz-dev` and could otherwise set the sentinel early. const DEV_NEST_MIGRATED_SENTINEL: &str = ".dev-nest-migrated"; /// Copy the `.repos-dir` dotfile from `~/.buzz` → `~/.buzz-dev`, non-destructively. /// /// Must be called on dev builds BEFORE `resolve_repos_at_boot()` reads the /// dotfile, so the dev nest boots with the correct workspace configuration on /// its first launch. Skip-if-dest-exists so it is idempotent and never /// overwrites a value set directly by the dev nest. fn migrate_dev_repos_dir() { let Some(home) = dirs::home_dir() else { return; }; let src = home.join(".buzz").join(".repos-dir"); if !src.exists() { return; } let Some(dev_nest) = crate::managed_agents::nest_dir() else { return; }; let dst = dev_nest.join(".repos-dir"); // Skip if the dev nest already has its own .repos-dir. if dst.exists() { return; } // Ensure the dev nest directory itself exists — this migration runs before // ensure_nest() in the boot sequence, so the directory may not yet exist. if let Err(e) = std::fs::create_dir_all(&dev_nest) { eprintln!( "buzz-desktop: dev-nest-migration: failed to create dev nest {}: {e}", dev_nest.display() ); return; } match std::fs::copy(&src, &dst) { Ok(_) => eprintln!( "buzz-desktop: dev-nest-migration: migrated .repos-dir to {}", dst.display() ), Err(e) => eprintln!("buzz-desktop: dev-nest-migration: failed to migrate .repos-dir: {e}"), } } /// One-time migration of dev-build nest contents from `~/.buzz` → `~/.buzz-dev`. /// /// When a dev build first boots after this change ships, it switches from the /// shared `~/.buzz` nest to a dedicated `~/.buzz-dev` nest. Without migration, /// all accumulated knowledge (RESEARCH/, PLANS/, GUIDES/, WORK_LOGS/, mem_* /// slugs, AGENTS.md, managed-agents.json) would be invisible to dev instances. /// /// Migration is non-destructive: `copy_dir_all` skips files already at the /// destination, so a partially-migrated state is safe to re-run. The source /// `~/.buzz` is never deleted — prod builds continue to use it normally. /// /// Completion is tracked by a [`DEV_NEST_MIGRATED_SENTINEL`] file written into /// `~/.buzz-dev`. Using an explicit sentinel (rather than RESEARCH/PLANS file /// presence) decouples this migration from the `.sprout` → `~/.buzz-dev` /// migration that runs earlier in the same boot, which might otherwise populate /// RESEARCH/PLANS and incorrectly suppress the `~/.buzz` copy. /// /// Only runs on dev builds (checked by the caller). Returns `true` when /// contents were copied (useful for a one-time log message, not required). pub fn migrate_dev_nest() -> bool { let Some(home) = dirs::home_dir() else { eprintln!("buzz-desktop: dev-nest-migration: cannot resolve home directory"); return false; }; let legacy = home.join(".buzz"); let current = home.join(".buzz-dev"); // If legacy doesn't exist, nothing to migrate. if !legacy.exists() { return false; } // Skip if migration has already run (explicit sentinel, not content-based). if current.join(DEV_NEST_MIGRATED_SENTINEL).exists() { return false; } let copied = migrate_legacy_nest_at(&legacy, ¤t); // Write the sentinel so future boots skip the copy. Non-fatal if it fails // — worst case we re-run the (idempotent) migration on the next boot. if copied { let sentinel = current.join(DEV_NEST_MIGRATED_SENTINEL); if let Err(e) = std::fs::write(&sentinel, "") { eprintln!( "buzz-desktop: dev-nest-migration: failed to write sentinel {}: {e}", sentinel.display() ); } } copied } /// Copy a single file only if the destination does not already exist, matching /// `copy_dir_all`'s non-destructive guard for top-level files (e.g. `AGENTS.md`). fn copy_file_if_absent(src: &Path, dst: &Path) -> std::io::Result<()> { if dst.exists() { return Ok(()); } if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent)?; } std::fs::copy(src, dst).map(|_| ()) } /// Copy `src` over `dst` when `dst` is absent or still the untouched generated /// default `AGENTS.md` (byte-equal to the embedded template). A user-edited /// destination — or an older default left by a since-bumped template — is /// preserved. /// /// On a first-time migration `ensure_nest` has just written the generated /// default, so `copy_file_if_absent` would always skip the legacy file and /// strand the user's instructions. This lets the legacy `AGENTS.md` win over /// that pristine default while never clobbering content a user has changed. fn copy_file_over_generated_default(src: &Path, dst: &Path) -> std::io::Result<()> { if dst.exists() { let current = std::fs::read_to_string(dst)?; if current != crate::managed_agents::AGENTS_MD { return Ok(()); } } if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent)?; } 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. 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; }; let Ok(mut records) = serde_json::from_str::>(&content) else { eprintln!( "buzz-desktop: patch-json-records: failed to parse {}", path.display() ); return; }; let mut changed = false; for record in &mut records { if let Some(obj) = record.as_object_mut() { changed |= f(obj); } } 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}"); } } } } /// Create symlinks for shared agent data files from the current (worktree) /// data directory to the canonical dev data directory. /// /// Guards: /// - `BUZZ_SHARE_IDENTITY` must be `"1"` /// - `BUZZ_PRIVATE_KEY` must parse as valid `nostr::Keys` /// - The canonical dir must differ from the current dir (skip if we ARE canonical) /// - The canonical dir must exist pub fn sync_shared_agent_data(app: &tauri::AppHandle) { // Guard: only runs when sharing identity with a worktree. let is_shared = std::env::var("BUZZ_SHARE_IDENTITY") .map(|v| v == "1") .unwrap_or(false); if !is_shared { return; } // Guard: BUZZ_PRIVATE_KEY must be a valid nostr key. let has_valid_key = std::env::var("BUZZ_PRIVATE_KEY") .ok() .and_then(|k| k.parse::().ok()) .is_some(); if !has_valid_key { eprintln!("buzz-desktop: shared-agent-sync: BUZZ_PRIVATE_KEY missing or invalid, skipping"); return; } let current_dir = match app.path().app_data_dir() { Ok(dir) => dir, Err(e) => { eprintln!("buzz-desktop: shared-agent-sync: cannot resolve app data dir: {e}"); return; } }; // Guard: refuse to sync against a prod-identifier data directory, regardless // of env vars. A release build launched from an env-armed shell (e.g. macOS // `open` inherits the caller's env) must never overwrite real prod files with // symlinks. Only data dirs whose name starts with CANONICAL_DEV_IDENTIFIER // (the canonical dev dir and all worktree variants) are safe targets. let is_dev = current_dir .file_name() .and_then(|n| n.to_str()) .is_some_and(is_dev_data_dir_name); if !is_dev { eprintln!( "buzz-desktop: shared-agent-sync: skipping — data dir is not a dev dir ({})", current_dir.display() ); return; } let canonical_dir = match canonical_dev_data_dir(¤t_dir) { Some(dir) => dir, None => { eprintln!("buzz-desktop: shared-agent-sync: cannot compute canonical dir (no parent)"); return; } }; // Guard: skip if we ARE the canonical instance. // Use canonicalize to handle case-insensitive FS and symlinks. let current_canonical = std::fs::canonicalize(¤t_dir).unwrap_or_else(|_| current_dir.clone()); let source_canonical = std::fs::canonicalize(&canonical_dir).unwrap_or_else(|_| canonical_dir.clone()); if current_canonical == source_canonical { return; } // Guard: skip if canonical dir doesn't exist. if !canonical_dir.exists() { eprintln!( "buzz-desktop: shared-agent-sync: canonical dir does not exist: {}", canonical_dir.display() ); 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 // to individual files. Without this, a fresh write in a worktree is never // promoted to canonical and gets clobbered by the symlink step. for rel in SHARED_AGENT_FILES { let canonical_file = canonical_dir.join(rel); if canonical_file.exists() { continue; } let Some(parent) = canonical_dir.parent() else { continue; }; let Ok(entries) = std::fs::read_dir(parent) else { continue; }; for entry in entries.flatten() { let sibling = entry.path(); if sibling == canonical_dir { continue; } let sibling_file = sibling.join(rel); if sibling_file.is_file() && !sibling_file.is_symlink() { if let Some(file_parent) = canonical_file.parent() { if let Err(e) = std::fs::create_dir_all(file_parent) { eprintln!( "buzz-desktop: shared-agent-sync: failed to create {}: {e}", file_parent.display() ); break; } } let _ = std::fs::rename(&sibling_file, &canonical_file); eprintln!( "buzz-desktop: shared-agent-sync: seeded {rel} from {}", sibling.display() ); break; } } } let mut synced = 0u32; for rel in SHARED_AGENT_FILES { let src = canonical_dir.join(rel); let dst = current_dir.join(rel); if !src.exists() { continue; } if let Some(parent) = dst.parent() { if let Err(e) = std::fs::create_dir_all(parent) { eprintln!( "buzz-desktop: shared-agent-sync: failed to create {}: {e}", parent.display() ); continue; } } synced += replace_with_symlink(&src, &dst); } // Ensure shared directories exist in canonical before symlinking. // Packs may have been installed in a sibling instance (e.g., `.main`) // before shared-dir syncing existed — migrate them to canonical. for rel in SHARED_AGENT_DIRS { let canonical_target = canonical_dir.join(rel); if !canonical_target.exists() { if let Err(e) = std::fs::create_dir_all(&canonical_target) { eprintln!( "buzz-desktop: shared-agent-sync: failed to create {}: {e}", canonical_target.display() ); } // Migrate from whichever sibling has real (non-symlink) content. if let Some(parent) = canonical_dir.parent() { if let Ok(entries) = std::fs::read_dir(parent) { for entry in entries.flatten() { let sibling = entry.path(); if sibling == canonical_dir { continue; } let sibling_dir = sibling.join(rel); if sibling_dir.is_dir() && !sibling_dir.is_symlink() { if let Ok(children) = std::fs::read_dir(&sibling_dir) { for child in children.flatten() { let dest = canonical_target.join(child.file_name()); if !dest.exists() { let _ = std::fs::rename(child.path(), &dest); } } } // Replace the sibling's dir with a symlink to canonical. // replace_with_symlink backs up any leftover real content. replace_with_symlink(&canonical_target, &sibling_dir); eprintln!( "buzz-desktop: shared-agent-sync: migrated {rel} from {}", sibling.display() ); break; } } } } } } for rel in SHARED_AGENT_DIRS { let src = canonical_dir.join(rel); let dst = current_dir.join(rel); if !src.exists() { continue; } if let Some(parent) = dst.parent() { if let Err(e) = std::fs::create_dir_all(parent) { eprintln!( "buzz-desktop: shared-agent-sync: failed to create {}: {e}", parent.display() ); continue; } } synced += replace_with_symlink(&src, &dst); } if synced > 0 { eprintln!( "buzz-desktop: shared-agent-sync: {synced} item(s) linked to {}", canonical_dir.display() ); } } fn reconcile_team_dirs_in_file(path: &Path, target_dir: &Path) { // Build per-component so the persisted value uses native separators on // every platform, matching fresh writes (agents.rs builds the same path as // base.join("teams").join(id)). A single join("agents/teams") would embed a // literal '/' on Windows, persisting a mixed-separator path into the store. let target_teams = target_dir.join("agents").join("teams"); patch_json_records(path, |obj| { // Handle both old field name and new field name let field_name = if obj.contains_key("persona_team_dir") { "persona_team_dir" } else if obj.contains_key("persona_pack_path") { "persona_pack_path" } else { return false; }; let team_path = match obj.get(field_name).and_then(|v| v.as_str()) { Some(p) => p, None => return false, }; let team_path = Path::new(team_path); // Extract the team ID from the path (component after "teams" or "packs") let mut found_dir = false; let mut team_id: Option<&std::ffi::OsStr> = None; for component in team_path.components() { if found_dir { team_id = Some(component.as_os_str()); break; } if component.as_os_str() == "teams" || component.as_os_str() == "packs" { found_dir = true; } } let Some(id) = team_id else { return false; }; let expected = target_teams.join(id); if team_path == expected { // Value already correct — still normalize the legacy field name so // stores converge on `persona_team_dir` (runtime reads either via // serde alias). if field_name == "persona_pack_path" { if let Some(val) = obj.remove("persona_pack_path") { obj.insert("persona_team_dir".to_string(), val); return true; } } return false; } // Rewriting to a path that does not exist on disk makes things worse // than leaving a stale-but-working path in place. fs::metadata follows // symlinks, so a valid symlinked install passes; a dangling symlink // fails with NotFound. if let Err(e) = std::fs::metadata(&expected) { eprintln!( "buzz-desktop: team-dir-reconcile: {:?}: {:?} expected at {:?} — {e}, leaving as-is", obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), team_path, expected, ); return false; } let Some(expected_str) = expected.to_str() else { eprintln!( "buzz-desktop: team-dir-reconcile: {:?}: expected path {:?} is not valid UTF-8, leaving as-is", obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), expected, ); return false; }; eprintln!( "buzz-desktop: team-dir-reconcile: {:?}: {:?} → {:?}", obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), team_path, expected, ); // Always write the new field name obj.remove("persona_pack_path"); obj.insert( "persona_team_dir".to_string(), serde_json::Value::String(expected_str.to_owned()), ); true }); } /// Select the data directory to reconcile against. /// /// Dev instances — identified by the data-dir name starting with /// `CANONICAL_DEV_IDENTIFIER` (covers the canonical dir itself and any /// worktree variant like `xyz.block.buzz.app.dev.mybranch`) — share /// `agents/managed-agents.json` and `agents/teams` via symlinks to the /// canonical dev dir, so they should normalize against that canonical dir. /// /// Release builds must reconcile their own data dir — keying off the canonical /// dev dir's mere existence would leave release records permanently stale on /// developer machines, where that dir is always present. fn reconcile_target_dir(current_dir: &Path) -> PathBuf { let is_dev_instance = current_dir .file_name() .and_then(|n| n.to_str()) .is_some_and(is_dev_data_dir_name); if is_dev_instance { match canonical_dev_data_dir(current_dir) { Some(dir) if dir.exists() => dir, _ => current_dir.to_path_buf(), } } else { current_dir.to_path_buf() } } /// Reconcile `persona_team_dir` (and legacy `persona_pack_path`) values in /// managed-agents.json to point to the correct `agents/teams/` prefix. /// /// Fixes two classes of stale paths: /// - Worktree dev instances whose records point at a sibling data dir rather /// than the canonical dev dir (dev instances share managed-agents.json and /// agents/teams via symlinks, so they all reconcile against the canonical dir). /// - Legacy paths left by historical renames: `agents/packs/` → `agents/teams/` /// (the packs→teams consolidation) and bundle-id `xyz.block.sprout.app` → /// `xyz.block.buzz.app` (the sprout→buzz rename, which moved the app data dir). /// /// Release builds reconcile their own data dir — choosing the canonical dev dir /// whenever it exists would leave release files permanently stale on developer /// machines. pub fn reconcile_persona_team_dirs(app: &tauri::AppHandle) { let Ok(current_dir) = app.path().app_data_dir() else { return; }; // Single-dir on purpose: unlike reconcile_legacy_command_names and // reconcile_provider_mcp_commands, which patch both [current, canonical], // path rewrites are target-dependent — a dual pass through a dev // instance's symlinked store would write worktree-local paths into the // shared canonical file. let target_dir = reconcile_target_dir(¤t_dir); let path = target_dir.join("agents/managed-agents.json"); if !path.exists() { return; } reconcile_team_dirs_in_file(&path, &target_dir); } /// One-time migration from packs to teams. /// /// Runs on app launch if `agents/packs/` exists or if any record in /// `managed-agents.json` still uses the old `persona_pack_path` field name. /// Steps (in order, each individually idempotent): /// /// 1. Rename `agents/packs/` → `agents/teams/` on disk /// 2. Rewrite `personas.json`: `source_pack` → `source_team`, `source_pack_persona_slug` → `source_team_persona_slug` /// 3. Rewrite `managed-agents.json`: `persona_pack_path` → `persona_team_dir` (with `/packs/` → `/teams/` path fix), `persona_name_in_pack` → `persona_name_in_team` pub fn migrate_packs_to_teams(app: &tauri::AppHandle) { use crate::managed_agents::MigrationReport; let Ok(current_dir) = app.path().app_data_dir() else { return; }; let target_dir = reconcile_target_dir(¤t_dir); let packs_dir = target_dir.join("agents/packs"); let teams_dir = target_dir.join("agents/teams"); let personas_path = target_dir.join("agents/personas.json"); let agents_path = target_dir.join("agents/managed-agents.json"); // Check if migration is needed: packs dir exists OR agents JSON has old field names let packs_dir_exists = packs_dir.exists() && !packs_dir.is_symlink(); let has_old_fields = agents_path.exists() && std::fs::read_to_string(&agents_path) .map(|c| c.contains("persona_pack_path")) .unwrap_or(false); let personas_has_old_fields = personas_path.exists() && std::fs::read_to_string(&personas_path) .map(|c| c.contains("\"source_pack\"")) .unwrap_or(false); if !packs_dir_exists && !has_old_fields && !personas_has_old_fields { return; } let mut report = MigrationReport { packs_migrated: 0, personas_updated: 0, agents_updated: 0, errors: Vec::new(), }; // Step 1: Rename directory agents/packs/ → agents/teams/ if packs_dir_exists { if teams_dir.exists() { // Merge: move contents from packs into teams, skip conflicts if let Ok(entries) = std::fs::read_dir(&packs_dir) { for entry in entries.flatten() { let dest = teams_dir.join(entry.file_name()); if !dest.exists() { if let Err(e) = std::fs::rename(entry.path(), &dest) { report .errors .push(format!("failed to move {:?}: {e}", entry.file_name())); } else { report.packs_migrated += 1; } } } } // Remove packs dir only if empty (external tools like ai-rules // may have recreated symlinks here between migration runs) let _ = std::fs::remove_dir(&packs_dir); } else { // Simple rename if let Some(parent) = teams_dir.parent() { let _ = std::fs::create_dir_all(parent); } match std::fs::rename(&packs_dir, &teams_dir) { Ok(_) => { if let Ok(entries) = std::fs::read_dir(&teams_dir) { report.packs_migrated = entries.count(); } } Err(e) => { report .errors .push(format!("failed to rename packs → teams: {e}")); eprintln!("buzz-desktop: packs→teams migration: directory rename failed: {e}"); return; } } } } // Step 2: Rewrite personas.json field names if personas_path.exists() { patch_json_records(&personas_path, |obj| { let mut changed = false; if let Some(val) = obj.remove("source_pack") { obj.insert("source_team".to_string(), val); changed = true; } if let Some(val) = obj.remove("source_pack_persona_slug") { obj.insert("source_team_persona_slug".to_string(), val); changed = true; } if changed { report.personas_updated += 1; } changed }); } // Step 3: Rewrite managed-agents.json field names and paths if agents_path.exists() { patch_json_records(&agents_path, |obj| { let mut changed = false; if let Some(val) = obj.remove("persona_pack_path") { // Also fix the path: replace /packs/ with /teams/ let new_val = if let Some(s) = val.as_str() { serde_json::Value::String(s.replace("/packs/", "/teams/")) } else { val }; obj.insert("persona_team_dir".to_string(), new_val); changed = true; } if let Some(val) = obj.remove("persona_name_in_pack") { obj.insert("persona_name_in_team".to_string(), val); changed = true; } if changed { report.agents_updated += 1; } changed }); } if report.packs_migrated > 0 || report.personas_updated > 0 || report.agents_updated > 0 { eprintln!( "buzz-desktop: packs→teams migration complete: {} dirs, {} personas, {} agents{}", report.packs_migrated, report.personas_updated, report.agents_updated, if report.errors.is_empty() { String::new() } else { format!(" ({} errors)", report.errors.len()) } ); } } fn reconcile_mcp_commands_in_file(path: &Path) { // 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 // 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| { let override_cmd = obj .get("agent_command_override") .and_then(|v| v.as_str()) .map(str::trim) .filter(|v| !v.is_empty()); let snapshot = obj.get("agent_command").and_then(|v| v.as_str()); let persona_cmd = obj .get("persona_id") .and_then(|v| v.as_str()) .and_then(|pid| persona_runtimes.get(pid)) .map(String::as_str) .and_then(crate::managed_agents::known_acp_runtime_exact) .and_then(|r| r.commands.first().copied()); let effective_command = match override_cmd.or(persona_cmd).or(snapshot) { Some(cmd) => cmd.to_string(), None => return false, }; let Some(runtime) = crate::managed_agents::known_acp_runtime(&effective_command) else { return false; }; let expected = runtime.mcp_command.unwrap_or(""); let current = obj .get("mcp_command") .and_then(|v| v.as_str()) .unwrap_or(""); if current == expected { return false; } // Only fix values that are clearly stale (empty or a removed binary). // Leave user-customized values untouched. if !current.is_empty() && current != "buzz-mcp-server" { return false; } eprintln!( "buzz-desktop: runtime-reconcile: {:?} ({:?}): mcp_command {:?} → {:?}", obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), effective_command, current, expected, ); obj.insert( "mcp_command".to_string(), serde_json::Value::String(expected.to_string()), ); true }); } fn replace_command_field( obj: &mut serde_json::Map, field: &str, replacement: String, ) -> bool { let Some(current) = obj.get(field).and_then(|v| v.as_str()) else { return false; }; if current == replacement { return false; } eprintln!( "buzz-desktop: command-rename-reconcile: {:?}: {field} {:?} → {:?}", obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), current, replacement, ); obj.insert(field.to_string(), serde_json::Value::String(replacement)); true } fn reconcile_legacy_command_names_in_file(path: &Path) { patch_json_records(path, |obj| { let mut changed = false; if let Some(acp_command) = obj .get("acp_command") .and_then(|v| v.as_str()) .map(str::to_string) { if acp_command == "sprout-acp" { changed |= replace_command_field(obj, "acp_command", "buzz-acp".to_string()); } } let mut agent_command = obj .get("agent_command") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if agent_command == "sprout-agent" { agent_command = "buzz-agent".to_string(); changed |= replace_command_field(obj, "agent_command", agent_command.clone()); } if let Some(mcp_command) = obj .get("mcp_command") .and_then(|v| v.as_str()) .map(str::to_string) { match mcp_command.as_str() { "sprout-dev-mcp" => { changed |= replace_command_field(obj, "mcp_command", "buzz-dev-mcp".to_string()); } "sprout-mcp" | "sprout-mcp-server" | "buzz-mcp-server" => { let replacement = if agent_command == "buzz-agent" { "buzz-dev-mcp" } else { "" }; changed |= replace_command_field(obj, "mcp_command", replacement.to_string()); } _ => {} } } changed }); } 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 }); } 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}")) } 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. 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). 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) { use crate::managed_agents::is_derived_provider_model_key; patch_json_records(path, |obj| { let mut changed = false; // Only rewrite the structured provider field when the baked build env // marks this as a Block build (BUZZ_AGENT_PROVIDER == "databricks_v2"). // OSS users may intentionally select V1 (Model Serving), so we must not // silently migrate their provider to V2 (AI Gateway). if rewrite_v1_provider && obj.get("provider").and_then(|v| v.as_str()) == Some("databricks") { let name = obj .get("name") .and_then(|v| v.as_str()) .unwrap_or("?") .to_string(); eprintln!( "buzz-desktop: databricks-v1-to-v2: {name:?}: provider \"databricks\" → \"databricks_v2\"", ); obj.insert( "provider".to_string(), serde_json::Value::String("databricks_v2".to_string()), ); // Also clear the model field — a V1 model name (e.g. "dbrx-instruct") // on a V2 provider would shadow the baked DATABRICKS_MODEL at spawn time // (BUZZ_AGENT_MODEL from runtime_metadata_env_vars takes priority in // buzz-agent config.rs). Clearing it lets the baked V2 default win. if obj.remove("model").is_some() { eprintln!( "buzz-desktop: databricks-v1-to-v2: {name:?}: cleared stale V1 model field", ); } changed = true; } // Strip derived provider/model keys from env_vars on ALL records, // regardless of rewrite_v1_provider. These keys are re-derived from // structured fields at spawn time; stale copies in env_vars silently // override the structured fields (last-write-wins in Command::env) and // can cause V1 routing even when the provider dropdown shows V2. // // The check is case-insensitive (matching the established helper) // to cover any case-variant that may have been written historically. if let Some(serde_json::Value::Object(env_vars)) = obj.get_mut("env_vars") { let stale_keys: Vec = env_vars .keys() .filter(|k| is_derived_provider_model_key(k)) .cloned() .collect(); for key in stale_keys { env_vars.remove(key.as_str()); eprintln!("buzz-desktop: databricks-v1-to-v2: removed stale env_vars[\"{key}\"]",); changed = true; } } changed }); } /// Strip stale derived provider/model keys from `env_vars` in all /// managed-agent records, and — on Block builds — also migrate any persisted /// `provider: "databricks"` to `"databricks_v2"`. /// /// **Block builds** (where `baked_build_env()` contains /// `BUZZ_AGENT_PROVIDER=databricks_v2`): the structured `provider` field is /// rewritten V1→V2 because the baked release targets V2 exclusively. Records /// that were saved before this migration would otherwise silently override the /// baked value at spawn time (last-write-wins in `Command::env`). /// /// **OSS builds** (baked env empty): the `provider` field is left alone — /// V1 (`databricks`) is a valid Model Serving choice for OSS users. /// /// In both cases, stale `BUZZ_AGENT_PROVIDER` / `BUZZ_AGENT_MODEL` / /// `GOOSE_PROVIDER` / `GOOSE_MODEL` are stripped from `env_vars`. These keys /// are always re-derived from structured fields at spawn time; persisted copies /// silence UI edits and cause stale routing. /// /// 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`. 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 obj.contains_key("runtime") { return false; } if let Some(value) = obj.remove("provider") { obj.insert("runtime".to_string(), value); true } else { false } }); } pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) { let Ok(dir) = app.path().app_data_dir() else { return; }; let path = dir.join("agents/personas.json"); if !path.exists() { return; } rename_provider_to_runtime_in_personas(&path); } mod materialize; pub use materialize::materialize_agent_runtimes; mod fold; pub use fold::fold_personas_into_agent_store; use fold::load_persona_runtimes; mod backfill; pub use backfill::backfill_standalone_agents; #[cfg(test)] #[path = "migration_test_support.rs"] mod test_support; #[cfg(test)] #[path = "migration_tests.rs"] mod tests; #[cfg(test)] #[path = "migration_command_tests.rs"] mod command_tests; #[cfg(test)] #[path = "migration_databricks_tests.rs"] mod databricks_tests; #[cfg(test)] #[path = "migration_team_dir_tests.rs"] mod team_dir_tests; #[cfg(test)] #[path = "migration_sync_guard_tests.rs"] mod sync_guard_tests;