diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 86c3bba318..a7ddffc825 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,8 +6,8 @@ use crate::{ 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, managed_agents_base_dir, normalize_agent_args, - provider_deploy, resolve_provider_binary, save_managed_agents, start_managed_agent_process, + 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, @@ -53,14 +53,15 @@ pub(super) fn retain_managed_agent_pending( use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; // The published content is the opt-IN projection JSON, independent of // signing and created_at. Compute it once to drive the no-republish // guard without signing twice. let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize managed-agent content: {e}"))?; let (owner_pubkey, event) = { - let keys = state.signing_keys()?; + let keys = &scope.owner_keys; let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -74,7 +75,7 @@ pub(super) fn retain_managed_agent_pending( // Monotonic created_at: bump past the retained head (NIP-AP step 3). let event = build_agent_event(record)? .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(&keys) + .sign_with_keys(keys) .map_err(|e| format!("failed to sign managed-agent event: {e}"))?; (owner_pubkey, event) }; @@ -125,15 +126,12 @@ pub(super) fn tombstone_managed_agent_pending( const KIND_DELETE: u32 = 5; let result = (|| -> Result<(), String> { - let (owner_pubkey, event) = { - let keys = state.signing_keys()?; - let owner_pubkey = keys.public_key().to_hex(); - let event = build_agent_delete(agent_pubkey, &owner_pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; - (owner_pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_delete(agent_pubkey, &owner_pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign managed-agent tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; delete_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, agent_pubkey)?; retain_event( &conn, @@ -219,13 +217,10 @@ pub(super) fn archive_managed_agent_pending(app: &AppHandle, state: &AppState, a use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let (owner_pubkey, event) = { - let keys = state.signing_keys()?; - let owner_pubkey = keys.public_key().to_hex(); - let event = build_agent_archive_request(&keys, agent_pubkey)?; - (owner_pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_agent_archive_request(&scope.owner_keys, agent_pubkey)?; + let conn = open_retention_db(&scope.db_path)?; retain_event( &conn, &RetainedEvent { diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index c3c6e69061..665f7fae56 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -45,7 +45,9 @@ pub async fn list_personas(app: AppHandle) -> Result, Strin .managed_agents_store_lock .lock() .map_err(|error| error.to_string())?; - load_personas(&app) + let mut personas = load_personas(&app)?; + pending::project_active_persona_sharing(&app, &state, &mut personas)?; + Ok(personas) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -72,6 +74,7 @@ pub async fn create_persona( .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; + pending::project_active_persona_sharing(&app, &state, &mut personas)?; let name_pool: Vec = input .name_pool .into_iter() @@ -173,6 +176,7 @@ pub async fn update_persona( .lock() .map_err(|error| error.to_string())?; let mut personas = load_personas(&app)?; + pending::project_active_persona_sharing(&app, &state, &mut personas)?; let persona = personas .iter_mut() .find(|record| record.id == input.id) @@ -554,7 +558,7 @@ fn reconcile_inbound_persona_event_blocking( ) -> Result<(), String> { use crate::managed_agents::{ agent_events::managed_agent_content_from_event, - load_managed_agents, load_teams, managed_agents_base_dir, + load_managed_agents, load_teams, persona_events::persona_from_event, retention::{open_retention_db, retain_inbound_event, InboundOutcome, RetainedEvent}, save_managed_agents, save_teams, @@ -602,7 +606,8 @@ fn reconcile_inbound_persona_event_blocking( .map_err(|error| error.to_string())?; // Resolve inbound vs. any pending local edit before touching the store. - let conn = open_retention_db(&managed_agents_base_dir(&app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(&app, &state)?; + let conn = open_retention_db(&scope.db_path)?; let outcome = retain_inbound_event( &conn, &RetainedEvent { @@ -706,7 +711,7 @@ fn reconcile_inbound_tombstone( state: &AppState, ) -> Result<(), String> { use crate::managed_agents::{ - load_managed_agents, load_teams, managed_agents_base_dir, + load_managed_agents, load_teams, retention::{ open_retention_db, retain_inbound_event, tombstone_retention_d_tag, InboundOutcome, RetainedEvent, @@ -731,7 +736,8 @@ fn reconcile_inbound_tombstone( // Resolve against the retained tombstone row (keyed by the target // coordinate, F2c) so a re-received tombstone or one older than a pending // local edit is a no-op. - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; let outcome = retain_inbound_event( &conn, &RetainedEvent { diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index b06983af2a..40779c5d68 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -5,7 +5,17 @@ use tauri::AppHandle; use crate::app_state::AppState; -use crate::managed_agents::AgentDefinition; +use crate::managed_agents::{ + retention::{RetainedEvent, RetentionScope}, + AgentDefinition, +}; + +pub(super) struct PreparedPersonaPublication { + pub scope: RetentionScope, + pub event: nostr::Event, + pub retained: RetainedEvent, + pub persona: AgentDefinition, +} /// Retain a freshly authored persona event in the local store, flagged for /// relay sync. Called inside a command's `managed_agents_store_lock`-held body @@ -16,6 +26,8 @@ use crate::managed_agents::AgentDefinition; /// newer-or-equal guard. `pending_sync = 1` enqueues it for the flush loop, /// which is the sole publisher. Best-effort: a failure here is logged and /// swallowed so a retention hiccup never blocks the disk-authoritative write. +/// The explicit catalog toggle uses [`prepare_persona_publication`] directly +/// so its durable enqueue failure reaches the UI. /// /// Unlike `retain_managed_agent_pending`, this has no projection-equality /// short-circuit: personas have no start/stop runtime churn, so a republish @@ -29,46 +41,110 @@ pub(in crate::commands) fn retain_persona_pending( state: &AppState, persona: &AgentDefinition, ) { + if let Err(e) = prepare_persona_publication(app, state, persona, None) { + eprintln!("buzz-desktop: persona-retain: {e}"); + } +} + +/// Build, sign, and durably retain a persona event in the active relay+owner +/// scope. +/// +/// Ordinary definition writes pass `None` and preserve the scoped head's +/// exact share tag. The explicit share toggle passes `Some(shared)`. Returning +/// the retained event lets that command immediately await relay acceptance +/// without rebuilding or re-signing a different NIP-33 head. +pub(super) fn prepare_persona_publication( + app: &AppHandle, + state: &AppState, + persona: &AgentDefinition, + shared_override: Option, +) -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let (event, retained, persona) = prepare_persona_publication_at( + &scope.db_path, + &scope.owner_keys, + persona, + shared_override, + )?; + Ok(PreparedPersonaPublication { + scope, + event, + retained, + persona, + }) +} + +fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool { + use buzz_core_pkg::kind::persona_event_is_shared; + use nostr::JsonUtil; + + row.and_then(|retained| nostr::Event::from_json(&retained.raw_event).ok()) + .is_some_and(|event| persona_event_is_shared(&event)) +} + +pub(super) fn project_active_persona_sharing( + app: &AppHandle, + state: &AppState, + personas: &mut [AgentDefinition], +) -> Result<(), String> { + use crate::managed_agents::{ + persona_events::persona_d_tag, + retention::{get_retained_event, open_retention_db}, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let owner_pubkey = scope.owner_keys.public_key().to_hex(); + let conn = open_retention_db(&scope.db_path)?; + for persona in personas { + if persona.is_builtin { + persona.shared = false; + continue; + } + let retained = + get_retained_event(&conn, KIND_PERSONA, &owner_pubkey, &persona_d_tag(persona))?; + persona.shared = retained_persona_is_shared(retained.as_ref()); + } + Ok(()) +} + +pub(super) fn prepare_persona_publication_at( + db_path: &std::path::Path, + keys: &nostr::Keys, + persona: &AgentDefinition, + shared_override: Option, +) -> Result<(nostr::Event, RetainedEvent, AgentDefinition), String> { use crate::managed_agents::{ - managed_agents_base_dir, persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; use buzz_core_pkg::kind::KIND_PERSONA; use nostr::JsonUtil; - let result = (|| -> Result<(), String> { - let d_tag = persona_d_tag(persona); - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - // Monotonic created_at: read the retained head for this coordinate - // and bump past it (NIP-AP step 3) so a same-second edit supersedes. - let prior = - get_retained_event(&conn, KIND_PERSONA, &keys.public_key().to_hex(), &d_tag)? - .map(|row| row.created_at); - let event = build_persona_event(persona)? - .custom_created_at(monotonic_created_at(prior)) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona event: {e}"))?; - (keys.public_key().to_hex(), event) - }; - retain_event( - &conn, - &RetainedEvent { - kind: KIND_PERSONA, - pubkey, - d_tag, - content: event.content.to_string(), - created_at: event.created_at.as_secs() as i64, - raw_event: event.as_json(), - pending_sync: true, - }, - ) - })(); - if let Err(e) = result { - eprintln!("buzz-desktop: persona-retain: {e}"); - } + let d_tag = persona_d_tag(persona); + let pubkey = keys.public_key().to_hex(); + let conn = open_retention_db(db_path)?; + let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?; + let mut scoped_persona = persona.clone(); + scoped_persona.shared = + shared_override.unwrap_or_else(|| retained_persona_is_shared(existing.as_ref())); + let event = build_persona_event(&scoped_persona)? + .custom_created_at(monotonic_created_at( + existing.as_ref().map(|row| row.created_at), + )) + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign persona event: {e}"))?; + let retained = RetainedEvent { + kind: KIND_PERSONA, + pubkey, + d_tag, + content: event.content.to_string(), + created_at: event.created_at.as_secs() as i64, + raw_event: event.as_json(), + pending_sync: true, + }; + retain_event(&conn, &retained)?; + Ok((event, retained, scoped_persona)) } /// Purge a deleted persona's pending row and enqueue a NIP-09 tombstone, both @@ -89,7 +165,6 @@ pub(in crate::commands) fn tombstone_persona_pending( d_tag: &str, ) { use crate::managed_agents::{ - managed_agents_base_dir, persona_events::build_persona_delete, retention::{ delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, @@ -102,15 +177,12 @@ pub(in crate::commands) fn tombstone_persona_pending( const KIND_DELETE: u32 = 5; let result = (|| -> Result<(), String> { - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - let event = build_persona_delete(d_tag, &pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; - (pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_persona_delete(d_tag, &pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign persona tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; // Purge the persona row first so an unpublished edit can never resurrect // it after the tombstone publishes. delete_retained_event(&conn, KIND_PERSONA, &pubkey, d_tag)?; @@ -133,3 +205,86 @@ pub(in crate::commands) fn tombstone_persona_pending( eprintln!("buzz-desktop: persona-tombstone: {e}"); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::retention::{ + get_retained_event, open_retention_db, scoped_retention_db_path, + }; + use buzz_core_pkg::kind::KIND_PERSONA; + use std::collections::BTreeMap; + + fn persona() -> AgentDefinition { + AgentDefinition { + id: "catalog-reviewer".to_string(), + display_name: "Catalog Reviewer".to_string(), + avatar_url: None, + system_prompt: "Review the catalog.".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-27T00:00:00Z".to_string(), + updated_at: "2026-07-27T00:00:00Z".to_string(), + } + } + + #[test] + fn share_state_and_pending_heads_are_scoped_by_relay_and_owner() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let community_a = scoped_retention_db_path(dir.path(), "wss://a.example", &owner); + let community_b = scoped_retention_db_path(dir.path(), "wss://b.example", &owner); + std::fs::create_dir_all(community_a.parent().unwrap()).unwrap(); + + let (_, _, shared_in_a) = + prepare_persona_publication_at(&community_a, &keys, &persona(), Some(true)).unwrap(); + assert!(shared_in_a.shared); + + let (_, _, unshared_in_b) = + prepare_persona_publication_at(&community_b, &keys, &persona(), None).unwrap(); + assert!(!unshared_in_b.shared); + + let mut edited = persona(); + edited.system_prompt = "Review the latest catalog.".to_string(); + let (_, _, edited_in_a) = + prepare_persona_publication_at(&community_a, &keys, &edited, None).unwrap(); + assert!( + edited_in_a.shared, + "ordinary edits preserve only the active scope's share choice" + ); + + let conn_a = open_retention_db(&community_a).unwrap(); + let conn_b = open_retention_db(&community_b).unwrap(); + assert!(retained_persona_is_shared( + get_retained_event(&conn_a, KIND_PERSONA, &owner, "catalog-reviewer") + .unwrap() + .as_ref() + )); + assert!(!retained_persona_is_shared( + get_retained_event(&conn_b, KIND_PERSONA, &owner, "catalog-reviewer") + .unwrap() + .as_ref() + )); + } + + #[test] + fn explicit_share_enqueue_failure_is_returned() { + let dir = tempfile::tempdir().unwrap(); + let keys = nostr::Keys::generate(); + let error = prepare_persona_publication_at(dir.path(), &keys, &persona(), Some(true)) + .expect_err("a directory cannot be opened as the retention database"); + assert!(error.contains("failed to open retention db")); + } +} diff --git a/desktop/src-tauri/src/commands/personas/sharing.rs b/desktop/src-tauri/src/commands/personas/sharing.rs index b2efb3ff97..f336c3486b 100644 --- a/desktop/src-tauri/src/commands/personas/sharing.rs +++ b/desktop/src-tauri/src/commands/personas/sharing.rs @@ -2,45 +2,276 @@ use tauri::{AppHandle, Manager}; use crate::{ app_state::AppState, - managed_agents::{load_personas, save_personas, AgentDefinition}, - util::now_iso, + managed_agents::{ + load_personas, + retention::{mark_synced, open_retention_db}, + AgentDefinition, + }, }; -use super::retain_persona_pending; +use super::pending::{prepare_persona_publication, PreparedPersonaPublication}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PersonaSharePublicationStatus { + Published, + Queued, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetPersonaSharedResult { + pub persona: AgentDefinition, + pub publication_status: PersonaSharePublicationStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub relay_message: Option, +} #[tauri::command] pub async fn set_persona_shared( id: String, shared: bool, app: AppHandle, -) -> Result { - 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 mut personas = load_personas(&app)?; - let persona = personas - .iter_mut() - .find(|record| record.id == id) - .ok_or_else(|| format!("agent {id} not found"))?; +) -> Result { + let prepared = tokio::task::spawn_blocking({ + let app = app.clone(); + move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let personas = load_personas(&app)?; + let persona = personas + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("agent {id} not found"))?; - if persona.is_builtin { - return Err("Built-in agents cannot be shared to the catalog.".to_string()); + if persona.is_builtin { + return Err("Built-in agents cannot be shared to the catalog.".to_string()); + } + + // Strict path: unlike ordinary definition saves, an enqueue failure + // for this privacy-sensitive toggle must reach the command/UI. + prepare_persona_publication(&app, &state, persona, Some(shared)) } - if persona.shared == shared { - return Ok(persona.clone()); - } - - persona.shared = shared; - persona.updated_at = now_iso(); - - let updated = persona.clone(); - save_personas(&app, &personas)?; - retain_persona_pending(&app, &state, &updated); - Ok(updated) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))? + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + let state = app.state::(); + publish_prepared_persona(&state, prepared).await +} + +async fn publish_prepared_persona( + state: &AppState, + prepared: PreparedPersonaPublication, +) -> Result { + let api_base_url = crate::relay::relay_http_base_url(&prepared.scope.relay_url); + let publish_result = crate::relay::submit_signed_event_at_with_keys( + &prepared.event, + state, + &api_base_url, + &prepared.scope.owner_keys, + ) + .await; + + match publish_result { + Ok(_) => { + let conn = open_retention_db(&prepared.scope.db_path)?; + mark_synced( + &conn, + prepared.retained.kind, + &prepared.retained.pubkey, + &prepared.retained.d_tag, + prepared.retained.created_at, + &prepared.retained.content, + )?; + Ok(SetPersonaSharedResult { + persona: prepared.persona, + publication_status: PersonaSharePublicationStatus::Published, + relay_message: None, + }) + } + Err(error) => Ok(SetPersonaSharedResult { + persona: prepared.persona, + publication_status: PersonaSharePublicationStatus::Queued, + relay_message: Some(error), + }), + } +} + +#[cfg(all(test, not(target_os = "windows")))] +mod tests { + use super::*; + use crate::{ + app_state::build_app_state, + commands::personas::pending::prepare_persona_publication_at, + managed_agents::{ + retention::{get_retained_event, open_retention_db, RetentionScope}, + AgentDefinition, + }, + }; + use std::collections::BTreeMap; + + fn persona() -> AgentDefinition { + AgentDefinition { + id: "catalog-reviewer".to_string(), + display_name: "Catalog Reviewer".to_string(), + avatar_url: None, + system_prompt: "Review the catalog.".to_string(), + runtime: None, + model: None, + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "2026-07-27T00:00:00Z".to_string(), + updated_at: "2026-07-27T00:00:00Z".to_string(), + } + } + + async fn spawn_relay(accepted: bool) -> String { + use axum::{routing::post, Router}; + + let app = Router::new().route( + "/events", + post(move |body: String| async move { + let event: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + serde_json::json!({ + "event_id": event.get("id").and_then(serde_json::Value::as_str).unwrap_or(""), + "accepted": accepted, + "message": if accepted { "" } else { "policy rejection" } + }) + .to_string() + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + format!("http://{addr}") + } + + fn prepared( + db_path: &std::path::Path, + relay_url: String, + keys: nostr::Keys, + ) -> PreparedPersonaPublication { + let (event, retained, persona) = + prepare_persona_publication_at(db_path, &keys, &persona(), Some(true)).unwrap(); + PreparedPersonaPublication { + scope: RetentionScope { + db_path: db_path.to_path_buf(), + relay_url, + owner_keys: keys, + }, + event, + retained, + persona, + } + } + + #[tokio::test] + async fn relay_rejection_stays_durably_queued() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, spawn_relay(false).await, keys); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Queued + ); + assert!(result + .relay_message + .as_deref() + .is_some_and(|message| message.contains("relay rejected event"))); + assert!( + get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + #[tokio::test] + async fn unavailable_relay_stays_durably_queued() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let relay_url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, relay_url, keys); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Queued + ); + assert!(result + .relay_message + .as_deref() + .is_some_and(|message| message.starts_with("relay unreachable:"))); + assert!( + get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } + + #[tokio::test] + async fn relay_acceptance_marks_the_scoped_head_synced() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("retention.db"); + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let prepared = prepared(&db_path, spawn_relay(true).await, keys); + let state = build_app_state(); + + let result = publish_prepared_persona(&state, prepared).await.unwrap(); + + assert_eq!( + result.publication_status, + PersonaSharePublicationStatus::Published + ); + assert!( + !get_retained_event( + &open_retention_db(&db_path).unwrap(), + buzz_core_pkg::kind::KIND_PERSONA, + &owner, + "catalog-reviewer" + ) + .unwrap() + .unwrap() + .pending_sync + ); + } } diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index eee8a2443b..8c6b95b3b7 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -603,7 +603,6 @@ pub async fn confirm_agent_snapshot_import( fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { use crate::managed_agents::{ agent_events::{agent_event_content, build_agent_event}, - managed_agents_base_dir, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; @@ -611,11 +610,12 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize agent content: {e}"))?; let (owner_pubkey, event) = { - let keys = state.signing_keys()?; + let keys = &scope.owner_keys; let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -624,7 +624,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent } let event = build_agent_event(record)? .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(&keys) + .sign_with_keys(keys) .map_err(|e| format!("failed to sign agent event: {e}"))?; (owner_pubkey, event) }; diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 688f51c257..d4636f3efc 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -848,7 +848,6 @@ pub async fn confirm_team_snapshot_import( fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgentRecord) { use crate::managed_agents::{ agent_events::{agent_event_content, build_agent_event}, - managed_agents_base_dir, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, }; @@ -856,11 +855,12 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; let content = serde_json::to_string(&agent_event_content(record)) .map_err(|e| format!("failed to serialize agent content: {e}"))?; let (owner_pubkey, event) = { - let keys = state.signing_keys()?; + let keys = &scope.owner_keys; let owner_pubkey = keys.public_key().to_hex(); let existing = get_retained_event(&conn, KIND_MANAGED_AGENT, &owner_pubkey, &record.pubkey)?; @@ -869,7 +869,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent } let event = build_agent_event(record)? .custom_created_at(monotonic_created_at(existing.map(|row| row.created_at))) - .sign_with_keys(&keys) + .sign_with_keys(keys) .map_err(|e| format!("failed to sign agent event: {e}"))?; (owner_pubkey, event) }; diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index ea9a6a4958..4377ddaa43 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -39,7 +39,6 @@ fn trim_optional(value: Option) -> Option { /// happens on an actual user edit. The guard is intentionally omitted. pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &TeamRecord) { use crate::managed_agents::{ - managed_agents_base_dir, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, team_events::build_team_event, @@ -48,19 +47,16 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team use nostr::JsonUtil; let result = (|| -> Result<(), String> { - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - // Monotonic created_at: bump past the retained head (NIP-AP step 3). - let prior = - get_retained_event(&conn, KIND_TEAM, &pubkey, &team.id)?.map(|row| row.created_at); - let event = build_team_event(team)? - .custom_created_at(monotonic_created_at(prior)) - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign team event: {e}"))?; - (pubkey, event) - }; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let conn = open_retention_db(&scope.db_path)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + // Monotonic created_at: bump past the retained head (NIP-AP step 3). + let prior = + get_retained_event(&conn, KIND_TEAM, &pubkey, &team.id)?.map(|row| row.created_at); + let event = build_team_event(team)? + .custom_created_at(monotonic_created_at(prior)) + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign team event: {e}"))?; retain_event( &conn, &RetainedEvent { @@ -90,7 +86,6 @@ pub(super) fn retain_team_pending(app: &AppHandle, state: &AppState, team: &Team /// disk-authoritative delete. fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { use crate::managed_agents::{ - managed_agents_base_dir, retention::{ delete_retained_event, open_retention_db, retain_event, tombstone_retention_d_tag, RetainedEvent, @@ -103,15 +98,12 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) { const KIND_DELETE: u32 = 5; let result = (|| -> Result<(), String> { - let (pubkey, event) = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - let pubkey = keys.public_key().to_hex(); - let event = build_team_delete(d_tag, &pubkey)? - .sign_with_keys(&keys) - .map_err(|e| format!("failed to sign team tombstone: {e}"))?; - (pubkey, event) - }; - let conn = open_retention_db(&managed_agents_base_dir(app)?.join("retention.db"))?; + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + let pubkey = scope.owner_keys.public_key().to_hex(); + let event = build_team_delete(d_tag, &pubkey)? + .sign_with_keys(&scope.owner_keys) + .map_err(|e| format!("failed to sign team tombstone: {e}"))?; + let conn = open_retention_db(&scope.db_path)?; delete_retained_event(&conn, KIND_TEAM, &pubkey, d_tag)?; retain_event( &conn, diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 561e901998..53a552b510 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -187,6 +187,20 @@ pub async fn apply_workspace( .map_err(|e| format!("spawn_blocking failed: {e}"))??; let state = restore_app.state::(); + // Backfill this exact relay+owner scope only after the workspace has been + // applied. Running at process boot would target the fallback relay and + // collapse every community into one pending-event store. + match crate::managed_agents::retention::active_retention_scope(&restore_app, &state) { + Ok(scope) => crate::event_sync::spawn_event_sync( + restore_app.clone(), + scope.owner_keys, + scope.db_path, + ), + Err(error) => { + eprintln!("buzz-desktop: scoped event-sync unavailable after workspace apply: {error}"); + } + } + let restore_pending = state .managed_agent_restore_pending .swap(false, Ordering::AcqRel); diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index 2ec5aa1c0e..d9fe6acdb9 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -13,10 +13,10 @@ use std::path::Path; /// `sync_team_personas` wrote in [`crate::migration::run_boot_migrations`] /// (see its `# Ordering` guard). Event signing needs the resolved owner keys, /// so this runs after identity resolution, not in the boot migrations. -pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { - migrate_personas_to_events(app, owner_keys); - migrate_teams_to_events(app, owner_keys); - crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys); +pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys, db_path: &Path) { + migrate_personas_to_events(app, owner_keys, db_path); + migrate_teams_to_events(app, owner_keys, db_path); + crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys, db_path); } /// Spawn the best-effort event reconcile off the synchronous Tauri setup path. @@ -25,10 +25,14 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) { /// `AppState::keys` mutex. The reconcile itself is still synchronous JSON, /// SQLite, and signing work, so it runs on the blocking pool rather than an /// async worker. -pub fn spawn_event_sync(app: tauri::AppHandle, owner_keys: nostr::Keys) { +pub fn spawn_event_sync( + app: tauri::AppHandle, + owner_keys: nostr::Keys, + db_path: std::path::PathBuf, +) { tauri::async_runtime::spawn(async move { if let Err(e) = tauri::async_runtime::spawn_blocking(move || { - run_event_sync(&app, &owner_keys); + run_event_sync(&app, &owner_keys, &db_path); }) .await { @@ -57,14 +61,14 @@ pub fn spawn_event_sync(app: tauri::AppHandle, owner_keys: nostr::Keys) { /// `pending_sync = 1` for later relay publish. Migration succeeds on local /// write, not relay acknowledgment. Every retained row is a real signed /// event — there is no placeholder path. -pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { +pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys, db_path: &Path) { use crate::managed_agents::managed_agents_base_dir; let Ok(base_dir) = managed_agents_base_dir(app) else { return; }; - match migrate_personas_in_dir(&base_dir, keys) { + match migrate_personas_in_dir_at(&base_dir, keys, db_path) { Ok(0) => {} Ok(migrated) => { eprintln!( @@ -82,7 +86,16 @@ pub fn migrate_personas_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { /// Returns the number of personas (re)written to the retention store. Returns /// `Ok(0)` when every non-builtin persona already has a matching retained row /// (or there are none to reconcile). +#[cfg(test)] fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + migrate_personas_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) +} + +fn migrate_personas_in_dir_at( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { use crate::managed_agents::{ persona_events::{build_persona_event, monotonic_created_at, persona_d_tag}, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, @@ -127,9 +140,8 @@ fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result Result Result {} Ok(migrated) => { eprintln!("buzz-desktop: team-event-migration: {migrated} teams migrated to retention"); @@ -225,7 +242,16 @@ pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { /// Returns the number of teams (re)written to the retention store. The /// per-coordinate content compare matches [`migrate_personas_in_dir`]: an /// unchanged team is skipped so a launch does not churn `pending_sync`. +#[cfg(test)] fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + migrate_teams_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) +} + +fn migrate_teams_in_dir_at( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { use crate::managed_agents::{ persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, @@ -252,9 +278,8 @@ fn migrate_teams_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result k.clone(), - Err(e) => { - eprintln!("buzz-desktop: fatal: owner keys lock poisoned: {e}"); - std::process::exit(1); - } - }; - // Backfill the pinned persona snapshot for any pre-existing agent // that predates the record-authoritative-spawn cutover (persona_id // set but no source_version). Must run before @@ -547,15 +537,6 @@ pub fn run() { try_regenerate_nest(&app_handle); - // Sync team-dir edits and reconcile persona/team/agent events after - // setup can continue. It is best-effort retention backfill, unlike - // identity resolution above, so JSON/SQLite/signing work must not - // hold the boot path hostage. Skipped in recovery mode — the owner - // key is ephemeral. - if !recovery_mode { - event_sync::spawn_event_sync(app_handle.clone(), owner_keys); - } - if let Some(mgr) = huddle::models::global_model_manager() { mgr.start_stt_download(state.http_client.clone()); mgr.start_tts_download(state.http_client.clone()); @@ -638,17 +619,13 @@ pub fn run() { tauri::async_runtime::spawn(async move { use std::time::Duration; use tauri::Manager; - let Ok(db_path) = managed_agents::managed_agents_base_dir(&flush_handle) - .map(|d| d.join("retention.db")) - else { - eprintln!("buzz-desktop: event-flush: cannot resolve retention db path"); - return; - }; loop { let state = flush_handle.state::(); - if let Err(e) = - managed_agents::persona_events::flush_pending_events(&db_path, &state) - .await + if let Err(e) = managed_agents::persona_events::flush_active_pending_events( + &flush_handle, + &state, + ) + .await { eprintln!("buzz-desktop: event-flush: {e}"); } diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index fb798b24bd..55d7549e9d 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -223,9 +223,34 @@ pub fn persona_from_event(event: &nostr::Event) -> Result Result { + let relay_url = crate::relay::relay_ws_url_with_override(state); + let owner_keys = state.signing_keys()?; + flush_pending_events_at(db_path, state, &relay_url, &owner_keys).await +} + +/// Resolve and flush only the currently active `(relay, owner)` scope. +/// +/// The scope snapshots its relay, owner keys, and database path together +/// before network work starts. Switching communities during the flush cannot +/// redirect rows from the old scope into the new relay. +pub async fn flush_active_pending_events( + app: &tauri::AppHandle, + state: &AppState, +) -> Result { + let scope = crate::managed_agents::retention::active_retention_scope(app, state)?; + flush_pending_events_at(&scope.db_path, state, &scope.relay_url, &scope.owner_keys).await +} + +async fn flush_pending_events_at( + db_path: &std::path::Path, + state: &AppState, + relay_url: &str, + owner_keys: &nostr::Keys, ) -> Result { use crate::managed_agents::retention::{ deferred_behind_failed_tombstone, get_pending_sync, get_retained_event, mark_synced, @@ -233,6 +258,8 @@ pub async fn flush_pending_events( }; use nostr::JsonUtil; + let owner_pubkey = owner_keys.public_key().to_hex(); + let relay_api_base = crate::relay::relay_http_base_url(relay_url); let pending = { let conn = open_retention_db(db_path)?; get_pending_sync(&conn)? @@ -242,6 +269,9 @@ pub async fn flush_pending_events( let mut failed_tombstones: std::collections::HashSet<(String, String)> = std::collections::HashSet::new(); for row in pending { + if row.pubkey != owner_pubkey { + continue; + } if deferred_behind_failed_tombstone(row.kind, &row.pubkey, &row.d_tag, &failed_tombstones) { continue; // its tombstone failed this sweep; next sweep re-orders them } @@ -275,9 +305,14 @@ pub async fn flush_pending_events( event }; - if crate::relay::submit_signed_event(&event, state) - .await - .is_err() + if crate::relay::submit_signed_event_at_with_keys( + &event, + state, + &relay_api_base, + owner_keys, + ) + .await + .is_err() { if current.kind == 5 { failed_tombstones.insert((current.pubkey.clone(), current.d_tag.clone())); 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 39354e3b93..dca3dc7382 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -905,6 +905,7 @@ mod flush_barrier { } let state = build_app_state(); + *state.keys.lock().unwrap() = keys; *state.relay_url_override.lock().unwrap() = Some(spawn_stub_relay().await); let flushed = flush_pending_events(&db_path, &state).await.expect("flush"); diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index dc73bc9739..66c68321fe 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -32,12 +32,16 @@ use nostr::JsonUtil; /// Reconcile `managed-agents.json` into kind:30177 events in the retention /// store. Boot-time entry point, called from `event_sync::run_event_sync` /// after the persona and team legs. -pub(crate) fn reconcile_agents_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) { +pub(crate) fn reconcile_agents_to_events( + app: &tauri::AppHandle, + keys: &nostr::Keys, + db_path: &Path, +) { let Ok(base_dir) = super::managed_agents_base_dir(app) else { return; }; - match reconcile_agents_in_dir(&base_dir, keys) { + match reconcile_agents_in_dir_at(&base_dir, keys, db_path) { Ok(0) => {} Ok(reconciled) => { eprintln!( @@ -61,7 +65,16 @@ pub(crate) fn reconcile_agents_to_events(app: &tauri::AppHandle, keys: &nostr::K /// never churns `pending_sync`. /// /// Returns the number of agents (re)written to the retention store. +#[cfg(test)] pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { + reconcile_agents_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) +} + +fn reconcile_agents_in_dir_at( + base_dir: &Path, + keys: &nostr::Keys, + db_path: &Path, +) -> Result { let store_path = base_dir.join("managed-agents.json"); if !store_path.exists() { return Ok(0); @@ -81,9 +94,8 @@ pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Re let owner_pubkey = keys.public_key().to_hex(); - let db_path = base_dir.join("retention.db"); let conn = - open_retention_db(&db_path).map_err(|e| format!("failed to open retention db: {e}"))?; + open_retention_db(db_path).map_err(|e| format!("failed to open retention db: {e}"))?; let mut reconciled = 0u32; diff --git a/desktop/src-tauri/src/managed_agents/retention.rs b/desktop/src-tauri/src/managed_agents/retention.rs index 5df566dbbe..5577101ea0 100644 --- a/desktop/src-tauri/src/managed_agents/retention.rs +++ b/desktop/src-tauri/src/managed_agents/retention.rs @@ -5,10 +5,62 @@ //! keyed on `(kind, pubkey, d_tag)`, replacing only on a newer-or-equal //! `created_at` for NIP-33 latest-wins semantics. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use rusqlite::{params, Connection, OptionalExtension}; +use sha2::{Digest, Sha256}; +use tauri::AppHandle; + +use crate::app_state::AppState; + +/// Durable event-retention scope for one community relay and owner identity. +/// +/// Persona, team, and managed-agent definitions are workspace-global, but +/// their relay heads and pending publications are not. Keeping a separate +/// database per `(relay_url, owner_pubkey)` prevents a pending write created in +/// community A from being drained into community B after a workspace switch. +pub struct RetentionScope { + pub db_path: PathBuf, + pub relay_url: String, + pub owner_keys: nostr::Keys, +} + +/// Resolve the retention database path for a relay + owner pair. +/// +/// The normalized scope is hashed so relay URLs never become path components. +/// Trimming a trailing slash keeps equivalent workspace URLs on one scope. +pub fn scoped_retention_db_path(base_dir: &Path, relay_url: &str, owner_pubkey: &str) -> PathBuf { + let normalized_relay = relay_url.trim().trim_end_matches('/'); + let mut hasher = Sha256::new(); + hasher.update(owner_pubkey.trim().to_ascii_lowercase().as_bytes()); + hasher.update(b"\0"); + hasher.update(normalized_relay.as_bytes()); + let scope_id = hex::encode(hasher.finalize()); + base_dir.join("retention").join(format!("{scope_id}.db")) +} + +/// Snapshot the active relay + owner and resolve their durable event store. +/// +/// Callers keep the returned relay and keys alongside the path whenever work +/// crosses an `.await`; a later workspace switch cannot retarget that work. +pub fn active_retention_scope(app: &AppHandle, state: &AppState) -> Result { + let relay_url = crate::relay::relay_ws_url_with_override(state); + let owner_keys = state.signing_keys()?; + let base_dir = super::managed_agents_base_dir(app)?; + let db_path = + scoped_retention_db_path(&base_dir, &relay_url, &owner_keys.public_key().to_hex()); + let parent = db_path + .parent() + .ok_or_else(|| "retention scope path has no parent".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create retention scope directory: {error}"))?; + Ok(RetentionScope { + db_path, + relay_url, + owner_keys, + }) +} /// A retained persona event row. #[derive(Debug, Clone)] @@ -368,6 +420,26 @@ pub fn get_retained_event( mod tests { use super::*; + #[test] + fn retention_scope_is_stable_and_separates_relay_and_owner() { + let base = Path::new("/tmp/buzz-retention-test"); + let owner_a = "a".repeat(64); + let owner_b = "b".repeat(64); + let community_a = scoped_retention_db_path(base, "wss://a.example/", &owner_a); + assert_eq!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://b.example", &owner_a) + ); + assert_ne!( + community_a, + scoped_retention_db_path(base, "wss://a.example", &owner_b) + ); + } + #[test] fn concurrent_open_waits_for_initialization_lock() { let dir = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index f85dc3177c..6415c6d83c 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -40,9 +40,11 @@ pub struct AgentDefinition { pub is_builtin: bool, #[serde(default = "default_record_active")] pub is_active: bool, - /// Whether this persona is discoverable by other members of the active - /// community. Persisted on the definition so every routine kind:30175 - /// republish preserves the relay's exact `["shared", "true"]` tag. + /// Whether this persona is discoverable in the currently active community. + /// + /// This is a command/view projection only. Durable share state lives in + /// the relay+owner-scoped retention head so one workspace's choice cannot + /// leak into another workspace's definition record. #[serde(default)] pub shared: bool, /// Team ID if this persona was imported from a team directory. @@ -135,7 +137,8 @@ impl AgentDefinition { name_pool: self.name_pool, is_builtin: self.is_builtin, is_active: self.is_active, - shared: self.shared, + // Catalog visibility is relay+owner scoped, not definition-global. + shared: false, source_team: self.source_team, source_team_persona_slug: self.source_team_persona_slug, definition_respond_to: self.respond_to, @@ -167,7 +170,8 @@ impl ManagedAgentRecord { name_pool: self.name_pool.clone(), is_builtin: self.is_builtin, is_active: self.is_active, - shared: self.shared, + // Projected by `list_personas` from the active retention scope. + shared: false, source_team: self.source_team.clone(), source_team_persona_slug: self.source_team_persona_slug.clone(), env_vars: self.env_vars.clone(), @@ -375,9 +379,12 @@ pub struct ManagedAgentRecord { /// definition hidden from pickers. Defaults `true` for existing records. #[serde(default = "default_record_active")] pub is_active: bool, - /// Definition-level catalog visibility. Only meaningful for key-less - /// records with a `slug`; instances retain the default `false`. - #[serde(default)] + /// Legacy process-global catalog visibility field. + /// + /// New writes omit it and definition views ignore it. It remains + /// deserializable for branch-era stores, but active visibility is projected + /// from the relay+owner-scoped retention database instead. + #[serde(default, skip_serializing)] pub shared: bool, /// Absorbed from `AgentDefinition.source_team` — team ID when this /// definition was imported from a team directory (team definitions are diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 1c9ba0095a..f896695624 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -532,49 +532,9 @@ pub struct AgentProfileInfo { // ── Signed-event submission ───────────────────────────────────────────────── mod submit; -pub use submit::{submit_event, submit_event_at_with_keys, SubmitEventResponse}; - -/// POST an already-signed event to `/events` with NIP-98 auth. -/// -/// The persona flush loop drains pre-signed events from the retention store, -/// so it must publish them verbatim — re-signing through `submit_event` would -/// mint a new `created_at`/signature and break the compare-and-clear that -/// `mark_synced` relies on. Only the NIP-98 request auth is signed here (with -/// the owner keys), and that lock is dropped before the `.await`. -pub async fn submit_signed_event( - event: &nostr::Event, - state: &AppState, -) -> Result { - crate::relay_admission::wait_for_rate_limit().await; - let url = format!("{}/events", relay_api_base_url_with_override(state)); - let body_bytes = event.as_json().into_bytes(); - let auth_header = { - let keys = state.signing_keys()?; - build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body_bytes)? - }; // keys dropped here - - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; - - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - let result: SubmitEventResponse = parse_json_response(response).await?; - - if !result.accepted { - return Err(format!("relay rejected event: {}", result.message)); - } - - Ok(result) -} +pub use submit::{ + submit_event, submit_event_at_with_keys, submit_signed_event_at_with_keys, SubmitEventResponse, +}; /// Sign an event with explicit keys and POST it to `/events` with NIP-98 auth. /// diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index 7fb3f94041..2a42d86c2b 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -8,22 +8,22 @@ pub struct SubmitEventResponse { pub message: String, } -/// Sign with an explicit identity and POST the event to an explicit relay. +/// POST an already-signed event to an explicit relay with an explicit owner. /// -/// The caller owns the signer lifetime. This is important for deferred work: -/// an in-process identity swap cannot retarget the event or its NIP-98 auth -/// after the caller has validated which identity the operation belongs to. -pub async fn submit_event_at_with_keys( - builder: nostr::EventBuilder, +/// Deferred/scoped publication uses this form so a workspace or identity +/// switch cannot retarget either the event or its NIP-98 authentication after +/// the operation captured its `(relay, owner)` scope. +pub async fn submit_signed_event_at_with_keys( + event: &nostr::Event, state: &AppState, api_base_url: &str, keys: &nostr::Keys, ) -> Result { + if event.pubkey != keys.public_key() { + return Err("signed event does not match the publishing identity".to_string()); + } crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/events", api_base_url.trim_end_matches('/')); - let event = builder - .sign_with_keys(keys) - .map_err(|e| format!("failed to sign event: {e}"))?; let body_bytes = event.as_json().into_bytes(); let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; @@ -49,6 +49,23 @@ pub async fn submit_event_at_with_keys( Ok(result) } +/// Sign with an explicit identity and POST the event to an explicit relay. +/// +/// The caller owns the signer lifetime. This is important for deferred work: +/// an in-process identity swap cannot retarget the event or its NIP-98 auth +/// after the caller has validated which identity the operation belongs to. +pub async fn submit_event_at_with_keys( + builder: nostr::EventBuilder, + state: &AppState, + api_base_url: &str, + keys: &nostr::Keys, +) -> Result { + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("failed to sign event: {e}"))?; + submit_signed_event_at_with_keys(&event, state, api_base_url, keys).await +} + /// Build and submit an event to the currently active workspace relay. pub async fn submit_event( builder: nostr::EventBuilder, diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 2af9ddb98c..55f9b1adb9 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -102,6 +102,14 @@ with a TypeScript lookup table or an id comparison in a component. Edit. In Edit, selecting Custom command keeps its required command field beside the harness picker rather than hiding it in Advanced. +10. **Catalog visibility is community-scoped relay state, never a global + definition field.** `AgentDefinition.shared` is only the active + relay+owner projection returned to the UI. Durable heads and pending + publications live in the scoped retention database, and explicit share + toggles await relay acceptance before the UI claims that an agent was + published or removed. A queued update must stay visibly queued, and the + catalog itself must render only relay-confirmed publications — never an + optimistic local persona. ## The tests that enforce this @@ -120,6 +128,8 @@ with a TypeScript lookup table or an id comparison in a component. acceptance coverage for readiness, failure states, defaults, navigation, successful-empty vs failed optional-model discovery, and persistence races. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. +- Rust: persona sharing/retention tests pin relay+owner scoping, durable + enqueue errors, relay rejection/unavailability, and accepted publication. ## Keep this file true diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index b04f1a47a6..646eaccfdf 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -187,7 +187,7 @@ test("foreign allowlist behavior imports as owner-only", () => { assert.deepEqual(personas[0].respondToAllowlist, []); }); -test("a newly shared local persona appears before relay sync completes", () => { +test("a pending local share does not appear before relay confirmation", () => { const localPersona = { id: "local-reviewer", displayName: "Local Reviewer", @@ -210,7 +210,5 @@ test("a newly shared local persona appears before relay sync completes", () => { }; const personas = catalogPersonasFromPublications([], [localPersona], ALICE); - assert.equal(personas.length, 1); - assert.equal(personas[0].catalogSource.eventId, "local:local-reviewer"); - assert.equal(personas[0].catalogSource.isOwn, true); + assert.deepEqual(personas, []); }); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 8c0a998996..62cbd9488c 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -218,6 +218,9 @@ function publicationToPersona( return { ...basePersona, + // Catalog membership is relay-confirmed by the shared event itself. Do not + // let a local pending toggle override this projection. + shared: true, catalogSource: { eventId: publication.eventId, ownerPubkey: publication.ownerPubkey, @@ -234,7 +237,6 @@ export function catalogPersonasFromPublications( ): CatalogPersona[] { const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; const personas: CatalogPersona[] = []; - const seenCoordinates = new Set(); for (const publication of publications) { const isOwn = publication.ownerPubkey === normalizedCurrentPubkey; @@ -243,31 +245,9 @@ export function catalogPersonasFromPublications( (persona) => persona.id === publication.sourcePersonaId, ) : undefined; - if (ownLocalPersona && !ownLocalPersona.shared) { - continue; - } - const coordinate = `${publication.ownerPubkey}:${publication.sourcePersonaId}`; - seenCoordinates.add(coordinate); personas.push(publicationToPersona(publication, ownLocalPersona, isOwn)); } - if (normalizedCurrentPubkey) { - for (const persona of localPersonas) { - if (persona.isBuiltIn || !persona.shared) continue; - const coordinate = `${normalizedCurrentPubkey}:${persona.id}`; - if (seenCoordinates.has(coordinate)) continue; - personas.push({ - ...persona, - catalogSource: { - eventId: `local:${persona.id}`, - ownerPubkey: normalizedCurrentPubkey, - isOwn: true, - sourcePersonaId: persona.id, - }, - }); - } - } - return personas.sort((left, right) => left.displayName.localeCompare(right.displayName), ); diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts index 073bf3b432..fa1384d16d 100644 --- a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -71,13 +71,13 @@ export function useSetPersonaCatalogSharedMutation(communityId: string | null) { return useMutation({ mutationFn: ({ id, shared }: { id: string; shared: boolean }) => setPersonaShared(id, shared), - onSuccess: (updated) => { + onSuccess: (result) => { queryClient.setQueryData( ["personas"], (current) => current?.map((persona) => - persona.id === updated.id ? updated : persona, - ) ?? [updated], + persona.id === result.persona.id ? result.persona : persona, + ) ?? [result.persona], ); void queryClient.invalidateQueries({ queryKey: personaCatalogQueryKey(communityId), diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index ff65277df3..fb5a20084a 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -124,12 +124,13 @@ export function usePersonaActions() { const personas = personasQuery.data ?? []; const publications = catalogQuery.data ?? []; const sharedCatalogPersonaIdSet = React.useMemo(() => { + const currentPubkey = identityQuery.data?.pubkey.toLowerCase(); return new Set( - personas - .filter((persona) => !persona.isBuiltIn && persona.shared) - .map((persona) => persona.id), + publications + .filter((publication) => publication.ownerPubkey === currentPubkey) + .map((publication) => publication.sourcePersonaId), ); - }, [personas]); + }, [identityQuery.data?.pubkey, publications]); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -475,16 +476,31 @@ export function usePersonaActions() { clearFeedback("library"); try { const shared = shareLevel !== "not-shared"; - const updated = await setCatalogSharedMutation.mutateAsync({ + const result = await setCatalogSharedMutation.mutateAsync({ id: persona.id, shared, }); setPersonaToShare((current) => - current?.persona.id === updated.id - ? { ...current, persona: updated } + current?.persona.id === result.persona.id + ? { ...current, persona: result.persona } : current, ); - if (!shared) { + if (result.publicationStatus === "queued") { + if (shared) { + setPersonaNoticeMessage( + `Sharing ${persona.displayName} is queued. It will appear after the relay accepts the update.`, + ); + } else { + setPersonaNoticeMessage( + `Removing ${persona.displayName} is queued. It may remain discoverable until the relay accepts the update.`, + ); + } + if (result.relayMessage) { + console.warn( + `[setPersonaShared] relay publication queued: ${result.relayMessage}`, + ); + } + } else if (!shared) { setPersonaNoticeMessage( `${persona.displayName} is no longer discoverable in the community catalog.`, ); diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index b49efdbc75..9f953eecd5 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -121,12 +121,25 @@ export async function setPersonaActive( export async function setPersonaShared( id: string, shared: boolean, -): Promise { - return fromRawPersona( - await invokeTauri("set_persona_shared", { id, shared }), - ); +): Promise { + const result = await invokeTauri<{ + persona: RawPersona; + publicationStatus: "published" | "queued"; + relayMessage?: string; + }>("set_persona_shared", { id, shared }); + return { + persona: fromRawPersona(result.persona), + publicationStatus: result.publicationStatus, + relayMessage: result.relayMessage ?? null, + }; } +export type PersonaSharePublicationResult = { + persona: AgentPersona; + publicationStatus: "published" | "queued"; + relayMessage: string | null; +}; + export type SnapshotMemoryLevel = "none" | "core" | "everything"; export type SnapshotFormat = "json" | "png"; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6b46673335..c565ed77a4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -222,6 +222,8 @@ type E2eConfig = { personas?: MockPersonaSeed[]; /** Community catalog replaceable-event heads returned by relay queries. */ personaCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit persona share publications. */ + personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number; @@ -7149,6 +7151,9 @@ let mockGlobalAgentConfig: { // Per-page get_nsec call counter for sequenced error testing. let nsecCallCount = 0; +// Per-page explicit catalog publication outcomes. +let personaSharePublicationCallCount = 0; + // Per-page confirm_team_snapshot_import call counter for sequenced error testing. let teamSnapshotConfirmCallCount = 0; @@ -7509,10 +7514,17 @@ function upsertMockPersonaEvent(persona: RawPersona): void { emitMockGlobalEvent(event); } -async function handleSetPersonaShared(args: { - id: string; - shared: boolean; -}): Promise { +async function handleSetPersonaShared( + args: { + id: string; + shared: boolean; + }, + config?: E2eConfig, +): Promise<{ + persona: RawPersona; + publicationStatus: "published" | "queued"; + relayMessage?: string; +}> { const persona = mockPersonas.find((candidate) => candidate.id === args.id); if (!persona) { throw new Error(`agent ${args.id} not found`); @@ -7522,8 +7534,20 @@ async function handleSetPersonaShared(args: { } persona.shared = args.shared; persona.updated_at = new Date().toISOString(); - upsertMockPersonaEvent(persona); - return { ...persona }; + const publicationStatus = + config?.mock?.personaSharePublicationStatuses?.[ + personaSharePublicationCallCount++ + ] ?? "published"; + if (publicationStatus === "published") { + upsertMockPersonaEvent(persona); + } + return { + persona: { ...persona }, + publicationStatus, + ...(publicationStatus === "queued" + ? { relayMessage: "relay unreachable: could not connect to relay" } + : {}), + }; } function ensureMockPersonaIsActive(personaId: string) { @@ -10342,6 +10366,7 @@ export function maybeInstallE2eTauriMocks() { case "set_persona_shared": return handleSetPersonaShared( payload as Parameters[0], + activeConfig, ); case "list_teams": return handleListTeams(); diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index d47bfa982d..e3f2ccaf70 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1488,6 +1488,46 @@ This deliberately long fenced-code example must not establish the minimum width ).toHaveCount(0); }); +test("a queued catalog share is not presented as relay-published", async ({ + page, +}) => { + const personaId = "custom:queued-catalog-agent"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Queued Catalog Agent", + systemPrompt: "Wait for relay acceptance.", + }, + ], + personaSharePublicationStatuses: ["queued"], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await page.getByLabel("Open actions for Queued Catalog Agent").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await page.getByTestId("persona-share-catalog-access").click(); + await page + .getByRole("menuitemradio", { name: "Agent only", exact: true }) + .click(); + + await expect( + page.getByText( + "Sharing Queued Catalog Agent is queued. It will appear after the relay accepts the update.", + ), + ).toBeVisible(); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toHaveCount(0); +}); + test("a foreign reader does not receive an unshared kind 30175 persona", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 3a42b5182a..d367c44292 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -219,6 +219,8 @@ type MockBridgeOptions = { personas?: MockPersonaSeed[]; /** Community catalog replaceable-event heads returned by relay queries. */ personaCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit persona share publications. */ + personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; agentListDelayMs?: number;