mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
refactor(desktop): consolidate packs into teams (#852)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -60,3 +60,6 @@ identity.key
|
||||
|
||||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
# mesh-llm build cache
|
||||
.cache/
|
||||
|
||||
29
AGENTS.md
29
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/<username>`.
|
||||
Re-runs overwrite the image blobs on the `agent-screenshots/<username>`
|
||||
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 <pr> --repo block/sprout --json comments \
|
||||
--jq '.comments[] | select(.body | test("pr-<pr>--")) | {id, url}'
|
||||
gh api -X DELETE repos/block/sprout/issues/comments/<stale-comment-id>
|
||||
```
|
||||
|
||||
Branch cleanup when fully done: `git push origin --delete agent-screenshots/<username>`.
|
||||
|
||||
### 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/<dir>/*.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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Vec<PersonaRecord>, 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<Vec<PackSummary>, String> {
|
||||
list_installed_packs(&app)
|
||||
}
|
||||
|
||||
@@ -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<bool>,
|
||||
) -> Result<TeamRecord, String> {
|
||||
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<SyncResult, String> {
|
||||
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<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let path = app.dialog().file().blocking_pick_folder();
|
||||
Ok(path.map(|p| p.to_string()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<PersonaRecord> {
|
||||
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: `<AppDataDir>/agents/packs/`
|
||||
fn packs_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
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 `<AppDataDir>/agents/packs/<pack-id>/` (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<Vec<PersonaRecord>, 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<PersonaRecord> = 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<Vec<PackSummary>, 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<Vec<PersonaRecord>, String> {
|
||||
let path = personas_store_path(app)?;
|
||||
let now = now_iso();
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -67,6 +67,10 @@ fn built_in_team_records(now: &str) -> Vec<TeamRecord> {
|
||||
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: `<AppDataDir>/agents/teams/`
|
||||
fn teams_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
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 `<data>/agents/teams/<resolved.id>/`,
|
||||
/// 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<TeamRecord, String> {
|
||||
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<PersonaRecord> = 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<String> = 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<crate::managed_agents::SyncResult, String> {
|
||||
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<String> = 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<String> = 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<ParsedTeamPreview, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "source_pack_persona_slug"
|
||||
)]
|
||||
pub source_team_persona_slug: Option<String>,
|
||||
/// 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<String>,
|
||||
#[serde(default)]
|
||||
pub provider_binary_path: Option<String>,
|
||||
/// 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<PathBuf>,
|
||||
/// Persona name within the pack.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub persona_name_in_pack: Option<String>,
|
||||
/// 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<PathBuf>,
|
||||
/// Persona name within the team.
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "persona_name_in_pack"
|
||||
)]
|
||||
pub persona_name_in_team: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub last_started_at: Option<String>,
|
||||
@@ -473,6 +489,18 @@ pub struct TeamRecord {
|
||||
pub persona_ids: Vec<String>,
|
||||
#[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<PathBuf>,
|
||||
/// 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<String>,
|
||||
/// Version from the team's `plugin.json` manifest.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -496,6 +524,24 @@ pub struct UpdateTeamRequest {
|
||||
pub persona_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of syncing a directory-backed team with its backing directory.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SyncResult {
|
||||
pub personas_added: Vec<String>,
|
||||
pub personas_removed: Vec<String>,
|
||||
pub personas_updated: Vec<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<Vec<String>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
833
desktop/src-tauri/src/migration_tests.rs
Normal file
833
desktop/src-tauri/src/migration_tests.rs
Normal file
@@ -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<serde_json::Value> {
|
||||
let content = std::fs::read_to_string(dir.join("agents/managed-agents.json")).unwrap();
|
||||
serde_json::from_str(&content).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_creates_teams_directory_symlink() {
|
||||
let (_parent, canonical, worktree) = setup_sync_layout();
|
||||
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<serde_json::Value> {
|
||||
let content = std::fs::read_to_string(dir.join("agents/personas.json")).unwrap();
|
||||
serde_json::from_str(&content).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_provider_to_runtime_migrates_field() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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());
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -72,10 +72,10 @@ export function PersonaActionsMenu({
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Remove from My Agents
|
||||
</DropdownMenuItem>
|
||||
) : persona.sourcePack ? (
|
||||
) : persona.sourceTeam ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Managed by pack
|
||||
Managed by team
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
CopyPlus,
|
||||
Download,
|
||||
Ellipsis,
|
||||
FolderOpen,
|
||||
FolderSync,
|
||||
Info,
|
||||
Link,
|
||||
Pencil,
|
||||
Rocket,
|
||||
Trash2,
|
||||
@@ -41,6 +44,9 @@ type TeamsSectionProps = {
|
||||
onDelete: (team: AgentTeam) => 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"
|
||||
/>
|
||||
<CreateNewButton
|
||||
ariaLabel="Create team"
|
||||
label="Team"
|
||||
onClick={onCreate}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={onInstallFromDirectory}
|
||||
type="button"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
Install from directory
|
||||
</button>
|
||||
<CreateNewButton
|
||||
ariaLabel="Create team"
|
||||
label="Team"
|
||||
onClick={onCreate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -138,6 +157,25 @@ export function TeamsSection({
|
||||
<p className="truncate text-sm font-semibold tracking-tight">
|
||||
{team.name}
|
||||
</p>
|
||||
{team.isSymlink ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Link className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<p>
|
||||
Linked from {team.symlinkTarget ?? team.sourceDir}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{team.version ? (
|
||||
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
v{team.version}
|
||||
</span>
|
||||
) : null}
|
||||
{team.description ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -221,6 +259,24 @@ export function TeamsSection({
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</DropdownMenuItem>
|
||||
{team.sourceDir ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={isPending}
|
||||
onClick={() => onSync(team)}
|
||||
>
|
||||
<FolderSync className="h-4 w-4" />
|
||||
Sync from directory
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onRevealInFinder(team)}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Reveal in Finder
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
@@ -15,13 +15,17 @@ import {
|
||||
type ParsedTeamPreview,
|
||||
createTeam as createTeamApi,
|
||||
exportTeamToJson,
|
||||
installTeamFromDirectory,
|
||||
parseTeamFile,
|
||||
pickTeamDirectory,
|
||||
syncTeamDirectory,
|
||||
} from "@/shared/api/tauriTeams";
|
||||
import {
|
||||
createPersona,
|
||||
deletePersona,
|
||||
updatePersona,
|
||||
} from "@/shared/api/tauriPersonas";
|
||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||
import type {
|
||||
AgentPersona,
|
||||
AgentTeam,
|
||||
@@ -207,6 +211,63 @@ export function useTeamActions(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstallFromDirectory() {
|
||||
actions.setActionNoticeMessage(null);
|
||||
actions.setActionErrorMessage(null);
|
||||
try {
|
||||
const path = await pickTeamDirectory();
|
||||
if (!path) return;
|
||||
const team = await installTeamFromDirectory(path, true);
|
||||
actions.setActionNoticeMessage(
|
||||
`Installed team "${team.name}" from directory.`,
|
||||
);
|
||||
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 install team from directory.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncTeam(team: AgentTeam) {
|
||||
actions.setActionNoticeMessage(null);
|
||||
actions.setActionErrorMessage(null);
|
||||
try {
|
||||
const result = await syncTeamDirectory(team.id);
|
||||
const changes = [
|
||||
result.personas_added.length > 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,
|
||||
|
||||
@@ -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<string | null> {
|
||||
return invokeTauri<string | null>("pick_team_directory");
|
||||
}
|
||||
|
||||
export async function installTeamFromDirectory(
|
||||
path: string,
|
||||
symlink?: boolean,
|
||||
): Promise<AgentTeam> {
|
||||
return fromRawTeam(
|
||||
await invokeTauri<RawTeam>("install_team_from_directory", {
|
||||
path,
|
||||
symlink: symlink ?? false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncTeamDirectory(teamId: string): Promise<SyncResult> {
|
||||
return invokeTauri<SyncResult>("sync_team_directory", { teamId });
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handlePickTeamDirectory(): Promise<string | null> {
|
||||
return "/Users/dev/agents/new-team";
|
||||
}
|
||||
|
||||
async function handleInstallTeamFromDirectory(args: {
|
||||
path: string;
|
||||
symlink: boolean;
|
||||
}): Promise<RawTeam> {
|
||||
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<typeof handleInstallTeamFromDirectory>[0],
|
||||
);
|
||||
case "sync_team_directory":
|
||||
return handleSyncTeamDirectory(
|
||||
payload as Parameters<typeof handleSyncTeamDirectory>[0],
|
||||
);
|
||||
case "parse_team_file":
|
||||
return handleParseTeamFile();
|
||||
case "parse_persona_files":
|
||||
|
||||
218
desktop/tests/e2e/team-management-screenshots.spec.ts
Normal file
218
desktop/tests/e2e/team-management-screenshots.spec.ts
Normal file
@@ -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<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
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<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
__TAURI_INTERNALS__?: {
|
||||
invoke?: (
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user