fix(desktop): heal stale persona_team_dir paths in release builds (#1003)

This commit is contained in:
Will Pfleger
2026-06-12 11:48:19 -04:00
committed by GitHub
parent 1fa63badad
commit df8896f13e
5 changed files with 584 additions and 224 deletions

View File

@@ -142,7 +142,9 @@ fn patch_json_records(
} }
if changed { if changed {
if let Ok(bytes) = serde_json::to_vec_pretty(&records) { if let Ok(bytes) = serde_json::to_vec_pretty(&records) {
let _ = std::fs::write(path, bytes); if let Err(e) = crate::managed_agents::atomic_write_json(path, &bytes) {
eprintln!("buzz-desktop: patch-json-records: {e}");
}
} }
} }
} }
@@ -343,8 +345,8 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) {
} }
} }
fn reconcile_team_dirs_in_file(path: &Path, canonical_dir: &Path) { fn reconcile_team_dirs_in_file(path: &Path, target_dir: &Path) {
let canonical_teams = canonical_dir.join("agents/teams"); let target_teams = target_dir.join("agents/teams");
patch_json_records(path, |obj| { patch_json_records(path, |obj| {
// Handle both old field name and new field name // Handle both old field name and new field name
let field_name = if obj.contains_key("persona_team_dir") { let field_name = if obj.contains_key("persona_team_dir") {
@@ -374,43 +376,111 @@ fn reconcile_team_dirs_in_file(path: &Path, canonical_dir: &Path) {
let Some(id) = team_id else { let Some(id) = team_id else {
return false; return false;
}; };
let expected = canonical_teams.join(id); let expected = target_teams.join(id);
if team_path == expected { if team_path == expected {
// Value already correct — still normalize the legacy field name so
// stores converge on `persona_team_dir` (runtime reads either via
// serde alias).
if field_name == "persona_pack_path" {
if let Some(val) = obj.remove("persona_pack_path") {
obj.insert("persona_team_dir".to_string(), val);
return true;
}
}
return false; return false;
} }
// Rewriting to a path that does not exist on disk makes things worse
// than leaving a stale-but-working path in place. fs::metadata follows
// symlinks, so a valid symlinked install passes; a dangling symlink
// fails with NotFound.
if let Err(e) = std::fs::metadata(&expected) {
eprintln!(
"buzz-desktop: team-dir-reconcile: {:?}: {:?} expected at {:?} — {e}, leaving as-is",
obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
team_path,
expected,
);
return false;
}
let Some(expected_str) = expected.to_str() else {
eprintln!(
"buzz-desktop: team-dir-reconcile: {:?}: expected path {:?} is not valid UTF-8, leaving as-is",
obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
expected,
);
return false;
};
eprintln!( eprintln!(
"buzz-desktop: team-dir-reconcile: {:?}: {:?} → {:?}", "buzz-desktop: team-dir-reconcile: {:?}: {:?} → {:?}",
obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
team_path, team_path,
expected, expected,
); );
// Always write the canonical new field name // Always write the new field name
obj.remove("persona_pack_path"); obj.remove("persona_pack_path");
obj.insert( obj.insert(
"persona_team_dir".to_string(), "persona_team_dir".to_string(),
serde_json::Value::String(expected.to_string_lossy().into_owned()), serde_json::Value::String(expected_str.to_owned()),
); );
true true
}); });
} }
/// Select the data directory to reconcile against.
///
/// Dev instances — identified by the data-dir name starting with
/// `CANONICAL_DEV_IDENTIFIER` (covers the canonical dir itself and any
/// worktree variant like `xyz.block.buzz.app.dev.mybranch`) — share
/// `agents/managed-agents.json` and `agents/teams` via symlinks to the
/// canonical dev dir, so they should normalize against that canonical dir.
///
/// Release builds must reconcile their own data dir — keying off the canonical
/// dev dir's mere existence would leave release records permanently stale on
/// developer machines, where that dir is always present.
fn reconcile_target_dir(current_dir: &Path) -> PathBuf {
let is_dev_instance = current_dir
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(CANONICAL_DEV_IDENTIFIER));
if is_dev_instance {
match canonical_dev_data_dir(current_dir) {
Some(dir) if dir.exists() => dir,
_ => current_dir.to_path_buf(),
}
} else {
current_dir.to_path_buf()
}
}
/// Reconcile `persona_team_dir` (and legacy `persona_pack_path`) values in /// Reconcile `persona_team_dir` (and legacy `persona_pack_path`) values in
/// managed-agents.json to point to the canonical dev data directory's /// managed-agents.json to point to the correct `agents/teams/` prefix.
/// `agents/teams/` prefix. Fixes stale paths left when agents were created ///
/// from worktree instances whose data directories don't have local team copies. /// Fixes two classes of stale paths:
/// - Worktree dev instances whose records point at a sibling data dir rather
/// than the canonical dev dir (dev instances share managed-agents.json and
/// agents/teams via symlinks, so they all reconcile against the canonical dir).
/// - Legacy paths left by historical renames: `agents/packs/` → `agents/teams/`
/// (the packs→teams consolidation) and bundle-id `xyz.block.sprout.app` →
/// `xyz.block.buzz.app` (the sprout→buzz rename, which moved the app data dir).
///
/// Release builds reconcile their own data dir — choosing the canonical dev dir
/// whenever it exists would leave release files permanently stale on developer
/// machines.
pub fn reconcile_persona_team_dirs(app: &tauri::AppHandle) { pub fn reconcile_persona_team_dirs(app: &tauri::AppHandle) {
let Ok(current_dir) = app.path().app_data_dir() else { let Ok(current_dir) = app.path().app_data_dir() else {
return; return;
}; };
let canonical_dir = match canonical_dev_data_dir(&current_dir) { // Single-dir on purpose: unlike reconcile_legacy_command_names and
Some(dir) if dir.exists() => dir, // reconcile_provider_mcp_commands, which patch both [current, canonical],
_ => current_dir, // path rewrites are target-dependent — a dual pass through a dev
}; // instance's symlinked store would write worktree-local paths into the
let path = canonical_dir.join("agents/managed-agents.json"); // shared canonical file.
let target_dir = reconcile_target_dir(&current_dir);
let path = target_dir.join("agents/managed-agents.json");
if !path.exists() { if !path.exists() {
return; return;
} }
reconcile_team_dirs_in_file(&path, &canonical_dir); reconcile_team_dirs_in_file(&path, &target_dir);
} }
/// One-time migration from packs to teams. /// One-time migration from packs to teams.
@@ -428,15 +498,12 @@ pub fn migrate_packs_to_teams(app: &tauri::AppHandle) {
let Ok(current_dir) = app.path().app_data_dir() else { let Ok(current_dir) = app.path().app_data_dir() else {
return; return;
}; };
let canonical_dir = match canonical_dev_data_dir(&current_dir) { let target_dir = reconcile_target_dir(&current_dir);
Some(dir) if dir.exists() => dir,
_ => current_dir,
};
let packs_dir = canonical_dir.join("agents/packs"); let packs_dir = target_dir.join("agents/packs");
let teams_dir = canonical_dir.join("agents/teams"); let teams_dir = target_dir.join("agents/teams");
let personas_path = canonical_dir.join("agents/personas.json"); let personas_path = target_dir.join("agents/personas.json");
let agents_path = canonical_dir.join("agents/managed-agents.json"); let agents_path = target_dir.join("agents/managed-agents.json");
// Check if migration is needed: packs dir exists OR agents JSON has old field names // Check if migration is needed: packs dir exists OR agents JSON has old field names
let packs_dir_exists = packs_dir.exists() && !packs_dir.is_symlink(); let packs_dir_exists = packs_dir.exists() && !packs_dir.is_symlink();
@@ -831,6 +898,10 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) {
rename_provider_to_runtime_in_personas(&path); rename_provider_to_runtime_in_personas(&path);
} }
#[cfg(test)]
#[path = "migration_test_support.rs"]
mod test_support;
#[cfg(test)] #[cfg(test)]
#[path = "migration_tests.rs"] #[path = "migration_tests.rs"]
mod tests; mod tests;
@@ -838,3 +909,7 @@ mod tests;
#[cfg(test)] #[cfg(test)]
#[path = "migration_command_tests.rs"] #[path = "migration_command_tests.rs"]
mod command_tests; mod command_tests;
#[cfg(test)]
#[path = "migration_team_dir_tests.rs"]
mod team_dir_tests;

View File

@@ -1,33 +1,5 @@
use super::test_support::*;
use super::*; use super::*;
use std::path::Path;
fn write_agents_json(dir: &Path, records: &serde_json::Value) {
std::fs::create_dir_all(dir.join("agents")).unwrap();
std::fs::write(
dir.join("agents/managed-agents.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
fn read_agents_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/managed-agents.json")).unwrap();
serde_json::from_str(&content).unwrap()
}
fn write_personas_json(dir: &Path, records: &serde_json::Value) {
std::fs::create_dir_all(dir.join("agents")).unwrap();
std::fs::write(
dir.join("agents/personas.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
fn read_personas_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/personas.json")).unwrap();
serde_json::from_str(&content).unwrap()
}
#[test] #[test]
fn reconcile_legacy_command_names_rewrites_renamed_sidecars() { fn reconcile_legacy_command_names_rewrites_renamed_sidecars() {

View File

@@ -0,0 +1,453 @@
use super::test_support::*;
use super::*;
// ── reconcile_team_dirs_in_file tests ────────────────────────────────
#[test]
fn team_dir_reconcile_rewrites_worktree_path() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
// Team must exist on disk for the reconcile to proceed past the existence gate.
std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap();
let worktree_pack_path = format!(
"{}/agents/packs/com.wpfleger.sietch-tabr",
parent
.path()
.join("xyz.block.buzz.app.dev.worktree-my-branch")
.display()
);
let expected_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_pack_path": worktree_pack_path
}]),
);
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_team_dir"], expected_path);
// Old field name should be removed
assert!(records[0].get("persona_pack_path").is_none());
}
#[test]
fn team_dir_reconcile_rewrites_new_field_name() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
// Team must exist on disk for the reconcile to proceed past the existence gate.
std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap();
let worktree_team_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
parent
.path()
.join("xyz.block.buzz.app.dev.worktree-my-branch")
.display()
);
let expected_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_team_dir": worktree_team_path
}]),
);
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_team_dir"], expected_path);
}
#[test]
fn team_dir_reconcile_leaves_canonical_path_unchanged() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
let canonical_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Duncan",
"persona_team_dir": canonical_path
}]),
);
let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let after = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
assert_eq!(before, after);
}
#[test]
fn team_dir_reconcile_skips_records_without_team_dir() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Test Agent",
"agent_command": "buzz-agent"
}]),
);
let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let after = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
assert_eq!(before, after);
}
#[test]
fn team_dir_reconcile_is_idempotent() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
// Team must exist on disk for the reconcile to proceed past the existence gate.
std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap();
let worktree_pack_path = format!(
"{}/agents/packs/com.wpfleger.sietch-tabr",
parent
.path()
.join("xyz.block.buzz.app.dev.worktree-my-branch")
.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_pack_path": worktree_pack_path
}]),
);
let path = canonical.join("agents/managed-agents.json");
reconcile_team_dirs_in_file(&path, &canonical);
let after_first = std::fs::read_to_string(&path).unwrap();
reconcile_team_dirs_in_file(&path, &canonical);
let after_second = std::fs::read_to_string(&path).unwrap();
assert_eq!(after_first, after_second);
}
#[test]
fn team_dir_reconcile_heals_legacy_release_path() {
// Record stored under the old bundle id and old packs segment should be
// rewritten to the new bundle id and teams segment.
let parent = tempfile::tempdir().unwrap();
let release_dir = parent.path().join("xyz.block.buzz.app");
std::fs::create_dir_all(release_dir.join("agents/teams/com.example.team")).unwrap();
let legacy_path = format!(
"{}/agents/packs/com.example.team",
parent.path().join("xyz.block.sprout.app").display()
);
let expected_path = format!("{}/agents/teams/com.example.team", release_dir.display());
write_agents_json(
&release_dir,
&serde_json::json!([{
"name": "Stilgar",
"persona_team_dir": legacy_path
}]),
);
reconcile_team_dirs_in_file(
&release_dir.join("agents/managed-agents.json"),
&release_dir,
);
let records = read_agents_json(&release_dir);
assert_eq!(records[0]["persona_team_dir"], expected_path);
}
#[test]
fn team_dir_reconcile_leaves_record_when_team_missing() {
// When the expected target does not exist, the record must be left untouched
// and the file must not be rewritten at all (no churn).
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
// Intentionally do NOT create agents/teams/com.example.missing.
let stale_path = format!(
"{}/agents/packs/com.example.missing",
parent
.path()
.join("xyz.block.buzz.app.dev.worktree-old")
.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Jessica",
"persona_pack_path": stale_path
}]),
);
let path = canonical.join("agents/managed-agents.json");
let before = std::fs::read_to_string(&path).unwrap();
reconcile_team_dirs_in_file(&path, &canonical);
let after = std::fs::read_to_string(&path).unwrap();
// File must be byte-identical — no churn.
assert_eq!(before, after);
// Record still has the old field and value.
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_pack_path"], stale_path);
assert!(records[0].get("persona_team_dir").is_none());
}
#[test]
fn team_dir_reconcile_heals_to_symlinked_team_dir() {
// When agents/teams/<id> is a symlink to a real directory elsewhere,
// Path::exists follows the symlink, so the rewrite should proceed.
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
// Real team lives outside the canonical dir; a symlink points to it.
let real_team = parent.path().join("real-team-store/com.example.team");
std::fs::create_dir_all(&real_team).unwrap();
let symlink_target = canonical.join("agents/teams/com.example.team");
std::fs::create_dir_all(canonical.join("agents/teams")).unwrap();
std::os::unix::fs::symlink(&real_team, &symlink_target).unwrap();
let stale_path = format!(
"{}/agents/packs/com.example.team",
parent
.path()
.join("xyz.block.buzz.app.dev.worktree-old")
.display()
);
let expected_path = format!("{}/agents/teams/com.example.team", canonical.display());
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Chani",
"persona_pack_path": stale_path
}]),
);
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_team_dir"], expected_path);
}
#[test]
fn team_dir_reconcile_skips_dangling_candidate_symlink() {
// When agents/teams/<id> is a symlink whose target does not exist,
// Path::exists returns false, so the record must be left unchanged.
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents/teams")).unwrap();
// Create a dangling symlink at the expected location.
let dangling_target = parent.path().join("nonexistent-dir");
let symlink_path = canonical.join("agents/teams/com.example.gone");
std::os::unix::fs::symlink(&dangling_target, &symlink_path).unwrap();
let stale_path = format!(
"{}/agents/packs/com.example.gone",
parent
.path()
.join("xyz.block.buzz.app.dev.worktree-old")
.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Gurney",
"persona_pack_path": stale_path
}]),
);
let path = canonical.join("agents/managed-agents.json");
let before = std::fs::read_to_string(&path).unwrap();
reconcile_team_dirs_in_file(&path, &canonical);
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(before, after);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_pack_path"], stale_path);
}
#[test]
fn team_dir_reconcile_through_symlink_preserves_symlink() {
// Dev worktree instances reach the canonical store through a symlinked
// managed-agents.json — the patched write must land in the canonical
// file and leave the symlink in place.
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
let worktree = parent
.path()
.join("xyz.block.buzz.app.dev.worktree-my-branch");
std::fs::create_dir_all(canonical.join("agents/teams/com.example.team")).unwrap();
std::fs::create_dir_all(worktree.join("agents")).unwrap();
let stale_path = format!("{}/agents/packs/com.example.team", worktree.display());
let expected_path = format!("{}/agents/teams/com.example.team", canonical.display());
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_pack_path": stale_path
}]),
);
let canonical_store = canonical.join("agents/managed-agents.json");
let worktree_store = worktree.join("agents/managed-agents.json");
std::os::unix::fs::symlink(&canonical_store, &worktree_store).unwrap();
reconcile_team_dirs_in_file(&worktree_store, &canonical);
assert!(worktree_store.is_symlink());
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_team_dir"], expected_path);
}
// ── reconcile_target_dir tests ───────────────────────────────────────
#[test]
fn reconcile_target_dir_release_with_existing_dev_sibling_returns_self() {
// Release build must reconcile its OWN dir, even when a dev sibling exists.
let parent = tempfile::tempdir().unwrap();
let release_dir = parent.path().join("xyz.block.buzz.app");
let dev_dir = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(&release_dir).unwrap();
std::fs::create_dir_all(&dev_dir).unwrap();
assert_eq!(reconcile_target_dir(&release_dir), release_dir);
}
#[test]
fn reconcile_target_dir_worktree_dev_with_canonical_sibling_returns_canonical() {
// A worktree dev instance defers to the canonical dev dir when it exists.
let parent = tempfile::tempdir().unwrap();
let worktree_dir = parent.path().join("xyz.block.buzz.app.dev.mybranch");
let canonical_dir = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(&worktree_dir).unwrap();
std::fs::create_dir_all(&canonical_dir).unwrap();
assert_eq!(reconcile_target_dir(&worktree_dir), canonical_dir);
}
#[test]
fn reconcile_target_dir_canonical_dev_returns_self() {
// The canonical dev dir is itself a dev instance; it should return itself
// (canonical_dev_data_dir points at the same path, and that path exists).
let parent = tempfile::tempdir().unwrap();
let canonical_dir = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(&canonical_dir).unwrap();
assert_eq!(reconcile_target_dir(&canonical_dir), canonical_dir);
}
#[test]
fn reconcile_target_dir_release_without_dev_sibling_returns_self() {
// Release build with no dev sibling present — stays on its own dir.
let parent = tempfile::tempdir().unwrap();
let release_dir = parent.path().join("xyz.block.buzz.app");
std::fs::create_dir_all(&release_dir).unwrap();
assert_eq!(reconcile_target_dir(&release_dir), release_dir);
}
#[test]
fn reconcile_target_dir_worktree_dev_without_canonical_sibling_returns_self() {
// Worktree dev instance with no canonical sibling present — stays on itself.
let parent = tempfile::tempdir().unwrap();
let worktree_dir = parent.path().join("xyz.block.buzz.app.dev.mybranch");
std::fs::create_dir_all(&worktree_dir).unwrap();
assert_eq!(reconcile_target_dir(&worktree_dir), worktree_dir);
}
#[test]
fn reconcile_target_dir_ignores_non_prefix_dev_identifier() {
// Dev detection is prefix-only — a dir merely *containing* the dev
// identifier later in its name is not a dev instance.
let parent = tempfile::tempdir().unwrap();
let odd_dir = parent.path().join("com.example.xyz.block.buzz.app.dev");
let canonical_dir = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(&odd_dir).unwrap();
std::fs::create_dir_all(&canonical_dir).unwrap();
assert_eq!(reconcile_target_dir(&odd_dir), odd_dir);
}
#[test]
fn team_dir_reconcile_skips_path_without_teams_or_packs_component() {
// A path with no teams/packs component yields no team id — record is
// silently skipped and the file is not rewritten.
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Leto",
"persona_team_dir": "/some/random/path"
}]),
);
let path = canonical.join("agents/managed-agents.json");
let before = std::fs::read_to_string(&path).unwrap();
reconcile_team_dirs_in_file(&path, &canonical);
let after = std::fs::read_to_string(&path).unwrap();
assert_eq!(before, after);
}
#[test]
fn team_dir_reconcile_renames_legacy_field_when_value_already_canonical() {
// Value already points at the target — only the legacy field name is
// normalized; the value is untouched.
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents/teams/com.example.team")).unwrap();
let correct_path = format!("{}/agents/teams/com.example.team", canonical.display());
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Thufir",
"persona_pack_path": correct_path
}]),
);
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_team_dir"], correct_path);
assert!(records[0].get("persona_pack_path").is_none());
}

