diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 3c80d1e75..faf4a845e 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -67,11 +67,13 @@ Your persistent workspace is in your working directory: | `GUIDES/` | How-to documentation | | `WORK_LOGS/` | Timestamped activity logs | | `OUTBOX/` | Drafts pending review or send | -| `REPOS/` | Checked-out source repositories | +| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does | | `.scratch/` | Ephemeral working files | Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `AGENTS.md` in your working directory for full workspace conventions. +These paths are relative to your working directory — keep exploration there. Never run `find` or recursive searches over `$HOME` or `/` hunting for workspace files: they live under your working directory, not elsewhere on disk. + ## Agent Memory Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions. diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index ca11fede2..6f82f2a24 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -440,7 +440,7 @@ async fn create_session_and_apply_model( // header from `engram_fetch::build_core_section`, so we just append it. let combined_system_prompt: Option = if agent.protocol_version >= 2 { with_core( - framed_system_prompt(ctx.base_prompt, ctx.system_prompt.as_deref()), + framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()), agent_core, ) } else { @@ -670,8 +670,20 @@ pub(crate) fn prepend_base_for_legacy( /// split the combined value into labeled sub-sections. Each prompt is wrapped /// only when present, so a persona-only agent yields `[System]\n{persona}` /// rather than an unlabeled blob that would be mislabeled as `[Base]`. -fn framed_system_prompt(base_prompt: Option<&str>, system_prompt: Option<&str>) -> Option { - match (base_prompt, system_prompt) { +/// +/// Prepends a `[Workspace]` section naming the agent's absolute working +/// directory. The base prompt describes the workspace layout but never its +/// absolute root, so without this anchor a model fills the gap by searching +/// `$HOME` (triggering macOS TCC prompts) or by inventing its own workspace +/// directory. The line is emitted only when a real base prompt is present and +/// `cwd` is an absolute path other than the `/` fallback — naming `/` as the +/// workspace would itself invite a `$HOME`-wide scan. +fn framed_system_prompt( + cwd: &str, + base_prompt: Option<&str>, + system_prompt: Option<&str>, +) -> Option { + let body = match (base_prompt, system_prompt) { (Some(bp), Some(sp)) => Some(format!( "{}\n\n[System]\n{sp}", crate::queue::base_section(bp) @@ -679,6 +691,32 @@ fn framed_system_prompt(base_prompt: Option<&str>, system_prompt: Option<&str>) (Some(bp), None) => Some(crate::queue::base_section(bp)), (None, Some(sp)) => Some(format!("[System]\n{sp}")), (None, None) => None, + }?; + // Anchor the workspace only when a base prompt is present — the workspace + // section grounds the base prompt's layout description, so it is meaningless + // for a persona-only (`[System]`-only) agent that never received that layout. + match (base_prompt, workspace_section(cwd)) { + (Some(_), Some(workspace)) => Some(format!("{workspace}\n\n{body}")), + _ => Some(body), + } +} + +/// Render the `[Workspace]` grounding section, or `None` when `cwd` is unusable. +/// +/// Skips relative paths and the `/` fallback (`std::env::current_dir()` resolves +/// to `/` on failure): a `/`-rooted workspace line would actively encourage the +/// `$HOME`-wide scan this section exists to prevent. +fn workspace_section(cwd: &str) -> Option { + if cwd != "/" && cwd.starts_with('/') { + Some(format!( + "[Workspace]\nYour absolute working directory is `{cwd}`. All workspace \ + files — `AGENTS.md`, `RESEARCH/`, `PLANS/`, `GUIDES/`, `WORK_LOGS/`, \ + `OUTBOX/` — and any repositories you clone (under `{cwd}/REPOS/`) live \ + here. This is where you already are; do not search `$HOME` or other \ + directories for them." + )) + } else { + None } } @@ -2393,14 +2431,14 @@ mod tests { #[test] fn test_framed_system_prompt_both_present_carries_both_headers() { - let framed = framed_system_prompt(Some("base text"), Some("persona text")) + let framed = framed_system_prompt("/", Some("base text"), Some("persona text")) .expect("both present yields Some"); assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text"); } #[test] fn test_framed_system_prompt_base_only_labels_base() { - let framed = framed_system_prompt(Some("base text"), None).expect("base yields Some"); + let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); assert_eq!(framed, "[Base]\nbase text"); } @@ -2408,13 +2446,51 @@ mod tests { fn test_framed_system_prompt_persona_only_labels_system() { // A bare persona would be mislabeled "Base" downstream — it must carry // its own [System] header even when no base prompt exists. - let framed = framed_system_prompt(None, Some("persona text")).expect("persona yields Some"); + let framed = + framed_system_prompt("/", None, Some("persona text")).expect("persona yields Some"); assert_eq!(framed, "[System]\npersona text"); } #[test] fn test_framed_system_prompt_neither_is_none() { - assert!(framed_system_prompt(None, None).is_none()); + assert!(framed_system_prompt("/", None, None).is_none()); + } + + #[test] + fn test_framed_system_prompt_absolute_cwd_prepends_workspace_before_base() { + let framed = framed_system_prompt("/Users/me/.buzz", Some("base text"), None) + .expect("base yields Some"); + assert!( + framed.starts_with("[Workspace]\n"), + "workspace section must lead: {framed}" + ); + assert!(framed.contains("`/Users/me/.buzz`")); + assert!( + framed.contains("\n\n[Base]\nbase text"), + "base must follow the workspace section: {framed}" + ); + } + + #[test] + fn test_framed_system_prompt_persona_only_omits_workspace() { + // The workspace section grounds the base prompt's layout; a persona-only + // agent never received that layout, so no [Workspace] anchor is emitted. + let framed = framed_system_prompt("/Users/me/.buzz", None, Some("persona text")) + .expect("persona yields Some"); + assert_eq!(framed, "[System]\npersona text"); + } + + #[test] + fn test_framed_system_prompt_root_cwd_omits_workspace() { + // The "/" fallback must never be named — it would invite a $HOME scan. + let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); + assert_eq!(framed, "[Base]\nbase text"); + } + + #[test] + fn test_workspace_section_relative_cwd_is_none() { + assert!(workspace_section("relative/path").is_none()); + assert!(workspace_section("").is_none()); } // ── with_core tests ────────────────────────────────────────────────────── diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6e362d8b1..cc6050e87 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -31,11 +31,20 @@ const rules = [ // file is broken up. Tracked as a follow-up. const overrides = new Map([ ["src-tauri/src/commands/agents.rs", 1294], - ["src-tauri/src/managed_agents/nest.rs", 1420], + // Residual repos_dir integration in ensure_nest_at: REPOS is provisioned + // outside NEST_DIRS (it may be a symlink), so it needs its own create + + // chmod-only-when-real-dir handling plus integration test coverage. The + // 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", 1447], ["src-tauri/src/managed_agents/runtime.rs", 1953], ["src-tauri/src/managed_agents/personas.rs", 1080], ["src-tauri/src/managed_agents/persona_card.rs", 1050], - ["src/shared/api/tauri.ts", 1196], + // applyWorkspace reposDir parameter threaded through the Tauri invoke for + // configurable repos_dir — a 3-line overage from load-bearing parameter + // plumbing, not generic debt growth. Approved override; still queued to split. + ["src/shared/api/tauri.ts", 1198], ["src-tauri/src/nostr_convert.rs", 1126], ["src/shared/api/relayClientSession.ts", 1022], ["src-tauri/src/migration.rs", 1295], diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 79ca43f97..7c3529fc1 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,9 +1,9 @@ use nostr::Keys; use serde::Serialize; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Emitter, State}; use crate::app_state::AppState; -use crate::managed_agents::try_regenerate_nest; +use crate::managed_agents::{ensure_repos_symlink, nest_dir, try_regenerate_nest}; use crate::relay; #[derive(Serialize)] @@ -26,11 +26,20 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result, + repos_dir: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result<(), String> { @@ -42,6 +51,24 @@ pub fn apply_workspace( None => None, }; + // Normalize repos_dir to a trimmed non-empty value. `None`/empty clears + // the override (REPOS falls back to a real dir). A bad path is rejected + // here — before any mutation — so the dialog sees a clean Err. + let repos_dir = repos_dir + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(dir) = repos_dir.as_deref() { + let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; + // Validate without mutating the filesystem. Keeps the command's + // "validate-first, nothing below can fail" contract honest. Also emit + // the error so it surfaces even at the init call site (which swallows + // the returned Err to console for the relay/keys path). + if let Err(error) = crate::managed_agents::validate_repos_dir(&nest, dir) { + let _ = app.emit("repos-dir-error", error.clone()); + return Err(error); + } + } + // ── Apply all state changes (nothing below can fail) ────────────────── { let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; @@ -53,6 +80,17 @@ pub fn apply_workspace( *keys_guard = keys; } + // ── Filesystem side-effect (non-fatal) ──────────────────────────────── + // Re-point REPOS to match repos_dir. Failure here (downgrade refused, + // external target gone) must NOT fail the command — relay/keys are already + // applied. Surface it via a `repos-dir-error` event the frontend toasts. + if let Some(nest) = nest_dir() { + if let Err(error) = ensure_repos_symlink(&nest, repos_dir.as_deref()) { + eprintln!("buzz-desktop: repos dir setup failed: {error}"); + let _ = app.emit("repos-dir-error", error); + } + } + try_regenerate_nest(&app); Ok(()) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 266733abe..d9b26841f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -586,6 +586,15 @@ pub fn run() { eprintln!("buzz-desktop: failed to create nest: {error}"); } + // 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. + // 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", ()); + } + // 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() { diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index d22598ed8..18d6af277 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -9,6 +9,7 @@ mod personas; mod process_lifecycle; #[cfg(feature = "mesh-llm")] mod relay_mesh; +mod repos; mod restore; mod runtime; mod storage; @@ -26,6 +27,7 @@ pub use personas::*; pub use process_lifecycle::*; #[cfg(feature = "mesh-llm")] pub use relay_mesh::*; +pub use repos::{ensure_repos_symlink, validate_repos_dir}; pub use restore::*; pub use runtime::*; pub use storage::*; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 8b1f3c62d..9fec60d6d 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -20,19 +20,22 @@ use tauri::{AppHandle, Manager}; use crate::managed_agents::discovery::known_skill_dirs; /// Subdirectories created inside the nest. +/// `REPOS` is intentionally absent: it is provisioned by +/// [`super::repos::ensure_repos_symlink`], which makes it either a real directory (default) +/// or a symlink to a user-configured `repos_dir`. Creating it here +/// unconditionally would race a future symlink re-point. const NEST_DIRS: &[&str] = &[ "GUIDES", "RESEARCH", "PLANS", "WORK_LOGS", - "REPOS", "OUTBOX", ".scratch", ]; /// Default AGENTS.md content written on first init. /// Fully static — no runtime interpolation, no secrets, no user paths. -const AGENTS_MD: &str = include_str!("nest_agents.md"); +pub(crate) const AGENTS_MD: &str = include_str!("nest_agents.md"); /// Default SKILL.md content for the buzz-cli skill. /// Written to ~/.buzz/.agents/skills/buzz-cli/SKILL.md on first init. @@ -109,6 +112,11 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { fs::create_dir_all(&path).map_err(|e| format!("create {}: {e}", path.display()))?; } + // REPOS is provisioned separately from NEST_DIRS: it may be a symlink to a + // user-configured repos_dir (applied later via apply_workspace), so setup + // must not clobber an existing configured symlink. See repos.rs. + super::repos::ensure_repos_setup_default(root)?; + // Write AGENTS.md only if it doesn't already exist. // Uses create_new (O_CREAT|O_EXCL) to atomically check-and-create, // closing the TOCTOU gap that exists() + write() would leave open. @@ -184,6 +192,18 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { .map_err(|e| format!("set permissions on {}: {e}", path.display()))?; } } + // REPOS is provisioned outside NEST_DIRS (it may be a symlink). Only + // chmod it when it is a real directory — chmod on a symlink would + // affect the user's external repos_dir target. + let repos_path = root.join("REPOS"); + let repos_is_symlink = repos_path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if !repos_is_symlink { + fs::set_permissions(&repos_path, perms.clone()) + .map_err(|e| format!("set permissions on {}: {e}", repos_path.display()))?; + } // Skill directory trees inside root get 700. // Build the list from canonical path + all known provider skill dirs. let mut skill_perm_dirs = Vec::new(); @@ -618,6 +638,9 @@ mod tests { for dir in NEST_DIRS { assert!(root.join(dir).is_dir(), "{dir}/ should exist"); } + // REPOS is provisioned separately (may be a symlink); with no + // repos_dir configured it lands as a real directory. + assert!(root.join("REPOS").is_dir(), "REPOS/ should exist"); // AGENTS.md was written with default content. let content = fs::read_to_string(root.join("AGENTS.md")).unwrap(); @@ -633,6 +656,12 @@ mod tests { let mode = fs::metadata(root.join(dir)).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o700, "{dir}/ should be 700"); } + let repos_mode = fs::metadata(root.join("REPOS")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(repos_mode, 0o700, "REPOS/ should be 700"); } } diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 427018be7..7cb7489b8 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -11,7 +11,7 @@ Your persistent workspace. Created once by the Buzz desktop app. The static cont | `RESEARCH/` | Findings, notes, and reference material | | `WORK_LOGS/` | Session logs — what was tried, learned, decided | | `OUTBOX/` | Shareable docs for external readers (no frontmatter) | -| `REPOS/` | Cloned repositories (clone freely here for exploration) | +| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does | | `.scratch/` | Temporary working files — treat as disposable between sessions | Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_FLOW_NOTES.md`). diff --git a/desktop/src-tauri/src/managed_agents/repos.rs b/desktop/src-tauri/src/managed_agents/repos.rs new file mode 100644 index 000000000..dd81950c4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/repos.rs @@ -0,0 +1,398 @@ +//! Per-workspace `REPOS` directory provisioning. +//! +//! The nest's `REPOS` directory is either a real directory (the default) or a +//! symlink to a user-configured `repos_dir`, letting agents work in existing +//! local checkouts instead of re-cloning. [`ensure_repos_symlink`] reconciles +//! `REPOS` with the configured path; [`validate_repos_dir`] guards the input. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +/// Validate a user-supplied `repos_dir`, returning the canonical target path. +/// +/// Requires an **existing absolute directory**. Rejects relative paths, +/// `~`-prefixed paths (shell tilde is not expanded by `std::fs` — the FE +/// expands before save, so a `~` reaching here is a bug to surface loudly), +/// non-directories, and a path that is the nest itself or an ancestor of it +/// (symlinking `REPOS` into its own parent would create a cycle). Never +/// creates the target — a typo must not silently mint a stray directory. +pub fn validate_repos_dir(nest_root: &Path, repos_dir: &str) -> Result { + let trimmed = repos_dir.trim(); + if trimmed.starts_with('~') { + return Err(format!( + "repos dir must be an absolute path (got `{trimmed}`); use e.g. /Users/you/Development" + )); + } + let target = Path::new(trimmed); + if !target.is_absolute() { + return Err(format!( + "repos dir must be an absolute path (got `{trimmed}`)" + )); + } + // Resolve symlinks/`..` so the directory check and ancestor check both + // operate on the real location. Fails loudly on a missing or unreadable + // path rather than falling back to a real REPOS dir. + let canonical = target + .canonicalize() + .map_err(|e| format!("repos dir `{trimmed}` is not accessible: {e}"))?; + if !canonical.is_dir() { + return Err(format!("repos dir `{trimmed}` is not a directory")); + } + // Refuse the nest itself or any ancestor of it — pointing REPOS there + // would nest the symlink inside its own target. + if let Ok(nest_canonical) = nest_root.canonicalize() { + if nest_canonical == canonical || nest_canonical.starts_with(&canonical) { + return Err(format!( + "repos dir `{trimmed}` is the nest or an ancestor of it; choose a separate directory" + )); + } + } + Ok(canonical) +} + +/// Ensure `nest_root/REPOS` matches the configured `repos_dir`. +/// +/// - **`repos_dir` = `None`/empty** → ensure `REPOS` is a real in-nest +/// directory (the default). A pre-existing symlink (from a prior +/// `repos_dir`) is removed first so clearing the field genuinely reverts; +/// removing a symlink never touches its target. Idempotent otherwise. +/// - **`repos_dir` set, `REPOS` absent** → create a symlink to the target. +/// - **`repos_dir` set, `REPOS` is a symlink** (any target) → replace it +/// (`remove_file` + re-symlink). Removing a symlink never touches the +/// target's contents, so this is data-safe. +/// - **`repos_dir` set, `REPOS` is an empty real dir** → remove it and +/// symlink. Converting an empty dir loses nothing. +/// - **`repos_dir` set, `REPOS` is a NON-EMPTY real dir** → refuse and warn. +/// Never `remove_dir_all` — that would destroy repos the agent cloned +/// in-nest. The user must clear or relocate them first. +/// +/// Validation (`validate_repos_dir`) runs before any filesystem mutation, so +/// an invalid path returns `Err` with `REPOS` left exactly as it was. +#[cfg(unix)] +pub fn ensure_repos_symlink(nest_root: &Path, repos_dir: Option<&str>) -> Result<(), String> { + let repos_path = nest_root.join("REPOS"); + + let Some(target) = repos_dir + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|raw| validate_repos_dir(nest_root, raw)) + .transpose()? + else { + // No repos_dir: REPOS must be a real in-nest directory. If it is + // currently a symlink (from a prior repos_dir), remove the link first + // — create_dir_all follows a symlink and would leave the stale link in + // place. remove_file never touches the link's target. + if let Ok(meta) = repos_path.symlink_metadata() { + if meta.file_type().is_symlink() { + fs::remove_file(&repos_path) + .map_err(|e| format!("remove symlink {}: {e}", repos_path.display()))?; + } + } + fs::create_dir_all(&repos_path) + .map_err(|e| format!("create {}: {e}", repos_path.display()))?; + return Ok(()); + }; + + match repos_path.symlink_metadata() { + // Existing symlink → replace it if it points elsewhere. Re-pointing a + // symlink is data-safe; remove_file never follows the link. + Ok(meta) if meta.file_type().is_symlink() => { + if repos_path.read_link().ok().as_deref() == Some(target.as_path()) { + return Ok(()); // already correct + } + fs::remove_file(&repos_path) + .map_err(|e| format!("remove symlink {}: {e}", repos_path.display()))?; + symlink_repos(&target, &repos_path) + } + // Existing real directory → convert only if empty; otherwise refuse. + Ok(meta) if meta.is_dir() => { + let empty = fs::read_dir(&repos_path) + .map_err(|e| format!("read {}: {e}", repos_path.display()))? + .next() + .is_none(); + if !empty { + return Err(format!( + "{} holds repositories; move or delete them before pointing repos dir elsewhere", + repos_path.display() + )); + } + fs::remove_dir(&repos_path) + .map_err(|e| format!("remove {}: {e}", repos_path.display()))?; + symlink_repos(&target, &repos_path) + } + // Exists but is neither symlink nor dir (e.g. a file) → refuse. + Ok(_) => Err(format!( + "{} exists and is not a directory; cannot point repos dir there", + repos_path.display() + )), + // Absent → create the symlink. + Err(e) if e.kind() == io::ErrorKind::NotFound => symlink_repos(&target, &repos_path), + Err(e) => Err(format!("stat {}: {e}", repos_path.display())), + } +} + +#[cfg(unix)] +fn symlink_repos(target: &Path, link: &Path) -> Result<(), String> { + std::os::unix::fs::symlink(target, link) + .map_err(|e| format!("symlink {} → {}: {e}", link.display(), target.display())) +} + +#[cfg(not(unix))] +pub fn ensure_repos_symlink(nest_root: &Path, _repos_dir: Option<&str>) -> Result<(), String> { + let repos_path = nest_root.join("REPOS"); + fs::create_dir_all(&repos_path).map_err(|e| format!("create {}: {e}", repos_path.display())) +} + +/// Provision `REPOS` at nest setup, before any configured `repos_dir` is known. +/// +/// Leaves an existing symlink untouched — `apply_workspace` is the sole +/// authority over a configured symlink. Clearing it here with `None` would +/// destroy a symlink restored from a prior session; async-restored agents +/// would then write into the fresh real dir, and the later FE re-point would +/// refuse the now-non-empty REPOS — silently breaking `repos_dir` on restart. +/// Otherwise (absent, or a real dir) lands the default real-dir fallback. +pub fn ensure_repos_setup_default(nest_root: &Path) -> Result<(), String> { + let repos_path = nest_root.join("REPOS"); + let is_symlink = repos_path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if is_symlink { + return Ok(()); + } + ensure_repos_symlink(nest_root, None) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── ensure_repos_symlink ────────────────────────────────────────────── + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_none_creates_real_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + ensure_repos_symlink(&root, None).unwrap(); + + let repos = root.join("REPOS"); + assert!(repos.is_dir(), "REPOS should be a real directory"); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "REPOS should not be a symlink when repos_dir is None" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_none_reverts_existing_symlink_to_real_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + let payload = external.join("KEEP.md"); + fs::write(&payload, "data").unwrap(); + + // First point REPOS at the external dir, then clear the field. + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, None).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.is_dir(), + "REPOS should be a real directory after clear" + ); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "REPOS should no longer be a symlink after clearing repos_dir" + ); + assert!( + payload.exists(), + "clearing repos_dir must not touch the external target's contents" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_absent_creates_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert!(repos.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap()); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_repoints_existing_wrong_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let old = tmp.path().join("old"); + let new = tmp.path().join("new"); + fs::create_dir_all(&old).unwrap(); + fs::create_dir_all(&new).unwrap(); + let payload = old.join("KEEP.md"); + fs::write(&payload, "data").unwrap(); + + ensure_repos_symlink(&root, Some(old.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, Some(new.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert_eq!(repos.read_link().unwrap(), new.canonicalize().unwrap()); + assert!( + payload.exists(), + "re-pointing a symlink must not touch the old target's contents" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_correct_symlink_is_noop() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let external = tmp.path().join("Development").canonicalize_or_make(); + + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert_eq!(repos.read_link().unwrap(), external); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_empty_real_dir_converts() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(root.join("REPOS")).unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + + ensure_repos_symlink(&root, Some(external.to_str().unwrap())).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.symlink_metadata().unwrap().file_type().is_symlink(), + "an empty real REPOS should convert to a symlink" + ); + } + + #[cfg(unix)] + #[test] + fn ensure_repos_symlink_nonempty_real_dir_refuses_and_preserves() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + let repos = root.join("REPOS"); + fs::create_dir_all(&repos).unwrap(); + let checkout = repos.join("buzz"); + fs::create_dir_all(&checkout).unwrap(); + fs::write(checkout.join("code.rs"), "fn main() {}").unwrap(); + let external = tmp.path().join("Development"); + fs::create_dir_all(&external).unwrap(); + + let result = ensure_repos_symlink(&root, Some(external.to_str().unwrap())); + + assert!(result.is_err(), "non-empty real REPOS must refuse"); + assert!( + !repos.symlink_metadata().unwrap().file_type().is_symlink(), + "refused REPOS must stay a real directory" + ); + assert!( + checkout.join("code.rs").exists(), + "refusal must never delete existing repositories" + ); + } + + // ensure_nest_at must NOT clobber an existing REPOS symlink on startup. + // Regression guard for Finding 1: the startup `ensure_repos_symlink(_, None)` + // call used to remove a configured symlink and mint an empty real REPOS, + // which async-restored agents could write into — the FE re-point then + // refused the now-non-empty dir, silently breaking a configured repos_dir. + #[cfg(unix)] + #[test] + fn ensure_nest_startup_preserves_existing_repos_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + + // First launch creates the real nest with a real REPOS dir. + crate::managed_agents::ensure_nest_at(&root).unwrap(); + + // Simulate a configured repos_dir: REPOS points at an external dir + // holding agent checkouts. + let external = tmp.path().join("Development"); + fs::create_dir(&external).unwrap(); + fs::write(external.join("KEEP.md"), "data").unwrap(); + fs::remove_dir(root.join("REPOS")).unwrap(); + std::os::unix::fs::symlink(&external, root.join("REPOS")).unwrap(); + + // Next launch must leave the configured symlink intact. + crate::managed_agents::ensure_nest_at(&root).unwrap(); + + let repos = root.join("REPOS"); + assert!( + repos.symlink_metadata().unwrap().file_type().is_symlink(), + "an existing REPOS symlink must survive startup" + ); + assert_eq!(repos.read_link().unwrap(), external); + assert!( + external.join("KEEP.md").exists(), + "the symlink's target contents must be untouched" + ); + } + + #[cfg(unix)] + #[test] + fn validate_repos_dir_rejects_tilde_relative_and_missing() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + fs::create_dir_all(&root).unwrap(); + + assert!(validate_repos_dir(&root, "~/Development").is_err()); + assert!(validate_repos_dir(&root, "relative/path").is_err()); + assert!(validate_repos_dir(&root, "/no/such/dir/here").is_err()); + + let file = tmp.path().join("afile"); + fs::write(&file, "x").unwrap(); + assert!(validate_repos_dir(&root, file.to_str().unwrap()).is_err()); + } + + #[cfg(unix)] + #[test] + fn validate_repos_dir_rejects_nest_ancestor() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("home").join(".buzz"); + fs::create_dir_all(&root).unwrap(); + let parent = root.parent().unwrap(); + + assert!( + validate_repos_dir(&root, parent.to_str().unwrap()).is_err(), + "a parent of the nest would nest REPOS inside its own target" + ); + } + + /// Test helper: canonicalize a path, creating it as a directory first. + #[cfg(unix)] + trait CanonicalizeOrMake { + fn canonicalize_or_make(&self) -> PathBuf; + } + #[cfg(unix)] + impl CanonicalizeOrMake for PathBuf { + fn canonicalize_or_make(&self) -> PathBuf { + fs::create_dir_all(self).unwrap(); + self.canonicalize().unwrap() + } + } +} diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 8f7f495bf..b6d3262d8 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -133,6 +133,134 @@ pub fn migrate_legacy_app_data_dir(app: &tauri::AppHandle) { } } +/// 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`. fn patch_json_records( diff --git a/desktop/src-tauri/src/migration_tests.rs b/desktop/src-tauri/src/migration_tests.rs index 0687d852d..4a79ae02b 100644 --- a/desktop/src-tauri/src/migration_tests.rs +++ b/desktop/src-tauri/src/migration_tests.rs @@ -842,3 +842,142 @@ fn reconcile_mcp_commands_skips_record_without_agent_command() { reconcile_mcp_commands_in_file(&path); assert_eq!(before, std::fs::read_to_string(&path).unwrap()); } + +#[test] +fn migrate_legacy_nest_carries_knowledge_and_skips_repos() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + // Knowledge: a top-level file plus a nested dir. + std::fs::create_dir_all(legacy.join("RESEARCH")).unwrap(); + std::fs::write(legacy.join("AGENTS.md"), "agents").unwrap(); + std::fs::write(legacy.join("RESEARCH/NOTES.md"), "notes").unwrap(); + // A fat REPOS/ that must NOT be copied. + std::fs::create_dir_all(legacy.join("REPOS/buzz")).unwrap(); + std::fs::write(legacy.join("REPOS/buzz/huge.bin"), "checkout").unwrap(); + + let migrated = super::migrate_legacy_nest_at(&legacy, ¤t); + + assert!(migrated, "migration ran because legacy nest existed"); + assert!( + !current.join("REPOS").exists(), + "REPOS/ must never be migrated" + ); + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + "agents" + ); + assert_eq!( + std::fs::read_to_string(current.join("RESEARCH/NOTES.md")).unwrap(), + "notes" + ); +} + +#[test] +fn migrate_legacy_nest_does_not_clobber_existing_destination() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + std::fs::create_dir_all(legacy.join("RESEARCH")).unwrap(); + std::fs::write(legacy.join("AGENTS.md"), "legacy-agents").unwrap(); + std::fs::write(legacy.join("RESEARCH/NOTES.md"), "legacy-notes").unwrap(); + // Pre-existing live content the migration must preserve. + std::fs::create_dir_all(current.join("RESEARCH")).unwrap(); + std::fs::write(current.join("AGENTS.md"), "live-agents").unwrap(); + std::fs::write(current.join("RESEARCH/NOTES.md"), "live-notes").unwrap(); + + super::migrate_legacy_nest_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + "live-agents", + "existing top-level file must not be clobbered" + ); + assert_eq!( + std::fs::read_to_string(current.join("RESEARCH/NOTES.md")).unwrap(), + "live-notes", + "existing nested file must not be clobbered" + ); +} + +#[test] +fn migrate_legacy_nest_is_idempotent_on_rerun() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + std::fs::create_dir_all(legacy.join("PLANS")).unwrap(); + std::fs::write(legacy.join("PLANS/PLAN.md"), "plan").unwrap(); + + super::migrate_legacy_nest_at(&legacy, ¤t); + super::migrate_legacy_nest_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("PLANS/PLAN.md")).unwrap(), + "plan" + ); +} + +#[test] +fn migrate_legacy_nest_noops_when_legacy_absent() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + let migrated = super::migrate_legacy_nest_at(&legacy, ¤t); + + assert!(!migrated, "no migration when legacy nest is absent"); + assert!( + !current.exists(), + "no destination created when legacy absent" + ); +} + +#[test] +fn migrate_legacy_nest_overwrites_generated_default_agents_md() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("AGENTS.md"), "legacy team instructions").unwrap(); + + // First-time launch order: ensure_nest writes the generated default into + // ~/.buzz/AGENTS.md, then migration runs. + crate::managed_agents::ensure_nest_at(¤t).unwrap(); + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + crate::managed_agents::AGENTS_MD, + "precondition: ensure_nest writes the generated default" + ); + + super::migrate_legacy_nest_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + "legacy team instructions", + "legacy AGENTS.md must overwrite the untouched generated default" + ); +} + +#[test] +fn migrate_legacy_nest_preserves_user_edited_agents_md() { + let dir = tempfile::tempdir().unwrap(); + let legacy = dir.path().join(".sprout"); + let current = dir.path().join(".buzz"); + + std::fs::create_dir_all(&legacy).unwrap(); + std::fs::write(legacy.join("AGENTS.md"), "legacy team instructions").unwrap(); + std::fs::create_dir_all(¤t).unwrap(); + std::fs::write(current.join("AGENTS.md"), "user-edited live AGENTS").unwrap(); + + super::migrate_legacy_nest_at(&legacy, ¤t); + + assert_eq!( + std::fs::read_to_string(current.join("AGENTS.md")).unwrap(), + "user-edited live AGENTS", + "a user-edited live AGENTS.md must never be clobbered" + ); +} diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index a7a03d806..4be18f502 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -18,6 +18,7 @@ import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSl import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; import type { Workspace } from "@/features/workspaces/types"; import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit"; +import { useNestNotifications } from "@/features/workspaces/useNestNotifications"; import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; @@ -242,6 +243,11 @@ export function App() { void unlisten.then((fn) => fn()); }; }, [addWorkspace, switchWorkspace, reconnectWorkspace]); + // Surface nest-related backend events (repos-dir errors, legacy migration) + // as toasts. Mounted before useWorkspaceInit so the listeners are registered + // ahead of the first apply_workspace call. + useNestNotifications(); + // Composite key: changes when workspace ID changes OR when // the active workspace's config is updated (relayUrl/token). const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`; diff --git a/desktop/src/features/workspaces/types.ts b/desktop/src/features/workspaces/types.ts index 77f346c65..ef3976796 100644 --- a/desktop/src/features/workspaces/types.ts +++ b/desktop/src/features/workspaces/types.ts @@ -10,6 +10,13 @@ export type Workspace = { */ pubkey?: string; addedAt: string; + /** + * Absolute directory the agent's `~/.buzz/REPOS` symlinks to, so agents + * work in the user's existing checkouts instead of re-cloning. `~` is + * expanded to an absolute path before save. Unset = the default real + * `REPOS` directory inside the nest. + */ + reposDir?: string; /** * @deprecated Never read. Kept on the type so old localStorage entries * deserialise without errors. New entries never set this field, and diff --git a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx index 4ae9d43c8..2c402664d 100644 --- a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx +++ b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import type { Workspace } from "@/features/workspaces/types"; import { deriveWorkspaceName, + expandTilde, normalizeRelayUrl, } from "@/features/workspaces/workspaceStorage"; import { Button } from "@/shared/ui/button"; @@ -29,33 +30,40 @@ export function AddWorkspaceDialog({ const [name, setName] = React.useState(""); const [relayUrl, setRelayUrl] = React.useState(""); const [token, setToken] = React.useState(""); + const [reposDir, setReposDir] = React.useState(""); const handleClose = React.useCallback(() => { onOpenChange(false); setName(""); setRelayUrl(""); setToken(""); + setReposDir(""); }, [onOpenChange]); const handleSubmit = React.useCallback( - (e: React.FormEvent) => { + async (e: React.FormEvent) => { e.preventDefault(); if (!relayUrl.trim()) { return; } + // Expand `~` before save — the backend rejects tilde paths. Empty input + // resolves to `undefined` so REPOS keeps its default location. + const expandedReposDir = await expandTilde(reposDir); + const workspace: Workspace = { id: crypto.randomUUID(), name: name.trim() || deriveWorkspaceName(relayUrl.trim()), relayUrl: normalizeRelayUrl(relayUrl.trim()), token: token.trim() || undefined, + reposDir: expandedReposDir, addedAt: new Date().toISOString(), }; onSubmit(workspace); handleClose(); }, - [name, relayUrl, token, onSubmit, handleClose], + [name, relayUrl, token, reposDir, onSubmit, handleClose], ); return ( @@ -68,7 +76,10 @@ export function AddWorkspaceDialog({ messages, and identity. -
+ void handleSubmit(e)} + >
+
+ + setReposDir(e.target.value)} + placeholder="~/Development" + type="text" + value={reposDir} + /> +

