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>
This commit is contained in:
Will Pfleger
2026-06-24 11:58:48 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 36d3d2ed1e
commit 86d7748ebd
10 changed files with 544 additions and 53 deletions
+1
View File
@@ -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,
+5 -4
View File
@@ -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],
+61 -29
View File
@@ -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)]
@@ -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,
@@ -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) ──────────────────
{
@@ -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);
}
+30 -8
View File
@@ -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.
@@ -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
@@ -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,
+4 -1
View File
@@ -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::*;
@@ -164,6 +164,117 @@ pub fn ensure_repos_setup_default(nest_root: &Path) -> Result<(), String> {
ensure_repos_symlink(nest_root, None)
}
/// Decide what `repos_dir` `apply_workspace` should persist and symlink for a
/// candidate value, running the single source-of-truth [`validate_repos_dir`].
///
/// - **`None`/empty candidate** → `Ok(None)`: no override; `REPOS` reverts to a
/// real in-nest directory.
/// - **valid candidate** → `Ok(Some(raw_trimmed))`: persist the *raw* trimmed
/// string (not the canonical path — persisting canonical would drift the
/// symlink target on `..`/symlinked-ancestor paths).
/// - **invalid candidate** → `Err(reason)`: the caller must persist `None`
/// (clearing the override so the next boot resolves clean and agent restore
/// proceeds) and surface `reason` to the user. Returning `Err` rather than
/// silently `Ok(None)` lets the caller emit the human-readable cause.
///
/// One validate call drives both the persisted value and the error: a bad path
/// is never persisted, so it can never silently skip agent restore on a later
/// boot. Pure (no FS mutation, no emit) so the persist decision is unit-tested.
pub fn effective_repos_dir(
nest_root: &Path,
candidate: Option<&str>,
) -> Result<Option<String>, String> {
let Some(trimmed) = candidate.map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(None);
};
validate_repos_dir(nest_root, trimmed).map(|_| Some(trimmed.to_string()))
}
/// Filename of the dotfile persisting the active workspace's `repos_dir`.
const REPOS_DIR_FILE: &str = ".repos-dir";
/// Read the persisted `repos_dir` from `nest_root/.repos-dir`.
///
/// Returns the trimmed value, or `None` when the file is absent, unreadable,
/// or empty. This is the backend's sole knowledge of `repos_dir` at boot —
/// the frontend persists it via [`write_persisted_repos_dir`] on every
/// `apply_workspace`, so [`resolve_repos_at_boot`] can resolve the `REPOS`
/// symlink before any agent is restored.
fn read_persisted_repos_dir(nest_root: &Path) -> Option<String> {
fs::read_to_string(nest_root.join(REPOS_DIR_FILE))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Persist the active workspace's `repos_dir` to `nest_root/.repos-dir`.
///
/// Writes the trimmed value (one line). A `None`/empty value clears the
/// override by removing the file, so a later boot reverts `REPOS` to a real
/// in-nest directory. Removing an absent file is not an error. Mirrors the
/// `.nest-agents-version` dotfile pattern.
pub fn write_persisted_repos_dir(nest_root: &Path, repos_dir: Option<&str>) -> Result<(), String> {
let path = nest_root.join(REPOS_DIR_FILE);
match repos_dir.map(str::trim).filter(|s| !s.is_empty()) {
Some(value) => fs::write(&path, format!("{value}\n"))
.map_err(|e| format!("write {}: {e}", path.display())),
None => match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("remove {}: {e}", path.display())),
},
}
}
/// Decide whether launch-time agent restore is safe given the boot symlink
/// outcome. Fails CLOSED: when a `repos_dir` was configured but its symlink
/// could not be resolved, restoring agents would let one clone into the empty
/// real `REPOS` that `ensure_nest` provisioned — the wrong location — and once
/// it is non-empty [`ensure_repos_symlink`] refuses forever, permanently
/// re-triggering the race. Skipping restore is recoverable on the next boot
/// once the external target is reachable; a misplaced clone is not.
///
/// The no-`repos_dir` case (`persisted_present == false`) is always safe: the
/// real in-nest `REPOS` default is exactly where clones belong.
fn should_restore_agents(persisted_present: bool, symlink_result: &Result<(), String>) -> bool {
!(persisted_present && symlink_result.is_err())
}
/// Resolve the `REPOS` symlink at boot from the persisted `repos_dir` and
/// report whether agent restore is safe to proceed.
///
/// Runs in the synchronous setup hook, after `ensure_nest` and before the
/// async agent restore is spawned, so `REPOS` is the user's configured symlink
/// before any agent can clone. Logs on failure (no toast path exists
/// pre-mount) and returns the fail-closed [`should_restore_agents`] decision.
pub fn resolve_repos_at_boot(nest_root: &Path) -> bool {
let persisted = read_persisted_repos_dir(nest_root);
let symlink_result = ensure_repos_symlink(nest_root, persisted.as_deref());
if let Err(error) = &symlink_result {
eprintln!("buzz-desktop: repos dir setup failed at boot: {error}");
}
let restore = should_restore_agents(persisted.is_some(), &symlink_result);
// Log the resolved outcome on success so a healthy boot is observable (the
// Err/skip branches already log; previously success was silent).
if symlink_result.is_ok() {
match persisted.as_deref() {
Some(dir) => eprintln!(
"buzz-desktop: repos dir resolved at boot — REPOS symlinked to configured `{dir}`"
),
None => eprintln!(
"buzz-desktop: repos dir resolved at boot — no configured override, REPOS is the default real dir"
),
}
}
if !restore {
eprintln!(
"buzz-desktop: skipping agent restore — configured repos_dir `{}` could not be resolved at boot; will retry on next launch",
persisted.as_deref().unwrap_or_default()
);
}
restore
}
#[cfg(test)]
mod tests {
use super::*;
@@ -383,6 +494,284 @@ mod tests {
);
}
// ── persisted repos_dir dotfile ───────────────────────────────────────
#[test]
fn persisted_repos_dir_roundtrips_write_read_clear() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join(".buzz");
fs::create_dir_all(&root).unwrap();
assert_eq!(read_persisted_repos_dir(&root), None, "absent → None");
write_persisted_repos_dir(&root, Some(" /Users/me/Development ")).unwrap();
assert_eq!(
read_persisted_repos_dir(&root).as_deref(),
Some("/Users/me/Development"),
"value is trimmed on write/read"
);
write_persisted_repos_dir(&root, None).unwrap();
assert_eq!(read_persisted_repos_dir(&root), None, "cleared → None");
assert!(
!root.join(".repos-dir").exists(),
"clearing removes the dotfile"
);
}
#[test]
fn persisted_repos_dir_empty_value_clears() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join(".buzz");
fs::create_dir_all(&root).unwrap();
write_persisted_repos_dir(&root, Some("/Users/me/Development")).unwrap();
write_persisted_repos_dir(&root, Some(" ")).unwrap();
assert_eq!(
read_persisted_repos_dir(&root),
None,
"a whitespace-only value clears the override"
);
}
#[test]
fn persisted_repos_dir_clear_when_absent_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join(".buzz");
fs::create_dir_all(&root).unwrap();
write_persisted_repos_dir(&root, None).expect("clearing an absent dotfile is not an error");
}
#[cfg(unix)]
#[test]
fn boot_resolves_symlink_from_persisted_value_into_empty_repos() {
// Mirrors the boot sequence: ensure_nest leaves REPOS an empty real
// dir, then the setup hook reads the persisted value and symlinks.
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();
write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap();
let persisted = read_persisted_repos_dir(&root);
ensure_repos_symlink(&root, persisted.as_deref()).unwrap();
let repos = root.join("REPOS");
assert!(
repos.symlink_metadata().unwrap().file_type().is_symlink(),
"boot must convert the empty real REPOS into a symlink"
);
assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap());
}
#[cfg(unix)]
#[test]
fn boot_leaves_already_correct_symlink_untouched() {
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();
write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap();
// First boot converts; second boot must be a noop.
let persisted = read_persisted_repos_dir(&root);
ensure_repos_symlink(&root, persisted.as_deref()).unwrap();
ensure_repos_symlink(&root, persisted.as_deref()).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 boot_with_cleared_value_reverts_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();
fs::write(external.join("KEEP.md"), "data").unwrap();
// Configure, then clear the field.
write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap();
ensure_repos_symlink(&root, read_persisted_repos_dir(&root).as_deref()).unwrap();
write_persisted_repos_dir(&root, None).unwrap();
// Next boot reads None and reverts REPOS to a real in-nest dir.
ensure_repos_symlink(&root, read_persisted_repos_dir(&root).as_deref()).unwrap();
let repos = root.join("REPOS");
assert!(
!repos.symlink_metadata().unwrap().file_type().is_symlink(),
"clearing the persisted value reverts REPOS to a real dir"
);
assert!(
external.join("KEEP.md").exists(),
"reverting must not touch the external target's contents"
);
}
#[cfg(unix)]
#[test]
fn effective_repos_dir_drives_persisted_dotfile_for_all_three_cases() {
// Pins the CRITICAL persist decision on `.repos-dir` CONTENTS, not just
// a return value: a bad path must clear the dotfile so the next boot's
// `should_restore_agents(false, _)` restores agents (the regression
// this hardening fixes). Drives each case through the same
// effective→persist path `apply_workspace` uses.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join(".buzz");
fs::create_dir_all(&root).unwrap();
let good = tmp.path().join("Development");
fs::create_dir_all(&good).unwrap();
let good_str = good.to_str().unwrap();
// Pre-seed a value so each case proves it overwrites/clears, not just
// that an absent dotfile stays absent.
let seed = |root: &Path| write_persisted_repos_dir(root, Some(good_str)).unwrap();
let persist = |root: &Path, candidate: Option<&str>| {
let effective = effective_repos_dir(root, candidate);
// Mirror apply_workspace: Err clears the override (persist None).
let to_persist = effective.unwrap_or(None);
write_persisted_repos_dir(root, to_persist.as_deref()).unwrap();
};
// Bad path → Err → dotfile cleared (the CRITICAL).
seed(&root);
persist(&root, Some("/no/such/dir/here"));
assert_eq!(
read_persisted_repos_dir(&root),
None,
"a bad repos_dir must clear `.repos-dir` so the next boot restores agents"
);
// Good path → Ok(Some(raw)) → dotfile holds the raw trimmed value.
persist(&root, Some(&format!(" {good_str} ")));
assert_eq!(
read_persisted_repos_dir(&root).as_deref(),
Some(good_str),
"a valid repos_dir must persist the raw trimmed path (not the canonical path)"
);
// Empty/whitespace → Ok(None) → dotfile cleared.
seed(&root);
persist(&root, Some(" "));
assert_eq!(
read_persisted_repos_dir(&root),
None,
"an empty repos_dir clears the override"
);
// None candidate → Ok(None) → dotfile cleared.
seed(&root);
persist(&root, None);
assert_eq!(
read_persisted_repos_dir(&root),
None,
"no repos_dir clears the override"
);
}
// ── resolve_repos_at_boot (boot sequence + fail-closed gate) ──────────
#[cfg(unix)]
#[test]
fn resolve_repos_at_boot_converts_empty_real_repos_and_allows_restore() {
// Drives the real boot sequence: ensure_repos_setup_default (called by
// ensure_nest) provisions REPOS as an empty real dir, then the setup
// hook calls resolve_repos_at_boot. Asserts the convert happens at that
// position and restore is allowed.
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();
write_persisted_repos_dir(&root, Some(external.to_str().unwrap())).unwrap();
ensure_repos_setup_default(&root).unwrap();
let repos = root.join("REPOS");
assert!(
repos.is_dir() && !repos.symlink_metadata().unwrap().file_type().is_symlink(),
"ensure_nest provisions REPOS as an empty real dir before the boot resolve"
);
let restore = resolve_repos_at_boot(&root);
assert!(restore, "restore proceeds when the symlink resolves");
assert!(
repos.symlink_metadata().unwrap().file_type().is_symlink(),
"boot resolve converts the empty real REPOS into the configured symlink"
);
assert_eq!(repos.read_link().unwrap(), external.canonicalize().unwrap());
}
#[cfg(unix)]
#[test]
fn resolve_repos_at_boot_fails_closed_when_target_unresolvable() {
// Persisted repos_dir whose target does not exist at boot (transiently
// unavailable external volume) → ensure_repos_symlink Errs. Restore must
// be skipped and REPOS left as the empty real dir, never the symlink, so
// no agent clones into the wrong place.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join(".buzz");
fs::create_dir_all(&root).unwrap();
let missing = tmp.path().join("not-mounted-yet");
write_persisted_repos_dir(&root, Some(missing.to_str().unwrap())).unwrap();
ensure_repos_setup_default(&root).unwrap();
let restore = resolve_repos_at_boot(&root);
assert!(
!restore,
"restore must be skipped when a configured repos_dir cannot resolve at boot"
);
let repos = root.join("REPOS");
assert!(
!repos.symlink_metadata().unwrap().file_type().is_symlink(),
"REPOS must not become a symlink to an unresolved target"
);
}
#[cfg(unix)]
#[test]
fn resolve_repos_at_boot_allows_restore_with_no_repos_dir() {
// No configured repos_dir → the real in-nest REPOS default is correct,
// restore proceeds normally (the fail-closed gate must not fire here).
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join(".buzz");
fs::create_dir_all(&root).unwrap();
ensure_repos_setup_default(&root).unwrap();
assert!(
resolve_repos_at_boot(&root),
"restore proceeds when no repos_dir is configured"
);
}
#[test]
fn should_restore_agents_only_blocks_configured_unresolved_boot() {
assert!(
should_restore_agents(false, &Ok(())),
"no repos_dir, symlink ok → restore"
);
assert!(
should_restore_agents(false, &Err("boom".into())),
"no repos_dir → real-dir default is correct even on Err → restore"
);
assert!(
should_restore_agents(true, &Ok(())),
"configured repos_dir resolved → restore"
);
assert!(
!should_restore_agents(true, &Err("boom".into())),
"configured repos_dir unresolved → fail closed, skip restore"
);
}
/// Test helper: canonicalize a path, creating it as a directory first.
#[cfg(unix)]
trait CanonicalizeOrMake {
@@ -6,6 +6,7 @@ import {
expandTilde,
normalizeRelayUrl,
} from "@/features/workspaces/workspaceStorage";
import { validateReposDir } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -31,6 +32,7 @@ export function AddWorkspaceDialog({
const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState("");
const [reposDir, setReposDir] = React.useState("");
const [reposDirError, setReposDirError] = React.useState<string | null>(null);
const handleClose = React.useCallback(() => {
onOpenChange(false);
@@ -38,6 +40,7 @@ export function AddWorkspaceDialog({
setRelayUrl("");
setToken("");
setReposDir("");
setReposDirError(null);
}, [onOpenChange]);
const handleSubmit = React.useCallback(
@@ -48,8 +51,16 @@ export function AddWorkspaceDialog({
}
// Expand `~` before save — the backend rejects tilde paths. Empty input
// resolves to `undefined` so REPOS keeps its default location.
// resolves to `undefined` so REPOS keeps its default location. Validate
// the expanded value (the bytes the backend canonicalizes) before save
// so a bad path is caught here instead of bricking a later boot.
const expandedReposDir = await expandTilde(reposDir);
try {
await validateReposDir(expandedReposDir ?? "");
} catch (error) {
setReposDirError(String(error));
return;
}
const workspace: Workspace = {
id: crypto.randomUUID(),
@@ -144,11 +155,17 @@ export function AddWorkspaceDialog({
</label>
<Input
id="ws-repos-dir"
onChange={(e) => setReposDir(e.target.value)}
onChange={(e) => {
setReposDir(e.target.value);
setReposDirError(null);
}}
placeholder="~/Development"
type="text"
value={reposDir}
/>
{reposDirError ? (
<p className="text-xs text-destructive">{reposDirError}</p>
) : null}
<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
@@ -5,6 +5,7 @@ import {
expandTilde,
normalizeRelayUrl,
} from "@/features/workspaces/workspaceStorage";
import { validateReposDir } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import {
Dialog,
@@ -41,6 +42,7 @@ export function EditWorkspaceDialog({
const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState("");
const [reposDir, setReposDir] = React.useState("");
const [reposDirError, setReposDirError] = React.useState<string | null>(null);
// Sync form state when the dialog opens with a workspace
React.useEffect(() => {
@@ -49,6 +51,7 @@ export function EditWorkspaceDialog({
setRelayUrl(workspace.relayUrl);
setToken(workspace.token ?? "");
setReposDir(workspace.reposDir ?? "");
setReposDirError(null);
}
}, [workspace, open]);
@@ -84,10 +87,18 @@ export function EditWorkspaceDialog({
// 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.
// real dir). Validate the expanded value (the bytes the backend
// canonicalizes) before save so a bad path is caught here instead of
// bricking a later boot. 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) {
try {
await validateReposDir(expandedReposDir ?? "");
} catch (error) {
setReposDirError(String(error));
return;
}
updates.reposDir = expandedReposDir;
}
@@ -185,11 +196,17 @@ export function EditWorkspaceDialog({
</label>
<Input
id="edit-ws-repos-dir"
onChange={(e) => setReposDir(e.target.value)}
onChange={(e) => {
setReposDir(e.target.value);
setReposDirError(null);
}}
placeholder="~/Development"
type="text"
value={reposDir}
/>
{reposDirError ? (
<p className="text-xs text-destructive">{reposDirError}</p>
) : null}
<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
@@ -139,12 +139,15 @@ export function useWorkspaceInit(
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`.
// A bad `repos_dir` no longer reaches here — `apply_workspace` treats
// it as non-fatal (relay/keys apply, bad value not persisted, REPOS
// falls back to a real dir, a `repos-dir-error` toast surfaces it) and
// returns Ok, so the app boots into a working state where the user can
// fix the value in workspace settings. This catch now only fires on a
// genuine relay/key apply failure (e.g. an invalid nsec or a poisoned
// lock). For those, marking the workspace ready would render
// workspace-scoped UI against a backend that never applied — park on
// the loading gate (isReady:false, no appliedKey) instead.
console.error("Failed to apply workspace to backend:", error);
if (!cancelled) {
setResult({ isReady: false, needsSetup: false, appliedKey: null });
+6
View File
@@ -1188,5 +1188,11 @@ export async function applyWorkspace(
});
}
// Validate a candidate repos dir without mutating the filesystem. Rejects
// with a human-readable reason; resolves for a valid or empty path.
export async function validateReposDir(dir: string): Promise<void> {
await invokeTauri("validate_repos_dir", { dir });
}
export const setPreventSleepActive = (active: boolean) =>
invokeTauri("set_prevent_sleep_active", { active });