From b795ed6c796aa01fd9cfcee4bdceb250f2386ebe Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sun, 2 Aug 2026 23:40:35 -0400 Subject: [PATCH] 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 Signed-off-by: Will Pfleger --- .../src/managed_agents/scope_init.rs | 74 +++++- desktop/src-tauri/src/migration.rs | 78 +++++- desktop/src-tauri/src/migration/backfill.rs | 2 +- desktop/src-tauri/src/migration/detach.rs | 2 +- desktop/src-tauri/src/migration/fold.rs | 2 +- .../src-tauri/src/migration/materialize.rs | 2 +- .../src-tauri/src/migration/team_suffix.rs | 2 +- desktop/src-tauri/src/migration_tests.rs | 239 ++++++++++-------- 8 files changed, 280 insertions(+), 121 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/scope_init.rs b/desktop/src-tauri/src/managed_agents/scope_init.rs index 65deff256..ad311c437 100644 --- a/desktop/src-tauri/src/managed_agents/scope_init.rs +++ b/desktop/src-tauri/src/managed_agents/scope_init.rs @@ -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(()) } diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index b3e613621..837b4684f 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -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//` +/// (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: +// `/agents/scopes//`) instead of the legacy +// `/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; diff --git a/desktop/src-tauri/src/migration/backfill.rs b/desktop/src-tauri/src/migration/backfill.rs index cd62f63bb..0155d8625 100644 --- a/desktop/src-tauri/src/migration/backfill.rs +++ b/desktop/src-tauri/src/migration/backfill.rs @@ -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 { +pub(crate) fn backfill_standalone_agents_in_dir(base_dir: &Path) -> Result { let agents_path = base_dir.join("managed-agents.json"); if !agents_path.exists() { return Ok(0); diff --git a/desktop/src-tauri/src/migration/detach.rs b/desktop/src-tauri/src/migration/detach.rs index 9f746e479..bce5492be 100644 --- a/desktop/src-tauri/src/migration/detach.rs +++ b/desktop/src-tauri/src/migration/detach.rs @@ -39,7 +39,7 @@ pub fn detach_directory_backed_teams(app: &tauri::AppHandle) { /// /// `base_dir` is the managed-agents base directory (`/agents/`). /// Returns the number of teams detached (0 = nothing to do). -pub(super) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result { +pub(crate) fn detach_directory_backed_teams_in_dir(base_dir: &Path) -> Result { let teams_path = base_dir.join("teams.json"); let agents_path = base_dir.join("managed-agents.json"); diff --git a/desktop/src-tauri/src/migration/fold.rs b/desktop/src-tauri/src/migration/fold.rs index 727982e44..516a1b0af 100644 --- a/desktop/src-tauri/src/migration/fold.rs +++ b/desktop/src-tauri/src/migration/fold.rs @@ -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, String> { +pub(crate) fn fold_personas_in_dir(base_dir: &Path) -> Result, String> { let personas_path = base_dir.join("personas.json"); if !personas_path.exists() { return Ok(None); diff --git a/desktop/src-tauri/src/migration/materialize.rs b/desktop/src-tauri/src/migration/materialize.rs index 5930920dd..a5de84071 100644 --- a/desktop/src-tauri/src/migration/materialize.rs +++ b/desktop/src-tauri/src/migration/materialize.rs @@ -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; diff --git a/desktop/src-tauri/src/migration/team_suffix.rs b/desktop/src-tauri/src/migration/team_suffix.rs index d65113fb8..646569468 100644 --- a/desktop/src-tauri/src/migration/team_suffix.rs +++ b/desktop/src-tauri/src/migration/team_suffix.rs @@ -64,7 +64,7 @@ pub fn strip_baked_team_instructions(app: &tauri::AppHandle) { /// `base_dir` is the managed-agents base directory (`/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 { +pub(crate) fn strip_baked_team_instructions_in_dir(base_dir: &Path) -> Result { let agents_path = base_dir.join("managed-agents.json"); if !agents_path.exists() { return Ok(0); diff --git a/desktop/src-tauri/src/migration_tests.rs b/desktop/src-tauri/src/migration_tests.rs index 0d49bd02a..cc9a671dc 100644 --- a/desktop/src-tauri/src/migration_tests.rs +++ b/desktop/src-tauri/src/migration_tests.rs @@ -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//.) + 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]