+ Point the agent's REPOS directory at an existing + folder so agents work in your local checkouts. Leave blank to use + the default location. +

+

Workspaces share your active identity. To use a different key, import it on the profile step (or in settings). diff --git a/desktop/src/features/workspaces/ui/EditWorkspaceDialog.tsx b/desktop/src/features/workspaces/ui/EditWorkspaceDialog.tsx index 6dce434d8..8dacc030c 100644 --- a/desktop/src/features/workspaces/ui/EditWorkspaceDialog.tsx +++ b/desktop/src/features/workspaces/ui/EditWorkspaceDialog.tsx @@ -1,7 +1,10 @@ import * as React from "react"; import type { Workspace } from "@/features/workspaces/types"; -import { normalizeRelayUrl } from "@/features/workspaces/workspaceStorage"; +import { + expandTilde, + normalizeRelayUrl, +} from "@/features/workspaces/workspaceStorage"; import { Button } from "@/shared/ui/button"; import { Dialog, @@ -18,7 +21,9 @@ type EditWorkspaceDialogProps = { onOpenChange: (open: boolean) => void; onSave: ( id: string, - updates: Partial>, + updates: Partial< + Pick + >, ) => void; onRemove?: (id: string) => void; canRemove?: boolean; @@ -35,6 +40,7 @@ export function EditWorkspaceDialog({ const [name, setName] = React.useState(""); const [relayUrl, setRelayUrl] = React.useState(""); const [token, setToken] = React.useState(""); + const [reposDir, setReposDir] = React.useState(""); // Sync form state when the dialog opens with a workspace React.useEffect(() => { @@ -42,6 +48,7 @@ export function EditWorkspaceDialog({ setName(workspace.name); setRelayUrl(workspace.relayUrl); setToken(workspace.token ?? ""); + setReposDir(workspace.reposDir ?? ""); } }, [workspace, open]); @@ -50,14 +57,15 @@ export function EditWorkspaceDialog({ }, [onOpenChange]); const handleSubmit = React.useCallback( - (e: React.FormEvent) => { + async (e: React.FormEvent) => { e.preventDefault(); if (!workspace || !relayUrl.trim()) { return; } - const updates: Partial> = - {}; + const updates: Partial< + Pick + > = {}; const trimmedName = name.trim(); if (trimmedName && trimmedName !== workspace.name) { @@ -74,13 +82,22 @@ export function EditWorkspaceDialog({ updates.token = trimmedToken; } + // Expand `~` to an absolute path before save — the backend rejects + // tilde paths. An empty field clears the override (REPOS reverts to a + // real dir). Only emit when the resolved value actually changed so a + // no-op edit doesn't trigger a backend re-apply. + const expandedReposDir = await expandTilde(reposDir); + if (expandedReposDir !== workspace.reposDir) { + updates.reposDir = expandedReposDir; + } + if (Object.keys(updates).length > 0) { onSave(workspace.id, updates); } handleClose(); }, - [workspace, name, relayUrl, token, onSave, handleClose], + [workspace, name, relayUrl, token, reposDir, onSave, handleClose], ); const handleRemove = React.useCallback(() => { @@ -103,7 +120,10 @@ export function EditWorkspaceDialog({ Update this workspace's name or relay URL. - + void handleSubmit(e)} + >

+
+ + setReposDir(e.target.value)} + placeholder="~/Development" + type="text" + value={reposDir} + /> +

+ Point the agent's REPOS directory at an existing + folder so agents work in your local checkouts. Leave blank to use + the default location. +

+
{canRemove && onRemove ? ( diff --git a/desktop/src/features/workspaces/useNestNotifications.ts b/desktop/src/features/workspaces/useNestNotifications.ts new file mode 100644 index 000000000..665788a55 --- /dev/null +++ b/desktop/src/features/workspaces/useNestNotifications.ts @@ -0,0 +1,46 @@ +import { listen } from "@tauri-apps/api/event"; +import { useEffect } from "react"; +import { toast } from "sonner"; + +const MIGRATION_TOAST_KEY = "buzz-legacy-nest-migrated-notified"; + +/** + * Surface nest-related backend events as toasts. + * + * - `repos-dir-error`: a configured `repos_dir` failed to validate or its + * symlink could not be applied (invalid path, downgrade refused, external + * target gone). Emitted by `apply_workspace` on both the validate-reject + * and the runtime symlink-failure paths, so a bad `repos_dir` is always + * visibly surfaced rather than silently logged to console. + * - `legacy-nest-migrated`: the agent's knowledge was carried over from a + * legacy `~/.sprout` nest. Shown once per machine (deduped via + * localStorage); the backend re-emits each launch while `~/.sprout` exists, + * which also covers the event being emitted before this listener mounts. + * + * Mounted at the app root ahead of the workspace-init effect so the listener + * is registered before the first `apply_workspace` call. + */ +export function useNestNotifications(): void { + useEffect(() => { + const unlistenReposError = listen("repos-dir-error", (event) => { + toast.error("Repos directory not applied", { + description: event.payload, + }); + }); + + const unlistenMigrated = listen("legacy-nest-migrated", () => { + if (localStorage.getItem(MIGRATION_TOAST_KEY) === "true") { + return; + } + localStorage.setItem(MIGRATION_TOAST_KEY, "true"); + toast.success("Migrated notes from ~/.sprout", { + description: "You can delete it to reclaim disk space.", + }); + }); + + return () => { + void unlistenReposError.then((fn) => fn()); + void unlistenMigrated.then((fn) => fn()); + }; + }, []); +} diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index c7026ecc1..c0d20f108 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -67,7 +67,7 @@ export function useWorkspaceInit( // On the initial mount we skip resetting singletons (they're fresh). const hasInitializedRef = useRef(false); - // biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token) — depending on the whole object would trigger resets on name-only changes + // biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes useEffect(() => { let cancelled = false; @@ -136,9 +136,20 @@ export function useWorkspaceInit( activeWorkspace.relayUrl, undefined, activeWorkspace.token, + activeWorkspace.reposDir, ); } catch (error) { + // apply_workspace validates repos_dir before touching relay/keys, so a + // hard reject (bad path) leaves the backend on the previous/default + // relay with nothing applied. Marking the workspace ready here would + // render workspace-scoped UI against the wrong relay — a silent + // split-brain. Park on the loading gate (isReady:false, no appliedKey) + // instead; the backend already toasts a `repos-dir-error`. console.error("Failed to apply workspace to backend:", error); + if (!cancelled) { + setResult({ isReady: false, needsSetup: false, appliedKey: null }); + } + return; } if (!cancelled) { @@ -159,6 +170,7 @@ export function useWorkspaceInit( activeWorkspace?.id, activeWorkspace?.relayUrl, activeWorkspace?.token, + activeWorkspace?.reposDir, isSharedIdentity, workspaceKey, ]); diff --git a/desktop/src/features/workspaces/useWorkspaces.tsx b/desktop/src/features/workspaces/useWorkspaces.tsx index 919dc9a2b..a0a8c3bad 100644 --- a/desktop/src/features/workspaces/useWorkspaces.tsx +++ b/desktop/src/features/workspaces/useWorkspaces.tsx @@ -32,7 +32,9 @@ export type UseWorkspacesReturn = { reconnectWorkspace: () => void; updateWorkspace: ( id: string, - updates: Partial>, + updates: Partial< + Pick + >, ) => void; }; @@ -154,7 +156,7 @@ function useWorkspacesInternal(): UseWorkspacesReturn { ( id: string, updates: Partial< - Pick + Pick >, ) => { setWorkspacesState((prev) => { diff --git a/desktop/src/features/workspaces/workspaceStorage.ts b/desktop/src/features/workspaces/workspaceStorage.ts index 25d7747d0..7c5e8d26f 100644 --- a/desktop/src/features/workspaces/workspaceStorage.ts +++ b/desktop/src/features/workspaces/workspaceStorage.ts @@ -1,8 +1,31 @@ import type { Workspace } from "./types"; +import { homeDir } from "@tauri-apps/api/path"; const WORKSPACES_KEY = "buzz-workspaces"; const ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id"; +/** + * Expand a leading `~` to the user's home directory. The backend rejects + * `~`-prefixed paths (`std::fs` does not expand the shell tilde), so the UI + * resolves it before save. Returns non-`~` input unchanged. Empty/whitespace + * input returns `undefined` so callers can clear the override. + */ +export async function expandTilde(input: string): Promise { + const trimmed = input.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed === "~") { + return homeDir(); + } + if (trimmed.startsWith("~/")) { + const home = await homeDir(); + const base = home.endsWith("/") ? home.slice(0, -1) : home; + return `${base}/${trimmed.slice(2)}`; + } + return trimmed; +} + export function loadWorkspaces(): Workspace[] { try { const raw = localStorage.getItem(WORKSPACES_KEY); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 6afa9d8f3..519f3fd85 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1178,11 +1178,13 @@ export async function applyWorkspace( relayUrl: string, nsec?: string, token?: string, + reposDir?: string, ): Promise { await invokeTauri("apply_workspace", { relayUrl, nsec: nsec ?? null, token: token ?? null, + reposDir: reposDir ?? null, }); }