mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user