mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
feat(desktop): isolate agent repos dirs per workspace
Make agents immune to the shared REPOS symlink re-pointing on workspace switches (Phase 4 of lazy multi-workspace agents, building on the Phase 1-3 relay pinning, lazy activation, and UI scoping): - Persist repos_dir per relay URL in a new .repos-dirs.json nest dotfile (keys normalized like agent relay pins), written by every apply_workspace alongside the single-value .repos-dir that keeps driving the boot-time symlink resolve. The REPOS symlink itself keeps following the active workspace as a human/tooling convention. - At spawn, resolve the agent's own workspace entry (keyed by its effective relay) to a canonical real path and hand it to the child as BUZZ_REPOS_DIR, so a mid-task switch that re-points REPOS can no longer land a workspace-A agent in workspace B's checkouts. A configured-but-unresolvable entry fails the spawn closed (same rationale as resolve_repos_at_boot); no entry falls back to the nest REPOS path, preserving pre-map behavior. - Adjust the nest AGENTS.md instructions (template v5) that referenced REPOS/ relatively: agents are told to address checkouts through $BUZZ_REPOS_DIR whenever it is set. - Hash the raw per-relay map value into the spawn-config hash so a repos-dir edit badges needsRestart while a pure workspace switch (and filesystem state alone) cannot. - Migrate the map entry in rebind_agent_relay when a community's relay URL is edited, so rebound agents keep their repos-dir isolation. - Split the grown repos.rs test module into repos/tests.rs (file-size guard), mirroring nest/tests.rs and spawn_hash/tests.rs. Tested: cargo test --manifest-path desktop/src-tauri/Cargo.toml (1433 passed, incl. 7 new repos-map/resolution tests and 2 new spawn-hash tests), clippy --all-targets -D warnings, rustfmt --check, cargo check --features mesh-llm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
committed by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
ee40613898
commit
81cd8d0dfc
@@ -6,8 +6,9 @@ use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use crate::app_state::AppState;
|
||||
use crate::managed_agents::{
|
||||
activate_workspace_agents, effective_repos_dir, ensure_repos_symlink, load_managed_agents,
|
||||
nest_dir, rebind_agent_relay_urls, save_managed_agents, stamp_blank_agent_relay_urls,
|
||||
try_regenerate_nest, write_persisted_repos_dir,
|
||||
nest_dir, persist_workspace_repos_dir, rebind_agent_relay_urls, rebind_workspace_repos_dir,
|
||||
save_managed_agents, stamp_blank_agent_relay_urls, try_regenerate_nest,
|
||||
write_persisted_repos_dir,
|
||||
};
|
||||
use crate::relay;
|
||||
|
||||
@@ -180,6 +181,18 @@ pub async fn apply_workspace(
|
||||
if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) {
|
||||
eprintln!("buzz-desktop: persist repos dir failed: {error}");
|
||||
}
|
||||
// Per-relay map entry for THIS workspace (Phase 4 — REPOS
|
||||
// isolation): spawn resolves an agent's repos dir from this map
|
||||
// by the agent's pinned relay, so agents stay immune to the REPOS
|
||||
// symlink re-point below when a later switch moves it. Must land
|
||||
// before the activation task spawned after this closure starts
|
||||
// this workspace's agents. A bad candidate clears the entry, same
|
||||
// as the dotfile above.
|
||||
if let Err(error) =
|
||||
persist_workspace_repos_dir(nest, &relay_url, effective_repos_dir.as_deref())
|
||||
{
|
||||
eprintln!("buzz-desktop: persist per-relay 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);
|
||||
@@ -274,6 +287,16 @@ pub async fn rebind_agent_relay(
|
||||
if changed > 0 {
|
||||
save_managed_agents(&app, &records)?;
|
||||
}
|
||||
// Carry the workspace's per-relay repos_dir along with its agents —
|
||||
// spawn resolves BUZZ_REPOS_DIR from the map by pinned relay, so
|
||||
// leaving the entry on the old URL would silently drop repos-dir
|
||||
// isolation for every rebound agent. Non-fatal: the next
|
||||
// apply_workspace of the edited community re-stamps the entry.
|
||||
if let Some(nest) = nest_dir() {
|
||||
if let Err(error) = rebind_workspace_repos_dir(&nest, &old_relay_url, &new_relay_url) {
|
||||
eprintln!("buzz-desktop: rebind per-relay repos dir failed: {error}");
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -63,8 +63,9 @@ pub(crate) use readiness::{
|
||||
pub use relay_mesh::*;
|
||||
pub(crate) use relay_pinning::{rebind_agent_relay_urls, stamp_blank_agent_relay_urls};
|
||||
pub use repos::{
|
||||
effective_repos_dir, ensure_repos_symlink, resolve_repos_at_boot, validate_repos_dir,
|
||||
write_persisted_repos_dir,
|
||||
effective_repos_dir, ensure_repos_symlink, persist_workspace_repos_dir,
|
||||
rebind_workspace_repos_dir, resolve_agent_repos_dir, resolve_repos_at_boot, validate_repos_dir,
|
||||
workspace_repos_dir_for_relay, write_persisted_repos_dir,
|
||||
};
|
||||
pub use restore::*;
|
||||
pub use runtime::*;
|
||||
|
||||
@@ -46,7 +46,7 @@ const BUZZ_CLI_SKILL_MD: &str = include_str!("nest_skill.md");
|
||||
/// Template content version for AGENTS.md static content (above managed markers).
|
||||
/// Bump this when changing `nest_agents.md` to trigger refresh on existing installs.
|
||||
/// Version 1 is implicitly "before this mechanism existed" (no version file).
|
||||
const NEST_AGENTS_VERSION: u32 = 4;
|
||||
const NEST_AGENTS_VERSION: u32 = 5;
|
||||
|
||||
/// Template content version for SKILL.md.
|
||||
/// Bump this when changing `nest_skill.md` to trigger refresh on existing installs.
|
||||
|
||||
@@ -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/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does |
|
||||
| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does. If `BUZZ_REPOS_DIR` is set in your environment, use that absolute path instead of `REPOS/` — see Core Guidelines |
|
||||
| `.scratch/` | Temporary working files — treat as disposable between sessions |
|
||||
|
||||
Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_FLOW_NOTES.md`).
|
||||
@@ -37,6 +37,7 @@ created: 2026-01-15
|
||||
|
||||
## Core Guidelines
|
||||
|
||||
- **Reach source checkouts via `$BUZZ_REPOS_DIR`** — when set, this env var is the absolute path to *your* workspace's repos directory. Always address checkouts through it (e.g. `"$BUZZ_REPOS_DIR"/project`), never the relative `REPOS/` link: the `REPOS` symlink follows whichever workspace is active in the desktop app and can re-point mid-task. Only fall back to `REPOS/` when the env var is unset
|
||||
- **Local first** — check `RESEARCH/`, `GUIDES/`, `PLANS/` before external searches
|
||||
- **Write findings down** — if you research something, save it to `RESEARCH/`
|
||||
- **Cite sources** — no claim without a path, link, or reference
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! The symlink follows the *active* workspace — a human/tooling convention.
|
||||
//! Agents are made immune to it: every applied workspace's `repos_dir` is
|
||||
//! also persisted per relay URL ([`persist_workspace_repos_dir`]), and spawn
|
||||
//! resolves the agent's own workspace entry to a real path
|
||||
//! ([`resolve_agent_repos_dir`]) so a later workspace switch re-pointing
|
||||
//! `REPOS` cannot move a running agent into another workspace's checkouts.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -229,6 +237,155 @@ pub fn write_persisted_repos_dir(nest_root: &Path, repos_dir: Option<&str>) -> R
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-relay repos_dir map ───────────────────────────────────────────────
|
||||
|
||||
/// Filename of the dotfile persisting every workspace's `repos_dir`, keyed by
|
||||
/// normalized relay URL. Distinct from [`REPOS_DIR_FILE`], which holds only
|
||||
/// the *active* workspace's value for the boot-time symlink resolve.
|
||||
const REPOS_DIR_MAP_FILE: &str = ".repos-dirs.json";
|
||||
|
||||
/// Read the per-relay `repos_dir` map from `nest_root/.repos-dirs.json`.
|
||||
///
|
||||
/// Keys are normalized relay URLs ([`crate::relay::normalize_relay_url`]);
|
||||
/// values are raw trimmed `repos_dir` strings, exactly as persisted by
|
||||
/// [`persist_workspace_repos_dir`]. An absent, unreadable, or malformed file
|
||||
/// yields an empty map — the map is an isolation layer over spawn, not a boot
|
||||
/// dependency, so it degrades to the pre-map default rather than failing.
|
||||
/// Keys are re-normalized and empty entries dropped on read, as defense
|
||||
/// against a hand-edited file.
|
||||
fn read_repos_dir_map(nest_root: &Path) -> BTreeMap<String, String> {
|
||||
let Ok(raw) = fs::read_to_string(nest_root.join(REPOS_DIR_MAP_FILE)) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
let Ok(parsed) = serde_json::from_str::<BTreeMap<String, String>>(&raw) else {
|
||||
eprintln!("buzz-desktop: {REPOS_DIR_MAP_FILE} is malformed; ignoring");
|
||||
return BTreeMap::new();
|
||||
};
|
||||
parsed
|
||||
.into_iter()
|
||||
.filter_map(|(relay, dir)| {
|
||||
let key = crate::relay::normalize_relay_url(&relay);
|
||||
let value = dir.trim().to_string();
|
||||
(!key.is_empty() && !value.is_empty()).then_some((key, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Persist the per-relay `repos_dir` map, removing the file when empty
|
||||
/// (mirrors [`write_persisted_repos_dir`]'s clear-by-removal).
|
||||
fn write_repos_dir_map(nest_root: &Path, map: &BTreeMap<String, String>) -> Result<(), String> {
|
||||
let path = nest_root.join(REPOS_DIR_MAP_FILE);
|
||||
if map.is_empty() {
|
||||
return match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("remove {}: {e}", path.display())),
|
||||
};
|
||||
}
|
||||
let json = serde_json::to_string_pretty(map)
|
||||
.map_err(|e| format!("serialize {REPOS_DIR_MAP_FILE}: {e}"))?;
|
||||
fs::write(&path, format!("{json}\n")).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Upsert (or clear, on `None`/empty) the applied workspace's `repos_dir` in
|
||||
/// the per-relay map.
|
||||
///
|
||||
/// Written on every `apply_workspace`, alongside the single-value
|
||||
/// [`REPOS_DIR_FILE`]: that dotfile drives the active-workspace `REPOS`
|
||||
/// symlink at boot, while this map is what makes agents immune to the symlink
|
||||
/// — spawn resolves the agent's own workspace entry via
|
||||
/// [`resolve_agent_repos_dir`], keyed by the agent's pinned relay. Values are
|
||||
/// stored raw (trimmed, not canonicalized) for the same reason
|
||||
/// [`effective_repos_dir`] persists raw.
|
||||
pub fn persist_workspace_repos_dir(
|
||||
nest_root: &Path,
|
||||
relay_url: &str,
|
||||
repos_dir: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let key = crate::relay::normalize_relay_url(relay_url);
|
||||
if key.is_empty() {
|
||||
return Err("cannot persist a repos dir for an empty relay URL".to_string());
|
||||
}
|
||||
let mut map = read_repos_dir_map(nest_root);
|
||||
match repos_dir.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
Some(value) => {
|
||||
if map.get(&key).map(String::as_str) == Some(value) {
|
||||
return Ok(());
|
||||
}
|
||||
map.insert(key, value.to_string());
|
||||
}
|
||||
None => {
|
||||
if map.remove(&key).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
write_repos_dir_map(nest_root, &map)
|
||||
}
|
||||
|
||||
/// Move a workspace's per-relay `repos_dir` entry when the community's relay
|
||||
/// URL is edited — the map analogue of `rebind_agent_relay_urls` for agent
|
||||
/// records. Without this, agents rebound to the new URL would resolve no map
|
||||
/// entry and silently fall back to the shared `REPOS` default. Returns
|
||||
/// whether an entry moved; moving onto an existing entry overwrites it (the
|
||||
/// edit just happened, so the edited community's value is freshest).
|
||||
pub fn rebind_workspace_repos_dir(
|
||||
nest_root: &Path,
|
||||
old_relay_url: &str,
|
||||
new_relay_url: &str,
|
||||
) -> Result<bool, String> {
|
||||
let old_key = crate::relay::normalize_relay_url(old_relay_url);
|
||||
let new_key = crate::relay::normalize_relay_url(new_relay_url);
|
||||
if new_key.is_empty() || old_key == new_key {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut map = read_repos_dir_map(nest_root);
|
||||
let Some(value) = map.remove(&old_key) else {
|
||||
return Ok(false);
|
||||
};
|
||||
map.insert(new_key, value);
|
||||
write_repos_dir_map(nest_root, &map)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The raw persisted `repos_dir` for the workspace identified by `relay_url`,
|
||||
/// or `None` when that workspace has no override (or has not been applied
|
||||
/// since the per-relay map was introduced). Raw and un-canonicalized so it is
|
||||
/// a stable spawn-config-hash input — filesystem state changes alone must not
|
||||
/// flip the restart badge; [`resolve_agent_repos_dir`] does the real-path
|
||||
/// resolution separately at spawn.
|
||||
pub fn workspace_repos_dir_for_relay(nest_root: &Path, relay_url: &str) -> Option<String> {
|
||||
read_repos_dir_map(nest_root).remove(&crate::relay::normalize_relay_url(relay_url))
|
||||
}
|
||||
|
||||
/// Resolve the repos directory a spawning agent should be handed
|
||||
/// (`BUZZ_REPOS_DIR`), from the per-relay map entry for the agent's effective
|
||||
/// relay.
|
||||
///
|
||||
/// - **Entry present** → the validated, canonical **real path** of that
|
||||
/// workspace's `repos_dir`. Real, so the agent stays in its own workspace's
|
||||
/// checkouts even while the `REPOS` symlink follows a different active
|
||||
/// workspace. An unresolvable entry (unmounted volume, deleted dir) is an
|
||||
/// `Err` — the spawn must fail CLOSED, mirroring [`resolve_repos_at_boot`]:
|
||||
/// a refused start is recoverable, work landing in another workspace's
|
||||
/// checkout is not.
|
||||
/// - **No entry** → the nest's `REPOS` path (the shared in-nest default).
|
||||
/// Deliberately NOT canonicalized: workspaces without an override share the
|
||||
/// in-nest `REPOS` by design, and baking in a symlink target that happens
|
||||
/// to be active now would be worse than the convention path.
|
||||
pub fn resolve_agent_repos_dir(nest_root: &Path, relay_url: &str) -> Result<PathBuf, String> {
|
||||
match workspace_repos_dir_for_relay(nest_root, relay_url) {
|
||||
Some(dir) => validate_repos_dir(nest_root, &dir).map_err(|reason| {
|
||||
format!(
|
||||
"repos dir `{dir}` configured for this agent's workspace ({relay_url}) cannot be \
|
||||
resolved: {reason}; refusing to start the agent so it cannot work in another \
|
||||
workspace's repos directory"
|
||||
)
|
||||
}),
|
||||
None => Ok(nest_root.join("REPOS")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -279,512 +436,4 @@ pub fn resolve_repos_at_boot(nest_root: &Path) -> bool {
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
714
desktop/src-tauri/src/managed_agents/repos/tests.rs
Normal file
714
desktop/src-tauri/src/managed_agents/repos/tests.rs
Normal file
@@ -0,0 +1,714 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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"
|
||||
);
|
||||
}
|
||||
|
||||
// ── per-relay repos_dir map ───────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn repos_dir_map_roundtrips_upsert_and_clear() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://relay-a.example"),
|
||||
None,
|
||||
"absent map → None"
|
||||
);
|
||||
|
||||
persist_workspace_repos_dir(&root, "wss://relay-a.example", Some(" /Users/me/DevA "))
|
||||
.unwrap();
|
||||
persist_workspace_repos_dir(&root, "wss://relay-b.example", Some("/Users/me/DevB")).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://relay-a.example").as_deref(),
|
||||
Some("/Users/me/DevA"),
|
||||
"value is trimmed on write"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://relay-b.example").as_deref(),
|
||||
Some("/Users/me/DevB"),
|
||||
"entries are independent per relay"
|
||||
);
|
||||
|
||||
// Clearing one workspace's override must not touch the other's.
|
||||
persist_workspace_repos_dir(&root, "wss://relay-a.example", None).unwrap();
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://relay-a.example"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://relay-b.example").as_deref(),
|
||||
Some("/Users/me/DevB")
|
||||
);
|
||||
|
||||
// Clearing the last entry removes the file entirely.
|
||||
persist_workspace_repos_dir(&root, "wss://relay-b.example", Some(" ")).unwrap();
|
||||
assert!(
|
||||
!root.join(".repos-dirs.json").exists(),
|
||||
"an empty map removes the dotfile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repos_dir_map_keys_are_normalized() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
|
||||
// Cosmetic URL differences (case, trailing slash) are the same relay:
|
||||
// a lookup must find the entry, and a re-persist must overwrite it
|
||||
// rather than minting a second entry.
|
||||
persist_workspace_repos_dir(&root, "WSS://Relay-A.Example/", Some("/Users/me/DevA")).unwrap();
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://relay-a.example").as_deref(),
|
||||
Some("/Users/me/DevA")
|
||||
);
|
||||
|
||||
persist_workspace_repos_dir(&root, "wss://relay-a.example", Some("/Users/me/Other")).unwrap();
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "WSS://Relay-A.Example/").as_deref(),
|
||||
Some("/Users/me/Other"),
|
||||
"cosmetic variants address the same entry"
|
||||
);
|
||||
assert_eq!(
|
||||
read_repos_dir_map(&root).len(),
|
||||
1,
|
||||
"no duplicate entries for cosmetic variants"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repos_dir_map_rejects_empty_relay_and_survives_malformed_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
|
||||
assert!(
|
||||
persist_workspace_repos_dir(&root, " ", Some("/Users/me/DevA")).is_err(),
|
||||
"an empty relay URL has no identity to key on"
|
||||
);
|
||||
|
||||
fs::write(root.join(".repos-dirs.json"), "not-json{{{").unwrap();
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://relay-a.example"),
|
||||
None,
|
||||
"a malformed map degrades to empty rather than failing"
|
||||
);
|
||||
persist_workspace_repos_dir(&root, "wss://relay-a.example", Some("/Users/me/DevA")).unwrap();
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://relay-a.example").as_deref(),
|
||||
Some("/Users/me/DevA"),
|
||||
"persisting over a malformed file recovers it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebind_workspace_repos_dir_moves_entry_to_new_relay() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
persist_workspace_repos_dir(&root, "wss://old.example", Some("/Users/me/DevA")).unwrap();
|
||||
|
||||
let moved =
|
||||
rebind_workspace_repos_dir(&root, "wss://old.example/", "WSS://New.Example").unwrap();
|
||||
|
||||
assert!(moved);
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://old.example"),
|
||||
None,
|
||||
"the old relay's entry is gone"
|
||||
);
|
||||
assert_eq!(
|
||||
workspace_repos_dir_for_relay(&root, "wss://new.example").as_deref(),
|
||||
Some("/Users/me/DevA"),
|
||||
"the entry follows the edited relay URL"
|
||||
);
|
||||
|
||||
// Rebinding a relay with no entry is a no-op, not an error.
|
||||
assert!(!rebind_workspace_repos_dir(&root, "wss://absent.example", "wss://x.example").unwrap());
|
||||
// A cosmetic-only edit (same normalized relay) moves nothing.
|
||||
assert!(!rebind_workspace_repos_dir(&root, "wss://new.example", "WSS://New.Example/").unwrap());
|
||||
}
|
||||
|
||||
// ── resolve_agent_repos_dir (spawn-time isolation) ────────────────────
|
||||
|
||||
// THE Phase-4 immunity property: each agent resolves its own workspace's
|
||||
// repos dir as a real path, regardless of where the shared REPOS symlink
|
||||
// currently points (it follows the ACTIVE workspace).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resolve_agent_repos_dir_is_immune_to_active_workspace_symlink() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let dir_a = tmp.path().join("DevA");
|
||||
let dir_b = tmp.path().join("DevB");
|
||||
fs::create_dir_all(&dir_a).unwrap();
|
||||
fs::create_dir_all(&dir_b).unwrap();
|
||||
|
||||
// Workspace A and B each applied with their own repos_dir; B is the
|
||||
// active workspace, so REPOS symlinks to B's dir.
|
||||
persist_workspace_repos_dir(
|
||||
&root,
|
||||
"wss://relay-a.example",
|
||||
Some(dir_a.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
persist_workspace_repos_dir(
|
||||
&root,
|
||||
"wss://relay-b.example",
|
||||
Some(dir_b.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
ensure_repos_symlink(&root, Some(dir_b.to_str().unwrap())).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolve_agent_repos_dir(&root, "wss://relay-a.example").unwrap(),
|
||||
dir_a.canonicalize().unwrap(),
|
||||
"an A-pinned agent resolves A's real dir even while REPOS points at B"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_agent_repos_dir(&root, "wss://relay-b.example").unwrap(),
|
||||
dir_b.canonicalize().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_agent_repos_dir_fails_closed_when_override_unresolvable() {
|
||||
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");
|
||||
persist_workspace_repos_dir(
|
||||
&root,
|
||||
"wss://relay-a.example",
|
||||
Some(missing.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = resolve_agent_repos_dir(&root, "wss://relay-a.example");
|
||||
|
||||
let error = result.expect_err("a configured-but-unresolvable repos dir must refuse spawn");
|
||||
assert!(
|
||||
error.contains("refusing to start"),
|
||||
"the error explains the fail-closed refusal: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_agent_repos_dir_defaults_to_nest_repos_without_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join(".buzz");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolve_agent_repos_dir(&root, "wss://relay-a.example").unwrap(),
|
||||
root.join("REPOS"),
|
||||
"no override → the shared in-nest REPOS default (pre-map behavior)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1330,16 +1330,28 @@ pub fn build_managed_agent_summary(
|
||||
let needs_restart = runtimes.get(&record.pubkey).is_some_and(|runtime| {
|
||||
use tauri::Manager;
|
||||
let state = app.state::<crate::app_state::AppState>();
|
||||
let workspace_relay = crate::relay::relay_ws_url_with_override(&state);
|
||||
let global_for_hash =
|
||||
crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
|
||||
let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default();
|
||||
// Repos-dir hash input mirrors spawn: the raw per-relay map value for
|
||||
// the agent's EFFECTIVE relay. Keyed by the agent's own pin, so a
|
||||
// workspace switch cannot move it; an edit to the agent's own
|
||||
// community repos dir can — and should badge.
|
||||
let repos_dir_for_hash = crate::managed_agents::nest_dir().and_then(|nest| {
|
||||
crate::managed_agents::workspace_repos_dir_for_relay(
|
||||
&nest,
|
||||
&crate::relay::effective_agent_relay_url(&record.relay_url, &workspace_relay),
|
||||
)
|
||||
});
|
||||
let hash_drift = runtime.spawn_config_hash
|
||||
!= crate::managed_agents::spawn_hash::spawn_config_hash(
|
||||
record,
|
||||
personas,
|
||||
&teams_for_hash,
|
||||
&crate::relay::relay_ws_url_with_override(&state),
|
||||
&workspace_relay,
|
||||
&global_for_hash,
|
||||
repos_dir_for_hash.as_deref(),
|
||||
);
|
||||
let availability_drift = super::availability_drift(
|
||||
runtime.adapter_availability.as_ref(),
|
||||
@@ -1542,6 +1554,25 @@ pub fn spawn_agent_child(
|
||||
)
|
||||
};
|
||||
|
||||
// The agent's workspace repos directory, resolved to a REAL path from the
|
||||
// per-relay map keyed by the agent's effective relay. The nest's REPOS
|
||||
// symlink follows the ACTIVE workspace (a human/tooling convention) and
|
||||
// can re-point mid-task on a workspace switch; handing the resolved path
|
||||
// as BUZZ_REPOS_DIR makes this agent immune (the nest AGENTS.md instructs
|
||||
// agents to prefer it over the relative REPOS/ path). Fails CLOSED when
|
||||
// this workspace has a configured repos dir that cannot be resolved right
|
||||
// now (e.g. unmounted volume) — same rationale as resolve_repos_at_boot:
|
||||
// a refused start is recoverable, work landing in another workspace's
|
||||
// checkouts is not. No map entry falls back to the nest REPOS path,
|
||||
// preserving pre-map behavior.
|
||||
let resolved_repos_dir = super::nest_dir()
|
||||
.map(|nest| super::resolve_agent_repos_dir(&nest, &effective_relay_url))
|
||||
.transpose()?;
|
||||
// Hash input: the RAW map value (stable against filesystem state), read
|
||||
// once here so the stamp below and this spawn's env agree by construction.
|
||||
let repos_dir_for_hash = super::nest_dir()
|
||||
.and_then(|nest| super::workspace_repos_dir_for_relay(&nest, &effective_relay_url));
|
||||
|
||||
// Augment PATH for DMG launches so child processes can find:
|
||||
// - bundled CLI via ~/.local/bin symlink
|
||||
// - nvm-managed node/npm (nvm initializes only in interactive shells)
|
||||
@@ -1572,6 +1603,9 @@ pub fn spawn_agent_child(
|
||||
command.env("RUST_LOG", child_rust_log_filter());
|
||||
command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec);
|
||||
command.env("BUZZ_RELAY_URL", &effective_relay_url);
|
||||
if let Some(repos_dir) = &resolved_repos_dir {
|
||||
command.env("BUZZ_REPOS_DIR", repos_dir);
|
||||
}
|
||||
command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command);
|
||||
command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(","));
|
||||
match &resolved_mcp_command {
|
||||
@@ -1900,6 +1934,7 @@ pub fn spawn_agent_child(
|
||||
&teams,
|
||||
&effective_relay_url,
|
||||
&global,
|
||||
repos_dir_for_hash.as_deref(),
|
||||
);
|
||||
|
||||
// Stamp the adapter availability for runtimes with a version gate (codex
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
//! URL difference (trailing slash, scheme/host case) does not.
|
||||
//! - Channel membership is not an input: agents pick up channel changes live
|
||||
//! (#1468), never via restart.
|
||||
//! - The workspace repos dir is hashed as the raw per-relay map value for the
|
||||
//! agent's effective relay (the source of the spawned `BUZZ_REPOS_DIR`):
|
||||
//! keyed by the agent's own pin, a workspace switch cannot move it, but an
|
||||
//! edit to the agent's community repos dir means a restart would change
|
||||
//! where the agent works.
|
||||
//!
|
||||
//! The hash never crosses a process or persistence boundary, so
|
||||
//! `DefaultHasher` (not stable across Rust releases) is sufficient.
|
||||
@@ -64,6 +69,9 @@ pub(crate) fn effective_team_instructions(
|
||||
|
||||
/// Digest the effective spawn configuration of `record` under the current
|
||||
/// `personas`, resolving a blank record relay against `workspace_relay`.
|
||||
/// `workspace_repos_dir` is the raw per-relay repos-dir map value for the
|
||||
/// agent's effective relay (callers look it up via
|
||||
/// `workspace_repos_dir_for_relay`); it feeds the spawned `BUZZ_REPOS_DIR`.
|
||||
/// Pure — no `AppHandle`, no disk, no keyring.
|
||||
pub(crate) fn spawn_config_hash(
|
||||
record: &ManagedAgentRecord,
|
||||
@@ -71,6 +79,7 @@ pub(crate) fn spawn_config_hash(
|
||||
teams: &[TeamRecord],
|
||||
workspace_relay: &str,
|
||||
global: &GlobalAgentConfig,
|
||||
workspace_repos_dir: Option<&str>,
|
||||
) -> u64 {
|
||||
// Prospective re-snapshot: apply the same `apply_persona_snapshot` the
|
||||
// start/restore paths run right before spawning, so the hash covers what a
|
||||
@@ -116,6 +125,10 @@ pub(crate) fn spawn_config_hash(
|
||||
workspace_relay,
|
||||
))
|
||||
.hash(&mut hasher);
|
||||
// The source value of the spawned BUZZ_REPOS_DIR. Hashed raw (not
|
||||
// canonicalized), so filesystem state changes alone cannot flip the badge
|
||||
// — only an actual config edit (or a relay rebind moving the entry) can.
|
||||
workspace_repos_dir.hash(&mut hasher);
|
||||
// Prompt and runtime-layered team instructions use the same resolver as spawn.
|
||||
effective_spawn_prompt(record).hash(&mut hasher);
|
||||
effective_team_instructions(record, teams).hash(&mut hasher);
|
||||
|
||||
@@ -85,8 +85,22 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition {
|
||||
fn hash_is_deterministic() {
|
||||
let rec = record();
|
||||
assert_eq!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,14 +126,16 @@ fn materializing_runtime_keeps_hash_stable() {
|
||||
&personas,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default()
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&post,
|
||||
&personas,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default()
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -132,8 +148,22 @@ fn record_env_var_edit_changes_hash() {
|
||||
.env_vars
|
||||
.insert("SOME_KEY".into(), "some-value".into());
|
||||
assert_ne!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&edited,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,8 +173,22 @@ fn record_prompt_edit_changes_hash() {
|
||||
let mut edited = record();
|
||||
edited.system_prompt = Some("Edited prompt.".into());
|
||||
assert_ne!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&edited,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,8 +201,22 @@ fn persona_runtime_edit_changes_hash() {
|
||||
let before = [persona("pers", Some("goose"), "prompt")];
|
||||
let after = [persona("pers", Some("claude"), "prompt")];
|
||||
assert_ne!(
|
||||
spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&before,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&after,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,8 +230,22 @@ fn persona_prompt_edit_changes_hash() {
|
||||
let before = [persona("pers", Some("goose"), "old prompt")];
|
||||
let after = [persona("pers", Some("goose"), "new prompt")];
|
||||
assert_ne!(
|
||||
spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&before,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&after,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,8 +256,22 @@ fn workspace_relay_change_trips_hash_for_blank_record_relay() {
|
||||
let mut rec = record();
|
||||
rec.relay_url = String::new();
|
||||
assert_ne!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()),
|
||||
spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://relay-a.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://relay-b.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,8 +281,22 @@ fn workspace_relay_change_ignored_for_pinned_record_relay() {
|
||||
// a workspace relay change must NOT badge a pinned agent.
|
||||
let rec = record();
|
||||
assert_eq!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()),
|
||||
spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://relay-a.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://relay-b.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,8 +308,22 @@ fn cosmetic_pin_difference_does_not_change_hash() {
|
||||
let mut slashed = record();
|
||||
slashed.relay_url = "WS://Localhost:3000/".into();
|
||||
assert_eq!(
|
||||
spawn_config_hash(&record(), &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&slashed, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&record(),
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&slashed,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,13 +335,21 @@ fn cosmetic_workspace_relay_difference_ignored_for_blank_record_relay() {
|
||||
let mut rec = record();
|
||||
rec.relay_url = String::new();
|
||||
assert_eq!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://relay-a.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"WSS://Relay-A.Example/",
|
||||
&Default::default()
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -239,8 +361,22 @@ fn respond_to_allowlist_edit_changes_hash() {
|
||||
edited.respond_to = RespondTo::Allowlist;
|
||||
edited.respond_to_allowlist = vec!["a".repeat(64)];
|
||||
assert_ne!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&edited,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,8 +388,22 @@ fn allowlist_ignored_when_mode_is_not_allowlist() {
|
||||
let mut edited = record();
|
||||
edited.respond_to_allowlist = vec!["a".repeat(64)];
|
||||
assert_eq!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&edited,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,8 +420,22 @@ fn allowlist_normalization_equivalent_edits_do_not_change_hash() {
|
||||
"a".repeat(64), // duplicate
|
||||
];
|
||||
assert_eq!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&edited,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,8 +447,22 @@ fn allowlist_content_edit_still_changes_hash() {
|
||||
let mut edited = rec.clone();
|
||||
edited.respond_to_allowlist = vec!["b".repeat(64)];
|
||||
assert_ne!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&edited,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,8 +475,22 @@ fn explicit_default_max_turn_duration_does_not_change_hash() {
|
||||
edited.max_turn_duration_seconds =
|
||||
Some(crate::managed_agents::types::DEFAULT_AGENT_MAX_TURN_DURATION_SECONDS);
|
||||
assert_eq!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&edited,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,8 +500,22 @@ fn non_default_max_turn_duration_changes_hash() {
|
||||
let mut edited = record();
|
||||
edited.max_turn_duration_seconds = Some(42);
|
||||
assert_ne!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&edited,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,8 +530,22 @@ fn non_spawn_bookkeeping_fields_do_not_change_hash() {
|
||||
edited.last_started_at = Some("later".into());
|
||||
edited.last_exit_code = Some(0);
|
||||
assert_eq!(
|
||||
spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default())
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&edited,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -355,14 +575,16 @@ fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() {
|
||||
&quadless_definition,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default()
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&definition_with_quad,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default()
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
"definition quad must not leak into the spawn hash of an existing instance"
|
||||
);
|
||||
@@ -378,8 +600,22 @@ fn empty_prompt_hashes_like_absent_prompt() {
|
||||
let mut empty = record();
|
||||
empty.system_prompt = Some(String::new());
|
||||
assert_eq!(
|
||||
spawn_config_hash(&absent, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&empty, &[], &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(
|
||||
&absent,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&empty,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,8 +631,22 @@ fn definition_runtime_edit_changes_hash_for_materialized_record() {
|
||||
let before = [persona("pers", Some("goose"), "prompt")];
|
||||
let after = [persona("pers", Some("claude"), "prompt")];
|
||||
assert_ne!(
|
||||
spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&before,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&after,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
"definition runtime edit must badge a materialized, override-free instance"
|
||||
);
|
||||
}
|
||||
@@ -413,8 +663,22 @@ fn agent_command_override_beats_definition_runtime_change() {
|
||||
let before = [persona("pers", Some("goose"), "prompt")];
|
||||
let after = [persona("pers", Some("claude"), "prompt")];
|
||||
assert_eq!(
|
||||
spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&before,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&after,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
"explicit override must win regardless of definition runtime change"
|
||||
);
|
||||
}
|
||||
@@ -438,19 +702,89 @@ fn missing_definition_leaves_materialized_runtime_in_hash() {
|
||||
no_personas,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default()
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
spawn_config_hash(
|
||||
&no_runtime,
|
||||
no_personas,
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default()
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
"materialized runtime must still affect hash when definition is absent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_repos_dir_change_trips_hash() {
|
||||
// Editing the agent's community repos dir changes where a restart would
|
||||
// put BUZZ_REPOS_DIR → the badge must trip; likewise configuring or
|
||||
// clearing an override.
|
||||
let rec = record();
|
||||
let base = spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
Some("/Users/me/DevA"),
|
||||
);
|
||||
assert_ne!(
|
||||
base,
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
Some("/Users/me/DevB"),
|
||||
),
|
||||
"a repos dir edit must badge"
|
||||
);
|
||||
assert_ne!(
|
||||
base,
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None
|
||||
),
|
||||
"clearing the override must badge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_repos_dir_is_stable_across_recomputes() {
|
||||
// The map value is keyed by the agent's pinned relay, so a pure workspace
|
||||
// switch supplies the SAME value on recompute — no spurious badge. This
|
||||
// pins that equal inputs hash equal (with and without an override).
|
||||
let rec = record();
|
||||
for repos_dir in [None, Some("/Users/me/DevA")] {
|
||||
assert_eq!(
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
repos_dir,
|
||||
),
|
||||
spawn_config_hash(
|
||||
&rec,
|
||||
&[],
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
repos_dir,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_spawn_prompt_matches_hash_semantics() {
|
||||
// The env write and the hash share effective_spawn_prompt — this row
|
||||
|
||||
@@ -137,6 +137,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() {
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None,
|
||||
);
|
||||
|
||||
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
|
||||
@@ -153,6 +154,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() {
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -186,6 +188,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() {
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None,
|
||||
);
|
||||
|
||||
backfill_standalone_agents_in_dir(&base(dir.path())).unwrap();
|
||||
@@ -202,6 +205,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() {
|
||||
&[],
|
||||
"wss://ws.example",
|
||||
&Default::default(),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(hash_before, hash_after);
|
||||
|
||||
Reference in New Issue
Block a user