View File

@@ -0,0 +1,31 @@
//! Shared helpers for the migration test modules.
use std::path::Path;
pub(crate) fn write_agents_json(dir: &Path, records: &serde_json::Value) {
std::fs::create_dir_all(dir.join("agents")).unwrap();
std::fs::write(
dir.join("agents/managed-agents.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
pub(crate) fn read_agents_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/managed-agents.json")).unwrap();
serde_json::from_str(&content).unwrap()
}
pub(crate) fn write_personas_json(dir: &Path, records: &serde_json::Value) {
std::fs::create_dir_all(dir.join("agents")).unwrap();
std::fs::write(
dir.join("agents/personas.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
pub(crate) fn read_personas_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/personas.json")).unwrap();
serde_json::from_str(&content).unwrap()
}

View File

@@ -1,3 +1,4 @@
use super::test_support::*;
use super::*; use super::*;
#[test] #[test]
@@ -320,20 +321,6 @@ fn canonical_dev_data_dir_returns_self_for_canonical_instance() {
assert_eq!(canonical_dev_data_dir(&canonical).unwrap(), canonical); assert_eq!(canonical_dev_data_dir(&canonical).unwrap(), canonical);
} }
fn write_agents_json(dir: &Path, records: &serde_json::Value) {
std::fs::create_dir_all(dir.join("agents")).unwrap();
std::fs::write(
dir.join("agents/managed-agents.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
fn read_agents_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/managed-agents.json")).unwrap();
serde_json::from_str(&content).unwrap()
}
#[test] #[test]
fn sync_creates_teams_directory_symlink() { fn sync_creates_teams_directory_symlink() {
let (_parent, canonical, worktree) = setup_sync_layout(); let (_parent, canonical, worktree) = setup_sync_layout();
@@ -397,150 +384,6 @@ fn sync_replaces_real_teams_dir_with_symlink() {
); );
} }
#[test]
fn team_dir_reconcile_rewrites_worktree_path() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
let worktree_pack_path = format!(
"{}/agents/packs/com.wpfleger.sietch-tabr",
parent
.path()
.join("xyz.block.buzz.app.dev.worktree-my-branch")
.display()
);
let expected_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_pack_path": worktree_pack_path
}]),
);
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_team_dir"], expected_path);
// Old field name should be removed
assert!(records[0].get("persona_pack_path").is_none());
}
#[test]
fn team_dir_reconcile_rewrites_new_field_name() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
let worktree_team_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
parent
.path()
.join("xyz.block.buzz.app.dev.worktree-my-branch")
.display()
);
let expected_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_team_dir": worktree_team_path
}]),
);
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_team_dir"], expected_path);
}
#[test]
fn team_dir_reconcile_leaves_canonical_path_unchanged() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
let canonical_path = format!(
"{}/agents/teams/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Duncan",
"persona_team_dir": canonical_path
}]),
);
let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let after = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
assert_eq!(before, after);
}
#[test]
fn team_dir_reconcile_skips_records_without_team_dir() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Test Agent",
"agent_command": "buzz-agent"
}]),
);
let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
reconcile_team_dirs_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let after = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
assert_eq!(before, after);
}
#[test]
fn team_dir_reconcile_is_idempotent() {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
std::fs::create_dir_all(canonical.join("agents")).unwrap();
let worktree_pack_path = format!(
"{}/agents/packs/com.wpfleger.sietch-tabr",
parent
.path()
.join("xyz.block.buzz.app.dev.worktree-my-branch")
.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_pack_path": worktree_pack_path
}]),
);
let path = canonical.join("agents/managed-agents.json");
reconcile_team_dirs_in_file(&path, &canonical);
let after_first = std::fs::read_to_string(&path).unwrap();
reconcile_team_dirs_in_file(&path, &canonical);
let after_second = std::fs::read_to_string(&path).unwrap();
assert_eq!(after_first, after_second);
}
// ── Packs → Teams migration tests ─────────────────────────────────── // ── Packs → Teams migration tests ───────────────────────────────────
#[test] #[test]
@@ -673,20 +516,6 @@ fn migrate_packs_to_teams_rewrites_agents_json() {
assert!(records[0].get("persona_name_in_pack").is_none()); assert!(records[0].get("persona_name_in_pack").is_none());
} }
fn write_personas_json(dir: &Path, records: &serde_json::Value) {
std::fs::create_dir_all(dir.join("agents")).unwrap();
std::fs::write(
dir.join("agents/personas.json"),
serde_json::to_vec_pretty(records).unwrap(),
)
.unwrap();
}
fn read_personas_json(dir: &Path) -> Vec<serde_json::Value> {
let content = std::fs::read_to_string(dir.join("agents/personas.json")).unwrap();
serde_json::from_str(&content).unwrap()
}
#[test] #[test]
fn rename_provider_to_runtime_migrates_field() { fn rename_provider_to_runtime_migrates_field() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();