Files
buzz/desktop/src-tauri/src/migration.rs
T
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7andWill Pfleger 40607d4a88 fix(desktop): pass-1 review corrections (round 2) — compensation, Mesh stop, scope, CI
Item 1 — Compensation primitive: replace AtomicBool gate with lock-owning
compensate_drain that takes the caller's already-held rt_transition guard
by value, re-acquires only the store lock, validates captured scope generation,
then restores journal entries via start_pair_under_held_locks. Split
start_pair_under_held_locks out of start_pair so both the normal and
compensation paths share the spawn-and-register body. execute_drain_journal
refactored to accept an injectable stop_fn via drain_journal_with_stop;
execute_drain_journal_with_stop_fn exposed for test injection. Tests: live
SIGKILL drain, structural lock-release proof, deterministic partial-failure
test with injected stop error covering stopped prefix and remaining tail.

Item 2 — Client stop + start serialization: mesh_stop_client Tauri command
in mesh_llm_scope.rs stops only a client-mode runtime; serve/absent are
no-ops. Client start (ensure_relay_mesh_for_record) acquires
workspace_transition through runtime installation to serialize against
apply_workspace, which holds workspace_transition from before the Option A
preflight through commit. fail_if_client_mesh_active preflight runs under
workspace_transition so no new client can start in the check→commit gap.
UI: 'Stop using shared compute' button in MeshComputeSettingsCard shown when
isConsuming; calls new meshStopClient() in tauriMesh.ts. e2eBridge mock for
mesh_stop_client added.

