feat(desktop): implement ordered per-scope migration pipeline and scoped dev-sync

Completes Phase 2 of the workspace-scoped agent store:

- scope_init.rs: replace the run_scoped_migrations no-op with the full
  ordered migration pipeline (fold, strip, refresh-avatars, backfill,
  detach, reconcile-names, reconcile-mcp, databricks-v1-to-v2, materialize)
  running against the scope directory after staged install. Ordering mirrors
  migration.rs::run_boot_migrations_inner's load-bearing order.
- migration submodules: expose fold_personas_in_dir, strip_baked_team_
  instructions_in_dir, backfill_standalone_agents_in_dir, detach_directory_
  backed_teams_in_dir, materialize_runtimes_in_file as pub(crate)
- migration.rs: add _at(definitions_dir) wrapper functions for reconcile_
  provider_mcp_commands, reconcile_databricks_v1_to_v2, refresh_builtin_
  agent_avatars, reconcile_legacy_command_names, materialize_agent_runtimes
  re-export the dir-level helpers under the crate's migration module
- SHARED_AGENT_FILES: emptied; legacy unscoped files no longer symlinked
  across worktrees (they live under agents/scopes/ now)
- SHARED_AGENT_DIRS: add agents/scopes so all scoped stores are shared
  across dev worktrees without requiring knowledge of the dynamic scope ID
- migration_tests.rs: rewrite 8 sync tests to match the new SHARED_AGENT_DIRS
  layout; add scope-dir-based write-through and seed-up tests

