Skip to content

Commit 86d7748

Browse files
wpfleger96npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
andauthored
fix(desktop): resolve repos_dir symlink at boot before agent restore (#1231)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
1 parent 36d3d2e commit 86d7748

10 files changed

Lines changed: 544 additions & 53 deletions

File tree

crates/buzz-relay/src/handlers/event.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,7 @@ mod tests {
10651065
CancellationToken::new(),
10661066
Arc::new(AtomicU8::new(0)),
10671067
Arc::new(Mutex::new(HashMap::new())),
1068+
3,
10681069
);
10691070
state.sub_registry.register(
10701071
conn_id,

desktop/scripts/check-file-sizes.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,11 @@ const overrides = new Map([
4141
["src-tauri/src/managed_agents/runtime.rs", 1953],
4242
["src-tauri/src/managed_agents/personas.rs", 1080],
4343
["src-tauri/src/managed_agents/persona_card.rs", 1050],
44-
// applyWorkspace reposDir parameter threaded through the Tauri invoke for
45-
// configurable repos_dir — a 3-line overage from load-bearing parameter
46-
// plumbing, not generic debt growth. Approved override; still queued to split.
47-
["src/shared/api/tauri.ts", 1198],
44+
// applyWorkspace reposDir parameter plus the validateReposDir binding,
45+
// threaded through Tauri invokes for configurable repos_dir — a 4-line
46+
// overage from load-bearing parameter plumbing, not generic debt growth.
47+
// Approved override; still queued to split.
48+
["src/shared/api/tauri.ts", 1199],
4849
["src-tauri/src/nostr_convert.rs", 1126],
4950
["src/shared/api/relayClientSession.ts", 1022],
5051
["src-tauri/src/migration.rs", 1295],

desktop/src-tauri/src/commands/workspace.rs

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ use serde::Serialize;
33
use tauri::{AppHandle, Emitter, State};
44

55
use crate::app_state::AppState;
6-
use crate::managed_agents::{ensure_repos_symlink, nest_dir, try_regenerate_nest};
6+
use crate::managed_agents::{
7+
effective_repos_dir, ensure_repos_symlink, nest_dir, try_regenerate_nest,
8+
write_persisted_repos_dir,
9+
};
710
use crate::relay;
811

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

29+
/// Validate a candidate `repos_dir` without mutating the filesystem.
30+
///
31+
/// The Add/Edit workspace dialogs call this on submit to block Save on a bad
32+
/// path, so a typo never reaches `apply_workspace`. Reuses the same
33+
/// `validate_repos_dir` the boot/apply path uses — one source of truth for
34+
/// "what's a valid repos dir". An empty/whitespace value clears the override
35+
/// and is valid. `Err` carries the human-readable reason for inline display.
36+
#[tauri::command]
37+
pub fn validate_repos_dir(dir: String) -> Result<(), String> {
38+
let trimmed = dir.trim();
39+
if trimmed.is_empty() {
40+
return Ok(());
41+
}
42+
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
43+
crate::managed_agents::validate_repos_dir(&nest, trimmed).map(|_| ())
44+
}
45+
2646
/// Apply a workspace's configuration to the backend session.
2747
///
2848
/// Called by the frontend on app init (after reload) to configure the
2949
/// Tauri backend with the selected workspace's relay URL, keys, and repos
3050
/// directory.
3151
///
32-
/// Validation runs before any state mutation: an invalid `repos_dir` (bad
33-
/// path) rejects cleanly with nothing applied. The `REPOS` symlink itself is
34-
/// a filesystem *side-effect* — its failure (e.g. a non-empty real `REPOS`
35-
/// refusing a downgrade, or a renamed external target on a later launch) is
36-
/// non-fatal: relay/keys still apply, the command returns `Ok`, and a
37-
/// `repos-dir-error` event surfaces the failure to the frontend.
52+
/// A bad `repos_dir` is non-fatal: relay/keys always apply (the relay is the
53+
/// active workspace's own choice — orthogonal to the filesystem repos dir),
54+
/// the bad value is NOT persisted (so the next boot starts clean), the
55+
/// `REPOS` symlink is skipped (REPOS stays a real dir), a `repos-dir-error`
56+
/// event surfaces the reason, and the command returns `Ok`. The dialogs
57+
/// already block a bad path at Save (`validate_repos_dir`); this fallback only
58+
/// catches a value that went bad after save (deleted dir, unmounted volume).
3859
#[tauri::command]
3960
pub fn apply_workspace(
4061
relay_url: String,
@@ -51,23 +72,25 @@ pub fn apply_workspace(
5172
None => None,
5273
};
5374

54-
// Normalize repos_dir to a trimmed non-empty value. `None`/empty clears
55-
// the override (REPOS falls back to a real dir). A bad path is rejected
56-
// here — before any mutation — so the dialog sees a clean Err.
57-
let repos_dir = repos_dir
58-
.map(|s| s.trim().to_string())
59-
.filter(|s| !s.is_empty());
60-
if let Some(dir) = repos_dir.as_deref() {
61-
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
62-
// Validate without mutating the filesystem. Keeps the command's
63-
// "validate-first, nothing below can fail" contract honest. Also emit
64-
// the error so it surfaces even at the init call site (which swallows
65-
// the returned Err to console for the relay/keys path).
66-
if let Err(error) = crate::managed_agents::validate_repos_dir(&nest, dir) {
67-
let _ = app.emit("repos-dir-error", error.clone());
68-
return Err(error);
69-
}
70-
}
75+
// Decide the effective repos_dir from the candidate. A bad path does NOT
76+
// reject — it is treated as if no override were set: relay/keys still
77+
// apply, the bad value is not persisted, and a `repos-dir-error` surfaces
78+
// the reason. Persisting a bad path would make every later boot read it,
79+
// fail to resolve the symlink, and silently skip agent restore. One
80+
// validate (inside `effective_repos_dir`) drives both the emit and the
81+
// persisted value. `nest` is resolved softly: when absent there is nothing
82+
// to persist or symlink, and relay/keys must still apply unconditionally.
83+
let nest = nest_dir();
84+
let effective_repos_dir = match nest.as_deref() {
85+
Some(nest) => match effective_repos_dir(nest, repos_dir.as_deref()) {
86+
Ok(value) => value,
87+
Err(error) => {
88+
let _ = app.emit("repos-dir-error", error);
89+
None
90+
}
91+
},
92+
None => None,
93+
};
7194

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

83106
// ── Filesystem side-effect (non-fatal) ────────────────────────────────
84-
// Re-point REPOS to match repos_dir. Failure here (downgrade refused,
85-
// external target gone) must NOT fail the command — relay/keys are already
86-
// applied. Surface it via a `repos-dir-error` event the frontend toasts.
87-
if let Some(nest) = nest_dir() {
88-
if let Err(error) = ensure_repos_symlink(&nest, repos_dir.as_deref()) {
107+
// Persist the *effective* repos_dir (None when the candidate failed
108+
// validation) for the backend to read at boot, then re-point REPOS to
109+
// match. Persisting first makes the dotfile authoritative even if the
110+
// symlink apply fails here (e.g. a non-empty real REPOS): the next boot
111+
// reads the persisted value and resolves the symlink before any agent can
112+
// clone into REPOS. A bad candidate persists `None`, so the next boot is
113+
// clean and agent restore proceeds. Failure of either must NOT fail the
114+
// command — relay/keys are already applied. Surface symlink errors via
115+
// `repos-dir-error`.
116+
if let Some(nest) = nest.as_deref() {
117+
if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) {
118+
eprintln!("buzz-desktop: persist repos dir failed: {error}");
119+
}
120+
if let Err(error) = ensure_repos_symlink(nest, effective_repos_dir.as_deref()) {
89121
eprintln!("buzz-desktop: repos dir setup failed: {error}");
90122
let _ = app.emit("repos-dir-error", error);
91123
}

desktop/src-tauri/src/lib.rs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,21 @@ pub fn run() {
586586
eprintln!("buzz-desktop: failed to create nest: {error}");
587587
}
588588

589+
// Resolve the REPOS symlink from the persisted repos_dir BEFORE
590+
// agents are restored below, and decide whether restore is safe.
591+
// The frontend's apply_workspace runs only after React mounts —
592+
// later than the async agent restore — so without this an agent
593+
// could clone into the empty real REPOS dir, and once REPOS is
594+
// non-empty ensure_repos_symlink refuses forever. resolve_repos_at_boot
595+
// fails closed: if a repos_dir was configured but its symlink could
596+
// not be resolved (transiently unavailable external volume), it
597+
// returns false so we skip restore this launch rather than let an
598+
// agent clone into the wrong REPOS. See managed_agents::repos.
599+
let restore_agents = match managed_agents::nest_dir() {
600+
Some(nest) => managed_agents::resolve_repos_at_boot(&nest),
601+
None => true,
602+
};
603+
589604
// Carry the agent's knowledge from the legacy nest (~/.sprout) into
590605
// the live nest (~/.buzz) after it exists. Must run after
591606
// ensure_nest() so the destination is present. Non-fatal.
@@ -637,14 +652,20 @@ pub fn run() {
637652
}
638653

639654
// Keep launch-time agent restoration off the synchronous setup path
640-
// so the frontend can mount and reveal the window promptly.
641-
tauri::async_runtime::spawn(async move {
642-
if let Err(error) =
643-
restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref()).await
644-
{
645-
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
646-
}
647-
});
655+
// so the frontend can mount and reveal the window promptly. Gated on
656+
// the boot-time repos symlink result (see restore_agents above):
657+
// skip when a configured repos_dir could not be resolved, so no
658+
// agent clones into a REPOS that isn't the user's target.
659+
if restore_agents {
660+
tauri::async_runtime::spawn(async move {
661+
if let Err(error) =
662+
restore_managed_agents_on_launch(&app_handle, shutdown_started.as_ref())
663+
.await
664+
{
665+
eprintln!("buzz-desktop: failed to restore managed agents: {error}");
666+
}
667+
});
668+
}
648669

649670
// Periodic sweep: reap orphaned agents from dead instances every 60s.
650671
// Catches agents that escaped both the Justfile trap and boot-time
@@ -844,6 +865,7 @@ pub fn run() {
844865
confirm_pairing_sas,
845866
cancel_pairing,
846867
apply_workspace,
868+
validate_repos_dir,
847869
get_active_workspace,
848870
set_prevent_sleep_active,
849871
get_agent_memory,

desktop/src-tauri/src/managed_agents/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ pub use personas::*;
2727
pub use process_lifecycle::*;
2828
#[cfg(feature = "mesh-llm")]
2929
pub use relay_mesh::*;
30-
pub use repos::{ensure_repos_symlink, validate_repos_dir};
30+
pub use repos::{
31+
effective_repos_dir, ensure_repos_symlink, resolve_repos_at_boot, validate_repos_dir,
32+
write_persisted_repos_dir,
33+
};
3134
pub use restore::*;
3235
pub use runtime::*;
3336
pub use storage::*;

0 commit comments

Comments
 (0)