feat(desktop): add update_persona_and_publish for save-and-publish

The edit dialog offers to publish catalog updates on save, but the only
save command enqueues best-effort and swallows enqueue failures, so the UI
could not report whether the relay accepted the edit.

The new command takes the same input as update_persona and returns the same
published | queued outcome as set_persona_shared. Both commands share the
save body through an update_persona_with seam parameterized on how the
saved record is retained; only that step differs. It passes no share
override, so an edit preserves whatever the scoped head already says.

The edit path moves to personas/update.rs, taking mod.rs from 1003 to 779
lines and retiring its file-size exception into a ratchet.

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:28:37 -04:00
parent 4340f8325c
commit b6439c7505
6 changed files with 373 additions and 216 deletions
+4 -1
View File
@@ -94,7 +94,10 @@ const overrides = new Map([
// 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for
// retry-safety. Load-bearing reviewer-required change; queued to split.
// Consolidation removed the legacy persona-card import/export codecs.
["src-tauri/src/commands/personas/mod.rs", 984],
// Retired-in-place ratchet: the edit path moved to personas/update.rs,
// taking mod.rs from 1003 to 779. Kept as a ratchet so the edit-path split
// cannot silently refill.
["src-tauri/src/commands/personas/mod.rs", 779],
// #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS
// const + build_thread_replies_filter helper, mirroring the channel sibling so
// the two p-gate filters can't drift) plus two guard unit tests. The file was
+8 -211
View File
@@ -5,12 +5,11 @@ use crate::{
app_state::AppState,
managed_agents::{
agent_events::ManagedAgentEventContent, apply_persona_behavior, current_instance_id,
delete_agent_key, effective_agent_command, load_managed_agents, load_personas, load_teams,
managed_agent_avatar_url, persona_events::persona_d_tag, save_managed_agents,
save_personas, stop_managed_agent_process, sync_managed_agent_processes,
team_events::TeamEventContent, try_regenerate_nest, validate_persona_activation_change,
validate_persona_deletion, AgentDefinition, CreatePersonaRequest, ManagedAgentRecord,
TeamRecord, UpdatePersonaRequest,
delete_agent_key, load_managed_agents, load_personas, load_teams,
persona_events::persona_d_tag, save_managed_agents, save_personas,
stop_managed_agent_process, sync_managed_agent_processes, team_events::TeamEventContent,
try_regenerate_nest, validate_persona_activation_change, validate_persona_deletion,
AgentDefinition, CreatePersonaRequest, ManagedAgentRecord, TeamRecord,
},
util::now_iso,
};
@@ -35,6 +34,9 @@ pub(in crate::commands) use pending::retain_persona_pending;
pub(super) use pending::tombstone_persona_pending;
mod sharing;
pub use sharing::set_persona_shared;
pub use sharing::update_persona_and_publish;
mod update;
pub use update::update_persona;
#[tauri::command]
pub async fn list_personas(app: AppHandle) -> Result<Vec<AgentDefinition>, String> {
@@ -114,215 +116,10 @@ pub async fn create_persona(
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
/// Return value of the `update_persona` command. Uses flatten so all
/// `AgentDefinition` fields appear at the top level of the JSON response —
/// backward-compatible with callers that already destructure a raw persona object.
#[derive(Debug, serde::Serialize)]
pub struct UpdatePersonaResult {
#[serde(flatten)]
persona: AgentDefinition,
}
/// Propagate a persona definition's display_name rename to linked agent instances.
/// Only instances whose current `name` equals `old_display_name` are updated;
/// pool-named instances (e.g. "Birch", "Compass") keep their individualised name.
/// Updates both `record.name` (relay display name) and `record.display_name`.
/// Returns the pubkeys of the records that were renamed.
fn propagate_persona_name_rename(
records: &mut [ManagedAgentRecord],
persona_id: &str,
old_display_name: &str,
new_display_name: &str,
) -> Vec<String> {
let mut renamed = Vec::new();
for record in records.iter_mut() {
if record.persona_id.as_deref() != Some(persona_id) {
continue;
}
if record.name != old_display_name {
continue; // pool-named instance — keep its individualised name
}
record.name = new_display_name.to_string();
record.display_name = Some(new_display_name.to_string());
renamed.push(record.pubkey.clone());
}
renamed
}
#[tauri::command]
pub async fn update_persona(
input: UpdatePersonaRequest,
app: AppHandle,
) -> Result<UpdatePersonaResult, String> {
use tauri::Manager;
/// Profile sync params collected under the store lock for async relay publish.
type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option<String>, Option<String>)>;
// Phase 1: synchronous save (persona record + linked agent avatar updates)
let (result, profile_sync_params) = tokio::task::spawn_blocking({
let app = app.clone();
move || -> Result<(AgentDefinition, ProfileSyncParams), String> {
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
let system_prompt = input.system_prompt.clone();
let avatar_url = trim_optional(input.avatar_url);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
let _store_guard = state
.managed_agents_store_lock
.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)
.ok_or_else(|| format!("agent {} not found", input.id))?;
// Track what changed so we can propagate to linked agent records.
let avatar_changed = persona.avatar_url != avatar_url;
let name_changed = persona.display_name != display_name;
let old_display_name = persona.display_name.clone();
persona.display_name = display_name;
persona.avatar_url = avatar_url;
persona.system_prompt = system_prompt;
persona.runtime = runtime;
persona.model = model;
persona.provider = provider;
persona.name_pool = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if let Some(env_vars) = input.env_vars {
crate::managed_agents::validate_user_env_keys(&env_vars)?;
persona.env_vars = env_vars;
}
apply_persona_behavior(persona, input.behavior)?;
persona.updated_at = now_iso();
let result = persona.clone();
save_personas(&app, &personas)?;
retain_persona_pending(&app, &state, &result);
try_regenerate_nest(&app);
// If the avatar or display_name changed, propagate to linked agent
// records and collect relay profile sync params for the async phase.
let sync_params: ProfileSyncParams = if avatar_changed || name_changed {
let mut records = load_managed_agents(&app)?;
let mut params: ProfileSyncParams = Vec::new();
let mut agents_modified = false;
let workspace_relay = crate::relay::relay_ws_url_with_override(&state);
// Propagate the display_name rename to instances that still
// carry the old definition display_name (pool-named instances
// keep their individualised name) in one pass; the loop below
// only decides which records need a relay profile sync.
let renamed: Vec<String> = if name_changed {
propagate_persona_name_rename(
&mut records,
&result.id,
&old_display_name,
&result.display_name,
)
} else {
Vec::new()
};
for record in records.iter_mut() {
if record.persona_id.as_deref() != Some(&result.id) {
continue;
}
let mut record_changed = renamed.contains(&record.pubkey);
if avatar_changed {
// Update the persisted avatar so reconciliation on next
// start agrees with what we're about to publish.
// When the persona avatar is cleared, fall back to the
// command-default icon so the record never stores `None`
// (which reconcile_agent_profile treats as "un-migrated").
let effective_cmd = effective_agent_command(
record.persona_id.as_deref(),
std::slice::from_ref(&result),
record.agent_command_override.as_deref(),
);
record.avatar_url = result
.avatar_url
.clone()
.or_else(|| managed_agent_avatar_url(&effective_cmd));
record_changed = true;
}
if record_changed {
agents_modified = true;
if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) {
let relay_url = crate::relay::effective_agent_relay_url(
&record.relay_url,
&workspace_relay,
);
params.push((
agent_keys,
relay_url,
record.name.clone(),
record.avatar_url.clone(),
record.auth_tag.clone(),
));
}
}
}
if agents_modified {
save_managed_agents(&app, &records)?;
}
params
} else {
Vec::new()
};
Ok((result, sync_params))
}
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
// Phase 2: await relay profile sync for linked agents whose avatar or
// display_name was just updated. We await (rather than fire-and-forget)
// so the frontend cache invalidation that follows the mutation settlement
// sees the fresh relay profile. Best-effort — failures are logged, not surfaced.
if !profile_sync_params.is_empty() {
let state = app.state::<AppState>();
for (agent_keys, relay_url, display_name, avatar_url, auth_tag) in profile_sync_params {
if let Err(e) = crate::relay::sync_managed_agent_profile(
&state,
&relay_url,
&agent_keys,
&display_name,
avatar_url.as_deref(),
auth_tag.as_deref(),
)
.await
{
eprintln!("buzz-desktop: relay profile sync failed after persona update: {e}");
}
}
}
Ok(UpdatePersonaResult { persona: result })
}
#[cfg(test)]
mod delete_cascade_tests;
#[cfg(test)]
mod inbound_tests;
#[cfg(test)]
mod name_propagation_tests;
/// Return pubkeys of every managed agent whose definition is the given persona.
///
@@ -63,6 +63,33 @@ pub async fn set_persona_shared(
publish_prepared_persona(&state, prepared).await
}
/// Save a persona edit AND publish its catalog head, returning the same
/// `published | queued` outcome as [`set_persona_shared`].
///
/// The "save and publish" affordance in the edit dialog promises the change
/// reaches the catalog on save. Plain `update_persona` only enqueues
/// best-effort, so the UI could not report whether the relay accepted it. This
/// takes the identical input and reuses the strict preparation path, then awaits
/// the relay exactly like the share toggle does — a rejection or an unreachable
/// relay stays durably queued for the flush loop and is reported as `queued`.
#[tauri::command]
pub async fn update_persona_and_publish(
input: crate::managed_agents::UpdatePersonaRequest,
app: AppHandle,
) -> Result<SetPersonaSharedResult, String> {
let (_, prepared) =
super::update::update_persona_with(input, app.clone(), |app, state, persona| {
// Strict path: this command's contract is to report the publication
// outcome, so an enqueue failure must reach the UI rather than being
// logged and swallowed.
prepare_persona_publication(app, state, persona, None)
})
.await?;
let state = app.state::<AppState>();
publish_prepared_persona(&state, prepared).await
}
async fn publish_prepared_persona(
state: &AppState,
prepared: PreparedPersonaPublication,
@@ -165,9 +192,10 @@ mod tests {
db_path: &std::path::Path,
relay_url: String,
keys: nostr::Keys,
shared_override: Option<bool>,
) -> PreparedPersonaPublication {
let (event, retained, persona) =
prepare_persona_publication_at(db_path, &keys, &persona(), Some(true)).unwrap();
prepare_persona_publication_at(db_path, &keys, &persona(), shared_override).unwrap();
PreparedPersonaPublication {
scope: RetentionScope {
db_path: db_path.to_path_buf(),
@@ -186,7 +214,7 @@ mod tests {
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 prepared = prepared(&db_path, spawn_relay(false).await, keys, Some(true));
let state = build_app_state();
let result = publish_prepared_persona(&state, prepared).await.unwrap();
@@ -221,7 +249,7 @@ mod tests {
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 prepared = prepared(&db_path, relay_url, keys, Some(true));
let state = build_app_state();
let result = publish_prepared_persona(&state, prepared).await.unwrap();
@@ -253,7 +281,7 @@ mod tests {
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 prepared = prepared(&db_path, spawn_relay(true).await, keys, Some(true));
let state = build_app_state();
let result = publish_prepared_persona(&state, prepared).await.unwrap();
@@ -274,4 +302,89 @@ mod tests {
.pending_sync
);
}
/// `update_persona_and_publish` differs from the share toggle in one way:
/// it passes no share override, so the edit must keep whatever the scoped
/// head already says, and it reports the relay outcome to the caller.
#[tokio::test]
async fn test_update_and_publish_acceptance_publishes_the_edit_at_the_current_share_state() {
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();
// The persona is already shared in this scope.
prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap();
let prepared = prepared(&db_path, spawn_relay(true).await, keys, None);
let state = build_app_state();
let result = publish_prepared_persona(&state, prepared).await.unwrap();
assert_eq!(
result.publication_status,
PersonaSharePublicationStatus::Published
);
assert!(
result.persona.shared,
"an ordinary edit must not silently unshare the persona"
);
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 test_update_and_publish_relay_rejection_reports_queued_not_failure() {
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();
prepare_persona_publication_at(&db_path, &keys, &persona(), Some(true)).unwrap();
let prepared = prepared(&db_path, spawn_relay(false).await, keys, None);
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,
"the edit stays queued for the flush loop"
);
}
/// The save path swallows enqueue failures (`retain_persona_pending` logs
/// them). This command promises a publication outcome, so the strict
/// preparation it uses must surface the failure instead.
#[tokio::test]
async fn test_update_and_publish_enqueue_failure_is_returned() {
let dir = tempfile::tempdir().unwrap();
let keys = nostr::Keys::generate();
let error = prepare_persona_publication_at(dir.path(), &keys, &persona(), None)
.expect_err("a directory cannot be opened as the retention database");
assert!(error.contains("failed to open retention db"));
}
}
@@ -0,0 +1,243 @@
//! The persona edit command surface: `update_persona` (best-effort enqueue)
//! and the `update_persona_with` seam that `update_persona_and_publish` reuses
//! to await relay acceptance for the same save.
use tauri::AppHandle;
use crate::{
app_state::AppState,
managed_agents::{
apply_persona_behavior, effective_agent_command, load_managed_agents, load_personas,
managed_agent_avatar_url, save_managed_agents, save_personas, try_regenerate_nest,
AgentDefinition, ManagedAgentRecord, UpdatePersonaRequest,
},
util::now_iso,
};
use super::{pending, retain_persona_pending, trim_optional, trim_required};
#[cfg(test)]
mod name_propagation_tests;
/// Return value of the `update_persona` command. Uses flatten so all
/// `AgentDefinition` fields appear at the top level of the JSON response —
/// backward-compatible with callers that already destructure a raw persona object.
#[derive(Debug, serde::Serialize)]
pub struct UpdatePersonaResult {
#[serde(flatten)]
persona: AgentDefinition,
}
/// Propagate a persona definition's display_name rename to linked agent instances.
/// Only instances whose current `name` equals `old_display_name` are updated;
/// pool-named instances (e.g. "Birch", "Compass") keep their individualised name.
/// Updates both `record.name` (relay display name) and `record.display_name`.
/// Returns the pubkeys of the records that were renamed.
fn propagate_persona_name_rename(
records: &mut [ManagedAgentRecord],
persona_id: &str,
old_display_name: &str,
new_display_name: &str,
) -> Vec<String> {
let mut renamed = Vec::new();
for record in records.iter_mut() {
if record.persona_id.as_deref() != Some(persona_id) {
continue;
}
if record.name != old_display_name {
continue; // pool-named instance — keep its individualised name
}
record.name = new_display_name.to_string();
record.display_name = Some(new_display_name.to_string());
renamed.push(record.pubkey.clone());
}
renamed
}
/// Profile sync params collected under the store lock for async relay publish.
type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option<String>, Option<String>)>;
#[tauri::command]
pub async fn update_persona(
input: UpdatePersonaRequest,
app: AppHandle,
) -> Result<UpdatePersonaResult, String> {
let (persona, ()) = update_persona_with(input, app, |app, state, persona| {
retain_persona_pending(app, state, persona);
Ok(())
})
.await?;
Ok(UpdatePersonaResult { persona })
}
/// Save an edited persona, hand the saved record to `retain` while the store
/// lock is still held, then sync the relay profiles of linked agent instances.
///
/// `retain` is the only difference between the two update commands:
/// [`update_persona`] enqueues best-effort, while
/// [`sharing::update_persona_and_publish`] prepares a strict publication and
/// returns the event so the caller can await relay acceptance.
pub(super) async fn update_persona_with<R: Send + 'static>(
input: UpdatePersonaRequest,
app: AppHandle,
retain: impl FnOnce(&AppHandle, &AppState, &AgentDefinition) -> Result<R, String> + Send + 'static,
) -> Result<(AgentDefinition, R), String> {
use tauri::Manager;
// Phase 1: synchronous save (persona record + linked agent avatar updates)
let (result, retained, profile_sync_params) = tokio::task::spawn_blocking({
let app = app.clone();
move || -> Result<(AgentDefinition, R, ProfileSyncParams), String> {
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
let system_prompt = input.system_prompt.clone();
let avatar_url = trim_optional(input.avatar_url);
let runtime = trim_optional(input.runtime);
let model = trim_optional(input.model);
let provider = trim_optional(input.provider);
let _store_guard = state
.managed_agents_store_lock
.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)
.ok_or_else(|| format!("agent {} not found", input.id))?;
// Track what changed so we can propagate to linked agent records.
let avatar_changed = persona.avatar_url != avatar_url;
let name_changed = persona.display_name != display_name;
let old_display_name = persona.display_name.clone();
persona.display_name = display_name;
persona.avatar_url = avatar_url;
persona.system_prompt = system_prompt;
persona.runtime = runtime;
persona.model = model;
persona.provider = provider;
persona.name_pool = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if let Some(env_vars) = input.env_vars {
crate::managed_agents::validate_user_env_keys(&env_vars)?;
persona.env_vars = env_vars;
}
apply_persona_behavior(persona, input.behavior)?;
persona.updated_at = now_iso();
let result = persona.clone();
save_personas(&app, &personas)?;
let retained = retain(&app, &state, &result)?;
try_regenerate_nest(&app);
// If the avatar or display_name changed, propagate to linked agent
// records and collect relay profile sync params for the async phase.
let sync_params: ProfileSyncParams = if avatar_changed || name_changed {
let mut records = load_managed_agents(&app)?;
let mut params: ProfileSyncParams = Vec::new();
let mut agents_modified = false;
let workspace_relay = crate::relay::relay_ws_url_with_override(&state);
// Propagate the display_name rename to instances that still
// carry the old definition display_name (pool-named instances
// keep their individualised name) in one pass; the loop below
// only decides which records need a relay profile sync.
let renamed: Vec<String> = if name_changed {
propagate_persona_name_rename(
&mut records,
&result.id,
&old_display_name,
&result.display_name,
)
} else {
Vec::new()
};
for record in records.iter_mut() {
if record.persona_id.as_deref() != Some(&result.id) {
continue;
}
let mut record_changed = renamed.contains(&record.pubkey);
if avatar_changed {
// Update the persisted avatar so reconciliation on next
// start agrees with what we're about to publish.
// When the persona avatar is cleared, fall back to the
// command-default icon so the record never stores `None`
// (which reconcile_agent_profile treats as "un-migrated").
let effective_cmd = effective_agent_command(
record.persona_id.as_deref(),
std::slice::from_ref(&result),
record.agent_command_override.as_deref(),
);
record.avatar_url = result
.avatar_url
.clone()
.or_else(|| managed_agent_avatar_url(&effective_cmd));
record_changed = true;
}
if record_changed {
agents_modified = true;
if let Ok(agent_keys) = nostr::Keys::parse(&record.private_key_nsec) {
let relay_url = crate::relay::effective_agent_relay_url(
&record.relay_url,
&workspace_relay,
);
params.push((
agent_keys,
relay_url,
record.name.clone(),
record.avatar_url.clone(),
record.auth_tag.clone(),
));
}
}
}
if agents_modified {
save_managed_agents(&app, &records)?;
}
params
} else {
Vec::new()
};
Ok((result, retained, sync_params))
}
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
// Phase 2: await relay profile sync for linked agents whose avatar or
// display_name was just updated. We await (rather than fire-and-forget)
// so the frontend cache invalidation that follows the mutation settlement
// sees the fresh relay profile. Best-effort — failures are logged, not surfaced.
if !profile_sync_params.is_empty() {
let state = app.state::<AppState>();
for (agent_keys, relay_url, display_name, avatar_url, auth_tag) in profile_sync_params {
if let Err(e) = crate::relay::sync_managed_agent_profile(
&state,
&relay_url,
&agent_keys,
&display_name,
avatar_url.as_deref(),
auth_tag.as_deref(),
)
.await
{
eprintln!("buzz-desktop: relay profile sync failed after persona update: {e}");
}
}
}
Ok((result, retained))
}
+1
View File
@@ -803,6 +803,7 @@ pub fn run() {
list_personas,
create_persona,
update_persona,
update_persona_and_publish,
delete_persona,
set_persona_active,
set_persona_shared,