feat(desktop): add team snapshot sharing commands (#1790)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-07-13 00:43:06 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent aceb5e0456
commit 150514f665
8 changed files with 735 additions and 42 deletions
+120 -37
View File
@@ -5,8 +5,13 @@ use tauri::State;
use crate::app_state::AppState;
use crate::commands::export_util::save_bytes_with_dialog;
use crate::commands::media::{detect_and_validate_mime, sanitize_filename};
use crate::commands::personas::{
decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, PNG_MAGIC,
use crate::commands::{
personas::{
decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, PNG_MAGIC,
},
team_snapshot::{
decode_team_snapshot_from_bytes, MAX_TEAM_SNAPSHOT_JSON_BYTES, MAX_TEAM_SNAPSHOT_PNG_BYTES,
},
};
use crate::relay::{classify_request_error, relay_api_base_url_with_override, relay_error_message};
@@ -275,16 +280,35 @@ async fn fetch_blob_bytes_with_cap(
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum SnapshotFileKind {
/// `.agent.json` — plaintext JSON; accepts memory; 5 MiB cap.
Json,
AgentJson,
/// `.agent.png` — PNG with embedded metadata; no memory; 10 MiB cap.
Png,
AgentPng,
/// `.team.json` — team template; 25 MiB cap.
TeamJson,
/// `.team.png` — team template PNG; 50 MiB cap.
TeamPng,
}
impl SnapshotFileKind {
fn cap(self) -> u64 {
match self {
SnapshotFileKind::Json => MAX_SNAPSHOT_JSON_BYTES as u64,
SnapshotFileKind::Png => MAX_SNAPSHOT_PNG_BYTES as u64,
SnapshotFileKind::AgentJson => MAX_SNAPSHOT_JSON_BYTES as u64,
SnapshotFileKind::AgentPng => MAX_SNAPSHOT_PNG_BYTES as u64,
SnapshotFileKind::TeamJson => MAX_TEAM_SNAPSHOT_JSON_BYTES as u64,
SnapshotFileKind::TeamPng => MAX_TEAM_SNAPSHOT_PNG_BYTES as u64,
}
}
fn is_png(self) -> bool {
matches!(self, SnapshotFileKind::AgentPng | SnapshotFileKind::TeamPng)
}
fn label(self) -> &'static str {
match self {
SnapshotFileKind::AgentJson => ".agent.json",
SnapshotFileKind::AgentPng => ".agent.png",
SnapshotFileKind::TeamJson => ".team.json",
SnapshotFileKind::TeamPng => ".team.png",
}
}
}
@@ -296,14 +320,18 @@ impl SnapshotFileKind {
/// fails closed before any bytes reach the frontend.
fn ensure_bytes_match_kind(bytes: &[u8], kind: SnapshotFileKind) -> Result<(), String> {
let has_png_magic = bytes.len() >= 4 && bytes[..4] == PNG_MAGIC;
match kind {
SnapshotFileKind::Png if !has_png_magic => {
Err("format mismatch: filename is .agent.png but bytes are not a PNG".to_string())
}
SnapshotFileKind::Json if has_png_magic => {
Err("format mismatch: filename is .agent.json but bytes are a PNG".to_string())
}
_ => Ok(()),
if kind.is_png() && !has_png_magic {
Err(format!(
"format mismatch: filename is {} but bytes are not a PNG",
kind.label()
))
} else if !kind.is_png() && has_png_magic {
Err(format!(
"format mismatch: filename is {} but bytes are a PNG",
kind.label()
))
} else {
Ok(())
}
}
@@ -312,35 +340,60 @@ fn ensure_bytes_match_kind(bytes: &[u8], kind: SnapshotFileKind) -> Result<(), S
fn snapshot_kind_for_filename(filename: &str) -> Result<SnapshotFileKind, String> {
let lower = filename.to_ascii_lowercase();
if lower.ends_with(".agent.json") {
Ok(SnapshotFileKind::Json)
Ok(SnapshotFileKind::AgentJson)
} else if lower.ends_with(".agent.png") {
Ok(SnapshotFileKind::Png)
Ok(SnapshotFileKind::AgentPng)
} else if lower.ends_with(".team.json") {
Ok(SnapshotFileKind::TeamJson)
} else if lower.ends_with(".team.png") {
Ok(SnapshotFileKind::TeamPng)
} else {
Err(format!(
"\"{}\" is not a snapshot filename — expected .agent.json or .agent.png",
"\"{}\" is not a snapshot filename — expected .agent.json, .agent.png, .team.json, or .team.png",
filename
))
}
}
/// Fetch and validate an agent snapshot attachment in memory.
/// Reject a metadata-declared size before opening the media stream.
///
/// Keeping this as a pure helper makes the per-kind cap a testable production
/// boundary, rather than relying on a duplicated test-side comparison.
fn ensure_declared_size_within_cap(
expected_size: usize,
kind: SnapshotFileKind,
) -> Result<(), String> {
if expected_size as u64 > kind.cap() {
return Err(format!(
"declared size {} exceeds the {} MiB cap for this format",
expected_size,
kind.cap() / (1024 * 1024)
));
}
Ok(())
}
/// Fetch and validate an agent or team snapshot attachment in memory.
///
/// Input validation (before HTTP):
/// - URL must be a valid same-relay `/media/` URL.
/// - Filename must end with `.agent.json` or `.agent.png`.
/// - Filename must end case-insensitively with `.agent.json`, `.agent.png`,
/// `.team.json`, or `.team.png`.
/// - `expected_sha256` and `expected_size` must be non-empty strings.
///
/// During fetch:
/// - Enforces a format-specific cap (5 MiB JSON, 10 MiB PNG) via
/// Content-Length header and streamed byte count.
/// During fetch, `SnapshotFileKind::cap()` enforces the kind-specific cap via
/// Content-Length and streamed byte count: 5 MiB JSON / 10 MiB PNG for agents,
/// or 25 MiB JSON / 50 MiB PNG for teams.
///
/// Post-fetch validation (all must pass; returns an error on first failure):
/// 1. Byte length equals `expected_size`.
/// 2. SHA-256 hex of bytes equals `expected_sha256` (lowercase).
/// 3. `decode_snapshot_from_bytes` succeeds — bytes are a well-formed snapshot.
/// 3. The byte magic matches the filename-selected kind.
/// 4. Agent kinds pass `decode_snapshot_from_bytes`; team kinds pass
/// `decode_team_snapshot_from_bytes`.
///
/// Returns `tauri::ipc::Response` so bytes cross IPC as a raw buffer rather
/// than a JSON number array (which would be ~3× the size at the 510 MiB cap).
/// than a JSON number array (which would be ~3× the size at the applicable cap).
#[tauri::command]
pub async fn fetch_snapshot_bytes(
url: String,
@@ -369,13 +422,7 @@ pub async fn fetch_snapshot_bytes(
if expected_size == 0 {
return Err("missing or zero expected size (imeta size field)".to_string());
}
if expected_size as u64 > cap {
return Err(format!(
"declared size {} exceeds the {} MiB cap for this format",
expected_size,
cap / (1024 * 1024)
));
}
ensure_declared_size_within_cap(expected_size, kind)?;
// ── Bounded fetch ─────────────────────────────────────────────────────
let bytes = fetch_blob_bytes_with_cap(&url, &state, cap).await?;
@@ -401,10 +448,19 @@ pub async fn fetch_snapshot_bytes(
// JSON) and .agent.json delivering PNG bytes.
ensure_bytes_match_kind(&bytes, kind)?;
// 4. Bytes must parse as a valid agent snapshot. This rejects malformed
// payloads, memory-bearing PNGs, JSON with inconsistent memory fields,
// and any format/extension mismatch not caught by magic-byte check.
decode_snapshot_from_bytes(&bytes).map_err(|e| format!("invalid snapshot: {e}"))?;
// 4. Bytes must parse as the snapshot type selected by the filename.
// Team parsing rejects retired flat JSON and persona-pack ZIP inputs
// before anything reaches the frontend.
match kind {
SnapshotFileKind::AgentJson | SnapshotFileKind::AgentPng => {
decode_snapshot_from_bytes(&bytes)
.map_err(|e| format!("invalid agent snapshot: {e}"))?;
}
SnapshotFileKind::TeamJson | SnapshotFileKind::TeamPng => {
decode_team_snapshot_from_bytes(&bytes)
.map_err(|e| format!("invalid team snapshot: {e}"))?;
}
}
Ok(tauri::ipc::Response::new(bytes))
}
@@ -416,14 +472,14 @@ mod tests {
#[test]
fn snapshot_kind_json_returns_json_kind_and_correct_cap() {
let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap();
assert_eq!(kind, SnapshotFileKind::Json);
assert_eq!(kind, SnapshotFileKind::AgentJson);
assert_eq!(kind.cap(), MAX_SNAPSHOT_JSON_BYTES as u64);
}
#[test]
fn snapshot_kind_png_returns_png_kind_and_correct_cap() {
let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap();
assert_eq!(kind, SnapshotFileKind::Png);
assert_eq!(kind, SnapshotFileKind::AgentPng);
assert_eq!(kind.cap(), MAX_SNAPSHOT_PNG_BYTES as u64);
}
@@ -449,6 +505,33 @@ mod tests {
assert!(snapshot_kind_for_filename("agentjson").is_err());
}
#[test]
fn snapshot_kind_team_extensions_are_case_insensitive_and_scale_caps() {
let json = snapshot_kind_for_filename("review.TEAM.JSON").unwrap();
let png = snapshot_kind_for_filename("review.TEAM.PNG").unwrap();
assert_eq!(json, SnapshotFileKind::TeamJson);
assert_eq!(png, SnapshotFileKind::TeamPng);
assert_eq!(json.cap(), 25 * 1024 * 1024);
assert_eq!(png.cap(), 50 * 1024 * 1024);
}
#[test]
fn fetch_boundary_team_png_filename_with_json_bytes_rejected() {
let bytes = br#"{"format":"buzz-team-snapshot","version":1}"#;
let kind = snapshot_kind_for_filename("review.team.png").unwrap();
let error = ensure_bytes_match_kind(bytes, kind).unwrap_err();
assert!(error.contains(".team.png") && error.contains("not a PNG"));
}
#[test]
fn fetch_boundary_team_declared_size_over_cap_rejected() {
let kind = snapshot_kind_for_filename("review.team.json").unwrap();
assert!(ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES, kind).is_ok());
let error =
ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES + 1, kind).unwrap_err();
assert!(error.contains("25 MiB"));
}
// ── Focused boundary tests: format mismatch and consistency ──────────────
//
// These tests exercise the guard logic that fetch_snapshot_bytes applies
+2
View File
@@ -38,6 +38,7 @@ mod project_terminal;
mod relay_members;
mod relay_reconnect;
mod social;
mod team_snapshot;
mod teams;
mod updater;
mod window_vibrancy;
@@ -80,6 +81,7 @@ pub use project_terminal::*;
pub use relay_members::*;
pub use relay_reconnect::*;
pub use social::*;
pub use team_snapshot::*;
pub use teams::*;
pub use updater::*;
pub use window_vibrancy::*;
@@ -31,7 +31,7 @@ fn trim_optional(value: Option<String>) -> Option<String> {
}
mod pending;
use pending::retain_persona_pending;
pub(in crate::commands) use pending::retain_persona_pending;
pub(super) use pending::tombstone_persona_pending;
#[tauri::command]
@@ -973,11 +973,11 @@ pub async fn set_persona_active(
}
pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47];
mod snapshot;
pub use snapshot::encode_agent_snapshot_for_send;
pub use snapshot::export_agent_snapshot;
pub(crate) use snapshot::import::{
decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES,
decode_snapshot_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES,
MAX_SNAPSHOT_PNG_BYTES,
};
pub use snapshot::{confirm_agent_snapshot_import, preview_agent_snapshot_import};
@@ -23,7 +23,7 @@ use crate::managed_agents::AgentDefinition;
/// does not retain, so the local-only `is_active` toggle never republishes, and
/// a byte-identical user-save republish is harmlessly NIP-33-replaced). The
/// guard is intentionally omitted.
pub(in crate::commands::personas) fn retain_persona_pending(
pub(in crate::commands) fn retain_persona_pending(
app: &AppHandle,
state: &AppState,
persona: &AgentDefinition,
@@ -0,0 +1,406 @@
//! Tauri commands for exporting and importing `buzz-team-snapshot v1` files.
//!
//! Team snapshots are definition-only templates: importing creates key-less
//! agent definitions and one team record. It never mints agent keys, auth tags,
//! managed-agent instances, or restores member memory.
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::{
app_state::AppState,
commands::{export_util::save_bytes_with_dialog, personas::resolve_snapshot_import_behavior},
managed_agents::team_snapshot::{
build_team_snapshot, decode_team_snapshot_json, decode_team_snapshot_png,
encode_team_snapshot_json, encode_team_snapshot_png, TeamSnapshot,
},
managed_agents::{
agent_snapshot::{build_snapshot, AgentSnapshot, MemoryLevel},
load_personas, load_teams, save_personas, save_teams, AgentDefinition, TeamRecord,
},
util::now_iso,
};
/// Team snapshots have a combined 25 MiB JSON / 50 MiB PNG payload cap.
/// Every member is validated before any persistent write.
pub(crate) const MAX_TEAM_SNAPSHOT_JSON_BYTES: usize = 25 * 1024 * 1024;
pub(crate) const MAX_TEAM_SNAPSHOT_PNG_BYTES: usize = 50 * 1024 * 1024;
const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47];
const ZIP_MAGIC_PREFIX: [u8; 2] = [0x50, 0x4b];
const LEGACY_TEAM_ERROR: &str =
"Legacy team files are no longer supported. Export a buzz-team-snapshot v1 .team.json or .team.png instead.";
/// Decode a canonical team snapshot, rejecting retired flat team JSON and
/// persona-pack ZIP files with a migration-oriented error.
pub(crate) fn decode_team_snapshot_from_bytes(file_bytes: &[u8]) -> Result<TeamSnapshot, String> {
if file_bytes.starts_with(&PNG_MAGIC) {
if file_bytes.len() > MAX_TEAM_SNAPSHOT_PNG_BYTES {
return Err(format!(
"Team snapshot file is too large ({} MiB). PNG snapshots must be under 50 MiB.",
file_bytes.len() / (1024 * 1024)
));
}
return decode_team_snapshot_png(file_bytes);
}
if file_bytes.len() > MAX_TEAM_SNAPSHOT_JSON_BYTES {
return Err(format!(
"Team snapshot file is too large ({} MiB). JSON snapshots must be under 25 MiB.",
file_bytes.len() / (1024 * 1024)
));
}
// Detect the retired schema before attempting canonical deserialization so
// old `.team.json` attachments never look like malformed new snapshots.
let value: serde_json::Value = match serde_json::from_slice(file_bytes) {
Ok(value) => value,
Err(_) if file_bytes.starts_with(&ZIP_MAGIC_PREFIX) => {
return Err(LEGACY_TEAM_ERROR.to_string());
}
Err(error) => return Err(format!("Invalid team snapshot JSON: {error}")),
};
if value.get("format").and_then(serde_json::Value::as_str)
!= Some(crate::managed_agents::team_snapshot::FORMAT_DISCRIMINATOR)
&& value.get("version").and_then(serde_json::Value::as_u64) == Some(1)
&& value.get("type").and_then(serde_json::Value::as_str) == Some("team")
{
return Err(LEGACY_TEAM_ERROR.to_string());
}
decode_team_snapshot_json(file_bytes)
}
fn parse_format_is_png(format: &str) -> Result<bool, String> {
match format {
"json" | "" => Ok(false),
"png" => Ok(true),
other => Err(format!(
"Invalid format: {other:?} (expected 'json' or 'png')"
)),
}
}
fn effective_avatar(member: &AgentSnapshot) -> Option<String> {
member
.profile
.avatar_data_url
.clone()
.or_else(|| member.profile.avatar_url.clone())
}
/// Build a definition from a team member snapshot without consuming its memory.
/// The ignored `memory` field is deliberate: definition-only imports have no
/// owner-to-agent key material or live instance to which memory could belong.
fn definition_from_snapshot(
member: &AgentSnapshot,
keep_allowlist: bool,
now: &str,
) -> Result<AgentDefinition, String> {
let behavior = resolve_snapshot_import_behavior(
member.definition.respond_to.as_deref(),
&member.definition.respond_to_allowlist,
member.definition.parallelism,
keep_allowlist,
)?;
let respond_to = (behavior.respond_to != crate::managed_agents::RespondTo::default())
.then(|| behavior.respond_to.as_str().to_string());
Ok(AgentDefinition {
id: Uuid::new_v4().to_string(),
display_name: member.profile.display_name.trim().to_string(),
avatar_url: effective_avatar(member),
system_prompt: member.definition.system_prompt.clone().unwrap_or_default(),
runtime: member.definition.runtime.clone(),
model: member.definition.model.clone(),
provider: member.definition.provider.clone(),
name_pool: member.definition.name_pool.clone(),
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: Default::default(),
respond_to,
respond_to_allowlist: behavior.respond_to_allowlist,
parallelism: behavior.parallelism,
created_at: now.to_string(),
updated_at: now.to_string(),
})
}
fn build_import_definitions(
snapshot: &TeamSnapshot,
keep_allowlist: bool,
now: &str,
) -> Result<Vec<AgentDefinition>, String> {
snapshot
.members
.iter()
.map(|member| definition_from_snapshot(member, keep_allowlist, now))
.collect()
}
/// Assemble the one new team record that references freshly built definitions.
/// Keeping this pure lets tests verify the definition-only import shape without
/// creating an `AppHandle` or touching the on-disk stores.
fn build_import_team(
snapshot: &TeamSnapshot,
persona_ids: Vec<String>,
now: &str,
) -> Result<TeamRecord, String> {
let name = snapshot.team.name.trim();
if name.is_empty() {
return Err("Team snapshot name is empty.".to_string());
}
Ok(TeamRecord {
id: Uuid::new_v4().to_string(),
name: name.to_string(),
description: snapshot.team.description.clone(),
persona_ids,
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: now.to_string(),
updated_at: now.to_string(),
})
}
fn member_preview(member: &AgentSnapshot) -> TeamSnapshotMemberPreview {
TeamSnapshotMemberPreview {
display_name: member.profile.display_name.clone(),
system_prompt: member.definition.system_prompt.clone(),
avatar_url: effective_avatar(member),
has_source_allowlist: !member.definition.respond_to_allowlist.is_empty(),
source_allowlist_count: member.definition.respond_to_allowlist.len(),
}
}
/// Preview metadata for one definition that will be imported with a team.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamSnapshotMemberPreview {
pub display_name: String,
pub system_prompt: Option<String>,
pub avatar_url: Option<String>,
pub has_source_allowlist: bool,
pub source_allowlist_count: usize,
}
/// Materialized team snapshot preview. No write happens before confirmation.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamSnapshotImportPreview {
pub name: String,
pub description: Option<String>,
pub members: Vec<TeamSnapshotMemberPreview>,
pub has_source_allowlist: bool,
}
/// Confirmation input for a definition-only team snapshot import.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamSnapshotImportConfirm {
pub file_bytes: Vec<u8>,
/// Applied uniformly to every member in v1.
pub keep_allowlist: bool,
}
/// Result of a definition-only team snapshot import.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TeamSnapshotImportResult {
pub team: TeamRecord,
pub persona_ids: Vec<String>,
}
/// In-memory bytes for the native team sharing flow.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EncodedTeamSnapshotPayload {
pub file_bytes: Vec<u8>,
pub file_name: String,
}
fn build_team_export_snapshot(
team: &TeamRecord,
personas: &[AgentDefinition],
) -> Result<TeamSnapshot, String> {
let members = team
.persona_ids
.iter()
.map(|id| {
let persona = personas
.iter()
.find(|persona| persona.id == *id)
.ok_or_else(|| {
format!("team {} references missing agent definition {id}", team.id)
})?;
Ok(build_snapshot(
&persona.clone().into_agent_record(),
MemoryLevel::None,
Vec::new(),
None,
))
})
.collect::<Result<Vec<_>, String>>()?;
Ok(build_team_snapshot(team, members))
}
async fn materialize_team_snapshot_bytes(
id: String,
is_png: bool,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<EncodedTeamSnapshotPayload, String> {
let (team, personas) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let team = load_teams(&app)?
.into_iter()
.find(|team| team.id == id)
.ok_or_else(|| format!("team {id} not found"))?;
(team, load_personas(&app)?)
};
let snapshot = build_team_export_snapshot(&team, &personas)?;
let slug = crate::util::slugify(&team.name, "team", 50);
let (file_bytes, file_name) = if is_png {
let bytes = encode_team_snapshot_png(&snapshot)
.map_err(|e| format!("Failed to encode .team.png: {e}"))?;
if bytes.len() > MAX_TEAM_SNAPSHOT_PNG_BYTES {
return Err(
"Team snapshot exceeds the 50 MiB size limit for .team.png files.".to_string(),
);
}
(bytes, format!("{slug}.team.png"))
} else {
let bytes = encode_team_snapshot_json(&snapshot)
.map_err(|e| format!("Failed to encode .team.json: {e}"))?;
if bytes.len() > MAX_TEAM_SNAPSHOT_JSON_BYTES {
return Err(
"Team snapshot exceeds the 25 MiB size limit for .team.json files.".to_string(),
);
}
(bytes, format!("{slug}.team.json"))
};
Ok(EncodedTeamSnapshotPayload {
file_bytes,
file_name,
})
}
/// Export a team template with each member's memory explicitly set to `none`.
#[tauri::command]
pub async fn export_team_snapshot(
id: String,
format: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
let is_png = parse_format_is_png(&format)?;
let payload = materialize_team_snapshot_bytes(id, is_png, app.clone(), state).await?;
if is_png {
save_bytes_with_dialog(
&app,
&payload.file_name,
"PNG image",
&["png"],
&payload.file_bytes,
)
.await
} else {
save_bytes_with_dialog(
&app,
&payload.file_name,
"Team snapshot",
&["json"],
&payload.file_bytes,
)
.await
}
}
/// Encode a team template for the native send flow without opening a dialog.
#[tauri::command]
pub async fn encode_team_snapshot_for_send(
id: String,
format: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<EncodedTeamSnapshotPayload, String> {
materialize_team_snapshot_bytes(id, parse_format_is_png(&format)?, app, state).await
}
/// Decode a team snapshot into a confirmation preview without writing anything.
#[tauri::command]
pub async fn preview_team_snapshot_import(
file_bytes: Vec<u8>,
_file_name: String,
) -> Result<TeamSnapshotImportPreview, String> {
tokio::task::spawn_blocking(move || {
let snapshot = decode_team_snapshot_from_bytes(&file_bytes)?;
let members: Vec<_> = snapshot.members.iter().map(member_preview).collect();
Ok(TeamSnapshotImportPreview {
name: snapshot.team.name,
description: snapshot.team.description,
has_source_allowlist: members.iter().any(|member| member.has_source_allowlist),
members,
})
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
/// Import a team snapshot as key-less agent definitions plus one team record.
/// No keypair, NIP-OA auth tag, managed-agent instance, or memory entry is
/// created; member memory is intentionally inert template data.
#[tauri::command]
pub async fn confirm_team_snapshot_import(
input: TeamSnapshotImportConfirm,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<TeamSnapshotImportResult, String> {
let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?;
let now = now_iso();
// Resolve every member before locking or writing so an invalid allowlist
// cannot leave a partially imported team behind.
let definitions = build_import_definitions(&snapshot, input.keep_allowlist, &now)?;
let persona_ids: Vec<String> = definitions
.iter()
.map(|definition| definition.id.clone())
.collect();
let imported_team = build_import_team(&snapshot, persona_ids.clone(), &now)?;
let team = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut personas = load_personas(&app)?;
personas.extend(definitions.iter().cloned());
save_personas(&app, &personas)?;
for definition in &definitions {
crate::commands::personas::retain_persona_pending(&app, &state, definition);
}
let mut teams = load_teams(&app)?;
teams.push(imported_team.clone());
let team = imported_team;
save_teams(&app, &teams)?;
crate::commands::teams::retain_team_pending(&app, &state, &team);
crate::managed_agents::try_regenerate_nest(&app);
let _ = app.emit("agents-data-changed", ());
team
};
Ok(TeamSnapshotImportResult { team, persona_ids })
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,198 @@
use super::*;
use crate::managed_agents::{
agent_snapshot::{
AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotMemoryEntry,
AgentSnapshotProfile,
},
team_snapshot::{TeamSnapshotMeta, FORMAT_DISCRIMINATOR, FORMAT_VERSION},
};
fn member(name: &str) -> AgentSnapshot {
AgentSnapshot {
format: crate::managed_agents::agent_snapshot::FORMAT_DISCRIMINATOR.to_string(),
version: crate::managed_agents::agent_snapshot::FORMAT_VERSION,
definition: AgentSnapshotDefinition {
name: name.to_string(),
system_prompt: Some(format!("{name} prompt")),
runtime: Some("goose".to_string()),
model: None,
provider: None,
parallelism: Some(2),
respond_to: Some("allowlist".to_string()),
respond_to_allowlist: vec!["ab".repeat(32)],
name_pool: vec![],
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
},
profile: AgentSnapshotProfile {
display_name: name.to_string(),
about: None,
avatar_data_url: None,
avatar_url: Some(format!("https://example.test/{name}.png")),
},
memory: AgentSnapshotMemory {
level: MemoryLevel::None,
entries: vec![],
},
}
}
fn snapshot(members: Vec<AgentSnapshot>) -> TeamSnapshot {
TeamSnapshot {
format: FORMAT_DISCRIMINATOR.to_string(),
version: FORMAT_VERSION,
team: TeamSnapshotMeta {
name: "Review Team".to_string(),
description: Some("Reviews changes".to_string()),
},
members,
}
}
#[test]
fn team_export_round_trip_preserves_team_and_excludes_member_memory() {
let definitions = vec![
AgentDefinition {
id: "alice".to_string(),
display_name: "Alice".to_string(),
avatar_url: None,
system_prompt: "Alice prompt".to_string(),
runtime: Some("goose".to_string()),
model: None,
provider: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: Default::default(),
respond_to: None,
respond_to_allowlist: vec![],
parallelism: None,
created_at: "now".to_string(),
updated_at: "now".to_string(),
},
AgentDefinition {
id: "bob".to_string(),
display_name: "Bob".to_string(),
avatar_url: None,
system_prompt: "Bob prompt".to_string(),
runtime: Some("goose".to_string()),
model: None,
provider: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: Default::default(),
respond_to: None,
respond_to_allowlist: vec![],
parallelism: None,
created_at: "now".to_string(),
updated_at: "now".to_string(),
},
];
let team = TeamRecord {
id: "review".to_string(),
name: "Review Team".to_string(),
description: Some("Reviews changes".to_string()),
persona_ids: vec!["alice".to_string(), "bob".to_string()],
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: "now".to_string(),
updated_at: "now".to_string(),
};
let bytes =
encode_team_snapshot_json(&build_team_export_snapshot(&team, &definitions).unwrap())
.unwrap();
let decoded = decode_team_snapshot_from_bytes(&bytes).unwrap();
assert_eq!(decoded.team.name, "Review Team");
assert_eq!(decoded.team.description.as_deref(), Some("Reviews changes"));
assert_eq!(decoded.members.len(), 2);
assert!(decoded.members.iter().all(|member| {
member.memory.level == MemoryLevel::None && member.memory.entries.is_empty()
}));
}
#[test]
fn team_import_creates_definitions_without_instances_or_memory() {
let mut memory_bearing = member("Alice");
memory_bearing.memory = AgentSnapshotMemory {
level: MemoryLevel::Everything,
entries: vec![AgentSnapshotMemoryEntry {
slug: "core".to_string(),
body: "must remain inert".to_string(),
}],
};
let decoded = decode_team_snapshot_from_bytes(
&encode_team_snapshot_json(&snapshot(vec![memory_bearing, member("Bob")])).unwrap(),
)
.unwrap();
let definitions = build_import_definitions(&decoded, false, "now").unwrap();
let team = build_import_team(
&decoded,
definitions
.iter()
.map(|definition| definition.id.clone())
.collect(),
"now",
)
.unwrap();
assert_eq!(definitions.len(), 2);
assert_eq!(team.persona_ids.len(), 2);
assert_eq!(
team.persona_ids,
definitions
.iter()
.map(|definition| definition.id.clone())
.collect::<Vec<_>>()
);
assert!(definitions.iter().all(|definition| {
definition.id.len() == 36
&& definition.source_team.is_none()
&& definition.env_vars.is_empty()
&& definition.respond_to_allowlist.is_empty()
}));
assert_eq!(definitions[0].system_prompt, "Alice prompt");
// `AgentDefinition` has no key/auth/memory fields: the exact import plan
// creates N definitions + one TeamRecord, never a managed instance/key.
}
#[test]
fn team_import_keeps_or_clears_every_member_allowlist_with_one_toggle() {
let source = snapshot(vec![member("Alice"), member("Bob")]);
let kept = build_import_definitions(&source, true, "now").unwrap();
let cleared = build_import_definitions(&source, false, "now").unwrap();
assert!(kept.iter().all(|definition| {
definition.respond_to.as_deref() == Some("allowlist")
&& definition.respond_to_allowlist == vec!["ab".repeat(32)]
}));
assert!(cleared.iter().all(|definition| {
definition.respond_to.is_none() && definition.respond_to_allowlist.is_empty()
}));
}
#[test]
fn legacy_flat_team_and_pack_zip_return_actionable_error() {
let old_flat = br#"{"version":1,"type":"team","name":"Old"}"#;
for bytes in [old_flat.as_slice(), b"PK\x05\x06empty-pack".as_slice()] {
let error = decode_team_snapshot_from_bytes(bytes).unwrap_err();
assert_eq!(error, LEGACY_TEAM_ERROR);
}
}
#[test]
fn canonical_team_json_is_accepted_without_extension_case_policy() {
let bytes = encode_team_snapshot_json(&snapshot(vec![member("Alice")])).unwrap();
// Preview/confirm intentionally decode content rather than file names, so
// canonical lowercase and uppercase extensions reach this same safe path.
assert!(decode_team_snapshot_from_bytes(&bytes).is_ok());
}
+1 -1
View File
@@ -40,7 +40,7 @@ fn trim_optional(value: Option<String>) -> Option<String> {
/// Unlike `retain_managed_agent_pending`, this has no projection-equality
/// short-circuit: teams have no start/stop runtime churn, so a republish only
/// happens on an actual user edit. The guard is intentionally omitted.
fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) {
pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) {
use crate::managed_agents::{
managed_agents_base_dir,
persona_events::monotonic_created_at,
+4
View File
@@ -810,6 +810,10 @@ pub fn run() {
preview_agent_snapshot_import,
confirm_agent_snapshot_import,
encode_agent_snapshot_for_send,
export_team_snapshot,
encode_team_snapshot_for_send,
preview_team_snapshot_import,
confirm_team_snapshot_import,
get_channel_workflows,
get_channels_workflows,
get_workflow,