Item 3 — Fallible migrations + atomic marker: rename_provider_to_runtime_in_personas
propagates Result; migrate_agent_keys_to_dev_service_at returns Result and
propagates from copy_agent_keys_between_stores. run_scoped_migrations uses ?
on persona-provider step. _ready marker written via temp+rename (atomic).
dev-key migration skipped in unit-test builds (#[cfg(not(test))]) to avoid
macOS Keychain dialogs. Tests: old-marker upgrade (no scope deletion), partial
migration failure withholds v1 until repair succeeds.

Item 4 — Global-config captured respawn: Phase 2 restart validates captured
scope generation under store lock before stop, and again before respawn via
start_local_agent_pairs_with_preflight_at (new captured variant using
definitions_dir). persist_last_error validates generation under store lock.

Item 5 — Snapshot imports captured operation context: both confirm_agent_snapshot_import
and confirm_team_snapshot_import capture owner keys at entry, verify against
captured_scope.owner_pubkey immediately, thread captured keys through all
mint/retention/engram phases. Re-verify owner key under store lock before
Phase 3a write. Outbound profile/memory phases use captured_scope.relay_url.

Item 6 — CI red: rustfmt applied (agents_scoped.rs, import.rs); clippy
needless_borrow at team_snapshot.rs:793 fixed; e2eBridge apply_workspace mock
returns { applied: true, degraded: [] } in both immediate and delayed branches;
mesh_stop_client mock case added. Stale 3-line doc fragment removed from
mesh_llm.rs; visibility of re-exported agents_scoped fns bumped to pub(crate).

Item 7 — Listener behavioral test: useNestNotifications.test.mjs exercises
workspace-degraded toast payload, unlisten cleanup, and boundary payloads
without requiring a real Tauri runtime. Doc comment updated: event-sync dispatch
failure does not emit workspace-degraded (shutdown-time, no toast surface).

Item 8 — Dead code (minor): backfill_persona_snapshots AppHandle shim removed;
stale scope_init.rs:388-393 comment corrected; make_base_dir test helper removed.

File-size gate: mesh_llm.rs (999), import.rs (999), team_snapshot.rs (999).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-03 22:08:42 -04:00

1212 lines
48 KiB
Rust

//! 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 sha2::{Digest, Sha256};
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.
/// Legacy unscoped agent files are absent; scoped stores live in `agents/scopes/`.
const SHARED_AGENT_FILES: &[&str] = &[];
/// Directories symlinked from worktree data directories to the canonical
/// dev data directory. Each entry becomes a single directory symlink.
/// `agents/scopes` shares all scoped stores across worktrees.
const SHARED_AGENT_DIRS: &[&str] = &["agents/teams", "agents/scopes"];
/// 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`.
pub(crate) 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<PathBuf> {
current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER))
}
pub(crate) fn legacy_app_data_dir(current: &Path) -> Option<PathBuf> {
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) {
run_boot_migrations_inner(app, false);
}
/// Entry point when a completed reset must suppress dev-nest re-import.
pub fn run_boot_migrations_after_reset(app: &tauri::AppHandle) {
run_boot_migrations_inner(app, true);
}
fn run_boot_migrations_inner(app: &tauri::AppHandle, reset_completed: bool) {
// 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.
// Uses the composed helper so the gate + migration run through the same
// code path that the behavioral test exercises.
if let (Some(home), Some(dev_nest)) = (dirs::home_dir(), crate::managed_agents::nest_dir()) {
maybe_migrate_dev_repos_dir(is_dev, reset_completed, &home, &dev_nest);
}
migrate_legacy_app_data_dir(app);
sync_shared_agent_data(app);
// Definition-touching migrations (fold, strip, backfill, etc.) and the
// dev-key keyring migration are NOT run here. They run inside the per-scope
// initialization pipeline (`scope_init::run_scoped_migrations` and
// `run_pre_ready_family`) after staged adoption so every scope sees exactly
// the migrations appropriate to its data.
//
// `migrate_persona_provider_to_runtime` moved to `run_scoped_migrations`
// as step 0 (before fold); runs on the scoped personas.json, not the legacy
// `agents/personas.json`.
//
// `migrate_agent_keys_to_dev_service` moved to `run_pre_ready_family` as
// step C (debug builds); runs after the scoped store is populated.
}
/// 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(&current_dir) else {
return;
};
if !legacy_dir.exists() {
return;
}
match copy_dir_all(&legacy_dir, &current_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"), &current_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;
}
// A deliberate dev reset pre-creates this marker to opt out of every
// production/legacy nest import. Normal first-run migration still copies
// `.sprout` before `migrate_dev_nest()` writes the marker later in boot.
if current.file_name().is_some_and(|name| name == ".buzz-dev")
&& current.join(DEV_NEST_MIGRATED_SENTINEL).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";
/// Returns true when `migrate_dev_repos_dir` should run: dev build AND no
/// completed reset this boot (a completed reset means the dev nest was just
/// wiped — re-importing from prod would undo the sign-out).
pub(crate) fn should_migrate_dev_repos_dir(is_dev: bool, reset_completed: bool) -> bool {
is_dev && !reset_completed
}
/// Injectable core: copy `.repos-dir` from `<home>/.buzz/` into `dev_nest`,
/// non-destructively. Extracted so tests can inject temp paths without
/// touching `dirs::home_dir()` or the global `nest_dir()` OnceLock.
pub(crate) fn migrate_dev_repos_dir_at(home: &Path, dev_nest: &Path) {
let src = home.join(".buzz").join(".repos-dir");
if !src.exists() {
return;
}
// This is a one-time migration. The full dev-nest migration writes the
// same sentinel, and a deliberate dev reset pre-creates it to keep the
// next launch from importing production workspace state again.
if dev_nest.join(DEV_NEST_MIGRATED_SENTINEL).exists() {
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}"),
}
}
/// Composed gate + migration: applies `should_migrate_dev_repos_dir` and, if
/// the gate passes, runs the injectable migration core. This is the seam that
/// `run_boot_migrations_inner` calls with real paths — a future mis-wired flag
/// or inverted gate breaks tests at this level, not just the predicate.
pub(crate) fn maybe_migrate_dev_repos_dir(
is_dev: bool,
reset_completed: bool,
home: &Path,
dev_nest: &Path,
) {
if should_migrate_dev_repos_dir(is_dev, reset_completed) {
migrate_dev_repos_dir_at(home, dev_nest);
}
}
/// 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, &current);
// 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<String, serde_json::Value>) -> bool,
) -> 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 mut records = serde_json::from_str::<Vec<serde_json::Value>>(&content).map_err(|e| {
format!(
"patch-json-records: failed to parse {}: {e}",
path.display()
)
})?;
let mut changed = false;
for record in &mut records {
if let Some(obj) = record.as_object_mut() {
changed |= f(obj);
}
}
if changed {
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> {
persona_id: &'a str,
data_url_sha256: &'a str,
// Catalog-created agents persist the blob descriptor for the PNG after
// `sanitize_image_for_upload` re-encodes it, not the decoded source bytes.
sanitized_media_sha256: &'a str,
persona_content_hash: &'a str,
}
const LEGACY_BUILTIN_AVATARS: &[LegacyBuiltInAvatar<'static>] = &[
LegacyBuiltInAvatar {
persona_id: "builtin:fizz",
data_url_sha256: "2771b8c9c46aa3c8ac1c4d2acfa23fa9ba35b79c4b1694554e923081e3b8b4d0",
sanitized_media_sha256: "1a4964ff4cf6c499df1a77a941c211c7d1e7ef755f1c395bc9a3b0f2878114a6",
persona_content_hash: "b36381d042c8eb5c786a1a692c7ba5a47ae129b9972a1473b64d8fe03f4817c1",
},
LegacyBuiltInAvatar {
persona_id: "builtin:honey",
data_url_sha256: "1979e54ef77fc94ec688170bd74dade35c563e7fcc82bb0714c672dfb018eab9",
sanitized_media_sha256: "0e0ed9a35d4050bdd290aa8138d5ab811f222549f6acc3cee40a7feb65933e1f",
persona_content_hash: "9c9b6b11f1cdd56ba645de02213c562e59c3690bf3f217f74a85df8e6575fd06",
},
LegacyBuiltInAvatar {
persona_id: "builtin:bumble",
data_url_sha256: "c08cf3b8b4c3f8721df6143367ababdebae8f913b9c654401ba74bb3d233655b",
sanitized_media_sha256: "9f798c61f8965b80beb808f505feb0a5b33726545188ea8212cc9ab22d05f0b6",
persona_content_hash: "544a73f9106a3c8848b0f308b7a8b6f95077ac8deccdb9ed5552caa833d66c95",
},
];
struct LegacyAvatarMatch<'a> {
persona_id: String,
metadata: &'a LegacyBuiltInAvatar<'a>,
was_uploaded: bool,
}
fn refresh_builtin_agent_avatars_in_file(
path: &Path,
legacy_avatars: &[LegacyBuiltInAvatar<'_>],
now: &str,
) -> 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 mut records = serde_json::from_str::<Vec<serde_json::Value>>(&contents).map_err(|e| {
format!(
"refresh-builtin-agent-avatars: invalid JSON in {}: {e}",
path.display()
)
})?;
// 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
// that was in sync before migration; a genuinely drifted instance keeps its
// old source version and therefore keeps its out-of-date indicator.
let mut persona_version_updates = std::collections::HashMap::new();
let mut changed = false;
for record in &mut records {
let Some(legacy_match) = legacy_avatar_match(record, legacy_avatars) else {
continue;
};
let persona_id = legacy_match.persona_id.clone();
let is_definition = record
.get("pubkey")
.and_then(serde_json::Value::as_str)
.is_some_and(str::is_empty)
&& record.get("slug").and_then(serde_json::Value::as_str) == Some(&persona_id);
if !is_definition {
continue;
}
let old_version = persona_version_from_record(record);
if !replace_builtin_avatar(record, &persona_id, now) {
continue;
}
changed = true;
if let (Some(old_version), Some(new_version)) =
(old_version, persona_version_from_record(record))
{
persona_version_updates.insert(persona_id, (old_version, new_version));
}
}
for record in &mut records {
let Some(legacy_match) = legacy_avatar_match(record, legacy_avatars) else {
continue;
};
let persona_id = legacy_match.persona_id.clone();
let legacy_avatar = record
.get("avatar_url")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let is_linked_instance = record
.get("pubkey")
.and_then(serde_json::Value::as_str)
.is_some_and(|pubkey| !pubkey.is_empty())
&& record.get("persona_id").and_then(serde_json::Value::as_str) == Some(&persona_id);
if !is_linked_instance {
continue;
}
let version_update = persona_version_updates
.get(&persona_id)
.cloned()
.or_else(|| {
legacy_avatar.as_deref().and_then(|legacy_avatar| {
built_in_persona_version_update(&legacy_match, legacy_avatar, now)
})
});
if !replace_builtin_avatar(record, &persona_id, now) {
continue;
}
changed = true;
if let Some((old_version, new_version)) = version_update {
let source_was_current = record
.get("persona_source_version")
.and_then(serde_json::Value::as_str)
== Some(old_version.as_str());
if source_was_current {
record["persona_source_version"] = serde_json::Value::String(new_version);
}
}
}
if changed {
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>(
record: &serde_json::Value,
legacy_avatars: &'a [LegacyBuiltInAvatar<'a>],
) -> Option<LegacyAvatarMatch<'a>> {
let persona_id = record
.get("persona_id")
.and_then(serde_json::Value::as_str)
.or_else(|| record.get("slug").and_then(serde_json::Value::as_str))?;
let metadata = legacy_avatars
.iter()
.find(|legacy| legacy.persona_id == persona_id)?;
let current_avatar = record
.get("avatar_url")
.and_then(serde_json::Value::as_str)?;
let matches_data_url =
hex::encode(Sha256::digest(current_avatar.as_bytes())) == metadata.data_url_sha256;
let matches_uploaded_media = uploaded_media_sha256(current_avatar)
.is_some_and(|sha256| sha256 == metadata.sanitized_media_sha256);
(matches_data_url || matches_uploaded_media).then(|| LegacyAvatarMatch {
persona_id: persona_id.to_string(),
metadata,
was_uploaded: matches_uploaded_media,
})
}
fn uploaded_media_sha256(avatar_url: &str) -> Option<String> {
let url = url::Url::parse(avatar_url).ok()?;
if !matches!(url.scheme(), "http" | "https") {
return None;
}
let filename = url.path_segments()?.next_back()?;
let (sha256, extension) = filename.rsplit_once('.')?;
(!extension.is_empty()
&& sha256.len() == 64
&& sha256.bytes().all(|byte| byte.is_ascii_hexdigit()))
.then(|| sha256.to_string())
}
fn persona_version_from_record(record: &serde_json::Value) -> Option<String> {
let record: crate::managed_agents::ManagedAgentRecord =
serde_json::from_value(record.clone()).ok()?;
let definition = record.to_definition_view()?;
Some(crate::managed_agents::persona_events::persona_content_hash(
&crate::managed_agents::persona_events::persona_event_content(&definition),
))
}
fn built_in_persona_version_update(
legacy_match: &LegacyAvatarMatch<'_>,
legacy_avatar: &str,
now: &str,
) -> Option<(String, String)> {
let mut current =
crate::managed_agents::built_in_persona_definition(&legacy_match.persona_id, now)?;
let new_version = crate::managed_agents::persona_events::persona_content_hash(
&crate::managed_agents::persona_events::persona_event_content(&current),
);
let old_version = if legacy_match.was_uploaded {
legacy_match.metadata.persona_content_hash.to_string()
} else {
current.avatar_url = Some(legacy_avatar.to_string());
crate::managed_agents::persona_events::persona_content_hash(
&crate::managed_agents::persona_events::persona_event_content(&current),
)
};
Some((old_version, new_version))
}
fn replace_builtin_avatar(record: &mut serde_json::Value, persona_id: &str, now: &str) -> bool {
let Some(replacement) = crate::managed_agents::built_in_persona_avatar_url(persona_id) else {
return false;
};
let Some(record) = record.as_object_mut() else {
return false;
};
record.insert(
"avatar_url".to_string(),
serde_json::Value::String(replacement.to_string()),
);
record.insert(
"updated_at".to_string(),
serde_json::Value::String(now.to_string()),
);
true
}
/// 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::<nostr::Keys>().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(&current_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(&current_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_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
// 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
})?;
Ok(())
}
fn replace_command_field(
obj: &mut serde_json::Map<String, serde_json::Value>,
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) -> Result<(), String> {
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_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;
// 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<String> = 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`.
fn rename_provider_to_runtime_in_personas(path: &Path) -> Result<(), String> {
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
}
})
.map_err(|e| format!("rename-provider-to-runtime: {e}"))
}
mod fold;
mod materialize;
use fold::load_persona_runtimes;
mod backfill;
mod detach;
mod team_suffix;
include!("migration_scope.rs");
#[cfg(test)]
#[path = "migration_test_support.rs"]
mod test_support;
#[cfg(test)]
#[path = "migration_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "migration_avatar_tests.rs"]
mod avatar_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;