diff --git a/crates/agent-controller/src/beta_migration.rs b/crates/agent-controller/src/beta_migration.rs new file mode 100644 index 000000000..bf16a2112 --- /dev/null +++ b/crates/agent-controller/src/beta_migration.rs @@ -0,0 +1,210 @@ +//! Teams carried over from old Buzz. Each imported agent keeps its beta team in +//! `imported.betaTeam`; there is no per-team record. The app's team step reads +//! the current catalog and fills in what is missing, so a rerun is harmless. +//! `betaText` is written once and never touched by team sync. +use crate::{config::Agent, Controller, LegacySource, Result, TeamCatalogEntry}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +pub(crate) const KEY: &str = "betaTeam"; +const NAME_LIMIT: usize = 120; +pub(crate) const FALLBACK_NAME: &str = "Team from old Buzz"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BetaTeamStatus { + Pending, + Completed, + Skipped, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct BetaTeam { + pub source_id: String, + pub team_id: String, + pub name: String, + /// `None` when old Buzz could not be read and the team was inferred. + pub existed: Option, + pub source: Option, + pub beta_text: String, + pub status: BetaTeamStatus, +} +impl BetaTeam { + pub(crate) fn read(agent: &Agent) -> Result> { + match agent.imported.get(KEY) { + None | Some(Value::Null) => Ok(None), + Some(raw) => serde_json::from_value(raw.clone()) + .map(Some) + .map_err(|_| "Invalid saved team from old Buzz".into()), + } + } + pub(crate) fn view(&self) -> BetaTeamView { + BetaTeamView { + team_id: self.team_id.clone(), + name: self.name.clone(), + status: self.status, + } + } +} +/// What the app may show about an agent's beta team. Never the text. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BetaTeamView { + pub team_id: String, + pub name: String, + pub status: BetaTeamStatus, +} + +/// The 1.0 team ID for a beta team ID. Hashing every ID, built-in ones +/// included, always yields a valid, collision-resistant 1.0 ID. +pub fn beta_team_id(source_id: &str) -> String { + format!("beta-{:x}", Sha256::digest(source_id.as_bytes())) +} +pub(crate) fn valid_source_id(source_id: &str) -> bool { + !source_id.is_empty() && source_id.len() <= 256 && !source_id.contains('\0') +} +pub(crate) fn team_name(raw: &str) -> String { + let name = raw.trim(); + if name.is_empty() { + return FALLBACK_NAME.into(); + } + // The app counts the limit in UTF-16 code units; never split a character. + let mut units = 0; + name.chars() + .take_while(|c| { + units += c.len_utf16(); + units <= NAME_LIMIT + }) + .collect() +} + +/// Pending agents of one team, read by the app's team step. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PendingBetaTeam { + pub team_id: String, + pub name: String, + /// Distinct beta texts of the members, normally one. + pub texts: Vec, + pub members: Vec, +} +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BetaTeamMember { + pub id: String, + pub pubkey: String, + pub revision: u64, +} + +/// Read-only grouping of earlier imports that have no `betaTeam` yet. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestorePreview { + pub token: String, + pub groups: Vec, +} +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreGroup { + pub team_id: String, + pub name: String, + /// Old Buzz could not be read; the grouping comes from saved agent copies. + pub inferred: bool, + /// Candidate team texts; the user picks one when there are several. + pub texts: Vec, + pub members: Vec, + #[serde(skip)] + pub(crate) source_id: String, + #[serde(skip)] + pub(crate) source: Option, +} + +impl Controller { + pub fn pending_beta_teams(&self, community: &str, owner: &str) -> Result> { + let relay = crate::config::canonical_relay(community)?; + let mut teams: BTreeMap = BTreeMap::new(); + for agent in self.store.agents()? { + let Some(beta) = BetaTeam::read(&agent)? else { + continue; + }; + if beta.status != BetaTeamStatus::Pending + || agent.relay_url != relay + || self.verify_team_member_owner(&agent.id, owner).is_err() + { + continue; + } + let team = teams + .entry(beta.team_id.clone()) + .or_insert_with(|| PendingBetaTeam { + team_id: beta.team_id.clone(), + name: beta.name.clone(), + texts: Vec::new(), + members: Vec::new(), + }); + if !team.texts.contains(&beta.beta_text) { + team.texts.push(beta.beta_text); + } + team.members.push(BetaTeamMember { + id: agent.id, + pubkey: agent.pubkey, + revision: agent.revision, + }); + } + Ok(teams.into_values().collect()) + } + pub fn restore_beta_teams_preview( + &self, + imports: &mut crate::Imports, + source: LegacySource, + app_data_parent: std::path::PathBuf, + ids: &[String], + ) -> Result { + imports.restore_preview(source, app_data_parent, ids, self.store.agents()?) + } + /// Start the migration of a whole restore group in one native write, using + /// the chosen text. Each member must still match the previewed revision; + /// afterwards every member finishes like a fresh import's pending agent. + pub fn restore_beta_team( + &mut self, + imports: &crate::Imports, + community: &str, + owner: &str, + token: &str, + team_id: &str, + text: &str, + ) -> Result<()> { + let relay = crate::config::canonical_relay(community)?; + let members = imports.restored(token, team_id, text)?; + for (id, _, _) in &members { + self.verify_team_member_owner(id, owner)?; + } + self.store.start_beta_teams(&relay, members) + } + /// One native write per agent: bind it to its team, resolve every team's + /// text on the same document, and record the outcome. `Completed` needs the + /// team readable with the agent on it; `Skipped` needs a team that no + /// longer lists it (a deleted team). Refusals leave the agent pending. + #[allow(clippy::too_many_arguments)] + pub fn finish_beta_team( + &mut self, + id: &str, + revision: u64, + outcome: BetaTeamStatus, + community: &str, + owner: &str, + heads: &BTreeMap, + texts: &BTreeMap, + ) -> Result<()> { + if texts.len() > 500 || texts.keys().any(|team| team.is_empty() || team.len() > 120) { + return Err("Invalid team instructions".into()); + } + self.verify_team_member_owner(id, owner)?; + let relay = crate::config::canonical_relay(community)?; + self.store + .finish_beta_team(id, revision, outcome, (&relay, owner), heads, texts) + } +} +#[cfg(test)] +mod tests; diff --git a/crates/agent-controller/src/beta_migration/tests.rs b/crates/agent-controller/src/beta_migration/tests.rs new file mode 100644 index 000000000..bc471675f --- /dev/null +++ b/crates/agent-controller/src/beta_migration/tests.rs @@ -0,0 +1,467 @@ +use super::*; +use crate::{Credentials, Imports, Secret, Store}; +use serde_json::json; +use std::{path::Path, sync::Arc}; + +struct NoCredentials; +impl Credentials for NoCredentials { + fn read(&self, _: &str, _: &str) -> Result> { + panic!("team migration must not read keys") + } + fn read_legacy(&self, _: LegacySource, _: &str) -> Result { + panic!("team migration must not read legacy keys") + } + fn add(&self, _: &str, _: &Secret) -> Result<()> { + panic!("team migration must not write keys") + } + fn delete(&self, _: &str, _: &str) -> Result<()> { + panic!("team migration must not delete keys") + } +} +const RELAY: &str = "wss://relay.example"; +fn owner() -> String { + let tag: Vec = + serde_json::from_str(&crate::secret::test_attestation(&"ab".repeat(32))).unwrap(); + tag[1].clone() +} +fn beta(status: &str, text: &str) -> Value { + json!({"sourceId": "crew", "teamId": beta_team_id("crew"), "name": "Crew", + "existed": true, "source": "installed", "betaText": text, "status": status}) +} +fn agent(key: &str, imported: Value) -> Agent { + let mut agent = crate::store::tests::fixture(); + agent.pubkey = key.repeat(32); + agent.id = crate::config::agent_id(&agent.pubkey, RELAY); + agent.credential_id = agent.id.clone(); + agent.auth_tag = Some(crate::secret::test_attestation(&agent.pubkey)); + agent.imported = imported; + agent +} +fn controller(root: &Path, agents: Vec) -> Controller { + let mut control = Controller::new( + Store::open(root.join("store")).unwrap(), + Arc::new(NoCredentials), + Err("No fixture runtime".into()), + root.join("ownership"), + ); + control.store.insert(agents).unwrap(); + control +} +fn heads(rosters: &[(&str, Vec<&Agent>)]) -> BTreeMap { + rosters + .iter() + .map(|(team, members)| { + let members: Vec = members.iter().map(|a| a.pubkey.clone()).collect(); + // Each distinct roster is a later head. + let entry = TeamCatalogEntry { + created_at: 1 + members.len() as u64, + event_id: format!("{:x}", Sha256::digest(format!("{team}{members:?}"))), + members, + deleted: false, + }; + (team.to_string(), entry) + }) + .collect() +} +fn tombstone(team: &str) -> BTreeMap { + let mut heads = heads(&[(team, vec![])]); + heads.get_mut(team).unwrap().deleted = true; + heads +} +fn texts(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(team, text)| (team.to_string(), text.to_string())) + .collect() +} +fn saved(control: &Controller, id: &str) -> Agent { + control + .store + .agents() + .unwrap() + .into_iter() + .find(|a| a.id == id) + .unwrap() +} +fn finish( + control: &mut Controller, + agent: &Agent, + outcome: BetaTeamStatus, + heads: &BTreeMap, + texts: &BTreeMap, +) -> Result<()> { + let revision = saved(control, &agent.id).revision; + control.finish_beta_team(&agent.id, revision, outcome, RELAY, &owner(), heads, texts) +} + +#[test] +fn beta_team_ids_are_valid_distinct_one_point_oh_ids() { + let welcome = beta_team_id("builtin-team:welcome"); + let fizz = beta_team_id("builtin-team:fizz"); + assert_ne!(welcome, fizz); + assert_eq!(fizz, beta_team_id("builtin-team:fizz")); + for id in [welcome, fizz, beta_team_id("0b9f7c1e-uuid")] { + assert_eq!(id.len(), 69); + assert!(id.starts_with("beta-")); + assert!(id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_".contains(&b))); + } + assert_eq!(team_name(" "), FALLBACK_NAME); + assert_eq!(team_name(&"é".repeat(130)).chars().count(), 120); + // A non-BMP character that would cross 120 UTF-16 units is dropped whole. + let name = format!("{}🐝", "a".repeat(119)); + assert_eq!(team_name(&name), "a".repeat(119)); + let fits = format!("{}🐝", "a".repeat(118)); + assert_eq!(team_name(&fits), fits); +} + +#[test] +fn finishing_binds_without_replacing_existing_bindings_and_delivers_once() { + let root = tempfile::tempdir().unwrap(); + let mut imported = json!({"teamInstructions": "BETA", "teamBindings": ["other"]}); + imported[KEY] = beta("pending", "BETA"); + let me = agent("ab", imported); + let mut control = controller(root.path(), vec![me.clone()]); + let team = beta_team_id("crew"); + let heads = heads(&[(&team, vec![&me]), ("other", vec![])]); + let texts = texts(&[(&team, "BETA")]); + finish(&mut control, &me, BetaTeamStatus::Completed, &heads, &texts).unwrap(); + let done = saved(&control, &me.id); + assert_eq!(done.imported["teamBindings"], json!(["other", team])); + assert_eq!(done.imported["teamInstructions"], "BETA"); + assert_eq!(done.imported[KEY]["status"], "completed"); + assert_eq!(done.revision, me.revision); + // A rerun of the team step finishing an already finished agent is a no-op. + finish(&mut control, &me, BetaTeamStatus::Completed, &heads, &texts).unwrap(); + assert_eq!(saved(&control, &me.id).imported, done.imported); + assert_eq!( + control.snapshot().unwrap().agents[0].beta_team, + Some(BetaTeamView { + team_id: team, + name: "Crew".into(), + status: BetaTeamStatus::Completed + }) + ); +} + +#[test] +fn removed_or_unreadable_member_stays_pending_without_a_write() { + let root = tempfile::tempdir().unwrap(); + let mut imported = json!({"teamInstructions": "BETA"}); + imported[KEY] = beta("pending", "BETA"); + let me = agent("ab", imported); + let mut control = controller(root.path(), vec![me.clone()]); + let team = beta_team_id("crew"); + let saved_file = root.path().join("store/agents.json"); + let before = std::fs::read(&saved_file).unwrap(); + // Removed from the roster just before finishing. + let removed = finish( + &mut control, + &me, + BetaTeamStatus::Completed, + &heads(&[(&team, vec![])]), + &texts(&[(&team, "BETA")]), + ); + assert!(removed.unwrap_err().contains("doesn't list")); + // Listed, but its text couldn't be read. + assert!(finish( + &mut control, + &me, + BetaTeamStatus::Completed, + &heads(&[(&team, vec![&me])]), + &texts(&[]), + ) + .is_err()); + // Only a tombstone is a deleted team: not a live team that lists the + // agent, a live team the agent was removed from (empty or not), or a + // team with no head at all. + let stranger = agent("ef", json!({})); + for roster in [ + heads(&[(&team, vec![&me])]), + heads(&[(&team, vec![])]), + heads(&[(&team, vec![&stranger])]), + BTreeMap::new(), + ] { + let refused = finish( + &mut control, + &me, + BetaTeamStatus::Skipped, + &roster, + &texts(&[]), + ); + assert!(refused.unwrap_err().contains("deleted team")); + } + // Stale revision. + assert!(control + .finish_beta_team( + &me.id, + me.revision + 1, + BetaTeamStatus::Completed, + RELAY, + &owner(), + &heads(&[(&team, vec![&me])]), + &texts(&[(&team, "BETA")]), + ) + .is_err()); + assert_eq!(std::fs::read(&saved_file).unwrap(), before); +} + +#[test] +fn a_cleared_team_is_not_refilled_and_a_deleted_team_is_skipped() { + let root = tempfile::tempdir().unwrap(); + let mut imported = json!({"teamInstructions": "BETA"}); + imported[KEY] = beta("pending", "BETA"); + let cleared = agent("ab", imported.clone()); + let deleted = agent("cd", imported); + let mut control = controller(root.path(), vec![cleared.clone()]); + let team = beta_team_id("crew"); + // The owner cleared the team's text: the empty head wins over beta text. + finish( + &mut control, + &cleared, + BetaTeamStatus::Completed, + &heads(&[(&team, vec![&cleared])]), + &texts(&[(&team, "")]), + ) + .unwrap(); + let done = saved(&control, &cleared.id); + assert_eq!(done.imported["teamInstructions"], ""); + assert_eq!(done.imported[KEY]["status"], "completed"); + assert_eq!(done.imported[KEY]["betaText"], "BETA"); + // The owner deleted the team: it stays deleted and beta text stops. + let other = tempfile::tempdir().unwrap(); + let mut control = controller(other.path(), vec![deleted.clone()]); + finish( + &mut control, + &deleted, + BetaTeamStatus::Skipped, + &tombstone(&team), + &texts(&[(&team, "")]), + ) + .unwrap(); + let skipped = saved(&control, &deleted.id); + // Heads saved before the deletion flag existed still read, as live heads. + let older_head: TeamCatalogEntry = + serde_json::from_value(json!({"createdAt": 1, "eventId": "e", "members": []})).unwrap(); + assert!(!older_head.deleted); + assert_eq!(skipped.imported[KEY]["status"], "skipped"); + assert_eq!(skipped.imported["teamBindings"], json!([])); + assert_eq!(skipped.imported["teamInstructions"], ""); +} + +#[test] +fn team_sync_never_changes_beta_text_and_pending_teams_group_by_owner_and_relay() { + let root = tempfile::tempdir().unwrap(); + let mut imported = json!({"teamInstructions": "BETA"}); + imported[KEY] = beta("pending", "BETA"); + let first = agent("ab", imported.clone()); + let second = agent("cd", imported.clone()); + let mut foreign = agent("ef", imported); + foreign.auth_tag = Some(json!(["auth", "12".repeat(32), "", "sig"]).to_string()); + let mut control = controller(root.path(), vec![first.clone(), second.clone(), foreign]); + let pending = control.pending_beta_teams(RELAY, &owner()).unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].texts, vec!["BETA".to_owned()]); + assert_eq!( + pending[0].members.iter().map(|m| &m.id).collect::>(), + vec![&first.id, &second.id] + ); + assert!(control + .pending_beta_teams("wss://other.example", &owner()) + .unwrap() + .is_empty()); + control + .sync_team_instructions( + RELAY, + &owner(), + &heads(&[("unrelated", vec![&first])]), + &texts(&[("unrelated", "NEW")]), + ) + .unwrap(); + let synced = saved(&control, &first.id); + assert_eq!(synced.imported["teamInstructions"], "NEW"); + assert_eq!(synced.imported[KEY]["betaText"], "BETA"); +} + +fn legacy(root: &Path, members: &[&Agent], teams: Value) { + let dir = root + .join(LegacySource::Installed.app_directory()) + .join("agents"); + std::fs::create_dir_all(&dir).unwrap(); + let records: Vec = members + .iter() + .map(|a| json!({"pubkey": a.pubkey, "name": a.name, "team_id": "crew"})) + .collect(); + std::fs::write( + dir.join("managed-agents.json"), + serde_json::to_vec(&records).unwrap(), + ) + .unwrap(); + std::fs::write(dir.join("teams.json"), serde_json::to_vec(&teams).unwrap()).unwrap(); +} +fn older(key: &str, text: &str) -> Agent { + agent( + key, + json!({"record": {"team_id": "crew"}, "teamInstructions": text}), + ) +} + +#[test] +fn restore_starts_the_whole_group_then_each_member_finishes() { + let root = tempfile::tempdir().unwrap(); + let first = older("ab", "BETA"); + let second = older("cd", "OLD COPY"); + let bee = format!("{}🐝", "a".repeat(119)); + legacy( + root.path(), + &[&first, &second], + json!([{"id": "crew", "name": bee, "instructions": " BETA "}]), + ); + let mut control = controller(root.path(), vec![first.clone(), second.clone()]); + let mut imports = Imports::default(); + let ids = [first.id.clone(), second.id.clone()]; + let preview_now = |control: &Controller, imports: &mut Imports| { + control + .restore_beta_teams_preview(imports, LegacySource::Installed, root.path().into(), &ids) + .unwrap() + }; + let saved_file = root.path().join("store/agents.json"); + // A member that changed after the preview refuses the whole group. + let stale = preview_now(&control, &mut imports); + let team = stale.groups[0].team_id.clone(); + let mut doc: Value = serde_json::from_slice(&std::fs::read(&saved_file).unwrap()).unwrap(); + for agent in doc["agents"].as_array_mut().unwrap() { + if agent["id"] == json!(second.id) { + agent["revision"] = json!(second.revision + 1); + } + } + std::fs::write(&saved_file, serde_json::to_vec(&doc).unwrap()).unwrap(); + let before = std::fs::read(&saved_file).unwrap(); + assert!(control + .restore_beta_team(&imports, RELAY, &owner(), &stale.token, &team, "BETA") + .unwrap_err() + .contains("preview the restore again")); + assert_eq!(std::fs::read(&saved_file).unwrap(), before); + + let preview = preview_now(&control, &mut imports); + let [group] = preview.groups.as_slice() else { + panic!("one group") + }; + assert!(!group.inferred); + assert_eq!(group.name, "a".repeat(119)); + assert_eq!(group.texts, vec!["BETA".to_owned()]); + assert_eq!(group.team_id, beta_team_id("crew")); + let restore = |control: &mut Controller, text: &str| { + control.restore_beta_team(&imports, RELAY, &owner(), &preview.token, &team, text) + }; + assert!(restore(&mut control, "OTHER").is_err()); + restore(&mut control, "BETA").unwrap(); + // Once started, the same restore can't initialize it again. + assert!(restore(&mut control, "BETA").is_err()); + // The owner has since changed the team text, so finishing the first + // member re-resolves the second's copy and raises its revision. + let roster = heads(&[(&team, vec![&first, &second])]); + let current = texts(&[(&team, "NEW")]); + let waiting = saved(&control, &second.id).revision; + finish( + &mut control, + &first, + BetaTeamStatus::Completed, + &roster, + ¤t, + ) + .unwrap(); + let raised = saved(&control, &second.id); + assert!(raised.revision > waiting); + assert_eq!(raised.imported[KEY]["status"], "pending"); + // The second finishes with its fresh revision (as `finish` reads it). + finish( + &mut control, + &second, + BetaTeamStatus::Completed, + &roster, + ¤t, + ) + .unwrap(); + for member in [&first, &second] { + let done = saved(&control, &member.id); + assert_eq!(done.imported[KEY]["status"], "completed"); + assert_eq!(done.imported[KEY]["betaText"], "BETA"); + assert_eq!(done.imported[KEY]["name"], "a".repeat(119)); + assert_eq!(done.imported["teamBindings"], json!([team])); + assert_eq!(done.imported["teamInstructions"], "NEW"); + } +} + +#[test] +fn restore_infers_without_old_buzz_and_never_offers_an_empty_inferred_group() { + let root = tempfile::tempdir().unwrap(); + let first = older("ab", "ONE"); + let second = older("cd", "TWO"); + let mut bound = older("ef", "FROM 1.0"); + bound.imported["teamBindings"] = json!(["other"]); + let empty = agent( + "12", + json!({"record": {"team_id": "gone"}, "teamInstructions": ""}), + ); + let control = controller( + root.path(), + vec![first.clone(), second.clone(), bound.clone(), empty.clone()], + ); + let ids = [ + first.id.clone(), + second.id.clone(), + bound.id.clone(), + empty.id.clone(), + ]; + let preview = control + .restore_beta_teams_preview( + &mut Imports::default(), + LegacySource::Installed, + root.path().into(), + &ids, + ) + .unwrap(); + let [group] = preview.groups.as_slice() else { + panic!("only the crew group") + }; + assert!(group.inferred); + assert_eq!(group.name, FALLBACK_NAME); + // Two members disagree; the user picks. A copy bound to a 1.0 team isn't beta text. + assert_eq!(group.texts, vec!["ONE".to_owned(), "TWO".to_owned()]); + assert_eq!(group.members.len(), 3); +} + +#[test] +fn restore_offers_a_real_empty_beta_team_and_skips_a_deleted_one() { + let root = tempfile::tempdir().unwrap(); + let first = older("ab", ""); + legacy( + root.path(), + &[&first], + json!([{"id": "crew", "name": "Crew"}]), + ); + let control = controller(root.path(), vec![first.clone()]); + let preview = control + .restore_beta_teams_preview( + &mut Imports::default(), + LegacySource::Installed, + root.path().into(), + std::slice::from_ref(&first.id), + ) + .unwrap(); + assert_eq!(preview.groups[0].texts, vec![String::new()]); + assert!(!preview.groups[0].inferred); + legacy(root.path(), &[&first], json!([])); + assert!(control + .restore_beta_teams_preview( + &mut Imports::default(), + LegacySource::Installed, + root.path().into(), + std::slice::from_ref(&first.id), + ) + .unwrap() + .groups + .is_empty()); +} diff --git a/crates/agent-controller/src/config.rs b/crates/agent-controller/src/config.rs index c39479ceb..40452bada 100644 --- a/crates/agent-controller/src/config.rs +++ b/crates/agent-controller/src/config.rs @@ -91,6 +91,8 @@ pub struct AgentView { pub restart_diff: Vec, pub deployed_remote: bool, pub needs_team_import: bool, + /// The team from old Buzz this agent joins; `None` for other agents. + pub beta_team: Option, pub configured: bool, } #[derive(Clone, Serialize)] @@ -392,6 +394,10 @@ impl Agent { restart_diff: Vec::new(), deployed_remote: self.deployed_remote(), needs_team_import: self.needs_team_import(), + beta_team: crate::beta_migration::BetaTeam::read(self) + .ok() + .flatten() + .map(|beta| beta.view()), } } pub fn needs_team_import(&self) -> bool { diff --git a/crates/agent-controller/src/import.rs b/crates/agent-controller/src/import.rs index ce9c7ec2e..238c09eef 100644 --- a/crates/agent-controller/src/import.rs +++ b/crates/agent-controller/src/import.rs @@ -1,5 +1,9 @@ //! Snapshot import, not a second writer of the old library. Chosen files are //! re-read before commit; key access happens only after explicit selection. +use crate::beta_migration::{ + beta_team_id, team_name, valid_source_id, BetaTeam, BetaTeamMember, BetaTeamStatus, + BetaTeamView, RestoreGroup, RestorePreview, FALLBACK_NAME, +}; use crate::config::{ agent_id, canonical_key, canonical_relay, Agent, HarnessEdit, MAX_AGENTS, MAX_BYTES, }; @@ -49,6 +53,8 @@ pub struct Candidate { pub name: String, /// The imported prompt drops a team section old Buzz baked into it. pub strips_team_instructions: bool, + /// The 1.0 team this agent joins, or `skipped` when old Buzz deleted it. + pub team: Option, } /// Reviewed text only. Never project legacy environment, commands, arguments, /// credentials, paths, owner authorization or retained source records for cloning. @@ -70,6 +76,7 @@ struct Pending { pub struct Imports { sequence: u64, pending: Option, + restore: Option, } struct Source { records: Vec, @@ -103,6 +110,163 @@ impl Imports { system_prompt: imported_prompt(string(definition, "system_prompt")).into(), }) } + /// Group earlier imports (no `betaTeam` yet) by their beta team. Old Buzz's + /// files decide when every member is still listed there; otherwise the + /// grouping is inferred from the agents' own beta copies. An inferred group + /// with no text is never offered: it can't be told apart from a deleted team. + pub(crate) fn restore_preview( + &mut self, + source_kind: LegacySource, + app_data_parent: PathBuf, + ids: &[String], + agents: Vec, + ) -> Result { + self.restore = None; + if ids.len() > MAX_AGENTS || ids.iter().collect::>().len() != ids.len() { + return Err("Select distinct agents to restore".into()); + } + let data = read_source(&app_data_parent.join(source_kind.app_directory())).ok(); + let mut grouped: BTreeMap> = BTreeMap::new(); + for id in ids { + let agent = agents + .iter() + .find(|agent| &agent.id == id) + .ok_or("Agent no longer exists")?; + let source_id = string(&agent.imported["record"], "team_id"); + if valid_source_id(source_id) && BetaTeam::read(agent)?.is_none() { + grouped.entry(source_id.into()).or_default().push(agent); + } + } + let mut groups = Vec::new(); + for (source_id, members) in grouped { + let listed = data.as_ref().filter(|data| { + members.iter().all(|agent| { + data.records.iter().any(|record| { + string(record, "pubkey") == agent.pubkey + && string(record, "team_id") == source_id + }) + }) + }); + let (name, texts, inferred) = match listed { + Some(data) => { + let Some(team) = data + .teams + .iter() + .find(|team| string(team, "id") == source_id) + else { + continue; + }; + let text = team_text(&team["instructions"])?.to_owned(); + (team_name(string(team, "name")), vec![text], false) + } + None => { + // Only an unbound copy is still beta's text; a bound one came from a 1.0 team. + let mut texts = Vec::new(); + for agent in members + .iter() + .filter(|a| a.imported.get("teamBindings").is_none()) + { + let text = team_text(&agent.imported["teamInstructions"])?; + if !text.is_empty() && !texts.iter().any(|t| t == text) { + texts.push(text.to_owned()); + } + } + if texts.is_empty() { + continue; + } + (FALLBACK_NAME.to_owned(), texts, true) + } + }; + groups.push(RestoreGroup { + team_id: beta_team_id(&source_id), + name, + inferred, + texts, + members: members + .iter() + .map(|agent| BetaTeamMember { + id: agent.id.clone(), + pubkey: agent.pubkey.clone(), + revision: agent.revision, + }) + .collect(), + source_id, + source: (!inferred).then_some(source_kind), + }); + } + self.sequence = self + .sequence + .checked_add(1) + .ok_or("Import preview exhausted")?; + let preview = RestorePreview { + token: format!("restore-{}", self.sequence), + groups, + }; + self.restore = Some(preview.clone()); + Ok(preview) + } + /// The `betaTeam` a restore choice starts for every member of one group, + /// each paired with the revision the preview showed. + pub(crate) fn restored( + &self, + token: &str, + team_id: &str, + text: &str, + ) -> Result> { + let preview = self + .restore + .as_ref() + .filter(|preview| preview.token == token) + .ok_or("Restore preview expired; preview it again")?; + let group = preview + .groups + .iter() + .find(|group| group.team_id == team_id) + .ok_or("Team was not in this restore preview")?; + if !group.texts.iter().any(|candidate| candidate == text) { + return Err("Choose one of the previewed team instructions".into()); + } + let beta = BetaTeam { + source_id: group.source_id.clone(), + team_id: group.team_id.clone(), + name: group.name.clone(), + existed: (!group.inferred).then_some(true), + source: group.source, + beta_text: text.into(), + status: BetaTeamStatus::Pending, + }; + Ok(group + .members + .iter() + .map(|member| (member.id.clone(), member.revision, beta.clone())) + .collect()) + } + /// Read-only: the team instructions one previewed candidate would bring + /// from old Buzz, so the app can check team clashes before committing. + /// Empty when the candidate has no live team there. + pub fn beta_text(&self, token: &str, id: &str) -> Result { + let pending = self + .pending + .as_ref() + .filter(|p| !token.is_empty() && p.preview.token == token) + .ok_or("Import preview expired; choose the source again")?; + let candidate = pending + .preview + .candidates + .iter() + .find(|c| c.id == id) + .ok_or("Identity was not in this preview")?; + let data = read_source(&pending.source)?; + if data.digest != pending.digest { + return Err("Source changed after preview; preview it again".into()); + } + let record = data + .records + .iter() + .find(|r| string(r, "pubkey") == candidate.pubkey) + .ok_or("Import identity disappeared")?; + team_instructions(&data, record) + } pub fn discard(&mut self) { self.pending = None; } @@ -147,6 +311,7 @@ impl Imports { imported_prompt(string(d, "system_prompt")).len() != string(d, "system_prompt").len() }), + team: beta_team(&data, record, source_kind)?.map(|beta| beta.view()), }); } self.sequence = self @@ -238,7 +403,13 @@ impl Imports { if existing.iter().any(|a| a.pubkey == candidate.pubkey) { return Err("Selected identity is already imported".into()); } - let agent = resolve(&data, record, &pending.workspace, &candidate.relay_url)?; + let agent = resolve( + &data, + record, + &pending.workspace, + &candidate.relay_url, + pending.source_kind, + )?; agent.validate()?; agents.push((agent, string(record, "private_key_nsec").to_owned())); } @@ -375,7 +546,13 @@ pub(crate) fn imported_prompt(prompt: &str) -> &str { .rfind(BAKED_TEAM_DELIMITER) .map_or(prompt, |at| &prompt[..at]) } -fn resolve(data: &Source, record: &Value, workspace: &Path, destination: &str) -> Result { +fn resolve( + data: &Source, + record: &Value, + workspace: &Path, + destination: &str, + source_kind: LegacySource, +) -> Result { let definition = source_definition(data, record)?; let fallback = |key| { let selected = string(definition, key); @@ -436,6 +613,11 @@ fn resolve(data: &Source, record: &Value, workspace: &Path, destination: &str) - if let Some(record) = retained.as_object_mut() { record.remove("private_key_nsec"); } + let mut imported = json!({ "record": retained, "definition": if std::ptr::eq(definition, record) { Value::Null } else { definition.clone() }, "global": data.global, "teamSuffixCleaned": true, "harness": custom, "teamInstructions": team_instructions(data, record)? }); + if let Some(beta) = beta_team(data, record, source_kind)? { + imported[crate::beta_migration::KEY] = + serde_json::to_value(beta).map_err(|_| "Could not encode source team")?; + } Ok(Agent { picture: None, id: id.clone(), @@ -464,10 +646,35 @@ fn resolve(data: &Source, record: &Value, workspace: &Path, destination: &str) - .get("auth_tag") .and_then(Value::as_str) .map(str::to_owned), - imported: json!({ "record": retained, "definition": if std::ptr::eq(definition, record) { Value::Null } else { definition.clone() }, "global": data.global, "teamSuffixCleaned": true, "harness": custom, "teamInstructions": team_instructions(data, record)? }), + imported, extra: BTreeMap::new(), }) } +/// The beta team this record names. A team missing from `teams.json` was +/// deleted in old Buzz: it is recorded as skipped and never created. +fn beta_team(data: &Source, record: &Value, source: LegacySource) -> Result> { + let id = string(record, "team_id"); + if id.is_empty() { + return Ok(None); + } + if !valid_source_id(id) { + return Err("Source contains an invalid team ID".into()); + } + let team = data.teams.iter().find(|team| string(team, "id") == id); + Ok(Some(BetaTeam { + source_id: id.into(), + team_id: beta_team_id(id), + name: team_name(team.map_or("", |team| string(team, "name"))), + existed: Some(team.is_some()), + source: Some(source), + beta_text: team_instructions(data, record)?, + status: if team.is_some() { + BetaTeamStatus::Pending + } else { + BetaTeamStatus::Skipped + }, + })) +} // Match old Buzz's deployment-team lookup: a deleted team contributes no section. fn team_instructions(data: &Source, record: &Value) -> Result { let id = string(record, "team_id"); diff --git a/crates/agent-controller/src/import/tests.rs b/crates/agent-controller/src/import/tests.rs index 9463eed1d..2368bf4d5 100644 --- a/crates/agent-controller/src/import/tests.rs +++ b/crates/agent-controller/src/import/tests.rs @@ -717,7 +717,14 @@ fn repair_only_adds_team_snapshot_without_keys_or_overwriting_edits() { let preview = team_preview(&mut imports, old.path(), dest.path()); let ids = [preview.candidates[0].id.clone()]; let data = read_source(&old.path().join(LegacySource::Installed.app_directory())).unwrap(); - let mut agent = resolve(&data, &data.records[1], dest.path(), "wss://relay.example").unwrap(); + let mut agent = resolve( + &data, + &data.records[1], + dest.path(), + "wss://relay.example", + LegacySource::Installed, + ) + .unwrap(); agent .imported .as_object_mut() @@ -754,7 +761,14 @@ fn repair_refuses_changed_settings_and_wrong_source_team_binding() { let keys = Memory::default(); let mut store = Store::open(dest.path().into()).unwrap(); let data = read_source(&old.path().join(LegacySource::Installed.app_directory())).unwrap(); - let mut agent = resolve(&data, &data.records[1], dest.path(), "wss://relay.example").unwrap(); + let mut agent = resolve( + &data, + &data.records[1], + dest.path(), + "wss://relay.example", + LegacySource::Installed, + ) + .unwrap(); agent .imported .as_object_mut() @@ -1163,3 +1177,87 @@ fn preview_does_not_flag_a_prompt_without_the_baked_suffix() { let preview = team_preview(&mut Imports::default(), old.path(), dest.path()); assert!(!preview.candidates[0].strips_team_instructions); } +#[test] +fn import_records_the_beta_team_once_and_skips_teams_old_buzz_deleted() { + let old = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + let teams = team_source(old.path(), json!(" team prompt ")); + let mut imports = Imports::default(); + let mut store = Store::open(dest.path().into()).unwrap(); + let preview = team_preview(&mut imports, old.path(), dest.path()); + let team = crate::beta_team_id("crew"); + assert_eq!( + preview.candidates[0].team, + Some(crate::BetaTeamView { + team_id: team.clone(), + name: crate::beta_migration::FALLBACK_NAME.into(), + status: crate::BetaTeamStatus::Pending, + }) + ); + assert!(!serde_json::to_string(&preview) + .unwrap() + .contains("team prompt")); + let ids = [preview.candidates[0].id.clone()]; + imports + .commit(&preview.token, &ids, &mut store, &Memory::default()) + .unwrap(); + let agent = &store.agents().unwrap()[0]; + assert_eq!( + agent.imported["betaTeam"], + json!({"sourceId": "crew", "teamId": team, "name": "Team from old Buzz", + "existed": true, "source": "installed", "betaText": "team prompt", "status": "pending"}) + ); + // A team missing from old Buzz's library is never created. + fs::write(&teams, b"[]").unwrap(); + let preview = team_preview(&mut imports, old.path(), dest.path()); + assert_eq!( + preview.candidates[0].team.as_ref().unwrap().status, + crate::BetaTeamStatus::Skipped + ); + // A name that would cross the app's 120 UTF-16 unit limit drops the whole + // non-BMP character. + let bee = format!("{}🐝", "a".repeat(119)); + fs::write( + &teams, + serde_json::to_vec(&json!([{"id": "crew", "name": bee}])).unwrap(), + ) + .unwrap(); + let preview = team_preview(&mut imports, old.path(), dest.path()); + assert_eq!( + preview.candidates[0].team.as_ref().unwrap().name, + "a".repeat(119) + ); +} +#[test] +fn beta_text_reads_only_a_current_preview_candidate_without_writes() { + let old = tempfile::tempdir().unwrap(); + let dest = tempfile::tempdir().unwrap(); + let teams = team_source(old.path(), json!(" team prompt ")); + let mut imports = Imports::default(); + let store = Store::open(dest.path().into()).unwrap(); + let preview = team_preview(&mut imports, old.path(), dest.path()); + let id = &preview.candidates[0].id; + assert_eq!( + imports.beta_text(&preview.token, id).unwrap(), + "team prompt" + ); + // Reading commits nothing and leaves the preview usable. + assert!(store.agents().unwrap().is_empty()); + assert_eq!( + imports.beta_text(&preview.token, id).unwrap(), + "team prompt" + ); + let refused = |result: crate::Result| result.unwrap_err(); + assert!(refused(imports.beta_text("wrong", id)).contains("expired")); + assert!(refused(imports.beta_text("", id)).contains("expired")); + assert!(refused(imports.beta_text(&preview.token, "unknown")).contains("not in this preview")); + // A source change after preview is refused rather than read. + fs::write(&teams, b"[]").unwrap(); + assert!(refused(imports.beta_text(&preview.token, id)).contains("Source changed")); + // A new preview expires the old token. + let next = team_preview(&mut imports, old.path(), dest.path()); + assert!(refused(imports.beta_text(&preview.token, id)).contains("expired")); + assert_eq!(imports.beta_text(&next.token, id).unwrap(), ""); + imports.discard(); + assert!(refused(imports.beta_text(&next.token, id)).contains("expired")); +} diff --git a/crates/agent-controller/src/lib.rs b/crates/agent-controller/src/lib.rs index cd74797ed..8518a3e34 100644 --- a/crates/agent-controller/src/lib.rs +++ b/crates/agent-controller/src/lib.rs @@ -2,6 +2,7 @@ //! No legacy desktop dependency, implicit identity creation, or credential projection. mod agent_defaults; mod app_agent; +mod beta_migration; mod bundle; pub mod codex; mod community; @@ -40,6 +41,10 @@ pub use supervisor::dispatch as dispatch_agent_supervisor; pub use agent_defaults::{AgentDefaultsEdit, AgentDefaultsView}; pub use app_agent::{AppAgent, AppAgents, NewAppAgent, Published}; +pub use beta_migration::{ + beta_team_id, BetaTeamMember, BetaTeamStatus, BetaTeamView, PendingBetaTeam, RestoreGroup, + RestorePreview, +}; pub use bundle::RuntimeBundle; pub use config::{ AgentEdit, AgentView, AiConfiguration, ControlSnapshot, EffortSelection, HarnessEdit, diff --git a/crates/agent-controller/src/store.rs b/crates/agent-controller/src/store.rs index d579c2a90..85560f67a 100644 --- a/crates/agent-controller/src/store.rs +++ b/crates/agent-controller/src/store.rs @@ -473,87 +473,111 @@ impl Store { heads: &std::collections::BTreeMap, texts: &std::collections::BTreeMap, ) -> Result<()> { - for raw in texts.values() { - crate::import::team_text(&json!(raw))?; + let mut doc = self.read()?; + if sync_doc(&mut doc, relay, owner, heads, texts)? { + self.write(&doc)?; } + Ok(()) + } + /// See `Controller::restore_beta_team`. All or nothing. + pub(crate) fn start_beta_teams( + &mut self, + relay: &str, + members: Vec<(String, u64, crate::beta_migration::BetaTeam)>, + ) -> Result<()> { + use crate::beta_migration::{BetaTeam, KEY}; let mut doc = self.read()?; - // Teams the app could not read keep their bindings and copied text. - let mut changed = reconcile_bindings(&mut doc, relay, owner, heads, |team| { - texts.contains_key(team) - })?; - for agent in &mut doc.agents { - let authorized = agent - .auth_tag - .as_deref() - .and_then(|tag| serde_json::from_str::>(tag).ok()) - .is_some_and(|tag| tag.get(1).map(String::as_str) == Some(owner)); - if agent.relay_url != relay || !authorized { - continue; - } - let saved: Vec = match agent.imported.get("teamBindings") { - Some(raw) => serde_json::from_value(raw.clone()) - .map_err(|_| "Invalid saved team bindings")?, - None => Vec::new(), - }; - let current = crate::import::team_text(&agent.imported["teamInstructions"])?.to_owned(); - let mut bindings: Vec = saved - .iter() - .filter(|team| !texts.contains_key(*team) || !heads.contains_key(*team)) - .cloned() - .collect(); - let unknown = !bindings.is_empty(); - let mut text: Option<(&str, &str)> = None; - for (team, raw) in texts { - let team_text = raw.trim(); - if team_text.is_empty() - || !heads - .get(team) - .is_some_and(|head| head.members.contains(&agent.pubkey)) - { - continue; - } - match text { - Some((other, chosen)) if chosen != team_text => { - return Err(format!( - "{} is on teams \"{other}\" and \"{team}\", which have different instructions. Give both teams the same instructions or remove the agent from one", - agent.name - )); - } - _ => text = Some((team, team_text)), - } - bindings.push(team.clone()); + for (id, revision, beta) in members { + let agent = doc + .agents + .iter_mut() + .find(|a| a.id == id) + .ok_or("Agent no longer exists")?; + if agent.revision != revision { + return Err("Agent settings changed; preview the restore again".into()); } - // Text imported from old Buzz has no team binding. It stays until a - // team with text claims the agent; that team's text then replaces it. - let legacy = agent.imported.get("teamBindings").is_none(); - let next = match text { - Some((_, chosen)) => chosen.to_owned(), - None if unknown || legacy => current.clone(), - None => String::new(), - }; - if bindings.len() > 100 { - return Err("Too many team bindings for this agent".into()); + if agent.relay_url != relay { + return Err("Agent belongs to another community".into()); } - if bindings == saved && next == current { - continue; + if BetaTeam::read(agent)?.is_some() { + return Err("This agent's team from old Buzz is already set up".into()); } - if agent.imported.is_null() { + if !agent.imported.is_object() { agent.imported = json!({}); } - agent.imported["teamBindings"] = json!(bindings); - if next != current { - agent.imported["teamInstructions"] = json!(next); - agent.revision = agent - .revision - .checked_add(1) - .ok_or("Agent revision exhausted")?; - } - changed = true; + agent.imported[KEY] = + serde_json::to_value(&beta).map_err(|_| "Could not encode team from old Buzz")?; } - if changed { - self.write(&doc)?; + self.write(&doc) + } + /// See `Controller::finish_beta_team`. + #[allow(clippy::too_many_arguments)] + pub(crate) fn finish_beta_team( + &mut self, + id: &str, + revision: u64, + outcome: crate::BetaTeamStatus, + (relay, owner): (&str, &str), + heads: &std::collections::BTreeMap, + texts: &std::collections::BTreeMap, + ) -> Result<()> { + use crate::beta_migration::{BetaTeam, KEY}; + use crate::BetaTeamStatus::{Completed, Pending, Skipped}; + let mut doc = self.read()?; + let agent = doc + .agents + .iter_mut() + .find(|a| a.id == id) + .ok_or("Agent no longer exists")?; + if agent.revision != revision { + return Err("Agent settings changed. Refresh before finishing team setup".into()); } - Ok(()) + if agent.relay_url != relay { + return Err("Agent belongs to another community".into()); + } + let mut beta = BetaTeam::read(agent)?.ok_or("This agent has no team from old Buzz")?; + if beta.status != Pending { + return Ok(()); + } + let head = heads.get(&beta.team_id); + let listed = head.map(|head| head.members.contains(&agent.pubkey)); + if !agent.imported.is_object() { + agent.imported = json!({}); + } + match outcome { + Completed => { + if listed != Some(true) || !texts.contains_key(&beta.team_id) { + return Err(format!( + "Team \"{}\" doesn't list {} yet or couldn't be read. Finish team setup again", + beta.name, agent.name + )); + } + let mut bindings: Vec = match agent.imported.get("teamBindings") { + Some(raw) => serde_json::from_value(raw.clone()) + .map_err(|_| "Invalid saved team bindings")?, + None => Vec::new(), + }; + if !bindings.contains(&beta.team_id) { + bindings.push(beta.team_id.clone()); + } + agent.imported["teamBindings"] = json!(bindings); + } + Skipped => { + if !head.is_some_and(|head| head.deleted) { + return Err("Only a deleted team can be skipped".into()); + } + // Stop delivering the unbound beta copy of a team the user deleted. + if agent.imported.get("teamBindings").is_none() { + agent.imported["teamBindings"] = json!([]); + } + } + Pending => return Err("Invalid team setup outcome".into()), + } + beta.status = outcome; + agent.imported[KEY] = + serde_json::to_value(&beta).map_err(|_| "Could not encode team from old Buzz")?; + sync_doc(&mut doc, relay, owner, heads, texts)?; + self.write(&doc) } pub fn save(&mut self, id: &str, revision: u64, edit: AgentEdit) -> Result<()> { let mut doc = self.read()?; @@ -857,5 +881,91 @@ fn reconcile_bindings( } Ok(changed) } +/// The team text resolver on one in-memory document; see `sync_team_instructions`. +fn sync_doc( + doc: &mut Document, + relay: &str, + owner: &str, + heads: &std::collections::BTreeMap, + texts: &std::collections::BTreeMap, +) -> Result { + for raw in texts.values() { + crate::import::team_text(&json!(raw))?; + } + // Teams the app could not read keep their bindings and copied text. + let mut changed = + reconcile_bindings(doc, relay, owner, heads, |team| texts.contains_key(team))?; + for agent in &mut doc.agents { + let authorized = agent + .auth_tag + .as_deref() + .and_then(|tag| serde_json::from_str::>(tag).ok()) + .is_some_and(|tag| tag.get(1).map(String::as_str) == Some(owner)); + if agent.relay_url != relay || !authorized { + continue; + } + let saved: Vec = match agent.imported.get("teamBindings") { + Some(raw) => { + serde_json::from_value(raw.clone()).map_err(|_| "Invalid saved team bindings")? + } + None => Vec::new(), + }; + let current = crate::import::team_text(&agent.imported["teamInstructions"])?.to_owned(); + let mut bindings: Vec = saved + .iter() + .filter(|team| !texts.contains_key(*team) || !heads.contains_key(*team)) + .cloned() + .collect(); + let unknown = !bindings.is_empty(); + let mut text: Option<(&str, &str)> = None; + for (team, raw) in texts { + let team_text = raw.trim(); + if team_text.is_empty() + || !heads + .get(team) + .is_some_and(|head| head.members.contains(&agent.pubkey)) + { + continue; + } + match text { + Some((other, chosen)) if chosen != team_text => { + return Err(format!( + "{} is on teams \"{other}\" and \"{team}\", which have different instructions. Give both teams the same instructions or remove the agent from one", + agent.name + )); + } + _ => text = Some((team, team_text)), + } + bindings.push(team.clone()); + } + // Text imported from old Buzz has no team binding. It stays until a + // team with text claims the agent; that team's text then replaces it. + let legacy = agent.imported.get("teamBindings").is_none(); + let next = match text { + Some((_, chosen)) => chosen.to_owned(), + None if unknown || legacy => current.clone(), + None => String::new(), + }; + if bindings.len() > 100 { + return Err("Too many team bindings for this agent".into()); + } + if bindings == saved && next == current { + continue; + } + if agent.imported.is_null() { + agent.imported = json!({}); + } + agent.imported["teamBindings"] = json!(bindings); + if next != current { + agent.imported["teamInstructions"] = json!(next); + agent.revision = agent + .revision + .checked_add(1) + .ok_or("Agent revision exhausted")?; + } + changed = true; + } + Ok(changed) +} #[cfg(test)] pub(crate) mod tests; diff --git a/crates/agent-controller/src/teams.rs b/crates/agent-controller/src/teams.rs index af5674e32..073960fb8 100644 --- a/crates/agent-controller/src/teams.rs +++ b/crates/agent-controller/src/teams.rs @@ -230,6 +230,9 @@ pub struct TeamCatalogEntry { pub created_at: u64, pub event_id: String, pub members: Vec, + /// The head is a tombstone. Absent in heads saved before it was recorded. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub deleted: bool, } impl Controller { fn validate_team_catalog( diff --git a/crates/agent-controller/src/teams/tests.rs b/crates/agent-controller/src/teams/tests.rs index fc0299d29..83d0e87a4 100644 --- a/crates/agent-controller/src/teams/tests.rs +++ b/crates/agent-controller/src/teams/tests.rs @@ -485,6 +485,7 @@ fn catalog(created_at: u64, members: Vec) -> crate::TeamCatalogEntry { created_at, event_id: format!("{created_at:064x}"), members, + deleted: false, } } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ec00cd6b2..405076315 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -120,6 +120,10 @@ fn main() { "agent_control_team_preview", "agent_control_team_export", "agent_control_team_sync", + "agent_control_beta_teams", + "agent_control_beta_team_restore_preview", + "agent_control_beta_team_restore", + "agent_control_beta_team_finish", "agent_control_snapshot", "agent_control_log_challenge", "agent_control_read_log", @@ -136,6 +140,7 @@ fn main() { "agent_control_action", "agent_control_attach_mention", "agent_control_import_preview", + "agent_control_import_beta_text", "agent_control_import_commit", "agent_control_local_clone_settings", "agent_control_clone_settings", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index fac28ed35..ce17a6902 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -90,6 +90,10 @@ "allow-agent-control-team-preview", "allow-agent-control-team-export", "allow-agent-control-team-sync", + "allow-agent-control-beta-teams", + "allow-agent-control-beta-team-restore-preview", + "allow-agent-control-beta-team-restore", + "allow-agent-control-beta-team-finish", "allow-agent-control-snapshot", "allow-agent-control-log-challenge", @@ -107,6 +111,7 @@ "allow-agent-control-action", "allow-agent-control-attach-mention", "allow-agent-control-import-preview", + "allow-agent-control-import-beta-text", "allow-agent-control-import-commit", "allow-agent-control-local-clone-settings", "allow-agent-control-clone-settings", diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index aee86fc12..0063e928a 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -1476,6 +1476,19 @@ pub(crate) async fn agent_control_import_preview( }) .await } +/// Read-only: one previewed candidate's team instructions from old Buzz, for +/// the clash check before import. The ordinary preview stays text-free. +#[tauri::command] +pub(crate) async fn agent_control_import_beta_text( + state: tauri::State<'_, AgentHost>, + token: String, + id: String, +) -> Result { + run(state.inner().clone(), move |host| { + host.imports.beta_text(&token, &id) + }) + .await +} #[tauri::command] pub(crate) async fn agent_control_import_commit( state: tauri::State<'_, AgentHost>, @@ -1767,6 +1780,86 @@ pub(crate) async fn agent_control_team_sync( .await } +/// Pending agents of teams from old Buzz in `community`, owned by this account. +#[tauri::command] +pub(crate) async fn agent_control_beta_teams( + state: tauri::State<'_, AgentHost>, + identity: tauri::State<'_, crate::identity::IdentityHost>, + community: String, +) -> Result, String> { + let owner = identity.inner().viewer().await?; + run(state.inner().clone(), move |host| { + host.controller.pending_beta_teams(&community, &owner) + }) + .await +} + +/// Read-only: group earlier imports by the team they had in old Buzz. +#[tauri::command] +pub(crate) async fn agent_control_beta_team_restore_preview( + state: tauri::State<'_, AgentHost>, + source: LegacySource, + ids: Vec, +) -> Result { + run(state.inner().clone(), move |host| { + host.controller.restore_beta_teams_preview( + &mut host.imports, + source, + host.legacy_parent.clone(), + &ids, + ) + }) + .await +} + +/// Start one restore-preview group's migration with the chosen text; its +/// members then finish like any pending agent. +#[tauri::command] +pub(crate) async fn agent_control_beta_team_restore( + state: tauri::State<'_, AgentHost>, + identity: tauri::State<'_, crate::identity::IdentityHost>, + community: String, + token: String, + team_id: String, + text: String, +) -> Result { + let owner = identity.inner().viewer().await?; + run(state.inner().clone(), move |host| { + host.controller.restore_beta_team( + &host.imports, + &community, + &owner, + &token, + &team_id, + &text, + )?; + Ok(host.snapshot()?.data) + }) + .await +} + +/// Finish one agent's team from old Buzz against the owner's current catalog. +/// `teams` maps every readable team to its current text, as for team sync. +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub(crate) async fn agent_control_beta_team_finish( + state: tauri::State<'_, AgentHost>, + identity: tauri::State<'_, crate::identity::IdentityHost>, + community: String, + teams: std::collections::BTreeMap, + id: String, + revision: u64, + outcome: buzz_agent_controller::BetaTeamStatus, +) -> Result { + let (owner, heads) = crate::relay::current_team_members(identity.inner(), &community).await?; + run(state.inner().clone(), move |host| { + host.controller + .finish_beta_team(&id, revision, outcome, &community, &owner, &heads, &teams)?; + Ok(host.snapshot()?.data) + }) + .await +} + #[tauri::command] pub(crate) async fn agent_control_team_export( state: tauri::State<'_, AgentHost>, diff --git a/src-tauri/src/browser_permissions_tests.rs b/src-tauri/src/browser_permissions_tests.rs index b9c5add18..d0291970d 100644 --- a/src-tauri/src/browser_permissions_tests.rs +++ b/src-tauri/src/browser_permissions_tests.rs @@ -137,6 +137,10 @@ fn native_command_permissions_allow_only_main_webview() { "agent_control_team_preview", "agent_control_team_export", "agent_control_team_sync", + "agent_control_beta_teams", + "agent_control_beta_team_restore_preview", + "agent_control_beta_team_restore", + "agent_control_beta_team_finish", "agent_control_snapshot", "agent_control_log_challenge", "agent_control_read_log", @@ -151,6 +155,7 @@ fn native_command_permissions_allow_only_main_webview() { "agent_control_delete", "agent_control_action", "agent_control_import_preview", + "agent_control_import_beta_text", "agent_control_import_commit", "agent_control_local_clone_settings", "agent_control_clone_settings", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5e2d6fc75..becf8a958 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -65,9 +65,11 @@ mod harness_setup; mod managed_npm; mod pi_models; use agents::{ - agent_control_action, agent_control_attach_mention, agent_control_clone_settings, - agent_control_create_authorize, agent_control_create_commit, agent_control_create_prepare, - agent_control_creation_profile, agent_control_delete, agent_control_import_commit, + agent_control_action, agent_control_attach_mention, agent_control_beta_team_finish, + agent_control_beta_team_restore, agent_control_beta_team_restore_preview, + agent_control_beta_teams, agent_control_clone_settings, agent_control_create_authorize, + agent_control_create_commit, agent_control_create_prepare, agent_control_creation_profile, + agent_control_delete, agent_control_import_beta_text, agent_control_import_commit, agent_control_import_preview, agent_control_local_clone_settings, agent_control_log_challenge, agent_control_read_log, agent_control_save, agent_control_save_defaults, agent_control_snapshot, agent_control_snapshot_memory_write, agent_control_start_on_app_launch, @@ -520,6 +522,11 @@ fn commands() -> impl Fn(tauri::ipc::Invoke) -> bool + Sen agent_control_snapshot_memory_write, agent_control_team_preview, agent_control_team_sync, + agent_control_beta_teams, + agent_control_beta_team_restore_preview, + agent_control_import_beta_text, + agent_control_beta_team_restore, + agent_control_beta_team_finish, agent_control_team_export, agent_control_snapshot, agent_control_log_challenge, diff --git a/src-tauri/src/relay/kit.rs b/src-tauri/src/relay/kit.rs index 56cf7a657..c7163f107 100644 --- a/src-tauri/src/relay/kit.rs +++ b/src-tauri/src/relay/kit.rs @@ -406,7 +406,7 @@ async fn decode_team_members( if events.len() >= 500 { return Err("Team catalog reached its read limit".into()); } - let mut heads: std::collections::BTreeMap)> = + let mut heads: std::collections::BTreeMap, bool)> = std::collections::BTreeMap::new(); for event in events { let signed: nostr::event::Event = @@ -449,7 +449,8 @@ async fn decode_team_members( .as_str() .ok_or("Invalid team catalog")? .to_owned(); - let members = if raw["deleted"] == true { + let deleted = raw["deleted"] == true; + let members = if deleted { vec![] } else { serde_json::from_value(raw["value"]["agents"].clone()) @@ -458,11 +459,14 @@ async fn decode_team_members( // A portable record supersedes the ordinary record it replaced, even // though retiring the ordinary record is the later write. let portable = coordinate.starts_with(&format!("{MANIFEST_TAG}:")); - if heads.get(&id).map_or(true, |(old_portable, time, old, _)| { - (portable, timestamp) > (*old_portable, *time) - || (portable == *old_portable && timestamp == *time && event_id < *old) - }) { - heads.insert(id, (portable, timestamp, event_id, members)); + if heads + .get(&id) + .map_or(true, |(old_portable, time, old, _, _)| { + (portable, timestamp) > (*old_portable, *time) + || (portable == *old_portable && timestamp == *time && event_id < *old) + }) + { + heads.insert(id, (portable, timestamp, event_id, members, deleted)); } } if host.viewer().await? != owner { @@ -470,13 +474,14 @@ async fn decode_team_members( } Ok(heads .into_iter() - .map(|(id, (_, created_at, event_id, members))| { + .map(|(id, (_, created_at, event_id, members, deleted))| { ( id, buzz_agent_controller::TeamCatalogEntry { created_at, event_id, members, + deleted, }, ) }) diff --git a/src/bundled/agents/AgentControlPanel.tsx b/src/bundled/agents/AgentControlPanel.tsx index f853a907e..b53a16ae9 100644 --- a/src/bundled/agents/AgentControlPanel.tsx +++ b/src/bundled/agents/AgentControlPanel.tsx @@ -131,6 +131,7 @@ export function AgentControlPanel({ const [exporting, setExporting] = useState(null); const [importingSnapshot, setImportingSnapshot] = useState(false); const [catalogOpen, setCatalogOpen] = useState(false); + const [teamProblem, setTeamProblem] = useState(null); const edit = (agent: AgentView, avatar?: string) => { setSelected({ id: agent.id, ...(avatar ? { avatar } : {}) }); if (editTarget) onCloseTarget?.(); @@ -239,6 +240,15 @@ export function AgentControlPanel({ state.status === "ready" && state.data.importAvailable !== false } disabled={state.busy} + teams={ + session?.viewer + ? { + kit: session.channelKit, + scope: session.scope, + viewer: session.viewer, + } + : undefined + } onClone={ control.cloneSettings && createOwner && importDestination ? (initialSettings) => { @@ -251,8 +261,9 @@ export function AgentControlPanel({ } : undefined } - onImported={(agents) => { + onImported={(agents, teamProblem) => { importCompleted.current = true; + setTeamProblem(teamProblem ?? null); setImportedId(agents[0]?.id ?? null); setImportSections([]); setImportSelection(null); @@ -370,6 +381,7 @@ export function AgentControlPanel({ ))} )} + {teamProblem &&

