Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ const overrides = new Map([
// self-contained repos_dir functions and their unit tests live in repos.rs;
// this is the seam that must stay in nest.rs. Approved override; still queued
// to split with the rest of this list.
["src-tauri/src/managed_agents/nest.rs", 1450],
// dev-nest namespace: OnceLock<Option<PathBuf>> + init_nest_dir + constants
// added to plumb the dev/prod discriminator. Load-bearing for the D2 nest fix.
["src-tauri/src/managed_agents/nest.rs", 1501],
// harness-persona-sync: persona-runtime resolution threaded into the spawn
// path here. Load-bearing feature growth; queued to split in the resolver
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
Expand Down Expand Up @@ -97,7 +99,7 @@ const overrides = new Map([
["src-tauri/src/migration_tests.rs", 1410],
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1449],
["src-tauri/src/migration.rs", 1575],
// onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop
// already here) for the single-toggle mark-read/unread menu item — a small
// overage from load-bearing per-message plumbing, not generic debt growth.
Expand All @@ -113,6 +115,10 @@ const overrides = new Map([
// fail-closed regression tests (silent identity rotation on keyring outage).
// A small overage from load-bearing security plumbing on a file already at
// 893 lines, not generic debt growth. Approved override; still queued to split.
// cross-process keychain race fix (D3): interprocess lock + BlobLockGuard +
// uid-keyed lockfile path + behavioral tests add ~303 lines. Load-bearing
// security fix for the lost-update race that stranded agent keys.
["src-tauri/src/secret_store.rs", 1043],
["src-tauri/src/app_state.rs", 1033],
// multi-slot splitting + no-op suppression (#1309): the ReadStateManager
// class grew from ~700 lines to ~1019 with the addition of
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ keyring = { version = "3.6.3", default-features = false, features = ["apple-nati
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }

[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Foundation"] }
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Foundation"] }
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }

[dependencies]
Expand Down
22 changes: 17 additions & 5 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,10 @@ pub fn run() {
.store(port, std::sync::atomic::Ordering::Relaxed);
});

// Create the Buzz nest (~/.buzz) before agents are restored,
// so default_agent_workdir() resolves to the nest directory.
// Non-fatal: agents fall back to $HOME if nest creation fails.
// Create the Buzz nest (~/.buzz or ~/.buzz-dev for dev builds) before
// agents are restored, so default_agent_workdir() resolves to the
// nest directory. Non-fatal: agents fall back to $HOME if nest
// creation fails.
if let Err(error) = ensure_nest() {
eprintln!("buzz-desktop: failed to create nest: {error}");
}
Expand All @@ -306,14 +307,25 @@ pub fn run() {
};

// Carry the agent's knowledge from the legacy nest (~/.sprout) into
// the live nest (~/.buzz) after it exists. Must run after
// ensure_nest() so the destination is present. Non-fatal.
// the live nest after it exists. Must run after ensure_nest() so the
// destination is present. Non-fatal.
// On a real migration, emit a one-time hint so the user can delete
// the now-inert ~/.sprout; the frontend dedupes the toast.
if migration::migrate_legacy_nest() {
let _ = app_handle.emit("legacy-nest-migrated", ());
}

// One-time migration for dev builds: copy accumulated knowledge
// from the shared ~/.buzz nest into the new dedicated ~/.buzz-dev
// nest so no work is lost when the nest is first namespaced.
// Runs only when nest_dir() resolved to ~/.buzz-dev (dev instance).
let is_dev_nest = managed_agents::nest_dir()
.and_then(|p| p.file_name().map(|n| n.to_os_string()))
.is_some_and(|n| n == ".buzz-dev");
if is_dev_nest {
migration::migrate_dev_nest();
}

// Create/update the local CLI symlink pointing to the
// bundled CLI binary. Non-fatal: agents find CLI via PATH.
if let Ok(exe) = std::env::current_exe() {
Expand Down
73 changes: 69 additions & 4 deletions desktop/src-tauri/src/managed_agents/nest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,53 @@ const END_MARKER: &str = "<!-- END BUZZ MANAGED -->";

/// Canonical skill directory path relative to the nest root.
const CANONICAL_SKILL_DIR: &str = ".agents/skills/buzz-cli";
/// Returns the nest root path (`~/.buzz`), or `None` if the home
/// directory cannot be resolved.

/// Nest directory name for production builds.
const NEST_DIR_PROD: &str = ".buzz";

/// Nest directory name for dev builds. Dev builds (those whose Tauri app-data
/// directory name starts with `"xyz.block.buzz.app.dev"`) use a separate nest
/// so that the DMG and dev-build instances don't clobber each other's
/// `.repos-dir` dotfile and `REPOS` symlink.
const NEST_DIR_DEV: &str = ".buzz-dev";

/// Process-lifetime nest directory. Initialized once at startup via
/// [`init_nest_dir`] before any call to [`nest_dir`].
///
/// `None` inside the `OnceLock` means "home dir was unresolvable at init time".
/// The outer `None` from `OnceLock::get` means "not initialized yet" —
/// [`nest_dir`] falls back to the prod path in that case, ensuring test code
/// that never calls [`init_nest_dir`] still works.
static NEST_DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();

/// Initialize the process-lifetime nest directory.
///
/// Must be called once at app startup (before any call to [`nest_dir`] that
/// may result in a filesystem operation). Subsequent calls are no-ops — the
/// `OnceLock` is set exactly once.
///
/// `is_dev` should be `true` when the running binary is a dev build — i.e.
/// when the Tauri app-data directory name starts with `"xyz.block.buzz.app.dev"`.
/// Pass `false` for production (signed DMG) builds.
pub fn init_nest_dir(is_dev: bool) {
let suffix = if is_dev { NEST_DIR_DEV } else { NEST_DIR_PROD };
let path = dirs::home_dir().map(|h| h.join(suffix));
// set() is a no-op when already initialized, which is correct: only the
// first call (at boot, before any filesystem work) should win.
let _ = NEST_DIR.set(path);
}

/// Returns the nest root path (`~/.buzz` for prod, `~/.buzz-dev` for dev),
/// or `None` if the home directory cannot be resolved.
///
/// If [`init_nest_dir`] has not been called yet (e.g. in unit tests), falls
/// back to the production path `~/.buzz`.
pub fn nest_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".buzz"))
match NEST_DIR.get() {
Some(path) => path.clone(),
// Not yet initialized — fall back to prod path. Covers test code.
None => dirs::home_dir().map(|h| h.join(NEST_DIR_PROD)),
}
}

/// Creates the Buzz nest at `~/.buzz` if it doesn't already exist.
Expand Down Expand Up @@ -614,7 +657,29 @@ mod tests {
#[test]
fn nest_dir_is_under_home() {
if let Some(dir) = nest_dir() {
assert!(dir.ends_with(".buzz"));
// Accepts both .buzz (prod) and .buzz-dev (dev) depending on
// whether init_nest_dir was called before this test ran.
let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or("");
assert!(
name == NEST_DIR_PROD || name == NEST_DIR_DEV,
"nest_dir must end with .buzz or .buzz-dev, got {dir:?}"
);
}
}

#[test]
fn init_nest_dir_prod_sets_buzz() {
// init_nest_dir is idempotent (OnceLock) — once set, subsequent calls
// are no-ops. We can only test the fallback path if the OnceLock is
// unset, which is only true in a fresh process. Instead, verify that
// nest_dir() always returns a path ending with a valid nest suffix.
let dir = nest_dir();
if let Some(d) = dir {
let name = d.file_name().and_then(|n| n.to_str()).unwrap_or("");
assert!(
name == NEST_DIR_PROD || name == NEST_DIR_DEV,
"nest_dir suffix must be .buzz or .buzz-dev, got {d:?}"
);
}
}

Expand Down
130 changes: 128 additions & 2 deletions desktop/src-tauri/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,30 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) {
/// so it runs pre-identity here ahead of all readers — reader-first loses a
/// launch (stale harness/`mcp_command` until the next boot).
pub fn run_boot_migrations(app: &tauri::AppHandle) {
// Initialize the process-lifetime nest directory before any filesystem
// operation that calls nest_dir(). The discriminator matches the existing
// pattern used by reconcile_target_dir: dev instances have an app-data-dir
// name starting with CANONICAL_DEV_IDENTIFIER.
let is_dev = if let Ok(data_dir) = app.path().app_data_dir() {
let dev = data_dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(CANONICAL_DEV_IDENTIFIER));
crate::managed_agents::init_nest_dir(dev);
dev
} else {
false
};

// On dev builds, copy `.repos-dir` from ~/.buzz → ~/.buzz-dev BEFORE
// control returns to lib.rs where resolve_repos_at_boot() reads it. This
// ensures the dev nest boots with the correct workspace on its first launch,
// matching what the prod nest had configured. Skip-if-dest-exists so it is
// idempotent and never clobbers a value the dev nest already set explicitly.
if is_dev {
migrate_dev_repos_dir();
}

migrate_legacy_app_data_dir(app);
sync_shared_agent_data(app);
migrate_packs_to_teams(app);
Expand Down Expand Up @@ -197,7 +221,7 @@ const LEGACY_NEST_KNOWLEDGE: &[&str] = &[
".scratch",
];

/// Migrate the legacy agent nest (`~/.sprout`) into the current nest (`~/.buzz`).
/// 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 —
Expand All @@ -220,7 +244,12 @@ pub fn migrate_legacy_nest() -> bool {
eprintln!("buzz-desktop: nest-migration: cannot resolve home directory");
return false;
};
migrate_legacy_nest_at(&home.join(".sprout"), &home.join(".buzz"))
// 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`.
Expand Down Expand Up @@ -266,6 +295,103 @@ fn migrate_legacy_nest_at(legacy: &Path, current: &Path) -> bool {
true
}

/// Filename of the completion sentinel written after a successful dev-nest
/// knowledge migration. Presence of this file means `~/.buzz` content has
/// already been copied into `~/.buzz-dev` and subsequent boots can skip the
/// copy. Using an explicit marker instead of checking for RESEARCH/PLANS
/// content decouples the dev migration from the `.sprout` migration, which
/// also copies into `~/.buzz-dev` and could otherwise set the sentinel early.
const DEV_NEST_MIGRATED_SENTINEL: &str = ".dev-nest-migrated";

/// Copy the `.repos-dir` dotfile from `~/.buzz` → `~/.buzz-dev`, non-destructively.
///
/// Must be called on dev builds BEFORE `resolve_repos_at_boot()` reads the
/// dotfile, so the dev nest boots with the correct workspace configuration on
/// its first launch. Skip-if-dest-exists so it is idempotent and never
/// overwrites a value set directly by the dev nest.
fn migrate_dev_repos_dir() {
let Some(home) = dirs::home_dir() else {
return;
};
let src = home.join(".buzz").join(".repos-dir");
if !src.exists() {
return;
}
let Some(dev_nest) = crate::managed_agents::nest_dir() else {
return;
};
let dst = dev_nest.join(".repos-dir");
// Skip if the dev nest already has its own .repos-dir.
if dst.exists() {
return;
}
// Ensure the dev nest directory itself exists — this migration runs before
// ensure_nest() in the boot sequence, so the directory may not yet exist.
if let Err(e) = std::fs::create_dir_all(&dev_nest) {
eprintln!(
"buzz-desktop: dev-nest-migration: failed to create dev nest {}: {e}",
dev_nest.display()
);
return;
}
match std::fs::copy(&src, &dst) {
Ok(_) => eprintln!(
"buzz-desktop: dev-nest-migration: migrated .repos-dir to {}",
dst.display()
),
Err(e) => eprintln!("buzz-desktop: dev-nest-migration: failed to migrate .repos-dir: {e}"),
}
}

/// One-time migration of dev-build nest contents from `~/.buzz` → `~/.buzz-dev`.
///
/// When a dev build first boots after this change ships, it switches from the
/// shared `~/.buzz` nest to a dedicated `~/.buzz-dev` nest. Without migration,
/// all accumulated knowledge (RESEARCH/, PLANS/, GUIDES/, WORK_LOGS/, mem_*
/// slugs, AGENTS.md, managed-agents.json) would be invisible to dev instances.
///
/// Migration is non-destructive: `copy_dir_all` skips files already at the
/// destination, so a partially-migrated state is safe to re-run. The source
/// `~/.buzz` is never deleted — prod builds continue to use it normally.
///
/// Completion is tracked by a [`DEV_NEST_MIGRATED_SENTINEL`] file written into
/// `~/.buzz-dev`. Using an explicit sentinel (rather than RESEARCH/PLANS file
/// presence) decouples this migration from the `.sprout` → `~/.buzz-dev`
/// migration that runs earlier in the same boot, which might otherwise populate
/// RESEARCH/PLANS and incorrectly suppress the `~/.buzz` copy.
///
/// Only runs on dev builds (checked by the caller). Returns `true` when
/// contents were copied (useful for a one-time log message, not required).
pub fn migrate_dev_nest() -> bool {
let Some(home) = dirs::home_dir() else {
eprintln!("buzz-desktop: dev-nest-migration: cannot resolve home directory");
return false;
};
let legacy = home.join(".buzz");
let current = home.join(".buzz-dev");
// If legacy doesn't exist, nothing to migrate.
if !legacy.exists() {
return false;
}
// Skip if migration has already run (explicit sentinel, not content-based).
if current.join(DEV_NEST_MIGRATED_SENTINEL).exists() {
return false;
}
let copied = migrate_legacy_nest_at(&legacy, &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<()> {
Expand Down
Loading
Loading