diff --git a/.github/workflows/voltage-buzz-gpui.yml b/.github/workflows/voltage-buzz-gpui.yml index b830e20dab5..0f8a1fdbddf 100644 --- a/.github/workflows/voltage-buzz-gpui.yml +++ b/.github/workflows/voltage-buzz-gpui.yml @@ -149,7 +149,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14, windows-latest] + os: [macos-15, windows-latest] steps: # The buzz-app git dependencies have paths past MAX_PATH. - if: runner.os == 'Windows' diff --git a/apps/buzz-gpui/README.md b/apps/buzz-gpui/README.md index 00ee5224979..37b32ce2833 100644 --- a/apps/buzz-gpui/README.md +++ b/apps/buzz-gpui/README.md @@ -8,6 +8,9 @@ same look. It can also manage agents running on other machines through See [ARCHITECTURE.md](ARCHITECTURE.md) for the design. +Voltage builds also support moving saved teams or selected agents through **Agents → Your agents → Move to remote**. +See [Move existing agents to Remote workspace](docs/remote-agent-migration.md) for setup, preserved settings, and recovery. + ## Laptop: install the app (Ubuntu) ```sh diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/mod.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/mod.rs index b72a5b71c4e..c83eb24162c 100644 --- a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/mod.rs +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/mod.rs @@ -43,6 +43,17 @@ pub fn init(cx: &mut App) { struct RemoteWorkspace; +/// Shared destination editor for a batch; per-agent instructions and +/// environments are deliberately absent from this form. +pub(crate) fn migration_settings( + view: crate::agents2::AgentView, + window: &mut Window, + cx: &mut App, +) -> AnyView { + cx.new(|cx| settings::SettingsTab::shared(view, window, cx)) + .into() +} + impl AgentType for RemoteWorkspace { fn id(&self) -> &'static str { TYPE_ID diff --git a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/settings.rs b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/settings.rs index 50e09006898..6586a75f7ed 100644 --- a/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/settings.rs +++ b/apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/settings.rs @@ -31,6 +31,7 @@ pub enum Picker { const FAST: &[(&str, &str)] = &[("off", "Off"), ("on", "On")]; pub struct SettingsTab { + shared_destination: bool, view: AgentView, /// The saved settings the draft started from. saved: Config, @@ -134,6 +135,7 @@ impl SettingsTab { })); } let mut tab = Self { + shared_destination: false, view, runtime: saved.runtime.clone(), effort: saved.effort.clone(), @@ -152,6 +154,14 @@ impl SettingsTab { tab } + /// Batch settings leave each agent's instructions intact and use the + /// enrolled profile for credentials rather than copying environments. + pub fn shared(view: AgentView, window: &mut Window, cx: &mut Context) -> Self { + let mut tab = Self::new(view, window, cx); + tab.shared_destination = true; + tab + } + fn reset(&mut self, saved: Config, window: &mut Window, cx: &mut Context) { self.model.update(cx, |state, cx| { state.set_value(saved.model.clone(), window, cx) @@ -506,7 +516,7 @@ impl Render for SettingsTab { &c, ))), ) - .child(field( + .when(!self.shared_destination, |body| body.child(field( "Environment", TextArea::new(&self.env) .rows(6) @@ -523,7 +533,7 @@ impl Render for SettingsTab { saving, "Sent as the harness's system prompt when a thread's Sandbox starts.", &c, - )) + ))) .map(|this| { let sending = self.enroll.sending; let owner = enroll_form::owner(cx).map(|owner| owner.pubkey_hex()); diff --git a/apps/buzz-gpui/crates/app/src/agents2/mod.rs b/apps/buzz-gpui/crates/app/src/agents2/mod.rs index 82d5bcb36be..47dd6f30304 100644 --- a/apps/buzz-gpui/crates/app/src/agents2/mod.rs +++ b/apps/buzz-gpui/crates/app/src/agents2/mod.rs @@ -22,6 +22,8 @@ pub mod listings; pub mod objects; mod presence; pub mod registry; +#[cfg(feature = "voltage-sandbox")] +pub mod remote_migration; pub mod runtime; mod service; pub mod store; diff --git a/apps/buzz-gpui/crates/app/src/agents2/remote_migration.rs b/apps/buzz-gpui/crates/app/src/agents2/remote_migration.rs new file mode 100644 index 00000000000..eb3bcbf544e --- /dev/null +++ b/apps/buzz-gpui/crates/app/src/agents2/remote_migration.rs @@ -0,0 +1,498 @@ +//! Reviewed batch changes of existing identities to the Remote workspace type. +//! The journal precedes every host mutation; interrupted changes are reconciled +//! against their exact destination rather than blindly repeated. + +use super::{Agent, CarryKey, RetypePreview, Status, store::Team}; +use crate::agent_plugins::remote_workspace::{config::Config, config::MANAGER_PUBKEY}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::io::{Read, Write}; +use std::path::PathBuf; + +/// The existing agent type; migration never creates another identity. +pub const TARGET: &str = "voltage.remote-workspace/sandbox"; +/// A batch fits in one review and bounds host work and recovery state. +pub const LIMIT: usize = 32; +const JOURNAL_LIMIT: u64 = 4 * 1024 * 1024; + +/// The identity and community that authorized the batch. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Scope { + pub owner: String, + pub relay: String, +} + +/// The authoritative inventory used for selection and before each change. +pub struct Snapshot { + pub scope: Scope, + pub ready: bool, + pub agents: Vec, + pub teams: Vec, +} + +/// Only the fields a provider change depends on; attention stays live. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Before { + pub pubkey: String, + pub name: String, + pub kind: String, + pub config: serde_json::Value, +} + +impl Before { + fn of(agent: &Agent) -> Self { + Self { + pubkey: agent.pubkey.clone(), + name: agent.name.clone(), + kind: agent.agent_type.clone(), + config: agent.config.clone(), + } + } +} + +/// One reviewed change, including settings retained for recovery. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Item { + pub before: Before, + pub after: serde_json::Value, + pub warnings: Vec, +} + +/// A saved team's roster is fenced, but its live instructions remain live. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Roster { + pub id: String, + pub members: BTreeSet, +} + +/// Durable status; cancelling explicitly abandons the remaining changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum State { + Active, + Complete, + Cancelled, +} + +/// A reviewable batch bound to its owner, sources, roster, and reference. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Plan { + version: u8, + pub scope: Scope, + pub roster: Option, + pub reference: Option, + pub items: Vec, + pub already_remote: Vec, + pub completed: BTreeSet, + pub state: State, +} + +/// The next action after reconciliation. +#[derive(Debug, PartialEq, Eq)] +pub enum Next { + Complete, + Reconcile(usize), + Change(usize, Change), +} + +/// A crash can leave the host's type changed before its local config saves. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Change { + Retype, + FinishConfig, +} + +/// A persisted step can start a host request, reconcile one, or finish. +pub enum Step { + Complete, + Reconciled, + Started(usize, T), +} + +/// What checked steps require from persistence, also used by failure tests. +pub trait Journal { + fn save(&self, plan: &Plan) -> Result<(), String>; +} + +/// A new configuration needs no already-remote reference agent. +pub fn defaults(scope: &Scope) -> Config { + Config { + provider_config: Config::relay(&scope.relay, MANAGER_PUBKEY), + ..Config::default() + } +} + +impl Snapshot { + fn agent(&self, pubkey: &str) -> Result<&Agent, String> { + let agent = self + .agents + .iter() + .find(|agent| agent.pubkey == pubkey) + .ok_or("A selected agent is no longer available here.")?; + if agent.owner != self.scope.owner || agent.relay != self.scope.relay { + return Err("Select agents owned by this account in this community.".into()); + } + Ok(agent) + } + + /// Resolve an entire saved team by its exact member keys. + pub fn roster(&self, id: &str) -> Result { + let team = self + .teams + .iter() + .find(|team| team.id == id) + .ok_or("The selected team no longer exists.")?; + Ok(Roster { + id: team.id.clone(), + members: team.members.iter().cloned().collect(), + }) + } +} + +impl Plan { + /// Build a batch from the same provider previews used by individual changes. + /// Known harnesses keep their model options; other types explicitly review + /// the selected destination runtime and any settings that stay behind. + pub fn review( + snapshot: &Snapshot, + selected: &BTreeSet, + team: Option<&str>, + reference: Option<&str>, + destination: &Config, + mut preview: impl FnMut(&str) -> Result, + ) -> Result { + if !snapshot.ready { + return Err("Wait for the agent inventory to finish loading.".into()); + } + if selected.is_empty() || selected.len() > LIMIT { + return Err(format!("Select between 1 and {LIMIT} agents.")); + } + destination.validate()?; + let reference = reference + .map(|key| -> Result { + let agent = snapshot.agent(key)?; + if agent.agent_type != TARGET { + return Err("The reference must be a Remote workspace agent.".into()); + } + Ok(Before::of(agent)) + }) + .transpose()?; + if let Some(reference) = &reference { + let mut expected = Config::read(&reference.config); + expected.env.clear(); + expected.instructions.clear(); + let mut supplied = destination.clone(); + supplied.env.clear(); + supplied.instructions.clear(); + if expected != supplied { + return Err( + "The reference configuration changed. Select it again before reviewing.".into(), + ); + } + } + let roster = team.map(|id| snapshot.roster(id)).transpose()?; + if roster + .as_ref() + .is_some_and(|team| !selected.is_subset(&team.members)) + { + return Err("The selected agents are no longer members of that team.".into()); + } + let mut items = Vec::new(); + let mut already_remote = Vec::new(); + for pubkey in selected { + let agent = snapshot.agent(pubkey)?; + if agent.agent_type == TARGET { + already_remote.push(agent.name.clone()); + continue; + } + let migration = preview(pubkey)?; + let mut config = destination.clone(); + // Credentials belong in the enrolled owner profile. Neither local + // harness logins nor a reference agent's private environment moves. + config.env.clear(); + config.instructions = Config::read(&migration.config).instructions; + if let Some(instructions) = agent.config["instructions"].as_str() + && instructions != config.instructions + { + return Err(format!( + "{}'s instructions cannot be preserved. Restore its agent type before moving it.", + agent.name + )); + } + let mut warnings = Vec::new(); + let runtime = match agent.agent_type.as_str() { + "buzz.codex/codex" => Some("codex"), + "buzz.claude-code/claude-code" => Some("claude"), + _ => None, + }; + if let Some(runtime) = runtime { + config.runtime = runtime.into(); + config.model = agent.config["model"].as_str().unwrap_or_default().into(); + config.effort = agent.config["effort"].as_str().unwrap_or_default().into(); + config.fast = agent.config["fast"].as_bool().unwrap_or(false); + } else { + warnings.push(format!("Runtime changes to {}.", config.runtime)); + } + if migration.dropped.contains(&CarryKey::Instructions) { + return Err(format!( + "{}'s instructions cannot be preserved.", + agent.name + )); + } + warnings.extend( + migration + .dropped + .iter() + .map(|key| format!("{} stays local.", key.label())), + ); + warnings.extend( + migration + .settings + .iter() + .filter(|setting| { + runtime.is_none() || !matches!(setting.id, "model" | "thinking" | "fast") + }) + .map(|setting| { + if setting.id == "sessions" { + "Sessions: each thread starts a separate remote sandbox.".into() + } else { + format!("{} is not copied.", setting.label) + } + }), + ); + config.validate()?; + items.push(Item { + before: Before::of(agent), + after: config.value(), + warnings, + }); + } + Ok(Self { + version: 1, + scope: snapshot.scope.clone(), + roster, + reference, + state: if items.is_empty() { + State::Complete + } else { + State::Active + }, + items, + already_remote, + completed: BTreeSet::new(), + }) + } + + /// Reject stale sources before selecting another mutation. An exact + /// destination recognizes a host write whose response was lost. + pub fn next(&self, snapshot: &Snapshot) -> Result { + if snapshot.scope != self.scope || !snapshot.ready { + return Err("Return to the original account and community, then resume.".into()); + } + if let Some(roster) = &self.roster + && snapshot.roster(&roster.id)? != *roster + { + return Err("The team's roster changed. Review the remaining selection again.".into()); + } + if let Some(reference) = &self.reference + && Before::of(snapshot.agent(&reference.pubkey)?) != *reference + { + return Err( + "The reference configuration changed. Review the remaining selection again.".into(), + ); + } + let mut next = None; + for (index, item) in self.items.iter().enumerate() { + let agent = snapshot.agent(&item.before.pubkey)?; + let current = Before::of(agent); + let after = Before { + kind: TARGET.into(), + config: item.after.clone(), + ..item.before.clone() + }; + let applied = current == after; + let interrupted = current.kind == TARGET + && current.config == item.before.config + && current.name == item.before.name; + if (!applied && !interrupted && current != item.before) + || (self.completed.contains(&index) && !applied) + { + return Err(format!( + "{} changed after review. Review the remaining agents again.", + item.before.name + )); + } + if next.is_none() && !self.completed.contains(&index) { + next = Some(if applied { + Next::Reconcile(index) + } else { + Next::Change( + index, + if interrupted { + Change::FinishConfig + } else { + Change::Retype + }, + ) + }); + } + } + Ok(next.unwrap_or(Next::Complete)) + } +} + +/// This is the production admission seam: persistence and stale-state checks +/// complete before the closure can start the existing single-agent operation. +pub fn checked_step( + plan: &mut Plan, + snapshot: &Snapshot, + journal: &impl Journal, + change: impl FnOnce(&Item, Change) -> T, +) -> Result, String> { + if plan.state != State::Active { + return Ok(Step::Complete); + } + match plan.next(snapshot)? { + Next::Complete => { + plan.state = State::Complete; + if let Err(error) = journal.save(plan) { + plan.state = State::Active; + return Err(error); + } + Ok(Step::Complete) + } + Next::Reconcile(index) => { + plan.completed.insert(index); + journal.save(plan)?; + Ok(Step::Reconciled) + } + Next::Change(index, operation) => { + journal.save(plan)?; + Ok(Step::Started(index, change(&plan.items[index], operation))) + } + } +} + +/// One bounded private journal per account/community, replaced atomically. +#[derive(Clone)] +pub struct FileJournal(PathBuf); + +impl FileJournal { + /// Select the journal without placing user-controlled strings in its path. + pub fn new(directory: PathBuf, scope: &Scope) -> Self { + let hash = Sha256::digest(format!("{}#{}", scope.owner, scope.relay).as_bytes()); + Self(directory.join(format!("remote-agent-migration-{hash:x}.json"))) + } + + /// Restore only an unfinished, valid operation for this exact account. + pub fn load(&self, scope: &Scope) -> Result, String> { + let file = match std::fs::File::open(&self.0) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Could not read the migration journal: {error}")), + }; + let mut bytes = Vec::new(); + file.take(JOURNAL_LIMIT + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("Could not read the migration journal: {error}"))?; + if bytes.len() as u64 > JOURNAL_LIMIT { + return Err("The migration journal exceeds 4 MiB.".into()); + } + let plan: Plan = serde_json::from_slice(&bytes).map_err( + |_| "The migration journal is invalid. Preserve it before starting another migration.", + )?; + if plan.version != 1 + || &plan.scope != scope + || plan.items.len() > LIMIT + || plan.already_remote.len() + plan.items.len() > LIMIT + || plan + .items + .iter() + .map(|item| &item.before.pubkey) + .collect::>() + .len() + != plan.items.len() + || plan + .completed + .iter() + .any(|index| *index >= plan.items.len()) + { + return Err("The migration journal does not match this account or version.".into()); + } + for item in &plan.items { + Config::read(&item.after).validate()?; + } + Ok((plan.state == State::Active).then_some(plan)) + } + + /// Keep an unreadable record for diagnosis while allowing a fresh review + /// of the live inventory. This never modifies an agent. + pub fn archive(&self) -> Result<(), String> { + let directory = self + .0 + .parent() + .ok_or("The journal directory is unavailable.")?; + let (_, backup) = tempfile::Builder::new() + .prefix("remote-agent-migration-archived-") + .suffix(".json") + .tempfile_in(directory) + .map_err(|error| format!("Could not create a migration archive: {error}"))? + .keep() + .map_err(|error| format!("Could not create a migration archive: {error}"))?; + std::fs::rename(&self.0, backup) + .map_err(|error| format!("Could not archive the migration record: {error}")) + } +} + +impl Journal for FileJournal { + fn save(&self, plan: &Plan) -> Result<(), String> { + let bytes = + serde_json::to_vec(plan).map_err(|_| "Could not serialize the migration journal.")?; + if bytes.len() as u64 > JOURNAL_LIMIT { + return Err("The migration journal exceeds 4 MiB. Select fewer agents.".into()); + } + let directory = self + .0 + .parent() + .ok_or("The journal directory is unavailable.")?; + std::fs::create_dir_all(directory) + .map_err(|error| format!("Could not create the journal directory: {error}"))?; + let mut file = tempfile::NamedTempFile::new_in(directory) + .map_err(|error| format!("Could not create the private migration journal: {error}"))?; + file.write_all(&bytes) + .and_then(|()| file.as_file().sync_all()) + .map_err(|error| format!("Could not save the migration journal: {error}"))?; + file.persist(&self.0) + .map_err(|error| format!("Could not replace the migration journal: {}", error.error))?; + #[cfg(unix)] + std::fs::File::open(directory) + .and_then(|file| file.sync_all()) + .map_err(|error| format!("Could not sync the migration journal directory: {error}"))?; + Ok(()) + } +} + +/// Capture the same account/community boundary that the Agents page shows. +pub fn current_scope(cx: &gpui_kit::App) -> Result { + use crate::community::{AppState, IdentityState}; + let owner = match &cx.global::().identity { + IdentityState::Ready(identity) => identity.pubkey_hex(), + _ => return Err("Sign in before moving agents.".into()), + }; + let community = AppState::current(cx).ok_or("Select a community first.")?; + let relay = community.read(cx).origin.websocket().to_owned(); + Ok(Scope { owner, relay }) +} + +/// Read the selected inventory with its authenticated community boundary. +pub fn snapshot(service: &super::Agents2, cx: &gpui_kit::App) -> Result { + Ok(Snapshot { + scope: current_scope(cx)?, + ready: *service.status() == Status::Ready, + agents: service.agents().to_vec(), + teams: service.teams().to_vec(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/apps/buzz-gpui/crates/app/src/agents2/remote_migration/tests.rs b/apps/buzz-gpui/crates/app/src/agents2/remote_migration/tests.rs new file mode 100644 index 00000000000..002983137bd --- /dev/null +++ b/apps/buzz-gpui/crates/app/src/agents2/remote_migration/tests.rs @@ -0,0 +1,415 @@ +use super::*; +use serde_json::json; +use std::cell::{Cell, RefCell}; + +fn inventory() -> Snapshot { + let scope = Scope { + owner: "ab".repeat(32), + relay: "wss://relay.example".into(), + }; + let agents = (1..=3).map(|id| Agent { + pubkey: format!("{id:064x}"), name: format!("Agent {id}"), + agent_type: if id == 1 { "buzz.codex/codex" } else { "buzz.claude-code/claude-code" }.into(), + owner: scope.owner.clone(), relay: scope.relay.clone(), picture: Some("https://example.com/avatar.png".into()), + attention: Default::default(), skipped: Default::default(), timers: Default::default(), attention_enabled: true, + config: json!({"instructions": format!("Role {id}"), "workspace": "/local/project", "model": format!("model-{id}"), "effort": "high", "fast": true}), + }).collect::>(); + let teams = vec![Team { + id: "engineering".into(), + name: "Engineering".into(), + instructions: "Work together".into(), + members: agents.iter().map(|agent| agent.pubkey.clone()).collect(), + }]; + Snapshot { + scope, + ready: true, + agents, + teams, + } +} + +fn review( + snapshot: &Snapshot, + keys: BTreeSet, + team: Option<&str>, + reference: Option<&str>, +) -> Plan { + Plan::review( + snapshot, + &keys, + team, + reference, + &defaults(&snapshot.scope), + |key| { + Ok(RetypePreview { + config: json!({"instructions": snapshot.agent(key)?.config["instructions"]}), + moved: vec![CarryKey::Instructions], + dropped: vec![CarryKey::Workspace], + settings: vec![], + }) + }, + ) + .unwrap() +} + +fn selected(snapshot: &Snapshot) -> BTreeSet { + snapshot + .agents + .iter() + .map(|agent| agent.pubkey.clone()) + .collect() +} + +#[derive(Default)] +struct MemoryJournal { + fail: Cell, + saved: RefCell>, +} +impl Journal for MemoryJournal { + fn save(&self, plan: &Plan) -> Result<(), String> { + if self.fail.get() { + return Err("disk unavailable".into()); + } + *self.saved.borrow_mut() = Some(plan.clone()); + Ok(()) + } +} + +#[test] +fn whole_team_and_arbitrary_subset_keep_each_agents_own_options() { + let snapshot = inventory(); + let plan = review(&snapshot, selected(&snapshot), Some("engineering"), None); + assert_eq!(plan.items.len(), 3); + assert!(plan.roster.is_some()); + for (index, item) in plan.items.iter().enumerate() { + let after = Config::read(&item.after); + assert_eq!(after.instructions, format!("Role {}", index + 1)); + assert_eq!(after.model, format!("model-{}", index + 1)); + assert_eq!(after.runtime, if index == 0 { "codex" } else { "claude" }); + assert_eq!(after.effort, "high"); + assert!(after.fast); + assert!(after.env.is_empty()); + assert!( + item.warnings + .iter() + .any(|warning| warning.contains("local")) + ); + } + let subset = [ + snapshot.agents[0].pubkey.clone(), + snapshot.agents[2].pubkey.clone(), + ] + .into(); + let plan = review(&snapshot, subset, None, None); + assert_eq!(plan.items.len(), 2); + assert_eq!(plan.items[1].before.pubkey, snapshot.agents[2].pubkey); +} + +#[test] +fn reference_shares_destination_without_copying_secrets_or_instructions() { + let mut snapshot = inventory(); + let mut destination = defaults(&snapshot.scope); + destination.env.insert("SECRET".into(), "private".into()); + destination.instructions = "Reference role".into(); + snapshot.agents[2].agent_type = TARGET.into(); + snapshot.agents[2].config = destination.value(); + let plan = Plan::review( + &snapshot, + &selected(&snapshot), + Some("engineering"), + Some(&snapshot.agents[2].pubkey), + &destination, + |key| { + Ok(RetypePreview { + config: json!({"instructions": snapshot.agent(key)?.config["instructions"]}), + moved: vec![CarryKey::Instructions], + dropped: vec![], + settings: vec![], + }) + }, + ) + .unwrap(); + assert_eq!(plan.items.len(), 2); + assert_eq!(plan.already_remote, vec!["Agent 3"]); + assert_eq!( + plan.reference.as_ref().unwrap().pubkey, + snapshot.agents[2].pubkey + ); + assert!(Config::read(&plan.items[0].after).env.is_empty()); + assert_eq!(Config::read(&plan.items[0].after).instructions, "Role 1"); +} + +#[test] +fn journal_failure_and_stale_state_prevent_the_production_mutation_closure() { + let snapshot = inventory(); + let original = review(&snapshot, selected(&snapshot), Some("engineering"), None); + let journal = MemoryJournal::default(); + let called = Cell::new(false); + journal.fail.set(true); + assert!( + checked_step(&mut original.clone(), &snapshot, &journal, |_, _| called + .set(true)) + .is_err() + ); + assert!(!called.get()); + journal.fail.set(false); + for variant in 0..7 { + let mut changed = inventory(); + match variant { + 0 => changed.scope.owner = "cd".repeat(32), + 1 => changed.scope.relay = "wss://other.example".into(), + 2 => changed.agents[1].config["instructions"] = "Edited after review".into(), + 3 => { + changed.teams[0].members.pop(); + } + 4 => changed.ready = false, + 5 => { + changed.agents.remove(1); + } + _ => changed.agents[1].owner = "cd".repeat(32), + } + assert!( + checked_step(&mut original.clone(), &changed, &journal, |_, _| called + .set(true)) + .is_err(), + "variant {variant}" + ); + assert!(!called.get()); + } + assert!(matches!( + checked_step(&mut original.clone(), &snapshot, &journal, |_, _| { + assert!(journal.saved.borrow().is_some()); + called.set(true) + }) + .unwrap(), + Step::Started(0, ()) + )); + assert!(called.get()); +} + +#[test] +fn resume_reconciles_a_lost_ack_and_moves_only_remaining_agents() { + let mut snapshot = inventory(); + let mut plan = review(&snapshot, selected(&snapshot), None, None); + let journal = MemoryJournal::default(); + snapshot.agents[0].agent_type = TARGET.into(); + snapshot.agents[0].config = plan.items[0].after.clone(); + assert!(matches!( + checked_step(&mut plan, &snapshot, &journal, |_, _| panic!( + "must not repeat applied change" + )) + .unwrap(), + Step::Reconciled + )); + assert_eq!( + journal.saved.borrow().as_ref().unwrap().completed, + [0].into() + ); + // Live attention and profile metadata are not overwritten by the batch. + snapshot.agents[1].attention_enabled = false; + snapshot.agents[1].picture = None; + assert!(matches!( + checked_step(&mut plan, &snapshot, &journal, |item, _| { + assert_eq!(item.before.pubkey, snapshot.agents[1].pubkey); + }) + .unwrap(), + Step::Started(1, ()) + )); + snapshot.agents[0].config["instructions"] = "Changed after moving".into(); + assert!( + checked_step(&mut plan, &snapshot, &journal, |_, _| panic!( + "stale completed source" + )) + .is_err() + ); +} + +#[test] +fn each_success_is_resumable_and_completion_is_durable() { + let mut snapshot = inventory(); + let mut plan = review(&snapshot, selected(&snapshot), None, None); + let journal = MemoryJournal::default(); + for index in 0..3 { + let step = checked_step(&mut plan, &snapshot, &journal, |item, operation| { + assert_eq!(operation, Change::Retype); + (item.before.pubkey.clone(), item.after.clone()) + }) + .unwrap(); + let Step::Started(actual, (key, config)) = step else { + panic!("expected next source"); + }; + assert_eq!(actual, index); + let agent = snapshot + .agents + .iter_mut() + .find(|agent| agent.pubkey == key) + .unwrap(); + agent.agent_type = TARGET.into(); + agent.config = config; + // Recover from the saved pre-request record, as on app restart. + plan = journal.saved.borrow().clone().unwrap(); + assert!(matches!( + checked_step(&mut plan, &snapshot, &journal, |_, _| panic!( + "already applied" + )) + .unwrap(), + Step::Reconciled + )); + } + journal.fail.set(true); + assert!(checked_step(&mut plan, &snapshot, &journal, |_, _| panic!("all applied")).is_err()); + assert_eq!(plan.state, State::Active); + journal.fail.set(false); + assert!(matches!( + checked_step(&mut plan, &snapshot, &journal, |_, _| panic!("all applied")).unwrap(), + Step::Complete + )); + assert_eq!( + journal.saved.borrow().as_ref().unwrap().state, + State::Complete + ); +} + +#[test] +fn interrupted_type_write_finishes_only_the_exact_reviewed_config() { + let mut snapshot = inventory(); + let mut plan = review(&snapshot, selected(&snapshot), None, None); + snapshot.agents[0].agent_type = TARGET.into(); + let journal = MemoryJournal::default(); + assert!(matches!( + checked_step(&mut plan, &snapshot, &journal, |item, operation| { + assert_eq!(operation, Change::FinishConfig); + assert_eq!(item.before.pubkey, snapshot.agents[0].pubkey); + assert!(journal.saved.borrow().is_some()); + }) + .unwrap(), + Step::Started(0, ()) + )); + snapshot.agents[0].config["instructions"] = "Newer edit".into(); + assert!( + checked_step(&mut plan, &snapshot, &journal, |_, _| panic!( + "must preserve newer edit" + )) + .is_err() + ); +} + +#[test] +fn reference_changes_cancelled_batches_and_instruction_loss_are_rejected() { + let mut snapshot = inventory(); + snapshot.agents[2].agent_type = TARGET.into(); + snapshot.agents[2].config = defaults(&snapshot.scope).value(); + let mut plan = review( + &snapshot, + selected(&snapshot), + None, + Some(&snapshot.agents[2].pubkey), + ); + snapshot.agents[2].config["model"] = "new-model".into(); + assert!( + checked_step( + &mut plan, + &snapshot, + &MemoryJournal::default(), + |_, _| panic!("reference changed") + ) + .is_err() + ); + plan.state = State::Cancelled; + assert!(matches!( + checked_step( + &mut plan, + &snapshot, + &MemoryJournal::default(), + |_, _| panic!("cancelled") + ) + .unwrap(), + Step::Complete + )); + let snapshot = inventory(); + let result = Plan::review( + &snapshot, + &selected(&snapshot), + None, + None, + &defaults(&snapshot.scope), + |_| { + Ok(RetypePreview { + config: json!({}), + moved: vec![], + dropped: vec![], + settings: vec![], + }) + }, + ); + assert!( + result + .unwrap_err() + .contains("instructions cannot be preserved") + ); +} + +#[test] +fn bounded_atomic_private_journal_restores_pending_work_and_refuses_corruption() { + let snapshot = inventory(); + let mut plan = review(&snapshot, selected(&snapshot), None, None); + let directory = tempfile::tempdir().unwrap(); + let journal = FileJournal::new(directory.path().into(), &snapshot.scope); + assert!(journal.load(&snapshot.scope).unwrap().is_none()); + journal.save(&plan).unwrap(); + assert_eq!( + journal.load(&snapshot.scope).unwrap().unwrap().items.len(), + 3 + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&journal.0).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + plan.completed.insert(0); + journal.save(&plan).unwrap(); + assert_eq!( + journal.load(&snapshot.scope).unwrap().unwrap().completed, + [0].into() + ); + let other = Scope { + owner: "cd".repeat(32), + ..snapshot.scope.clone() + }; + assert!(journal.load(&other).is_err()); + plan.completed.insert(999); + journal.save(&plan).unwrap(); + assert!(journal.load(&snapshot.scope).is_err()); + std::fs::write(&journal.0, b"{").unwrap(); + assert!(journal.load(&snapshot.scope).is_err()); + journal.archive().unwrap(); + assert!(!journal.0.exists()); + assert!(std::fs::read_dir(directory.path()).unwrap().any(|entry| { + entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("remote-agent-migration-archived-") + })); + std::fs::write(&journal.0, vec![b' '; JOURNAL_LIMIT as usize + 1]).unwrap(); + assert!(journal.load(&snapshot.scope).is_err()); + plan.completed.clear(); + plan.state = State::Complete; + journal.save(&plan).unwrap(); + assert!(journal.load(&snapshot.scope).unwrap().is_none()); + let oversized = (0..=LIMIT).map(|id| format!("{id:064x}")).collect(); + assert!( + Plan::review( + &snapshot, + &oversized, + None, + None, + &defaults(&snapshot.scope), + |_| panic!("too many agents") + ) + .is_err() + ); +} diff --git a/apps/buzz-gpui/crates/app/src/ui/agents2/migration_controls.rs b/apps/buzz-gpui/crates/app/src/ui/agents2/migration_controls.rs new file mode 100644 index 00000000000..dc82d119d51 --- /dev/null +++ b/apps/buzz-gpui/crates/app/src/ui/agents2/migration_controls.rs @@ -0,0 +1,134 @@ +//! Semantic controls own focus, activation, and the accessible label. The +//! existing design-system components paint their contents without handlers. + +use buzz_ui::{ActiveTheme, ButtonSize, Surface}; +use gpui_kit::prelude::FluentBuilder; +use gpui_kit::*; +use std::rc::Rc; + +type Click = Rc; +type Change = Rc; + +#[derive(IntoElement)] +pub(super) struct Button { + id: ElementId, + label: SharedString, + paint: buzz_ui::Button, + disabled: bool, + loading: bool, + click: Option, + focus: Option, +} + +impl Button { + pub fn new(id: impl Into, label: impl Into) -> Self { + let id = id.into(); + let label = label.into(); + Self { + paint: buzz_ui::Button::new(id.clone(), label.clone()), + id, + label, + disabled: false, + loading: false, + click: None, + focus: None, + } + } + pub fn size(mut self, size: ButtonSize) -> Self { + self.paint = self.paint.size(size); + self + } + pub fn prominent(mut self) -> Self { + self.paint = self.paint.prominent(); + self + } + pub fn surface(mut self, surface: Surface) -> Self { + self.paint = self.paint.surface(surface); + self + } + pub fn tooltip(mut self, text: impl Into) -> Self { + self.paint = self.paint.tooltip(text); + self + } + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self.paint = self.paint.disabled(disabled); + self + } + pub fn loading(mut self, loading: bool) -> Self { + self.loading = loading; + self.paint = self.paint.loading(loading); + self + } + pub fn on_click( + mut self, + click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.click = Some(Rc::new(click)); + self + } + + /// Keep focus when the review button becomes the apply button. + pub fn track_focus(mut self, focus: &FocusHandle) -> Self { + self.focus = Some(focus.clone()); + self + } +} + +impl RenderOnce for Button { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let ring = cx.colors().border_focus; + gpui_kit::base::Button::new(self.id) + .accessibility_label(self.label) + .disabled(self.disabled || self.loading) + .when_some(self.focus, |button, focus| button.track_focus(&focus)) + .child(self.paint) + .focus_visible(move |style| style.border_2().border_color(ring)) + .when_some(self.click, |button, click| { + button.on_click(move |event, window, cx| click(event, window, cx)) + }) + } +} + +#[derive(IntoElement)] +pub(super) struct Checkbox { + id: ElementId, + checked: bool, + label: SharedString, + change: Option, +} + +impl Checkbox { + pub fn new(id: impl Into, checked: bool) -> Self { + Self { + id: id.into(), + checked, + label: "".into(), + change: None, + } + } + pub fn label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } + pub fn on_change(mut self, change: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.change = Some(Rc::new(change)); + self + } +} + +impl RenderOnce for Checkbox { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let ring = cx.colors().border_focus; + gpui_kit::base::Checkbox::new(self.id.clone()) + .checked(self.checked) + .accessibility_label(self.label.clone()) + .child(buzz_ui::Checkbox::new(self.id, self.checked).label(self.label)) + .focus_visible(move |style| style.border_2().border_color(ring)) + .when_some(self.change, |checkbox, change| { + checkbox.on_change(move |state, _, window, cx| { + change(state == gpui_kit::base::CheckboxState::Checked, window, cx) + }) + }) + } +} diff --git a/apps/buzz-gpui/crates/app/src/ui/agents2/mod.rs b/apps/buzz-gpui/crates/app/src/ui/agents2/mod.rs index 3564dcc8f21..596e9801fd9 100644 --- a/apps/buzz-gpui/crates/app/src/ui/agents2/mod.rs +++ b/apps/buzz-gpui/crates/app/src/ui/agents2/mod.rs @@ -8,8 +8,12 @@ pub(crate) mod card; mod create; mod manage; mod memory; +#[cfg(feature = "voltage-sandbox")] +mod migration_controls; mod packs; mod page; +#[cfg(feature = "voltage-sandbox")] +mod remote_migration; pub(crate) mod routes; mod tabs; mod teams; diff --git a/apps/buzz-gpui/crates/app/src/ui/agents2/page.rs b/apps/buzz-gpui/crates/app/src/ui/agents2/page.rs index f2d0a83ab76..bb40f9fc965 100644 --- a/apps/buzz-gpui/crates/app/src/ui/agents2/page.rs +++ b/apps/buzz-gpui/crates/app/src/ui/agents2/page.rs @@ -191,6 +191,8 @@ pub struct Agents2Page { import: Option>, browse: Option>, teams: Option>, + #[cfg(feature = "voltage-sandbox")] + migration: Option>, build: Option>, more_open: bool, /// The Provider settings submenu is open. @@ -223,6 +225,8 @@ impl Agents2Page { import: None, browse: None, teams: None, + #[cfg(feature = "voltage-sandbox")] + migration: None, build: None, more_open: false, providers_open: false, @@ -426,11 +430,32 @@ impl Agents2Page { cx.notify(); } + #[cfg(feature = "voltage-sandbox")] + fn open_migration(&mut self, team: Option, cx: &mut Context) { + let panel = cx.new(|cx| super::remote_migration::RemoteMigration::new(team, cx)); + cx.subscribe( + &panel, + |this, _, _: &super::remote_migration::CloseMigration, cx| { + this.migration = None; + cx.notify(); + }, + ) + .detach(); + self.more_open = false; + self.migration = Some(panel); + cx.notify(); + } + fn open_teams(&mut self, cx: &mut Context) { self.more_open = false; let panel = cx.new(TeamsPanel::new); cx.subscribe(&panel, |this, _, _: &CloseTeams, cx| this.browse(None, cx)) .detach(); + #[cfg(feature = "voltage-sandbox")] + cx.subscribe(&panel, |this, _, event: &super::teams::MoveTeam, cx| { + this.open_migration(Some(event.0.clone()), cx); + }) + .detach(); self.teams = Some(panel); self.frame = Frame::Browse { selected: None, @@ -539,10 +564,26 @@ impl Agents2Page { } None => { let teams_open = matches!(self.frame, Frame::Browse { teams: true, .. }); - header.label(Some(IconName::Robot), "Agents").when( + let header = header.label(Some(IconName::Robot), "Agents").when( self.tab == TabId::Yours && ready && !types(cx).is_empty(), |this| this.action(self.more(teams_open, window, cx)), - ) + ); + #[cfg(feature = "voltage-sandbox")] + let header = header.when( + self.tab == TabId::Yours + && ready + && type_of(crate::agents2::remote_migration::TARGET, cx).is_some(), + |header| { + header.action( + super::migration_controls::Button::new( + "agents-move-remote", + "Move to remote", + ) + .on_click(cx.listener(|this, _, _, cx| this.open_migration(None, cx))), + ) + }, + ); + header } } } @@ -1091,6 +1132,11 @@ impl Render for Agents2Page { .child(div().flex_1().min_h_0().child(body)) .children(self.create.clone()) .children(self.provider_dialog(window, cx)) + .map(|page| { + #[cfg(feature = "voltage-sandbox")] + let page = page.children(self.migration.clone()); + page + }) } } diff --git a/apps/buzz-gpui/crates/app/src/ui/agents2/remote_migration.rs b/apps/buzz-gpui/crates/app/src/ui/agents2/remote_migration.rs new file mode 100644 index 00000000000..e155c96654f --- /dev/null +++ b/apps/buzz-gpui/crates/app/src/ui/agents2/remote_migration.rs @@ -0,0 +1,642 @@ +//! First-class team and multi-agent migration, using the existing type editor +//! and single-agent operation rather than importing copies of identities. + +use super::migration_controls::{Button, Checkbox}; +use crate::agent_plugins::remote_workspace::config::Config; +use crate::agents2::remote_migration::{ + self as migration, FileJournal, Journal, Plan, Scope, State, Step, TARGET, +}; +use crate::agents2::{AgentView, Agents2, SettingsHost, type_of}; +use crate::ui::overlay; +use buzz_ui::{ActiveTheme, ButtonSize, Dialog, DialogSize, Surface, TypeStyled}; +use gpui_kit::base::FocusTrapElement; +use gpui_kit::prelude::FluentBuilder; +use gpui_kit::*; +use std::collections::BTreeSet; +use std::rc::Rc; + +/// The owner closed the migration dialog. +pub struct CloseMigration; + +/// One dialog can select a saved team, arbitrary existing agents, and a shared +/// configuration. A pending journal survives closing the page or app. +pub struct RemoteMigration { + scope: Option, + selected: BTreeSet, + team: Option, + reference: Option, + destination: Config, + editor: Option, + plan: Option, + journal: Option, + load_error: bool, + pending: bool, + attempted: bool, + primary_focus: FocusHandle, + dialog_focus: FocusHandle, + focus_on_open: bool, + error: Option, +} + +impl EventEmitter for RemoteMigration {} + +impl RemoteMigration { + /// `team` preselects that team's exact keys; `None` permits any selection. + pub fn new(team: Option, cx: &mut Context) -> Self { + let snapshot = Agents2::global(cx) + .ok_or_else(|| "Agents is not running.".to_string()) + .and_then(|service| migration::snapshot(service.read(cx), cx)); + let mut this = Self { + scope: None, + selected: BTreeSet::new(), + team, + reference: None, + destination: Config::default(), + editor: None, + plan: None, + journal: None, + load_error: false, + pending: false, + attempted: false, + primary_focus: cx.focus_handle(), + dialog_focus: cx.focus_handle().tab_stop(false), + focus_on_open: true, + error: None, + }; + match snapshot { + Ok(snapshot) => { + this.destination = migration::defaults(&snapshot.scope); + if let Some(team) = this.team.as_deref() { + match snapshot.roster(team) { + Ok(team) => this.selected = team.members, + Err(error) => this.error = Some(error.into()), + } + } + let journal = FileJournal::new(crate::community::data_dir(), &snapshot.scope); + match journal.load(&snapshot.scope) { + Ok(Some(plan)) => { + this.attempted = true; + this.selected = plan + .items + .iter() + .map(|item| item.before.pubkey.clone()) + .collect(); + this.plan = Some(plan); + } + Ok(None) => {} + Err(error) => { + this.error = Some(error.into()); + this.load_error = true; + } + } + this.scope = Some(snapshot.scope); + this.journal = Some(journal); + } + Err(error) => this.error = Some(error.into()), + } + if let Some(service) = Agents2::global(cx) { + cx.observe(&service, |_, _, cx| cx.notify()).detach(); + } + cx.observe_global::(|this, cx| { + // Never carry an enrollment editor into another account/community. + if migration::current_scope(cx).ok().as_ref() != this.scope.as_ref() { + this.editor = None; + } + cx.notify(); + }) + .detach(); + this + } + + fn close(&mut self, cx: &mut Context) { + if !self.pending { + cx.emit(CloseMigration); + } + } + + fn review(&mut self, cx: &mut Context) { + self.error = None; + let result = (|| { + let service = Agents2::global(cx).ok_or("Agents is not running.")?; + service.update(cx, |service, cx| { + let snapshot = migration::snapshot(service, cx)?; + if self.scope.as_ref() != Some(&snapshot.scope) { + return Err( + "Return to the account and community where you opened this dialog.".into(), + ); + } + Plan::review( + &snapshot, + &self.selected, + self.team.as_deref(), + self.reference.as_deref(), + &self.destination, + |key| service.preview_retype(key, TARGET, cx), + ) + }) + })(); + match result { + Ok(plan) => { + self.plan = Some(plan); + self.editor = None; + } + Err(error) => self.error = Some(error.into()), + } + cx.notify(); + } + + fn choose_reference(&mut self, reference: Option, cx: &mut Context) { + if self.pending || self.plan.is_some() { + return; + } + if migration::current_scope(cx).ok().as_ref() != self.scope.as_ref() { + return; + } + let Some(service) = Agents2::global(cx) else { + return; + }; + self.destination = match &reference { + Some(key) => match service.read(cx).find(key) { + Some(agent) => Config::read(&agent.config), + None => { + self.error = Some("The reference agent is no longer available.".into()); + return; + } + }, + None => match &self.scope { + Some(scope) => migration::defaults(scope), + None => return, + }, + }; + self.destination.env.clear(); + self.destination.instructions.clear(); + self.reference = reference; + self.editor = None; + self.error = None; + cx.notify(); + } + + fn configure(&mut self, window: &mut Window, cx: &mut Context) { + if migration::current_scope(cx).ok().as_ref() != self.scope.as_ref() { + return; + } + let Some(service) = Agents2::global(cx) else { + return; + }; + let first = self + .selected + .iter() + .find_map(|key| service.read(cx).find(key).cloned()); + let Some(mut agent) = first else { + self.error = + Some("Select at least one agent before configuring the destination.".into()); + cx.notify(); + return; + }; + if type_of(TARGET, cx).is_none() { + return; + } + agent.agent_type = TARGET.into(); + agent.config = self.destination.value(); + let weak = cx.entity().downgrade(); + let pending = Rc::new(move |config: serde_json::Value, cx: &mut App| { + let saved = weak + .update(cx, |this, cx| { + if migration::current_scope(cx).ok().as_ref() != this.scope.as_ref() { + return Err("Return to the original account and community first.".into()); + } + let mut config = Config::read(&config); + config.env.clear(); + config.instructions.clear(); + config.validate()?; + this.destination = config; + this.reference = None; + this.editor = None; + cx.notify(); + Ok::<(), String>(()) + }) + .map_err(|_| "The migration dialog closed.".to_string()) + .and_then(|saved| saved); + Task::ready(saved) + }); + self.editor = Some(crate::agent_plugins::remote_workspace::migration_settings( + AgentView { + agent, + conversation: None, + settings_host: Some(SettingsHost { + type_control: None, + pending: Some(pending), + }), + }, + window, + cx, + )); + cx.notify(); + } + + fn run(&mut self, cx: &mut Context) { + if self.pending || self.load_error { + return; + } + let (Some(mut plan), Some(journal), Some(service)) = + (self.plan.clone(), self.journal.clone(), Agents2::global(cx)) + else { + return; + }; + if plan.state != State::Active { + return; + } + self.pending = true; + self.attempted = true; + self.error = None; + cx.spawn(async move |this, cx| { + let result = async { + loop { + // The stale-state gate and retype admission share one GUI + // update. retype sets transitioning before it awaits, and + // the existing save path refuses config edits during it. + let step = service.update(cx, |service, cx| { + let snapshot = migration::snapshot(service, cx)?; + migration::checked_step(&mut plan, &snapshot, &journal, |item, operation| { + if operation == migration::Change::FinishConfig { + let state = service.run_state(&item.before.pubkey); + if state.working_since.is_some() || !state.held { + return Task::ready(Err("Wait until this app holds the idle agent before resuming its configuration.".into())); + } + service.save(&item.before.pubkey, crate::agents2::AgentChange { + config: Some(item.after.clone()), ..Default::default() + }, cx) + } else { + let task = service.retype(&item.before.pubkey, TARGET, Some(item.after.clone()), cx); + cx.spawn(async move |_, _| task.await.map(drop)) + } + }) + })?; + match step { + Step::Complete => break, + Step::Reconciled => {} + Step::Started(index, task) => { + task.await?; + plan.completed.insert(index); + } + } + this.update(cx, |this, cx| { + this.plan = Some(plan.clone()); cx.notify(); + }).map_err(|_| "The migration dialog closed; reopen it to resume.".to_string())?; + } + Ok::<(), String>(()) + }.await; + let _ = this.update(cx, |this, cx| { + this.pending = false; + this.plan = Some(plan); + this.error = result.err().map(|error| format!("{error} Remaining agents were not changed. Resume when ready, or cancel the remaining migration.").into()); + cx.notify(); + }); + }).detach(); + cx.notify(); + } + + fn new_selection(&mut self, cx: &mut Context) { + if self.pending || self.load_error { + return; + } + if migration::current_scope(cx).ok().as_ref() != self.scope.as_ref() { + self.error = Some("Return to the original account and community first.".into()); + cx.notify(); + return; + } + if let (Some(plan), Some(journal)) = (&mut self.plan, &self.journal) + && plan.state == State::Active + { + plan.state = State::Cancelled; + if let Err(error) = journal.save(plan) { + plan.state = State::Active; + self.error = Some(error.into()); + cx.notify(); + return; + } + } + self.plan = None; + self.attempted = false; + self.focus_on_open = true; + self.error = None; + cx.notify(); + } + + fn archive_unreadable(&mut self, cx: &mut Context) { + if !self.load_error + || self.pending + || migration::current_scope(cx).ok().as_ref() != self.scope.as_ref() + { + return; + } + let Some(journal) = &self.journal else { + return; + }; + match journal.archive() { + Ok(()) => { + self.load_error = false; + self.error = None; + self.focus_on_open = true; + } + Err(error) => self.error = Some(error.into()), + } + cx.notify(); + } +} + +impl Render for RemoteMigration { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.focus_on_open { + self.focus_on_open = false; + let focus = if self.plan.is_some() { + self.primary_focus.clone() + } else { + self.dialog_focus.clone() + }; + window.on_next_frame(move |window, cx| focus.focus(window, cx)); + } + let c = *cx.colors(); + let snapshot = + Agents2::global(cx).and_then(|service| migration::snapshot(service.read(cx), cx).ok()); + let same_scope = snapshot + .as_ref() + .is_some_and(|snapshot| Some(&snapshot.scope) == self.scope.as_ref()); + let key_labels = snapshot + .as_ref() + .map(|snapshot| { + crate::ui::keys::public_key_labels( + snapshot.agents.iter().map(|agent| &agent.pubkey), + ) + }) + .unwrap_or_default(); + let mut body = div() + .id("remote-migration-body") + .flex() + .flex_col() + .gap(rems(0.75)) + .max_h(rems(34.)) + .overflow_y_scroll(); + if !same_scope { + body = + body.child(div().text_color(c.text_warning).child( + "Return to the original account and community to continue this migration.", + )); + } else if let Some(plan) = &self.plan { + let finished = plan.state != State::Active; + body = body.child(div().text_label().child(format!( + "{} of {} moved", + plan.completed.len(), + plan.items.len() + ))); + for (index, item) in plan.items.iter().enumerate() { + let config = Config::read(&item.after); + let completed = plan.completed.contains(&index); + body = body.child( + div() + .flex() + .flex_col() + .gap(rems(0.25)) + .child(div().text_label_sm().child(format!( + "{} · {} · {}{}", + item.before.name, + key_labels.get(&item.before.pubkey).unwrap_or(&item.before.pubkey), + config.runtime, + if completed { " · moved" } else { "" } + ))) + .child( + div() + .text_body_sm() + .text_color(c.text_subtle) + .child(format!( + "Model: {}. Instructions move with this agent.", + if config.model.is_empty() { + "harness default" + } else { + &config.model + } + )), + ) + .children(item.warnings.iter().map(|warning| { + div() + .text_body_sm() + .text_color(c.text_warning) + .child(warning.clone()) + })) + .children( + type_of(&item.before.kind, cx) + .and_then(|kind| kind.kind.migration_warning()) + .map(|warning| { + div() + .text_body_sm() + .text_color(c.text_warning) + .child(warning) + }), + ), + ); + } + if !plan.already_remote.is_empty() { + body = body.child(div().text_body_sm().child(format!( + "Already remote: {}", + plan.already_remote.join(", ") + ))); + } + body = body.child(div().text_body_sm().text_color(c.text_warning).child( + "Keys, names, pictures, memberships, and attention stay with each identity. Local files and session history stay on this computer. Future mentions can start remote sessions. Saved watches and timers do not launch Remote workspace.")); + body = body.child( + div() + .flex() + .gap(rems(0.5)) + .when(!finished, |body| { + body.child( + Button::new( + "remote-migration-apply", + if self.attempted { + "Resume migration" + } else { + "Move selected agents" + }, + ) + .prominent() + .track_focus(&self.primary_focus) + .loading(self.pending) + .disabled(self.pending || self.load_error) + .on_click(cx.listener(|this, _, _, cx| this.run(cx))), + ) + }) + .child( + Button::new( + "remote-migration-review-again", + if finished { + "New selection" + } else { + "Cancel remaining and review again" + }, + ) + .disabled(self.pending || self.load_error) + .when(finished, |button| button.track_focus(&self.primary_focus)) + .on_click(cx.listener(|this, _, _, cx| this.new_selection(cx))), + ), + ); + } else if let Some(snapshot) = snapshot { + body = body.child(div().text_body_sm().child("Select a saved team or choose individual existing agents. No new identities are created.")); + body = body.child( + div() + .flex() + .flex_wrap() + .gap(rems(0.5)) + .children(snapshot.teams.iter().enumerate().map(|(index, team)| { + let id = team.id.clone(); + let members: BTreeSet<_> = team.members.iter().cloned().collect(); + Button::new(("remote-migration-team", index), team.name.clone()) + .size(ButtonSize::Sm) + .on_click(cx.listener(move |this, _, _, cx| { + this.team = Some(id.clone()); + this.selected = members.clone(); + this.editor = None; + this.error = None; + cx.notify(); + })) + })) + .child( + Button::new("remote-migration-all", "Select all agents") + .size(ButtonSize::Sm) + .on_click(cx.listener(|this, _, _, cx| { + if let Some(service) = Agents2::global(cx) { + this.selected = service + .read(cx) + .agents() + .iter() + .map(|agent| agent.pubkey.clone()) + .collect(); + } + this.team = None; + this.editor = None; + cx.notify(); + })), + ), + ); + for (index, agent) in snapshot.agents.iter().enumerate() { + let key = agent.pubkey.clone(); + let weak = cx.entity().downgrade(); + body = body.child( + Checkbox::new( + ("remote-migration-agent", index), + self.selected.contains(&key), + ) + .label(format!( + "{} · {}{}", + agent.name, + key_labels.get(&key).unwrap_or(&key), + if agent.agent_type == TARGET { + " (already remote)" + } else { + "" + } + )) + .on_change(move |checked, _, cx| { + let _ = weak.update(cx, |this, cx| { + if checked { + this.selected.insert(key.clone()); + } else { + this.selected.remove(&key); + } + this.team = None; + this.editor = None; + cx.notify(); + }); + }), + ); + } + body = body + .child(div().text_label_sm().child("Remote configuration")) + .child( + Button::new("remote-migration-default", "New remote configuration") + .size(ButtonSize::Sm) + .on_click(cx.listener(|this, _, _, cx| this.choose_reference(None, cx))), + ); + for (index, agent) in snapshot + .agents + .iter() + .filter(|agent| agent.agent_type == TARGET) + .enumerate() + { + let key = agent.pubkey.clone(); + body = body.child( + Button::new( + ("remote-migration-reference", index), + format!( + "Use {}'s configuration ({})", + agent.name, + key_labels.get(&key).unwrap_or(&key) + ), + ) + .size(ButtonSize::Sm) + .on_click(cx.listener(move |this, _, _, cx| { + this.choose_reference(Some(key.clone()), cx) + })), + ); + } + body = body.child(div().text_body_sm().text_color(c.text_subtle).child(format!( + "{} selected (maximum {}). Configuration: {}. Each Claude/Codex agent keeps its harness and model options. Credentials come from your enrolled workspace profile, not local logins or copied agent environments.", + self.selected.len(), migration::LIMIT, self.reference.as_ref().and_then(|key| snapshot.agents.iter().find(|agent| &agent.pubkey == key)).map_or("new configuration", |agent| agent.name.as_str())))); + body = body + .child( + Button::new("remote-migration-configure", "Configure remote workspace") + .disabled(self.selected.is_empty() || self.load_error) + .on_click(cx.listener(|this, _, window, cx| this.configure(window, cx))), + ) + .children(self.editor.clone()); + body = body.child( + Button::new("remote-migration-preview", "Review migration") + .prominent() + .track_focus(&self.primary_focus) + .disabled( + self.selected.is_empty() + || self.selected.len() > migration::LIMIT + || self.editor.is_some() + || self.load_error, + ) + .on_click(cx.listener(|this, _, _, cx| this.review(cx))), + ); + } + if same_scope && self.load_error { + body = body.child( + Button::new( + "remote-migration-archive", + "Archive unreadable record and start a fresh review", + ) + .on_click(cx.listener(|this, _, _, cx| this.archive_unreadable(cx))), + ); + } + let dialog = Dialog::new("remote-migration-dialog", "Move agents to Remote workspace") + .size(DialogSize::Wide) + .on_close(cx.listener(|this, _, _, cx| this.close(cx))) + .child(body) + .children( + self.error + .clone() + .filter(|_| same_scope) + .map(|error| div().text_body_sm().text_color(c.text_danger).child(error)), + ) + .footer( + Button::new("remote-migration-close", "Close") + .surface(Surface::Floating) + .disabled(self.pending) + .on_click(cx.listener(|this, _, _, cx| this.close(cx))), + ); + overlay::modal( + window, + div() + .id("remote-migration-focus-scope") + .key_context(buzz_ui::DIALOG_CONTEXT) + .on_action(cx.listener(|this, _: &buzz_ui::Cancel, _, cx| this.close(cx))) + .role(Role::Dialog) + .aria_label("Move agents to Remote workspace") + .track_focus(&self.dialog_focus) + .child(dialog) + .focus_trap("remote-migration-focus-trap", &self.dialog_focus), + None, + ) + } +} diff --git a/apps/buzz-gpui/crates/app/src/ui/agents2/teams.rs b/apps/buzz-gpui/crates/app/src/ui/agents2/teams.rs index 3dc65f96cda..ec5d822bbda 100644 --- a/apps/buzz-gpui/crates/app/src/ui/agents2/teams.rs +++ b/apps/buzz-gpui/crates/app/src/ui/agents2/teams.rs @@ -19,6 +19,13 @@ use gpui_kit::*; /// The panel asks to close. pub struct CloseTeams; +/// Review moving this saved team to Remote workspace. +#[cfg(feature = "voltage-sandbox")] +pub struct MoveTeam(pub String); + +#[cfg(feature = "voltage-sandbox")] +impl EventEmitter for TeamsPanel {} + /// The editor of one team, or of a new one. struct Editor { /// `None` for a new team. @@ -202,6 +209,8 @@ impl TeamsPanel { .collect::>() .join(", "); let edit = team.clone(); + #[cfg(feature = "voltage-sandbox")] + let move_id = team.id.clone(); div() .flex() .items_center() @@ -229,6 +238,21 @@ impl TeamsPanel { }), ), ) + .map(|row| { + #[cfg(feature = "voltage-sandbox")] + let row = row.child( + super::migration_controls::Button::new( + ("team-move-remote", index), + "Move to remote", + ) + .size(ButtonSize::Sm) + .tooltip(format!("Move {} to Remote workspace", team.name)) + .on_click( + cx.listener(move |_, _, _, cx| cx.emit(MoveTeam(move_id.clone()))), + ), + ); + row + }) .child( Button::new(("team-edit", index), "Edit") .size(ButtonSize::Sm) diff --git a/apps/buzz-gpui/docs/remote-agent-migration.md b/apps/buzz-gpui/docs/remote-agent-migration.md new file mode 100644 index 00000000000..680151a3cb2 --- /dev/null +++ b/apps/buzz-gpui/docs/remote-agent-migration.md @@ -0,0 +1,62 @@ +# Move existing agents to Remote workspace + +The native Agents page can move a saved team or several existing agents to Remote workspace. +This flow is available in builds with the `voltage-sandbox` feature. +It changes each selected agent's type through the existing single-agent operation. + +## Move a team or a selection + +1. Open **Agents → Your agents → Move to remote**. +2. Select a saved team, select individual agents, or choose **Select all agents**. +3. Choose **New remote configuration** or use an existing remote agent's configuration. +4. If needed, open **Configure remote workspace**, enroll your workspace profile, and save the destination draft. +5. Select **Review migration** and check each agent's harness, model, and local settings. +6. Select **Move selected agents**. + +Alternatively, open **Teams** and select **Move to remote** beside a team. +The dialog preselects that team's members. +Each batch supports up to 32 agents. +Agents already using Remote workspace remain unchanged. + +| Setting | Result | +| --- | --- | +| Key, name, picture, channel memberships, team membership | Preserved by the existing identity | +| Agent instructions | Carried through the type's migration policy | +| Claude/Codex harness, model, effort, fast mode | Preserved per agent | +| Other agent types | Use the reviewed destination harness and model options | +| Attention settings | Preserved; remote execution follows the remote type's supported triggers | +| Local workspace files and conversation sessions | Remain local | +| Local logins and agent environment variables | Not copied; enroll credentials in the remote workspace profile | + +Moving an agent configures future remote sessions. +A mention starts a remote sandbox through the existing Sandbox manager. +Moving does not upload a local working directory or copy an existing conversation session. + +## Resume interrupted work + +Changes run sequentially. +The app saves a private migration record before changing an agent. +If an agent is busy or a host request fails, the batch stops and retains the remaining work. + +Reopen **Move to remote** in the same account and community. +Select **Resume migration** after resolving the reported problem. +If a host completed a change before its response was lost, the app recognizes the exact destination configuration. +It does not repeat that change. + +If a source, reference configuration, or selected team roster changed, select **Cancel remaining and review again**. +Completed changes remain in place. +The new review uses the current inventory. + +If the saved migration record is unreadable, select **Archive unreadable record and start a fresh review**. +The app preserves the old record beside its data files and allows another review. +This action does not change any agent. + +## Validation before review readiness + +Use a separate test profile with a mixed Claude/Codex team and one already-remote agent. +Exercise both the whole-team and individual-selection paths. +Check that identities, memberships, instructions, and per-agent model options remain intact. +Interrupt a batch, reopen the dialog, and resume it. +Change one source after review and verify that the batch stops before changing that source. + +A human must exercise the flow and confirm the results before this change becomes ready for review. diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index fdca77b97ab..f69f774279e 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -273,7 +273,11 @@ Create a key at [openrouter.ai/keys](https://openrouter.ai/keys). The **Buzz age 1. In the Buzz app, open **Agents → Your agents**. 2. Select **Create agent**, then select **Remote workspace** in the agent type list. - To convert an existing Codex or Claude Code agent instead, open it and select **Remote workspace** under **Settings → Provider**. The agent keeps its instructions. + To move a team or several existing agents, select **Agents → Your agents → Move to remote**. + Choose a team or individual agents, use a new or existing remote configuration, and review the batch. + Already-remote agents remain unchanged. + See [Move existing agents to Remote workspace](../apps/buzz-gpui/docs/remote-agent-migration.md) for preservation and recovery details. + For one agent, you can also select **Remote workspace** under **Settings → Provider**. 3. Fill in **Settings**, then select **Save**. | Field | Value |