diff --git a/.gitignore b/.gitignore index b1d476204..9109b19b6 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ identity.key # Claude Code worktrees .claude/worktrees/ + +# mesh-llm build cache +.cache/ diff --git a/AGENTS.md b/AGENTS.md index 613f22a29..79883a8df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -319,8 +319,19 @@ Right-click shows "Mark as read". {{02-context-menu}} ``` -Re-runs for the same PR overwrite previous images. Cleanup: -`git push origin --delete agent-screenshots/`. +Re-runs overwrite the image blobs on the `agent-screenshots/` +branch, but the script **appends a new PR comment** — it does not edit or +delete the previous one. After reposting, delete the superseded comment so +only the current set remains, otherwise reviewers still see the stale images: + +```bash +# List screenshot comments to find the stale one's id +gh pr view --repo block/sprout --json comments \ + --jq '.comments[] | select(.body | test("pr---")) | {id, url}' +gh api -X DELETE repos/block/sprout/issues/comments/ +``` + +Branch cleanup when fully done: `git push origin --delete agent-screenshots/`. ### Writing E2E Screenshot Specs @@ -357,6 +368,20 @@ await menuItem.evaluate((el) => **Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable for sidebar features. Sidebar = 256px; context menus ~450px. +**Distinct states — verify before posting:** when one view renders many +elements at once (e.g. all team cards in a single grid), an unscoped +full-page `page.screenshot()` captures the *same* pixels for every shot, so +multiple PNGs come out byte-identical. Scope each shot to its subject with +`locator.screenshot()` (full-page `clip` only when an overlay like an open +dropdown must be included). Then gate on hash distinctness before posting: + +```bash +shasum -a 256 test-results//*.png # every hash must be unique +``` + +Identical hashes mean two shots captured the same state — fix the spec, do +not post. This catches the most common screenshot regression. + **`general` has pre-seeded messages** making `hasUnread` always true. Use `engineering` for "muted + no unread" visual states. diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e4ce54441..526589e57 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ "**/channel-mute-screenshots.spec.ts", "**/channel-star-screenshots.spec.ts", "**/channel-controls-screenshots.spec.ts", + "**/team-management-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/mentions.spec.ts", "**/relay-reconnect.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 1645b3d64..d8f228d8e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -40,6 +40,7 @@ const overrides = new Map([ ["src-tauri/src/nostr_convert.rs", 1116], ["src/shared/api/relayClientSession.ts", 1022], ["src-tauri/src/migration.rs", 1295], + ["src-tauri/src/managed_agents/teams.rs", 1020], ]); await runFileSizeCheck({ diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 57039b407..9bbc7f6c7 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -462,13 +462,13 @@ pub async fn create_managed_agent( requested_persona_id.as_deref().and_then(|pid| { let personas = load_personas(&app).ok()?; let persona = personas.iter().find(|p| p.id == pid)?; - let pack_id = persona.source_pack.as_deref()?; - let slug = persona.source_pack_persona_slug.as_deref()?; + let team_id = persona.source_team.as_deref()?; + let slug = persona.source_team_persona_slug.as_deref()?; let base = managed_agents_base_dir(&app).ok()?; - let pack_path = base.join("packs").join(pack_id); + let team_path = base.join("teams").join(team_id); // Use the validated slug stored during import — no need to // re-resolve the pack. The slug is [a-zA-Z0-9_-]+ by construction. - Some((pack_path, slug.to_owned())) + Some((team_path, slug.to_owned())) }); // Resolve the avatar URL once at creation and persist it on the record. @@ -540,11 +540,11 @@ pub async fn create_managed_agent( backend: input.backend.clone(), backend_agent_id: None, provider_binary_path, - // Pack-backed personas: record path + internal slug so the runtime - // can resolve pack config at startup. Must be the slug (e.g., "lep"), + // Team-backed personas: record path + internal slug so the runtime + // can resolve team config at startup. Must be the slug (e.g., "lep"), // NOT the display_name — ACP's resolve_persona_by_name() matches slugs. - persona_pack_path: pack_metadata.as_ref().map(|(path, _)| path.clone()), - persona_name_in_pack: pack_metadata.as_ref().map(|(_, name)| name.clone()), + persona_team_dir: pack_metadata.as_ref().map(|(path, _)| path.clone()), + persona_name_in_team: pack_metadata.as_ref().map(|(_, name)| name.clone()), env_vars: input.env_vars.clone(), created_at: now_iso(), updated_at: now_iso(), diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index a221e2d7a..56f6742f4 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -5,12 +5,11 @@ use super::export_util::save_json_with_dialog; use crate::{ app_state::AppState, managed_agents::{ - encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents, - load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona, - parse_zip_personas, save_managed_agents, save_personas, try_regenerate_nest, - uninstall_persona_pack as do_uninstall_persona_pack, validate_persona_activation_change, - validate_persona_deletion, CreatePersonaRequest, PackSummary, ParsePersonaFilesResult, - PersonaRecord, UpdatePersonaRequest, + encode_persona_json, load_managed_agents, load_personas, load_teams, parse_json_persona, + parse_md_persona, parse_png_persona, parse_zip_personas, save_managed_agents, + save_personas, try_regenerate_nest, validate_persona_activation_change, + validate_persona_deletion, CreatePersonaRequest, ParsePersonaFilesResult, PersonaRecord, + UpdatePersonaRequest, }, util::now_iso, }; @@ -79,8 +78,8 @@ pub fn create_persona( name_pool, is_builtin: false, is_active: true, - source_pack: None, - source_pack_persona_slug: None, + source_team: None, + source_team_persona_slug: None, env_vars: input.env_vars, created_at: now.clone(), updated_at: now, @@ -374,44 +373,3 @@ pub async fn export_persona_to_json( let filename = format!("{slug}.persona.json"); save_json_with_dialog(&app, &filename, &json_bytes).await } - -// ── Pack management commands ────────────────────────────────────────────────── - -#[tauri::command] -pub fn install_persona_pack( - app: AppHandle, - state: State<'_, AppState>, - path: String, -) -> Result, String> { - let _lock = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let source = std::path::PathBuf::from(&path); - if !source.is_dir() { - return Err(format!("pack path is not a directory: {path}")); - } - let result = import_persona_pack(&app, &source)?; - try_regenerate_nest(&app); - Ok(result) -} - -#[tauri::command] -pub fn uninstall_persona_pack( - app: AppHandle, - state: State<'_, AppState>, - pack_id: String, -) -> Result<(), String> { - let _lock = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - do_uninstall_persona_pack(&app, &pack_id)?; - try_regenerate_nest(&app); - Ok(()) -} - -#[tauri::command] -pub fn list_persona_packs(app: AppHandle) -> Result, String> { - list_installed_packs(&app) -} diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index ff36f49a4..ad8930f9f 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -5,9 +5,10 @@ use super::export_util::save_json_with_dialog; use crate::{ app_state::AppState, managed_agents::{ - encode_team_json, ensure_persona_ids_are_active, load_personas, load_teams, - parse_team_json, save_teams, validate_team_deletion, CreateTeamRequest, ParsedTeamPreview, - TeamRecord, UpdateTeamRequest, + delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active, + import_team_from_directory as do_import_team, load_personas, load_teams, parse_team_json, + save_teams, sync_team_from_dir as do_sync_team, try_regenerate_nest, CreateTeamRequest, + ParsedTeamPreview, SyncResult, TeamRecord, UpdateTeamRequest, }, util::now_iso, }; @@ -59,6 +60,10 @@ pub fn create_team( description, persona_ids: input.persona_ids, is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, created_at: now.clone(), updated_at: now, }; @@ -104,14 +109,51 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - let mut teams = load_teams(&app)?; - let team = teams - .iter() - .find(|record| record.id == id) - .ok_or_else(|| format!("team {id} not found"))?; - validate_team_deletion(team)?; - teams.retain(|record| record.id != id); - save_teams(&app, &teams) + delete_team_with_cascade(&app, &id)?; + try_regenerate_nest(&app); + Ok(()) +} + +#[tauri::command] +pub fn install_team_from_directory( + app: AppHandle, + state: State<'_, AppState>, + path: String, + symlink: Option, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let source = std::path::PathBuf::from(&path); + if !source.is_dir() { + return Err(format!("team path is not a directory: {path}")); + } + let result = do_import_team(&app, &source, symlink.unwrap_or(false))?; + try_regenerate_nest(&app); + Ok(result) +} + +#[tauri::command] +pub fn sync_team_directory( + app: AppHandle, + state: State<'_, AppState>, + team_id: String, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let result = do_sync_team(&app, &team_id)?; + try_regenerate_nest(&app); + Ok(result) +} + +#[tauri::command] +pub async fn pick_team_directory(app: AppHandle) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + let path = app.dialog().file().blocking_pick_folder(); + Ok(path.map(|p| p.to_string())) } // --------------------------------------------------------------------------- diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 953a2ea18..c8b9f0964 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -520,12 +520,13 @@ pub fn run() { // this worktree's data directory. Must run before // restore_managed_agents_on_launch (which reads managed-agents.json). migration::sync_shared_agent_data(&app_handle); - migration::reconcile_persona_pack_paths(&app_handle); + migration::migrate_packs_to_teams(&app_handle); + migration::reconcile_persona_team_dirs(&app_handle); migration::reconcile_provider_mcp_commands(&app_handle); migration::migrate_persona_provider_to_runtime(&app_handle); - if let Err(e) = managed_agents::sync_pack_personas(&app_handle) { - eprintln!("sprout-desktop: sync-pack-personas: {e}"); + if let Err(e) = managed_agents::sync_team_personas(&app_handle) { + eprintln!("sprout-desktop: sync-team-personas: {e}"); } // Resolve persisted identity key (env var → file → generate+save). @@ -734,13 +735,13 @@ pub fn run() { create_team, update_team, delete_team, + install_team_from_directory, + sync_team_directory, + pick_team_directory, export_team_to_json, parse_team_file, parse_persona_files, export_persona_to_json, - install_persona_pack, - uninstall_persona_pack, - list_persona_packs, get_channel_workflows, get_workflow, create_workflow, diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index aea77c0cc..2f300b62a 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -947,8 +947,8 @@ mod tests { name_pool: vec![], is_builtin: false, is_active: true, - source_pack: None, - source_pack_persona_slug: None, + source_team: None, + source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), created_at: String::new(), updated_at: String::new(), @@ -980,8 +980,8 @@ mod tests { backend: BackendKind::default(), backend_agent_id: None, provider_binary_path: None, - persona_pack_path: None, - persona_name_in_pack: None, + persona_team_dir: None, + persona_name_in_team: None, created_at: String::new(), updated_at: String::new(), last_started_at: None, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index de5f69773..35eb575f5 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -1,7 +1,6 @@ use std::{fs, path::PathBuf}; use tauri::AppHandle; -use uuid::Uuid; use crate::{ managed_agents::{managed_agents_base_dir, PersonaRecord}, @@ -506,8 +505,8 @@ fn built_in_persona_records(now: &str) -> Vec { name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(), is_builtin: true, is_active: true, - source_pack: None, - source_pack_persona_slug: None, + source_team: None, + source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), created_at: now.to_string(), updated_at: now.to_string(), @@ -677,9 +676,9 @@ pub fn validate_persona_deletion( return Err("Built-in personas cannot be deleted.".to_string()); } - if persona.source_pack.is_some() { + if persona.source_team.is_some() { return Err(format!( - "{} belongs to a pack. Use \"Uninstall Pack\" to remove all pack personas together.", + "{} belongs to a team. Delete the team to remove all team personas together.", persona.display_name )); } @@ -723,324 +722,6 @@ pub fn validate_persona_activation_change( Ok(()) } -// ── Pack import ─────────────────────────────────────────────────────────────── - -/// Packs directory: `/agents/packs/` -fn packs_dir(app: &AppHandle) -> Result { - let dir = managed_agents_base_dir(app)?.join("packs"); - fs::create_dir_all(&dir).map_err(|e| format!("failed to create packs dir: {e}"))?; - Ok(dir) -} - -/// Validate pack ID: only `[a-zA-Z0-9._-]+` allowed (Lep F2: zip-slip defense). -fn validate_pack_id(id: &str) -> Result<(), String> { - if id.is_empty() { - return Err("pack ID is empty".into()); - } - if id.len() > 128 { - return Err(format!("pack ID too long: {} chars (max 128)", id.len())); - } - // Character allowlist: [a-zA-Z0-9._-] - if !id - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') - { - return Err(format!( - "pack ID contains invalid characters: \"{id}\". Only [a-zA-Z0-9._-] allowed." - )); - } - // Path traversal defense: block ".", "..", leading dots, and IDs with - // no alphanumeric characters (e.g., "---", "..."). - if id.starts_with('.') { - return Err(format!("pack ID \"{id}\" must not start with '.'")); - } - if !id.chars().any(|c| c.is_ascii_alphanumeric()) { - return Err(format!( - "pack ID \"{id}\" must contain at least one alphanumeric character" - )); - } - Ok(()) -} - -/// Copy a directory tree, skipping symlinks (Lep F2: zip-slip defense). -fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { - fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?; - for entry in fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))? { - let entry = entry.map_err(|e| format!("dir entry error: {e}"))?; - let ft = entry - .file_type() - .map_err(|e| format!("file type error: {e}"))?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - - if ft.is_symlink() { - // Skip symlinks — defense against symlink escape attacks. - continue; - } else if ft.is_dir() { - copy_dir_no_symlinks(&src_path, &dst_path)?; - } else if ft.is_file() { - fs::copy(&src_path, &dst_path) - .map_err(|e| format!("failed to copy {}: {e}", src_path.display()))?; - } - } - Ok(()) -} - -/// Import a persona pack from a directory into the app's pack storage. -/// -/// 1. Validate + resolve the pack at the source path -/// 2. Sanitize pack ID (Lep F2) -/// 3. Check for existing pack with same ID -/// 4. Copy pack to `/agents/packs//` (no symlinks) -/// 5. Re-validate the copy (Lep F2: defense-in-depth) -/// 6. Create PersonaRecords for each persona -/// 7. Merge into personas.json -pub fn import_persona_pack( - app: &AppHandle, - source_dir: &std::path::Path, -) -> Result, String> { - // 1. Validate + resolve at source - let resolved = sprout_persona::resolve::resolve_pack(source_dir) - .map_err(|e| format!("pack validation failed: {e}"))?; - - // 2. Sanitize pack ID - validate_pack_id(&resolved.id)?; - - // 3. Check for existing pack - let packs = packs_dir(app)?; - let dest = packs.join(&resolved.id); - if dest.exists() { - return Err(format!( - "Pack \"{}\" is already installed. Uninstall it first.", - resolved.id - )); - } - - // 4. Copy pack (no symlinks) - copy_dir_no_symlinks(source_dir, &dest)?; - - // 5. Re-validate the copy (defense-in-depth) - sprout_persona::resolve::resolve_pack(&dest).map_err(|e| { - // Clean up failed copy - let _ = fs::remove_dir_all(&dest); - format!("pack re-validation failed after copy: {e}") - })?; - - // 6. Create PersonaRecords - let now = now_iso(); - let new_personas: Vec = resolved - .personas - .iter() - .map(|p| PersonaRecord { - id: Uuid::new_v4().to_string(), - display_name: p.display_name.clone(), - avatar_url: p.avatar.clone(), - system_prompt: p.system_prompt.clone(), - runtime: p.runtime.clone(), - model: p.model.clone(), - provider: p.llm_provider.clone(), - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_pack: Some(resolved.id.clone()), - source_pack_persona_slug: Some(p.name.clone()), - // Filter derived provider/model env keys at import time so stale - // values don't shadow the structured fields after UI edits. - env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars( - p.runtime_env_vars.iter().cloned(), - ), - created_at: now.clone(), - updated_at: now.clone(), - }) - .collect(); - - // 7. Merge into personas.json - let mut personas = load_personas(app)?; - personas.extend(new_personas.clone()); - save_personas(app, &personas)?; - - Ok(new_personas) -} - -/// Uninstall a persona pack: remove pack directory + pack PersonaRecords. -pub fn uninstall_persona_pack(app: &AppHandle, pack_id: &str) -> Result<(), String> { - validate_pack_id(pack_id)?; - - // Block uninstall if any managed agent still references this pack. - let agents = crate::managed_agents::load_managed_agents(app)?; - let referencing: Vec<&str> = agents - .iter() - .filter(|a| { - a.persona_pack_path - .as_ref() - .and_then(|p| p.file_name()) - .and_then(|n| n.to_str()) - == Some(pack_id) - }) - .map(|a| a.name.as_str()) - .collect(); - if !referencing.is_empty() { - return Err(format!( - "Cannot uninstall pack \"{pack_id}\": {} agent(s) still reference it ({}). \ - Delete or reconfigure them first.", - referencing.len(), - referencing.join(", ") - )); - } - - // Remove pack directory (or symlink) - let packs = packs_dir(app)?; - let pack_dir = packs.join(pack_id); - if pack_dir.exists() { - let is_symlink = fs::symlink_metadata(&pack_dir) - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); - if is_symlink { - fs::remove_file(&pack_dir) - .map_err(|e| format!("failed to remove pack symlink: {e}"))?; - } else { - fs::remove_dir_all(&pack_dir) - .map_err(|e| format!("failed to remove pack directory: {e}"))?; - } - } - - // Remove pack PersonaRecords - let mut personas = load_personas(app)?; - personas.retain(|p| p.source_pack.as_deref() != Some(pack_id)); - save_personas(app, &personas)?; - - Ok(()) -} - -/// List installed packs (by scanning packs directory). -pub fn list_installed_packs(app: &AppHandle) -> Result, String> { - let packs = packs_dir(app)?; - let mut summaries = Vec::new(); - - if !packs.exists() { - return Ok(summaries); - } - - for entry in fs::read_dir(&packs).map_err(|e| format!("failed to read packs dir: {e}"))? { - let entry = entry.map_err(|e| format!("dir entry error: {e}"))?; - let pack_dir = entry.path(); - // Use stat (follows symlinks) rather than lstat so symlinked packs are recognised. - if !pack_dir.is_dir() { - continue; - } - if let Ok(resolved) = sprout_persona::resolve::resolve_pack(&pack_dir) { - summaries.push(PackSummary { - id: resolved.id, - name: resolved.name, - version: resolved.version, - persona_count: resolved.personas.len(), - path: pack_dir, - }); - } - } - - Ok(summaries) -} - -/// Summary of an installed pack (for listing). -#[derive(Debug, Clone, serde::Serialize)] -pub struct PackSummary { - pub id: String, - pub name: String, - pub version: String, - pub persona_count: usize, - pub path: PathBuf, -} - -/// Re-read pack directories and update persona records whose source content -/// has changed. Runs on launch so pack edits on disk propagate without -/// manual intervention. -pub fn sync_pack_personas(app: &AppHandle) -> Result<(), String> { - let mut records = load_personas(app)?; - let packs = packs_dir(app)?; - - if !packs.exists() { - return Ok(()); - } - - let mut changed = false; - - for record in records.iter_mut() { - let pack_id = match &record.source_pack { - Some(id) => id.clone(), - None => continue, - }; - let slug = match &record.source_pack_persona_slug { - Some(s) => s.clone(), - None => continue, - }; - - // Find the pack directory whose resolved ID matches - let pack_dir_entries = - fs::read_dir(&packs).map_err(|e| format!("failed to read packs dir: {e}"))?; - - let mut found = false; - for entry in pack_dir_entries { - let entry = entry.map_err(|e| format!("dir entry error: {e}"))?; - let dir = entry.path(); - if !dir.is_dir() { - continue; - } - let resolved = match sprout_persona::resolve::resolve_pack(&dir) { - Ok(r) => r, - Err(_) => continue, - }; - if resolved.id != pack_id { - continue; - } - - // Found the matching pack — find the persona by slug - if let Some(persona) = resolved.personas.iter().find(|p| p.name == slug) { - let mut record_changed = false; - - if record.system_prompt != persona.system_prompt { - record.system_prompt = persona.system_prompt.clone(); - record_changed = true; - } - if record.model != persona.model { - record.model = persona.model.clone(); - record_changed = true; - } - if record.avatar_url != persona.avatar { - record.avatar_url = persona.avatar.clone(); - record_changed = true; - } - if record.display_name != persona.display_name { - record.display_name = persona.display_name.clone(); - record_changed = true; - } - - if record_changed { - record.updated_at = now_iso(); - changed = true; - eprintln!( - "sprout-desktop: sync-pack-personas: updated {:?} from pack {:?}", - record.display_name, pack_id - ); - } - } - found = true; - break; - } - - if !found { - // Pack directory no longer exists or doesn't resolve — skip silently - continue; - } - } - - if changed { - save_personas(app, &records)?; - } - - Ok(()) -} - pub fn load_personas(app: &AppHandle) -> Result, String> { let path = personas_store_path(app)?; let now = now_iso(); diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index ae1b037bb..e513d4c58 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -1,8 +1,9 @@ use super::{ 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, + migrate_retired_personas, validate_persona_activation_change, validate_persona_deletion, + BUILT_IN_PERSONAS, RETIRED_PERSONAS, }; +use crate::managed_agents::validate_team_id; use crate::managed_agents::PersonaRecord; fn custom_persona(id: &str, display_name: &str) -> PersonaRecord { @@ -17,8 +18,8 @@ fn custom_persona(id: &str, display_name: &str) -> PersonaRecord { name_pool: Vec::new(), is_builtin: false, is_active: true, - source_pack: None, - source_pack_persona_slug: None, + source_team: None, + source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), created_at: "2026-03-19T00:00:00Z".to_string(), updated_at: "2026-03-19T00:00:00Z".to_string(), @@ -297,59 +298,59 @@ fn validate_persona_deletion_allows_safe_custom_personas() { assert!(validate_persona_deletion(&persona, false).is_ok()); } -// ── validate_pack_id ────────────────────────────────────────────────────────── +// ── validate_team_id ────────────────────────────────────────────────────────── #[test] fn pack_id_valid_reverse_dns() { - assert!(validate_pack_id("com.example.security-team").is_ok()); + assert!(validate_team_id("com.example.security-team").is_ok()); } #[test] fn pack_id_valid_simple() { - assert!(validate_pack_id("my-pack").is_ok()); + assert!(validate_team_id("my-pack").is_ok()); } #[test] fn pack_id_rejects_empty() { - assert!(validate_pack_id("").is_err()); + assert!(validate_team_id("").is_err()); } #[test] fn pack_id_rejects_dot_dot_path_traversal() { // Critical regression test: ".." must never pass validation. // A pack with id ".." would write into the parent directory. - assert!(validate_pack_id("..").is_err()); + assert!(validate_team_id("..").is_err()); } #[test] fn pack_id_rejects_single_dot() { - assert!(validate_pack_id(".").is_err()); + assert!(validate_team_id(".").is_err()); } #[test] fn pack_id_rejects_leading_dot() { - assert!(validate_pack_id(".hidden").is_err()); + assert!(validate_team_id(".hidden").is_err()); } #[test] fn pack_id_rejects_slashes() { - assert!(validate_pack_id("../etc/passwd").is_err()); - assert!(validate_pack_id("foo/bar").is_err()); + assert!(validate_team_id("../etc/passwd").is_err()); + assert!(validate_team_id("foo/bar").is_err()); } #[test] fn pack_id_rejects_no_alphanumeric() { - assert!(validate_pack_id("---").is_err()); - assert!(validate_pack_id("___").is_err()); + assert!(validate_team_id("---").is_err()); + assert!(validate_team_id("___").is_err()); } #[test] fn pack_id_rejects_too_long() { let long_id = "a".repeat(129); - assert!(validate_pack_id(&long_id).is_err()); + assert!(validate_team_id(&long_id).is_err()); // 128 chars is fine let max_id = "a".repeat(128); - assert!(validate_pack_id(&max_id).is_ok()); + assert!(validate_team_id(&max_id).is_ok()); } // ── migrate_retired_personas ────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index f8f588000..a8da42f92 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -86,8 +86,8 @@ mod tests { backend: BackendKind::Local, backend_agent_id: None, provider_binary_path: None, - persona_pack_path: None, - persona_name_in_pack: None, + persona_team_dir: None, + persona_name_in_team: None, created_at: "now".into(), updated_at: "now".into(), last_started_at: None, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 1b1b8187a..7a3ee2253 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -913,10 +913,10 @@ pub fn spawn_agent_child( } } } - if let (Some(pack_path), Some(persona_name)) = - (&record.persona_pack_path, &record.persona_name_in_pack) + if let (Some(team_dir), Some(persona_name)) = + (&record.persona_team_dir, &record.persona_name_in_team) { - command.env("SPROUT_ACP_PERSONA_PACK", pack_path); + command.env("SPROUT_ACP_PERSONA_PACK", team_dir); command.env("SPROUT_ACP_PERSONA_NAME", persona_name); } diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index baa2e44fd..694db42d1 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -84,8 +84,8 @@ fn fixture( backend: Default::default(), backend_agent_id: None, provider_binary_path: None, - persona_pack_path: None, - persona_name_in_pack: None, + persona_team_dir: None, + persona_name_in_team: None, created_at: "now".into(), updated_at: "now".into(), last_started_at: None, @@ -210,8 +210,8 @@ fn persona_with_provider( name_pool: Vec::new(), is_builtin: false, is_active: true, - source_pack: None, - source_pack_persona_slug: None, + source_team: None, + source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), created_at: "2026-06-09T00:00:00Z".to_string(), updated_at: "2026-06-09T00:00:00Z".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index 45b169970..0d9bcd6e9 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -67,6 +67,10 @@ fn built_in_team_records(now: &str) -> Vec { description: team.description.map(|s| s.to_string()), persona_ids: team.persona_ids.iter().map(|s| s.to_string()).collect(), is_builtin: true, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, created_at: now.to_string(), updated_at: now.to_string(), }) @@ -156,6 +160,440 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String> crate::managed_agents::storage::atomic_write_json(&path, &payload) } +// --------------------------------------------------------------------------- +// Directory-backed team operations +// --------------------------------------------------------------------------- + +/// Teams directory: `/agents/teams/` +fn teams_dir(app: &AppHandle) -> Result { + let dir = managed_agents_base_dir(app)?.join("teams"); + fs::create_dir_all(&dir).map_err(|e| format!("failed to create teams dir: {e}"))?; + Ok(dir) +} + +/// Validate team/pack ID: only `[a-zA-Z0-9._-]+` allowed (zip-slip defense). +pub(crate) fn validate_team_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("team ID is empty".into()); + } + if id.len() > 128 { + return Err(format!("team ID too long: {} chars (max 128)", id.len())); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') + { + return Err(format!( + "team ID contains invalid characters: \"{id}\". Only [a-zA-Z0-9._-] allowed." + )); + } + if id.starts_with('.') { + return Err(format!("team ID \"{id}\" must not start with '.'")); + } + if !id.chars().any(|c| c.is_ascii_alphanumeric()) { + return Err(format!( + "team ID \"{id}\" must contain at least one alphanumeric character" + )); + } + Ok(()) +} + +/// Copy a directory tree, skipping symlinks (zip-slip defense). +fn copy_dir_no_symlinks(src: &std::path::Path, dst: &std::path::Path) -> Result<(), String> { + fs::create_dir_all(dst).map_err(|e| format!("failed to create {}: {e}", dst.display()))?; + for entry in fs::read_dir(src).map_err(|e| format!("failed to read {}: {e}", src.display()))? { + let entry = entry.map_err(|e| format!("dir entry error: {e}"))?; + let ft = entry + .file_type() + .map_err(|e| format!("file type error: {e}"))?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if ft.is_symlink() { + continue; + } else if ft.is_dir() { + copy_dir_no_symlinks(&src_path, &dst_path)?; + } else if ft.is_file() { + fs::copy(&src_path, &dst_path) + .map_err(|e| format!("failed to copy {}: {e}", src_path.display()))?; + } + } + Ok(()) +} + +/// Import a team from a local directory in open plugin format. +/// +/// Copies the directory into `/agents/teams//`, +/// creates PersonaRecords for each persona, creates a TeamRecord with source_dir set. +pub fn import_team_from_directory( + app: &AppHandle, + source_dir: &std::path::Path, + symlink: bool, +) -> Result { + use uuid::Uuid; + + // 1. Validate + resolve at source + let resolved = sprout_persona::resolve::resolve_pack(source_dir) + .map_err(|e| format!("team directory validation failed: {e}"))?; + + // 2. Sanitize team ID + validate_team_id(&resolved.id)?; + + // 3. Check for existing team with same ID + let teams_base = teams_dir(app)?; + let dest = teams_base.join(&resolved.id); + if dest.exists() { + return Err(format!( + "Team \"{}\" is already installed. Delete it first or use sync.", + resolved.id + )); + } + + // 4. Determine install mode: symlink or copy + let source_is_symlink = fs::symlink_metadata(source_dir) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + let use_symlink = symlink || source_is_symlink; + + if use_symlink { + // Resolve the canonical target for symlink + let canonical = fs::canonicalize(source_dir) + .map_err(|e| format!("failed to resolve symlink target: {e}"))?; + #[cfg(unix)] + { + std::os::unix::fs::symlink(&canonical, &dest) + .map_err(|e| format!("failed to create symlink: {e}"))?; + } + #[cfg(not(unix))] + { + // Fallback to copy on non-unix + copy_dir_no_symlinks(source_dir, &dest)?; + } + } else { + copy_dir_no_symlinks(source_dir, &dest)?; + } + + // 5. Re-validate the copy/symlink target (defense-in-depth) + let re_resolved = sprout_persona::resolve::resolve_pack(&dest).map_err(|e| { + // Clean up on failure + if use_symlink { + let _ = fs::remove_file(&dest); + } else { + let _ = fs::remove_dir_all(&dest); + } + format!("team re-validation failed after install: {e}") + })?; + + // 6. Create PersonaRecords + let now = now_iso(); + let new_personas: Vec = re_resolved + .personas + .iter() + .map(|p| PersonaRecord { + id: Uuid::new_v4().to_string(), + display_name: p.display_name.clone(), + avatar_url: p.avatar.clone(), + system_prompt: p.system_prompt.clone(), + runtime: p.runtime.clone(), + model: p.model.clone(), + provider: p.llm_provider.clone(), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: Some(resolved.id.clone()), + source_team_persona_slug: Some(p.name.clone()), + env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars( + p.runtime_env_vars.iter().cloned(), + ), + created_at: now.clone(), + updated_at: now.clone(), + }) + .collect(); + + let persona_ids: Vec = new_personas.iter().map(|p| p.id.clone()).collect(); + + // 7. Save personas + let mut personas = super::load_personas(app)?; + personas.extend(new_personas); + super::save_personas(app, &personas)?; + + // 8. Create and save TeamRecord + let symlink_target = if use_symlink { + fs::canonicalize(source_dir) + .ok() + .map(|p| p.display().to_string()) + } else { + None + }; + + let team = TeamRecord { + id: resolved.id, + name: resolved.name, + description: if resolved.description.is_empty() { + None + } else { + Some(resolved.description) + }, + persona_ids, + is_builtin: false, + source_dir: Some(dest), + is_symlink: use_symlink, + symlink_target, + version: Some(resolved.version), + created_at: now.clone(), + updated_at: now, + }; + + let mut teams = load_teams(app)?; + teams.push(team.clone()); + save_teams(app, &teams)?; + + Ok(team) +} + +/// Delete a team. For directory-backed teams, also removes the backing directory +/// and all personas sourced from that team. For JSON-only teams, only removes +/// the team record (personas are preserved). +pub fn delete_team_with_cascade(app: &AppHandle, team_id: &str) -> Result<(), String> { + let mut teams = load_teams(app)?; + let team = teams + .iter() + .find(|record| record.id == team_id) + .ok_or_else(|| format!("team {team_id} not found"))?; + + validate_team_deletion(team)?; + + if team.source_dir.is_some() { + // Directory-backed team: full cascade + // 1. Check no managed agents reference these personas + let agents = crate::managed_agents::load_managed_agents(app)?; + let referencing: Vec<&str> = agents + .iter() + .filter(|a| { + a.persona_team_dir + .as_ref() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + == Some(team_id) + }) + .map(|a| a.name.as_str()) + .collect(); + if !referencing.is_empty() { + return Err(format!( + "Cannot delete team \"{team_id}\": {} agent(s) still reference it ({}). \ + Delete or reconfigure them first.", + referencing.len(), + referencing.join(", ") + )); + } + + // 2. Remove all PersonaRecords where source_team == team_id + let mut personas = super::load_personas(app)?; + personas.retain(|p| p.source_team.as_deref() != Some(team_id)); + super::save_personas(app, &personas)?; + + // 3. Remove directory + if let Some(source_dir) = &team.source_dir { + if source_dir.exists() { + let is_symlink = fs::symlink_metadata(source_dir) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if is_symlink { + fs::remove_file(source_dir) + .map_err(|e| format!("failed to remove team symlink: {e}"))?; + } else { + fs::remove_dir_all(source_dir) + .map_err(|e| format!("failed to remove team directory: {e}"))?; + } + } + } + } + + // 4. Remove TeamRecord + teams.retain(|record| record.id != team_id); + save_teams(app, &teams) +} + +/// Re-reads a directory-backed team and reconciles with stored records. +pub fn sync_team_from_dir( + app: &AppHandle, + team_id: &str, +) -> Result { + use uuid::Uuid; + + let teams = load_teams(app)?; + let team = teams + .iter() + .find(|t| t.id == team_id) + .ok_or_else(|| format!("team {team_id} not found"))?; + + let source_dir = team + .source_dir + .as_ref() + .ok_or_else(|| format!("team {team_id} is not directory-backed"))?; + + if !source_dir.exists() { + return Err(format!( + "team directory does not exist: {}", + source_dir.display() + )); + } + + // Resolve current state of the directory + let resolved = sprout_persona::resolve::resolve_pack(source_dir) + .map_err(|e| format!("failed to resolve team directory: {e}"))?; + + let mut personas = super::load_personas(app)?; + let now = now_iso(); + + let mut added = Vec::new(); + let mut removed = Vec::new(); + let mut updated = Vec::new(); + + // Find existing personas for this team + let existing_slugs: Vec<(String, String)> = personas + .iter() + .filter(|p| p.source_team.as_deref() == Some(team_id)) + .map(|p| { + ( + p.source_team_persona_slug.clone().unwrap_or_default(), + p.id.clone(), + ) + }) + .collect(); + + // Check for new personas in directory + for dir_persona in &resolved.personas { + if let Some((_slug, persona_id)) = existing_slugs + .iter() + .find(|(slug, _)| slug == &dir_persona.name) + { + // Existing persona — check for content changes + if let Some(record) = personas.iter_mut().find(|p| p.id == *persona_id) { + let mut changed = false; + if record.display_name != dir_persona.display_name { + record.display_name = dir_persona.display_name.clone(); + changed = true; + } + if record.system_prompt != dir_persona.system_prompt { + record.system_prompt = dir_persona.system_prompt.clone(); + changed = true; + } + if record.avatar_url != dir_persona.avatar { + record.avatar_url = dir_persona.avatar.clone(); + changed = true; + } + if record.runtime != dir_persona.runtime { + record.runtime = dir_persona.runtime.clone(); + changed = true; + } + if record.model != dir_persona.model { + record.model = dir_persona.model.clone(); + changed = true; + } + if record.provider != dir_persona.llm_provider { + record.provider = dir_persona.llm_provider.clone(); + changed = true; + } + if changed { + record.updated_at = now.clone(); + updated.push(persona_id.clone()); + } + } + } else { + // New persona — create record + let new_persona = PersonaRecord { + id: Uuid::new_v4().to_string(), + display_name: dir_persona.display_name.clone(), + avatar_url: dir_persona.avatar.clone(), + system_prompt: dir_persona.system_prompt.clone(), + runtime: dir_persona.runtime.clone(), + model: dir_persona.model.clone(), + provider: dir_persona.llm_provider.clone(), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: Some(team_id.to_string()), + source_team_persona_slug: Some(dir_persona.name.clone()), + env_vars: crate::managed_agents::env_vars::filter_derived_provider_model_env_vars( + dir_persona.runtime_env_vars.iter().cloned(), + ), + created_at: now.clone(), + updated_at: now.clone(), + }; + added.push(new_persona.id.clone()); + personas.push(new_persona); + } + } + + // Check for personas removed from directory + let dir_slugs: Vec<&str> = resolved.personas.iter().map(|p| p.name.as_str()).collect(); + let to_remove: Vec = existing_slugs + .iter() + .filter(|(slug, _)| !dir_slugs.contains(&slug.as_str())) + .map(|(_, id)| id.clone()) + .collect(); + + // Only remove if no active managed agent uses the persona + let agents = crate::managed_agents::load_managed_agents(app)?; + for persona_id in &to_remove { + let in_use = agents + .iter() + .any(|a| a.persona_id.as_deref() == Some(persona_id)); + if !in_use { + personas.retain(|p| p.id != *persona_id); + removed.push(persona_id.clone()); + } + } + + // Update team metadata if changed + let mut teams = load_teams(app)?; + let mut metadata_changed = false; + if let Some(team_record) = teams.iter_mut().find(|t| t.id == team_id) { + if team_record.name != resolved.name { + team_record.name = resolved.name; + metadata_changed = true; + } + let new_desc = if resolved.description.is_empty() { + None + } else { + Some(resolved.description) + }; + if team_record.description != new_desc { + team_record.description = new_desc; + metadata_changed = true; + } + let new_version = Some(resolved.version); + if team_record.version != new_version { + team_record.version = new_version; + metadata_changed = true; + } + // Update persona_ids to reflect current state + let current_ids: Vec = personas + .iter() + .filter(|p| p.source_team.as_deref() == Some(team_id)) + .map(|p| p.id.clone()) + .collect(); + if team_record.persona_ids != current_ids { + team_record.persona_ids = current_ids; + metadata_changed = true; + } + if metadata_changed { + team_record.updated_at = now; + } + } + + super::save_personas(app, &personas)?; + save_teams(app, &teams)?; + + Ok(crate::managed_agents::SyncResult { + personas_added: added, + personas_removed: removed, + personas_updated: updated, + metadata_changed, + }) +} + // --------------------------------------------------------------------------- // Team JSON export / import // --------------------------------------------------------------------------- @@ -274,6 +712,21 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result { }) } +/// Sync all directory-backed teams on launch — the team equivalent of the +/// former `sync_pack_personas`. Silently skips teams whose source directory +/// is missing (e.g., external drive unmounted). +pub fn sync_team_personas(app: &AppHandle) -> Result<(), String> { + let teams = load_teams(app)?; + for team in &teams { + if team.source_dir.as_ref().is_some_and(|d| d.exists()) { + if let Err(e) = sync_team_from_dir(app, &team.id) { + eprintln!("sprout-desktop: sync team {}: {e}", team.id); + } + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{ @@ -289,6 +742,10 @@ mod tests { description: None, persona_ids: Vec::new(), is_builtin: false, + source_dir: None, + is_symlink: false, + symlink_target: None, + version: None, created_at: "2026-03-20T00:00:00Z".to_string(), updated_at: "2026-03-20T00:00:00Z".to_string(), } @@ -335,8 +792,8 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, - source_pack: None, - source_pack_persona_slug: None, + source_team: None, + source_team_persona_slug: None, env_vars: std::collections::BTreeMap::new(), created_at: "2026-03-20T00:00:00Z".to_string(), updated_at: "2026-03-20T00:00:00Z".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 6a160c384..544c344f8 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -41,15 +41,23 @@ pub struct PersonaRecord { pub is_builtin: bool, #[serde(default = "default_record_active")] pub is_active: bool, - /// Pack ID if this persona was imported from a persona pack. - /// Pack personas are non-editable (system_prompt, model locked). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_pack: Option, - /// Internal persona slug within the pack (e.g., "lep", "pip"). + /// Team ID if this persona was imported from a team directory. + /// Team personas are non-editable (system_prompt, model locked). + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "source_pack" + )] + pub source_team: Option, + /// Internal persona slug within the team (e.g., "lep", "pip"). /// Used by ACP's `resolve_persona_by_name()` to find the right persona. /// Validated: `[a-zA-Z0-9_-]+`, max 64 chars (safe for env vars and paths). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_pack_persona_slug: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "source_pack_persona_slug" + )] + pub source_team_persona_slug: Option, /// Harness-level configuration passed to the agent subprocess as environment variables. /// Opaque to Sprout — keys and values are runtime-specific. /// @@ -133,12 +141,20 @@ pub struct ManagedAgentRecord { pub backend_agent_id: Option, #[serde(default)] pub provider_binary_path: Option, - /// Installed pack path (absolute). Set when agent was created from a pack persona. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub persona_pack_path: Option, - /// Persona name within the pack. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub persona_name_in_pack: Option, + /// Installed team directory path (absolute). Set when agent was created from a team persona. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "persona_pack_path" + )] + pub persona_team_dir: Option, + /// Persona name within the team. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "persona_name_in_pack" + )] + pub persona_name_in_team: Option, pub created_at: String, pub updated_at: String, pub last_started_at: Option, @@ -473,6 +489,18 @@ pub struct TeamRecord { pub persona_ids: Vec, #[serde(default)] pub is_builtin: bool, + /// Absolute path to the team's backing directory (if directory-backed). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_dir: Option, + /// Whether `source_dir` is a symlink to an external directory. + #[serde(default)] + pub is_symlink: bool, + /// Resolved symlink target path (for display). Only set when `is_symlink` is true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symlink_target: Option, + /// Version from the team's `plugin.json` manifest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, pub created_at: String, pub updated_at: String, } @@ -496,6 +524,24 @@ pub struct UpdateTeamRequest { pub persona_ids: Vec, } +/// Result of syncing a directory-backed team with its backing directory. +#[derive(Debug, Clone, Serialize)] +pub struct SyncResult { + pub personas_added: Vec, + pub personas_removed: Vec, + pub personas_updated: Vec, + pub metadata_changed: bool, +} + +/// Report from the one-time packs→teams migration. +#[derive(Debug, Clone, Serialize)] +pub struct MigrationReport { + pub packs_migrated: usize, + pub personas_updated: usize, + pub agents_updated: usize, + pub errors: Vec, +} + pub const DEFAULT_ACP_COMMAND: &str = "sprout-acp"; pub const DEFAULT_AGENT_COMMAND: &str = "goose"; /// ~5 min (320s) — matches the CLI harness default (SPROUT_ACP_IDLE_TIMEOUT). @@ -581,6 +627,7 @@ pub fn validate_respond_to_allowlist(input: &[String]) -> Result, St #[cfg(test)] mod tests { use super::{ManagedAgentRecord, PersonaRecord}; + use std::path::PathBuf; #[test] fn persona_record_defaults_active_when_field_is_missing() { @@ -827,4 +874,105 @@ mod tests { let back: RelayMeshConfig = serde_json::from_str(&json).unwrap(); assert_eq!(back, config); } + + // ── Packs → Teams serde alias backward compatibility ──────────────── + + #[test] + fn persona_record_deserializes_old_source_pack_fields_via_alias() { + let record: PersonaRecord = serde_json::from_str( + r#"{ + "id": "persona-1", + "display_name": "Test", + "avatar_url": null, + "system_prompt": "Prompt", + "source_pack": "com.example.my-pack", + "source_pack_persona_slug": "agent-one", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#, + ) + .expect("old-format persona with source_pack should deserialize via alias"); + + assert_eq!(record.source_team.as_deref(), Some("com.example.my-pack")); + assert_eq!( + record.source_team_persona_slug.as_deref(), + Some("agent-one") + ); + } + + #[test] + fn persona_record_serializes_new_field_names() { + let record: PersonaRecord = serde_json::from_str( + r#"{ + "id": "persona-1", + "display_name": "Test", + "avatar_url": null, + "system_prompt": "Prompt", + "source_team": "com.example.my-team", + "source_team_persona_slug": "agent-one", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#, + ) + .unwrap(); + + let json = serde_json::to_string(&record).unwrap(); + assert!(json.contains("source_team")); + assert!(json.contains("source_team_persona_slug")); + assert!(!json.contains("source_pack")); + } + + #[test] + fn managed_agent_record_deserializes_old_pack_path_fields_via_alias() { + let record: ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "sprout-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "persona_pack_path": "/path/to/agents/packs/my-pack", + "persona_name_in_pack": "agent-one", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("old-format agent with persona_pack_path should deserialize via alias"); + + assert_eq!( + record.persona_team_dir, + Some(PathBuf::from("/path/to/agents/packs/my-pack")) + ); + assert_eq!(record.persona_name_in_team.as_deref(), Some("agent-one")); + } + + #[test] + fn team_record_deserializes_without_new_fields() { + let record: super::TeamRecord = serde_json::from_str( + r#"{ + "id": "team-1", + "name": "My Team", + "description": null, + "persona_ids": ["p1", "p2"], + "is_builtin": false, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }"#, + ) + .expect("team record without new fields should deserialize with defaults"); + + assert_eq!(record.source_dir, None); + assert!(!record.is_symlink); + assert_eq!(record.symlink_target, None); + assert_eq!(record.version, None); + } } diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index b006c84af..5ced85d12 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -29,7 +29,7 @@ const SHARED_AGENT_FILES: &[&str] = &[ /// Directories symlinked from worktree data directories to the canonical /// dev data directory. Each entry becomes a single directory symlink. -const SHARED_AGENT_DIRS: &[&str] = &["agents/packs"]; +const SHARED_AGENT_DIRS: &[&str] = &["agents/teams"]; fn canonical_dev_data_dir(current: &Path) -> Option { current.parent().map(|p| p.join(CANONICAL_DEV_IDENTIFIER)) @@ -264,51 +264,62 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) { } } -fn reconcile_pack_paths_in_file(path: &Path, canonical_dir: &Path) { - let canonical_packs = canonical_dir.join("agents/packs"); +fn reconcile_team_dirs_in_file(path: &Path, canonical_dir: &Path) { + let canonical_teams = canonical_dir.join("agents/teams"); patch_json_records(path, |obj| { - let pack_path = match obj.get("persona_pack_path").and_then(|v| v.as_str()) { + // Handle both old field name and new field name + let field_name = if obj.contains_key("persona_team_dir") { + "persona_team_dir" + } else if obj.contains_key("persona_pack_path") { + "persona_pack_path" + } else { + return false; + }; + let team_path = match obj.get(field_name).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()); + let team_path = Path::new(team_path); + // Extract the team ID from the path (component after "teams" or "packs") + let mut found_dir = false; + let mut team_id: Option<&std::ffi::OsStr> = None; + for component in team_path.components() { + if found_dir { + team_id = Some(component.as_os_str()); break; } - if component.as_os_str() == "packs" { - found_packs = true; + if component.as_os_str() == "teams" || component.as_os_str() == "packs" { + found_dir = true; } } - let Some(id) = pack_id else { + let Some(id) = team_id else { return false; }; - let expected = canonical_packs.join(id); - if pack_path == expected { + let expected = canonical_teams.join(id); + if team_path == expected { return false; } eprintln!( - "sprout-desktop: pack-path-reconcile: {:?}: {:?} → {:?}", + "sprout-desktop: team-dir-reconcile: {:?}: {:?} → {:?}", obj.get("name").and_then(|v| v.as_str()).unwrap_or("?"), - pack_path, + team_path, expected, ); + // Always write the canonical new field name + obj.remove("persona_pack_path"); obj.insert( - "persona_pack_path".to_string(), + "persona_team_dir".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) { +/// 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. +pub fn reconcile_persona_team_dirs(app: &tauri::AppHandle) { let Ok(current_dir) = app.path().app_data_dir() else { return; }; @@ -320,7 +331,158 @@ pub fn reconcile_persona_pack_paths(app: &tauri::AppHandle) { if !path.exists() { return; } - reconcile_pack_paths_in_file(&path, &canonical_dir); + reconcile_team_dirs_in_file(&path, &canonical_dir); +} + +/// One-time migration from packs to teams. +/// +/// Runs on app launch if `agents/packs/` exists or if any record in +/// `managed-agents.json` still uses the old `persona_pack_path` field name. +/// Steps (in order, each individually idempotent): +/// +/// 1. Rename `agents/packs/` → `agents/teams/` on disk +/// 2. Rewrite `personas.json`: `source_pack` → `source_team`, `source_pack_persona_slug` → `source_team_persona_slug` +/// 3. Rewrite `managed-agents.json`: `persona_pack_path` → `persona_team_dir` (with `/packs/` → `/teams/` path fix), `persona_name_in_pack` → `persona_name_in_team` +pub fn migrate_packs_to_teams(app: &tauri::AppHandle) { + use crate::managed_agents::MigrationReport; + + 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 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"); + + // 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 has_old_fields = agents_path.exists() + && std::fs::read_to_string(&agents_path) + .map(|c| c.contains("persona_pack_path")) + .unwrap_or(false); + let personas_has_old_fields = personas_path.exists() + && std::fs::read_to_string(&personas_path) + .map(|c| c.contains("\"source_pack\"")) + .unwrap_or(false); + + if !packs_dir_exists && !has_old_fields && !personas_has_old_fields { + return; + } + + let mut report = MigrationReport { + packs_migrated: 0, + personas_updated: 0, + agents_updated: 0, + errors: Vec::new(), + }; + + // Step 1: Rename directory agents/packs/ → agents/teams/ + if packs_dir_exists { + if teams_dir.exists() { + // Merge: move contents from packs into teams, skip conflicts + if let Ok(entries) = std::fs::read_dir(&packs_dir) { + for entry in entries.flatten() { + let dest = teams_dir.join(entry.file_name()); + if !dest.exists() { + if let Err(e) = std::fs::rename(entry.path(), &dest) { + report + .errors + .push(format!("failed to move {:?}: {e}", entry.file_name())); + } else { + report.packs_migrated += 1; + } + } + } + } + // Remove packs dir only if empty (external tools like ai-rules + // may have recreated symlinks here between migration runs) + let _ = std::fs::remove_dir(&packs_dir); + } else { + // Simple rename + if let Some(parent) = teams_dir.parent() { + let _ = std::fs::create_dir_all(parent); + } + match std::fs::rename(&packs_dir, &teams_dir) { + Ok(_) => { + if let Ok(entries) = std::fs::read_dir(&teams_dir) { + report.packs_migrated = entries.count(); + } + } + Err(e) => { + report + .errors + .push(format!("failed to rename packs → teams: {e}")); + eprintln!( + "sprout-desktop: packs→teams migration: directory rename failed: {e}" + ); + return; + } + } + } + } + + // Step 2: Rewrite personas.json field names + if personas_path.exists() { + patch_json_records(&personas_path, |obj| { + let mut changed = false; + if let Some(val) = obj.remove("source_pack") { + obj.insert("source_team".to_string(), val); + changed = true; + } + if let Some(val) = obj.remove("source_pack_persona_slug") { + obj.insert("source_team_persona_slug".to_string(), val); + changed = true; + } + if changed { + report.personas_updated += 1; + } + changed + }); + } + + // Step 3: Rewrite managed-agents.json field names and paths + if agents_path.exists() { + patch_json_records(&agents_path, |obj| { + let mut changed = false; + if let Some(val) = obj.remove("persona_pack_path") { + // Also fix the path: replace /packs/ with /teams/ + let new_val = if let Some(s) = val.as_str() { + serde_json::Value::String(s.replace("/packs/", "/teams/")) + } else { + val + }; + obj.insert("persona_team_dir".to_string(), new_val); + changed = true; + } + if let Some(val) = obj.remove("persona_name_in_pack") { + obj.insert("persona_name_in_team".to_string(), val); + changed = true; + } + if changed { + report.agents_updated += 1; + } + changed + }); + } + + if report.packs_migrated > 0 || report.personas_updated > 0 || report.agents_updated > 0 { + eprintln!( + "sprout-desktop: packs→teams migration complete: {} dirs, {} personas, {} agents{}", + report.packs_migrated, + report.personas_updated, + report.agents_updated, + if report.errors.is_empty() { + String::new() + } else { + format!(" ({} errors)", report.errors.len()) + } + ); + } } fn reconcile_mcp_commands_in_file(path: &Path) { @@ -406,838 +568,7 @@ pub fn migrate_persona_provider_to_runtime(app: &tauri::AppHandle) { } rename_provider_to_runtime_in_personas(&path); } -#[cfg(test)] -mod tests { - use super::*; - - #[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. - /// 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"); - - 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(); - - // 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) - } - - /// Helper: sync files directly (without a Tauri AppHandle) for unit testing. - /// Mirrors the symlink loop of `sync_shared_agent_data` but takes explicit - /// paths. `sync_shared_agent_data` requires a live Tauri AppHandle and - /// cannot be unit-tested directly. - 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(); - } - if dst.is_symlink() { - if let Ok(target) = std::fs::read_link(&dst) { - if target == src { - continue; - } - } - } - if dst.exists() || dst.is_symlink() { - let _ = std::fs::remove_file(&dst); - } - 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 - } - - #[test] - fn sync_creates_symlinks_to_fresh_worktree() { - let (_parent, canonical, worktree) = setup_sync_layout(); - let synced = sync_files(&canonical, &worktree); - assert_eq!(synced, 4); - for rel in SHARED_AGENT_FILES { - let dst = worktree.join(rel); - assert!(dst.is_symlink(), "{rel} should be a symlink"); - assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); - } - 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"}]"#, - ); - } - - #[test] - fn sync_replaces_existing_files_with_symlinks() { - let (_parent, canonical, worktree) = setup_sync_layout(); - 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, 4); - for rel in SHARED_AGENT_FILES { - let dst = worktree.join(rel); - assert!( - dst.is_symlink(), - "{rel} should be a symlink after replacing regular file" - ); - 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"}]"#, - ); - } - - #[test] - fn sync_preserves_correct_symlinks() { - let (_parent, canonical, worktree) = setup_sync_layout(); - 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); - assert!(dst.is_symlink()); - assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); - } - } - - #[test] - fn sync_replaces_wrong_symlinks() { - let (_parent, canonical, worktree) = setup_sync_layout(); - let wrong_target = PathBuf::from("/nonexistent/wrong-target.json"); - std::fs::create_dir_all(worktree.join("agents")).unwrap(); - for rel in SHARED_AGENT_FILES { - std::os::unix::fs::symlink(&wrong_target, worktree.join(rel)).unwrap(); - } - let synced = sync_files(&canonical, &worktree); - assert_eq!(synced, 4); - for rel in SHARED_AGENT_FILES { - assert_eq!( - std::fs::read_link(worktree.join(rel)).unwrap(), - canonical.join(rel) - ); - } - } - - #[test] - fn sync_handles_broken_symlinks() { - let (_parent, canonical, worktree) = setup_sync_layout(); - std::fs::create_dir_all(worktree.join("agents")).unwrap(); - let broken_target = PathBuf::from("/this/does/not/exist.json"); - for rel in SHARED_AGENT_FILES { - std::os::unix::fs::symlink(&broken_target, worktree.join(rel)).unwrap(); - } - let synced = sync_files(&canonical, &worktree); - assert_eq!(synced, 4); - for rel in SHARED_AGENT_FILES { - let dst = worktree.join(rel); - assert!(dst.is_symlink()); - assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); - // Content should be readable through the fixed symlink. - assert!(std::fs::read_to_string(&dst).is_ok()); - } - } - - #[test] - fn writes_through_symlink_reach_canonical() { - let (_parent, canonical, worktree) = setup_sync_layout(); - sync_files(&canonical, &worktree); - - let worktree_path = worktree.join("agents/personas.json"); - let canonical_path = canonical.join("agents/personas.json"); - - // Write through the symlink using the same pattern as atomic_write_json. - let new_content = r#"[{"id":"builtin:solo","updated":true}]"#; - let resolved = std::fs::canonicalize(&worktree_path).unwrap(); - let tmp = resolved.with_extension("json.tmp"); - std::fs::write(&tmp, new_content.as_bytes()).unwrap(); - std::fs::rename(&tmp, &resolved).unwrap(); - - // The canonical file should have the new content. - assert_eq!( - std::fs::read_to_string(&canonical_path).unwrap(), - new_content - ); - // The worktree path should still be a symlink. - assert!(worktree_path.is_symlink()); - // Reading through the symlink should return the new content. - assert_eq!( - std::fs::read_to_string(&worktree_path).unwrap(), - new_content - ); - } - - #[test] - fn canonical_dev_data_dir_returns_self_for_canonical_instance() { - // 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"); - assert_eq!(canonical_dev_data_dir(¤t).unwrap(), current); - - // Also verify with a temp dir on the real filesystem. - let parent = tempfile::tempdir().unwrap(); - let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); - 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 { - let content = std::fs::read_to_string(dir.join("agents/managed-agents.json")).unwrap(); - serde_json::from_str(&content).unwrap() - } - - #[test] - fn reconcile_clears_mcp_command_for_goose() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Scout", - "agent_command": "goose", - "mcp_command": "sprout-mcp-server" - }]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let records = read_agents_json(dir.path()); - assert_eq!(records[0]["mcp_command"], ""); - } - - #[test] - fn reconcile_clears_mcp_command_for_claude() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Claude Agent", - "agent_command": "claude-agent-acp", - "mcp_command": "sprout-mcp-server" - }]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let records = read_agents_json(dir.path()); - assert_eq!(records[0]["mcp_command"], ""); - } - - #[test] - fn reconcile_preserves_sprout_dev_mcp() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Solo", - "agent_command": "sprout-agent", - "mcp_command": "sprout-dev-mcp" - }]), - ); - let before = - std::fs::read_to_string(dir.path().join("agents/managed-agents.json")).unwrap(); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let after = std::fs::read_to_string(dir.path().join("agents/managed-agents.json")).unwrap(); - assert_eq!( - before, after, - "file should not be rewritten when already correct" - ); - } - - #[test] - fn reconcile_fixes_sprout_agent_if_stale() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Solo", - "agent_command": "sprout-agent", - "mcp_command": "sprout-mcp-server" - }]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let records = read_agents_json(dir.path()); - assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp"); - } - - #[test] - fn reconcile_leaves_unknown_agent_untouched() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Custom Bot", - "agent_command": "my-custom-agent", - "mcp_command": "my-custom-mcp" - }]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let records = read_agents_json(dir.path()); - assert_eq!(records[0]["mcp_command"], "my-custom-mcp"); - } - - #[test] - fn reconcile_is_idempotent() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Scout", - "agent_command": "goose", - "mcp_command": "sprout-mcp-server" - }]), - ); - let path = dir.path().join("agents/managed-agents.json"); - reconcile_mcp_commands_in_file(&path); - let after_first = std::fs::read_to_string(&path).unwrap(); - reconcile_mcp_commands_in_file(&path); - let after_second = std::fs::read_to_string(&path).unwrap(); - assert_eq!(after_first, after_second); - } - - #[test] - fn reconcile_handles_mixed_records() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([ - {"name": "Scout", "agent_command": "goose", "mcp_command": "sprout-mcp-server"}, - {"name": "Claude", "agent_command": "claude-agent-acp", "mcp_command": "sprout-mcp-server"}, - {"name": "Solo", "agent_command": "sprout-agent", "mcp_command": "sprout-dev-mcp"}, - {"name": "Custom", "agent_command": "my-bot", "mcp_command": "my-mcp"}, - {"name": "Codex", "agent_command": "codex-acp", "mcp_command": "sprout-mcp-server"} - ]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let records = read_agents_json(dir.path()); - assert_eq!(records[0]["mcp_command"], "", "goose should be cleared"); - assert_eq!(records[1]["mcp_command"], "", "claude should be cleared"); - assert_eq!( - records[2]["mcp_command"], "sprout-dev-mcp", - "sprout-agent preserved" - ); - assert_eq!( - records[3]["mcp_command"], "my-mcp", - "custom agent untouched" - ); - assert_eq!(records[4]["mcp_command"], "", "codex should be cleared"); - } - - #[test] - fn reconcile_adds_mcp_command_when_key_absent() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Solo", - "agent_command": "sprout-agent" - }]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let records = read_agents_json(dir.path()); - assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp"); - } - - #[test] - fn reconcile_treats_null_mcp_command_as_empty() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Solo", - "agent_command": "sprout-agent", - "mcp_command": null - }]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - 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); - } - - 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 { - 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(); - write_personas_json( - dir.path(), - &serde_json::json!([{ - "id": "persona-1", - "displayName": "Alice", - "provider": "goose" - }]), - ); - rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); - let records = read_personas_json(dir.path()); - assert_eq!(records[0]["runtime"], "goose"); - assert!(records[0].get("provider").is_none()); - } - - #[test] - fn rename_provider_to_runtime_is_idempotent() { - let dir = tempfile::tempdir().unwrap(); - write_personas_json( - dir.path(), - &serde_json::json!([{ - "id": "persona-1", - "displayName": "Alice", - "runtime": "goose" - }]), - ); - let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); - rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); - let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); - assert_eq!( - before, after, - "file should not be rewritten when already migrated" - ); - } - - #[test] - fn rename_provider_to_runtime_skips_record_without_either_key() { - let dir = tempfile::tempdir().unwrap(); - write_personas_json( - dir.path(), - &serde_json::json!([{ - "id": "persona-1", - "displayName": "Alice" - }]), - ); - let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); - rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); - let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); - assert_eq!( - before, after, - "file should not be rewritten when no provider key exists" - ); - } - - #[test] - fn rename_provider_to_runtime_preserves_existing_runtime_over_provider() { - let dir = tempfile::tempdir().unwrap(); - write_personas_json( - dir.path(), - &serde_json::json!([{ - "id": "persona-1", - "displayName": "Alice", - "provider": "old-value", - "runtime": "correct-value" - }]), - ); - rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); - let records = read_personas_json(dir.path()); - assert_eq!(records[0]["runtime"], "correct-value"); - // provider key should still be there since the closure returns false when runtime exists - assert_eq!(records[0]["provider"], "old-value"); - } - - #[test] - fn reconcile_mcp_commands_clears_stale_sprout_mcp_server() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Solo", - "agent_command": "goose", - "mcp_command": "sprout-mcp-server" - }]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let records = read_agents_json(dir.path()); - assert_eq!(records[0]["mcp_command"], ""); - } - - #[test] - fn reconcile_mcp_commands_sets_canonical_for_sprout_agent() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Stilgar", - "agent_command": "sprout-agent", - "mcp_command": "sprout-mcp-server" - }]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let records = read_agents_json(dir.path()); - assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp"); - } - - #[test] - fn reconcile_mcp_commands_leaves_custom_value_untouched() { - let dir = tempfile::tempdir().unwrap(); - let json = serde_json::json!([{ - "name": "Solo", - "agent_command": "goose", - "mcp_command": "my-custom-mcp" - }]); - write_agents_json(dir.path(), &json); - let path = dir.path().join("agents/managed-agents.json"); - let before = std::fs::read_to_string(&path).unwrap(); - reconcile_mcp_commands_in_file(&path); - assert_eq!(before, std::fs::read_to_string(&path).unwrap()); - } - - #[test] - fn reconcile_mcp_commands_leaves_unknown_runtime_untouched() { - let dir = tempfile::tempdir().unwrap(); - let json = serde_json::json!([{ - "name": "Custom", - "agent_command": "my-custom-agent", - "mcp_command": "sprout-mcp-server" - }]); - write_agents_json(dir.path(), &json); - let path = dir.path().join("agents/managed-agents.json"); - let before = std::fs::read_to_string(&path).unwrap(); - reconcile_mcp_commands_in_file(&path); - assert_eq!(before, std::fs::read_to_string(&path).unwrap()); - } - - #[test] - fn reconcile_mcp_commands_is_idempotent() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([{ - "name": "Solo", - "agent_command": "goose", - "mcp_command": "sprout-mcp-server" - }]), - ); - let path = dir.path().join("agents/managed-agents.json"); - reconcile_mcp_commands_in_file(&path); - let after_first = std::fs::read_to_string(&path).unwrap(); - reconcile_mcp_commands_in_file(&path); - assert_eq!(after_first, std::fs::read_to_string(&path).unwrap()); - } - - #[test] - fn reconcile_mcp_commands_handles_mixed_agents() { - let dir = tempfile::tempdir().unwrap(); - write_agents_json( - dir.path(), - &serde_json::json!([ - {"name": "Stale Goose", "agent_command": "goose", "mcp_command": "sprout-mcp-server"}, - {"name": "Clean Goose", "agent_command": "goose", "mcp_command": ""}, - {"name": "Custom Agent", "agent_command": "goose", "mcp_command": "my-custom-mcp"}, - {"name": "Stale Sprout", "agent_command": "sprout-agent", "mcp_command": "sprout-mcp-server"} - ]), - ); - reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); - let records = read_agents_json(dir.path()); - assert_eq!(records[0]["mcp_command"], ""); - assert_eq!(records[1]["mcp_command"], ""); - assert_eq!(records[2]["mcp_command"], "my-custom-mcp"); - assert_eq!(records[3]["mcp_command"], "sprout-dev-mcp"); - } - - #[test] - fn reconcile_mcp_commands_skips_record_without_agent_command() { - let dir = tempfile::tempdir().unwrap(); - let json = serde_json::json!([{ - "name": "No Command", - "mcp_command": "sprout-mcp-server" - }]); - write_agents_json(dir.path(), &json); - let path = dir.path().join("agents/managed-agents.json"); - let before = std::fs::read_to_string(&path).unwrap(); - reconcile_mcp_commands_in_file(&path); - assert_eq!(before, std::fs::read_to_string(&path).unwrap()); - } -} +#[cfg(test)] +#[path = "migration_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/migration_tests.rs b/desktop/src-tauri/src/migration_tests.rs new file mode 100644 index 000000000..133228856 --- /dev/null +++ b/desktop/src-tauri/src/migration_tests.rs @@ -0,0 +1,833 @@ +use super::*; + +#[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. +/// 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"); + + 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(); + + // Teams installed from `.main` — canonical has no teams dir. + let team_dir = main_instance.join("agents/teams/com.example.test-pack"); + std::fs::create_dir_all(&team_dir).unwrap(); + std::fs::write(team_dir.join("instructions.md"), "# Test pack").unwrap(); + std::fs::write(team_dir.join("solo.persona.md"), "# Solo").unwrap(); + + (parent, canonical, worktree) +} + +/// Helper: sync files directly (without a Tauri AppHandle) for unit testing. +/// Mirrors the symlink loop of `sync_shared_agent_data` but takes explicit +/// paths. `sync_shared_agent_data` requires a live Tauri AppHandle and +/// cannot be unit-tested directly. +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(); + } + if dst.is_symlink() { + if let Ok(target) = std::fs::read_link(&dst) { + if target == src { + continue; + } + } + } + if dst.exists() || dst.is_symlink() { + let _ = std::fs::remove_file(&dst); + } + 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 +} + +#[test] +fn sync_creates_symlinks_to_fresh_worktree() { + let (_parent, canonical, worktree) = setup_sync_layout(); + let synced = sync_files(&canonical, &worktree); + assert_eq!(synced, 4); + for rel in SHARED_AGENT_FILES { + let dst = worktree.join(rel); + assert!(dst.is_symlink(), "{rel} should be a symlink"); + assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); + } + 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"}]"#, + ); +} + +#[test] +fn sync_replaces_existing_files_with_symlinks() { + let (_parent, canonical, worktree) = setup_sync_layout(); + 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, 4); + for rel in SHARED_AGENT_FILES { + let dst = worktree.join(rel); + assert!( + dst.is_symlink(), + "{rel} should be a symlink after replacing regular file" + ); + 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"}]"#, + ); +} + +#[test] +fn sync_preserves_correct_symlinks() { + let (_parent, canonical, worktree) = setup_sync_layout(); + 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); + assert!(dst.is_symlink()); + assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); + } +} + +#[test] +fn sync_replaces_wrong_symlinks() { + let (_parent, canonical, worktree) = setup_sync_layout(); + let wrong_target = PathBuf::from("/nonexistent/wrong-target.json"); + std::fs::create_dir_all(worktree.join("agents")).unwrap(); + for rel in SHARED_AGENT_FILES { + std::os::unix::fs::symlink(&wrong_target, worktree.join(rel)).unwrap(); + } + let synced = sync_files(&canonical, &worktree); + assert_eq!(synced, 4); + for rel in SHARED_AGENT_FILES { + assert_eq!( + std::fs::read_link(worktree.join(rel)).unwrap(), + canonical.join(rel) + ); + } +} + +#[test] +fn sync_handles_broken_symlinks() { + let (_parent, canonical, worktree) = setup_sync_layout(); + std::fs::create_dir_all(worktree.join("agents")).unwrap(); + let broken_target = PathBuf::from("/this/does/not/exist.json"); + for rel in SHARED_AGENT_FILES { + std::os::unix::fs::symlink(&broken_target, worktree.join(rel)).unwrap(); + } + let synced = sync_files(&canonical, &worktree); + assert_eq!(synced, 4); + for rel in SHARED_AGENT_FILES { + let dst = worktree.join(rel); + assert!(dst.is_symlink()); + assert_eq!(std::fs::read_link(&dst).unwrap(), canonical.join(rel)); + // Content should be readable through the fixed symlink. + assert!(std::fs::read_to_string(&dst).is_ok()); + } +} + +#[test] +fn writes_through_symlink_reach_canonical() { + let (_parent, canonical, worktree) = setup_sync_layout(); + sync_files(&canonical, &worktree); + + let worktree_path = worktree.join("agents/personas.json"); + let canonical_path = canonical.join("agents/personas.json"); + + // Write through the symlink using the same pattern as atomic_write_json. + let new_content = r#"[{"id":"builtin:solo","updated":true}]"#; + let resolved = std::fs::canonicalize(&worktree_path).unwrap(); + let tmp = resolved.with_extension("json.tmp"); + std::fs::write(&tmp, new_content.as_bytes()).unwrap(); + std::fs::rename(&tmp, &resolved).unwrap(); + + // The canonical file should have the new content. + assert_eq!( + std::fs::read_to_string(&canonical_path).unwrap(), + new_content + ); + // The worktree path should still be a symlink. + assert!(worktree_path.is_symlink()); + // Reading through the symlink should return the new content. + assert_eq!( + std::fs::read_to_string(&worktree_path).unwrap(), + new_content + ); +} + +#[test] +fn canonical_dev_data_dir_returns_self_for_canonical_instance() { + // 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"); + assert_eq!(canonical_dev_data_dir(¤t).unwrap(), current); + + // Also verify with a temp dir on the real filesystem. + let parent = tempfile::tempdir().unwrap(); + let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); + 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 { + 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(); + sync_files(&canonical, &worktree); + + let teams_link = worktree.join("agents/teams"); + assert!(teams_link.is_symlink()); + assert_eq!( + std::fs::read_link(&teams_link).unwrap(), + canonical.join("agents/teams") + ); + assert_eq!( + std::fs::read_to_string( + worktree.join("agents/teams/com.example.test-pack/instructions.md") + ) + .unwrap(), + "# Test pack" + ); +} + +#[test] +fn sync_migrates_teams_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 teams, .main has the real team dir. + assert!(!canonical.join("agents/teams").exists()); + assert!(main_instance + .join("agents/teams/com.example.test-pack") + .is_dir()); + + sync_files(&canonical, &worktree); + + // After sync: canonical has the team, .main is now a symlink. + assert!(canonical + .join("agents/teams/com.example.test-pack/instructions.md") + .exists()); + assert!(main_instance.join("agents/teams").is_symlink()); + assert_eq!( + std::fs::read_link(main_instance.join("agents/teams")).unwrap(), + canonical.join("agents/teams") + ); +} + +#[test] +fn sync_replaces_real_teams_dir_with_symlink() { + let (_parent, canonical, worktree) = setup_sync_layout(); + let real_teams = worktree.join("agents/teams"); + std::fs::create_dir_all(&real_teams).unwrap(); + std::fs::write(real_teams.join("stale-file.txt"), "stale").unwrap(); + + sync_files(&canonical, &worktree); + + assert!(worktree.join("agents/teams").is_symlink()); + assert_eq!( + std::fs::read_link(worktree.join("agents/teams")).unwrap(), + canonical.join("agents/teams") + ); +} + +#[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.sprout.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.sprout.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": "sprout-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.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_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] +fn migrate_packs_merge_preserves_non_empty_dir() { + // When packs/ contains symlinks that weren't moved (e.g., external tools + // recreated them), the migration should NOT delete the packs/ directory. + let parent = tempfile::tempdir().unwrap(); + let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); + let packs_dir = canonical.join("agents/packs"); + let teams_dir = canonical.join("agents/teams"); + std::fs::create_dir_all(&packs_dir).unwrap(); + std::fs::create_dir_all(&teams_dir).unwrap(); + + // Simulate an external symlink that already exists in teams/ (conflict) + let external_target = parent.path().join("external-pack"); + std::fs::create_dir_all(&external_target).unwrap(); + std::os::unix::fs::symlink(&external_target, packs_dir.join("com.ext.pack")).unwrap(); + // Same name already in teams/ — so the migration skips it + std::os::unix::fs::symlink(&external_target, teams_dir.join("com.ext.pack")).unwrap(); + + // Run the merge logic (mirrors what migrate_packs_to_teams does) + if let Ok(entries) = std::fs::read_dir(&packs_dir) { + for entry in entries.flatten() { + let dest = teams_dir.join(entry.file_name()); + if !dest.exists() { + let _ = std::fs::rename(entry.path(), &dest); + } + } + } + // This is the fix: remove_dir only succeeds on empty dirs + let _ = std::fs::remove_dir(&packs_dir); + + // packs/ should still exist because it has a remaining symlink + assert!(packs_dir.exists(), "packs/ should survive when non-empty"); + assert!(packs_dir.join("com.ext.pack").is_symlink()); +} + +#[test] +fn migrate_packs_to_teams_renames_directory() { + let parent = tempfile::tempdir().unwrap(); + let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER); + let packs_dir = canonical.join("agents/packs/com.example.test-pack"); + std::fs::create_dir_all(&packs_dir).unwrap(); + std::fs::write(packs_dir.join("plugin.json"), "{}").unwrap(); + + // No personas or agents JSON needed for directory rename + std::fs::create_dir_all(canonical.join("agents")).unwrap(); + + // Simulate calling the migration steps directly (no AppHandle needed) + let packs = canonical.join("agents/packs"); + let teams = canonical.join("agents/teams"); + std::fs::rename(&packs, &teams).unwrap(); + + assert!(!packs.exists()); + assert!(teams.join("com.example.test-pack/plugin.json").exists()); +} + +#[test] +fn migrate_packs_to_teams_rewrites_personas_json() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ + "id": "persona-1", + "display_name": "Test", + "source_pack": "com.example.my-pack", + "source_pack_persona_slug": "agent-one" + }]), + ); + + let path = dir.path().join("agents/personas.json"); + patch_json_records(&path, |obj| { + let mut changed = false; + if let Some(val) = obj.remove("source_pack") { + obj.insert("source_team".to_string(), val); + changed = true; + } + if let Some(val) = obj.remove("source_pack_persona_slug") { + obj.insert("source_team_persona_slug".to_string(), val); + changed = true; + } + changed + }); + + let records = read_personas_json(dir.path()); + assert_eq!(records[0]["source_team"], "com.example.my-pack"); + assert_eq!(records[0]["source_team_persona_slug"], "agent-one"); + assert!(records[0].get("source_pack").is_none()); + assert!(records[0].get("source_pack_persona_slug").is_none()); +} + +#[test] +fn migrate_packs_to_teams_rewrites_agents_json() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Paul", + "persona_pack_path": "/data/agents/packs/com.example.my-pack", + "persona_name_in_pack": "agent-one" + }]), + ); + + let path = dir.path().join("agents/managed-agents.json"); + patch_json_records(&path, |obj| { + let mut changed = false; + if let Some(val) = obj.remove("persona_pack_path") { + let new_val = if let Some(s) = val.as_str() { + serde_json::Value::String(s.replace("/packs/", "/teams/")) + } else { + val + }; + obj.insert("persona_team_dir".to_string(), new_val); + changed = true; + } + if let Some(val) = obj.remove("persona_name_in_pack") { + obj.insert("persona_name_in_team".to_string(), val); + changed = true; + } + changed + }); + + let records = read_agents_json(dir.path()); + assert_eq!( + records[0]["persona_team_dir"], + "/data/agents/teams/com.example.my-pack" + ); + assert_eq!(records[0]["persona_name_in_team"], "agent-one"); + assert!(records[0].get("persona_pack_path").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 { + 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(); + write_personas_json( + dir.path(), + &serde_json::json!([{ + "id": "persona-1", + "displayName": "Alice", + "provider": "goose" + }]), + ); + rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); + let records = read_personas_json(dir.path()); + assert_eq!(records[0]["runtime"], "goose"); + assert!(records[0].get("provider").is_none()); +} + +#[test] +fn rename_provider_to_runtime_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ + "id": "persona-1", + "displayName": "Alice", + "runtime": "goose" + }]), + ); + let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); + rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); + let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); + assert_eq!( + before, after, + "file should not be rewritten when already migrated" + ); +} + +#[test] +fn rename_provider_to_runtime_skips_record_without_either_key() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ + "id": "persona-1", + "displayName": "Alice" + }]), + ); + let before = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); + rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); + let after = std::fs::read_to_string(dir.path().join("agents/personas.json")).unwrap(); + assert_eq!( + before, after, + "file should not be rewritten when no provider key exists" + ); +} + +#[test] +fn rename_provider_to_runtime_preserves_existing_runtime_over_provider() { + let dir = tempfile::tempdir().unwrap(); + write_personas_json( + dir.path(), + &serde_json::json!([{ + "id": "persona-1", + "displayName": "Alice", + "provider": "old-value", + "runtime": "correct-value" + }]), + ); + rename_provider_to_runtime_in_personas(&dir.path().join("agents/personas.json")); + let records = read_personas_json(dir.path()); + assert_eq!(records[0]["runtime"], "correct-value"); + // provider key should still be there since the closure returns false when runtime exists + assert_eq!(records[0]["provider"], "old-value"); +} + +#[test] +fn reconcile_mcp_commands_clears_stale_sprout_mcp_server() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Solo", + "agent_command": "goose", + "mcp_command": "sprout-mcp-server" + }]), + ); + reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["mcp_command"], ""); +} + +#[test] +fn reconcile_mcp_commands_sets_canonical_for_sprout_agent() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Stilgar", + "agent_command": "sprout-agent", + "mcp_command": "sprout-mcp-server" + }]), + ); + reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["mcp_command"], "sprout-dev-mcp"); +} + +#[test] +fn reconcile_mcp_commands_leaves_custom_value_untouched() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!([{ + "name": "Solo", + "agent_command": "goose", + "mcp_command": "my-custom-mcp" + }]); + write_agents_json(dir.path(), &json); + let path = dir.path().join("agents/managed-agents.json"); + let before = std::fs::read_to_string(&path).unwrap(); + reconcile_mcp_commands_in_file(&path); + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); +} + +#[test] +fn reconcile_mcp_commands_leaves_unknown_runtime_untouched() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!([{ + "name": "Custom", + "agent_command": "my-custom-agent", + "mcp_command": "sprout-mcp-server" + }]); + write_agents_json(dir.path(), &json); + let path = dir.path().join("agents/managed-agents.json"); + let before = std::fs::read_to_string(&path).unwrap(); + reconcile_mcp_commands_in_file(&path); + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); +} + +#[test] +fn reconcile_mcp_commands_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([{ + "name": "Solo", + "agent_command": "goose", + "mcp_command": "sprout-mcp-server" + }]), + ); + let path = dir.path().join("agents/managed-agents.json"); + reconcile_mcp_commands_in_file(&path); + let after_first = std::fs::read_to_string(&path).unwrap(); + reconcile_mcp_commands_in_file(&path); + assert_eq!(after_first, std::fs::read_to_string(&path).unwrap()); +} + +#[test] +fn reconcile_mcp_commands_handles_mixed_agents() { + let dir = tempfile::tempdir().unwrap(); + write_agents_json( + dir.path(), + &serde_json::json!([ + {"name": "Stale Goose", "agent_command": "goose", "mcp_command": "sprout-mcp-server"}, + {"name": "Clean Goose", "agent_command": "goose", "mcp_command": ""}, + {"name": "Custom Agent", "agent_command": "goose", "mcp_command": "my-custom-mcp"}, + {"name": "Stale Sprout", "agent_command": "sprout-agent", "mcp_command": "sprout-mcp-server"} + ]), + ); + reconcile_mcp_commands_in_file(&dir.path().join("agents/managed-agents.json")); + let records = read_agents_json(dir.path()); + assert_eq!(records[0]["mcp_command"], ""); + assert_eq!(records[1]["mcp_command"], ""); + assert_eq!(records[2]["mcp_command"], "my-custom-mcp"); + assert_eq!(records[3]["mcp_command"], "sprout-dev-mcp"); +} + +#[test] +fn reconcile_mcp_commands_skips_record_without_agent_command() { + let dir = tempfile::tempdir().unwrap(); + let json = serde_json::json!([{ + "name": "No Command", + "mcp_command": "sprout-mcp-server" + }]); + write_agents_json(dir.path(), &json); + let path = dir.path().join("agents/managed-agents.json"); + let before = std::fs::read_to_string(&path).unwrap(); + reconcile_mcp_commands_in_file(&path); + assert_eq!(before, std::fs::read_to_string(&path).unwrap()); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index ff50219ca..5786a0183 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -150,6 +150,9 @@ export function AgentsView() { onEdit={teamActions.openEditDialog} onExport={teamActions.handleExportTeam} onImportFile={teamActions.handleImportFile} + onInstallFromDirectory={teamActions.handleInstallFromDirectory} + onSync={teamActions.handleSyncTeam} + onRevealInFinder={teamActions.handleRevealInFinder} onAddToChannel={teamActions.setTeamToAddToChannel} personas={personas.libraryPersonas} teams={teamActions.teams} diff --git a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx index 87b208b3c..742bf8352 100644 --- a/desktop/src/features/agents/ui/PersonaActionsMenu.tsx +++ b/desktop/src/features/agents/ui/PersonaActionsMenu.tsx @@ -72,10 +72,10 @@ export function PersonaActionsMenu({ Remove from My Agents - ) : persona.sourcePack ? ( + ) : persona.sourceTeam ? ( - Managed by pack + Managed by team ) : ( void; onAddToChannel: (team: AgentTeam) => void; onImportFile: (fileBytes: number[], fileName: string) => void; + onInstallFromDirectory: () => void; + onSync: (team: AgentTeam) => void; + onRevealInFinder: (team: AgentTeam) => void; }; export function TeamsSection({ @@ -56,6 +62,9 @@ export function TeamsSection({ onDelete, onAddToChannel, onImportFile, + onInstallFromDirectory, + onSync, + onRevealInFinder, }: TeamsSectionProps) { const { fileInputRef, @@ -93,11 +102,21 @@ export function TeamsSection({ ref={fileInputRef} type="file" /> - +
+ + +
{isLoading ? ( @@ -138,6 +157,25 @@ export function TeamsSection({

{team.name}

+ {team.isSymlink ? ( + + + + + + + +

+ Linked from {team.symlinkTarget ?? team.sourceDir} +

+
+
+ ) : null} + {team.version ? ( + + v{team.version} + + ) : null} {team.description ? ( @@ -221,6 +259,24 @@ export function TeamsSection({ Export
+ {team.sourceDir ? ( + <> + + onSync(team)} + > + + Sync from directory + + onRevealInFinder(team)} + > + + Reveal in Finder + + + ) : null} 0 && + `${result.personas_added.length} added`, + result.personas_updated.length > 0 && + `${result.personas_updated.length} updated`, + result.personas_removed.length > 0 && + `${result.personas_removed.length} removed`, + ].filter(Boolean); + const summary = + changes.length > 0 ? changes.join(", ") : "already up to date"; + actions.setActionNoticeMessage(`Synced "${team.name}": ${summary}.`); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: teamsQueryKey }), + queryClient.invalidateQueries({ queryKey: personasQueryKey }), + queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }), + ]); + } catch (err) { + actions.setActionErrorMessage( + err instanceof Error ? err.message : "Failed to sync team directory.", + ); + } + } + + function handleRevealInFinder(team: AgentTeam) { + if (!team.sourceDir) return; + void revealItemInDir(team.sourceDir); + } + async function handleEditDialogImportUpdateFile( teamId: string, fileBytes: number[], @@ -492,6 +553,9 @@ export function useTeamActions( handleTeamDeployed, handleExportTeam, handleImportFile, + handleInstallFromDirectory, + handleSyncTeam, + handleRevealInFinder, handleEditDialogImportUpdateFile, handleTeamImportComplete, handleTeamImportUpdateApply, diff --git a/desktop/src/shared/api/tauriTeams.ts b/desktop/src/shared/api/tauriTeams.ts index ddb2617d9..166078b99 100644 --- a/desktop/src/shared/api/tauriTeams.ts +++ b/desktop/src/shared/api/tauriTeams.ts @@ -11,6 +11,10 @@ type RawTeam = { description: string | null; persona_ids: string[]; is_builtin?: boolean; + source_dir?: string | null; + is_symlink?: boolean; + symlink_target?: string | null; + version?: string | null; created_at: string; updated_at: string; }; @@ -22,6 +26,10 @@ function fromRawTeam(team: RawTeam): AgentTeam { description: team.description, personaIds: team.persona_ids, isBuiltin: team.is_builtin ?? false, + sourceDir: team.source_dir ?? null, + isSymlink: team.is_symlink ?? false, + symlinkTarget: team.symlink_target ?? null, + version: team.version ?? null, createdAt: team.created_at, updatedAt: team.updated_at, }; @@ -83,3 +91,30 @@ export async function parseTeamFile( fileName, }); } + +export type SyncResult = { + personas_added: string[]; + personas_removed: string[]; + personas_updated: string[]; + metadata_changed: boolean; +}; + +export async function pickTeamDirectory(): Promise { + return invokeTauri("pick_team_directory"); +} + +export async function installTeamFromDirectory( + path: string, + symlink?: boolean, +): Promise { + return fromRawTeam( + await invokeTauri("install_team_from_directory", { + path, + symlink: symlink ?? false, + }), + ); +} + +export async function syncTeamDirectory(teamId: string): Promise { + return invokeTauri("sync_team_directory", { teamId }); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 821f86da0..3c31118e1 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -474,8 +474,8 @@ export type AgentPersona = { namePool: string[]; isBuiltIn: boolean; isActive: boolean; - /** Pack ID if this persona was imported from a persona pack. Pack personas are non-editable. */ - sourcePack?: string | null; + /** Team ID if this persona was imported from a team directory. Team personas are non-editable. */ + sourceTeam?: string | null; /** Environment variables injected for agents created from this persona. * Layered as: desktop parent env < persona envVars < agent envVars. */ envVars: Record; @@ -513,6 +513,14 @@ export type AgentTeam = { description: string | null; personaIds: string[]; isBuiltin: boolean; + /** Absolute path to the team's backing directory (if directory-backed). */ + sourceDir: string | null; + /** Whether sourceDir is a symlink to an external directory. */ + isSymlink: boolean; + /** Resolved symlink target path (for display). Only set when isSymlink is true. */ + symlinkTarget: string | null; + /** Version from the team's plugin.json manifest. */ + version: string | null; createdAt: string; updatedAt: string; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b1ad96911..b8a0c27e8 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -374,6 +374,10 @@ type RawTeam = { description: string | null; persona_ids: string[]; is_builtin: boolean; + source_dir: string | null; + is_symlink: boolean; + symlink_target: string | null; + version: string | null; created_at: string; updated_at: string; }; @@ -880,7 +884,50 @@ function resetMockPersonas() { } function resetMockTeams() { - mockTeams = []; + const now = new Date().toISOString(); + mockTeams = [ + { + id: "team-engineering-001", + name: "Engineering", + description: "Core engineering personas", + // Scout is intentionally excluded so the clean deselect flow has a + // built-in persona that is not pre-referenced by any default team. + persona_ids: ["builtin:solo", "builtin:kit"], + is_builtin: false, + source_dir: null, + is_symlink: false, + symlink_target: null, + version: null, + created_at: now, + updated_at: now, + }, + { + id: "team-research-002", + name: "Research Agents", + description: "Directory-backed research team", + persona_ids: ["builtin:solo", "builtin:kit"], + is_builtin: false, + source_dir: "/Users/dev/agents/research", + is_symlink: false, + symlink_target: null, + version: "1.2.0", + created_at: now, + updated_at: now, + }, + { + id: "team-platform-003", + name: "Platform Tools", + description: "Symlinked platform team", + persona_ids: ["builtin:kit"], + is_builtin: false, + source_dir: "/Users/dev/agents/platform", + is_symlink: true, + symlink_target: "/opt/shared-teams/platform", + version: "2.0.1", + created_at: now, + updated_at: now, + }, + ]; } function getMockProfileByPubkey(pubkey: string): RawProfile | null { @@ -4160,6 +4207,10 @@ async function handleCreateTeam(args: { description: args.input.description?.trim() || null, persona_ids: [...args.input.personaIds], is_builtin: false, + source_dir: null, + is_symlink: false, + symlink_target: null, + version: null, created_at: now, updated_at: now, }; @@ -4216,6 +4267,50 @@ async function handleExportTeamToJson(args: { id: string }): Promise { return true; } +async function handlePickTeamDirectory(): Promise { + return "/Users/dev/agents/new-team"; +} + +async function handleInstallTeamFromDirectory(args: { + path: string; + symlink: boolean; +}): Promise { + const now = new Date().toISOString(); + const team: RawTeam = { + id: crypto.randomUUID(), + name: "Installed Team", + description: null, + persona_ids: [], + is_builtin: false, + source_dir: args.path, + is_symlink: args.symlink, + symlink_target: args.symlink ? args.path : null, + version: null, + created_at: now, + updated_at: now, + }; + mockTeams.push(team); + return { ...team, persona_ids: [...team.persona_ids] }; +} + +async function handleSyncTeamDirectory(args: { teamId: string }): Promise<{ + personas_added: string[]; + personas_removed: string[]; + personas_updated: string[]; + metadata_changed: boolean; +}> { + const team = mockTeams.find((candidate) => candidate.id === args.teamId); + if (!team) { + throw new Error(`Team ${args.teamId} not found.`); + } + return { + personas_added: [], + personas_removed: [], + personas_updated: [], + metadata_changed: false, + }; +} + async function handleParseTeamFile(): Promise<{ name: string; description: string | null; @@ -5662,6 +5757,16 @@ export function maybeInstallE2eTauriMocks() { ); case "export_team_to_json": return handleExportTeamToJson(payload as { id: string }); + case "pick_team_directory": + return handlePickTeamDirectory(); + case "install_team_from_directory": + return handleInstallTeamFromDirectory( + payload as Parameters[0], + ); + case "sync_team_directory": + return handleSyncTeamDirectory( + payload as Parameters[0], + ); case "parse_team_file": return handleParseTeamFile(); case "parse_persona_files": diff --git a/desktop/tests/e2e/team-management-screenshots.spec.ts b/desktop/tests/e2e/team-management-screenshots.spec.ts new file mode 100644 index 000000000..b497c929f --- /dev/null +++ b/desktop/tests/e2e/team-management-screenshots.spec.ts @@ -0,0 +1,218 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/team-management"; + +async function waitForInvokeBridge(page: import("@playwright/test").Page) { + await page.waitForFunction( + () => { + const tauriWindow = window as Window & { + __SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown; + __TAURI_INTERNALS__?: { + invoke?: unknown; + }; + }; + + return ( + typeof tauriWindow.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ === "function" || + typeof tauriWindow.__TAURI_INTERNALS__?.invoke === "function" + ); + }, + null, + { timeout: 5_000 }, + ); +} + +async function invokeMockCommand( + page: import("@playwright/test").Page, + command: string, + payload?: Record, +): Promise { + await waitForInvokeBridge(page); + + return page.evaluate( + async ({ command: cmd, payload: pl }) => { + const tauriWindow = window as Window & { + __SPROUT_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + __TAURI_INTERNALS__?: { + invoke?: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + + const invoke = + tauriWindow.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ ?? + tauriWindow.__TAURI_INTERNALS__?.invoke; + if (!invoke) { + throw new Error("Mock invoke bridge is unavailable."); + } + + return invoke(cmd, pl); + }, + { command, payload }, + ); +} + +async function activatePersonas(page: import("@playwright/test").Page) { + for (const id of ["builtin:solo", "builtin:kit", "builtin:scout"]) { + await invokeMockCommand(page, "set_persona_active", { id, active: true }); + } +} + +async function openAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await waitForInvokeBridge(page); + await activatePersonas(page); + await page.getByTestId("open-agents-view").click(); + await expect(page.getByTestId("agents-library-teams")).toBeVisible({ + timeout: 10_000, + }); +} + +test.describe("team management screenshots", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("01 — teams section with cards", async ({ page }) => { + await installMockBridge(page); + await openAgentsView(page); + + const teamsSection = page.getByTestId("agents-library-teams"); + await expect(teamsSection).toContainText("Engineering"); + await expect(teamsSection).toContainText("Research Agents"); + await expect(teamsSection).toContainText("Platform Tools"); + + await teamsSection.screenshot({ + path: `${SHOTS}/01-teams-section.png`, + }); + }); + + test("02 — regular team context menu", async ({ page }) => { + await installMockBridge(page); + await openAgentsView(page); + + const teamsSection = page.getByTestId("agents-library-teams"); + const engineeringCard = teamsSection + .locator("[class*='card']") + .filter({ hasText: "Engineering" }) + .first(); + await engineeringCard.locator("button").last().click(); + + const deployItem = page.getByRole("menuitem", { + name: "Deploy to channel", + }); + await expect(deployItem).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Edit" })).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Duplicate" }), + ).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Export" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Delete" })).toBeVisible(); + + await deployItem.evaluate((el) => + Promise.all( + el + .closest("[data-state]") + ?.getAnimations() + .map((a) => a.finished) ?? [], + ), + ); + + await page.screenshot({ + path: `${SHOTS}/02-team-card-menu.png`, + clip: { x: 0, y: 0, width: 1280, height: 720 }, + }); + }); + + test("03 — directory-backed team with version badge", async ({ page }) => { + await installMockBridge(page); + await openAgentsView(page); + + const teamsSection = page.getByTestId("agents-library-teams"); + const versionBadge = teamsSection.locator("span", { hasText: "v1.2.0" }); + await expect(versionBadge).toBeVisible(); + + const researchCard = teamsSection + .locator("[class*='card']") + .filter({ hasText: "Research Agents" }) + .first(); + await expect(researchCard).toBeVisible(); + + await researchCard.screenshot({ + path: `${SHOTS}/03-directory-team-card.png`, + }); + }); + + test("04 — directory team context menu", async ({ page }) => { + await installMockBridge(page); + await openAgentsView(page); + + const teamsSection = page.getByTestId("agents-library-teams"); + const researchCard = teamsSection + .locator("[class*='card']") + .filter({ hasText: "Research Agents" }) + .first(); + await researchCard.locator("button").last().click(); + + const syncItem = page.getByRole("menuitem", { + name: "Sync from directory", + }); + await expect(syncItem).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Reveal in Finder" }), + ).toBeVisible(); + + await syncItem.evaluate((el) => + Promise.all( + el + .closest("[data-state]") + ?.getAnimations() + .map((a) => a.finished) ?? [], + ), + ); + + await page.screenshot({ + path: `${SHOTS}/04-directory-team-menu.png`, + clip: { x: 0, y: 0, width: 1280, height: 720 }, + }); + }); + + test("05 — symlinked team with link icon", async ({ page }) => { + await installMockBridge(page); + await openAgentsView(page); + + const teamsSection = page.getByTestId("agents-library-teams"); + const platformCard = teamsSection + .locator("[class*='card']") + .filter({ hasText: "Platform Tools" }) + .first(); + await expect(platformCard).toBeVisible(); + + const versionBadge = platformCard.locator("span", { hasText: "v2.0.1" }); + await expect(versionBadge).toBeVisible(); + + await platformCard.screenshot({ + path: `${SHOTS}/05-symlinked-team.png`, + }); + }); + + test("06 — install from directory button", async ({ page }) => { + await installMockBridge(page); + await openAgentsView(page); + + const teamsSection = page.getByTestId("agents-library-teams"); + const installButton = teamsSection.getByRole("button", { + name: "Install from directory", + }); + await expect(installButton).toBeVisible(); + + await installButton.screenshot({ + path: `${SHOTS}/06-install-from-directory.png`, + }); + }); +});