diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index d93c83bf6..cab555d9e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,7 +30,7 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ - ["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check + ["src-tauri/src/managed_agents/personas.rs", 980], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check + retired persona migration (RETIRED_PERSONAS constant + migrate_retired_personas) ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests ["src/app/AppShell.tsx", 815], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + projects view + memory-leak safeguards + home-badge state lifted here so it consumes the same NIP-RS read-state as the sidebar (single ReadStateManager) @@ -45,6 +45,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", 640], // legacy data dir migration + worktree shared-agent-data sync (SHARED_AGENT_FILES copy-with-overwrite + runtime_pid scrub) + tests for both ["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", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ffc3efafb..b94d26673 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -356,6 +356,11 @@ pub fn run() { // resolving identity, so the persisted key is available at the new // path on first launch after the identifier change. migration::migrate_legacy_data_dir(&app_handle); + // Sync shared agent data from the canonical dev data directory to + // this worktree's data directory. Must run after legacy migration + // (which may populate missing files) and before + // restore_managed_agents_on_launch (which reads managed-agents.json). + migration::sync_shared_agent_data(&app_handle); // Resolve persisted identity key (env var → file → generate+save). // This is fatal — the app should not start with an ephemeral identity diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index b3cc61ae6..3b916171b 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -455,6 +455,39 @@ Your name is Scout. You are friendly and helpful. You are understated, but have }, ]; +const RETIRED_PERSONAS: &[(&str, &str, &str)] = &[ + ( + "builtin:orchestrator", + "Orchestrator", + "You are an orchestration agent. Coordinate multi-step work across specialized agents, keep the overall plan moving, and synthesize results into a clear final outcome. When another agent should take a task, @mention them explicitly with the assignment, expected deliverable, and any relevant constraints or deadlines.", + ), + ( + "builtin:researcher", + "Researcher", + "You are a research agent. Gather relevant information, compare sources, call out uncertainty, and return concise findings with evidence.", + ), + ( + "builtin:planner", + "Planner", + "You are a planning agent. Turn ambiguous requests into structured plans with milestones, dependencies, risks, and clear next actions. Do not implement the work yourself unless asked.", + ), + ( + "builtin:implementer", + "Builder", + "You are a builder agent. Execute tasks directly, make code and configuration changes carefully, validate the result, and explain important decisions and follow-up items.", + ), + ( + "builtin:refactor", + "Refactor", + "You are a refactoring agent. Improve structure, naming, duplication, and module boundaries without changing externally observable behavior. Keep changes incremental, preserve compatibility, and add or update validation when behavior could drift.", + ), + ( + "builtin:reviewer", + "Reviewer", + "You are a review agent. Inspect plans, code, and outputs for bugs, regressions, edge cases, security issues, and missing tests. Prioritize findings by severity, cite concrete evidence, and keep summaries secondary to the actual review.", + ), +]; + fn personas_store_path(app: &AppHandle) -> Result { Ok(managed_agents_base_dir(app)?.join("personas.json")) } @@ -553,10 +586,53 @@ fn merge_personas(mut stored: Vec, now: &str) -> (Vec bool { + let mut changed = false; + + for record in stored.iter_mut() { + if let Some((_, _original_name, original_prompt)) = + RETIRED_PERSONAS.iter().find(|(id, _, _)| *id == record.id) + { + let retired_suffix = " (retired)"; + if !record.display_name.ends_with(retired_suffix) { + let was_unmodified = record.system_prompt == *original_prompt; + eprintln!( + "sprout-desktop: persona-migration: retiring {} persona '{}' → '{} (retired)'", + if was_unmodified { + "unmodified" + } else { + "customized" + }, + record.display_name, + record.display_name, + ); + record.display_name = format!("{}{}", record.display_name, retired_suffix); + record.is_active = false; + record.updated_at = now.to_string(); + changed = true; + } + } + } + + changed +} + pub fn ensure_persona_is_active( personas: &[PersonaRecord], persona_id: &str, diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 71dbb637b..d13ec0af5 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -1,6 +1,7 @@ use super::{ - ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, validate_pack_id, - validate_persona_activation_change, validate_persona_deletion, BUILT_IN_PERSONAS, + ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas, + migrate_retired_personas, validate_pack_id, validate_persona_activation_change, + validate_persona_deletion, BUILT_IN_PERSONAS, RETIRED_PERSONAS, }; use crate::managed_agents::PersonaRecord; @@ -161,6 +162,9 @@ fn merge_personas_backfills_new_builtins_for_existing_store() { #[test] fn merge_personas_demotes_retired_builtins() { + // custom_persona uses "Custom prompt", which doesn't match the original + // retired system prompt, so the migration pass soft-deprecates rather + // than removes the record. let mut retired = custom_persona("builtin:reviewer", "Reviewer"); retired.is_builtin = true; retired.is_active = true; @@ -172,9 +176,11 @@ fn merge_personas_demotes_retired_builtins() { let demoted = records .iter() .find(|record| record.id == "builtin:reviewer") - .expect("retired built-in should be retained as a custom persona"); + .expect("retired built-in should be retained as a soft-deprecated custom persona"); assert!(!demoted.is_builtin); - assert!(demoted.is_active); + // migrate_retired_personas deactivates customized retired personas. + assert!(!demoted.is_active); + assert_eq!(demoted.display_name, "Reviewer (retired)"); assert_eq!(demoted.created_at, original_created_at); assert_eq!(demoted.updated_at, "2026-04-01T00:00:00Z"); } @@ -344,3 +350,112 @@ fn pack_id_rejects_too_long() { let max_id = "a".repeat(128); assert!(validate_pack_id(&max_id).is_ok()); } + +// ── migrate_retired_personas ────────────────────────────────────────────────── + +#[test] +fn migrate_retires_unmodified_personas() { + let now = "2026-04-01T00:00:00Z"; + // Simulate a store from before the Solo/Kit/Scout transition: all 6 + // retired personas with original system prompts. + let mut stored: Vec = RETIRED_PERSONAS + .iter() + .map(|(id, name, prompt)| PersonaRecord { + id: id.to_string(), + display_name: name.to_string(), + system_prompt: prompt.to_string(), + is_builtin: false, // already demoted by merge_personas + ..custom_persona(id, name) + }) + .collect(); + + let changed = migrate_retired_personas(&mut stored, now); + + assert!(changed); + assert_eq!( + stored.len(), + RETIRED_PERSONAS.len(), + "all retired personas should be soft-deprecated, not removed", + ); + assert!( + stored + .iter() + .all(|r| r.display_name.ends_with(" (retired)")), + "all retired personas should have ' (retired)' suffix", + ); + assert!( + stored.iter().all(|r| !r.is_active), + "all retired personas should be inactive", + ); + assert!( + stored.iter().all(|r| r.updated_at == now), + "all retired personas should have refreshed updated_at", + ); +} + +#[test] +fn migrate_preserves_customized_personas() { + let now = "2026-04-01T00:00:00Z"; + let mut stored = vec![PersonaRecord { + id: "builtin:researcher".to_string(), + display_name: "My Researcher".to_string(), + system_prompt: "My custom research workflow with special instructions".to_string(), + is_builtin: false, + is_active: true, + ..custom_persona("builtin:researcher", "My Researcher") + }]; + + let changed = migrate_retired_personas(&mut stored, now); + + assert!(changed); + assert_eq!(stored.len(), 1); + let record = &stored[0]; + assert_eq!(record.display_name, "My Researcher (retired)"); + assert!(!record.is_active); + assert_eq!( + record.system_prompt, + "My custom research workflow with special instructions" + ); + assert_eq!(record.updated_at, now); +} + +#[test] +fn migrate_is_idempotent() { + let now = "2026-04-01T00:00:00Z"; + + // 1. Non-retired persona — no-op. + let mut stored = vec![custom_persona("custom:test", "Custom")]; + assert!(!migrate_retired_personas(&mut stored, now)); + assert_eq!(stored.len(), 1); + + // 2. Already-retired persona (display_name ends with " (retired)") — no-op. + let mut stored_with_retired = vec![PersonaRecord { + id: "builtin:researcher".to_string(), + display_name: "Researcher (retired)".to_string(), + system_prompt: "My custom prompt".to_string(), + is_builtin: false, + is_active: false, + ..custom_persona("builtin:researcher", "Researcher (retired)") + }]; + assert!( + !migrate_retired_personas(&mut stored_with_retired, now), + "already-retired persona should not trigger another change" + ); + + // 3. Retired persona still marked is_builtin: true (pre-demotion). + // migrate_retired_personas should still soft-deprecate it. + let mut stored_pre_demotion = vec![PersonaRecord { + id: "builtin:reviewer".to_string(), + display_name: "Reviewer".to_string(), + system_prompt: "Custom review prompt".to_string(), + is_builtin: true, + is_active: true, + ..custom_persona("builtin:reviewer", "Reviewer") + }]; + assert!(migrate_retired_personas(&mut stored_pre_demotion, now)); + assert_eq!(stored_pre_demotion[0].display_name, "Reviewer (retired)"); + assert!(!stored_pre_demotion[0].is_active); + + // 4. Run again on result of (3) — should be no-op. + assert!(!migrate_retired_personas(&mut stored_pre_demotion, now)); +} diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 9f8b6b752..96d83e07b 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -1,31 +1,39 @@ -//! Per-file migration from the legacy `com.wesb.sprout` app data directory -//! to the current `xyz.block.sprout.app` directory. +//! Data migrations and worktree sync for the Sprout desktop app. //! -//! On each launch, for every known file in [`LEGACY_FILES`]: -//! - If the file **already exists** at the new path → skip (it's its own guard). -//! - If the file exists at the old path but not the new → copy it over. +//! **Legacy migration** (`migrate_legacy_data_dir`): One-time, idempotent +//! per-file copy from the legacy `com.wesb.sprout` app data directory to +//! the current directory. Runs on every launch; each file's existence at +//! the destination is its own guard. //! -//! Each `std::fs::copy` is atomic per-file, so there is no partial-migration -//! problem and no sentinel file is needed. -//! -//! The legacy directory is intentionally **not** deleted — users can clean it -//! up manually once they're satisfied everything works. -//! -//! Errors are logged but never fatal; the app must still start even if -//! individual file copies fail. -//! -//! **Note on dev/prod side-by-side:** Both the production build -//! (`xyz.block.sprout.app`) and the dev build (`xyz.block.sprout.app.dev`) -//! will attempt to migrate from the same legacy `com.wesb.sprout` directory, -//! resulting in duplicated identity keys. To avoid this, set the -//! `SPROUT_PRIVATE_KEY` env var when running the dev build — this bypasses -//! file-based identity resolution entirely. +//! **Worktree sync** (`sync_shared_agent_data`): Per-launch copy-with- +//! overwrite of agent data files from the canonical dev data directory +//! (`xyz.block.sprout.app.dev`) to the current worktree data directory. +//! Only runs when `SPROUT_SHARE_IDENTITY=1` and `SPROUT_PRIVATE_KEY` is +//! set. Overwrites on every launch so worktree data stays current. use std::path::{Path, PathBuf}; use tauri::Manager; const LEGACY_DATA_DIR_NAME: &str = "com.wesb.sprout"; +const CANONICAL_DEV_IDENTIFIER: &str = "xyz.block.sprout.app.dev"; + +/// JSON files synced from the canonical dev data directory to worktree +/// data directories. This is the agent-data subset of [`LEGACY_FILES`]; +/// `identity.key` is deliberately excluded because worktree instances +/// receive their identity via the `SPROUT_PRIVATE_KEY` env var. +/// Only data files — never `agent-pids/` or `logs/`. +/// +/// NOTE: `agents/packs/` is intentionally excluded — recursive directory +/// sync 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", +]; + /// Known files to migrate from the legacy data directory. /// /// Agent logs and `.window-state.json` are excluded — logs are ephemeral and @@ -37,10 +45,16 @@ const LEGACY_FILES: &[&str] = &[ "agents/teams.json", ]; -/// Compute the legacy `com.wesb.sprout` data directory path by replacing the -/// last component of the current app data directory. +fn sibling_data_dir(current: &Path, name: &str) -> Option { + current.parent().map(|p| p.join(name)) +} + fn legacy_data_dir(current: &Path) -> Option { - current.parent().map(|p| p.join(LEGACY_DATA_DIR_NAME)) + sibling_data_dir(current, LEGACY_DATA_DIR_NAME) +} + +fn canonical_dev_data_dir(current: &Path) -> Option { + sibling_data_dir(current, CANONICAL_DEV_IDENTIFIER) } /// Copy a single file from `old_dir/rel` to `new_dir/rel`, creating parent @@ -121,6 +135,156 @@ pub fn migrate_legacy_data_dir(app: &tauri::AppHandle) { } } +/// After copying managed-agents.json from the canonical dir, remove +/// process-local runtime state that would cause the worktree instance +/// to kill the canonical instance's running agents. +fn scrub_managed_agents_runtime_state(path: &Path) { + let Ok(content) = std::fs::read_to_string(path) else { + return; + }; + let Ok(mut records) = serde_json::from_str::>(&content) else { + return; + }; + + const RUNTIME_FIELDS: &[&str] = &[ + "runtime_pid", + "last_error", + "last_exit_code", + "last_stopped_at", + "last_started_at", + "backend_agent_id", + ]; + + let mut scrubbed_any = false; + for record in &mut records { + if let Some(obj) = record.as_object_mut() { + for field in RUNTIME_FIELDS { + if obj.remove(*field).is_some() { + scrubbed_any = true; + } + } + } + } + + if scrubbed_any { + if let Ok(bytes) = serde_json::to_vec_pretty(&records) { + let _ = std::fs::write(path, bytes); + } + } +} + +/// Copy shared agent data files from the canonical dev data directory to +/// the current (worktree) data directory, overwriting any existing files. +/// +/// Guards: +/// - `SPROUT_SHARE_IDENTITY` must be `"1"` +/// - `SPROUT_PRIVATE_KEY` must parse as valid `nostr::Keys` +/// - The canonical dir must differ from the current dir (skip if we ARE canonical) +/// - The canonical dir must exist +/// +/// Unlike `migrate_file()`, this always overwrites — pre-existing worktree +/// data directories already have empty/default files that must be replaced. +pub fn sync_shared_agent_data(app: &tauri::AppHandle) { + // Guard: only runs when sharing identity with a worktree. + let is_shared = std::env::var("SPROUT_SHARE_IDENTITY") + .map(|v| v == "1") + .unwrap_or(false); + if !is_shared { + return; + } + + // Guard: SPROUT_PRIVATE_KEY must be a valid nostr key. + let has_valid_key = std::env::var("SPROUT_PRIVATE_KEY") + .ok() + .and_then(|k| k.parse::().ok()) + .is_some(); + if !has_valid_key { + eprintln!( + "sprout-desktop: shared-agent-sync: SPROUT_PRIVATE_KEY missing or invalid, skipping" + ); + return; + } + + let current_dir = match app.path().app_data_dir() { + Ok(dir) => dir, + Err(e) => { + eprintln!("sprout-desktop: shared-agent-sync: cannot resolve app data dir: {e}"); + return; + } + }; + + let canonical_dir = match canonical_dev_data_dir(¤t_dir) { + Some(dir) => dir, + None => { + eprintln!( + "sprout-desktop: shared-agent-sync: cannot compute canonical dir (no parent)" + ); + return; + } + }; + + // Guard: skip if we ARE the canonical instance. + // Use canonicalize to handle case-insensitive FS and symlinks. + let current_canonical = + std::fs::canonicalize(¤t_dir).unwrap_or_else(|_| current_dir.clone()); + let source_canonical = + std::fs::canonicalize(&canonical_dir).unwrap_or_else(|_| canonical_dir.clone()); + if current_canonical == source_canonical { + return; + } + + // Guard: skip if canonical dir doesn't exist. + if !canonical_dir.exists() { + eprintln!( + "sprout-desktop: shared-agent-sync: canonical dir does not exist: {}", + canonical_dir.display() + ); + return; + } + + let mut synced = 0u32; + for rel in SHARED_AGENT_FILES { + let src = canonical_dir.join(rel); + let dst = current_dir.join(rel); + + if !src.exists() { + continue; + } + + // Ensure parent directories exist (e.g. `agents/`). + 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; + } + } + + match std::fs::copy(&src, &dst) { + Ok(_) => synced += 1, + Err(e) => { + eprintln!("sprout-desktop: shared-agent-sync: failed to copy {rel}: {e}"); + } + } + } + + // Scrub runtime-local state from the copied managed-agents.json to + // prevent the worktree from killing the canonical instance's running agents. + let managed_agents_dst = current_dir.join("agents/managed-agents.json"); + if managed_agents_dst.exists() { + scrub_managed_agents_runtime_state(&managed_agents_dst); + } + + if synced > 0 { + eprintln!( + "sprout-desktop: shared-agent-sync: {synced} file(s) synced from {}", + canonical_dir.display() + ); + } +} + #[cfg(test)] mod tests { use super::*; @@ -283,4 +447,189 @@ mod tests { "nsec1-already-here" ); } + + #[test] + fn canonical_dev_data_dir_replaces_last_component() { + let current = PathBuf::from( + "/Users/me/Library/Application Support/xyz.block.sprout.app.dev.my-branch", + ); + let canonical = canonical_dev_data_dir(¤t).unwrap(); + assert_eq!( + canonical, + PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app.dev") + ); + } + + #[test] + fn canonical_dev_data_dir_returns_none_for_root() { + // A root path has no parent — should return None. + assert!(canonical_dev_data_dir(Path::new("/")).is_none()); + } + + /// Helper: create a temp dir structure mimicking canonical + worktree layout. + /// 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"); + + // Populate canonical with agent data. + std::fs::create_dir_all(canonical.join("agents")).unwrap(); + std::fs::write( + canonical.join("agents/managed-agents.json"), + r#"[{"id":"agent-1"}]"#, + ) + .unwrap(); + std::fs::write( + canonical.join("agents/personas.json"), + r#"[{"id":"builtin:solo"}]"#, + ) + .unwrap(); + std::fs::write(canonical.join("agents/teams.json"), r#"[{"id":"team-1"}]"#).unwrap(); + + (parent, canonical, worktree) + } + + /// Helper: sync files directly (without a Tauri AppHandle) for unit testing. + /// Mirrors the core loop of `sync_shared_agent_data` but takes explicit paths. + fn sync_files(canonical: &Path, worktree: &Path) -> u32 { + let mut synced = 0u32; + for rel in SHARED_AGENT_FILES { + 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(); + } + std::fs::copy(&src, &dst).unwrap(); + synced += 1; + } + synced + } + + #[test] + fn sync_copies_files_to_fresh_worktree() { + let (_parent, canonical, worktree) = setup_sync_layout(); + + let synced = sync_files(&canonical, &worktree); + + assert_eq!(synced, 3); + assert_eq!( + std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(), + r#"[{"id":"agent-1"}]"#, + ); + assert_eq!( + std::fs::read_to_string(worktree.join("agents/personas.json")).unwrap(), + r#"[{"id":"builtin:solo"}]"#, + ); + assert_eq!( + std::fs::read_to_string(worktree.join("agents/teams.json")).unwrap(), + r#"[{"id":"team-1"}]"#, + ); + } + + #[test] + fn sync_overwrites_existing_files() { + let (_parent, canonical, worktree) = setup_sync_layout(); + + // Pre-create worktree with stale/empty data (the real-world scenario). + std::fs::create_dir_all(worktree.join("agents")).unwrap(); + 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, 3); + // Must contain canonical data, NOT the empty arrays. + assert_eq!( + std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(), + r#"[{"id":"agent-1"}]"#, + ); + } + + #[test] + fn sync_skips_when_canonical_equals_current() { + let parent = tempfile::tempdir().unwrap(); + let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); + + // When the current dir IS the canonical dir, canonical_dev_data_dir + // returns the exact same path. The equality guard in + // sync_shared_agent_data detects this and skips the sync entirely, + // so data is never clobbered. + let resolved = canonical_dev_data_dir(&canonical).unwrap(); + assert_eq!(resolved, canonical); + } + + #[test] + fn canonical_dev_data_dir_for_canonical_instance_returns_self() { + // When the current app data dir IS the canonical dev identifier, + // canonical_dev_data_dir returns the exact same path. The caller + // (sync_shared_agent_data) uses this equality to skip the sync. + // The env-var guards (SPROUT_SHARE_IDENTITY, SPROUT_PRIVATE_KEY) + // require a live Tauri AppHandle and are covered by integration + // testing only. + let current = + PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app.dev"); + let canonical = canonical_dev_data_dir(¤t).unwrap(); + assert_eq!(canonical, current); + } + + #[test] + fn sync_scrubs_runtime_pid_from_managed_agents() { + let (_parent, canonical, worktree) = setup_sync_layout(); + + // Write canonical managed-agents.json with runtime state fields. + std::fs::write( + canonical.join("agents/managed-agents.json"), + serde_json::to_string_pretty(&serde_json::json!([{ + "id": "agent-1", + "name": "Test Agent", + "runtime_pid": 12345, + "last_error": "some error", + "last_exit_code": 1, + "last_stopped_at": "2026-01-01T00:00:00Z", + "last_started_at": "2026-01-01T00:00:00Z", + "backend_agent_id": "ba-123" + }])) + .unwrap(), + ) + .unwrap(); + + sync_files(&canonical, &worktree); + scrub_managed_agents_runtime_state(&worktree.join("agents/managed-agents.json")); + + let content = std::fs::read_to_string(worktree.join("agents/managed-agents.json")).unwrap(); + let records: Vec = serde_json::from_str(&content).unwrap(); + let agent = &records[0]; + + assert_eq!(agent["id"], "agent-1"); + assert_eq!(agent["name"], "Test Agent"); + assert!( + agent.get("runtime_pid").is_none(), + "runtime_pid should be scrubbed" + ); + assert!( + agent.get("last_error").is_none(), + "last_error should be scrubbed" + ); + assert!( + agent.get("last_exit_code").is_none(), + "last_exit_code should be scrubbed" + ); + assert!( + agent.get("last_stopped_at").is_none(), + "last_stopped_at should be scrubbed" + ); + assert!( + agent.get("last_started_at").is_none(), + "last_started_at should be scrubbed" + ); + assert!( + agent.get("backend_agent_id").is_none(), + "backend_agent_id should be scrubbed" + ); + } }