fix(desktop): sync agent relay profile when persona avatar changes (#1512)

Signed-off-by: Kenny Lopez <kenny@block.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
klopez4212
2026-07-06 16:30:16 +01:00
committed by GitHub
parent 9e773f103a
commit ce901f7c1e
3 changed files with 175 additions and 61 deletions

View File

@@ -5,9 +5,10 @@ use super::export_util::save_json_with_dialog;
use crate::{
app_state::AppState,
managed_agents::{
agent_events::ManagedAgentEventContent, encode_persona_json, load_managed_agents,
load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona,
parse_zip_personas, persona_events::persona_d_tag, save_managed_agents, save_personas,
agent_events::ManagedAgentEventContent, effective_agent_command, encode_persona_json,
load_managed_agents, load_personas, load_teams, managed_agent_avatar_url,
parse_json_persona, parse_md_persona, parse_png_persona, parse_zip_personas,
persona_events::persona_d_tag, save_managed_agents, save_personas,
team_events::TeamEventContent, team_persona_key, try_regenerate_nest,
validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest,
ManagedAgentRecord, ParsePersonaFilesResult, PersonaRecord, TeamRecord,
@@ -225,67 +226,158 @@ pub async fn update_persona(
app: AppHandle,
) -> Result<PersonaRecord, String> {
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
// Do not trim system_prompt: `compose_prompt` appends pack_instructions
// verbatim (including any trailing newline), and write_back_persona_md
// decomposes by suffix-stripping. Trimming would break that exact-suffix
// match for the common case where instructions.md has a trailing newline.
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)?;
let persona = personas
.iter_mut()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("persona {} not found", input.id))?;
/// Profile sync params collected under the store lock for async relay publish.
type ProfileSyncParams = Vec<(nostr::Keys, String, String, Option<String>, Option<String>)>;
if persona.is_builtin {
return Err("Built-in personas cannot be edited.".to_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<(PersonaRecord, ProfileSyncParams), String> {
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
// Do not trim system_prompt: `compose_prompt` appends pack_instructions
// verbatim (including any trailing newline), and write_back_persona_md
// decomposes by suffix-stripping. Trimming would break that exact-suffix
// match for the common case where instructions.md has a trailing newline.
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)?;
let persona = personas
.iter_mut()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("persona {} not found", input.id))?;
if persona.is_builtin {
return Err("Built-in personas cannot be edited.".to_string());
}
// Track whether avatar changed so we can sync linked agents.
let avatar_changed = persona.avatar_url != avatar_url;
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;
}
persona.updated_at = now_iso();
save_personas(&app, &personas)?;
let result = personas
.into_iter()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
// For pack-backed personas, also write the edit back to the source
// `.persona.md` so that launch sync (which reads the file) becomes a
// no-op rather than overwriting the record we just saved.
write_back_persona_md(&app, &result);
retain_persona_pending(&app, &state, &result);
try_regenerate_nest(&app);
// If the avatar changed, propagate to linked agent records and
// collect relay profile sync params for the async phase.
let sync_params: ProfileSyncParams = if avatar_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);
for record in records.iter_mut() {
if record.persona_id.as_deref() != Some(&result.id) {
continue;
}
// 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));
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))
}
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;
}
persona.updated_at = now_iso();
save_personas(&app, &personas)?;
let result = personas
.into_iter()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("persona {} disappeared unexpectedly", input.id))?;
// For pack-backed personas, also write the edit back to the source
// `.persona.md` so that launch sync (which reads the file) becomes a
// no-op rather than overwriting the record we just saved.
write_back_persona_md(&app, &result);
retain_persona_pending(&app, &state, &result);
try_regenerate_nest(&app);
Ok(result)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
.map_err(|e| format!("spawn_blocking failed: {e}"))??;
// Phase 2: await relay profile sync for linked agents whose avatar 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 avatar update: {e}"
);
}
}
}
Ok(result)
}
mod writeback;

View File

@@ -326,10 +326,32 @@ export function useUpdatePersonaMutation() {
return useMutation({
mutationFn: (input: UpdatePersonaInput) => updatePersona(input),
onSettled: async () => {
onSettled: async (_data, _error, variables) => {
// Evict per-pubkey users-batch-entry caches for agents linked to this
// persona so the subsequent batch invalidation refetches fresh profiles
// instead of re-reading stale entries (mirrors useUpdateManagedAgentMutation).
const agents = queryClient.getQueryData<ManagedAgent[]>(
managedAgentsQueryKey,
);
if (agents) {
const linkedPubkeys = agents
.filter((a) => a.personaId === variables.id)
.map((a) => a.pubkey.toLowerCase());
evictUsersBatchEntries(queryClient, linkedPubkeys);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
// Persona avatar changes re-sync linked agents' relay profiles;
// invalidate cached user-profile and users-batch queries so the UI
// picks up the updated kind:0 picture without waiting for staleTime
// expiry — covers agent cards, message timelines, and member lists.
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "user-profile" ||
query.queryKey[0] === "users-batch",
}),
]);
},
});

View File

@@ -301,7 +301,7 @@ function AgentPersonaCard({
const isActive = agent ? isManagedAgentActive(agent) : false;
const profileQuery = useUserProfileQuery(agent?.pubkey);
const avatarUrl = agent
? firstAvatarUrl(profileQuery.data?.avatarUrl, persona.avatarUrl)
? firstAvatarUrl(persona.avatarUrl, profileQuery.data?.avatarUrl)
: persona.avatarUrl;
const friendlyError = agent
? friendlyAgentLastError(agent.lastError)?.copy