fix(desktop): ground agent workspace, migrate legacy nest, configurable repos_dir (#1194)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-23 18:30:12 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent fa1262a92c
commit 1011cea268
20 changed files with 1034 additions and 29 deletions
+3 -1
View File
@@ -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.
+83 -7
View File
@@ -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<String> = 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<String> {
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<String> {
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<String> {
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 ──────────────────────────────────────────────────────
+11 -2
View File
@@ -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],
+41 -3
View File
@@ -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<ActiveWorkspac
/// Apply a workspace's configuration to the backend session.
///
/// Called by the frontend on app init (after reload) to configure the
/// Tauri backend with the selected workspace's relay URL and keys.
/// Tauri backend with the selected workspace's relay URL, keys, and repos
/// directory.
///
/// Validation runs before any state mutation: an invalid `repos_dir` (bad
/// path) rejects cleanly with nothing applied. The `REPOS` symlink itself is
/// a filesystem *side-effect* — its failure (e.g. a non-empty real `REPOS`
/// refusing a downgrade, or a renamed external target on a later launch) is
/// non-fatal: relay/keys still apply, the command returns `Ok`, and a
/// `repos-dir-error` event surfaces the failure to the frontend.
#[tauri::command]
pub fn apply_workspace(
relay_url: String,
nsec: Option<String>,
repos_dir: Option<String>,
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(())
+9
View File
@@ -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() {
@@ -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::*;
+31 -2
View File
@@ -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");
}
}
@@ -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`).
@@ -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<PathBuf, String> {
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()
}
}
}
+128
View File
@@ -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(
+139
View File
@@ -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, &current);
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, &current);
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, &current);
super::migrate_legacy_nest_at(&legacy, &current);
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, &current);
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(&current).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, &current);
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(&current).unwrap();
std::fs::write(current.join("AGENTS.md"), "user-edited live AGENTS").unwrap();
super::migrate_legacy_nest_at(&legacy, &current);
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"
);
}
+6
View File
@@ -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}`;
+7
View File
@@ -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
@@ -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.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void handleSubmit(e)}
>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
@@ -121,6 +132,29 @@ export function AddWorkspaceDialog({
value={token}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="ws-repos-dir"
>
Repos Directory
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional)
</span>
</label>
<Input
id="ws-repos-dir"
onChange={(e) => setReposDir(e.target.value)}
placeholder="~/Development"
type="text"
value={reposDir}
/>
<p className="text-xs text-muted-foreground">
Point the agent's <code>REPOS</code> directory at an existing
folder so agents work in your local checkouts. Leave blank to use
the default location.
</p>
</div>
<p className="text-xs text-muted-foreground">
Workspaces share your active identity. To use a different key,
import it on the profile step (or in settings).
@@ -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<Pick<Workspace, "name" | "relayUrl" | "token">>,
updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "reposDir">
>,
) => 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<Pick<Workspace, "name" | "relayUrl" | "token">> =
{};
const updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "reposDir">
> = {};
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.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<form
className="flex flex-col gap-4"
onSubmit={(e) => void handleSubmit(e)}
>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
@@ -153,6 +173,29 @@ export function EditWorkspaceDialog({
value={token}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="edit-ws-repos-dir"
>
Repos Directory
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional)
</span>
</label>
<Input
id="edit-ws-repos-dir"
onChange={(e) => setReposDir(e.target.value)}
placeholder="~/Development"
type="text"
value={reposDir}
/>
<p className="text-xs text-muted-foreground">
Point the agent's <code>REPOS</code> directory at an existing
folder so agents work in your local checkouts. Leave blank to use
the default location.
</p>
</div>
<div className="flex items-center justify-between pt-2">
<div>
{canRemove && onRemove ? (
@@ -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<string>("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());
};
}, []);
}
@@ -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,
]);
@@ -32,7 +32,9 @@ export type UseWorkspacesReturn = {
reconnectWorkspace: () => void;
updateWorkspace: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey">>,
updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
) => void;
};
@@ -154,7 +156,7 @@ function useWorkspacesInternal(): UseWorkspacesReturn {
(
id: string,
updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey">
Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
) => {
setWorkspacesState((prev) => {
@@ -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<string | undefined> {
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);
+2
View File
@@ -1178,11 +1178,13 @@ export async function applyWorkspace(
relayUrl: string,
nsec?: string,
token?: string,
reposDir?: string,
): Promise<void> {
await invokeTauri("apply_workspace", {
relayUrl,
nsec: nsec ?? null,
token: token ?? null,
reposDir: reposDir ?? null,
});
}