Skip to content
Merged
57 changes: 55 additions & 2 deletions desktop/src-tauri/src/commands/personas/inbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,14 @@ fn reconcile_inbound_persona_event_blocking(
}
KIND_TEAM => {
let mut teams = load_teams(&app)?;
apply_inbound_team(&mut teams, d_tag, team_content_from_event(&event)?);
save_teams(&app, &teams)?;
commit_inbound_team(
&mut teams,
d_tag,
team_content_from_event(&event)?,
|teams| save_teams(&app, teams),
|| load_managed_agents(&app),
|records| save_managed_agents(&app, records),
)?;
}
KIND_MANAGED_AGENT => {
let mut agents = load_managed_agents(&app)?;
Expand Down Expand Up @@ -584,6 +590,53 @@ 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).
fn commit_inbound_team(
teams: &mut Vec<TeamRecord>,
d_tag: String,
inbound: TeamEventContent,
persist_teams: impl FnOnce(&[TeamRecord]) -> Result<(), String>,
load_agents: impl FnOnce() -> Result<Vec<ManagedAgentRecord>, String>,
save_agents: impl FnOnce(&[ManagedAgentRecord]) -> Result<(), String>,
) -> Result<(), String> {
let team_id = d_tag.clone();
let previous_persona_ids = teams
.iter()
.find(|record| record.id == team_id)
.map(|record| record.persona_ids.clone())
.unwrap_or_default();
apply_inbound_team(teams, d_tag, inbound);
let current_persona_ids = teams
.iter()
.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,
);
Ok(())
}

/// Merge an inbound kind:30176 team projection into the local set.
///
/// Matches the local record whose `id` equals the event's d-tag (the d-tag IS
Expand Down
170 changes: 170 additions & 0 deletions desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,176 @@ fn inbound_team_no_match_inserts_idempotently() {
assert_eq!(teams.len(), 2, "re-receive of inserted team no-ops");
}

// ── Inbound team → membership propagation (commit_inbound_team wiring) ─────

use std::cell::RefCell;

/// A running instance of `persona_id`, optionally bound to a team.
fn team_instance(seed: char, persona_id: &str, team_id: Option<&str>) -> ManagedAgentRecord {
let mut record = local_agent();
record.pubkey = seed.to_string().repeat(64);
record.name = persona_id.to_string();
record.persona_id = Some(persona_id.to_string());
record.team_id = team_id.map(str::to_string);
record
}

/// An inbound team edit that ADDS a persona must bind that persona's unbound
/// running instances to the team — exactly like a local `update_team`. Without
/// the propagation wiring the instance stays unbound (member in roster, not in
/// behavior) until restart.
#[test]
fn inbound_team_add_binds_unbound_instance_through_wiring() {
let mut teams = vec![local_team()];
teams[0].persona_ids = vec!["p-existing".to_string()];
let existing = vec![
team_instance('a', "p-added", None),
team_instance('b', "p-existing", Some(TEAM_ID)),
];
let saved = RefCell::new(None);

commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
TeamEventContent {
name: "Team".to_string(),
description: None,
instructions: None,
persona_ids: Some(vec!["p-existing".to_string(), "p-added".to_string()]),
},
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
)
.expect("inbound add succeeds");

let saved = saved
.borrow()
.clone()
.expect("add must save the agent store");
assert_eq!(
saved[0].team_id.as_deref(),
Some(TEAM_ID),
"the added persona's unbound instance is bound to the team"
);
assert_eq!(
saved[1].team_id.as_deref(),
Some(TEAM_ID),
"an instance already on the team is untouched"
);
}

/// An inbound team edit that REMOVES a persona ("keep agents") must detach that
/// persona's instances bound to this team, so a kept instance stops drawing the
/// team's instructions at spawn.
#[test]
fn inbound_team_removal_detaches_instance_through_wiring() {
let mut teams = vec![local_team()];
teams[0].persona_ids = vec!["p-removed".to_string()];
let existing = vec![team_instance('a', "p-removed", Some(TEAM_ID))];
let saved = RefCell::new(None);

commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
TeamEventContent {
name: "Team".to_string(),
description: None,
instructions: None,
persona_ids: Some(vec![]),
},
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
)
.expect("inbound removal succeeds");

let saved = saved
.borrow()
.clone()
.expect("removal must save the agent store");
assert_eq!(
saved[0].team_id, None,
"the removed persona's instance is detached from the team"
);
}

/// An inbound edit that omits `persona_ids` (a pre-always-publish client)
/// preserves local membership, so the delta is empty and no instance is
/// re-pointed — a metadata-only inbound edit must not disturb bindings.
#[test]
fn inbound_team_omitted_roster_leaves_bindings_untouched() {
let mut teams = vec![local_team()];
teams[0].persona_ids = vec!["p-a".to_string()];
let existing = vec![team_instance('a', "p-a", None)];
let saved = RefCell::new(None);

commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
team_content_omitting_optional_fields("Renamed"),
|_| Ok(()),
|| Ok(existing.clone()),
|records| {
*saved.borrow_mut() = Some(records.to_vec());
Ok(())
},
)
.expect("inbound metadata-only edit succeeds");

assert!(
saved.borrow().is_none(),
"an empty membership delta writes nothing to the agent store"
);
}

/// 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.
#[test]
fn inbound_team_swallows_agent_store_failure() {
let mut teams = vec![local_team()];
teams[0].persona_ids = vec![];
commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
TeamEventContent {
name: "Team".to_string(),
description: None,
instructions: None,
persona_ids: Some(vec!["p-added".to_string()]),
},
|_| Ok(()),
|| Err("agent store unreadable".to_string()),
|_| Ok(()),
)
.expect("inbound reconcile swallows secondary-store failure");
}

/// A `persist_teams` error propagates — the authoritative team write failing is
/// a real reconcile failure, unlike best-effort agent IO.
#[test]
fn inbound_team_propagates_persist_teams_error() {
let mut teams = vec![local_team()];
let err = commit_inbound_team(
&mut teams,
TEAM_ID.to_string(),
team_content("Team"),
|_| Err("disk full".to_string()),
|| Ok(vec![]),
|_| Ok(()),
)
.expect_err("a failed team persist must propagate");
assert_eq!(err, "disk full");
}

// ── Tombstone (kind:5) consume ────────────────────────────────────────────

fn deletion_event(coord: &str) -> nostr::Event {
Expand Down
Loading
Loading