diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 94d162e620..92ed08ab04 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -1,6 +1,7 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, io::Write, + path::PathBuf, sync::{ atomic::{AtomicBool, AtomicU16}, Arc, Mutex, @@ -45,6 +46,15 @@ pub struct AppState { /// Never perform network I/O while holding this lock. pub managed_agent_runtime_transition: Mutex<()>, pub managed_agents_store_lock: Mutex<()>, + /// Retention scopes whose owner-authored persona/agent backfill completed + /// during this process. Persona-backed key generation fails closed until + /// its active scope is present, so an empty-but-not-yet-synced reference + /// store can never be mistaken for proof that no keypair exists. + managed_agent_reference_sync_ready: Mutex>, + /// Persona ids currently inside the key-generation/save critical section. + /// This closes the gap between checking the retained identity index and + /// saving the new local record when two create commands race. + managed_agent_persona_mints: Mutex>, pub channel_templates_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, @@ -209,6 +219,8 @@ pub fn build_app_state() -> AppState { managed_agent_runtime_transition: Mutex::new(()), identity_mutation: Mutex::new(()), managed_agents_store_lock: Mutex::new(()), + managed_agent_reference_sync_ready: Mutex::new(HashSet::new()), + managed_agent_persona_mints: Mutex::new(HashSet::new()), channel_templates_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), session_config_cache: Mutex::new(HashMap::new()), @@ -232,109 +244,9 @@ pub fn build_app_state() -> AppState { } } -impl AppState { - /// Lock the huddle state mutex, converting a poisoned-lock error to a String. - /// - /// Convenience wrapper — replaces 15+ instances of - /// `state.huddle_state.lock().map_err(|e| e.to_string())?` throughout the - /// huddle module. - pub fn huddle(&self) -> Result, String> { - self.huddle_state.lock().map_err(|e| e.to_string()) - } - - pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option { - self.session_config_cache.lock().ok()?.get(key).cloned() - } - - pub fn put_session_cache(&self, key: ManagedAgentRuntimeKey, cache: SessionConfigCache) { - if let Ok(mut map) = self.session_config_cache.lock() { - map.insert(key, cache); - } - } - - pub fn clear_agent_session_cache(&self, key: &ManagedAgentRuntimeKey) { - if let Ok(mut map) = self.session_config_cache.lock() { - map.remove(key); - } - } - - pub fn clear_agent_session_caches(&self, pubkey: &str) { - if let Ok(mut map) = self.session_config_cache.lock() { - map.retain(|key, _| key.pubkey != pubkey); - } - } - - /// Record that `channel_id` was just created by `creator_pubkey` and its - /// kind:39002 owner membership has not yet been observed. - pub fn mark_pending_owned_channel(&self, creator_pubkey: &str, channel_id: &str) { - if let Ok(mut set) = self.pending_owned_channels.lock() { - set.insert((creator_pubkey.to_string(), channel_id.to_string())); - } - } - - /// Whether `channel_id` is still awaiting `my_pubkey`'s kind:39002 entry. - /// Bound to `my_pubkey` so an in-process identity swap never inherits - /// another identity's pending-owner entry for the same channel id. - pub fn is_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) -> bool { - self.pending_owned_channels - .lock() - .map(|set| set.contains(&(my_pubkey.to_string(), channel_id.to_string()))) - .unwrap_or(false) - } - - /// Drop the `(my_pubkey, channel_id)` entry from the pending-owner - /// overlay once that identity's real kind:39002 membership has been - /// observed. - pub fn clear_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) { - if let Ok(mut set) = self.pending_owned_channels.lock() { - set.remove(&(my_pubkey.to_string(), channel_id.to_string())); - } - } - - /// Return the active identity keys if they are in a signable state. - /// - /// Returns `Err` when the identity is in a lost state (`identity_lost` - /// — ephemeral key, user must re-import their nsec) or when the keyring - /// is locked (`keyring_locked` — key is held in a keyring that is - /// unavailable this boot). All signing and publish commands must call - /// this instead of locking `state.keys` directly, so that recovery mode - /// blocks publishing under an invalid or inaccessible identity. - pub fn signing_keys(&self) -> Result { - if self - .identity_lost - .load(std::sync::atomic::Ordering::Acquire) - || self - .keyring_locked - .load(std::sync::atomic::Ordering::Acquire) - { - return Err("identity is in recovery mode; event signing is disabled \ - until the identity is restored and Buzz is relaunched" - .to_string()); - } - self.keys - .lock() - .map_err(|e| e.to_string()) - .map(|k| k.clone()) - } - - /// Emit the current huddle state to the frontend via Tauri event. - /// - /// Acquires both locks (app_handle + huddle_state), clones a snapshot, - /// releases both, then emits. Best-effort — no-op if either lock is - /// poisoned or the app_handle hasn't been set yet. - pub fn emit_huddle_state_changed(&self) { - let app = match self.app_handle.lock() { - Ok(guard) => guard.clone(), - Err(_) => return, - }; - let Some(app) = app else { return }; - let snapshot = match self.huddle_state.lock() { - Ok(hs) => hs.clone(), - Err(_) => return, - }; - crate::huddle::state::emit_huddle_state(&app, &snapshot); - } -} +#[path = "app_state_methods.rs"] +mod methods; +pub(crate) use methods::ManagedAgentPersonaMintReservation; /// Resolve the user's identity key from the app data directory and wire /// the resulting [`RecoveryState`] into `AppState`. diff --git a/desktop/src-tauri/src/app_state_methods.rs b/desktop/src-tauri/src/app_state_methods.rs new file mode 100644 index 0000000000..27d1c3c401 --- /dev/null +++ b/desktop/src-tauri/src/app_state_methods.rs @@ -0,0 +1,198 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::Mutex, +}; + +use nostr::Keys; + +use super::AppState; +use crate::managed_agents::{config_bridge::SessionConfigCache, ManagedAgentRuntimeKey}; + +impl AppState { + pub(crate) fn clear_managed_agent_reference_sync_ready(&self) -> Result<(), String> { + self.managed_agent_reference_sync_ready + .lock() + .map_err(|error| error.to_string())? + .clear(); + Ok(()) + } + + pub(crate) fn mark_managed_agent_reference_sync_ready( + &self, + scope: PathBuf, + ) -> Result<(), String> { + self.managed_agent_reference_sync_ready + .lock() + .map_err(|error| error.to_string())? + .insert(scope); + Ok(()) + } + + pub(crate) fn managed_agent_reference_sync_is_ready( + &self, + scope: &Path, + ) -> Result { + Ok(self + .managed_agent_reference_sync_ready + .lock() + .map_err(|error| error.to_string())? + .contains(scope)) + } + + pub(crate) fn reserve_managed_agent_persona_mint( + &self, + persona_id: String, + ) -> Result, String> { + let mut active = self + .managed_agent_persona_mints + .lock() + .map_err(|error| error.to_string())?; + if !active.insert(persona_id.clone()) { + return Err(format!( + "agent identity creation is already in progress for persona {persona_id}" + )); + } + Ok(ManagedAgentPersonaMintReservation { + active: &self.managed_agent_persona_mints, + persona_id, + }) + } + + /// Lock the huddle state mutex, converting a poisoned-lock error to a String. + /// + /// Convenience wrapper — replaces 15+ instances of + /// `state.huddle_state.lock().map_err(|e| e.to_string())?` throughout the + /// huddle module. + pub fn huddle(&self) -> Result, String> { + self.huddle_state.lock().map_err(|e| e.to_string()) + } + + pub fn get_session_cache(&self, key: &ManagedAgentRuntimeKey) -> Option { + self.session_config_cache.lock().ok()?.get(key).cloned() + } + + pub fn put_session_cache(&self, key: ManagedAgentRuntimeKey, cache: SessionConfigCache) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.insert(key, cache); + } + } + + pub fn clear_agent_session_cache(&self, key: &ManagedAgentRuntimeKey) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.remove(key); + } + } + + pub fn clear_agent_session_caches(&self, pubkey: &str) { + if let Ok(mut map) = self.session_config_cache.lock() { + map.retain(|key, _| key.pubkey != pubkey); + } + } + + /// Record that `channel_id` was just created by `creator_pubkey` and its + /// kind:39002 owner membership has not yet been observed. + pub fn mark_pending_owned_channel(&self, creator_pubkey: &str, channel_id: &str) { + if let Ok(mut set) = self.pending_owned_channels.lock() { + set.insert((creator_pubkey.to_string(), channel_id.to_string())); + } + } + + /// Whether `channel_id` is still awaiting `my_pubkey`'s kind:39002 entry. + /// Bound to `my_pubkey` so an in-process identity swap never inherits + /// another identity's pending-owner entry for the same channel id. + pub fn is_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) -> bool { + self.pending_owned_channels + .lock() + .map(|set| set.contains(&(my_pubkey.to_string(), channel_id.to_string()))) + .unwrap_or(false) + } + + /// Drop the `(my_pubkey, channel_id)` entry from the pending-owner + /// overlay once that identity's real kind:39002 membership has been + /// observed. + pub fn clear_pending_owned_channel(&self, my_pubkey: &str, channel_id: &str) { + if let Ok(mut set) = self.pending_owned_channels.lock() { + set.remove(&(my_pubkey.to_string(), channel_id.to_string())); + } + } + + /// Return the active identity keys if they are in a signable state. + /// + /// Returns `Err` when the identity is in a lost state (`identity_lost` + /// — ephemeral key, user must re-import their nsec) or when the keyring + /// is locked (`keyring_locked` — key is held in a keyring that is + /// unavailable this boot). All signing and publish commands must call + /// this instead of locking `state.keys` directly, so that recovery mode + /// blocks publishing under an invalid or inaccessible identity. + pub fn signing_keys(&self) -> Result { + if self + .identity_lost + .load(std::sync::atomic::Ordering::Acquire) + || self + .keyring_locked + .load(std::sync::atomic::Ordering::Acquire) + { + return Err("identity is in recovery mode; event signing is disabled \ + until the identity is restored and Buzz is relaunched" + .to_string()); + } + self.keys + .lock() + .map_err(|e| e.to_string()) + .map(|k| k.clone()) + } + + /// Emit the current huddle state to the frontend via Tauri event. + /// + /// Acquires both locks (app_handle + huddle_state), clones a snapshot, + /// releases both, then emits. Best-effort — no-op if either lock is + /// poisoned or the app_handle hasn't been set yet. + pub fn emit_huddle_state_changed(&self) { + let app = match self.app_handle.lock() { + Ok(guard) => guard.clone(), + Err(_) => return, + }; + let Some(app) = app else { return }; + let snapshot = match self.huddle_state.lock() { + Ok(hs) => hs.clone(), + Err(_) => return, + }; + crate::huddle::state::emit_huddle_state(&app, &snapshot); + } +} + +pub(crate) struct ManagedAgentPersonaMintReservation<'a> { + active: &'a Mutex>, + persona_id: String, +} + +impl Drop for ManagedAgentPersonaMintReservation<'_> { + fn drop(&mut self) { + if let Ok(mut active) = self.active.lock() { + active.remove(&self.persona_id); + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn persona_mint_reservation_serializes_the_pre_save_window() { + let state = crate::app_state::build_app_state(); + let first = state + .reserve_managed_agent_persona_mint("persona-1".to_string()) + .unwrap(); + + let error = match state.reserve_managed_agent_persona_mint("persona-1".to_string()) { + Ok(_) => panic!("a second reservation must not enter the mint window"), + Err(error) => error, + }; + assert!(error.contains("already in progress")); + + drop(first); + assert!(state + .reserve_managed_agent_persona_mint("persona-1".to_string()) + .is_ok()); + } +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 7ce03b140b..b8dc9084cd 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -830,9 +830,24 @@ pub async fn update_managed_agent( state.clear_agent_session_caches(pubkey); } + let persona_link_update = input.persona_id; + let link_personas = if persona_link_update.is_some() { + Some(load_personas(&app)?) + } else { + None + }; let record = find_managed_agent_mut(&mut records, &input.pubkey)?; let previous_record = record.clone(); + if let Some(persona_update) = persona_link_update { + crate::managed_agents::persona_events::apply_persona_link_update( + record, + link_personas + .as_deref() + .expect("persona update loads definitions"), + persona_update, + )?; + } let mut name_changed = false; if let Some(name_update) = input.name { let trimmed = name_update.trim().to_string(); @@ -850,29 +865,16 @@ pub async fn update_managed_agent( if let Some(parallelism) = input.parallelism { record.parallelism = parallelism; } - // turn_timeout_seconds is intentionally not applied here — - // BUZZ_ACP_TURN_TIMEOUT is deprecated and ignored by the harness. - // Use idle_timeout_seconds or max_turn_duration_seconds instead. - // Store the relay override exactly as supplied (trimmed). An explicit - // value pins the agent; empty falls back to the workspace relay at - // read-time. A name-only edit (relay_url == None) leaves the pin intact. + // BUZZ_ACP_TURN_TIMEOUT is deprecated; use idle/max duration instead. if let Some(relay_url) = input.relay_url { record.relay_url = relay_url.trim().to_string(); } if let Some(acp_command) = input.acp_command { record.acp_command = acp_command; } - // Harness edit: the persona's runtime is authoritative, so an explicit - // `agent_command_override` is persisted ONLY when the user picks a - // command that diverges from the persona, and the empty/whitespace - // "Inherit from persona" sentinel clears both the pin and the - // materialized record runtime. A name-only edit - // (`agent_command == None`) leaves the pin intact. `harness_override` - // threads the user's explicit intent — see `apply_agent_command_update` - // and `update_time_agent_command_override` for the full resolution - // rules. + // Empty restores persona inheritance; absent preserves the current pin. if let Some(agent_command) = input.agent_command { - let personas = load_personas(&app).unwrap_or_default(); + let personas = link_personas.unwrap_or_else(|| load_personas(&app).unwrap_or_default()); crate::managed_agents::apply_agent_command_update( record, &personas, @@ -883,9 +885,7 @@ pub async fn update_managed_agent( if let Some(agent_args) = input.agent_args { record.agent_args = agent_args; } - // mcp_command is intentionally not applied here — the effective MCP - // command is always catalog-derived (known_acp_runtime at spawn time) - // and the per-record field is never read by the runtime. + // mcp_command is catalog-derived at spawn time, never request-applied. if let Some(env_vars) = input.env_vars { crate::managed_agents::validate_user_env_keys(&env_vars)?; record.env_vars = env_vars; diff --git a/desktop/src-tauri/src/commands/agent_references.rs b/desktop/src-tauri/src/commands/agent_references.rs new file mode 100644 index 0000000000..b7d2522a7c --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_references.rs @@ -0,0 +1,300 @@ +use serde::Serialize; +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::{AppState, ManagedAgentPersonaMintReservation}, + managed_agents::{ + agent_events::ManagedAgentEventContent, + ensure_persona_is_active, load_personas, + retention::{ + active_retention_scope, get_retained_events, open_retention_db, + retained_tombstone_covers, + }, + ManagedAgentRecord, + }, +}; + +/// Public, non-runnable identity link learned from owner-authored kind:30177. +/// +/// A secondary install deliberately does not receive the agent's secret key, +/// but it still needs this association to avoid minting a duplicate body for +/// an account-scoped definition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ManagedAgentReference { + pub pubkey: String, + pub name: String, + pub persona_id: String, +} + +#[tauri::command] +pub async fn list_managed_agent_references( + app: AppHandle, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let scope = active_retention_scope(&app, &state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let conn = open_retention_db(&scope.db_path)?; + load_live_managed_agent_references(&conn, &owner_pubkey) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + +pub(crate) fn prepare_managed_agent_key_mint<'a>( + app: &AppHandle, + state: &'a AppState, + records: &[ManagedAgentRecord], + persona_id: Option<&str>, +) -> Result<(nostr::Keys, Option>), String> { + let Some(persona_id) = persona_id else { + return Ok((nostr::Keys::generate(), None)); + }; + let reservation = state.reserve_managed_agent_persona_mint(persona_id.to_string())?; + + ensure_persona_is_active(&load_personas(app)?, persona_id)?; + let scope = active_retention_scope(app, state)?; + let reference_sync_ready = state.managed_agent_reference_sync_is_ready(&scope.db_path)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let conn = open_retention_db(&scope.db_path)?; + let mut identities = records + .iter() + .filter_map(|record| { + record + .persona_id + .as_ref() + .map(|persona_id| ManagedAgentReference { + pubkey: record.pubkey.clone(), + name: record.name.clone(), + persona_id: persona_id.clone(), + }) + }) + .collect::>(); + identities.extend(load_live_managed_agent_references(&conn, &owner_pubkey)?); + let keys = generate_managed_agent_keys( + Some(persona_id), + reference_sync_ready, + &identities, + nostr::Keys::generate, + )?; + Ok((keys, Some(reservation))) +} + +/// Mark an exact relay+owner retention scope safe for persona-backed key +/// generation. The frontend calls this only after its historical 30177/5 +/// backfill has been durably reconciled. Both coordinates are checked so a +/// cancelled sync cannot mark a newly selected identity on the same relay. +#[tauri::command] +pub fn mark_managed_agent_reference_sync_ready( + owner_pubkey: String, + arrival_relay_url: String, + app: AppHandle, +) -> Result<(), String> { + let state = app.state::(); + let Some(scope) = crate::managed_agents::retention::arrival_retention_scope( + &app, + &state, + &arrival_relay_url, + )? + else { + return Err("managed-agent identity sync scope changed before completion".to_string()); + }; + if !scope + .owner_keys + .public_key() + .to_hex() + .eq_ignore_ascii_case(owner_pubkey.trim()) + { + return Err("managed-agent identity sync owner changed before completion".to_string()); + } + state.mark_managed_agent_reference_sync_ready(scope.db_path) +} + +pub(crate) fn load_live_managed_agent_references( + conn: &rusqlite::Connection, + owner_pubkey: &str, +) -> Result, String> { + let retained = + get_retained_events(conn, buzz_core_pkg::kind::KIND_MANAGED_AGENT, owner_pubkey)?; + let mut live = Vec::new(); + for event in retained { + if !retained_tombstone_covers( + conn, + buzz_core_pkg::kind::KIND_MANAGED_AGENT, + owner_pubkey, + &event.d_tag, + event.created_at, + )? { + live.push((event.d_tag, event.content)); + } + } + references_from_retained(live) +} + +/// The key-generation boundary for persona-backed agents. +/// +/// `reference_sync_ready` distinguishes a genuinely empty retained identity +/// index from one that has not caught up yet. `identities` contains both local +/// runnable records and account-scoped non-runnable references. Any existing +/// link blocks the factory entirely: the invariant is one persona id, one +/// keypair, independent of relay event ordering or which duplicate happened to +/// remain live in a previous failure. +pub(crate) fn generate_managed_agent_keys( + persona_id: Option<&str>, + reference_sync_ready: bool, + identities: &[ManagedAgentReference], + generate: F, +) -> Result +where + F: FnOnce() -> nostr::Keys, +{ + let Some(persona_id) = persona_id else { + return Ok(generate()); + }; + if !reference_sync_ready { + return Err( + "agent identities are still syncing; wait for sync to finish before starting this persona" + .to_string(), + ); + } + + let mut existing_pubkeys = identities + .iter() + .filter(|reference| reference.persona_id == persona_id) + .map(|reference| reference.pubkey.as_str()) + .collect::>(); + existing_pubkeys.sort_unstable(); + existing_pubkeys.dedup(); + if !existing_pubkeys.is_empty() { + return Err(format!( + "persona {persona_id} already has a managed-agent keypair ({}) and cannot mint another", + existing_pubkeys.join(", ") + )); + } + + Ok(generate()) +} + +fn references_from_retained( + events: impl IntoIterator, +) -> Result, String> { + let mut references = events + .into_iter() + .filter_map(|(pubkey, content)| { + let content: ManagedAgentEventContent = match serde_json::from_str(&content) { + Ok(content) => content, + Err(error) => { + return Some(Err(format!("failed to parse managed-agent event: {error}"))) + } + }; + content.persona_id.map(|persona_id| { + Ok(ManagedAgentReference { + pubkey, + name: content.name, + persona_id, + }) + }) + }) + .collect::, String>>()?; + references.sort_by(|left, right| left.pubkey.cmp(&right.pubkey)); + Ok(references) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[test] + fn retained_links_form_non_runnable_reference_index() { + let linked = serde_json::json!({ + "name": "Rimac-Buzz", + "persona_id": "rimac-definition", + "parallelism": 1, + "respond_to": "owner-only" + }) + .to_string(); + let standalone = serde_json::json!({ + "name": "Standalone", + "parallelism": 1, + "respond_to": "owner-only" + }) + .to_string(); + + let references = references_from_retained([ + ("497d45dd".to_string(), linked), + ("standalone-pubkey".to_string(), standalone), + ]) + .unwrap(); + + assert_eq!( + references, + vec![ManagedAgentReference { + pubkey: "497d45dd".to_string(), + name: "Rimac-Buzz".to_string(), + persona_id: "rimac-definition".to_string(), + }] + ); + } + + #[test] + fn unsynced_empty_index_fails_closed_before_key_generation() { + let calls = AtomicUsize::new(0); + let error = generate_managed_agent_keys(Some("rimac-definition"), false, &[], || { + calls.fetch_add(1, Ordering::Relaxed); + nostr::Keys::generate() + }) + .unwrap_err(); + + assert!(error.contains("still syncing")); + assert_eq!(calls.load(Ordering::Relaxed), 0); + } + + #[test] + fn an_existing_persona_key_blocks_mint_without_selecting_a_winner() { + let identities = vec![ + ManagedAgentReference { + pubkey: "original-key".to_string(), + name: "Rimac-Buzz".to_string(), + persona_id: "rimac-definition".to_string(), + }, + ManagedAgentReference { + pubkey: "previous-duplicate".to_string(), + name: "Rimac-Buzz".to_string(), + persona_id: "rimac-definition".to_string(), + }, + ]; + let calls = AtomicUsize::new(0); + let error = + generate_managed_agent_keys(Some("rimac-definition"), true, &identities, || { + calls.fetch_add(1, Ordering::Relaxed); + nostr::Keys::generate() + }) + .unwrap_err(); + + assert!(error.contains("original-key")); + assert!(error.contains("previous-duplicate")); + assert_eq!( + calls.load(Ordering::Relaxed), + 0, + "no second keypair may be generated" + ); + } + + #[test] + fn a_ready_unclaimed_persona_generates_exactly_one_keypair() { + let calls = AtomicUsize::new(0); + let keys = generate_managed_agent_keys(Some("new-definition"), true, &[], || { + calls.fetch_add(1, Ordering::Relaxed); + nostr::Keys::generate() + }) + .unwrap(); + + assert!(!keys.public_key().to_hex().is_empty()); + assert_eq!(calls.load(Ordering::Relaxed), 1); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac..a17e09e4f0 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1,22 +1,25 @@ use nostr::{Keys, ToBech32}; use tauri::{AppHandle, State}; +use super::agent_references::prepare_managed_agent_key_mint; +#[path = "agents_create.rs"] +mod create; use crate::{ app_state::AppState, managed_agents::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, - ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, stop_managed_agent_workspace_pair, - sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, - ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, - DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + find_managed_agent_mut, load_managed_agents, load_personas, load_teams, + managed_agent_avatar_url, normalize_agent_args, provider_deploy, resolve_provider_binary, + save_managed_agents, start_managed_agent_process, stop_managed_agent_process, + stop_managed_agent_workspace_pair, sync_managed_agent_processes, try_regenerate_nest, + validate_provider_config, BackendKind, CreateManagedAgentRequest, + CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, + DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, }; +use create::{normalize_relay_mesh, trim_to_optional_string}; /// Read the workspace owner pubkey without holding the lock. Used to populate `BUZZ_ACP_AGENT_OWNER` /// as a fallback for legacy agent records that have no NIP-OA `auth_tag`. @@ -201,36 +204,6 @@ pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, a } } -fn normalize_relay_mesh( - config: Option<&RelayMeshConfig>, - backend: &BackendKind, -) -> Result, String> { - let Some(config) = config else { - return Ok(None); - }; - - let model_ref = config.model_ref.trim(); - if model_ref.is_empty() { - return Err("Buzz shared compute model is required".to_string()); - } - if backend != &BackendKind::Local { - return Err("Buzz shared compute agents must use the local backend".to_string()); - } - - Ok(Some(RelayMeshConfig { - model_ref: model_ref.to_string(), - })) -} - -fn trim_to_optional_string(value: &str) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - fn resolve_created_avatar_url( requested_avatar_url: Option<&str>, persona_avatar_url: Option, @@ -606,7 +579,7 @@ pub async fn create_managed_agent( let owner_hex = workspace_owner_hex(&state)?; // ── Phase 1: generate keys (sync lock) ──────────────────────────────────── - let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = { + let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input, persona_mint_reservation) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -625,11 +598,12 @@ pub async fn create_managed_agent( for pubkey in &exited_pubkeys { state.clear_agent_session_caches(pubkey); } - if let Some(persona_id) = requested_persona_id.as_deref() { - let personas = load_personas(&app)?; - ensure_persona_is_active(&personas, persona_id)?; - } - let keys = Keys::generate(); + let (keys, persona_mint_reservation) = prepare_managed_agent_key_mint( + &app, + &state, + &records, + requested_persona_id.as_deref(), + )?; let pubkey = keys.public_key().to_hex(); if records.iter().any(|record| record.pubkey == pubkey) { return Err(format!("agent {pubkey} already exists")); @@ -649,7 +623,14 @@ pub async fn create_managed_agent( .unwrap_or("") .to_string(); - (keys, private_key_nsec, pubkey, resolved_relay_url, input) + ( + keys, + private_key_nsec, + pubkey, + resolved_relay_url, + input, + persona_mint_reservation, + ) }; // ── Pre-Phase 2: validate provider config BEFORE any side effects ──────── @@ -939,6 +920,7 @@ pub async fn create_managed_agent( resolved_avatar_url, ) }; + drop(persona_mint_reservation); // ── Phase 3b: local spawn (async preflight outside store lock) ─────────── let mut spawn_error = None; diff --git a/desktop/src-tauri/src/commands/agents_create.rs b/desktop/src-tauri/src/commands/agents_create.rs new file mode 100644 index 0000000000..22212944c5 --- /dev/null +++ b/desktop/src-tauri/src/commands/agents_create.rs @@ -0,0 +1,31 @@ +use crate::managed_agents::{BackendKind, RelayMeshConfig}; + +pub(super) fn normalize_relay_mesh( + config: Option<&RelayMeshConfig>, + backend: &BackendKind, +) -> Result, String> { + let Some(config) = config else { + return Ok(None); + }; + + let model_ref = config.model_ref.trim(); + if model_ref.is_empty() { + return Err("Buzz shared compute model is required".to_string()); + } + if backend != &BackendKind::Local { + return Err("Buzz shared compute agents must use the local backend".to_string()); + } + + Ok(Some(RelayMeshConfig { + model_ref: model_ref.to_string(), + })) +} + +pub(super) fn trim_to_optional_string(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..2298c030ac 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ mod agent_model_process; mod agent_models; mod agent_models_env; mod agent_providers; +mod agent_references; mod agent_settings; mod agent_update_rollback; mod agents; @@ -68,6 +69,7 @@ pub use agent_logs::*; pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; +pub use agent_references::*; pub use agent_settings::*; pub use agents::*; pub use canvas::*; diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index d7ffecef2d..34711bcb6a 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -73,7 +73,10 @@ fn reconcile_inbound_persona_event_blocking( agent_events::managed_agent_content_from_event, load_managed_agents, load_teams, persona_events::persona_from_event, - retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, + retention::{ + open_retention_db, retain_inbound_event, retained_tombstone_covers, InboundOutcome, + RetainedEvent, + }, save_managed_agents, save_teams, team_events::team_content_from_event, }; @@ -131,6 +134,15 @@ fn reconcile_inbound_persona_event_blocking( return Ok(()); }; let conn = open_retention_db(&scope.db_path)?; + if retained_tombstone_covers( + &conn, + kind, + &event.pubkey.to_hex(), + &d_tag, + event.created_at.as_secs() as i64, + )? { + return Ok(()); + } let outcome = retain_inbound_event( &conn, &RetainedEvent { @@ -224,6 +236,26 @@ fn parse_deletion_coordinate(event: &nostr::Event) -> Option<(u32, String)> { }) } +/// Preserve local identities before an inbound shared-definition tombstone. +/// +/// Returns the pubkeys whose kind:30177 projection must be republished after +/// persistence. +fn detach_persona_agents( + agents: &mut [ManagedAgentRecord], + persona: &AgentDefinition, +) -> Vec { + let mut detached = Vec::new(); + for agent in agents + .iter_mut() + .filter(|agent| agent.persona_id.as_deref() == Some(persona.id.as_str())) + { + crate::managed_agents::persona_events::detach_persona(agent, persona); + agent.updated_at = now_iso(); + detached.push(agent.pubkey.clone()); + } + detached +} + /// Apply an inbound kind:5 NIP-09 deletion: remove the local record at the /// tombstone's target coordinate, scoped per-kind. Mirrors the upsert spine — /// arrival-scoped retention resolution under the store lock, then a per-kind @@ -239,8 +271,8 @@ fn reconcile_inbound_tombstone( use crate::managed_agents::{ load_managed_agents, load_teams, retention::{ - open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, - RetainedEvent, + delete_retained_event, open_retention_db, retain_inbound_event, + tombstone_retention_d_tag, InboundOutcome, RetainedEvent, }, save_managed_agents, save_teams, }; @@ -286,13 +318,34 @@ fn reconcile_inbound_tombstone( return Ok(()); } - // Remove the local record using the SAME per-kind match rule the apply fns - // use: persona by `persona_d_tag`, team by `id`, managed-agent by `pubkey`. + // Purge the superseded upsert so the non-runnable identity index cannot + // resurrect a deleted reference. + delete_retained_event(&conn, target_kind, &event.pubkey.to_hex(), &target_d_tag)?; + match target_kind { KIND_PERSONA => { let mut personas = load_personas(app)?; + let removed_persona = personas + .iter() + .find(|record| persona_d_tag(record) == target_d_tag) + .cloned(); + let mut agents = load_managed_agents(app)?; + let detached_agents = removed_persona + .as_ref() + .map(|persona| detach_persona_agents(&mut agents, persona)) + .unwrap_or_default(); + if !detached_agents.is_empty() { + save_managed_agents(app, &agents)?; + } + personas.retain(|record| persona_d_tag(record) != target_d_tag); save_personas(app, &personas)?; + + for pubkey in &detached_agents { + if let Some(agent) = agents.iter().find(|agent| agent.pubkey == *pubkey) { + crate::commands::agents::retain_managed_agent_pending(app, state, agent); + } + } } KIND_TEAM => { let mut teams = load_teams(app)?; diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index 1005a83432..3e17f30973 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -632,6 +632,39 @@ fn tombstone_removal_predicates_match_apply_fn_keys() { assert!(agents.is_empty(), "agent removed by pubkey"); } +#[test] +fn persona_tombstone_detaches_linked_agents_without_changing_identity() { + let persona = local_in_app(); + let mut linked = local_agent(); + linked.persona_id = Some(persona.id.clone()); + linked.system_prompt = Some("stale instance prompt".to_string()); + let original_pubkey = linked.pubkey.clone(); + let mut unrelated = local_agent(); + unrelated.pubkey = "unrelated-agent-pubkey".to_string(); + unrelated.persona_id = Some("another-persona".to_string()); + let mut agents = vec![linked, unrelated]; + + let detached = detach_persona_agents(&mut agents, &persona); + + assert_eq!(detached, vec![original_pubkey.clone()]); + let preserved = agents + .iter() + .find(|agent| agent.pubkey == original_pubkey) + .unwrap(); + assert_eq!(preserved.persona_id, None); + assert_eq!(preserved.persona_source_version, None); + assert_eq!( + preserved.system_prompt.as_deref(), + Some("local prompt"), + "the disappearing definition is snapshotted before detachment" + ); + assert_eq!( + agents[1].persona_id.as_deref(), + Some("another-persona"), + "unrelated links are untouched" + ); +} + // ── Inbound signature gate ────────────────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 66f7296a25..931ac2691d 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -3,8 +3,8 @@ use tauri::AppHandle; use crate::{ app_state::AppState, managed_agents::{ - current_instance_id, delete_agent_key, load_managed_agents, load_personas, load_teams, - save_managed_agents, save_personas, stop_managed_agent_process, + current_instance_id, delete_agent_key, is_relay_synced_persona, load_managed_agents, + load_personas, load_teams, save_managed_agents, save_personas, stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest, validate_persona_activation_change, validate_persona_deletion, AgentDefinition, ManagedAgentRecord, }, @@ -135,9 +135,10 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { .any(|persona_id| persona_id == id.as_str()) }); validate_persona_deletion(persona, referenced_by_team)?; - // Capture the coordinate before the record might leave the list. Only - // reached for non-builtin, non-team personas (both rejected above), - // so every deleted persona here is one this owner published. + // A definition learned from the owner's relay belongs to another + // install. Removing its bodyless entry here is a local cleanup, + // never authority to delete the shared definition everywhere. + let relay_synced = is_relay_synced_persona(persona); let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona); // ── Phase 1: Stage ───────────────────────────────────────────── @@ -224,10 +225,22 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { })?; } - let original_len = personas.len(); - personas.retain(|record| record.id != id); - if personas.len() == original_len { - return Err(format!("persona {id} not found")); + if relay_synced { + let persona = personas + .iter_mut() + .find(|record| record.id == id) + .ok_or_else(|| format!("persona {id} not found"))?; + // Keep a local inactive marker so a reconnecting relay upsert + // patches this row without making the removed entry visible + // again in My Agents. + persona.is_active = false; + persona.updated_at = now_iso(); + } else { + let original_len = personas.len(); + personas.retain(|record| record.id != id); + if personas.len() == original_len { + return Err(format!("persona {id} not found")); + } } save_personas(&app, &personas)?; @@ -239,7 +252,9 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { super::agents::tombstone_managed_agent_pending(&app, &state, pk); super::agents::archive_managed_agent_pending(&app, &state, pk); } - tombstone_persona_pending(&app, &state, &d_tag); + if !relay_synced { + tombstone_persona_pending(&app, &state, &d_tag); + } // _store_guard drops here, before try_regenerate_nest. } diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 731a99d9d9..9fee34143c 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -164,6 +164,11 @@ pub async fn apply_workspace( }; // ── Apply all state changes (nothing below can fail) ────────────────── + // A successful backfill belongs to the exact relay+owner scope that + // produced it. Clear the process-local readiness proof before either + // coordinate changes; AppShell will mark the new scope ready only + // after its retained-event backfill has been reconciled. + state.clear_managed_agent_reference_sync_ready()?; { let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; *override_guard = Some(relay_url); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866..bba5025c35 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -770,6 +770,8 @@ pub fn run() { resolve_oa_owner, list_relay_agents, list_managed_agents, + list_managed_agent_references, + mark_managed_agent_reference_sync_ready, list_managed_agent_runtimes, start_managed_agent_runtime, stop_managed_agent_runtime, diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index ea61a811db..e6c1ac673e 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -521,5 +521,66 @@ pub fn preview_prospective_persona_snapshot( } preview } + +/// Link an existing local agent identity to a live definition and refresh its +/// pinned runnable snapshot. Used by the edit surface to repair orphaned +/// `persona_id` references without minting a replacement identity. +pub fn relink_persona(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { + record.persona_id = Some(persona.id.clone()); + apply_persona_snapshot(record, persona); +} + +/// Convert a definition-linked local agent into a standalone agent while the +/// definition is still available. +/// +/// A shared definition tombstone must not leave a live local identity pointing +/// at a missing persona. Materialize the definition's effective config first, +/// including inherited env vars, then clear the relationship and drift marker. +/// The agent keeps its keypair and remains runnable from its own record. +pub fn detach_persona(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { + apply_persona_snapshot(record, persona); + for (key, value) in &persona.env_vars { + record + .env_vars + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + record.persona_id = None; + record.persona_source_version = None; +} + +/// Apply the tri-state managed-agent definition update. +/// +/// `Some(id)` relinks to an active definition, while `None` detaches. The +/// caller distinguishes this from an absent request field. +pub fn apply_persona_link_update( + record: &mut ManagedAgentRecord, + personas: &[AgentDefinition], + persona_update: Option, +) -> Result<(), String> { + match persona_update { + Some(persona_id) => { + super::ensure_persona_is_active(personas, &persona_id)?; + let persona = personas + .iter() + .find(|persona| persona.id == persona_id) + .ok_or_else(|| format!("persona {persona_id} not found"))?; + relink_persona(record, persona); + } + None => { + if let Some(persona) = record + .persona_id + .as_deref() + .and_then(|id| personas.iter().find(|persona| persona.id == id)) + { + detach_persona(record, persona); + } else { + record.persona_id = None; + record.persona_source_version = None; + } + } + } + Ok(()) +} #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b9542f9a87..80d00ef555 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -164,6 +164,8 @@ fn sample_persona() -> AgentDefinition { } } +mod identity_repair_tests; + #[test] fn monotonic_created_at_bumps_past_head() { // No head: uses now (floor 0). diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests/identity_repair_tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests/identity_repair_tests.rs new file mode 100644 index 0000000000..2911b66da6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests/identity_repair_tests.rs @@ -0,0 +1,63 @@ +use super::*; + +#[test] +fn relink_persona_repairs_identity_without_reminting() { + let persona = sample_persona(); + let mut record = persona.clone().into_agent_record(); + record.pubkey = "existing-agent-pubkey".to_string(); + record.slug = None; + record.persona_id = Some("deleted-persona".to_string()); + record.persona_source_version = None; + + relink_persona(&mut record, &persona); + + assert_eq!(record.pubkey, "existing-agent-pubkey"); + assert_eq!(record.persona_id.as_deref(), Some("test-persona")); + assert_eq!( + record.system_prompt.as_deref(), + Some("You are a test assistant.") + ); + assert!(record.persona_source_version.is_some()); +} + +#[test] +fn detach_persona_materializes_runnable_standalone_snapshot() { + let mut persona = sample_persona(); + persona + .env_vars + .insert("INHERITED".to_string(), "materialized".to_string()); + let mut record = persona.clone().into_agent_record(); + record.pubkey = "existing-agent-pubkey".to_string(); + record.slug = None; + record.persona_id = Some(persona.id.clone()); + record.env_vars.clear(); + record + .env_vars + .insert("KEY".to_string(), "instance-override".to_string()); + record + .env_vars + .insert("INSTANCE_ONLY".to_string(), "yes".to_string()); + + detach_persona(&mut record, &persona); + + assert_eq!(record.pubkey, "existing-agent-pubkey"); + assert_eq!(record.persona_id, None); + assert_eq!(record.persona_source_version, None); + assert_eq!(record.runtime.as_deref(), Some("goose")); + assert_eq!(record.model.as_deref(), Some("claude-opus-4")); + assert_eq!(record.provider.as_deref(), Some("anthropic")); + assert_eq!( + record.env_vars.get("KEY").map(String::as_str), + Some("instance-override"), + "instance override must win over inherited definition env" + ); + assert_eq!( + record.env_vars.get("INSTANCE_ONLY").map(String::as_str), + Some("yes") + ); + assert_eq!( + record.env_vars.get("INHERITED").map(String::as_str), + Some("materialized"), + "inherited env must survive after the definition is removed" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 9bf7ab74b0..cdd7d4f13c 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -298,6 +298,17 @@ pub fn validate_persona_deletion( Ok(()) } +/// Whether this install learned the definition from the owner's relay rather +/// than authoring it locally. +/// +/// Locally-created definitions keep `source_team_persona_slug = None`; the +/// inbound kind:30175 parser records the relay d-tag there. Team definitions +/// also carry a slug, but have `source_team = Some` and are handled by the +/// existing team deletion guard. +pub fn is_relay_synced_persona(persona: &AgentDefinition) -> bool { + persona.source_team.is_none() && persona.source_team_persona_slug.is_some() +} + pub fn validate_persona_activation_change( persona: &AgentDefinition, active: bool, diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index 387b4d72c6..0f431e1ac3 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -1,7 +1,8 @@ use super::{ built_in_persona_records, ensure_persona_ids_are_active, ensure_persona_is_active, - merge_personas, migrate_retired_personas, validate_persona_activation_change, - validate_persona_deletion, BUILT_IN_PERSONAS, RETIRED_PERSONAS, + is_relay_synced_persona, merge_personas, migrate_retired_personas, + validate_persona_activation_change, validate_persona_deletion, BUILT_IN_PERSONAS, + RETIRED_PERSONAS, }; use crate::managed_agents::discovery::{default_agent_command, effective_agent_command}; use crate::managed_agents::AgentDefinition; @@ -31,6 +32,23 @@ fn custom_persona(id: &str, display_name: &str) -> AgentDefinition { } } +#[test] +fn relay_synced_persona_is_distinct_from_local_and_team_definitions() { + let local = custom_persona("local", "Local"); + assert!(!is_relay_synced_persona(&local)); + + let mut relay_synced = custom_persona("remote", "Remote"); + relay_synced.source_team_persona_slug = Some("remote".to_string()); + assert!(is_relay_synced_persona(&relay_synced)); + + let mut team = relay_synced; + team.source_team = Some("team-1".to_string()); + assert!( + !is_relay_synced_persona(&team), + "team definitions use their existing guarded deletion path" + ); +} + #[test] fn merge_personas_adds_missing_built_ins() { let (records, changed) = merge_personas(Vec::new(), "2026-03-19T00:00:00Z"); diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 7e97fa1f56..a7a9ce5dac 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -16,6 +16,8 @@ use crate::app_state::AppState; mod legacy_migration; pub use legacy_migration::migrate_legacy_retention_db; +mod queries; +pub use queries::{get_retained_events, retained_tombstone_covers}; /// Durable event-retention scope for one community relay and owner identity. /// @@ -310,31 +312,17 @@ pub fn get_retained_personas( conn: &Connection, pubkey: &str, ) -> Result, String> { - let mut stmt = conn - .prepare( - "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync - FROM persona_events - WHERE pubkey = ?1 - ORDER BY d_tag", - ) - .map_err(|e| format!("failed to prepare query: {e}"))?; - - let rows = stmt - .query_map(params![pubkey], |row| { - Ok(RetainedEvent { - kind: row.get(0)?, - pubkey: row.get(1)?, - d_tag: row.get(2)?, - content: row.get(3)?, - created_at: row.get(4)?, - raw_event: row.get(5)?, - pending_sync: row.get::<_, i32>(6)? != 0, - }) - }) - .map_err(|e| format!("failed to query retained events: {e}"))?; - - rows.collect::, _>>() - .map_err(|e| format!("failed to read retained event row: {e}")) + let mut rows = Vec::new(); + for kind in [ + buzz_core_pkg::kind::KIND_PERSONA, + buzz_core_pkg::kind::KIND_TEAM, + buzz_core_pkg::kind::KIND_MANAGED_AGENT, + 5, + ] { + rows.extend(get_retained_events(conn, kind, pubkey)?); + } + rows.sort_by(|left, right| left.d_tag.cmp(&right.d_tag)); + Ok(rows) } /// Get all events marked as pending sync (not yet confirmed on relay). diff --git a/desktop/src-tauri/src/managed_agents/retention/queries.rs b/desktop/src-tauri/src/managed_agents/retention/queries.rs new file mode 100644 index 0000000000..d8015f6c60 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/retention/queries.rs @@ -0,0 +1,90 @@ +use rusqlite::{params, Connection}; + +use super::{get_retained_event, tombstone_retention_d_tag, RetainedEvent}; + +/// Load retained events for one owner and kind. +pub fn get_retained_events( + conn: &Connection, + kind: u32, + pubkey: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT kind, pubkey, d_tag, content, created_at, raw_event, pending_sync + FROM persona_events + WHERE kind = ?1 AND pubkey = ?2 + ORDER BY d_tag", + ) + .map_err(|e| format!("failed to prepare query: {e}"))?; + + let rows = stmt + .query_map(params![kind, pubkey], |row| { + Ok(RetainedEvent { + kind: row.get(0)?, + pubkey: row.get(1)?, + d_tag: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + raw_event: row.get(5)?, + pending_sync: row.get::<_, i32>(6)? != 0, + }) + }) + .map_err(|e| format!("failed to query retained events: {e}"))?; + + rows.collect::, _>>() + .map_err(|e| format!("failed to read retained event row: {e}")) +} + +/// Whether a retained NIP-09 tombstone suppresses an upsert at the target +/// coordinate. A strictly newer upsert may recreate the coordinate. +pub fn retained_tombstone_covers( + conn: &Connection, + target_kind: u32, + pubkey: &str, + d_tag: &str, + created_at: i64, +) -> Result { + Ok(get_retained_event( + conn, + 5, + pubkey, + &tombstone_retention_d_tag(target_kind, d_tag), + )? + .is_some_and(|tombstone| tombstone.created_at >= created_at)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::retention::{open_retention_db, retain_event}; + + #[test] + fn tombstone_suppression_is_delivery_order_independent() { + let dir = tempfile::tempdir().unwrap(); + let conn = open_retention_db(&dir.path().join("retention.db")).unwrap(); + retain_event( + &conn, + &RetainedEvent { + kind: 5, + pubkey: "owner".to_string(), + d_tag: tombstone_retention_d_tag(30177, "agent-pubkey"), + content: String::new(), + created_at: 2000, + raw_event: "{}".to_string(), + pending_sync: false, + }, + ) + .unwrap(); + + for created_at in [1999, 2000] { + assert!( + retained_tombstone_covers(&conn, 30177, "owner", "agent-pubkey", created_at) + .unwrap() + ); + } + assert!( + !retained_tombstone_covers(&conn, 30177, "owner", "agent-pubkey", 2001).unwrap(), + "a newer upsert may legitimately recreate the coordinate" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461..72a3800b1f 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -200,6 +200,10 @@ pub struct CreateManagedAgentRequest { #[serde(rename_all = "camelCase")] pub struct UpdateManagedAgentRequest { pub pubkey: String, + /// Absent = don't touch. null = detach into a standalone agent. + /// "id" = link (or relink) to that active definition. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub persona_id: Option>, /// Absent = don't touch. Present = rename the agent. #[serde(default)] pub name: Option, @@ -292,6 +296,20 @@ mod tests { } } + #[test] + fn managed_agent_persona_update_has_tri_state_semantics() { + let absent: UpdateManagedAgentRequest = serde_json::from_str(r#"{"pubkey":"pk"}"#).unwrap(); + assert_eq!(absent.persona_id, None); + + let detach: UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"pk","personaId":null}"#).unwrap(); + assert_eq!(detach.persona_id, Some(None)); + + let relink: UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"pk","personaId":"persona-2"}"#).unwrap(); + assert_eq!(relink.persona_id, Some(Some("persona-2".to_string()))); + } + /// The anchor regression row: an absent behavior group must leave a /// stored behavior group untouched — legacy update_persona callers (team import, /// profile panel) send no behavior field and must not wipe it. diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54..b1c480e812 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -38,6 +38,7 @@ import { saveCustomHarness, updateManagedAgent, } from "@/shared/api/tauri"; +import { listManagedAgentReferences } from "@/shared/api/agentReferences"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; import { setManagedAgentAutoRestart, @@ -106,6 +107,9 @@ export type { export const relayAgentsQueryKey = ["relay-agents"] as const; export const managedAgentsQueryKey = ["managed-agents"] as const; +export const managedAgentReferencesQueryKey = [ + "managed-agent-references", +] as const; export const personasQueryKey = ["personas"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const; @@ -359,6 +363,14 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) { }); } +export function useManagedAgentReferencesQuery() { + return useQuery({ + queryKey: managedAgentReferencesQueryKey, + queryFn: listManagedAgentReferences, + staleTime: 5_000, + }); +} + export function useCreateManagedAgentMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/features/agents/lib/managedAgentPersonaLinks.test.mjs b/desktop/src/features/agents/lib/managedAgentPersonaLinks.test.mjs new file mode 100644 index 0000000000..78d4ae354d --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentPersonaLinks.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildManagedAgentPersonaLinks, + findRemotePersonaAgent, +} from "./managedAgentPersonaLinks.ts"; + +test("remote kind:30177 links participate in persona identity dedup", () => { + const remote = { + pubkey: "A".repeat(64), + name: "Rimac-Buzz", + personaId: "rimac-definition", + }; + const links = buildManagedAgentPersonaLinks([], [remote]); + + assert.equal( + links.byPubkey.get(remote.pubkey.toLowerCase()), + "rimac-definition", + ); + assert.deepEqual(links.personaIds, new Set(["rimac-definition"])); +}); + +test("remote persona body blocks a second-install mint but local body does not", () => { + const reference = { + pubkey: "a".repeat(64), + name: "Rimac-Buzz", + personaId: "rimac-definition", + }; + + assert.equal( + findRemotePersonaAgent("rimac-definition", new Set(), [reference]), + reference, + ); + assert.equal( + findRemotePersonaAgent( + "rimac-definition", + new Set([reference.pubkey.toUpperCase()]), + [reference], + ), + undefined, + ); +}); diff --git a/desktop/src/features/agents/lib/managedAgentPersonaLinks.ts b/desktop/src/features/agents/lib/managedAgentPersonaLinks.ts new file mode 100644 index 0000000000..074dd44883 --- /dev/null +++ b/desktop/src/features/agents/lib/managedAgentPersonaLinks.ts @@ -0,0 +1,39 @@ +import type { ManagedAgentReference } from "@/shared/api/agentReferences"; +import type { ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export function buildManagedAgentPersonaLinks( + agents: readonly Pick[], + references: readonly ManagedAgentReference[], +) { + const byPubkey = new Map( + references.map((reference) => [ + normalizePubkey(reference.pubkey), + reference.personaId, + ]), + ); + const personaIds = new Set( + references.map((reference) => reference.personaId), + ); + for (const agent of agents) { + if (!agent.personaId) continue; + byPubkey.set(normalizePubkey(agent.pubkey), agent.personaId); + personaIds.add(agent.personaId); + } + return { byPubkey, personaIds }; +} + +export function findRemotePersonaAgent( + personaId: string, + localPubkeys: ReadonlySet, + references: readonly ManagedAgentReference[], +) { + const normalizedLocalPubkeys = new Set( + [...localPubkeys].map((pubkey) => normalizePubkey(pubkey)), + ); + return references.find( + (reference) => + reference.personaId === personaId && + !normalizedLocalPubkeys.has(normalizePubkey(reference.pubkey)), + ); +} diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 174fb9c92c..091d1adc9d 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { + managedAgentReferencesQueryKey, managedAgentsQueryKey, personasQueryKey, relayAgentsQueryKey, @@ -43,6 +44,9 @@ export function useAgentsDataRefresh(): void { void queryClient.invalidateQueries({ queryKey: personasQueryKey }); void queryClient.invalidateQueries({ queryKey: teamsQueryKey }); void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: managedAgentReferencesQueryKey, + }); void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); }, COALESCE_MS); }); diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index 0dc12ddfd1..aa146d92bf 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -23,9 +23,18 @@ const EXPECTED_KINDS = [ // first live event. `startPersonaSync` MUST do a one-shot history fetch up // front, and both the backfill and the live sub MUST carry the deletion kind // so tombstones catch up too. -test("startPersonaSync backfills history including the deletion kind", () => { +test("startPersonaSync backfills history including the deletion kind", async () => { const fetchCalls = []; const liveCalls = []; + const invokes = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (cmd, args) => { + invokes.push({ cmd, args }); + return Promise.resolve(); + }, + }, + }; mock.method(relayClient, "fetchEvents", (filter) => { fetchCalls.push(filter); return Promise.resolve([]); @@ -55,8 +64,17 @@ test("startPersonaSync backfills history including the deletion kind", () => { EXPECTED_KINDS, "live sub must also carry the deletion kind", ); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal( + invokes.filter( + (call) => call.cmd === "mark_managed_agent_reference_sync_ready", + ).length, + 1, + "a completed empty backfill is explicitly marked ready", + ); mock.reset(); + delete globalThis.window; }); // Regression guard for the arrival-scope fix (F6): the reconcile must carry the @@ -66,11 +84,17 @@ test("startPersonaSync backfills history including the deletion kind", () => { // community's scoped retention store on a mid-flight switch. test("startPersonaSync forwards its own relay as the event arrival relay", async () => { const invokes = []; + let finishReconcile; // @tauri-apps/api/core reads `window.__TAURI_INTERNALS__.invoke`. globalThis.window = { __TAURI_INTERNALS__: { invoke: (cmd, args) => { invokes.push({ cmd, args }); + if (cmd === "reconcile_inbound_persona_event") { + return new Promise((resolve) => { + finishReconcile = resolve; + }); + } return Promise.resolve(); }, }, @@ -87,7 +111,7 @@ test("startPersonaSync forwards its own relay as the event arrival relay", async ); startPersonaSync("owner-pubkey", "wss://community-a.example", () => false); - // Let the backfill promise chain and the reconcile invoke settle. + // Let the backfill reach the durable reconcile, but do not complete it yet. await new Promise((resolve) => setImmediate(resolve)); const reconciles = invokes.filter( @@ -104,6 +128,98 @@ test("startPersonaSync forwards its own relay as the event arrival relay", async "reconcile must carry the subscription's relay as the arrival relay", ); assert.equal(JSON.parse(reconciles[0].args.eventJson).id, "e1"); + assert.equal( + invokes.some( + (call) => call.cmd === "mark_managed_agent_reference_sync_ready", + ), + false, + "the identity index must remain unknown while persistence is in flight", + ); + + finishReconcile(); + await new Promise((resolve) => setImmediate(resolve)); + const ready = invokes.filter( + (call) => call.cmd === "mark_managed_agent_reference_sync_ready", + ); + assert.equal(ready.length, 1); + assert.deepEqual(ready[0].args, { + ownerPubkey: "owner-pubkey", + arrivalRelayUrl: "wss://community-a.example", + }); + + mock.reset(); + delete globalThis.window; +}); + +test("startPersonaSync never marks a failed backfill ready", async () => { + const invokes = []; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (cmd, args) => { + invokes.push({ cmd, args }); + return Promise.resolve(); + }, + }, + }; + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("relay unavailable")), + ); + mock.method(relayClient, "subscribeLive", () => + Promise.resolve(() => Promise.resolve()), + ); + mock.method(console, "warn", () => {}); + + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal( + invokes.some( + (call) => call.cmd === "mark_managed_agent_reference_sync_ready", + ), + false, + ); + + mock.reset(); + delete globalThis.window; +}); + +test("startPersonaSync treats a capped, unpageable history as unknown", async () => { + const invokes = []; + let fetchCount = 0; + const tiedPage = Array.from({ length: 500 }, (_, index) => ({ + id: `agent-${index}`, + pubkey: "owner-pubkey", + kind: KIND_MANAGED_AGENT, + created_at: 100, + })); + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: (cmd, args) => { + invokes.push({ cmd, args }); + return Promise.resolve(); + }, + }, + }; + mock.method(relayClient, "fetchEvents", () => { + fetchCount += 1; + return Promise.resolve(tiedPage); + }); + mock.method(relayClient, "subscribeLive", () => + Promise.resolve(() => Promise.resolve()), + ); + mock.method(console, "warn", () => {}); + + startPersonaSync("owner-pubkey", "wss://relay.example", () => false); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(fetchCount, 2, "the inclusive boundary is retried once"); + assert.equal( + invokes.some( + (call) => call.cmd === "mark_managed_agent_reference_sync_ready", + ), + false, + "a capped history must not be treated as a complete identity index", + ); mock.reset(); delete globalThis.window; diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index f18194c5c6..8fb8da53ca 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -1,7 +1,10 @@ import * as React from "react"; import { relayClient } from "@/shared/api/relayClient"; -import { reconcileInboundPersonaEvent } from "@/shared/api/tauriPersonas"; +import { + markManagedAgentReferenceSyncReady, + reconcileInboundPersonaEvent, +} from "@/shared/api/tauriPersonas"; import type { RelayEvent } from "@/shared/api/types"; import { KIND_DELETION, @@ -19,6 +22,43 @@ const PERSONA_SYNC_KINDS = [ KIND_MANAGED_AGENT, KIND_DELETION, ]; +const PERSONA_SYNC_PAGE_SIZE = 500; +const MAX_PERSONA_SYNC_PAGES = 20; + +async function fetchPersonaSyncBackfill(pubkey: string): Promise<{ + events: RelayEvent[]; + complete: boolean; +}> { + const byId = new Map(); + let until: number | undefined; + + for (let page = 0; page < MAX_PERSONA_SYNC_PAGES; page += 1) { + const events = await relayClient.fetchEvents({ + kinds: PERSONA_SYNC_KINDS, + authors: [pubkey], + limit: PERSONA_SYNC_PAGE_SIZE, + ...(until === undefined ? {} : { until }), + }); + const sizeBefore = byId.size; + let oldestCreatedAt = Number.POSITIVE_INFINITY; + for (const event of events) { + byId.set(event.id, event); + oldestCreatedAt = Math.min(oldestCreatedAt, event.created_at); + } + + if (events.length < PERSONA_SYNC_PAGE_SIZE) { + return { events: [...byId.values()], complete: true }; + } + if (byId.size === sizeBefore) { + // `until` is inclusive. A full page made entirely of boundary repeats + // cannot prove there are no unseen events with the same timestamp. + return { events: [...byId.values()], complete: false }; + } + until = oldestCreatedAt; + } + + return { events: [...byId.values()], complete: false }; +} // Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: // one-shot backfill of existing heads + tombstones, then a live subscription. @@ -35,32 +75,53 @@ export function startPersonaSync( relayUrl: string, onCancelled: () => boolean, ): () => Promise { - const reconcile = (event: RelayEvent) => { + const reconcile = async (event: RelayEvent) => { if (event.pubkey !== pubkey) return; - void reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl).catch( - (error) => { - console.warn("[usePersonaSync] reconcile failed:", error); - }, - ); + await reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl); + }; + const reconcileLive = (event: RelayEvent) => { + void reconcile(event).catch((error) => { + console.warn("[usePersonaSync] reconcile failed:", error); + }); }; // One-shot backfill of existing heads + tombstones (closes the fresh-start // gap that live-only subscription + reconnect-replay cannot recover). - void relayClient - .fetchEvents({ kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 500 }) - .then((events) => { + // Reconcile every event durably before declaring the managed-agent identity + // index ready. A successful but not-yet-applied empty view is "unknown", not + // proof that a persona has no keypair. + void (async () => { + try { + const backfill = await fetchPersonaSyncBackfill(pubkey); if (onCancelled()) return; - for (const event of events) reconcile(event); - }) - .catch((error) => { + let reconcileFailed = false; + for (const event of backfill.events) { + if (onCancelled()) return; + try { + await reconcile(event); + } catch (error) { + reconcileFailed = true; + console.warn("[usePersonaSync] reconcile failed:", error); + } + } + if (onCancelled()) return; + if (!backfill.complete || reconcileFailed) { + console.warn( + "[usePersonaSync] managed-agent identity backfill is incomplete; persona starts remain blocked", + ); + return; + } + await markManagedAgentReferenceSyncReady(pubkey, relayUrl); + } catch (error) { console.warn("[usePersonaSync] backfill failed:", error); - }); + } + })(); let unsub: (() => Promise) | null = null; void relayClient .subscribeLive( { kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 0 }, - reconcile, + reconcileLive, ) .then((dispose) => { if (onCancelled()) { diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f3c410e2ff..9ff5064dd5 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -89,6 +89,7 @@ import { runtimeDropdownAction, usePendingHarnessSelection, } from "./addCustomHarness"; +import { useAgentRelink } from "./OrphanedAgentRelinkField"; const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, @@ -145,13 +146,9 @@ export function AgentInstanceEditDialog({ const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] = React.useState(agent.autoRestartOnConfigChange); const personasQuery = usePersonasQuery(); - const linkedPersona = React.useMemo( - () => - agent.personaId - ? (personasQuery.data?.find((p) => p.id === agent.personaId) ?? null) - : null, - [agent.personaId, personasQuery.data], - ); + const personas = personasQuery.data ?? []; + const orphanRelink = useAgentRelink(agent, open, personas, setInheritHarness); + const linkedPersona = orphanRelink.linkedPersona; const inheritedEnvVars = linkedPersona?.envVars ?? {}; const [respondTo, setRespondTo] = React.useState( agent.respondTo, @@ -656,6 +653,7 @@ export function AgentInstanceEditDialog({ const submitEnvVars = inheritedSubmission.envVars; const input: UpdateManagedAgentInput = { pubkey: agent.pubkey, + personaId: orphanRelink.inputPersonaId, name: name.trim() !== agent.name ? name.trim() : undefined, // relayUrl deliberately never submitted: the legacy per-record pin is // ignored (#2122) and the stored value is preserved as-is. @@ -936,6 +934,8 @@ export function AgentInstanceEditDialog({ + {orphanRelink.renderField(updateMutation.isPending)} + {/* Who can talk to this agent */} void, +) { + const [personaId, setPersonaId] = React.useState(""); + + // biome-ignore lint/correctness/useExhaustiveDependencies: switching agents while the dialog stays open must clear a stale repair target + React.useEffect(() => { + if (open) setPersonaId(""); + }, [agent.pubkey, open]); + + const linkedPersona = React.useMemo(() => { + const effectiveId = personaId || agent.personaId; + return effectiveId + ? (personas.find((persona) => persona.id === effectiveId) ?? null) + : null; + }, [agent.personaId, personaId, personas]); + + const inputPersonaId: UpdateManagedAgentInput["personaId"] = + agent.personaOrphaned && personaId ? personaId : undefined; + + const options = React.useMemo( + () => + personas + .filter((persona) => persona.isActive) + .sort((left, right) => + left.displayName.localeCompare(right.displayName), + ) + .map((persona) => ({ + label: persona.displayName, + value: persona.id, + })), + [personas], + ); + + const renderField = (disabled: boolean) => + agent.personaOrphaned ? ( +
+ +

+ This agent's definition was deleted. Choose an existing + definition to repair the link without changing the agent's + identity. +

+ { + setPersonaId(value); + onRelink(true); + }} + options={options} + placeholder={ + options.length === 0 + ? "No active definitions available" + : "Choose a definition" + } + value={personaId} + /> +
+ ) : null; + + return { inputPersonaId, linkedPersona, renderField }; +} diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index e1c2e9c9fc..8f0e0b9489 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -5,6 +5,7 @@ import { useAvailableAcpRuntimes, useCreateManagedAgentMutation, useManagedAgentLogQuery, + useManagedAgentReferencesQuery, useManagedAgentsQuery, useRelayAgentsQuery, useSetManagedAgentStartOnAppLaunchMutation, @@ -22,7 +23,7 @@ import type { ManagedAgent, } from "@/shared/api/types"; import { removeChannelMember } from "@/shared/api/tauri"; -import { normalizePubkey } from "@/shared/lib/pubkey"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { deleteManagedAgentWithRules, isManagedAgentActive, @@ -35,11 +36,13 @@ import { buildInstanceInputForDefinition, resolveStartRuntimeForDefinition, } from "../lib/instanceInputForDefinition"; +import { findRemotePersonaAgent } from "../lib/managedAgentPersonaLinks"; export function useManagedAgentActions() { const { globalConfig } = useGlobalAgentConfig(); const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentReferencesQuery = useManagedAgentReferencesQuery(); const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false); const channelsQuery = useChannelsQuery({ enabled: shouldLoadChannels }); const startMutation = useStartManagedAgentMutation(); @@ -192,6 +195,22 @@ export function useManagedAgentActions() { setPersonaStartPending(persona.id, true); clearFeedback(); try { + const referenceResult = await managedAgentReferencesQuery.refetch(); + if (referenceResult.isError) { + throw new Error( + `Could not verify whether ${persona.displayName} already has an agent identity. Wait for agent sync and try again.`, + ); + } + const existingReference = findRemotePersonaAgent( + persona.id, + managedPubkeys, + referenceResult.data ?? [], + ); + if (existingReference) { + throw new Error( + `${persona.displayName} already has an agent identity on another install (${truncatePubkey(existingReference.pubkey)}). Start it from the install that owns its key, or delete that instance before creating a replacement.`, + ); + } const runtimes = await availableRuntimesForStart(availableRuntimesQuery); const { runtime, warnings } = resolveStartRuntimeForDefinition( persona, diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..f2c0ebce9e 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { useManagedAgentsQuery, + useManagedAgentReferencesQuery, usePersonasQuery, useRelayAgentsQuery, useTeamsQuery, @@ -34,6 +35,7 @@ import type { import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { buildManagedAgentPersonaLinks } from "@/features/agents/lib/managedAgentPersonaLinks"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { flushMentionDebounce } from "./flushMentionDebounce"; import { hasMention } from "./hasMention"; @@ -104,6 +106,7 @@ export function useMentions( const members = externalMembers ?? membersQuery.data; const isArchivedDiscovery = useIsArchivedPredicate(); const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentReferencesQuery = useManagedAgentReferencesQuery(); const relayAgentsQuery = useRelayAgentsQuery(); const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); @@ -139,26 +142,16 @@ export function useMentions( ), [managedAgentsQuery.data], ); - const managedAgentPersonaIdsByPubkey = React.useMemo( + const { + byPubkey: managedAgentPersonaIdsByPubkey, + personaIds: managedAgentPersonaIds, + } = React.useMemo( () => - new Map( - (managedAgentsQuery.data ?? []) - .filter((agent) => Boolean(agent.personaId)) - .map((agent) => [ - normalizePubkey(agent.pubkey), - agent.personaId as string, - ]), + buildManagedAgentPersonaLinks( + managedAgentsQuery.data ?? [], + managedAgentReferencesQuery.data ?? [], ), - [managedAgentsQuery.data], - ); - const managedAgentPersonaIds = React.useMemo( - () => - new Set( - (managedAgentsQuery.data ?? []) - .map((agent) => agent.personaId) - .filter((personaId): personaId is string => Boolean(personaId)), - ), - [managedAgentsQuery.data], + [managedAgentReferencesQuery.data, managedAgentsQuery.data], ); const managedAgentPubkeys = React.useMemo( () => diff --git a/desktop/src/shared/api/agentReferences.ts b/desktop/src/shared/api/agentReferences.ts new file mode 100644 index 0000000000..2efe039df0 --- /dev/null +++ b/desktop/src/shared/api/agentReferences.ts @@ -0,0 +1,28 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +/** Account-scoped agent identity learned from kind:30177 without local keys. */ +export type ManagedAgentReference = { + pubkey: string; + name: string; + personaId: string; +}; + +type RawManagedAgentReference = { + pubkey: string; + name: string; + persona_id: string; +}; + +export async function listManagedAgentReferences(): Promise< + ManagedAgentReference[] +> { + return ( + await invokeTauri( + "list_managed_agent_references", + ) + ).map((reference) => ({ + pubkey: reference.pubkey, + name: reference.name, + personaId: reference.persona_id, + })); +} diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index 66e07f5e88..76880017f9 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -324,3 +324,17 @@ export async function reconcileInboundPersonaEvent( arrivalRelayUrl, }); } + +/** + * Allow persona-backed agent creation only after this exact owner/community + * has durably reconciled its retained managed-agent identity history. + */ +export async function markManagedAgentReferenceSyncReady( + ownerPubkey: string, + arrivalRelayUrl: string, +): Promise { + await invokeTauri("mark_managed_agent_reference_sync_ready", { + ownerPubkey, + arrivalRelayUrl, + }); +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 689c400b03..c8c52bc8c8 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -708,9 +708,9 @@ export type RuntimeConfigSurface = { extensions: ExtensionEntry[]; sources: ConfigSourceReport; }; - export type UpdateManagedAgentInput = { pubkey: string; + personaId?: string | null; // absent: unchanged; null: detach; ID: relink name?: string; model?: string | null; provider?: string | null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4cfc553df1..69bcb5797d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10767,6 +10767,20 @@ export function maybeInstallE2eTauriMocks() { } case "list_managed_agents": return handleListManagedAgents(activeConfig); + case "list_managed_agent_references": + return mockManagedAgents.flatMap((agent) => + agent.persona_id + ? [ + { + pubkey: agent.pubkey, + name: agent.name, + persona_id: agent.persona_id, + }, + ] + : [], + ); + case "mark_managed_agent_reference_sync_ready": + return; case "get_agent_memory": return handleGetAgentMemory( (payload as Parameters[0]) ?? {},