mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Scope catalog sharing to communities
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -45,7 +45,9 @@ pub async fn list_personas(app: AppHandle) -> Result<Vec<AgentDefinition>, 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<String> = 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 {
|
||||
|
||||
@@ -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<bool>,
|
||||
) -> Result<PreparedPersonaPublication, String> {
|
||||
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<bool>,
|
||||
) -> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_persona_shared(
|
||||
id: String,
|
||||
shared: bool,
|
||||
app: AppHandle,
|
||||
) -> Result<AgentDefinition, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
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<SetPersonaSharedResult, String> {
|
||||
let prepared = tokio::task::spawn_blocking({
|
||||
let app = app.clone();
|
||||
move || {
|
||||
let state = app.state::<AppState>();
|
||||
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::<AppState>();
|
||||
publish_prepared_persona(&state, prepared).await
|
||||
}
|
||||
|
||||
async fn publish_prepared_persona(
|
||||
state: &AppState,
|
||||
prepared: PreparedPersonaPublication,
|
||||
) -> Result<SetPersonaSharedResult, String> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -39,7 +39,6 @@ fn trim_optional(value: Option<String>) -> Option<String> {
|
||||
/// 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,
|
||||
|
||||
@@ -187,6 +187,20 @@ pub async fn apply_workspace(
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
|
||||
|
||||
let state = restore_app.state::<AppState>();
|
||||
// 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);
|
||||
|
||||
@@ -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<u32, String> {
|
||||
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<u32, String> {
|
||||
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<u32, S
|
||||
}
|
||||
|
||||
// Open (or create) the retention database.
|
||||
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 migrated = 0u32;
|
||||
|
||||
@@ -149,7 +161,12 @@ fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, S
|
||||
// bump (F1) so a changed body always lands.
|
||||
let existing = get_retained_event(&conn, KIND_PERSONA, &pubkey, &d_tag)?;
|
||||
|
||||
let event = build_persona_event(record)
|
||||
let mut scoped_record = record.clone();
|
||||
scoped_record.shared = existing
|
||||
.as_ref()
|
||||
.and_then(|row| nostr::Event::from_json(&row.raw_event).ok())
|
||||
.is_some_and(|event| buzz_core_pkg::kind::persona_event_is_shared(&event));
|
||||
let event = build_persona_event(&scoped_record)
|
||||
.map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))?
|
||||
.custom_created_at(monotonic_created_at(
|
||||
existing.as_ref().map(|row| row.created_at),
|
||||
@@ -202,14 +219,14 @@ fn migrate_personas_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result<u32, S
|
||||
///
|
||||
/// Must run after the persisted identity is resolved (it signs each event with
|
||||
/// the owner's keys).
|
||||
pub fn migrate_teams_to_events(app: &tauri::AppHandle, keys: &nostr::Keys) {
|
||||
pub fn migrate_teams_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_teams_in_dir(&base_dir, keys) {
|
||||
match migrate_teams_in_dir_at(&base_dir, keys, db_path) {
|
||||
Ok(0) => {}
|
||||
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<u32, String> {
|
||||
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<u32, String> {
|
||||
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<u32, Stri
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
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 migrated = 0u32;
|
||||
|
||||
|
||||
@@ -418,16 +418,6 @@ pub fn run() {
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
let recovery_mode = identity_lost || keyring_locked;
|
||||
|
||||
// Snapshot owner keys after identity resolution; the best-effort
|
||||
// event reconcile itself runs off the synchronous setup path below.
|
||||
let owner_keys = match state.keys.lock() {
|
||||
Ok(k) => 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::<AppState>();
|
||||
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}");
|
||||
}
|
||||
|
||||
@@ -223,9 +223,34 @@ pub fn persona_from_event(event: &nostr::Event) -> Result<AgentDefinition, Strin
|
||||
/// Returns the number of events the relay accepted. Best-effort: a relay
|
||||
/// failure on one row leaves it pending for the next sweep and does not abort
|
||||
/// the remaining rows.
|
||||
#[cfg(test)]
|
||||
pub async fn flush_pending_events(
|
||||
db_path: &std::path::Path,
|
||||
state: &AppState,
|
||||
) -> Result<u32, String> {
|
||||
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<u32, String> {
|
||||
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<u32, String> {
|
||||
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()));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<u32, String> {
|
||||
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<u32, String> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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<RetentionScope, String> {
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<SubmitEventResponse, String> {
|
||||
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.
|
||||
///
|
||||
|
||||
@@ -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<SubmitEventResponse, String> {
|
||||
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<SubmitEventResponse, String> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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),
|
||||
);
|
||||
|
||||
@@ -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<AgentPersona[]>(
|
||||
["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),
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -121,12 +121,25 @@ export async function setPersonaActive(
|
||||
export async function setPersonaShared(
|
||||
id: string,
|
||||
shared: boolean,
|
||||
): Promise<AgentPersona> {
|
||||
return fromRawPersona(
|
||||
await invokeTauri<RawPersona>("set_persona_shared", { id, shared }),
|
||||
);
|
||||
): Promise<PersonaSharePublicationResult> {
|
||||
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";
|
||||
|
||||
|
||||
@@ -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<RawPersona> {
|
||||
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<typeof handleSetPersonaShared>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "list_teams":
|
||||
return handleListTeams();
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user