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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,7 @@ mod tests {
CancellationToken::new(),
Arc::new(AtomicU8::new(0)),
Arc::new(Mutex::new(HashMap::new())),
3,
);
state.sub_registry.register(
conn_id,
Expand Down
9 changes: 5 additions & 4 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ const overrides = new Map([
["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],
// 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],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir — a 4-line
// overage from load-bearing parameter plumbing, not generic debt growth.
// Approved override; still queued to split.
["src/shared/api/tauri.ts", 1199],
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295],
Expand Down
90 changes: 61 additions & 29 deletions desktop/src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ use serde::Serialize;
use tauri::{AppHandle, Emitter, State};

use crate::app_state::AppState;
use crate::managed_agents::{ensure_repos_symlink, nest_dir, try_regenerate_nest};
use crate::managed_agents::{
effective_repos_dir, ensure_repos_symlink, nest_dir, try_regenerate_nest,
write_persisted_repos_dir,
};
use crate::relay;

#[derive(Serialize)]
Expand All @@ -23,18 +26,36 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result<ActiveWorkspac
})
}

/// Validate a candidate `repos_dir` without mutating the filesystem.
///
/// The Add/Edit workspace dialogs call this on submit to block Save on a bad
/// path, so a typo never reaches `apply_workspace`. Reuses the same
/// `validate_repos_dir` the boot/apply path uses — one source of truth for
/// "what's a valid repos dir". An empty/whitespace value clears the override
/// and is valid. `Err` carries the human-readable reason for inline display.
#[tauri::command]
pub fn validate_repos_dir(dir: String) -> Result<(), String> {
let trimmed = dir.trim();
if trimmed.is_empty() {
return Ok(());
}
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
crate::managed_agents::validate_repos_dir(&nest, trimmed).map(|_| ())
}

/// 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, 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.
/// A bad `repos_dir` is non-fatal: relay/keys always apply (the relay is the
/// active workspace's own choice — orthogonal to the filesystem repos dir),
/// the bad value is NOT persisted (so the next boot starts clean), the
/// `REPOS` symlink is skipped (REPOS stays a real dir), a `repos-dir-error`
/// event surfaces the reason, and the command returns `Ok`. The dialogs
/// already block a bad path at Save (`validate_repos_dir`); this fallback only
/// catches a value that went bad after save (deleted dir, unmounted volume).
#[tauri::command]
pub fn apply_workspace(
relay_url: String,
Expand All @@ -51,23 +72,25 @@ 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);
}
}
// Decide the effective repos_dir from the candidate. A bad path does NOT
// reject — it is treated as if no override were set: relay/keys still
// apply, the bad value is not persisted, and a `repos-dir-error` surfaces
// the reason. Persisting a bad path would make every later boot read it,
// fail to resolve the symlink, and silently skip agent restore. One
// validate (inside `effective_repos_dir`) drives both the emit and the
// persisted value. `nest` is resolved softly: when absent there is nothing
// to persist or symlink, and relay/keys must still apply unconditionally.
let nest = nest_dir();
let effective_repos_dir = match nest.as_deref() {
Some(nest) => match effective_repos_dir(nest, repos_dir.as_deref()) {
Ok(value) => value,
Err(error) => {
let _ = app.emit("repos-dir-error", error);
None
}
},
None => None,
};

// ── Apply all state changes (nothing below can fail) ──────────────────
{
Expand All @@ -81,11 +104,20 @@ pub fn apply_workspace(
}

// ── 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()) {
// Persist the *effective* repos_dir (None when the candidate failed
// validation) for the backend to read at boot, then re-point REPOS to
// match. Persisting first makes the dotfile authoritative even if the
// symlink apply fails here (e.g. a non-empty real REPOS): the next boot
// reads the persisted value and resolves the symlink before any agent can
// clone into REPOS. A bad candidate persists `None`, so the next boot is
// clean and agent restore proceeds. Failure of either must NOT fail the
// command — relay/keys are already applied. Surface symlink errors via
// `repos-dir-error`.
if let Some(nest) = nest.as_deref() {
if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) {
eprintln!("buzz-desktop: persist repos dir failed: {error}");
}
if let Err(error) = ensure_repos_symlink(nest, effective_repos_dir.as_deref()) {
eprintln!("buzz-desktop: repos dir setup failed: {error}");
let _ = app.emit("repos-dir-error", error);
}
Expand Down
38 changes: 30 additions & 8 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,21 @@ pub fn run() {
eprintln!("buzz-desktop: failed to create nest: {error}");
}

// Resolve the REPOS symlink from the persisted repos_dir BEFORE
// agents are restored below, and decide whether restore is safe.
// The frontend's apply_workspace runs only after React mounts —
// later than the async agent restore — so without this an agent
// could clone into the empty real REPOS dir, and once REPOS is
// non-empty ensure_repos_symlink refuses forever. resolve_repos_at_boot
// fails closed: if a repos_dir was configured but its symlink could
// not be resolved (transiently unavailable external volume), it
// returns false so we skip restore this launch rather than let an
// agent clone into the wrong REPOS. See managed_agents::repos.
let restore_agents = match managed_agents::nest_dir() {
Some(nest) => managed_agents::resolve_repos_at_boot(&nest),
None => true,
};

// 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.
Expand Down Expand Up @@ -637,14 +652,20 @@ pub fn run() {
}

// Keep launch-time agent restoration off the synchronous setup path
// so the frontend can mount and reveal the window promptly.
tauri::async_runtime::spawn(async move {
if let Err(error) =
restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref()).await
{
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
}
});
// so the frontend can mount and reveal the window promptly. Gated on
// the boot-time repos symlink result (see restore_agents above):
// skip when a configured repos_dir could not be resolved, so no
// agent clones into a REPOS that isn't the user's target.
if restore_agents {
tauri::async_runtime::spawn(async move {
if let Err(error) =
restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref())
.await
{
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
}
});
}

// Periodic sweep: reap orphaned agents from dead instances every 60s.
// Catches agents that escaped both the Justfile trap and boot-time
Expand Down Expand Up @@ -844,6 +865,7 @@ pub fn run() {
confirm_pairing_sas,
cancel_pairing,
apply_workspace,
validate_repos_dir,
get_active_workspace,
set_prevent_sleep_active,
get_agent_memory,
Expand Down
5 changes: 4 additions & 1 deletion desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ 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 repos::{
effective_repos_dir, ensure_repos_symlink, resolve_repos_at_boot, validate_repos_dir,
write_persisted_repos_dir,
};
pub use restore::*;
pub use runtime::*;
pub use storage::*;
Expand Down
Loading
Loading