mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
fix(desktop): heal stale persona_team_dir paths in release builds (#1003)
This commit is contained in:
@@ -142,7 +142,9 @@ fn patch_json_records(
|
||||
}
|
||||
if changed {
|
||||
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) {
|
||||
let canonical_teams = canonical_dir.join("agents/teams");
|
||||
fn reconcile_team_dirs_in_file(path: &Path, target_dir: &Path) {
|
||||
let target_teams = target_dir.join("agents/teams");
|
||||
patch_json_records(path, |obj| {
|
||||
// Handle both old field name and new field name
|
||||
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 {
|
||||
return false;
|
||||
};
|
||||
let expected = canonical_teams.join(id);
|
||||
let expected = target_teams.join(id);
|
||||
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;
|
||||
}
|
||||
// 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!(
|
||||
"buzz-desktop: team-dir-reconcile: {:?}: {:?} → {:?}",
|
||||
obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
|
||||
team_path,
|
||||
expected,
|
||||
);
|
||||
// Always write the canonical new field name
|
||||
// Always write the new field name
|
||||
obj.remove("persona_pack_path");
|
||||
obj.insert(
|
||||
"persona_team_dir".to_string(),
|
||||
serde_json::Value::String(expected.to_string_lossy().into_owned()),
|
||||
serde_json::Value::String(expected_str.to_owned()),
|
||||
);
|
||||
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
|
||||
/// managed-agents.json to point to the canonical dev data directory's
|
||||
/// `agents/teams/` prefix. Fixes stale paths left when agents were created
|
||||
/// from worktree instances whose data directories don't have local team copies.
|
||||
/// managed-agents.json to point to the correct `agents/teams/` prefix.
|
||||
///
|
||||
/// 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) {
|
||||
let Ok(current_dir) = app.path().app_data_dir() else {
|
||||
return;
|
||||
};
|
||||
let canonical_dir = match canonical_dev_data_dir(¤t_dir) {
|
||||
Some(dir) if dir.exists() => dir,
|
||||
_ => current_dir,
|
||||
};
|
||||
let path = canonical_dir.join("agents/managed-agents.json");
|
||||
// Single-dir on purpose: unlike reconcile_legacy_command_names and
|
||||
// reconcile_provider_mcp_commands, which patch both [current, canonical],
|
||||
// path rewrites are target-dependent — a dual pass through a dev
|
||||
// instance's symlinked store would write worktree-local paths into the
|
||||
// shared canonical file.
|
||||
let target_dir = reconcile_target_dir(¤t_dir);
|
||||
let path = target_dir.join("agents/managed-agents.json");
|
||||
if !path.exists() {
|
||||
return;
|
||||
}
|
||||
reconcile_team_dirs_in_file(&path, &canonical_dir);
|
||||
reconcile_team_dirs_in_file(&path, &target_dir);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
return;
|
||||
};
|
||||
let canonical_dir = match canonical_dev_data_dir(¤t_dir) {
|
||||
Some(dir) if dir.exists() => dir,
|
||||
_ => current_dir,
|
||||
};
|
||||
let target_dir = reconcile_target_dir(¤t_dir);
|
||||
|
||||
let packs_dir = canonical_dir.join("agents/packs");
|
||||
let teams_dir = canonical_dir.join("agents/teams");
|
||||
let personas_path = canonical_dir.join("agents/personas.json");
|
||||
let agents_path = canonical_dir.join("agents/managed-agents.json");
|
||||
let packs_dir = target_dir.join("agents/packs");
|
||||
let teams_dir = target_dir.join("agents/teams");
|
||||
let personas_path = target_dir.join("agents/personas.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
|
||||
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);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "migration_test_support.rs"]
|
||||
mod test_support;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "migration_tests.rs"]
|
||||
mod tests;
|
||||
@@ -838,3 +909,7 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "migration_command_tests.rs"]
|
||||
mod command_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "migration_team_dir_tests.rs"]
|
||||
mod team_dir_tests;
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
use super::test_support::*;
|
||||
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]
|
||||
fn reconcile_legacy_command_names_rewrites_renamed_sidecars() {
|
||||
|
||||
453
desktop/src-tauri/src/migration_team_dir_tests.rs
Normal file
453
desktop/src-tauri/src/migration_team_dir_tests.rs
Normal 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());
|
||||
}
|
||||
31
desktop/src-tauri/src/migration_test_support.rs
Normal file
31
desktop/src-tauri/src/migration_test_support.rs
Normal 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()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::test_support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -320,20 +321,6 @@ fn canonical_dev_data_dir_returns_self_for_canonical_instance() {
|
||||
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]
|
||||
fn sync_creates_teams_directory_symlink() {
|
||||
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 ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
@@ -673,20 +516,6 @@ fn migrate_packs_to_teams_rewrites_agents_json() {
|
||||
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]
|
||||
fn rename_provider_to_runtime_migrates_field() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user