mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
e6aa269814
commit
b795ed6c79
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user