perf(desktop): stop beachballs on agents menu and thread open (#1402)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-30 12:26:46 -07:00
committed by GitHub
co-authored by Brain Pinky
parent 5cc09e6989
commit 34d61acc95
6 changed files with 557 additions and 480 deletions
@@ -0,0 +1,42 @@
use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo};
#[tauri::command]
pub fn discover_backend_providers() -> Vec<BackendProviderInfo> {
discover_provider_candidates()
.into_iter()
.map(|(id, path)| BackendProviderInfo {
id,
binary_path: path.display().to_string(),
})
.collect()
}
#[tauri::command]
pub async fn probe_backend_provider(binary_path: String) -> Result<serde_json::Value, String> {
// Validate that the requested path is actually a discovered buzz-backend-* binary.
// This prevents arbitrary binary execution via a compromised frontend or IPC.
let candidates = discover_provider_candidates();
let path = std::path::PathBuf::from(&binary_path);
let canonical = path
.canonicalize()
.map_err(|e| format!("binary not found: {binary_path}: {e}"))?;
let is_known = candidates
.iter()
.any(|(_, p)| p.canonicalize().ok().as_ref() == Some(&canonical));
if !is_known {
return Err(format!(
"binary '{binary_path}' is not a discovered buzz-backend-* provider"
));
}
// request_id is for provider-side logging — not validated in the response
// (stdin→stdout is 1:1 per process invocation).
let request = serde_json::json!({
"op": "info",
"request_id": uuid::Uuid::new_v4().to_string(),
});
tokio::task::spawn_blocking(move || {
invoke_provider(&canonical, &request, std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
+138 -154
View File
@@ -5,14 +5,14 @@ use crate::{
app_state::AppState,
managed_agents::{
build_managed_agent_summary, current_instance_id, discover_provider_candidates,
ensure_persona_is_active, find_managed_agent_mut, invoke_provider, load_managed_agents,
load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir,
ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas,
managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir,
normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary,
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse,
ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND,
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
},
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
util::now_iso,
@@ -452,35 +452,44 @@ async fn deploy_to_provider(
Ok(())
}
// Async so the blocking body (disk reads of agent/persona records, per-agent
// process-liveness syscalls, and a possible save) runs on Tauri's worker pool
// via spawn_blocking instead of the main UI thread — it was a beachball on the
// agents menu mount and after every start/stop/edit refetch. State is re-derived
// from the owned AppHandle inside the closure because `State<'_, _>` is borrowed
// and `std::sync::MutexGuard` is not `Send`.
#[tauri::command]
pub fn list_managed_agents(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Vec<ManagedAgentSummary>, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
pub async fn list_managed_agents(app: AppHandle) -> Result<Vec<ManagedAgentSummary>, String> {
use tauri::Manager;
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 records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
if sync_changed {
save_managed_agents(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_session_cache(pubkey);
}
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
if sync_changed {
save_managed_agents(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_session_cache(pubkey);
}
let personas = load_personas(&app).unwrap_or_default();
records
.iter()
.map(|record| build_managed_agent_summary(&app, record, &runtimes, &personas))
.collect()
let personas = load_personas(&app).unwrap_or_default();
records
.iter()
.map(|record| build_managed_agent_summary(&app, record, &runtimes, &personas))
.collect()
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
@@ -1243,60 +1252,17 @@ fn profile_needs_sync(
}
}
// Async so the blocking body (disk reads/writes + process termination) runs off
// the main UI thread via spawn_blocking. State is re-derived from the owned
// AppHandle inside the closure (`State<'_, _>` is borrowed, MutexGuard is !Send).
#[tauri::command]
pub fn stop_managed_agent(
pub async fn stop_managed_agent(
pubkey: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ManagedAgentSummary, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
let (sync_changed, exited_pubkeys) =
sync_managed_agent_processes(&mut records, &mut runtimes, &current_instance_id(&app));
if sync_changed {
save_managed_agents(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_session_cache(pubkey);
}
{
let record = find_managed_agent_mut(&mut records, &pubkey)?;
// Remote agents are stopped via !shutdown @mention from the frontend,
// not via this backend command. Reject the call.
if record.backend != BackendKind::Local {
return Err(
"remote agents are stopped via !shutdown message, not this command".to_string(),
);
}
stop_managed_agent_process(&app, record, &mut runtimes)?;
}
state.clear_session_cache(&pubkey);
save_managed_agents(&app, &records)?;
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
let personas = load_personas(&app).unwrap_or_default();
build_managed_agent_summary(&app, record, &runtimes, &personas)
}
#[tauri::command]
pub fn delete_managed_agent(
pubkey: String,
force_remote_delete: Option<bool>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
{
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -1316,43 +1282,104 @@ pub fn delete_managed_agent(
state.clear_session_cache(pubkey);
}
// Guard: reject deletion of deployed remote agents unless explicitly forced.
// This turns "don't orphan remote infra" from a UI convention into a backend
// invariant — a buggy or compromised IPC caller cannot silently orphan a live
// remote deployment. The frontend sends force_remote_delete: true only after
// the user confirms the orphan warning.
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
if record.backend != BackendKind::Local
&& record.backend_agent_id.is_some()
&& !force_remote_delete.unwrap_or(false)
{
{
let record = find_managed_agent_mut(&mut records, &pubkey)?;
// Remote agents are stopped via !shutdown @mention from the frontend,
// not via this backend command. Reject the call.
if record.backend != BackendKind::Local {
return Err(
"cannot delete a deployed remote agent without force_remote_delete: true"
.to_string(),
"remote agents are stopped via !shutdown message, not this command".to_string(),
);
}
}
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
stop_managed_agent_process(&app, record, &mut runtimes)?;
}
state.clear_session_cache(&pubkey);
let initial_len = records.len();
records.retain(|record| record.pubkey != pubkey);
if records.len() == initial_len {
return Err(format!("agent {pubkey} not found"));
}
save_managed_agents(&app, &records)?;
// Remove the agent's nsec from the keyring after the record is gone.
crate::managed_agents::delete_agent_key(&pubkey);
// Tombstone-after-validation: only reached past the deployed-remote
// guard above and a confirmed removal — never orphan a live remote
// deployment's relay record. Inside the lock, before the block closes
// (no .await here). Every agent published, so every delete tombstones.
tombstone_managed_agent_pending(&app, &state, &pubkey);
}
try_regenerate_nest(&app);
Ok(())
let record = records
.iter()
.find(|record| record.pubkey == pubkey)
.ok_or_else(|| format!("agent {pubkey} not found"))?;
let personas = load_personas(&app).unwrap_or_default();
build_managed_agent_summary(&app, record, &runtimes, &personas)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
// Async so the blocking body (disk reads/writes, process termination, keyring
// delete, nest regeneration) runs off the main UI thread via spawn_blocking.
#[tauri::command]
pub async fn delete_managed_agent(
pubkey: String,
force_remote_delete: Option<bool>,
app: AppHandle,
) -> Result<(), String> {
use tauri::Manager;
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 records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|error| error.to_string())?;
let (sync_changed, exited_pubkeys) = sync_managed_agent_processes(
&mut records,
&mut runtimes,
&current_instance_id(&app),
);
if sync_changed {
save_managed_agents(&app, &records)?;
}
for pubkey in &exited_pubkeys {
state.clear_session_cache(pubkey);
}
// Guard: reject deletion of deployed remote agents unless explicitly forced.
// This turns "don't orphan remote infra" from a UI convention into a backend
// invariant — a buggy or compromised IPC caller cannot silently orphan a live
// remote deployment. The frontend sends force_remote_delete: true only after
// the user confirms the orphan warning.
if let Some(record) = records.iter().find(|r| r.pubkey == pubkey) {
if record.backend != BackendKind::Local
&& record.backend_agent_id.is_some()
&& !force_remote_delete.unwrap_or(false)
{
return Err(
"cannot delete a deployed remote agent without force_remote_delete: true"
.to_string(),
);
}
}
if let Some(record) = records.iter_mut().find(|record| record.pubkey == pubkey) {
stop_managed_agent_process(&app, record, &mut runtimes)?;
}
state.clear_session_cache(&pubkey);
let initial_len = records.len();
records.retain(|record| record.pubkey != pubkey);
if records.len() == initial_len {
return Err(format!("agent {pubkey} not found"));
}
save_managed_agents(&app, &records)?;
// Remove the agent's nsec from the keyring after the record is gone.
crate::managed_agents::delete_agent_key(&pubkey);
// Tombstone-after-validation: only reached past the deployed-remote
// guard above and a confirmed removal — never orphan a live remote
// deployment's relay record. Inside the lock, before the block closes
// (no .await here). Every agent published, so every delete tombstones.
tombstone_managed_agent_pending(&app, &state, &pubkey);
}
try_regenerate_nest(&app);
Ok(())
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
@@ -1382,49 +1409,6 @@ pub fn get_managed_agent_log(
})
}
// ── New backend-provider commands ────────────────────────────────────────────
#[tauri::command]
pub fn discover_backend_providers() -> Vec<BackendProviderInfo> {
discover_provider_candidates()
.into_iter()
.map(|(id, path)| BackendProviderInfo {
id,
binary_path: path.display().to_string(),
})
.collect()
}
#[tauri::command]
pub async fn probe_backend_provider(binary_path: String) -> Result<serde_json::Value, String> {
// Validate that the requested path is actually a discovered buzz-backend-* binary.
// This prevents arbitrary binary execution via a compromised frontend or IPC.
let candidates = discover_provider_candidates();
let path = std::path::PathBuf::from(&binary_path);
let canonical = path
.canonicalize()
.map_err(|e| format!("binary not found: {binary_path}: {e}"))?;
let is_known = candidates
.iter()
.any(|(_, p)| p.canonicalize().ok().as_ref() == Some(&canonical));
if !is_known {
return Err(format!(
"binary '{binary_path}' is not a discovered buzz-backend-* provider"
));
}
// request_id is for provider-side logging — not validated in the response
// (stdin→stdout is 1:1 per process invocation).
let request = serde_json::json!({
"op": "info",
"request_id": uuid::Uuid::new_v4().to_string(),
});
tokio::task::spawn_blocking(move || {
invoke_provider(&canonical, &request, std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
// Remote agent shutdown is handled entirely by the frontend:
// 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key)
// 2. Harness sees it, exits gracefully, sets presence to "offline"
+2
View File
@@ -1,6 +1,7 @@
mod agent_config;
mod agent_discovery;
mod agent_models;
mod agent_providers;
mod agent_settings;
mod agents;
mod canvas;
@@ -34,6 +35,7 @@ mod workspace;
pub use agent_config::*;
pub use agent_discovery::*;
pub use agent_models::*;
pub use agent_providers::*;
pub use agent_settings::*;
pub use agents::*;
pub use canvas::*;
+206 -186
View File
@@ -150,129 +150,142 @@ pub(super) fn tombstone_persona_pending(app: &AppHandle, state: &AppState, d_tag
}
#[tauri::command]
pub fn list_personas(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Vec<PersonaRecord>, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
load_personas(&app)
pub async fn list_personas(app: AppHandle) -> Result<Vec<PersonaRecord>, String> {
use tauri::Manager;
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())?;
load_personas(&app)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub fn create_persona(
pub async fn create_persona(
input: CreatePersonaRequest,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<PersonaRecord, String> {
let display_name = trim_required(&input.display_name, "Display name")?;
// System prompt optional: core memory is auto-injected. Empty is valid.
let system_prompt = input.system_prompt.trim().to_string();
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 now = now_iso();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
let name_pool: Vec<String> = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
let persona = PersonaRecord {
id: Uuid::new_v4().to_string(),
display_name,
avatar_url,
system_prompt,
runtime,
model,
provider,
name_pool,
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: input.env_vars,
created_at: now.clone(),
updated_at: now,
};
personas.push(persona.clone());
save_personas(&app, &personas)?;
retain_persona_pending(&app, &state, &persona);
try_regenerate_nest(&app);
Ok(persona)
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let display_name = trim_required(&input.display_name, "Display name")?;
// System prompt optional: core memory is auto-injected. Empty is valid.
let system_prompt = input.system_prompt.trim().to_string();
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 now = now_iso();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
let name_pool: Vec<String> = input
.name_pool
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
crate::managed_agents::validate_user_env_keys(&input.env_vars)?;
let persona = PersonaRecord {
id: Uuid::new_v4().to_string(),
display_name,
avatar_url,
system_prompt,
runtime,
model,
provider,
name_pool,
is_builtin: false,
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: input.env_vars,
created_at: now.clone(),
updated_at: now,
};
personas.push(persona.clone());
save_personas(&app, &personas)?;
retain_persona_pending(&app, &state, &persona);
try_regenerate_nest(&app);
Ok(persona)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub fn update_persona(
pub async fn update_persona(
input: UpdatePersonaRequest,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<PersonaRecord, String> {
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);
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))?;
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());
}
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();
if persona.is_builtin {
return Err("Built-in personas cannot be edited.".to_string());
}
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))?;
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);
// 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)
retain_persona_pending(&app, &state, &result);
try_regenerate_nest(&app);
Ok(result)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
mod writeback;
@@ -282,55 +295,57 @@ use writeback::write_back_persona_md;
mod inbound_tests;
#[tauri::command]
pub fn delete_persona(
id: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
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()
.find(|record| record.id == id)
.ok_or_else(|| format!("persona {id} not found"))?;
let referenced_by_team = load_teams(&app)?.iter().any(|team| {
team.persona_ids
pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> {
use tauri::Manager;
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()
.any(|persona_id| persona_id == id.as_str())
});
validate_persona_deletion(persona, referenced_by_team)?;
// Capture the coordinate before the record leaves the list. Only reached
// for non-builtin, non-team personas (validate_persona_deletion rejects
// both), so every deleted persona here is one this owner published.
let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona);
.find(|record| record.id == id)
.ok_or_else(|| format!("persona {id} not found"))?;
let referenced_by_team = load_teams(&app)?.iter().any(|team| {
team.persona_ids
.iter()
.any(|persona_id| persona_id == id.as_str())
});
validate_persona_deletion(persona, referenced_by_team)?;
// Capture the coordinate before the record leaves the list. Only reached
// for non-builtin, non-team personas (validate_persona_deletion rejects
// both), so every deleted persona here is one this owner published.
let d_tag = crate::managed_agents::persona_events::persona_d_tag(persona);
let original_len = personas.len();
personas.retain(|record| record.id != id);
if personas.len() == original_len {
return Err(format!("persona {id} not found"));
}
save_personas(&app, &personas)?;
tombstone_persona_pending(&app, &state, &d_tag);
let mut agents = load_managed_agents(&app)?;
let mut changed_agents = false;
let now = now_iso();
for agent in &mut agents {
if agent.persona_id.as_deref() == Some(id.as_str()) {
agent.persona_id = None;
agent.updated_at = now.clone();
changed_agents = true;
let original_len = personas.len();
personas.retain(|record| record.id != id);
if personas.len() == original_len {
return Err(format!("persona {id} not found"));
}
}
if changed_agents {
save_managed_agents(&app, &agents)?;
}
try_regenerate_nest(&app);
save_personas(&app, &personas)?;
tombstone_persona_pending(&app, &state, &d_tag);
Ok(())
let mut agents = load_managed_agents(&app)?;
let mut changed_agents = false;
let now = now_iso();
for agent in &mut agents {
if agent.persona_id.as_deref() == Some(id.as_str()) {
agent.persona_id = None;
agent.updated_at = now.clone();
changed_agents = true;
}
}
if changed_agents {
save_managed_agents(&app, &agents)?;
}
try_regenerate_nest(&app);
Ok(())
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
/// Apply an inbound kind:30175 persona event from the relay onto the local
@@ -679,51 +694,56 @@ fn apply_inbound_team(teams: &mut Vec<TeamRecord>, d_tag: String, inbound: TeamE
}
#[tauri::command]
pub fn set_persona_active(
pub async fn set_persona_active(
id: String,
active: bool,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<PersonaRecord, String> {
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!("persona {id} not found"))?;
use tauri::Manager;
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!("persona {id} not found"))?;
let referenced_by_managed_agent = !active
&& load_managed_agents(&app)?
.iter()
.any(|agent| agent.persona_id.as_deref() == Some(id.as_str()));
let referenced_by_team = !active
&& load_teams(&app)?.iter().any(|team| {
team.persona_ids
let referenced_by_managed_agent = !active
&& load_managed_agents(&app)?
.iter()
.any(|persona_id| persona_id == id.as_str())
});
.any(|agent| agent.persona_id.as_deref() == Some(id.as_str()));
let referenced_by_team = !active
&& load_teams(&app)?.iter().any(|team| {
team.persona_ids
.iter()
.any(|persona_id| persona_id == id.as_str())
});
validate_persona_activation_change(
persona,
active,
referenced_by_managed_agent,
referenced_by_team,
)?;
validate_persona_activation_change(
persona,
active,
referenced_by_managed_agent,
referenced_by_team,
)?;
if persona.is_active == active {
return Ok(persona.clone());
}
if persona.is_active == active {
return Ok(persona.clone());
}
persona.is_active = active;
persona.updated_at = now_iso();
persona.is_active = active;
persona.updated_at = now_iso();
let updated = persona.clone();
save_personas(&app, &personas)?;
try_regenerate_nest(&app);
Ok(updated)
let updated = persona.clone();
save_personas(&app, &personas)?;
try_regenerate_nest(&app);
Ok(updated)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
const MAX_PNG_BYTES: usize = 10 * 1024 * 1024;
+146 -123
View File
@@ -137,139 +137,162 @@ fn tombstone_team_pending(app: &AppHandle, state: &AppState, d_tag: &str) {
}
#[tauri::command]
pub fn list_teams(app: AppHandle, state: State<'_, AppState>) -> Result<Vec<TeamRecord>, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
load_teams(&app)
pub async fn list_teams(app: AppHandle) -> Result<Vec<TeamRecord>, String> {
use tauri::Manager;
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())?;
load_teams(&app)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub fn create_team(
input: CreateTeamRequest,
pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result<TeamRecord, String> {
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let name = trim_required(&input.name, "Team name")?;
let description = trim_optional(input.description);
let now = now_iso();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let personas = load_personas(&app)?;
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
let mut teams = load_teams(&app)?;
let team = TeamRecord {
id: Uuid::new_v4().to_string(),
name,
description,
persona_ids: input.persona_ids,
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: now.clone(),
updated_at: now,
};
teams.push(team.clone());
save_teams(&app, &teams)?;
// Created teams are always non-builtin; publish to the relay.
retain_team_pending(&app, &state, &team);
Ok(team)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result<TeamRecord, String> {
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let name = trim_required(&input.name, "Team name")?;
let description = trim_optional(input.description);
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let personas = load_personas(&app)?;
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
let mut teams = load_teams(&app)?;
let team = teams
.iter_mut()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("team {} not found", input.id))?;
team.name = name;
team.description = description;
team.persona_ids = input.persona_ids;
team.updated_at = now_iso();
let updated = team.clone();
save_teams(&app, &teams)?;
// Built-in teams are not owner-authored — never publish them.
if !updated.is_builtin {
retain_team_pending(&app, &state, &updated);
}
Ok(updated)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub async fn delete_team(id: String, app: AppHandle) -> Result<(), String> {
use tauri::Manager;
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 cascaded_persona_d_tags = delete_team_with_cascade(&app, &id)?;
// delete_team_with_cascade rejects built-in teams via validate_team_deletion,
// so reaching here means this team was owner-published — tombstone it. The
// d_tag is the team id, captured before the record left the store.
tombstone_team_pending(&app, &state, &id);
// Tombstone the cascaded personas too, so their orphaned kind:30175 heads
// don't linger on the relay (F4). Each d-tag was captured pre-removal.
for persona_d_tag in &cascaded_persona_d_tags {
super::personas::tombstone_persona_pending(&app, &state, persona_d_tag);
}
try_regenerate_nest(&app);
Ok(())
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub async fn install_team_from_directory(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<TeamRecord, String> {
let name = trim_required(&input.name, "Team name")?;
let description = trim_optional(input.description);
let now = now_iso();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let personas = load_personas(&app)?;
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
let mut teams = load_teams(&app)?;
let team = TeamRecord {
id: Uuid::new_v4().to_string(),
name,
description,
persona_ids: input.persona_ids,
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: now.clone(),
updated_at: now,
};
teams.push(team.clone());
save_teams(&app, &teams)?;
// Created teams are always non-builtin; publish to the relay.
retain_team_pending(&app, &state, &team);
Ok(team)
}
#[tauri::command]
pub fn update_team(
input: UpdateTeamRequest,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<TeamRecord, String> {
let name = trim_required(&input.name, "Team name")?;
let description = trim_optional(input.description);
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let personas = load_personas(&app)?;
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
let mut teams = load_teams(&app)?;
let team = teams
.iter_mut()
.find(|record| record.id == input.id)
.ok_or_else(|| format!("team {} not found", input.id))?;
team.name = name;
team.description = description;
team.persona_ids = input.persona_ids;
team.updated_at = now_iso();
let updated = team.clone();
save_teams(&app, &teams)?;
// Built-in teams are not owner-authored — never publish them.
if !updated.is_builtin {
retain_team_pending(&app, &state, &updated);
}
Ok(updated)
}
#[tauri::command]
pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let cascaded_persona_d_tags = delete_team_with_cascade(&app, &id)?;
// delete_team_with_cascade rejects built-in teams via validate_team_deletion,
// so reaching here means this team was owner-published — tombstone it. The
// d_tag is the team id, captured before the record left the store.
tombstone_team_pending(&app, &state, &id);
// Tombstone the cascaded personas too, so their orphaned kind:30175 heads
// don't linger on the relay (F4). Each d-tag was captured pre-removal.
for persona_d_tag in &cascaded_persona_d_tags {
super::personas::tombstone_persona_pending(&app, &state, persona_d_tag);
}
try_regenerate_nest(&app);
Ok(())
}
#[tauri::command]
pub fn install_team_from_directory(
app: AppHandle,
state: State<'_, AppState>,
path: String,
symlink: Option<bool>,
) -> Result<TeamRecord, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let source = std::path::PathBuf::from(&path);
if !source.is_dir() {
return Err(format!("team path is not a directory: {path}"));
}
let result = do_import_team(&app, &source, symlink.unwrap_or(false))?;
try_regenerate_nest(&app);
Ok(result)
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let source = std::path::PathBuf::from(&path);
if !source.is_dir() {
return Err(format!("team path is not a directory: {path}"));
}
let result = do_import_team(&app, &source, symlink.unwrap_or(false))?;
try_regenerate_nest(&app);
Ok(result)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
pub fn sync_team_directory(
app: AppHandle,
state: State<'_, AppState>,
team_id: String,
) -> Result<SyncResult, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let result = do_sync_team(&app, &team_id)?;
try_regenerate_nest(&app);
Ok(result)
pub async fn sync_team_directory(app: AppHandle, team_id: String) -> Result<SyncResult, String> {
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let result = do_sync_team(&app, &team_id)?;
try_regenerate_nest(&app);
Ok(result)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}
#[tauri::command]
@@ -1,7 +1,5 @@
import * as React from "react";
import { flushSync } from "react-dom";
import type {
useDeleteMessageMutation,
useEditMessageMutation,
@@ -81,19 +79,26 @@ export function useChannelPaneHandlers({
const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync);
toggleMutateRef.current = toggleReactionMutation.mutateAsync;
const deferPanelState = React.useCallback((update: () => void) => {
window.setTimeout(() => {
React.startTransition(update);
}, 0);
}, []);
const handleCancelThreadReply = React.useCallback(() => {
setThreadReplyTargetId(openThreadHeadIdRef.current);
}, [setThreadReplyTargetId]);
const handleCloseThread = React.useCallback(() => {
flushSync(() => {
deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(null);
setOpenThreadHeadId(null);
setThreadReplyTargetId(null);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
});
setOpenThreadHeadId(null);
setThreadReplyTargetId(null);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
}, [
deferPanelState,
onOptimisticOpenThreadHeadIdChange,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
@@ -135,27 +140,28 @@ export function useChannelPaneHandlers({
const handleOpenThread = React.useCallback(
(message: { id: string }) => {
if (openThreadHeadIdRef.current === message.id) {
flushSync(() => {
deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(null);
setOpenThreadHeadId(null);
setThreadReplyTargetId(null);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
});
setOpenThreadHeadId(null);
setThreadReplyTargetId(null);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
setEditTargetId(null);
return;
}
flushSync(() => {
deferPanelState(() => {
onOptimisticOpenThreadHeadIdChange(message.id);
setOpenThreadHeadId(message.id);
setThreadReplyTargetId(message.id);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
});
setOpenThreadHeadId(message.id);
setThreadReplyTargetId(message.id);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
setEditTargetId(null);
},
[
deferPanelState,
onOptimisticOpenThreadHeadIdChange,
setEditTargetId,
setExpandedThreadReplyIds,