fix(desktop): keep agent list and edits working in recovery mode

The share-state projection resolves the relay+owner retention scope, which
requires signing keys. In recovery mode (identity lost or keyring locked)
those keys are withheld, so the hard `?` at all three call sites failed
list_personas, create_persona, and update_persona for EVERY agent — not
just catalog-shared ones.

Share state is a view projection, so an unresolvable scope now degrades to
"not shared" rather than propagating. It can under-report visibility but
never presents an unshared persona as published, and the durable state
lives in the retention head so the true value returns once signing works.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Will Pfleger
2026-07-27 13:17:23 -04:00
parent fbc9d515be
commit 4340f8325c
2 changed files with 115 additions and 7 deletions
@@ -46,7 +46,7 @@ pub async fn list_personas(app: AppHandle) -> Result<Vec<AgentDefinition>, Strin
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
pending::project_active_persona_sharing(&app, &state, &mut personas)?;
pending::project_active_persona_sharing(&app, &state, &mut personas);
Ok(personas)
})
.await
@@ -74,7 +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)?;
pending::project_active_persona_sharing(&app, &state, &mut personas);
let name_pool: Vec<String> = input
.name_pool
.into_iter()
@@ -176,7 +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)?;
pending::project_active_persona_sharing(&app, &state, &mut personas);
let persona = personas
.iter_mut()
.find(|record| record.id == input.id)
@@ -82,10 +82,49 @@ fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool {
.is_some_and(|event| persona_event_is_shared(&event))
}
/// Project each persona's catalog visibility from the active relay+owner
/// scope's retained head.
///
/// Infallible by design. The scope needs `signing_keys()`, which fails for the
/// whole process whenever the identity is lost or the keyring is locked, and a
/// propagated error there would break listing, creating, and updating EVERY
/// agent. Share state is a view projection, so an unresolvable scope degrades
/// to "not shared" — the safe direction: it can under-report visibility but can
/// never present an unshared persona as published. The durable share state
/// lives in the retention head, so nothing is lost: the true value reappears
/// once the identity is signable again.
pub(super) fn project_active_persona_sharing(
app: &AppHandle,
state: &AppState,
personas: &mut [AgentDefinition],
) {
let scope = crate::managed_agents::retention::active_retention_scope(app, state);
project_scoped_persona_sharing(scope, personas);
}
fn project_scoped_persona_sharing(
scope: Result<RetentionScope, String>,
personas: &mut [AgentDefinition],
) {
let projected = scope.and_then(|scope| {
project_persona_sharing_at(
&scope.db_path,
&scope.owner_keys.public_key().to_hex(),
personas,
)
});
if let Err(error) = projected {
eprintln!("buzz-desktop: persona-share-projection unavailable, reporting every agent as unshared: {error}");
for persona in personas {
persona.shared = false;
}
}
}
fn project_persona_sharing_at(
db_path: &std::path::Path,
owner_pubkey: &str,
personas: &mut [AgentDefinition],
) -> Result<(), String> {
use crate::managed_agents::{
persona_events::persona_d_tag,
@@ -93,16 +132,14 @@ pub(super) fn project_active_persona_sharing(
};
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)?;
let conn = open_retention_db(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))?;
get_retained_event(&conn, KIND_PERSONA, owner_pubkey, &persona_d_tag(persona))?;
persona.shared = retained_persona_is_shared(retained.as_ref());
}
Ok(())
@@ -279,6 +316,77 @@ mod tests {
));
}
/// A `shared = true` persona plus the scope that says so.
fn shared_persona_scope(dir: &std::path::Path) -> (RetentionScope, Vec<AgentDefinition>) {
let keys = nostr::Keys::generate();
let db_path = scoped_retention_db_path(dir, "wss://a.example", &keys.public_key().to_hex());
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap();
(
RetentionScope {
db_path,
relay_url: "wss://a.example".to_string(),
owner_keys: keys,
},
vec![persona()],
)
}
#[test]
fn test_resolvable_scope_projects_the_retained_share_state() {
let dir = tempfile::tempdir().unwrap();
let (scope, mut personas) = shared_persona_scope(dir.path());
project_scoped_persona_sharing(Ok(scope), &mut personas);
assert!(personas[0].shared);
}
#[test]
fn test_recovery_mode_identity_projects_unshared_instead_of_failing() {
let dir = tempfile::tempdir().unwrap();
let (_scope, mut personas) = shared_persona_scope(dir.path());
personas[0].shared = true;
// The real recovery-mode failure: `active_retention_scope` cannot
// resolve a scope without signing keys, which is exactly what
// `identity_lost` / `keyring_locked` withhold.
let state = crate::app_state::build_app_state();
state
.identity_lost
.store(true, std::sync::atomic::Ordering::Release);
let error = state
.signing_keys()
.expect_err("recovery mode must withhold signing keys");
project_scoped_persona_sharing(Err(error), &mut personas);
assert!(
!personas[0].shared,
"an unresolvable scope degrades to unshared so list/create/update keep working"
);
}
#[test]
fn test_unopenable_retention_db_projects_unshared_instead_of_failing() {
let dir = tempfile::tempdir().unwrap();
let keys = nostr::Keys::generate();
let mut personas = vec![persona()];
personas[0].shared = true;
project_scoped_persona_sharing(
Ok(RetentionScope {
// A directory cannot be opened as the retention database.
db_path: dir.path().to_path_buf(),
relay_url: "wss://a.example".to_string(),
owner_keys: keys,
}),
&mut personas,
);
assert!(!personas[0].shared);
}
#[test]
fn explicit_share_enqueue_failure_is_returned() {
let dir = tempfile::tempdir().unwrap();