Scope catalog sharing to communities

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-07-27 16:10:54 +01:00
parent 53c3245600
commit 8eba9226fa
26 changed files with 873 additions and 290 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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"));
}
}

View File

@@ -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
);
}
}

View File

@@ -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)
};

View File

@@ -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)
};

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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}");
}

View File

@@ -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()));

View File

@@ -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");

View File

@@ -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;

View File

@@ -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();

View File

@@ -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

View File

@@ -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.
///

View File

@@ -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,

View File

@@ -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

View File

@@ -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, []);
});

View File

@@ -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),
);

View File

@@ -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),

View File

@@ -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.`,
);

View File

@@ -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";

View File

@@ -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();

View File

@@ -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,
}) => {

View File

@@ -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;