mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
36d3d2ed1e
commit
86d7748ebd
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user