{teamProblem}

} {state.data && !importSelection && (state.data.parked === undefined || needsRepair) && ( diff --git a/src/bundled/agents/AgentImport.test.tsx b/src/bundled/agents/AgentImport.test.tsx index 6a2bb34b4..e62cdacfd 100644 --- a/src/bundled/agents/AgentImport.test.tsx +++ b/src/bundled/agents/AgentImport.test.tsx @@ -159,3 +159,159 @@ it("hides the stripped-instructions notice on the repair action", async () => { control.dispose(); } }); + +const betaSteps = vi.hoisted(() => ({ + betaTeamConflict: vi.fn(), + setUpImportedTeam: vi.fn(), +})); +vi.mock("../../features/agents/beta-team-import", () => betaSteps); +const viewer = "e".repeat(64); +const teamAccess = (status = "ready") => ({ + kit: { snapshot: () => ({ status, entries: [] }) } as never, + scope: `https://relay.example.test:${viewer}`, + viewer, +}); +const withTeam = (f: ReturnType) => { + previewWith(f, [ + { id: "joiner", name: "Joiner", stripsTeamInstructions: false }, + ]); + f.host.importBetaText = vi.fn(async () => "BETA"); + const preview = f.host.previewImport; + if (!preview) throw new Error("fixture has no import preview"); + f.host.previewImport = async (...args) => { + const result = await preview(...args); + return { + ...result, + candidates: result.candidates.map((c) => ({ + ...c, + team: { teamId: "beta-1", name: "Writers", status: "pending" as const }, + })), + }; + }; +}; +it("shows the old Buzz team, checks it before import and sets it up after", async () => { + betaSteps.betaTeamConflict.mockReset().mockResolvedValue(undefined); + betaSteps.setUpImportedTeam.mockReset().mockResolvedValue("not yet"); + const f = controlFixture(); + withTeam(f); + const control = createAgentControl(f.host); + const imported = vi.fn(); + const mounted = mount(control, { teams: teamAccess(), onImported: imported }); + try { + expect(await screen.findByText("Joins team “Writers”")).toBeVisible(); + const submit = await screen.findByRole("button", { name: "Import Joiner" }); + await waitFor(() => expect(submit).toBeEnabled()); + fireEvent.click(submit); + await waitFor(() => expect(imported).toHaveBeenCalledOnce()); + expect(betaSteps.betaTeamConflict).toHaveBeenCalledOnce(); + expect(betaSteps.setUpImportedTeam).toHaveBeenCalledWith( + expect.anything(), + control, + "https://relay.example.test", + "beta-1", + ); + expect(imported.mock.calls[0]?.[1]).toBe("not yet"); + } finally { + mounted.unmount(); + control.dispose(); + } +}); +it("refuses an import whose real beta text clashes with the agent's other team", async () => { + const { betaTeamConflict } = await vi.importActual< + typeof import("../../features/agents/beta-team-import") + >("../../features/agents/beta-team-import"); + // The real check runs; the target team "beta-1" isn't in the catalog yet. + betaSteps.betaTeamConflict.mockReset().mockImplementation(betaTeamConflict); + betaSteps.setUpImportedTeam.mockReset(); + const f = controlFixture(); + withTeam(f); + const control = createAgentControl(f.host); + const editors = { + type: "team" as const, + id: "editors", + name: "Editors", + agents: ["1".repeat(64)], + }; + const kit = { + snapshot: () => ({ + status: "ready", + entries: [ + { + eventId: "editors-head", + createdAt: 1, + record: { + version: 1, + community: "https://relay.example.test", + deleted: false, + value: editors, + }, + }, + ], + }), + refresh: vi.fn(async () => {}), + readText: vi.fn(async () => ({ text: "EDITORS", head: "h" })), + }; + const mounted = mount(control, { + teams: { ...teamAccess(), kit: kit as never }, + }); + try { + const submit = await screen.findByRole("button", { name: "Import Joiner" }); + await waitFor(() => expect(submit).toBeEnabled()); + fireEvent.click(submit); + expect(await screen.findByRole("alert")).toHaveTextContent("Editors"); + expect(f.host.importBetaText).toHaveBeenCalledWith( + "fixture-preview", + "joiner", + ); + expect(f.calls.some((call) => call.action === "import")).toBe(false); + expect(betaSteps.setUpImportedTeam).not.toHaveBeenCalled(); + } finally { + mounted.unmount(); + control.dispose(); + } +}); +it("refuses an import whose team would give a member two texts", async () => { + betaSteps.betaTeamConflict.mockReset().mockResolvedValue("clash"); + betaSteps.setUpImportedTeam.mockReset(); + const f = controlFixture(); + withTeam(f); + const control = createAgentControl(f.host); + const mounted = mount(control, { teams: teamAccess() }); + try { + const submit = await screen.findByRole("button", { name: "Import Joiner" }); + await waitFor(() => expect(submit).toBeEnabled()); + fireEvent.click(submit); + expect(await screen.findByText("clash")).toBeVisible(); + expect(f.calls.some((call) => call.action === "import")).toBe(false); + expect(betaSteps.setUpImportedTeam).not.toHaveBeenCalled(); + } finally { + mounted.unmount(); + control.dispose(); + } +}); +it.each([ + [ + "another community", + { ...teamAccess(), scope: `https://other.test:${viewer}` }, + ], + ["a catalog still loading", teamAccess("loading")], +])("leaves the team pending for %s", async (_name, teams) => { + betaSteps.betaTeamConflict.mockReset(); + betaSteps.setUpImportedTeam.mockReset(); + const f = controlFixture(); + withTeam(f); + const control = createAgentControl(f.host); + const imported = vi.fn(); + const mounted = mount(control, { teams, onImported: imported }); + try { + const submit = await screen.findByRole("button", { name: "Import Joiner" }); + await waitFor(() => expect(submit).toBeEnabled()); + fireEvent.click(submit); + await waitFor(() => expect(imported).toHaveBeenCalledOnce()); + expect(betaSteps.betaTeamConflict).not.toHaveBeenCalled(); + expect(betaSteps.setUpImportedTeam).not.toHaveBeenCalled(); + } finally { + mounted.unmount(); + control.dispose(); + } +}); diff --git a/src/bundled/agents/AgentImport.tsx b/src/bundled/agents/AgentImport.tsx index 4b77131a9..378495b34 100644 --- a/src/bundled/agents/AgentImport.tsx +++ b/src/bundled/agents/AgentImport.tsx @@ -10,7 +10,48 @@ import type { ImportSource, CloneSettings, } from "../../features/agents/control"; +import { oldBuzzLabel } from "../../features/agents/control"; import { Button } from "../../shared/design-system/ui/Button"; +import type { ChannelKit } from "../../features/channel-templates/capability"; +import { + betaTeamConflict, + setUpImportedTeam, +} from "../../features/agents/beta-team-import"; +import { sessionCommunity } from "../../features/agents/team-instructions"; +import { relayOrigin } from "../../features/communities/destination"; +import { relayPartition } from "../../features/relay/partition"; + +type Candidate = AgentImportPreview["candidates"][number]; +/** The candidate's old Buzz team text; the clash check needs the real text. */ +const betaText = (control: AgentControl, token: string, id: string) => { + if (!control.importBetaText) + throw new Error("Teams from old Buzz are unavailable."); + return control.importBetaText(token, id); +}; +/** A team catalog and the relay session scope it belongs to. */ +export type BetaTeamAccess = { kit: ChannelKit; scope: string; viewer: string }; +/** The catalog only when it is ready and the import destination's, for this + * owner. */ +const teamsFor = (teams: BetaTeamAccess | undefined, destination: string) => { + if (teams?.kit.snapshot().status !== "ready") return undefined; + try { + return relayPartition(relayOrigin(destination), teams.viewer) === + teams.scope + ? { + kit: teams.kit, + community: sessionCommunity(teams.scope, teams.viewer), + } + : undefined; + } catch { + return undefined; + } +}; +const JoinsTeam = ({ candidate }: { candidate: Candidate | undefined }) => + candidate?.team?.status === "pending" ? ( +

+ Joins team “{candidate.team.name}” +

+ ) : null; const STRIPPED_TEAM_INSTRUCTIONS = "Old team instructions saved inside this agent's prompt are left out, so the agent doesn't get them twice."; @@ -28,6 +69,7 @@ export function AgentImport({ commitAvailable = true, onImported, onClone, + teams, }: { ref?: Ref; control: AgentControl; @@ -41,7 +83,10 @@ export function AgentImport({ managedAgents: readonly AgentView[]; commitAvailable?: boolean; onClone?: ((settings: CloneSettings) => void) | undefined; - onImported?: (agents: AgentView[]) => void; + /** Without a ready catalog for the destination, imported agents keep their + * team pending until Finish team setup runs in that community. */ + teams?: BetaTeamAccess | undefined; + onImported?: (agents: AgentView[], teamProblem?: string) => void; }) { const [source, setSource] = useState(initialSource); const [destination, setDestination] = useState(initialDestination); @@ -103,11 +148,37 @@ export function AgentImport({ ? !selectedPubkey && saved.needsTeamImport : !repairOnly && !managedKeys.has(candidate.pubkey.toLowerCase()); }); - const commit = async (id: string, repair: boolean, name: string) => { + const commit = async (candidate: Candidate, repair: boolean) => { + const { id, name } = candidate; if (disabled || importing || !destination.trim() || !preview?.token) return; const current = generation.current; setImporting(true); + const team = + !repair && candidate.team?.status === "pending" + ? candidate.team + : undefined; + const access = teamsFor(teams, destination); try { + // Reads the candidate's old Buzz text from the preview and checks it + // against the team's current text. The team step rechecks with both. + const conflict = + team && + access && + (await betaTeamConflict( + access.kit, + control, + { + teamId: team.teamId, + name: team.name, + texts: [], + members: [{ id, pubkey: candidate.pubkey, revision: 0 }], + }, + await betaText(control, preview.token, id), + )); + if (conflict) { + if (generation.current === current) setError(conflict); + return; + } const result = await control.commitImport(preview.token, [id]); if (generation.current !== current) return; if (repair) { @@ -117,7 +188,20 @@ export function AgentImport({ void load(source, destination); return; } - onImported?.(result.agents.filter((agent) => agent.id === id)); + const teamProblem = + team && access + ? await setUpImportedTeam( + access.kit, + control, + access.community, + team.teamId, + ) + : undefined; + if (generation.current !== current) return; + onImported?.( + result.agents.filter((agent) => agent.id === id), + teamProblem, + ); } catch (problem) { if (generation.current === current) { setPreview(null); @@ -166,9 +250,7 @@ export function AgentImport({ )}
Source
-
- {source === "installed" ? "Installed Buzz" : "Development Buzz"} -
+
{oldBuzzLabel(source)}
Destination
{initialDestination ? ( @@ -207,6 +289,7 @@ export function AgentImport({ )}
+ {candidate?.stripsTeamInstructions && (

{STRIPPED_TEAM_INSTRUCTIONS} @@ -239,8 +322,10 @@ export function AgentImport({ void load(next, destination); }} > - - + + {preview && ( @@ -268,9 +353,7 @@ export function AgentImport({ !candidate || !preview?.token } - onClick={() => - candidate && void commit(candidate.id, false, candidate.name) - } + onClick={() => candidate && void commit(candidate, false)} > {importing ? "Importing…" : "Import agent"} @@ -346,6 +429,7 @@ export function AgentImport({

{repair ? "Team instructions not imported" : "Not imported"}

+ {!repair && } {!repair && candidate.stripsTeamInstructions && (

{STRIPPED_TEAM_INSTRUCTIONS} @@ -390,7 +474,7 @@ export function AgentImport({ !preview?.token } aria-label={`${repair ? "Repair team import for" : "Import"} ${candidate.name}`} - onClick={() => void commit(candidate.id, repair, candidate.name)} + onClick={() => void commit(candidate, repair)} > {repair ? "Repair team import" : "Import"} @@ -417,8 +501,8 @@ export function AgentImport({ { label: "", options: [ - { value: "installed", label: "Installed Buzz" }, - { value: "development", label: "Development Buzz" }, + { value: "installed", label: oldBuzzLabel("installed") }, + { value: "development", label: oldBuzzLabel("development") }, ], }, ]} diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 29d1f8e6e..2de477ccf 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -3178,7 +3178,7 @@ it("browses both local libraries without a community and requires a destination expect(read).toHaveBeenCalledTimes(1); await userEvent.click(screen.getByLabelText("Source library")); await userEvent.click( - await screen.findByRole("option", { name: "Development Buzz" }), + await screen.findByRole("option", { name: "Old Buzz (Development)" }), ); await waitFor(() => expect(f.calls).toContainEqual({ @@ -3363,7 +3363,7 @@ it("unified card Import selects exact identity and development source, then move name: "Import agent", }); await waitFor(() => expect(submit).toBeEnabled()); - expect(within(form).getByText("Development Buzz")).toBeVisible(); + expect(within(form).getByText("Old Buzz (Development)")).toBeVisible(); expect(within(form).getByText("https://relay.example.test")).toBeVisible(); expect(within(form).queryByLabelText("Source library")).toBeNull(); expect( @@ -3503,7 +3503,9 @@ it.each(["installed", "development"] as const)( }); expect( within(form).getByText( - source === "installed" ? "Installed Buzz" : "Development Buzz", + source === "installed" + ? "Old Buzz (Installed)" + : "Old Buzz (Development)", ), ).toBeVisible(); const submit = within(form).getByRole("button", { name: "Import agent" }); @@ -3573,7 +3575,7 @@ it("keeps the chosen source authoritative when an earlier preview finishes late" ); await userEvent.click(within(form).getByLabelText("Source library")); await userEvent.click( - await screen.findByRole("option", { name: "Development Buzz" }), + await screen.findByRole("option", { name: "Old Buzz (Development)" }), ); expect( within(form).queryByRole("button", { name: /Import .* settings/ }), diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index 8b078a250..51e8daffd 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -35,6 +35,7 @@ import { AgentLibrary } from "./AgentLibrary"; import { Button } from "../../shared/design-system/ui/Button"; import { AgentCard, type ProfileResolver } from "./AgentCard"; import { AgentControlPanel } from "./AgentControlPanel"; +import { BetaTeamSetup } from "./BetaTeamSetup"; import { ManagedAgentActions, useManagedAgentActions, @@ -355,8 +356,24 @@ export function AgentsPage({ onUseHere, onImport, onShare, - ) => - state.status === "unavailable" ? ( + ) => { + // Restored teams appear in Agent teams, right above. + const teamSections = ( + <> + {teams} + {connection.status === "ready" && + connection.session.viewer && + control.betaTeams && ( + + )} + + ); + return state.status === "unavailable" ? ( <> {teams} {library} @@ -364,7 +381,7 @@ export function AgentsPage({ ) : state.data?.parked !== undefined ? ( - ) - } + ); + }} ) : ( <> diff --git a/src/bundled/agents/BetaTeamSetup.test.tsx b/src/bundled/agents/BetaTeamSetup.test.tsx new file mode 100644 index 000000000..9af0e30cc --- /dev/null +++ b/src/bundled/agents/BetaTeamSetup.test.tsx @@ -0,0 +1,801 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import type { + AgentControl, + AgentControlState, + AgentView, + PendingBetaTeam, +} from "../../features/agents/control"; +import { createAgentControl } from "../../features/agents/control"; +import { useAgentControl } from "../../features/agents/control-react"; +import { controlFixture } from "../../features/agents/control-testing"; +import type { RelaySession } from "../../features/relay/session"; +import { BetaTeamSetup } from "./BetaTeamSetup"; + +const runner = vi.hoisted(() => ({ runBetaTeamStep: vi.fn() })); +vi.mock("../../features/agents/beta-team-import", () => runner); +afterEach(cleanup); + +const viewer = "e".repeat(64); +const ready = { status: "ready", entries: [] }; +const kit = { snapshot: () => ready }; +const session = { + scope: `https://relay.example.test:${viewer}`, + viewer, + channelKit: { ...kit, subscribe: () => () => {}, ensure: () => {} }, +} as unknown as RelaySession; +const agent = (id: string, betaTeam: AgentView["betaTeam"]) => + ({ + id, + pubkey: id.padEnd(64, "0"), + revision: 1, + relayUrl: "wss://relay.example.test", + configured: true, + betaTeam, + }) as AgentView; +const team = (texts: string[]): PendingBetaTeam => ({ + teamId: "beta-1", + name: "Writers", + texts, + members: [{ id: "a1", pubkey: "a1".padEnd(64, "0"), revision: 1 }], +}); +const both = [ + { pubkey: "p", name: "P", sources: ["installed", "development"] }, +]; +const state = (agents: AgentView[], parked: unknown[] = both) => + ({ + status: "ready", + busy: false, + data: { agents, parked }, + }) as unknown as AgentControlState; +const control = (overrides: Partial) => + ({ + finishBetaTeam: vi.fn(), + betaTeams: vi.fn(async () => []), + ...overrides, + }) as unknown as AgentControl; +const mount = (c: AgentControl, agents: AgentView[], parked?: unknown[]) => + render( + , + ); + +it("finishes a pending team with its only old Buzz text", async () => { + runner.runBetaTeamStep + .mockReset() + .mockResolvedValue({ finished: ["a1"], failed: [] }); + const pending = team(["BETA"]); + const c = control({ betaTeams: vi.fn(async () => [pending]) }); + mount(c, [ + agent("a1", { teamId: "beta-1", name: "Writers", status: "pending" }), + ]); + fireEvent.click( + await screen.findByRole("button", { + name: "Finish team setup for Writers", + }), + ); + await waitFor(() => + expect(runner.runBetaTeamStep).toHaveBeenCalledWith( + session.channelKit, + c, + "https://relay.example.test", + pending, + { text: "BETA" }, + ), + ); +}); + +it("asks which text to keep when members disagreed and reports a failure", async () => { + runner.runBetaTeamStep + .mockReset() + .mockResolvedValue({ finished: [], failed: ["refused"] }); + const c = control({ betaTeams: vi.fn(async () => [team(["ONE", "TWO"])]) }); + mount(c, [ + agent("a1", { teamId: "beta-1", name: "Writers", status: "pending" }), + ]); + fireEvent.click( + await screen.findByRole("button", { + name: "Choose instructions for Writers", + }), + ); + fireEvent.click( + await screen.findByRole("button", { + name: "Finish team setup for Writers with instructions 2", + }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent("refused"); + expect(runner.runBetaTeamStep.mock.calls[0]?.[4]).toEqual({ text: "TWO" }); +}); + +it("restores an earlier import's team, then finishes it", async () => { + runner.runBetaTeamStep + .mockReset() + .mockResolvedValue({ finished: ["a1"], failed: [] }); + const pending = team(["BETA"]); + const betaTeams = vi.fn(async () => [] as PendingBetaTeam[]); + const restoreBetaTeam = vi.fn(async () => { + betaTeams.mockResolvedValue([pending]); + return {} as never; + }); + const restoreBetaTeams = vi.fn(async () => ({ + token: "t", + groups: [{ ...team(["BETA"]), inferred: true }], + })); + const c = control({ betaTeams, restoreBetaTeam, restoreBetaTeams }); + // Only agents without a recorded team are offered; "done" already has one. + mount(c, [ + agent("a1", null), + agent("done", { teamId: "beta-2", name: "X", status: "completed" }), + ]); + fireEvent.click(await screen.findByRole("button", { name: "Find teams" })); + expect(restoreBetaTeams).toHaveBeenCalledWith("installed", ["a1"]); + expect(await screen.findByText(/rebuilt from/)).toBeVisible(); + fireEvent.click(screen.getByRole("button", { name: "Restore team Writers" })); + await waitFor(() => + expect(runner.runBetaTeamStep).toHaveBeenCalledWith( + session.channelKit, + c, + "https://relay.example.test", + pending, + { text: "BETA" }, + ), + ); + expect(restoreBetaTeam).toHaveBeenCalledWith( + "https://relay.example.test", + "t", + "beta-1", + "BETA", + ); +}); + +it("stays with a done line once every agent has its team", async () => { + const betaTeams = vi.fn(async () => []); + mount( + control({ betaTeams, restoreBetaTeam: vi.fn(), restoreBetaTeams: vi.fn() }), + [agent("a1", { teamId: "beta-1", name: "W", status: "completed" })], + ); + await waitFor(() => expect(betaTeams).toHaveBeenCalled()); + expect(screen.getByRole("heading", { name: "From old Buzz" })).toBeVisible(); + expect(screen.getByRole("status")).toHaveTextContent( + "All teams from old Buzz are set up.", + ); + expect( + screen.getByRole("combobox", { name: "Old Buzz library" }), + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Find teams" })).toBeDisabled(); +}); + +it("hides the library picker when only one old Buzz library exists", async () => { + const restoreBetaTeams = vi.fn(async () => ({ token: "t", groups: [] })); + mount( + control({ restoreBetaTeam: vi.fn(), restoreBetaTeams }), + [agent("a1", null)], + [{ pubkey: "p", name: "P", sources: ["development"] }], + ); + fireEvent.click(await screen.findByRole("button", { name: "Find teams" })); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(restoreBetaTeams).toHaveBeenCalledWith("development", ["a1"]); + expect( + await screen.findByText(/Old Buzz \(Development\) has no teams/), + ).toBeVisible(); +}); + +it("finds again after an empty result, in the other library", async () => { + const restoreBetaTeams = vi + .fn() + .mockResolvedValueOnce({ token: "t1", groups: [] }) + .mockResolvedValueOnce({ token: "t2", groups: [] }) + .mockResolvedValueOnce({ + token: "t3", + groups: [{ ...team(["BETA"]), inferred: false }], + }); + const c = control({ restoreBetaTeam: vi.fn(), restoreBetaTeams }); + mount(c, [agent("a1", null)]); + fireEvent.click(await screen.findByRole("button", { name: "Find teams" })); + expect( + await screen.findByText(/Old Buzz \(Installed\) has no teams/), + ).toBeVisible(); + const again = screen.getByRole("button", { name: "Find teams again" }); + fireEvent.click(again); + await waitFor(() => expect(restoreBetaTeams).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(again).toBeEnabled()); + await choose("Old Buzz (Development)"); + fireEvent.click(again); + expect( + await screen.findByRole("button", { name: "Restore team Writers" }), + ).toBeVisible(); + expect(restoreBetaTeams).toHaveBeenLastCalledWith("development", ["a1"]); +}); + +it("re-previews after a refused restore, then restores with the new token", async () => { + runner.runBetaTeamStep + .mockReset() + .mockResolvedValue({ finished: ["a1"], failed: [] }); + const restoreBetaTeam = vi + .fn() + .mockRejectedValueOnce(new Error("preview the restore again")) + .mockResolvedValueOnce({}); + const restoreBetaTeams = vi + .fn() + .mockResolvedValueOnce({ + token: "old", + groups: [{ ...team(["BETA"]), inferred: false }], + }) + .mockResolvedValueOnce({ + token: "new", + groups: [{ ...team(["BETA"]), inferred: false }], + }); + const c = control({ restoreBetaTeam, restoreBetaTeams }); + mount(c, [agent("a1", null)]); + fireEvent.click(await screen.findByRole("button", { name: "Find teams" })); + fireEvent.click( + await screen.findByRole("button", { name: "Restore team Writers" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "preview the restore again", + ); + const again = screen.getByRole("button", { name: "Find teams again" }); + await waitFor(() => expect(again).toHaveFocus()); + fireEvent.click(again); + await waitFor(() => expect(restoreBetaTeams).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(again).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "Restore team Writers" })); + await waitFor(() => + expect(restoreBetaTeam).toHaveBeenLastCalledWith( + "https://relay.example.test", + "new", + "beta-1", + "BETA", + ), + ); + await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); +}); + +const choose = async (name: string) => { + await userEvent.click( + screen.getByRole("combobox", { name: "Old Buzz library" }), + ); + await userEvent.click(await screen.findByRole("option", { name })); +}; + +const previewed = (token: string, texts = ["BETA"]) => ({ + token, + groups: [{ ...team(texts), inferred: false }], +}); + +it("drops a found preview when the library changes", async () => { + const restoreBetaTeams = vi.fn(async () => previewed("installed-token")); + mount(control({ restoreBetaTeam: vi.fn(), restoreBetaTeams }), [ + agent("a1", null), + ]); + fireEvent.click(await screen.findByRole("button", { name: "Find teams" })); + await screen.findByRole("button", { name: "Restore team Writers" }); + await choose("Old Buzz (Development)"); + expect( + screen.queryByRole("button", { name: "Restore team Writers" }), + ).toBeNull(); +}); + +it("offers no old Restore after finding again fails", async () => { + const restoreBetaTeams = vi + .fn() + .mockResolvedValueOnce(previewed("old")) + .mockRejectedValueOnce(new Error("Old Buzz library is unreadable.")); + mount(control({ restoreBetaTeam: vi.fn(), restoreBetaTeams }), [ + agent("a1", null), + ]); + fireEvent.click(await screen.findByRole("button", { name: "Find teams" })); + await screen.findByRole("button", { name: "Restore team Writers" }); + fireEvent.click(screen.getByRole("button", { name: "Find teams again" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Old Buzz library is unreadable.", + ); + expect( + screen.queryByRole("button", { name: "Restore team Writers" }), + ).toBeNull(); +}); + +it("recovers a refused restore through the real agent control", async () => { + runner.runBetaTeamStep + .mockReset() + .mockResolvedValue({ finished: ["fixture-agent"], failed: [] }); + const fixture = controlFixture(); + fixture.agent.betaTeam = null; + const restoreBetaTeam = vi + .fn() + .mockRejectedValueOnce("Agent settings changed; preview the restore again") + .mockImplementationOnce(async () => structuredClone(fixture.data)); + const restoreBetaTeams = vi + .fn() + .mockResolvedValueOnce(previewed("old")) + .mockResolvedValueOnce(previewed("new")); + const real = createAgentControl({ + ...fixture.host, + betaTeams: async () => [], + finishBetaTeam: vi.fn(), + restoreBetaTeams, + restoreBetaTeam, + }); + await real.refresh(); + function Live() { + return ( + + ); + } + render(); + fireEvent.click(await screen.findByRole("button", { name: "Find teams" })); + fireEvent.click( + await screen.findByRole("button", { name: "Restore team Writers" }), + ); + await waitFor(() => expect(real.snapshot().status).toBe("error")); + await act(() => real.refresh()); + expect(real.snapshot().status).toBe("ready"); + expect(await screen.findByRole("alert")).toHaveTextContent( + "preview the restore again", + ); + const again = screen.getByRole("button", { name: "Find teams again" }); + await waitFor(() => expect(again).toHaveFocus()); + fireEvent.click(again); + fireEvent.click( + await screen.findByRole("button", { name: "Restore team Writers" }), + ); + await waitFor(() => + expect(restoreBetaTeam).toHaveBeenLastCalledWith( + "https://relay.example.test", + "new", + "beta-1", + "BETA", + ), + ); + real.dispose(); +}); + +it("offers both libraries when the host reports no inventory", async () => { + const restoreBetaTeams = vi.fn(async () => previewed("t")); + render( + , + ); + await screen.findByRole("button", { name: "Find teams" }); + await choose("Old Buzz (Development)"); + fireEvent.click(screen.getByRole("button", { name: "Find teams" })); + expect( + await screen.findByRole("button", { name: "Restore team Writers" }), + ).toBeVisible(); + expect(restoreBetaTeams).toHaveBeenCalledWith("development", ["a1"]); +}); + +it("never claims setup is done when pending teams could not be read", async () => { + const betaTeams = vi.fn(async () => { + throw new Error("Pending teams could not be read."); + }); + mount( + control({ betaTeams, restoreBetaTeam: vi.fn(), restoreBetaTeams: vi.fn() }), + [agent("a1", { teamId: "beta-1", name: "W", status: "pending" })], + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Pending teams could not be read.", + ); + expect(screen.queryByText("All teams from old Buzz are set up.")).toBeNull(); +}); + +const writers = (status: "pending" | "completed") => [ + agent("a1", { teamId: "beta-1", name: "Writers", status }), +]; +const remount = ( + rerender: (ui: React.ReactElement) => void, + c: AgentControl, + status: "pending" | "completed", +) => + rerender( + , + ); +// Tabs forward until the named control has focus. +const tabTo = async ( + user: ReturnType, + name: string, +) => { + const target = await screen.findByRole("button", { name }); + for (let i = 0; i < 20 && document.activeElement !== target; i++) + await user.tab(); + expect(target).toHaveFocus(); +}; + +it("keeps keyboard focus in the section after a chosen text finishes its row", async () => { + const user = userEvent.setup(); + const betaTeams = vi.fn(async () => [team(["ONE", "TWO"])]); + runner.runBetaTeamStep.mockReset().mockImplementation(async () => { + betaTeams.mockResolvedValue([]); + return { finished: ["a1"], failed: [] }; + }); + const c = control({ betaTeams }); + const { rerender } = mount(c, writers("pending")); + await tabTo(user, "Choose instructions for Writers"); + await user.keyboard("{Enter}"); + await tabTo(user, "Finish team setup for Writers with instructions 2"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(runner.runBetaTeamStep).toHaveBeenCalled()); + // The finished agent's new status triggers the re-read that drops the row. + remount(rerender, c, "completed"); + expect( + await screen.findByText("All teams from old Buzz are set up."), + ).toBeVisible(); + const region = screen.getByRole("region", { name: "From old Buzz" }); + await waitFor(() => expect(region).toHaveFocus()); +}); + +it("forgets the focus handoff when Finish setup fails", async () => { + runner.runBetaTeamStep + .mockReset() + .mockResolvedValue({ finished: [], failed: ["refused"] }); + const betaTeams = vi.fn(async () => [team(["ONE"])]); + const c = control({ betaTeams }); + const { rerender } = mount(c, writers("pending")); + await userEvent.click( + await screen.findByRole("button", { + name: "Finish team setup for Writers", + }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent("refused"); + (document.activeElement as HTMLElement | null)?.blur(); + expect(document.body).toHaveFocus(); + // A later re-read removes the row without any new choice. + betaTeams.mockResolvedValue([]); + remount(rerender, c, "completed"); + expect( + await screen.findByText("All teams from old Buzz are set up."), + ).toBeVisible(); + expect(document.body).toHaveFocus(); +}); + +it("forgets the focus handoff once focus moves away during a successful choice", async () => { + const betaTeams = vi.fn(async () => [team(["ONE"])]); + let release = () => {}; + runner.runBetaTeamStep.mockReset().mockImplementation( + () => + new Promise((resolve) => { + release = () => { + betaTeams.mockResolvedValue([]); + resolve({ finished: ["a1"], failed: [] }); + }; + }), + ); + const c = control({ betaTeams }); + const { rerender } = render( + <> + + + , + ); + await userEvent.click( + await screen.findByRole("button", { + name: "Finish team setup for Writers", + }), + ); + await waitFor(() => expect(runner.runBetaTeamStep).toHaveBeenCalled()); + const elsewhere = screen.getByRole("button", { name: "Elsewhere" }); + act(() => elsewhere.focus()); + await act(async () => release()); + expect(elsewhere).toHaveFocus(); + // Leaving that control later must not revive the old handoff. + act(() => elsewhere.blur()); + rerender( + <> + + + , + ); + expect( + await screen.findByText("All teams from old Buzz are set up."), + ).toBeVisible(); + expect(document.body).toHaveFocus(); +}); + +it("retries a failed pending-team read and clears its error", async () => { + const betaTeams = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("Pending teams could not be read.")) + .mockResolvedValue([team(["ONE"])]); + mount( + control({ betaTeams, restoreBetaTeam: vi.fn(), restoreBetaTeams: vi.fn() }), + writers("pending"), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Pending teams could not be read.", + ); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect( + await screen.findByRole("button", { + name: "Finish team setup for Writers", + }), + ).toBeInTheDocument(); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +it("doesn't say it is reading teams while a team is being set up", async () => { + runner.runBetaTeamStep.mockReset().mockReturnValue(new Promise(() => {})); + mount( + control({ + betaTeams: vi.fn(async () => [team(["ONE"])]), + restoreBetaTeam: vi.fn(), + restoreBetaTeams: vi.fn(), + }), + [...writers("pending"), agent("a2", null)], + ); + fireEvent.click( + await screen.findByRole("button", { + name: "Finish team setup for Writers", + }), + ); + await waitFor(() => expect(runner.runBetaTeamStep).toHaveBeenCalled()); + expect(screen.queryByText("Reading teams from old Buzz…")).toBeNull(); + const find = screen.getByRole("button", { name: /Find teams/ }); + expect(find).not.toHaveAttribute("aria-busy", "true"); + expect(find).toBeDisabled(); +}); + +it("hands focus to the section when a pending row finishes beside a preview of the same team", async () => { + const user = userEvent.setup(); + const betaTeams = vi.fn(async () => [team(["ONE"])]); + runner.runBetaTeamStep.mockReset().mockImplementation(async () => { + betaTeams.mockResolvedValue([]); + return { finished: ["a1"], failed: [] }; + }); + const c = control({ + betaTeams, + restoreBetaTeam: vi.fn(), + restoreBetaTeams: vi.fn(async () => ({ + token: "t", + groups: [{ ...team(["ONE"]), inferred: false }], + })), + }); + const agents = (status: "pending" | "completed") => [ + ...writers(status), + agent("a2", null), + ]; + const view = (status: "pending" | "completed") => ( + + ); + const { rerender } = render(view("pending")); + await user.click(await screen.findByRole("button", { name: "Find teams" })); + await screen.findByRole("button", { name: "Restore team Writers" }); + await tabTo(user, "Finish team setup for Writers"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(runner.runBetaTeamStep).toHaveBeenCalled()); + rerender(view("completed")); + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Finish team setup for Writers" }), + ).toBeNull(), + ); + // The same team's preview row stays; it isn't the row the action came from. + expect( + screen.getByRole("button", { name: "Restore team Writers" }), + ).toBeVisible(); + const region = screen.getByRole("region", { name: "From old Buzz" }); + await waitFor(() => expect(region).toHaveFocus()); +}); + +it("keeps the focus handoff while the catalog reloads after a successful choice", async () => { + const user = userEvent.setup(); + const loading = { status: "loading", entries: [] }; + let status = "ready"; + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; + const reloading = { + ...session, + channelKit: { + snapshot: () => (status === "ready" ? ready : loading), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ensure: () => {}, + }, + } as unknown as RelaySession; + const betaTeams = vi.fn(async () => [team(["ONE"])]); + let release = () => {}; + runner.runBetaTeamStep.mockReset().mockImplementation( + () => + new Promise((resolve) => { + release = () => { + betaTeams.mockResolvedValue([]); + resolve({ finished: ["a1"], failed: [] }); + }; + }), + ); + render( + , + ); + await tabTo(user, "Finish team setup for Writers"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(runner.runBetaTeamStep).toHaveBeenCalled()); + // A background catalog read hides the section just as setup completes. + act(() => { + status = "loading"; + notify(); + }); + await act(async () => release()); + expect(screen.queryByRole("region", { name: "From old Buzz" })).toBeNull(); + act(() => { + status = "ready"; + notify(); + }); + const region = await screen.findByRole("region", { name: "From old Buzz" }); + await waitFor(() => expect(region).toHaveFocus()); +}); + +it("hands focus to the section after a keyboard retry succeeds", async () => { + const user = userEvent.setup(); + const betaTeams = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("Pending teams could not be read.")) + .mockResolvedValue([team(["ONE"])]); + mount( + control({ betaTeams, restoreBetaTeam: vi.fn(), restoreBetaTeams: vi.fn() }), + writers("completed"), + ); + expect( + await screen.findByRole("button", { name: /Find teams/ }), + ).toBeDisabled(); + await tabTo(user, "Try again"); + await user.keyboard("{Enter}"); + expect( + await screen.findByRole("button", { + name: "Finish team setup for Writers", + }), + ).toBeInTheDocument(); + expect(screen.queryByRole("alert")).toBeNull(); + const region = screen.getByRole("region", { name: "From old Buzz" }); + await waitFor(() => expect(region).toHaveFocus()); +}); + +it("forgets the focus handoff when the person clicks away while the catalog reloads", async () => { + const user = userEvent.setup(); + const loading = { status: "loading", entries: [] }; + let status = "ready"; + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; + const reloading = { + ...session, + channelKit: { + snapshot: () => (status === "ready" ? ready : loading), + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ensure: () => {}, + }, + } as unknown as RelaySession; + const betaTeams = vi.fn(async () => [team(["ONE"])]); + let release = () => {}; + runner.runBetaTeamStep.mockReset().mockImplementation( + () => + new Promise((resolve) => { + release = () => { + betaTeams.mockResolvedValue([]); + resolve({ finished: ["a1"], failed: [] }); + }; + }), + ); + render( + , + ); + await tabTo(user, "Finish team setup for Writers"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(runner.runBetaTeamStep).toHaveBeenCalled()); + act(() => { + status = "loading"; + notify(); + }); + await act(async () => release()); + // An empty area takes no focus, so only the pointer says they moved on. + await user.pointer({ keys: "[MouseLeft]", target: document.body }); + act(() => { + status = "ready"; + notify(); + }); + const region = await screen.findByRole("region", { name: "From old Buzz" }); + await act(async () => {}); + expect(region).not.toHaveFocus(); + expect(document.body).toHaveFocus(); +}); + +it("forgets the retry's focus handoff when the person clicks away during the read", async () => { + const user = userEvent.setup(); + let succeed = () => {}; + const betaTeams = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("Pending teams could not be read.")) + .mockImplementation( + () => + new Promise((resolve) => { + succeed = () => resolve([team(["ONE"])]); + }), + ); + mount( + control({ betaTeams, restoreBetaTeam: vi.fn(), restoreBetaTeams: vi.fn() }), + writers("completed"), + ); + expect( + await screen.findByRole("button", { name: /Find teams/ }), + ).toBeDisabled(); + await tabTo(user, "Try again"); + await user.keyboard("{Enter}"); + await waitFor(() => expect(betaTeams).toHaveBeenCalledTimes(2)); + await user.pointer({ keys: "[MouseLeft]", target: document.body }); + await act(async () => succeed()); + expect( + await screen.findByRole("button", { + name: "Finish team setup for Writers", + }), + ).toBeInTheDocument(); + await act(async () => {}); + expect( + screen.getByRole("region", { name: "From old Buzz" }), + ).not.toHaveFocus(); + expect(document.body).toHaveFocus(); +}); diff --git a/src/bundled/agents/BetaTeamSetup.tsx b/src/bundled/agents/BetaTeamSetup.tsx new file mode 100644 index 000000000..697e176ca --- /dev/null +++ b/src/bundled/agents/BetaTeamSetup.tsx @@ -0,0 +1,496 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { + AgentControl, + AgentControlState, + BetaTeamRestorePreview, + ImportSource, + PendingBetaTeam, +} from "../../features/agents/control"; +import { oldBuzzLabel } from "../../features/agents/control"; +import { runBetaTeamStep } from "../../features/agents/beta-team-import"; +import { sameCommunityAgents } from "../../features/agents/choices"; +import { sessionCommunity } from "../../features/agents/team-instructions"; +import type { RelaySession } from "../../features/relay/session"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { InlineHeader } from "../../shared/design-system/ui/Header"; +import { Select } from "../../shared/design-system/ui/Select"; +import { + ArrowsClockwiseIcon, + CheckCircleIcon, + UsersIcon, + WarningCircleIcon, +} from "../../shared/design-system/icons"; + +const message = (reason: unknown) => + reason instanceof Error ? reason.message : String(reason); + +const count = (n: number) => (n === 1 ? "1 agent" : `${n} agents`); + +/** Picks one of several old Buzz texts for a team, away from the page. */ +function ChooseInstructions({ + team, + action, + disabled, + onChoose, +}: { + team: PendingBetaTeam; + action: string; + disabled: boolean; + onChoose: (text: string) => void; +}) { + const [open, setOpen] = useState(false); + return ( + <> + +

+
+ {team.texts.map((text, index) => ( +
+
+                {text}
+              
+ +
+ ))} +
+
+ + ); +} + +/** One team row: what it is, why it's here, and its one action. */ +function TeamRow({ + row, + team, + names, + note, + stalled, + label, + action, + disabled, + onChoose, +}: { + row: string; + team: PendingBetaTeam; + names: string; + note?: string | undefined; + stalled?: boolean; + label: string; + action: string; + disabled: boolean; + onChoose: (text: string) => void; +}) { + const Icon = stalled ? WarningCircleIcon : UsersIcon; + return ( +
+
+ + +
+

{team.name}

+

+ {stalled + ? `Restored, but ${count(team.members.length)} still need to join it.` + : `${count(team.members.length)}${names && ` · ${names}`}`} +

+ {note &&

{note}

} + {team.texts.length > 1 && ( +

+ Members had different instructions in old Buzz. +

+ )} +
+
+
+ {team.texts.length > 1 ? ( + + ) : ( + + )} +
+
+ ); +} + +/** Finishes teams carried over from old Buzz in the open community, and + * restores them for agents imported before teams were recorded. Needs the + * community's ready team catalog; until then agents simply stay pending. */ +export function BetaTeamSetup({ + control, + state, + session, + viewer, +}: { + control: AgentControl; + state: AgentControlState; + session: RelaySession; + viewer: string; +}) { + const kit = useSyncExternalStore( + session.channelKit.subscribe, + session.channelKit.snapshot, + ); + useEffect(() => session.channelKit.ensure(), [session]); + const community = sessionCommunity(session.scope, viewer); + // Null until the first successful read: unknown is never "all set up". + const [pending, setPending] = useState(null); + const [preview, setPreview] = useState(null); + const [source, setSource] = useState("installed"); + // Which operation runs; every control waits on any of them. + const [busy, setBusy] = useState<"finding" | "setting" | null>(null); + const working = busy !== null; + const [problem, setProblem] = useState(null); + // The pending-team read fails apart from actions, and retries on its own. + const [readProblem, setReadProblem] = useState(null); + const [attempt, setAttempt] = useState(0); + const find = useRef(null); + const [refind, setRefind] = useState(false); + const section = useRef(null); + // A finished choice removes its row, and a successful retry its button, + // taking the focused control along. Holds the `data-settle` key whose + // removal hands focus to the section, from the action until that element + // goes, the action fails, or focus or a pointer moves elsewhere. A pending + // and a preview row can share a team, so rows are keyed by kind and team. + const settle = useRef(null); + const ready = kit.status === "ready" && state.status === "ready"; + // A refused restore needs a fresh preview. The control hides this section + // until status is confirmed again, so wait for an enabled Find to exist. + useEffect(() => { + if (!refind || working || !ready || !find.current) return; + find.current.focus(); + setRefind(false); + }, [refind, working, ready]); + useEffect(() => { + // Clicking empty space moves no focus, so pointers cancel too. A chosen + // text arms only on click, after its pointerdown, so dialogs don't cancel. + const moved = (event: Event) => { + const row = settle.current; + if (row === null || !(event.target instanceof Element)) return; + const at = event.target.closest("[data-settle]"); + if (at?.dataset.settle !== row) settle.current = null; + }; + document.addEventListener("focusin", moved); + document.addEventListener("pointerdown", moved); + return () => { + document.removeEventListener("focusin", moved); + document.removeEventListener("pointerdown", moved); + }; + }, []); + useEffect(() => { + const row = settle.current; + // A hidden section (catalog reloading) proves nothing; wait for it. + if (row === null || working || !section.current) return; + const marks = + section.current.querySelectorAll("[data-settle]"); + if ([...marks].some((el) => el.dataset.settle === row)) return; + settle.current = null; + const active = document.activeElement; + if (active === null || active === document.body) section.current.focus(); + }); + const agents = sameCommunityAgents(state.data?.agents ?? [], session.scope); + const unrecorded = agents.filter((agent) => agent.betaTeam === null); + // Re-read whenever an agent's team status or revision changes. + const statusKey = agents + .map((a) => `${a.id}:${a.revision}:${a.betaTeam?.status ?? ""}`) + .join(","); + useEffect(() => { + if (!ready || !control.betaTeams) return; + let live = true; + void statusKey; + void attempt; + control.betaTeams(community).then( + (teams) => { + if (!live) return; + setPending(teams); + setReadProblem(null); + }, + (reason) => { + if (!live) return; + if (settle.current === "retry") settle.current = null; + setPending(null); + setReadProblem(message(reason)); + }, + ); + return () => { + live = false; + }; + }, [control, community, ready, statusKey, attempt]); + if (!ready || !control.finishBetaTeam) return null; + const act = async ( + kind: "finding" | "setting", + task: () => Promise, + ) => { + setBusy(kind); + setProblem(null); + try { + setProblem((await task()) ?? null); + } catch (reason) { + setProblem(message(reason)); + } finally { + setBusy(null); + } + }; + // Arms the focus handoff for one choice; failure or refusal disarms it. + const settled = (row: string, task: () => Promise) => + act("setting", async () => { + settle.current = row; + try { + const failed = await task(); + if (failed !== undefined) settle.current = null; + return failed; + } catch (reason) { + settle.current = null; + throw reason; + } + }); + const finish = (team: PendingBetaTeam, text: string) => + settled(`pending:${team.teamId}`, async () => { + const { failed } = await runBetaTeamStep( + session.channelKit, + control, + community, + team, + { text }, + ); + return failed[0]; + }); + const restore = ( + preview: BetaTeamRestorePreview, + group: PendingBetaTeam, + text: string, + ) => + settled(`preview:${group.teamId}`, async () => { + if (!control.restoreBetaTeam || !control.betaTeams) + throw new Error("Teams from old Buzz are unavailable."); + await control + .restoreBetaTeam(community, preview.token, group.teamId, text) + .catch((reason) => { + setRefind(true); + throw reason; + }); + setPreview(null); + const team = (await control.betaTeams(community)).find( + (t) => t.teamId === group.teamId, + ); + if (!team) return undefined; + const { failed } = await runBetaTeamStep( + session.channelKit, + control, + community, + team, + { text }, + ); + return failed[0]; + }); + const canRestore = !!control.restoreBetaTeams && !!control.restoreBetaTeam; + // Only an inventory naming exactly one library hides the other; older + // hosts omit it, and an unreadable library can keep stale rows. + const sources = ["installed", "development"] as const; + const listed = sources.filter((value) => + state.data?.parked?.some((row) => row.sources.includes(value)), + ); + const libraries = listed.length === 1 ? listed : sources; + const library = + libraries.length === 1 ? (libraries[0] as ImportSource) : source; + const names = (team: PendingBetaTeam) => + team.members + .map((member) => agents.find((agent) => agent.id === member.id)?.name) + .filter(Boolean) + .join(", "); + const findTeams = () => + act("finding", async () => { + if (!control.restoreBetaTeams) + throw new Error("Teams from old Buzz are unavailable."); + // Finding again can expire the shown token, even on failure. + setPreview(null); + const next = await control.restoreBetaTeams( + library, + unrecorded.map((agent) => agent.id), + ); + setPreview(next); + return undefined; + }); + const known = pending ?? []; + const done = pending !== null && !pending.length && !unrecorded.length; + const body = () => { + if (busy === "finding") return "Reading teams from old Buzz…"; + if (done) return null; + if (preview && !preview.groups.length) + return `${oldBuzzLabel(library)} has no teams for your imported agents.${ + libraries.length > 1 + ? " If your agents also lived in the other library, choose it above and find again." + : "" + }`; + if (!preview && !known.length) + return "Find the teams your imported agents belonged to in old Buzz. Nothing changes until you restore a team."; + return null; + }; + const line = body(); + const rows = [ + ...known.map((team) => ( + void finish(team, text)} + /> + )), + ...(preview?.groups ?? []).map((group) => ( + preview && void restore(preview, group, text)} + /> + )), + ]; + return ( +
+ + {libraries.length > 1 && ( + @@ -373,11 +372,7 @@ export function InventoryIdentityCard({ {row.oldBuzzSources.length > 0 && (

{row.oldBuzzSources - .map((source) => - source === "installed" - ? "Installed Buzz" - : "Development Buzz", - ) + .map((source) => oldBuzzLabel(source)) .join(" · ")}

)} diff --git a/src/bundled/agents/InventoryView.test.tsx b/src/bundled/agents/InventoryView.test.tsx index d11fedc8c..620029c84 100644 --- a/src/bundled/agents/InventoryView.test.tsx +++ b/src/bundled/agents/InventoryView.test.tsx @@ -171,14 +171,18 @@ it("renders four exclusive sections with all setups on one exact-key card", asyn within(management).getAllByRole("button", { name: "Stop" }), ).toHaveLength(2); expect( - within(management).getByText("Installed Buzz · Development Buzz"), + within(management).getByText( + "Old Buzz (Installed) · Old Buzz (Development)", + ), ).not.toBeVisible(); fireEvent.click( within(management).queryByText("Identity & sources") ?? within(management).getByLabelText(/^Details for /), ); expect( - within(management).getByText("Installed Buzz · Development Buzz"), + within(management).getByText( + "Old Buzz (Installed) · Old Buzz (Development)", + ), ).toBeVisible(); fireEvent.click(within(management).getByRole("button", { name: "Close" })); expect( diff --git a/src/features/agents/beta-team-import.test.ts b/src/features/agents/beta-team-import.test.ts new file mode 100644 index 000000000..4bd7aaaea --- /dev/null +++ b/src/features/agents/beta-team-import.test.ts @@ -0,0 +1,261 @@ +import { expect, it, vi } from "vitest"; +import type { ChannelKit } from "../channel-templates/capability"; +import type { KitEntry, Team } from "../channel-templates/model"; +import type { AgentControl, PendingBetaTeam } from "./control"; +import { betaTeamConflict, runBetaTeamStep } from "./beta-team-import"; +import { teamTextConflict } from "./team-instructions"; + +const a = "a".repeat(64); +const b = "b".repeat(64); +const id = "beta-1"; +const pending = (pubkeys = [a, b]): PendingBetaTeam => ({ + teamId: id, + name: "Reviewers", + texts: ["BETA"], + members: pubkeys.map((pubkey, i) => ({ + id: `agent-${i}`, + pubkey, + revision: 1, + })), +}); +const manifest = { + version: 1 as const, + owner: "c".repeat(64), + revision: "r", + digest: "d".repeat(64), + bytes: 1, + chunks: 1, +}; + +/** A catalog with one entry per team and one text head per team. `text` + * missing means no head; `null` is a tombstone. */ +function fixture({ + teams = [] as (Team & { deleted?: boolean })[], + heads = {} as Record, + portableText = "", +} = {}) { + let events = 0; + const entries = new Map( + teams.map(({ deleted = false, ...team }) => [ + team.id, + { + eventId: `head-${events++}`, + createdAt: 1, + record: { version: 1, community: "c", deleted, value: team }, + }, + ]), + ); + const text = new Map(Object.entries(heads)); + const kit = { + refresh: vi.fn(async () => {}), + snapshot: () => ({ status: "ready", entries: [...entries.values()] }), + readText: vi.fn(async (team: string) => + text.has(team) + ? { text: text.get(team) ?? "", head: "text-head" } + : undefined, + ), + loadTeam: vi.fn(async () => ({})), + save: vi.fn(async (value: Team, expected: string | undefined) => { + expect(expected).toBe(entries.get(value.id)?.eventId); + const eventId = `head-${events++}`; + entries.set(value.id, { + eventId, + createdAt: 1, + record: { version: 1, community: "c", deleted: false, value }, + }); + return eventId; + }), + prepareText: vi.fn(async () => manifest), + publishText: vi.fn( + async ( + team: string, + _m: unknown, + expected: string | undefined, + roster: string, + ) => { + expect(expected).toBeUndefined(); + expect(roster).toBe(entries.get(team)?.eventId); + text.set(team, "BETA"); + return "text-head"; + }, + ), + }; + const finished: Record = {}; + const finishBetaTeam = vi.fn( + async ( + _community: string, + _texts: Record, + agent: string, + _revision: number, + outcome: "completed" | "skipped", + ) => { + const pubkey = agent === "agent-0" ? a : b; + const listed = ( + entries.get(id)?.record.value as Team | undefined + )?.agents.includes(pubkey); + if ((outcome === "completed") !== !!listed) + throw new Error(`${agent} not listed`); + finished[agent] = outcome; + return { agents: [] } as never; + }, + ); + const control = { + finishBetaTeam, + previewTeam: vi.fn(async () => ({ + team: { name: "", instructions: portableText }, + })), + snapshot: () => ({ data: { agents: [{ id: "agent-0", revision: 7 }] } }), + } as unknown as AgentControl; + return { + kit: kit as unknown as ChannelKit, + raw: kit, + control, + finishBetaTeam, + finished, + entries, + text, + }; +} +const run = (f: ReturnType, team = pending()) => + runBetaTeamStep(f.kit, f.control, "wss://c", team, { text: "BETA" }); +const roster = (f: ReturnType) => + (f.entries.get(id)?.record.value as Team | undefined)?.agents; + +it("creates the team with its old Buzz text and finishes every member", async () => { + const f = fixture(); + expect(await run(f)).toEqual({ + finished: ["agent-0", "agent-1"], + failed: [], + }); + expect(roster(f)).toEqual([a, b]); + expect(f.text.get(id)).toBe("BETA"); + // The current revision wins over the listed one for an ordinary finish. + expect(f.finishBetaTeam.mock.calls.map((c) => c[3])).toEqual([7, 1]); +}); + +it("reruns after the roster was saved but not the text without saving it again", async () => { + const f = fixture({ + teams: [{ type: "team", id, name: "Reviewers", agents: [a, b] }], + }); + await run(f); + expect(f.raw.save).not.toHaveBeenCalled(); + expect(f.raw.publishText).toHaveBeenCalledOnce(); +}); + +it("adds a later member to an existing team and keeps its saved text", async () => { + const f = fixture({ + teams: [{ type: "team", id, name: "Renamed", agents: [a] }], + heads: { [id]: "EDITED" }, + }); + await run(f, pending([b])); + expect(f.raw.save).toHaveBeenCalledWith( + { type: "team", id, name: "Renamed", agents: [a, b] }, + "head-0", + ); + expect(f.raw.prepareText).not.toHaveBeenCalled(); + expect(f.text.get(id)).toBe("EDITED"); +}); + +it.each([ + ["cleared to empty", { heads: { [id]: "" } }], + ["with deleted instructions", { heads: { [id]: null } }], + ["from a portable file with its own text", { portableText: "FROM FILE" }], +])("never refills a team %s", async (_, options) => { + const f = fixture({ + teams: [ + { + type: "team", + id, + name: "Reviewers", + agents: [a, b], + portable: manifest, + }, + ], + ...options, + }); + await run(f); + expect(f.raw.prepareText).not.toHaveBeenCalled(); + expect(f.raw.publishText).not.toHaveBeenCalled(); + expect(f.finished).toEqual({ + "agent-0": "completed", + "agent-1": "completed", + }); +}); + +it("leaves a deleted team deleted and skips its members", async () => { + const f = fixture({ + teams: [{ type: "team", id, name: "Reviewers", agents: [], deleted: true }], + }); + await run(f); + expect(f.raw.save).not.toHaveBeenCalled(); + expect(f.raw.publishText).not.toHaveBeenCalled(); + expect(f.finished).toEqual({ "agent-0": "skipped", "agent-1": "skipped" }); +}); + +it("keeps a member removed before its finish pending", async () => { + const f = fixture(); + f.raw.publishText.mockImplementationOnce(async () => { + const team = f.entries.get(id)?.record.value as Team; + team.agents = [a]; + return "text-head"; + }); + const outcome = await run(f); + expect(outcome.finished).toEqual(["agent-0"]); + expect(outcome.failed).toEqual(["agent-1 not listed"]); + expect(f.finished).toEqual({ "agent-0": "completed" }); +}); + +it("refuses before any write when a member would get two team texts", async () => { + const f = fixture({ + teams: [{ type: "team", id: "other", name: "Writers", agents: [a] }], + heads: { other: "OTHER" }, + }); + await expect( + betaTeamConflict(f.kit, f.control, pending(), "BETA"), + ).resolves.toContain('"Writers"'); + await expect(run(f)).rejects.toThrow('"Writers"'); + expect(f.raw.save).not.toHaveBeenCalled(); + expect(f.finishBetaTeam).not.toHaveBeenCalled(); +}); + +it("documented limit: a text edit to an overlapping team during a held read is not fenced", async () => { + // Both teams agree on OTHER, so adding b to the target passes the check. + // While the target read is held, another device changes only Writers' + // text. The roster save guards only the target's own record, so it lands; + // the clash surfaces afterwards, never as a refusal before the save. + const f = fixture({ + teams: [ + { type: "team", id, name: "Reviewers", agents: [a] }, + { type: "team", id: "other", name: "Writers", agents: [b] }, + ], + heads: { [id]: "OTHER", other: "OTHER" }, + }); + let release!: () => void; + const held = new Promise((resolve) => (release = resolve)); + let targetReads = 0; + f.raw.readText.mockImplementation(async (team: string) => { + const text = f.text.get(team) ?? ""; + if (team === id && ++targetReads === 2) await held; + return { text, head: "text-head" }; + }); + // Like native finish, refuse when the texts it is given clash. + f.finishBetaTeam.mockImplementation(async (_c, texts, agent) => { + const teams = [...f.entries.values()].map((e) => e.record.value as Team); + const others = teams.map((team) => ({ team, text: texts[team.id] })); + const target = { id, agents: roster(f) ?? [] }; + const conflict = teamTextConflict(others, target, texts[id] ?? ""); + if (conflict) throw new Error(conflict); + f.finished[agent] = "completed"; + return { agents: [] } as never; + }); + const step = run(f, pending([b])); + await vi.waitFor(() => expect(targetReads).toBe(2)); + f.text.set("other", "EDITED"); + release(); + const outcome = await step; + expect(f.raw.save).toHaveBeenCalledOnce(); + expect(roster(f)).toEqual([a, b]); + expect(outcome.finished).toEqual([]); + expect(outcome.failed).toEqual([expect.stringContaining('"Writers"')]); + expect(f.finished).not.toHaveProperty("agent-0"); +}); diff --git a/src/features/agents/beta-team-import.ts b/src/features/agents/beta-team-import.ts new file mode 100644 index 000000000..1d02dfdc8 --- /dev/null +++ b/src/features/agents/beta-team-import.ts @@ -0,0 +1,176 @@ +import type { ChannelKit } from "../channel-templates/capability"; +import type { Team } from "../channel-templates/model"; +import type { AgentControl, PendingBetaTeam } from "./control"; +import { + readableTexts, + readTeamTexts, + resolveTeamText, + teamTextConflict, +} from "./team-instructions"; + +export type BetaTeamOutcome = { finished: string[]; failed: string[] }; + +const liveTeam = (kit: ChannelKit, teamId: string) => { + const entry = kit + .snapshot() + .entries.find( + (e) => e.record.value.type === "team" && e.record.value.id === teamId, + ); + return entry && { entry, team: entry.record.value as Team }; +}; + +/** What the step would write against the current catalog: the roster with + * every pending member, and the old Buzz text only when the team has never + * had instructions saved (no text head and no text in a portable file). */ +async function plan( + kit: ChannelKit, + control: AgentControl, + team: PendingBetaTeam, + text: string, +) { + const current = liveTeam(kit, team.teamId); + if (current?.entry.record.deleted) return { deleted: true as const }; + const saved = current + ? await resolveTeamText(kit, control, current.team) + : { text: "", head: undefined }; + const publish = + saved.head === undefined && !saved.text.trim() && !!text.trim(); + const agents = [ + ...new Set([ + ...(current?.team.agents ?? []), + ...team.members.map((m) => m.pubkey), + ]), + ]; + const roster: Team = current + ? { ...current.team, agents } + : { type: "team", id: team.teamId, name: team.name, agents }; + return { + deleted: false as const, + head: current?.entry.eventId, + roster, + rosterChanged: agents.length !== current?.team.agents.length, + publish, + text: publish ? text : saved.text, + }; +} + +/** Plans against a fresh catalog and validates that exact plan, so the + * caller writes only what was checked. */ +async function checkedPlan( + kit: ChannelKit, + control: AgentControl, + team: PendingBetaTeam, + text: string, +) { + const others = await readTeamTexts(kit, control); + const next = await plan(kit, control, team, text); + const conflict = next.deleted + ? undefined + : teamTextConflict(others, next.roster, next.text); + return { next, conflict }; +} + +/** A save the step would refuse because a member would get two different + * team texts. Reads a fresh catalog. */ +export async function betaTeamConflict( + kit: ChannelKit, + control: AgentControl, + team: PendingBetaTeam, + text: string, +) { + return (await checkedPlan(kit, control, team, text)).conflict; +} + +/** Brings one team from old Buzz into the current catalog and finishes its + * pending agents. Keeps no record of its own: every run reads the catalog as + * it is now and fills in what is missing, so a rerun after any failure is + * harmless. A deleted team stays deleted and its agents are skipped. The + * native finish re-reads the roster, so a member removed meanwhile stays + * pending. */ +export async function runBetaTeamStep( + kit: ChannelKit, + control: AgentControl, + community: string, + team: PendingBetaTeam, + choice: { text: string }, +): Promise { + const finish = control.finishBetaTeam; + if (!finish) throw new Error("Teams from old Buzz are unavailable."); + const check = async () => { + const { next, conflict } = await checkedPlan( + kit, + control, + team, + choice.text, + ); + if (conflict) throw new Error(conflict); + return next; + }; + const next = await check(); + if (!next.deleted) { + let head = next.head; + // The save guards only this team's record: the relay cannot make it + // depend on another record, so a text-only edit to an overlapping team + // after the check goes undetected. Native finish and the delivery check + // both refuse then, so the agent stays pending and never runs with mixed + // instructions; the clashing roster stays until either team is edited. + if (next.rosterChanged) head = await kit.save(next.roster, next.head); + if (next.publish) { + const manifest = await kit.prepareText( + team.teamId, + choice.text, + crypto.randomUUID(), + ); + await check(); + await kit.publishText(team.teamId, manifest, undefined, head); + } + } + const texts = readableTexts(await readTeamTexts(kit, control)); + const outcome: BetaTeamOutcome = { finished: [], failed: [] }; + for (const member of team.members) { + // An earlier finish can re-bind later members and bump their revision. + const revision = + control.snapshot().data?.agents.find((a) => a.id === member.id) + ?.revision ?? member.revision; + try { + await finish( + community, + texts, + member.id, + revision, + next.deleted ? "skipped" : "completed", + ); + outcome.finished.push(member.id); + } catch (reason) { + outcome.failed.push( + reason instanceof Error ? reason.message : String(reason), + ); + } + } + return outcome; +} + +/** The team step right after one agent is imported. Returns why its team + * isn't finished yet, or `undefined` once nothing is left to do. Several old + * Buzz texts wait for the user to pick one in Finish team setup. */ +export async function setUpImportedTeam( + kit: ChannelKit, + control: AgentControl, + community: string, + teamId: string, +): Promise { + try { + const team = (await control.betaTeams?.(community))?.find( + (t) => t.teamId === teamId, + ); + if (!team) return undefined; + if (team.texts.length > 1) + return `Members of "${team.name}" had different team instructions in old Buzz. Choose one under From old Buzz on the Agents page.`; + const { failed } = await runBetaTeamStep(kit, control, community, team, { + text: team.texts[0] ?? "", + }); + return failed[0]; + } catch (reason) { + return reason instanceof Error ? reason.message : String(reason); + } +} diff --git a/src/features/agents/control-native.ts b/src/features/agents/control-native.ts index f24ad70ad..5413b298d 100644 --- a/src/features/agents/control-native.ts +++ b/src/features/agents/control-native.ts @@ -23,6 +23,26 @@ export function nativeAgentControlHost(): AgentControlHost | null { syncTeamInstructions: (community, teams) => invoke("agent_control_team_sync", { community, teams }), previewTeam: (content) => invoke("agent_control_team_preview", { content }), + betaTeams: (community) => invoke("agent_control_beta_teams", { community }), + importBetaText: (token, id) => + invoke("agent_control_import_beta_text", { token, id }), + restoreBetaTeams: (source, ids) => + invoke("agent_control_beta_team_restore_preview", { source, ids }), + finishBetaTeam: (community, teams, id, revision, outcome) => + invoke("agent_control_beta_team_finish", { + community, + teams, + id, + revision, + outcome, + }), + restoreBetaTeam: (community, token, teamId, text) => + invoke("agent_control_beta_team_restore", { + community, + token, + teamId, + text, + }), publishProfile: (id) => invoke("agent_control_creation_profile", { id }), writeSnapshotMemory: (id, entries) => invoke("agent_control_snapshot_memory_write", { id, entries }), diff --git a/src/features/agents/control.ts b/src/features/agents/control.ts index 78bfaf413..9e3ce2dd9 100644 --- a/src/features/agents/control.ts +++ b/src/features/agents/control.ts @@ -12,6 +12,9 @@ declare module "@deepseek-ai/cordis" { } export type AgentAction = "start" | "stop" | "restart"; export type ImportSource = "installed" | "development"; +/** How every picker names an old Buzz library. */ +export const oldBuzzLabel = (source: ImportSource) => + source === "installed" ? "Old Buzz (Installed)" : "Old Buzz (Development)"; /** Native-redacted saved-versus-running difference; raw values never cross IPC. */ export type RestartChange = | { kind: "value"; before: unknown; after: unknown } @@ -98,9 +101,33 @@ export interface AgentView { deployedRemote?: boolean; /** Older imports need an explicit snapshot of their legacy team instructions. */ needsTeamImport?: boolean; + /** The team this agent had in old Buzz, once recorded. Never its text. */ + betaTeam?: BetaTeamView | null; /** Absent on older hosts means an existing configured setup. */ configured?: boolean; } +export interface BetaTeamView { + teamId: string; + name: string; + status: "pending" | "completed" | "skipped"; +} +export interface BetaTeamMember { + id: string; + pubkey: string; + revision: number; +} +/** Unfinished agents of one team from old Buzz. */ +export interface PendingBetaTeam { + teamId: string; + name: string; + /** Distinct old Buzz texts of the members, normally one. */ + texts: string[]; + members: BetaTeamMember[]; +} +export interface BetaTeamRestorePreview { + token: string; + groups: (PendingBetaTeam & { inferred: boolean })[]; +} export interface ParkedIdentity { pubkey: string; name: string; @@ -208,6 +235,7 @@ export interface AgentImportPreview { candidates: (Pick & { /** The imported prompt drops a team section old Buzz baked into it. */ stripsTeamInstructions?: boolean; + team?: BetaTeamView | null; })[]; warnings: string[]; } @@ -284,6 +312,31 @@ export interface AgentControlHost { teams: Record, ): Promise; previewTeam?(content: string): Promise; + betaTeams?(community: string): Promise; + /** One previewed candidate's team text from old Buzz, read-only, for the + * clash check before import. */ + importBetaText?(token: string, id: string): Promise; + restoreBetaTeams?( + source: ImportSource, + ids: string[], + ): Promise; + /** Finishes one agent's team from old Buzz in one native write. `teams` + * maps every readable team to its current text, as for team sync. */ + finishBetaTeam?( + community: string, + teams: Record, + id: string, + revision: number, + outcome: "completed" | "skipped", + ): Promise; + /** Starts one restore-preview group with the chosen text in one write; its + * members then finish like any pending agent. */ + restoreBetaTeam?( + community: string, + token: string, + teamId: string, + text: string, + ): Promise; publishProfile?(id: string): Promise; writeSnapshotMemory?( id: string, @@ -370,6 +423,31 @@ export interface AgentControl { teams: Record, ): Promise; previewTeam?(content: string): Promise; + betaTeams?(community: string): Promise; + /** One previewed candidate's team text from old Buzz, read-only, for the + * clash check before import. */ + importBetaText?(token: string, id: string): Promise; + restoreBetaTeams?( + source: ImportSource, + ids: string[], + ): Promise; + /** Finishes one agent's team from old Buzz in one native write. `teams` + * maps every readable team to its current text, as for team sync. */ + finishBetaTeam?( + community: string, + teams: Record, + id: string, + revision: number, + outcome: "completed" | "skipped", + ): Promise; + /** Starts one restore-preview group with the chosen text in one write; its + * members then finish like any pending agent. */ + restoreBetaTeam?( + community: string, + token: string, + teamId: string, + text: string, + ): Promise; publishProfile?(id: string): Promise; writeSnapshotMemory?: AgentControlHost["writeSnapshotMemory"]; setStartOnAppLaunch?(id: string, enabled: boolean): Promise; @@ -757,6 +835,59 @@ export function createAgentControl( }, } : {}), + ...(host?.betaTeams + ? { + betaTeams: (community: string) => { + if (!host.betaTeams) + throw new Error("Teams from old Buzz are unavailable."); + return host.betaTeams(community); + }, + } + : {}), + ...(host?.importBetaText + ? { + importBetaText: (token: string, id: string) => { + if (!host.importBetaText) + throw new Error("Teams from old Buzz are unavailable."); + return host.importBetaText(token, id); + }, + } + : {}), + ...(host?.restoreBetaTeams + ? { + restoreBetaTeams: (source: ImportSource, ids: string[]) => { + if (!host.restoreBetaTeams) + throw new Error("Teams from old Buzz are unavailable."); + return host.restoreBetaTeams(source, ids); + }, + } + : {}), + ...(host?.finishBetaTeam + ? { + finishBetaTeam: ( + ...args: Parameters> + ) => + run(async (host) => { + if (!host.finishBetaTeam) + throw new Error("Teams from old Buzz are unavailable."); + return host.finishBetaTeam(...args); + }, ready), + } + : {}), + ...(host?.restoreBetaTeam + ? { + restoreBetaTeam: ( + ...args: Parameters< + NonNullable + > + ) => + run(async (host) => { + if (!host.restoreBetaTeam) + throw new Error("Teams from old Buzz are unavailable."); + return host.restoreBetaTeam(...args); + }, ready), + } + : {}), ...(host?.publishProfile ? { publishProfile: (id: string) => diff --git a/src/features/agents/team-instructions.ts b/src/features/agents/team-instructions.ts index 0f7794764..dbb5562c0 100644 --- a/src/features/agents/team-instructions.ts +++ b/src/features/agents/team-instructions.ts @@ -107,6 +107,18 @@ export function teamTextConflict( : undefined; } +/** The community part of a relay session's scope, as native team calls take it. */ +export const sessionCommunity = (scope: string, viewer: string) => + scope.slice(0, -(viewer.length + 1)); + +/** The text map native team calls take: unreadable teams are left out. */ +export const readableTexts = (texts: readonly TeamText[]) => + Object.fromEntries( + texts.flatMap(({ team, text }) => + text === undefined ? [] : [[team.id, text]], + ), + ); + /** The agent and two teams that make a delivery impossible: one of the * owner's agents is listed on two teams with different text. Names them so * the user knows which team to edit. */ @@ -184,17 +196,12 @@ async function deliverNow( session: RelaySession | undefined, ) { if (!control?.syncTeamInstructions || !session?.viewer) return; - const relay = session.scope.slice(0, -(session.viewer.length + 1)); const texts = await readTeamTexts(kit, control); const conflict = deliveryConflict(texts, control, session.scope); if (conflict) throw new Error(conflict); await control.syncTeamInstructions( - relay, - Object.fromEntries( - texts.flatMap(({ team, text }) => - text === undefined ? [] : [[team.id, text]], - ), - ), + sessionCommunity(session.scope, session.viewer), + readableTexts(texts), ); setSyncError(kit, undefined); } diff --git a/src/shared/design-system/styles/content-header.css b/src/shared/design-system/styles/content-header.css index 0862f5b06..e1f2b6e40 100644 --- a/src/shared/design-system/styles/content-header.css +++ b/src/shared/design-system/styles/content-header.css @@ -1,12 +1,14 @@ @layer components { .buzz-content-header { display: flex; + flex-wrap: wrap; align-items: start; gap: var(--space-3); margin-bottom: var(--space-5); } + /* Actions drop below the copy, whole, before the copy gets too narrow. */ .buzz-content-header-copy { - flex: 1; + flex: 1 1 12rem; min-width: 0; } .buzz-content-header-title { @@ -28,6 +30,13 @@ display: flex; flex-shrink: 0; } + .buzz-content-header-actions { + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); + max-width: 100%; + margin-inline-start: auto; + } .buzz-content-header[data-inline] { margin-bottom: var(--space-3); } diff --git a/tests/browser/agent-control.spec.mjs b/tests/browser/agent-control.spec.mjs index f779d7b1b..90104e446 100644 --- a/tests/browser/agent-control.spec.mjs +++ b/tests/browser/agent-control.spec.mjs @@ -405,7 +405,7 @@ test("local controls preserve drafts, confirm operations and distinguish disable .getByRole("combobox", { name: "Source library", exact: true }) .click(); await page - .getByRole("option", { name: "Development Buzz", exact: true }) + .getByRole("option", { name: "Old Buzz (Development)", exact: true }) .click(); await panel .getByLabel("Destination community", { exact: true }) @@ -1328,7 +1328,7 @@ for (const changed of ["destination", "source"]) { .getByRole("combobox", { name: "Source library", exact: true }) .click(); await page - .getByRole("option", { name: "Development Buzz", exact: true }) + .getByRole("option", { name: "Old Buzz (Development)", exact: true }) .click(); } if (changed === "destination") { @@ -1373,7 +1373,7 @@ for (const changed of ["destination", "source"]) { .getByRole("combobox", { name: "Source library", exact: true }) .click(); await page - .getByRole("option", { name: "Installed Buzz", exact: true }) + .getByRole("option", { name: "Old Buzz (Installed)", exact: true }) .click(); } await page.evaluate(() => window.agentControlFixture.releasePreview()); @@ -1533,7 +1533,7 @@ test("card Import opens a focused review and restores focus after dismissal", as dialog.getByRole("button", { name: "Import agent" }), ).toBeEnabled(); await expect( - dialog.getByText("Development Buzz", { exact: true }), + dialog.getByText("Old Buzz (Development)", { exact: true }), ).toBeVisible(); await expect(dialog.getByLabel("Source library")).toHaveCount(0); await expect(