Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ pub use relay_members::*;
pub use relay_reconnect::*;
pub use social::*;
pub use team_snapshot::*;
pub(crate) use teams::replay_pending_team_membership;
pub use teams::*;
pub use updater::*;
pub use window_chrome::*;
Expand Down
8 changes: 5 additions & 3 deletions desktop/src-tauri/src/commands/personas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::{
app_state::AppState,
managed_agents::{
current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams,
save_managed_agents, save_personas, stop_managed_agent_process,
save_managed_agents, save_personas, source_team_exists, stop_managed_agent_process,
sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change,
validate_persona_deletion, AgentDefinition, ManagedAgentRecord,
},
Expand Down Expand Up @@ -129,12 +129,14 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> {
.iter()
.find(|record| record.id == id)
.ok_or_else(|| format!("persona {id} not found"))?;
let referenced_by_team = load_teams(&app)?.iter().any(|team| {
let teams = load_teams(&app)?;
let referenced_by_team = teams.iter().any(|team| {
team.persona_ids
.iter()
.any(|persona_id| persona_id == id.as_str())
});
validate_persona_deletion(persona, referenced_by_team)?;
let source_team_exists = source_team_exists(persona, &teams);
validate_persona_deletion(persona, referenced_by_team, source_team_exists)?;
// Capture the coordinate before the record might leave the list. Only
// reached for non-builtin, non-team personas (both rejected above),
// so every deleted persona here is one this owner published.
Expand Down
15 changes: 15 additions & 0 deletions desktop/src-tauri/src/commands/team_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ 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.";
/// Refusal for an export of a team that has no members. The importer rejects
/// such a snapshot, so the producer must not write one.
pub(crate) const EMPTY_TEAM_EXPORT_ERROR: &str =
"This team has no agents. Add at least one agent before you share or export it.";

/// Decode a canonical team snapshot, rejecting retired flat team JSON and
/// persona-pack ZIP files with a migration-oriented error.
Expand Down Expand Up @@ -263,13 +267,24 @@ struct MintedMember {
effective_avatar: Option<String>,
}

/// Builds the snapshot for an export.
///
/// A team with no members must not become a snapshot. The importer rejects such
/// an artifact, because `validate_team_snapshot` needs one member or more. Both
/// export commands come through this function, so the rejection belongs here.
/// A disabled menu item is not sufficient: the commands stay callable, and a
/// share dialog can submit after the roster becomes empty.
fn build_team_export_snapshot(
team: &TeamRecord,
personas: &[AgentDefinition],
records: &[ManagedAgentRecord],
memory_level: MemoryLevel,
memory_entries_by_persona: &std::collections::HashMap<String, Vec<AgentSnapshotMemoryEntry>>,
) -> Result<TeamSnapshot, String> {
if team.persona_ids.is_empty() {
return Err(EMPTY_TEAM_EXPORT_ERROR.to_string());
}

let members = team
.persona_ids
.iter()
Expand Down
37 changes: 37 additions & 0 deletions desktop/src-tauri/src/commands/team_snapshot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,43 @@ fn team_export_with_instance_and_memory_level_uses_supplied_entries() {
assert!(snap_no_instance.members[0].memory.entries.is_empty());
}

/// The producer must refuse a team that has no members.
///
/// The importer rejects such a snapshot, so an export of it makes a file that
/// nobody can import. `managed_agents::team_snapshot` pins that rejection in
/// `validate_rejects_zero_members`. A disabled menu item does not stop the
/// export: both export commands stay callable, and they read the team from disk
/// at the time of the call. This test holds the guard in the producer, where
/// both commands pass.
#[test]
fn empty_team_export_is_refused_before_any_bytes() {
let team = TeamRecord {
id: "empty".to_string(),
name: "Emptied Team".to_string(),
description: None,
instructions: None,
persona_ids: vec![],
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 err = build_team_export_snapshot(
&team,
&[],
&[],
MemoryLevel::None,
&std::collections::HashMap::new(),
)
.expect_err("an export of a team with no members must fail");

assert_eq!(err, EMPTY_TEAM_EXPORT_ERROR);
}

#[test]
fn team_import_definitions_are_built_for_all_members() {
let mut memory_bearing = member("Alice");
Expand Down
Loading
Loading