Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -124,6 +124,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
67 changes: 43 additions & 24 deletions desktop/src-tauri/src/commands/personas/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@ fn reconcile_inbound_persona_event_blocking<R: tauri::Runtime>(
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
// A prior create, update, or inbound team write can have reached teams.json
// before its agent-store write failed. Replay it before accepting any next
// inbound event, so an identical retained event cannot consume that intent.
crate::commands::replay_pending_team_membership(&app)?;

// Resolve inbound vs. any pending local edit before touching the store, in
// the scope the event ARRIVED on. A workspace switch since arrival leaves
Expand Down Expand Up @@ -305,9 +309,11 @@ fn reconcile_inbound_persona_event_blocking<R: tauri::Runtime>(
&mut teams,
d_tag,
team_content_from_event(&event)?,
|pending| crate::commands::teams::save_pending_team_membership(&app, pending),
|teams| save_teams(&app, teams),
|| load_managed_agents(&app),
|records| save_managed_agents(&app, records),
|| crate::commands::teams::clear_pending_team_membership(&app),
)
})?;
if outcome == InboundOutcome::Skipped {
Expand Down Expand Up @@ -754,29 +760,23 @@ fn apply_inbound_managed_agent(
false
}

/// In-memory core of the inbound `KIND_TEAM` reconcile: capture the matched
/// team's roster *before* applying the inbound projection, apply it, persist
/// teams authoritatively, then propagate the prior→current membership delta to
/// live instances best-effort — the same binding semantics the local
/// create/update commands use. Without this, a 30176 team edit from another
/// device lands on `teams.json` but never touches `ManagedAgentRecord.team_id`:
/// an added persona's running instances stay unbound (member in roster, not in
/// behavior) and a removed persona's instances keep drawing the old team's
/// instructions at spawn until restart.
///
/// A no-match insert has no prior roster, so its whole roster is the added
/// delta — symmetric with `commit_team_create`. Injected persistence keeps it
/// `AppHandle`-free so the prior-roster capture and delta direction are
/// unit-testable; a `persist_teams` error propagates, agent IO is best-effort
/// (mirrors the local command path: the authoritative team write already
/// landed, and boot repair is the designed retry for a stale binding).
/// In-memory core of the inbound `KIND_TEAM` reconcile. It stages a changed
/// roster before the team write, then commits the team and agent stores. It
/// clears the stage only after both writes succeed. This makes an agent-store
/// failure retryable when the team contains a persona shared by another team.
/// The caller advances relay retention only after this function succeeds.
#[allow(clippy::too_many_arguments)]
fn commit_inbound_team(
teams: &mut Vec<TeamRecord>,
d_tag: String,
inbound: TeamEventContent,
save_pending: impl FnOnce(
&crate::commands::teams::PendingTeamMembershipUpdate,
) -> Result<(), String>,
persist_teams: impl FnOnce(&[TeamRecord]) -> Result<(), String>,
load_agents: impl FnOnce() -> Result<Vec<ManagedAgentRecord>, String>,
save_agents: impl FnOnce(&[ManagedAgentRecord]) -> Result<(), String>,
clear_pending: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
let team_id = d_tag.clone();
let previous_persona_ids = teams
Expand All @@ -790,14 +790,33 @@ fn commit_inbound_team(
.find(|record| record.id == team_id)
.map(|record| record.persona_ids.clone())
.unwrap_or_default();
persist_teams(teams)?;
crate::commands::teams::propagate_membership_best_effort(
&team_id,
&previous_persona_ids,
&current_persona_ids,
load_agents,
save_agents,
);
let membership_changed = previous_persona_ids != current_persona_ids;
if membership_changed {
save_pending(&crate::commands::teams::PendingTeamMembershipUpdate {
team_id: team_id.clone(),
previous_persona_ids: previous_persona_ids.clone(),
current_persona_ids: current_persona_ids.clone(),
})?;
persist_teams(teams)?;
crate::commands::teams::propagate_membership(
&team_id,
&previous_persona_ids,
&current_persona_ids,
load_agents,
save_agents,
)
.map_err(|error| format!("could not update inbound team agents: {error}"))?;
clear_pending()?;
} else {
persist_teams(teams)?;
crate::commands::teams::propagate_membership_best_effort(
&team_id,
&previous_persona_ids,
&current_persona_ids,
load_agents,
save_agents,
);
}
Ok(())
}

Expand Down
71 changes: 61 additions & 10 deletions desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -597,11 +597,13 @@ fn inbound_team_add_binds_unbound_instance_through_wiring() {
persona_ids: Some(vec!["p-existing".to_string(), "p-added".to_string()]),
},
|_| Ok(()),
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
|| Ok(()),
)
.expect("inbound add succeeds");

Expand Down Expand Up @@ -641,11 +643,13 @@ fn inbound_team_removal_detaches_instance_through_wiring() {
persona_ids: Some(vec![]),
},
|_| Ok(()),
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
|| Ok(()),
)
.expect("inbound removal succeeds");