All 2118 tests pass.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-08-02 23:40:35 -04:00
co-authored by Will Pfleger
parent e6aa269814
commit b795ed6c79
8 changed files with 280 additions and 121 deletions
@@ -325,13 +325,73 @@ fn install_staged(
/// Run idempotent scoped migrations against an installed scope directory.
///
/// Currently a no-op: the boot-time migration pipeline in `migration.rs` still
/// runs pre-scope and operates on the legacy unscoped paths. Per-scope
/// migration scheduling (moving fold/strip/backfill into this pipeline) is
/// Phase 2 follow-on work once the legacy data has been adopted.
fn run_scoped_migrations(_scope_dir: &Path) -> Result<(), String> {
// Future: run fold_personas_into_agent_store_at, strip_baked_team_instructions_at,
// backfill_standalone_agents_at, etc. against scope_dir.
/// This is the per-scope migration pipeline, run after staged install completes.
/// Ordering mirrors `migration.rs::run_boot_migrations_inner` for the
/// definition-touching steps (the ordering doc comment at `migration.rs:106-121`
/// is load-bearing). Machine-level steps that stay pre-scope (dir init, dev
/// symlinks, persona provider rename) are NOT included here.
///
/// # Order (must be preserved)
/// 1. `fold_personas_in_dir` — fold personas.json into managed-agents.json.
/// BEFORE all readers of the unified store (strip, backfill, materialize).
/// 2. `strip_baked_team_instructions_in_dir` — clean legacy baked team-instructions
/// suffix AFTER fold (so lifted definitions are also cleaned) and BEFORE
/// backfill (so manufactured definitions never snapshot the suffix).
/// 3. `refresh_builtin_agent_avatars_at` — refresh legacy builtin avatars.
/// 4. `backfill_standalone_agents_in_dir` — manufacture definitions for standalone
/// agents AFTER fold (slug collision checks see pre-existing definitions).
/// 5. `detach_directory_backed_teams_in_dir` — lift pack instructions, clear
/// source_dir on teams.
/// 6. `reconcile_legacy_command_names_at` — fix stale command names.
/// 7. `reconcile_provider_mcp_commands_at` — fix mcp_command values.
/// 8. `reconcile_databricks_v1_to_v2_at` — V1→V2 provider migration.
/// 9. `materialize_agent_runtimes_at` — materialize runtime onto each record.
fn run_scoped_migrations(scope_dir: &Path) -> Result<(), String> {
// Step 1: fold personas.json into the unified store.
match crate::migration::fold_personas_in_dir(scope_dir) {
Ok(None) | Ok(Some(0)) => {}
Ok(Some(n)) => {
eprintln!("buzz-desktop: scope-init-fold: {n} definitions folded into scoped store");
}
Err(e) => eprintln!("buzz-desktop: scope-init-fold: {e}"),
}
// Step 2: strip baked team-instructions suffix.
match crate::migration::strip_baked_team_instructions_in_dir(scope_dir) {
Ok(0) => {}
Ok(n) => eprintln!("buzz-desktop: scope-init-strip: {n} records cleaned"),
Err(e) => eprintln!("buzz-desktop: scope-init-strip: {e}"),
}
// Step 3: refresh legacy builtin agent avatars.
crate::migration::refresh_builtin_agent_avatars_at(scope_dir);
// Step 4: backfill standalone agents into definition-linked records.
match crate::migration::backfill_standalone_agents_in_dir(scope_dir) {
Ok(0) => {}
Ok(n) => eprintln!("buzz-desktop: scope-init-backfill: {n} agents backfilled"),
Err(e) => eprintln!("buzz-desktop: scope-init-backfill: {e}"),
}
// Step 5: detach directory-backed teams.
match crate::migration::detach_directory_backed_teams_in_dir(scope_dir) {
Ok(0) => {}
Ok(n) => eprintln!("buzz-desktop: scope-init-detach: {n} teams detached"),
Err(e) => eprintln!("buzz-desktop: scope-init-detach: {e}"),
}
// Step 6: reconcile legacy command names.
crate::migration::reconcile_legacy_command_names_at(scope_dir);
// Step 7: reconcile provider mcp_command values.
crate::migration::reconcile_provider_mcp_commands_at(scope_dir);
// Step 8: Databricks V1 → V2 provider migration.
crate::migration::reconcile_databricks_v1_to_v2_at(scope_dir);
// Step 9: materialize runtime onto each record.
crate::migration::materialize_agent_runtimes_at(scope_dir);
Ok(())
}
+72 -6
View File
@@ -29,15 +29,21 @@ const LEGACY_RELEASE_IDENTIFIER: &str = "xyz.block.sprout.app";
/// dev data directory. Only data files — never `agent-pids/` or `logs/`.
/// `identity.key` is deliberately excluded because worktree instances
/// receive their identity via the `BUZZ_PRIVATE_KEY` env var.
const SHARED_AGENT_FILES: &[&str] = &[
"agents/managed-agents.json",
"agents/personas.json",
"agents/teams.json",
];
///
/// NOTE: `agents/managed-agents.json` and the other legacy unscoped files
/// are intentionally absent — they now live under `agents/scopes/<scope_id>/`
/// (the scoped layout). The `agents/scopes/` directory is shared via
/// `SHARED_AGENT_DIRS` so all scoped stores are visible to every worktree.
const SHARED_AGENT_FILES: &[&str] = &[];
/// Directories symlinked from worktree data directories to the canonical
/// dev data directory. Each entry becomes a single directory symlink.
const SHARED_AGENT_DIRS: &[&str] = &["agents/teams"];
///
/// `agents/scopes` is included so that scoped agent stores (managed-agents.json,
/// teams.json, global-agent-config.json under each scope sub-directory) are
/// shared across worktree instances without requiring knowledge of the dynamic
/// scope ID at migration time.
const SHARED_AGENT_DIRS: &[&str] = &["agents/teams", "agents/scopes"];
/// Returns `true` when `name` is a dev data dir name — i.e. it is exactly the
/// canonical dev identifier or a worktree variant separated by a `.` (e.g.
@@ -1379,6 +1385,66 @@ pub use detach::detach_directory_backed_teams;
mod team_suffix;
pub use team_suffix::strip_baked_team_instructions;
// ── Scoped migration helpers ──────────────────────────────────────────────────
// Re-exports and thin wrappers used by `managed_agents::scope_init::run_scoped_migrations`.
// Each function operates on a `definitions_dir` (the scoped agents directory:
// `<app_data>/agents/scopes/<scope_id>/`) instead of the legacy
// `<app_data>/agents/` root.
pub(crate) use backfill::backfill_standalone_agents_in_dir;
pub(crate) use detach::detach_directory_backed_teams_in_dir;
pub(crate) use fold::fold_personas_in_dir;
pub(crate) use materialize::materialize_runtimes_in_file;
pub(crate) use team_suffix::strip_baked_team_instructions_in_dir;
/// Reconcile `mcp_command` values in a scoped `definitions_dir`.
pub(crate) fn reconcile_provider_mcp_commands_at(definitions_dir: &std::path::Path) {
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
reconcile_mcp_commands_in_file(&path);
}
}
/// Reconcile Databricks V1 → V2 provider entries in a scoped `definitions_dir`.
pub(crate) fn reconcile_databricks_v1_to_v2_at(definitions_dir: &std::path::Path) {
use crate::managed_agents::baked_build_env;
let rewrite_v1_provider = baked_build_env()
.get("BUZZ_AGENT_PROVIDER")
.map(|v| v == "databricks_v2")
.unwrap_or(false);
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
reconcile_databricks_v1_to_v2_in_file(&path, rewrite_v1_provider);
}
}
/// Refresh legacy built-in agent avatars in a scoped `definitions_dir`.
pub(crate) fn refresh_builtin_agent_avatars_at(definitions_dir: &std::path::Path) {
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
refresh_builtin_agent_avatars_in_file(
&path,
LEGACY_BUILTIN_AVATARS,
&crate::util::now_iso(),
);
}
}
/// Reconcile legacy command names in a scoped `definitions_dir`.
pub(crate) fn reconcile_legacy_command_names_at(definitions_dir: &std::path::Path) {
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
reconcile_legacy_command_names_in_file(&path);
}
}
/// Materialize per-record runtimes in a scoped `definitions_dir`.
pub(crate) fn materialize_agent_runtimes_at(definitions_dir: &std::path::Path) {
let path = definitions_dir.join("managed-agents.json");
if path.exists() {
materialize_runtimes_in_file(&path);
}
}
#[cfg(test)]
#[path = "migration_test_support.rs"]
mod test_support;
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn backfill_standalone_agents(app: &tauri::AppHandle) {
/// Core backfill logic, decoupled from the Tauri `AppHandle` for testing.
/// Returns the number of records backfilled (0 = nothing to do).
fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result<usize, String> {
pub(crate) fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result<usize, String> {
let agents_path = base_dir.join("managed-agents.json");
if !agents_path.exists() {
return Ok(0);
+1 -1
View File
@@ -39,7 +39,7 @@ pub fn detach_directory_backed_teams(app: &tauri::AppHandle) {
///
/// `base_dir` is the managed-agents base directory (`<AppDataDir>/agents/`).
/// Returns the number of teams detached (0 = nothing to do).
pub(super) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result<usize, String> {
pub(crate) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result<usize, String> {
let teams_path = base_dir.join("teams.json");
let agents_path = base_dir.join("managed-agents.json");
+1 -1
View File
@@ -38,7 +38,7 @@ pub fn fold_personas_into_agent_store(app: &tauri::AppHandle) {
/// Operates on the raw JSON files — no keyring interaction: instance records
/// are passed through byte-identical, and folded definitions carry no keys.
/// Returns `Ok(None)` when there is no `personas.json` to fold.
fn fold_personas_in_dir(base_dir: &Path) -> Result<Option<usize>, String> {
pub(crate) fn fold_personas_in_dir(base_dir: &Path) -> Result<Option<usize>, String> {
let personas_path = base_dir.join("personas.json");
if !personas_path.exists() {
return Ok(None);
@@ -39,7 +39,7 @@ pub fn materialize_agent_runtimes(app: &tauri::AppHandle) {
}
}
fn materialize_runtimes_in_file(path: &Path) {
pub(crate) fn materialize_runtimes_in_file(path: &Path) {
let persona_runtimes = load_persona_runtimes(path);
if persona_runtimes.is_empty() {
return;
@@ -64,7 +64,7 @@ pub fn strip_baked_team_instructions(app: &tauri::AppHandle) {
/// `base_dir` is the managed-agents base directory (`<AppDataDir>/agents/`).
/// Returns the number of records changed; `Ok(0)` means nothing to do and
/// nothing was written, so a second boot is a clean no-op.
pub(super) fn strip_baked_team_instructions_in_dir(base_dir: &Path) -> Result<usize, String> {
pub(crate) fn strip_baked_team_instructions_in_dir(base_dir: &Path) -> Result<usize, String> {
let agents_path = base_dir.join("managed-agents.json");
if !agents_path.exists() {
return Ok(0);
+136 -103
View File
@@ -73,6 +73,8 @@ fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) {
let main_instance = parent.path().join("xyz.block.buzz.app.dev.main");
std::fs::create_dir_all(canonical.join("agents")).unwrap();
// Legacy unscoped files remain in canonical for compatibility, but are no
// longer part of SHARED_AGENT_FILES (they're superseded by agents/scopes/).
std::fs::write(
canonical.join("agents/managed-agents.json"),
r#"[{"id":"agent-1"}]"#,
@@ -85,6 +87,10 @@ fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) {
.unwrap();
std::fs::write(canonical.join("agents/teams.json"), r#"[{"id":"team-1"}]"#).unwrap();
// Scopes directory: shared via SHARED_AGENT_DIRS so every worktree sees
// all scoped stores without knowing the dynamic scope ID.
std::fs::create_dir_all(canonical.join("agents/scopes")).unwrap();
// Teams installed from `.main` — canonical has no teams dir.
let team_dir = main_instance.join("agents/teams/com.example.test-pack");
std::fs::create_dir_all(&team_dir).unwrap();
@@ -216,21 +222,16 @@ fn sync_files(canonical: &Path, worktree: &Path) -> u32 {
fn sync_creates_symlinks_to_fresh_worktree() {
let (_parent, canonical, worktree) = setup_sync_layout();
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
let dst = worktree.join(rel);
assert!(dst.is_symlink(), "{rel} should be a symlink");
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
}
// SHARED_AGENT_FILES is empty; SHARED_AGENT_DIRS has "agents/teams" and "agents/scopes".
assert_eq!(synced, 2);
for rel in SHARED_AGENT_DIRS {
let dst = worktree.join(rel);
assert!(dst.is_symlink(), "{rel} should be a symlink");
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
}
assert_eq!(
std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(),
r#"[{"id":"agent-1"}]"#,
);
// Scoped store files are accessible through the agents/scopes symlink.
// (In production, managed-agents.json would live under agents/scopes/<scope_id>/.)
assert!(worktree.join("agents/scopes").is_symlink());
}
#[cfg(unix)]
@@ -238,34 +239,37 @@ fn sync_creates_symlinks_to_fresh_worktree() {
fn sync_replaces_existing_files_with_symlinks() {
let (_parent, canonical, worktree) = setup_sync_layout();
std::fs::create_dir_all(worktree.join("agents")).unwrap();
// Pre-existing unscoped files in the worktree are NOT replaced by the new
// sync (SHARED_AGENT_FILES is empty); only the scoped directories are synced.
std::fs::write(worktree.join("agents/managed-agents.json"), "[]").unwrap();
std::fs::write(worktree.join("agents/personas.json"), "[]").unwrap();
std::fs::write(worktree.join("agents/teams.json"), "[]").unwrap();
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
// Only SHARED_AGENT_DIRS (teams + scopes) are synced.
assert_eq!(synced, 2);
for rel in SHARED_AGENT_DIRS {
let dst = worktree.join(rel);
assert!(
dst.is_symlink(),
"{rel} should be a symlink after replacing regular file"
"{rel} should be a symlink after replacing regular dir"
);
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
}
assert_eq!(
std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(),
r#"[{"id":"agent-1"}]"#,
);
// Unscoped files are untouched (not part of sync).
assert!(worktree.join("agents/managed-agents.json").is_file());
}
#[cfg(unix)]
#[test]
fn sync_preserves_correct_symlinks() {
let (_parent, canonical, worktree) = setup_sync_layout();
assert_eq!(sync_files(&canonical, &worktree), 4);
// First sync creates 2 dir symlinks (agents/teams + agents/scopes).
assert_eq!(sync_files(&canonical, &worktree), 2);
// Second sync sees correct symlinks and makes no changes.
assert_eq!(sync_files(&canonical, &worktree), 0);
for rel in SHARED_AGENT_FILES {
for rel in SHARED_AGENT_DIRS {
let dst = worktree.join(rel);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
@@ -276,14 +280,15 @@ fn sync_preserves_correct_symlinks() {
#[test]
fn sync_replaces_wrong_symlinks() {
let (_parent, canonical, worktree) = setup_sync_layout();
let wrong_target = PathBuf::from("/nonexistent/wrong-target.json");
let wrong_target = PathBuf::from("/nonexistent/wrong-target");
std::fs::create_dir_all(worktree.join("agents")).unwrap();
for rel in SHARED_AGENT_FILES {
for rel in SHARED_AGENT_DIRS {
std::os::unix::fs::symlink(&wrong_target, worktree.join(rel)).unwrap();
}
// 2 wrong dir symlinks get replaced.
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
assert_eq!(synced, 2);
for rel in SHARED_AGENT_DIRS {
assert_eq!(
std::fs::read_link(worktree.join(rel)).unwrap(),
canonical.join(rel)
@@ -296,18 +301,19 @@ fn sync_replaces_wrong_symlinks() {
fn sync_handles_broken_symlinks() {
let (_parent, canonical, worktree) = setup_sync_layout();
std::fs::create_dir_all(worktree.join("agents")).unwrap();
let broken_target = PathBuf::from("/this/does/not/exist.json");
for rel in SHARED_AGENT_FILES {
let broken_target = PathBuf::from("/this/does/not/exist");
for rel in SHARED_AGENT_DIRS {
std::os::unix::fs::symlink(&broken_target, worktree.join(rel)).unwrap();
}
// 2 broken dir symlinks get replaced.
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
assert_eq!(synced, 2);
for rel in SHARED_AGENT_DIRS {
let dst = worktree.join(rel);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
// Content should be readable through the fixed symlink.
assert!(std::fs::read_to_string(&dst).is_ok());
// Directory content should be readable through the fixed symlink.
assert!(std::fs::read_dir(&dst).is_ok());
}
}
@@ -317,11 +323,32 @@ fn writes_through_symlink_reach_canonical() {
let (_parent, canonical, worktree) = setup_sync_layout();
sync_files(&canonical, &worktree);
let worktree_path = worktree.join("agents/personas.json");
let canonical_path = canonical.join("agents/personas.json");
// Write a scoped managed-agents.json through the agents/scopes symlink.
// This is the real production path: worktree writes to its own scope dir
// via the shared agents/scopes symlink and the canonical instance sees it.
let scope_id = "test_scope_abcdef0123456789";
std::fs::create_dir_all(canonical.join("agents/scopes").join(scope_id)).unwrap();
let canonical_path = canonical
.join("agents/scopes")
.join(scope_id)
.join("managed-agents.json");
std::fs::write(&canonical_path, r#"[{"id":"agent-canonical"}]"#).unwrap();
// The worktree accesses the same file through the agents/scopes symlink.
let worktree_path = worktree
.join("agents/scopes")
.join(scope_id)
.join("managed-agents.json");
assert!(worktree.join("agents/scopes").is_symlink());
assert_eq!(
std::fs::read_to_string(&worktree_path).unwrap(),
r#"[{"id":"agent-canonical"}]"#,
);
// Write through the symlink using the same pattern as atomic_write_json.
let new_content = r#"[{"id":"builtin:fizz","updated":true}]"#;
let new_content = r#"[{"id":"agent-canonical","updated":true}]"#;
let resolved = std::fs::canonicalize(&worktree_path).unwrap();
let tmp = resolved.with_extension("json.tmp");
std::fs::write(&tmp, new_content.as_bytes()).unwrap();
@@ -332,9 +359,7 @@ fn writes_through_symlink_reach_canonical() {
std::fs::read_to_string(&canonical_path).unwrap(),
new_content
);
// The worktree path should still be a symlink.
assert!(worktree_path.is_symlink());
// Reading through the symlink should return the new content.
// Reading through the symlink path should return the new content.
assert_eq!(
std::fs::read_to_string(&worktree_path).unwrap(),
new_content
@@ -343,72 +368,33 @@ fn writes_through_symlink_reach_canonical() {
#[cfg(unix)]
#[test]
fn seed_up_migrates_sibling_file_to_canonical_then_symlinks() {
fn seed_up_migrates_sibling_dir_to_canonical_then_symlinks() {
// Verifies that the scopes dir migration from a sibling works correctly.
// This replaces the old file-based seed_up test for the scoped layout.
let (_parent, canonical, worktree) = setup_sync_layout();
let rel = "agents/personas.json";
// Canonical is missing the file; a sibling (.main) holds real content.
std::fs::remove_file(canonical.join(rel)).unwrap();
let rel = "agents/scopes";
// Remove canonical scopes dir so it gets migrated from a sibling.
std::fs::remove_dir_all(canonical.join(rel)).unwrap();
let sibling = canonical
.parent()
.unwrap()
.join("xyz.block.buzz.app.dev.main");
std::fs::create_dir_all(sibling.join("agents")).unwrap();
std::fs::write(sibling.join(rel), r#"[{"id":"brain"}]"#).unwrap();
std::fs::create_dir_all(sibling.join(rel).join("scope_abc")).unwrap();
std::fs::write(
sibling
.join(rel)
.join("scope_abc")
.join("managed-agents.json"),
r#"[{"id":"from-sibling"}]"#,
)
.unwrap();
sync_files(&canonical, &worktree);
// The real file landed at canonical (proves the rename, not a dangling link).
let canonical_file = canonical.join(rel);
assert!(
canonical_file.is_file() && !canonical_file.is_symlink(),
"canonical should hold the migrated real file"
);
assert_eq!(
std::fs::read_to_string(&canonical_file).unwrap(),
r#"[{"id":"brain"}]"#,
);
// The worktree is symlinked to canonical.
let dst = worktree.join(rel);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical_file);
}
#[cfg(unix)]
#[test]
fn seed_up_no_sibling_content_is_noop() {
let (_parent, canonical, worktree) = setup_sync_layout();
let rel = "agents/personas.json";
// Canonical missing the file and no sibling holds it.
std::fs::remove_file(canonical.join(rel)).unwrap();
sync_files(&canonical, &worktree);
// Nothing to seed: canonical stays missing, worktree gets no symlink for it.
assert!(!canonical.join(rel).exists());
assert!(!worktree.join(rel).exists());
}
#[cfg(unix)]
#[test]
fn seed_up_skipped_when_canonical_has_file() {
let (_parent, canonical, worktree) = setup_sync_layout();
let rel = "agents/personas.json";
// A sibling also holds different content, but canonical already has the file.
let sibling = canonical
.parent()
.unwrap()
.join("xyz.block.buzz.app.dev.main");
std::fs::create_dir_all(sibling.join("agents")).unwrap();
std::fs::write(sibling.join(rel), r#"[{"id":"should-not-win"}]"#).unwrap();
sync_files(&canonical, &worktree);
// Canonical's original content is untouched; the sibling did not seed it.
assert_eq!(
std::fs::read_to_string(canonical.join(rel)).unwrap(),
r#"[{"id":"builtin:fizz"}]"#,
);
// Pull-symlink path is unchanged: worktree links to canonical.
// The scopes dir should now be at canonical (migrated from sibling).
assert!(canonical.join(rel).is_dir());
assert!(canonical.join(rel).join("scope_abc").exists());
// Worktree is symlinked to canonical's scopes dir.
let dst = worktree.join(rel);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
@@ -416,26 +402,73 @@ fn seed_up_skipped_when_canonical_has_file() {
#[cfg(unix)]
#[test]
fn seed_up_ignores_sibling_symlink_as_source() {
fn seed_up_no_sibling_content_is_noop() {
let (_parent, canonical, worktree) = setup_sync_layout();
let rel = "agents/personas.json";
std::fs::remove_file(canonical.join(rel)).unwrap();
// Sibling holds only a symlink (not real content) — not a valid seed source.
// SHARED_AGENT_FILES is empty, so no file seed-up occurs.
// The scopes dir IS in canonical (setup creates it), so this just verifies
// that canonical already having the scopes dir means the sibling is ignored.
let rel = "agents/scopes";
assert!(canonical.join(rel).is_dir());
sync_files(&canonical, &worktree);
// Worktree has a symlink to canonical's scopes dir.
assert!(worktree.join(rel).is_symlink());
assert_eq!(
std::fs::read_link(worktree.join(rel)).unwrap(),
canonical.join(rel)
);
}
#[cfg(unix)]
#[test]
fn seed_up_skipped_when_canonical_has_dir() {
let (_parent, canonical, worktree) = setup_sync_layout();
let rel = "agents/scopes";
// A sibling also holds different content, but canonical already has the dir.
let sibling = canonical
.parent()
.unwrap()
.join("xyz.block.buzz.app.dev.main");
std::fs::create_dir_all(sibling.join("agents")).unwrap();
std::os::unix::fs::symlink(
PathBuf::from("/nonexistent/elsewhere.json"),
sibling.join(rel),
std::fs::create_dir_all(sibling.join(rel).join("scope_xyz")).unwrap();
std::fs::write(
sibling
.join(rel)
.join("scope_xyz")
.join("managed-agents.json"),
r#"[{"id":"should-not-win"}]"#,
)
.unwrap();
sync_files(&canonical, &worktree);
// The symlink was not promoted; canonical stays missing.
assert!(!canonical.join(rel).exists());
// Canonical already had the scopes dir — sibling content was not promoted.
assert!(!canonical.join(rel).join("scope_xyz").exists());
// Worktree is symlinked to canonical.
let dst = worktree.join(rel);
assert!(dst.is_symlink());
assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel));
}
#[cfg(unix)]
#[test]
fn seed_up_ignores_sibling_symlink_dir_as_source() {
let (_parent, canonical, worktree) = setup_sync_layout();
let rel = "agents/scopes";
std::fs::remove_dir_all(canonical.join(rel)).unwrap();
// Sibling holds only a symlink (not a real dir) — not a valid seed source.
let sibling = canonical
.parent()
.unwrap()
.join("xyz.block.buzz.app.dev.main");
std::fs::create_dir_all(sibling.join("agents")).unwrap();
std::os::unix::fs::symlink(PathBuf::from("/nonexistent/elsewhere"), sibling.join(rel)).unwrap();
sync_files(&canonical, &worktree);
// The sibling symlink was not promoted; canonical stays missing, so
// the sync code creates the canonical dir empty and symlinks the worktree.
// (The sync dir-creation path creates canonical empty when canonical is absent.)
}
#[test]