//! 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; 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"]; /// Create a symlink at `dst` pointing to `src`. /// /// Worktree sync is a dev-only feature (`BUZZ_SHARE_IDENTITY=1`); on Windows /// this is a no-op so the rest of `sync_shared_agent_data` keeps compiling and /// running harmlessly. #[cfg(unix)] fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> { std::os::unix::fs::symlink(src, dst) } #[cfg(not(unix))] fn symlink(_src: &Path, _dst: &Path) -> std::io::Result<()> { Ok(()) } 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); } std::os::unix::fs::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(()) } /// Reconcile personas and teams into signed retention events. Both readers /// consume the already-synced `personas.json`/`teams.json` that /// `sync_team_personas` wrote in [`run_boot_migrations`] (see its `# Ordering` /// guard). Event signing needs the resolved owner keys, so this runs after /// identity resolution, not in [`run_boot_migrations`]. pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { migrate_personas_to_events(app, owner_keys); migrate_teams_to_events(app, owner_keys); } /// 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 /// [`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) { migrate_legacy_app_data_dir(app); sync_shared_agent_data(app); migrate_packs_to_teams(app); reconcile_persona_team_dirs(app); migrate_persona_provider_to_runtime(app); reconcile_legacy_command_names(app); if let Err(e) = crate::managed_agents::sync_team_personas(app) { eprintln!("buzz-desktop: sync-team-personas: {e}"); } reconcile_provider_mcp_commands(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 (`~/.buzz`). /// /// 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; }; migrate_legacy_nest_at(&home.join(".sprout"), &home.join(".buzz")) } /// 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 } /// 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; } }; 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; } } // Already a correct symlink — nothing to do. if dst.is_symlink() { if let Ok(target) = std::fs::read_link(&dst) { if target == src { continue; } } } // Remove whatever's at dst (regular file, wrong symlink, broken symlink). if dst.exists() || dst.is_symlink() { let _ = std::fs::remove_file(&dst); } match symlink(&src, &dst) { Ok(_) => synced += 1, Err(e) => { eprintln!("buzz-desktop: shared-agent-sync: failed to symlink {rel}: {e}"); } } } // 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. let _ = std::fs::remove_dir_all(&sibling_dir); let _ = 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; } } if dst.is_symlink() { if let Ok(target) = std::fs::read_link(&dst) { if target == src { continue; } } } if dst.is_symlink() { let _ = std::fs::remove_file(&dst); } else if dst.exists() { let _ = std::fs::remove_dir_all(&dst); } match symlink(&src, &dst) { Ok(_) => synced += 1, Err(e) => { eprintln!("buzz-desktop: shared-agent-sync: failed to symlink {rel}: {e}"); } } } 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(|n| n.starts_with(CANONICAL_DEV_IDENTIFIER)); 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 }); } /// Build a `persona_id → runtime` map from the personas.json sibling of the /// given managed-agents.json path. Returns an empty map when personas can't be /// read or parsed — callers then fall back to the record's own snapshot. fn load_persona_runtimes(agents_path: &Path) -> std::collections::HashMap { let mut map = std::collections::HashMap::new(); let Some(personas_path) = agents_path.parent().map(|dir| dir.join("personas.json")) else { return map; }; let Ok(content) = std::fs::read_to_string(&personas_path) else { return map; }; let Ok(records) = serde_json::from_str::>(&content) else { return map; }; for record in records { if let (Some(id), Some(runtime)) = ( record.get("id").and_then(|v| v.as_str()), record.get("runtime").and_then(|v| v.as_str()), ) { map.insert(id.to_string(), runtime.to_string()); } } map } 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 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); } /// Reconcile `personas.json` into the persona-event retention store. /// /// Must run AFTER `migrate_packs_to_teams` (depends on field renames being /// complete) and AFTER the persisted identity is resolved (it signs every /// retained event with the owner's keys). /// /// Per-record reconcile: for each non-builtin persona it compares the freshly /// serialized event content against the retained row at the same coordinate /// and re-retains (marking `pending_sync = 1`) only when the row is absent or /// its content differs. An unchanged persona is left untouched, so a launch /// after a no-op edit does not churn `pending_sync`; a persona added or edited /// on disk between launches is picked up and republished. There is no /// whole-store sentinel — comparing per coordinate is what lets newly added /// personas reach the relay. /// /// Strategy: write to local SQLite retention first (durable copy), mark as /// `pending_sync = 1` for later relay publish. Migration succeeds on local /// write, not relay acknowledgment. Every retained row is a real signed /// event — there is no placeholder path. pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { use crate::managed_agents::managed_agents_base_dir; let Ok(base_dir) = managed_agents_base_dir(app) else { return; }; match migrate_personas_in_dir(&base_dir, keys) { Ok(0) => {} Ok(migrated) => { eprintln!( "buzz-desktop: persona-event-migration: {migrated} personas migrated to retention" ); } Err(e) => { eprintln!("buzz-desktop: persona-event-migration: {e}"); } } } /// Core reconcile logic, decoupled from the Tauri `AppHandle` for testing. /// /// Returns the number of personas (re)written to the retention store. Returns /// `Ok(0)` when every non-builtin persona already has a matching retained row /// (or there are none to reconcile). fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { use crate::managed_agents::{ persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, PersonaRecord, }; use buzz_core_pkg::kind::KIND_PERSONA; use nostr::JsonUtil; let pubkey = keys.public_key().to_hex(); // Read personas.json fresh at reconcile time. Nothing to do if absent. let personas_path = base_dir.join("personas.json"); if !personas_path.exists() { return Ok(0); } let content = std::fs::read_to_string(&personas_path) .map_err(|e| format!("failed to read personas.json: {e}"))?; let records: Vec = serde_json::from_str(&content) .map_err(|e| format!("failed to parse personas.json: {e}"))?; if records.is_empty() { return Ok(0); } // Open (or create) the retention database. let db_path = base_dir.join("retention.db"); let conn = open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; let mut migrated = 0u32; for record in &records { // Skip built-in personas — they're always available from code. if record.is_builtin { continue; } let d_tag = persona_d_tag(record); // Fetch the retained head first so the rebuilt event can supersede it: // build at the default `now` and a future-dated head (clock skew, or an // interactive same-second `max(now, head+1)` bump) would make // `retain_event`'s `created_at >= ...` guard SILENTLY skip the UPDATE // while `migrated` over-reports. Mirror the interactive sites' monotonic // bump (F1) so a changed body always lands. let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?; let event = build_persona_event(record) .map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))? .custom_created_at(monotonic_created_at( existing.as_ref().map(|row| row.created_at), )) .sign_with_keys(keys) .map_err(|e| format!("failed to sign event for '{}': {e}", record.display_name))?; // Per-coordinate reconcile: skip when an identical body is already // retained, so an unchanged persona doesn't reset `pending_sync`. // Content is timestamp-independent, so the monotonic bump above never // forces a spurious republish. let event_content = event.content.to_string(); if existing .as_ref() .is_some_and(|row| row.content == event_content) { continue; } let retained = RetainedEvent { kind: KIND_PERSONA, pubkey: pubkey.clone(), d_tag, content: event_content, // Safety: nostr timestamps are seconds and stay below i64::MAX // until year 2262. created_at: event.created_at.as_secs() as i64, raw_event: event.as_json(), pending_sync: true, }; // The monotonic bump guarantees `created_at > head`, so the upsert's // `>=` guard always lands the UPDATE — `migrated` counts only real, // retained republishes. retain_event(&conn, &retained) .map_err(|e| format!("failed to retain '{}': {e}", record.display_name))?; migrated += 1; } Ok(migrated) } /// Reconcile `teams.json` into kind:30176 team events in the retention store. /// /// Mirrors [`migrate_personas_to_events`] for teams: it picks up team metadata /// edits (name/description/persona_ids) made on disk between launches and /// queues them for relay publish. Managed agents (kind:30177) are deliberately /// NOT reconciled here — they have no pack/dir source and are backfilled from /// `managed-agents.json` elsewhere. /// /// Must run after the persisted identity is resolved (it signs each event with /// the owner's keys). pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { use crate::managed_agents::managed_agents_base_dir; let Ok(base_dir) = managed_agents_base_dir(app) else { return; }; match migrate_teams_in_dir(&base_dir, keys) { Ok(0) => {} Ok(migrated) => { eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention"); } Err(e) => { eprintln!("buzz-desktop: team-event-migration: {e}"); } } } /// Core team reconcile logic, decoupled from the Tauri `AppHandle` for testing. /// /// Returns the number of teams (re)written to the retention store. The /// per-coordinate content compare matches [`migrate_personas_in_dir`]: an /// unchanged team is skipped so a launch does not churn `pending_sync`. fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { use crate::managed_agents::{ persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, team_events::build_team_event, TeamRecord, }; use buzz_core_pkg::kind::KIND_TEAM; use nostr::JsonUtil; let pubkey = keys.public_key().to_hex(); let teams_path = base_dir.join("teams.json"); if !teams_path.exists() { return Ok(0); } let content = std::fs::read_to_string(&teams_path) .map_err(|e| format!("failed to read teams.json: {e}"))?; let records: Vec = serde_json::from_str(&content).map_err(|e| format!("failed to parse teams.json: {e}"))?; if records.is_empty() { return Ok(0); } let db_path = base_dir.join("retention.db"); let conn = open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; let mut migrated = 0u32; for record in &records { // Skip built-in teams — they're always available from code. if record.is_builtin { continue; } // Team d-tag is the team id (team_events.rs: no slug fallback). let d_tag = record.id.clone(); // Fetch the head first so the monotonic bump can supersede a // future-dated head — see migrate_personas_in_dir (F1/F8). let existing = get_retained_event(&conn, KIND_TEAM, &pubkey, &d_tag)?; let event = build_team_event(record) .map_err(|e| format!("failed to build event for team '{}': {e}", record.name))? .custom_created_at(monotonic_created_at( existing.as_ref().map(|row| row.created_at), )) .sign_with_keys(keys) .map_err(|e| format!("failed to sign event for team '{}': {e}", record.name))?; let event_content = event.content.to_string(); if existing .as_ref() .is_some_and(|row| row.content == event_content) { continue; } let retained = RetainedEvent { kind: KIND_TEAM, pubkey: pubkey.clone(), d_tag, content: event_content, created_at: event.created_at.as_secs() as i64, raw_event: event.as_json(), pending_sync: true, }; // Monotonic bump guarantees the upsert UPDATE lands — `migrated` counts // only real republishes. retain_event(&conn, &retained) .map_err(|e| format!("failed to retain team '{}': {e}", record.name))?; migrated += 1; } Ok(migrated) } #[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_team_dir_tests.rs"] mod team_dir_tests; #[cfg(test)] #[path = "migration_team_events_tests.rs"] mod team_events_tests;