Expand Down Expand Up @@ -674,11 +678,13 @@ fn inbound_team_omitted_roster_leaves_bindings_untouched() {
TEAM_ID.to_string(),
team_content_omitting_optional_fields("Renamed"),
|_| Ok(()),
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
|| Ok(()),
)
.expect("inbound metadata-only edit succeeds");

Expand All @@ -688,15 +694,24 @@ fn inbound_team_omitted_roster_leaves_bindings_untouched() {
);
}

/// A failing agent-store write after the authoritative `save_teams` is
/// swallowed: the inbound reconcile still succeeds (boot repair is the retry),
/// so a secondary-store hiccup never aborts an inbound event whose team write
/// already landed.
/// An inbound add persists a recovery stage before the team write. A failed
/// agent write leaves that stage available to bind a shared persona on restart.
/// The error stops `commit_inbound_with_store`, so the retention head does not
/// advance until the binding becomes durable.
#[test]
fn inbound_team_swallows_agent_store_failure() {
let mut teams = vec![local_team()];
fn inbound_team_failure_keeps_a_durable_shared_persona_replay_delta() {
let pending_file = tempfile::NamedTempFile::new().expect("temporary stage");
std::fs::write(pending_file.path(), "null").expect("initialize stage");
let mut teams = vec![local_team(), {
let mut other = local_team();
other.id = "other-team".to_string();
other.persona_ids = vec!["p-added".to_string()];
other
}];
teams[0].persona_ids = vec![];
commit_inbound_team(
let agents = RefCell::new(vec![team_instance('a', "p-added", None)]);

let error = commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
TeamEventContent {
Expand All @@ -705,11 +720,45 @@ fn inbound_team_swallows_agent_store_failure() {
instructions: None,
persona_ids: Some(vec!["p-added".to_string()]),
},
|pending| {
crate::commands::teams::save_pending_team_membership_at(
pending_file.path(),
Some(pending),
)
},
|_| Ok(()),
|| Err("agent store unreadable".to_string()),
|_| Ok(()),
|| Ok(agents.borrow().clone()),
|_| Err("agent store unwritable".to_string()),
|| crate::commands::teams::save_pending_team_membership_at(pending_file.path(), None),
)
.expect("inbound reconcile swallows secondary-store failure");
.expect_err("inbound reconcile must report a lost membership binding");
assert!(
error.contains("could not update inbound team agents"),
"{error}"
);
assert_eq!(
teams[0].persona_ids,
vec!["p-added"],
"the team write landed"
);

let pending = crate::commands::teams::load_pending_team_membership_at(pending_file.path())
.expect("read staged delta")
.expect("the failed inbound add keeps its stage");
crate::commands::teams::propagate_membership(
&pending.team_id,
&pending.previous_persona_ids,
&pending.current_persona_ids,
|| Ok(agents.borrow().clone()),
|records| {
*agents.borrow_mut() = records.to_vec();
Ok(())
},
)
.expect("a restart replay binds the explicit inbound add");
crate::commands::teams::save_pending_team_membership_at(pending_file.path(), None)
.expect("clear replayed stage");
assert_eq!(agents.borrow()[0].team_id.as_deref(), Some(TEAM_ID));
}

/// A `persist_teams` error propagates — the authoritative team write failing is
Expand All @@ -721,9 +770,11 @@ fn inbound_team_propagates_persist_teams_error() {
&mut teams,
TEAM_ID.to_string(),
team_content("Team"),
|_| Ok(()),
|_| Err("disk full".to_string()),
|| Ok(vec![]),
|_| Ok(()),
|| Ok(()),
)
.expect_err("a failed team persist must propagate");
assert_eq!(err, "disk full");
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 @@ -165,12 +165,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 @@ -272,13 +276,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
39 changes: 39 additions & 0 deletions desktop/src-tauri/src/commands/team_snapshot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,45 @@ 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,
shared: false,
catalog_source: None,
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