mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
1449 lines
54 KiB
Rust
1449 lines
54 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 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<PathBuf> {
|
|
current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER))
|
|
}
|
|
|
|
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);
|
|
}
|
|
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<String, serde_json::Value>) -> bool,
|
|
) {
|
|
let Ok(content) = std::fs::read_to_string(path) else {
|
|
return;
|
|
};
|
|
let Ok(mut records) = serde_json::from_str::<Vec<serde_json::Value>>(&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::<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;
|
|
}
|
|
};
|
|
|
|
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<String, String> {
|
|
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::<Vec<serde_json::Value>>(&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<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) {
|
|
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<String> {
|
|
let (frontmatter, body) = buzz_persona_pkg::persona::split_frontmatter(content).ok()?;
|
|
let mut value = serde_yaml::from_str::<serde_yaml::Value>(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<u32, String> {
|
|
use crate::managed_agents::{
|
|
persona_events::{build_persona_event, monotonic_created_at, persona_d_tag},
|
|
retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent},
|
|
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<PersonaRecord> = 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<u32, String> {
|
|
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<TeamRecord> =
|
|
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;
|