fix(desktop): sync persona pack directory across worktree instances (#782)

This commit is contained in:
Will Pfleger
2026-05-28 20:41:23 -04:00
committed by GitHub
parent a28a0e6b42
commit fa7febe40f
3 changed files with 399 additions and 17 deletions
+2 -2
View File
@@ -47,7 +47,7 @@ const overrides = new Map([
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
["src-tauri/src/migration.rs", 630], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES symlink-to-canonical) + mcp_command provider reconciliation + tests
["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
@@ -79,7 +79,7 @@ const overrides = new Map([
["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
["src-tauri/src/lib.rs", 733], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands calls on launch
["src-tauri/src/lib.rs", 735], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
]);
+1
View File
@@ -404,6 +404,7 @@ pub fn run() {
// restore_managed_agents_on_launch (which reads managed-agents.json).
migration::sync_shared_agent_data(&app_handle);
migration::reconcile_provider_mcp_commands(&app_handle);
migration::reconcile_persona_pack_paths(&app_handle);
// Resolve persisted identity key (env var → file → generate+save).
// This is fatal — the app should not start with an ephemeral identity
+396 -15
View File
@@ -21,17 +21,16 @@ const CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.sprout.app.dev";
/// dev data directory. Only data files — never `agent-pids/` or `logs/`.
/// `identity.key` is deliberately excluded because worktree instances
/// receive their identity via the `SPROUT_PRIVATE_KEY` env var.
///
/// NOTE: `agents/packs/` is intentionally excluded — recursive directory
/// symlink is out of scope. Pack personas will appear in the worktree but
/// agents with `persona_pack_path` may fail if the ACP reads pack files
/// at runtime. Install packs in the worktree separately if needed.
const SHARED_AGENT_FILES: &[&str] = &[
"agents/managed-agents.json",
"agents/personas.json",
"agents/teams.json",
];
/// 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/packs"];
fn canonical_dev_data_dir(current: &Path) -> Option<PathBuf> {
current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER))
}
@@ -172,9 +171,94 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) {
}
}
// Ensure shared directories exist in canonical before symlinking.
// Packs may have been installed in a sibling instance (e.g., `.main`)
// before shared-dir syncing existed — migrate them to canonical.
for rel in SHARED_AGENT_DIRS {
let canonical_target = canonical_dir.join(rel);
if !canonical_target.exists() {
if let Err(e) = std::fs::create_dir_all(&canonical_target) {
eprintln!(
"sprout-desktop: shared-agent-sync: failed to create {}: {e}",
canonical_target.display()
);
}
// Migrate from whichever sibling has real (non-symlink) content.
if let Some(parent) = canonical_dir.parent() {
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let sibling = entry.path();
if sibling == canonical_dir {
continue;
}
let sibling_dir = sibling.join(rel);
if sibling_dir.is_dir() && !sibling_dir.is_symlink() {
if let Ok(children) = std::fs::read_dir(&sibling_dir) {
for child in children.flatten() {
let dest = canonical_target.join(child.file_name());
if !dest.exists() {
let _ = std::fs::rename(child.path(), &dest);
}
}
}
// Replace the sibling's dir with a symlink to canonical.
let _ = std::fs::remove_dir_all(&sibling_dir);
let _ = std::os::unix::fs::symlink(&canonical_target, &sibling_dir);
eprintln!(
"sprout-desktop: shared-agent-sync: migrated {rel} from {}",
sibling.display()
);
break;
}
}
}
}
}
}
for rel in SHARED_AGENT_DIRS {
let src = canonical_dir.join(rel);
let dst = current_dir.join(rel);
if !src.exists() {
continue;
}
if let Some(parent) = dst.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!(
"sprout-desktop: shared-agent-sync: failed to create {}: {e}",
parent.display()
);
continue;
}
}
if dst.is_symlink() {
if let Ok(target) = std::fs::read_link(&dst) {
if target == src {
continue;
}
}
}
if dst.is_symlink() {
let _ = std::fs::remove_file(&dst);
} else if dst.exists() {
let _ = std::fs::remove_dir_all(&dst);
}
match std::os::unix::fs::symlink(&src, &dst) {
Ok(_) => synced += 1,
Err(e) => {
eprintln!("sprout-desktop: shared-agent-sync: failed to symlink {rel}: {e}");
}
}
}
if synced > 0 {
eprintln!(
"sprout-desktop: shared-agent-sync: {synced} file(s) linked to {}",
"sprout-desktop: shared-agent-sync: {synced} item(s) linked to {}",
canonical_dir.display()
);
}
@@ -227,6 +311,65 @@ pub fn reconcile_provider_mcp_commands(app: &tauri::AppHandle) {
reconcile_mcp_commands_in_file(&path);
}
fn reconcile_pack_paths_in_file(path: &Path, canonical_dir: &Path) {
let canonical_packs = canonical_dir.join("agents/packs");
patch_json_records(path, |obj| {
let pack_path = match obj.get("persona_pack_path").and_then(|v| v.as_str()) {
Some(p) => p,
None => return false,
};
let pack_path = Path::new(pack_path);
let mut found_packs = false;
let mut pack_id: Option<&std::ffi::OsStr> = None;
for component in pack_path.components() {
if found_packs {
pack_id = Some(component.as_os_str());
break;
}
if component.as_os_str() == "packs" {
found_packs = true;
}
}
let Some(id) = pack_id else {
return false;
};
let expected = canonical_packs.join(id);
if pack_path == expected {
return false;
}
eprintln!(
"sprout-desktop: pack-path-reconcile: {:?}: {:?}{:?}",
obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"),
pack_path,
expected,
);
obj.insert(
"persona_pack_path".to_string(),
serde_json::Value::String(expected.to_string_lossy().into_owned()),
);
true
});
}
/// Reconcile `persona_pack_path` values in managed-agents.json to point
/// to the canonical dev data directory's `agents/packs/` prefix. Fixes
/// stale paths left when agents were created from worktree instances
/// whose data directories don't have local pack copies.
pub fn reconcile_persona_pack_paths(app: &tauri::AppHandle) {
let Ok(current_dir) = app.path().app_data_dir() else {
return;
};
let canonical_dir = match canonical_dev_data_dir(&current_dir) {
Some(dir) if dir.exists() => dir,
_ => current_dir,
};
let path = canonical_dir.join("agents/managed-agents.json");
if !path.exists() {
return;
}
reconcile_pack_paths_in_file(&path, &canonical_dir);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -250,13 +393,14 @@ mod tests {
}
/// Helper: create a temp dir structure mimicking canonical + worktree layout.
/// Packs live in a `.main` sibling (not canonical) to match real-world state.
/// Returns `(parent_dir_handle, canonical_dir, worktree_dir)`.
fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) {
let parent = tempfile::tempdir().unwrap();
let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
let worktree = parent.path().join("xyz.block.sprout.app.dev.my-branch");
let main_instance = parent.path().join("xyz.block.sprout.app.dev.main");
// Populate canonical with agent data.
std::fs::create_dir_all(canonical.join("agents")).unwrap();
std::fs::write(
canonical.join("agents/managed-agents.json"),
@@ -270,6 +414,12 @@ mod tests {
.unwrap();
std::fs::write(canonical.join("agents/teams.json"), r#"[{"id":"team-1"}]"#).unwrap();
// Packs installed from `.main` — canonical has no packs dir.
let pack_dir = main_instance.join("agents/packs/com.example.test-pack");
std::fs::create_dir_all(&pack_dir).unwrap();
std::fs::write(pack_dir.join("instructions.md"), "# Test pack").unwrap();
std::fs::write(pack_dir.join("solo.persona.md"), "# Solo").unwrap();
(parent, canonical, worktree)
}
@@ -301,6 +451,62 @@ mod tests {
std::os::unix::fs::symlink(&src, &dst).unwrap();
synced += 1;
}
// Migrate packs from siblings to canonical (mirrors production logic).
for rel in SHARED_AGENT_DIRS {
let canonical_target = canonical.join(rel);
if !canonical_target.exists() {
std::fs::create_dir_all(&canonical_target).unwrap();
if let Some(parent) = canonical.parent() {
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let sibling = entry.path();
if sibling == canonical {
continue;
}
let sibling_dir = sibling.join(rel);
if sibling_dir.is_dir() && !sibling_dir.is_symlink() {
if let Ok(children) = std::fs::read_dir(&sibling_dir) {
for child in children.flatten() {
let dest = canonical_target.join(child.file_name());
if !dest.exists() {
let _ = std::fs::rename(child.path(), &dest);
}
}
}
let _ = std::fs::remove_dir_all(&sibling_dir);
let _ = std::os::unix::fs::symlink(&canonical_target, &sibling_dir);
break;
}
}
}
}
}
}
for rel in SHARED_AGENT_DIRS {
let src = canonical.join(rel);
let dst = worktree.join(rel);
if !src.exists() {
continue;
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent).unwrap();
}
if dst.is_symlink() {
if let Ok(target) = std::fs::read_link(&dst) {
if target == src {
continue;
}
}
}
if dst.is_symlink() {
let _ = std::fs::remove_file(&dst);
} else if dst.exists() {
let _ = std::fs::remove_dir_all(&dst);
}
std::os::unix::fs::symlink(&src, &dst).unwrap();
synced += 1;
}
synced
}
@@ -308,13 +514,17 @@ mod tests {
fn sync_creates_symlinks_to_fresh_worktree() {
let (_parent, canonical, worktree) = setup_sync_layout();
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 3);
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));
}
// Content is readable through symlinks.
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"}]"#,
@@ -331,7 +541,7 @@ mod tests {
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 3);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
let dst = worktree.join(rel);
assert!(
@@ -349,9 +559,7 @@ mod tests {
#[test]
fn sync_preserves_correct_symlinks() {
let (_parent, canonical, worktree) = setup_sync_layout();
// First sync creates symlinks.
assert_eq!(sync_files(&canonical, &worktree), 3);
// Second sync should be a no-op.
assert_eq!(sync_files(&canonical, &worktree), 4);
assert_eq!(sync_files(&canonical, &worktree), 0);
for rel in SHARED_AGENT_FILES {
let dst = worktree.join(rel);
@@ -369,7 +577,7 @@ mod tests {
std::os::unix::fs::symlink(&wrong_target, worktree.join(rel)).unwrap();
}
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 3);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
assert_eq!(
std::fs::read_link(worktree.join(rel)).unwrap(),
@@ -387,7 +595,7 @@ mod tests {
std::os::unix::fs::symlink(&broken_target, worktree.join(rel)).unwrap();
}
let synced = sync_files(&canonical, &worktree);
assert_eq!(synced, 3);
assert_eq!(synced, 4);
for rel in SHARED_AGENT_FILES {
let dst = worktree.join(rel);
assert!(dst.is_symlink());
@@ -620,4 +828,177 @@ mod tests {
let records = read_agents_json(dir.path());
assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp");
}
#[test]
fn sync_creates_packs_directory_symlink() {
let (_parent, canonical, worktree) = setup_sync_layout();
sync_files(&canonical, &worktree);
let packs_link = worktree.join("agents/packs");
assert!(packs_link.is_symlink());
assert_eq!(
std::fs::read_link(&packs_link).unwrap(),
canonical.join("agents/packs")
);
assert_eq!(
std::fs::read_to_string(
worktree.join("agents/packs/com.example.test-pack/instructions.md")
)
.unwrap(),
"# Test pack"
);
}
#[test]
fn sync_migrates_packs_from_sibling_to_canonical() {
let (_parent, canonical, worktree) = setup_sync_layout();
let main_instance = canonical
.parent()
.unwrap()
.join("xyz.block.sprout.app.dev.main");
// Before sync: canonical has no packs, .main has the real pack.
assert!(!canonical.join("agents/packs").exists());
assert!(main_instance
.join("agents/packs/com.example.test-pack")
.is_dir());
sync_files(&canonical, &worktree);
// After sync: canonical has the pack, .main is now a symlink.
assert!(canonical
.join("agents/packs/com.example.test-pack/instructions.md")
.exists());
assert!(main_instance.join("agents/packs").is_symlink());
assert_eq!(
std::fs::read_link(main_instance.join("agents/packs")).unwrap(),
canonical.join("agents/packs")
);
}
#[test]
fn sync_replaces_real_packs_dir_with_symlink() {
let (_parent, canonical, worktree) = setup_sync_layout();
let real_packs = worktree.join("agents/packs");
std::fs::create_dir_all(&real_packs).unwrap();
std::fs::write(real_packs.join("stale-file.txt"), "stale").unwrap();
sync_files(&canonical, &worktree);
assert!(worktree.join("agents/packs").is_symlink());
assert_eq!(
std::fs::read_link(worktree.join("agents/packs")).unwrap(),
canonical.join("agents/packs")
);
}
#[test]
fn pack_path_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.sprout.app.dev.worktree-my-branch")
.display()
);
let expected_path = format!(
"{}/agents/packs/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Paul",
"persona_pack_path": worktree_pack_path
}]),
);
reconcile_pack_paths_in_file(&canonical.join("agents/managed-agents.json"), &canonical);
let records = read_agents_json(&canonical);
assert_eq!(records[0]["persona_pack_path"], expected_path);
}
#[test]
fn pack_path_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/packs/com.wpfleger.sietch-tabr",
canonical.display()
);
write_agents_json(
&canonical,
&serde_json::json!([{
"name": "Duncan",
"persona_pack_path": canonical_path
}]),
);
let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
reconcile_pack_paths_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 pack_path_reconcile_skips_records_without_pack_path() {
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": "sprout-agent"
}]),
);
let before = std::fs::read_to_string(canonical.join("agents/managed-agents.json")).unwrap();
reconcile_pack_paths_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 pack_path_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.sprout.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_pack_paths_in_file(&path, &canonical);
let after_first = std::fs::read_to_string(&path).unwrap();
reconcile_pack_paths_in_file(&path, &canonical);
let after_second = std::fs::read_to_string(&path).unwrap();
assert_eq!(after_first, after_second);
}
}