diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 7836de795..bc7e951e2 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -165,8 +165,10 @@ fn retag_persona_default(field: &mut Option) { /// Returns `null` when the runtime has no config file or it cannot be parsed. /// Currently only "goose" is supported; other runtimes return `null`. #[tauri::command] -pub fn get_runtime_file_config(runtime_id: String) -> Option { - match runtime_id.as_str() { +pub async fn get_runtime_file_config( + runtime_id: String, +) -> Result, String> { + tokio::task::spawn_blocking(move || match runtime_id.as_str() { "goose" => { let cfg = read_goose_file_config()?; let satisfied_env_keys = cfg @@ -182,7 +184,9 @@ pub fn get_runtime_file_config(runtime_id: String) -> Option None, - } + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) } /// Return the key names of all non-empty baked build env vars. diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 35462cffc..6b6978d6d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -20,9 +20,13 @@ fn active_installs() -> &'static std::sync::Mutex Vec { - crate::managed_agents::clear_resolve_cache(); - crate::managed_agents::discover_acp_runtimes() +pub async fn discover_acp_providers() -> Result, String> { + tokio::task::spawn_blocking(|| { + crate::managed_agents::clear_resolve_cache(); + crate::managed_agents::discover_acp_runtimes() + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) } #[tauri::command] @@ -292,26 +296,30 @@ fn floor_char_boundary(s: &str, mut index: usize) -> usize { } #[tauri::command] -pub fn discover_managed_agent_prereqs( +pub async fn discover_managed_agent_prereqs( input: DiscoverManagedAgentPrereqsRequest, -) -> ManagedAgentPrereqsInfo { - let acp_command = input - .acp_command - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(DEFAULT_ACP_COMMAND); - let mcp_command = input - .mcp_command - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(""); +) -> Result { + tokio::task::spawn_blocking(move || { + let acp_command = input + .acp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(DEFAULT_ACP_COMMAND); + let mcp_command = input + .mcp_command + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(""); - ManagedAgentPrereqsInfo { - acp: command_availability(acp_command), - mcp: command_availability(mcp_command), - } + ManagedAgentPrereqsInfo { + acp: command_availability(acp_command), + mcp: command_availability(mcp_command), + } + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/agent_logs.rs b/desktop/src-tauri/src/commands/agent_logs.rs new file mode 100644 index 000000000..25d3d4d1c --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_logs.rs @@ -0,0 +1,40 @@ +use tauri::{AppHandle, Manager}; + +use crate::{ + app_state::AppState, + managed_agents::{ + load_managed_agents, managed_agent_log_path, read_log_tail, BackendKind, + ManagedAgentLogResponse, + }, +}; + +#[tauri::command] +pub async fn get_managed_agent_log( + pubkey: String, + line_count: Option, + app: AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + if record.backend != BackendKind::Local { + return Err("logs are not available for remote agents".to_string()); + } + + let log_path = managed_agent_log_path(&app, &pubkey)?; + Ok(ManagedAgentLogResponse { + content: read_log_tail(&log_path, line_count.unwrap_or(120) as usize)?, + log_path: log_path.display().to_string(), + }) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} diff --git a/desktop/src-tauri/src/commands/agent_providers.rs b/desktop/src-tauri/src/commands/agent_providers.rs index a903e4bba..178ec0bb6 100644 --- a/desktop/src-tauri/src/commands/agent_providers.rs +++ b/desktop/src-tauri/src/commands/agent_providers.rs @@ -1,14 +1,18 @@ use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo}; #[tauri::command] -pub fn discover_backend_providers() -> Vec { - discover_provider_candidates() - .into_iter() - .map(|(id, path)| BackendProviderInfo { - id, - binary_path: path.display().to_string(), - }) - .collect() +pub async fn discover_backend_providers() -> Result, String> { + tokio::task::spawn_blocking(|| { + discover_provider_candidates() + .into_iter() + .map(|(id, path)| BackendProviderInfo { + id, + binary_path: path.display().to_string(), + }) + .collect() + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index b1cb92822..73588caef 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager}; use crate::{ app_state::AppState, @@ -11,42 +11,46 @@ use crate::{ }; #[tauri::command] -pub fn set_managed_agent_start_on_app_launch( +pub async fn set_managed_agent_start_on_app_launch( pubkey: String, start_on_app_launch: bool, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - 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())?; + tokio::task::spawn_blocking(move || { + let state = app.state::(); + 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, ¤t_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)?; + record.start_on_app_launch = start_on_app_launch; + record.updated_at = now_iso(); + } - let (sync_changed, exited_pubkeys) = - sync_managed_agent_processes(&mut records, &mut runtimes, ¤t_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)?; - record.start_on_app_launch = start_on_app_launch; - record.updated_at = now_iso(); - } - - 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) + 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}"))? } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index ac29591f5..31dde39bb 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,13 +6,12 @@ use crate::{ managed_agents::{ build_managed_agent_summary, current_instance_id, discover_provider_candidates, 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, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse, - ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, - DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, provider_deploy, + 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, CreateManagedAgentRequest, + CreateManagedAgentResponse, 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, @@ -1250,33 +1249,6 @@ pub async fn delete_managed_agent( .map_err(|e| format!("spawn_blocking failed: {e}"))? } -#[tauri::command] -pub fn get_managed_agent_log( - pubkey: String, - line_count: Option, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let records = load_managed_agents(&app)?; - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - if record.backend != BackendKind::Local { - return Err("logs are not available for remote agents".to_string()); - } - - let log_path = managed_agent_log_path(&app, &pubkey)?; - Ok(ManagedAgentLogResponse { - content: read_log_tail(&log_path, line_count.unwrap_or(120) as usize)?, - log_path: log_path.display().to_string(), - }) -} - // 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" diff --git a/desktop/src-tauri/src/commands/channel_templates.rs b/desktop/src-tauri/src/commands/channel_templates.rs index c4e8dfca1..5d88e162c 100644 --- a/desktop/src-tauri/src/commands/channel_templates.rs +++ b/desktop/src-tauri/src/commands/channel_templates.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Manager}; use uuid::Uuid; use crate::{ @@ -44,142 +44,157 @@ fn validate_visibility(value: &str) -> Result<(), String> { } #[tauri::command] -pub fn list_channel_templates( - app: AppHandle, - state: State<'_, AppState>, -) -> Result, String> { - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - load_channel_templates(&app) +pub async fn list_channel_templates(app: AppHandle) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + load_channel_templates(&app) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn create_channel_template( +pub async fn create_channel_template( input: CreateChannelTemplateRequest, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let name = trim_required(&input.name, "Template name")?; - let description = trim_optional(input.description); - let canvas_template = trim_optional(input.canvas_template); - let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); - let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); - validate_channel_type(&channel_type)?; - validate_visibility(&visibility)?; - let now = now_iso(); + tokio::task::spawn_blocking(move || { + let name = trim_required(&input.name, "Template name")?; + let description = trim_optional(input.description); + let canvas_template = trim_optional(input.canvas_template); + let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); + let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); + validate_channel_type(&channel_type)?; + validate_visibility(&visibility)?; + let now = now_iso(); - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut templates = load_channel_templates(&app)?; + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; - let template = ChannelTemplateRecord { - id: Uuid::new_v4().to_string(), - name, - description, - channel_type, - visibility, - canvas_template, - agents: input.agents, - is_builtin: false, - created_at: now.clone(), - updated_at: now, - }; + let template = ChannelTemplateRecord { + id: Uuid::new_v4().to_string(), + name, + description, + channel_type, + visibility, + canvas_template, + agents: input.agents, + is_builtin: false, + created_at: now.clone(), + updated_at: now, + }; - templates.push(template.clone()); - save_channel_templates(&app, &templates)?; - Ok(template) + templates.push(template.clone()); + save_channel_templates(&app, &templates)?; + Ok(template) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn update_channel_template( +pub async fn update_channel_template( input: UpdateChannelTemplateRequest, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let name = trim_required(&input.name, "Template name")?; - let description = trim_optional(input.description); - let canvas_template = trim_optional(input.canvas_template); - let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); - let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); - validate_channel_type(&channel_type)?; - validate_visibility(&visibility)?; + tokio::task::spawn_blocking(move || { + let name = trim_required(&input.name, "Template name")?; + let description = trim_optional(input.description); + let canvas_template = trim_optional(input.canvas_template); + let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string()); + let visibility = input.visibility.unwrap_or_else(|| "open".to_string()); + validate_channel_type(&channel_type)?; + validate_visibility(&visibility)?; - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut templates = load_channel_templates(&app)?; - let template = templates - .iter_mut() - .find(|record| record.id == input.id) - .ok_or_else(|| format!("template {} not found", input.id))?; + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + let template = templates + .iter_mut() + .find(|record| record.id == input.id) + .ok_or_else(|| format!("template {} not found", input.id))?; - template.name = name; - template.description = description; - template.channel_type = channel_type; - template.visibility = visibility; - template.canvas_template = canvas_template; - template.agents = input.agents; - template.updated_at = now_iso(); + template.name = name; + template.description = description; + template.channel_type = channel_type; + template.visibility = visibility; + template.canvas_template = canvas_template; + template.agents = input.agents; + template.updated_at = now_iso(); - let updated = template.clone(); - save_channel_templates(&app, &templates)?; - Ok(updated) + let updated = template.clone(); + save_channel_templates(&app, &templates)?; + Ok(updated) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn delete_channel_template( - id: String, - app: AppHandle, - state: State<'_, AppState>, -) -> Result<(), String> { - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut templates = load_channel_templates(&app)?; - let template = templates - .iter() - .find(|record| record.id == id) - .ok_or_else(|| format!("template {id} not found"))?; - validate_channel_template_deletion(template)?; - templates.retain(|record| record.id != id); - save_channel_templates(&app, &templates) +pub async fn delete_channel_template(id: String, app: AppHandle) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + let template = templates + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("template {id} not found"))?; + validate_channel_template_deletion(template)?; + templates.retain(|record| record.id != id); + save_channel_templates(&app, &templates) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] -pub fn duplicate_channel_template( +pub async fn duplicate_channel_template( id: String, app: AppHandle, - state: State<'_, AppState>, ) -> Result { - let now = now_iso(); + tokio::task::spawn_blocking(move || { + let now = now_iso(); - let _store_guard = state - .channel_templates_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut templates = load_channel_templates(&app)?; - let source = templates - .iter() - .find(|record| record.id == id) - .ok_or_else(|| format!("template {id} not found"))? - .clone(); + let state = app.state::(); + let _store_guard = state + .channel_templates_store_lock + .lock() + .map_err(|error| error.to_string())?; + let mut templates = load_channel_templates(&app)?; + let source = templates + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("template {id} not found"))? + .clone(); - let duplicate = ChannelTemplateRecord { - id: Uuid::new_v4().to_string(), - name: format!("{} (Copy)", source.name), - is_builtin: false, - created_at: now.clone(), - updated_at: now, - ..source - }; + let duplicate = ChannelTemplateRecord { + id: Uuid::new_v4().to_string(), + name: format!("{} (Copy)", source.name), + is_builtin: false, + created_at: now.clone(), + updated_at: now, + ..source + }; - templates.push(duplicate.clone()); - save_channel_templates(&app, &templates)?; - Ok(duplicate) + templates.push(duplicate.clone()); + save_channel_templates(&app, &templates)?; + Ok(duplicate) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 2afa45c44..8a7abb46c 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -165,43 +165,48 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result { } #[tauri::command] -pub fn import_identity( +pub async fn import_identity( nsec: String, app_handle: tauri::AppHandle, - state: State<'_, AppState>, ) -> Result { - let trimmed = nsec.trim(); - let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; + tokio::task::spawn_blocking(move || { + let trimmed = nsec.trim(); + let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; - // Persist to identity.key - let data_dir = app_handle - .path() - .app_data_dir() - .map_err(|e| format!("app data dir: {e}"))?; - std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; - let key_path = data_dir.join("identity.key"); - crate::app_state::save_key_file(&key_path, &keys)?; + // Persist to identity.key before swapping in-memory state. If the disk + // write fails, the running app keeps the old identity. + let data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("app data dir: {e}"))?; + std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; + let key_path = data_dir.join("identity.key"); + crate::app_state::save_key_file(&key_path, &keys)?; - // Update in-memory keys - let pubkey = keys.public_key(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; + // Update in-memory keys only after persistence succeeds. + let state = app_handle.state::(); + let pubkey = keys.public_key(); + *state.keys.lock().map_err(|e| e.to_string())? = keys; - let pubkey_hex = pubkey.to_hex(); - let bech32 = pubkey - .to_bech32() - .map_err(|error| format!("bech32 encode failed: {error}"))?; - let display_name = if bech32.len() > 16 { - format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) - } else { - bech32 - }; + let pubkey_hex = pubkey.to_hex(); + let bech32 = pubkey + .to_bech32() + .map_err(|error| format!("bech32 encode failed: {error}"))?; + let display_name = if bech32.len() > 16 { + format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) + } else { + bech32 + }; - eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex); + eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex); - Ok(IdentityInfo { - pubkey: pubkey_hex, - display_name, + Ok(IdentityInfo { + pubkey: pubkey_hex, + display_name, + }) }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/legacy_storage.rs b/desktop/src-tauri/src/commands/legacy_storage.rs index eebd083dc..fa7854d02 100644 --- a/desktop/src-tauri/src/commands/legacy_storage.rs +++ b/desktop/src-tauri/src/commands/legacy_storage.rs @@ -171,34 +171,39 @@ fn merge_legacy_workspace_storage( /// under `~/Library/WebKit//...` on macOS and is not included in the /// app data directory. #[tauri::command] -pub fn get_legacy_workspace_storage( +pub async fn get_legacy_workspace_storage( app: tauri::AppHandle, ) -> Result { - let Some(identifier) = legacy_identifier(&app.config().identifier) else { - return Ok(LegacyWorkspaceStorage::default()); - }; - let Some(root) = legacy_webkit_data_root(&identifier) else { - return Ok(LegacyWorkspaceStorage::default()); - }; - if !root.exists() { - return Ok(LegacyWorkspaceStorage::default()); - } - - let mut databases = Vec::new(); - collect_local_storage_databases(&root, &mut databases); - - let mut result = LegacyWorkspaceStorage::default(); - for database in databases { - match read_legacy_workspace_storage_db(&database) { - Ok(storage) => merge_legacy_workspace_storage(&mut result, storage), - Err(error) => eprintln!( - "buzz-desktop: legacy-local-storage-migration: {}: {error}", - database.display() - ), + let identifier = app.config().identifier.clone(); + tokio::task::spawn_blocking(move || { + let Some(identifier) = legacy_identifier(&identifier) else { + return Ok(LegacyWorkspaceStorage::default()); + }; + let Some(root) = legacy_webkit_data_root(&identifier) else { + return Ok(LegacyWorkspaceStorage::default()); + }; + if !root.exists() { + return Ok(LegacyWorkspaceStorage::default()); } - } - Ok(result) + let mut databases = Vec::new(); + collect_local_storage_databases(&root, &mut databases); + + let mut result = LegacyWorkspaceStorage::default(); + for database in databases { + match read_legacy_workspace_storage_db(&database) { + Ok(storage) => merge_legacy_workspace_storage(&mut result, storage), + Err(error) => eprintln!( + "buzz-desktop: legacy-local-storage-migration: {}: {error}", + database.display() + ), + } + } + + Ok(result) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 68f5e1e78..f601defa5 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ mod agent_config; mod agent_discovery; +mod agent_logs; mod agent_metric_archive; mod agent_models; mod agent_providers; @@ -43,6 +44,7 @@ mod workspace; pub use agent_config::*; pub use agent_discovery::*; +pub use agent_logs::*; pub use agent_metric_archive::*; pub use agent_models::*; pub use agent_providers::*; diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index b140050f4..4f91c9adf 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use uuid::Uuid; use super::export_util::save_json_with_dialog; @@ -466,10 +466,18 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { /// a pending local edit leaves the local record — and its queued publish — /// untouched. #[tauri::command] -pub fn reconcile_inbound_persona_event( +pub async fn reconcile_inbound_persona_event( + event_json: String, + app: AppHandle, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || reconcile_inbound_persona_event_blocking(event_json, app)) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +fn reconcile_inbound_persona_event_blocking( event_json: String, app: AppHandle, - state: State<'_, AppState>, ) -> Result<(), String> { use crate::managed_agents::{ agent_events::managed_agent_content_from_event, @@ -482,6 +490,7 @@ pub fn reconcile_inbound_persona_event( use buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM}; use nostr::JsonUtil; + let state = app.state::(); let event = nostr::Event::from_json(&event_json) .map_err(|e| format!("failed to parse inbound event: {e}"))?; @@ -847,29 +856,47 @@ const ZIP_MAGIC: [u8; 4] = [0x50, 0x4B, 0x03, 0x04]; const JSON_OPEN_BRACE: u8 = 0x7B; #[tauri::command] -pub fn parse_persona_files( +pub async fn parse_persona_files( file_bytes: Vec, file_name: String, ) -> Result { - if file_bytes.len() > MAX_ZIP_BYTES { - return Err("File is too large (max 100 MB).".to_string()); - } - if file_bytes.is_empty() { - return Err("File is empty.".to_string()); - } + tokio::task::spawn_blocking(move || { + if file_bytes.len() > MAX_ZIP_BYTES { + return Err("File is too large (max 100 MB).".to_string()); + } + if file_bytes.is_empty() { + return Err("File is empty.".to_string()); + } - let first_byte = file_bytes[0]; + let first_byte = file_bytes[0]; - if file_bytes.len() >= 4 { - let magic: [u8; 4] = file_bytes[..4] - .try_into() - .map_err(|_| "Failed to read file header".to_string())?; + if file_bytes.len() >= 4 { + let magic: [u8; 4] = file_bytes[..4] + .try_into() + .map_err(|_| "Failed to read file header".to_string())?; - if magic == PNG_MAGIC { - if file_bytes.len() > MAX_PNG_BYTES { - return Err("PNG file is too large (max 10 MB).".to_string()); + if magic == PNG_MAGIC { + if file_bytes.len() > MAX_PNG_BYTES { + return Err("PNG file is too large (max 10 MB).".to_string()); + } + let mut preview = parse_png_persona(&file_bytes)?; + preview.source_file = file_name; + return Ok(ParsePersonaFilesResult { + personas: vec![preview], + skipped: vec![], + }); } - let mut preview = parse_png_persona(&file_bytes)?; + + if magic == ZIP_MAGIC { + return parse_zip_personas(&file_bytes); + } + } + + if first_byte == JSON_OPEN_BRACE { + if file_bytes.len() > MAX_JSON_BYTES { + return Err("JSON file is too large (max 5 MB).".to_string()); + } + let mut preview = parse_json_persona(&file_bytes)?; preview.source_file = file_name; return Ok(ParsePersonaFilesResult { personas: vec![preview], @@ -877,48 +904,34 @@ pub fn parse_persona_files( }); } - if magic == ZIP_MAGIC { - return parse_zip_personas(&file_bytes); + // .persona.md: YAML frontmatter starts with "---" + let lower_name = file_name.to_ascii_lowercase(); + if lower_name.ends_with(".persona.md") { + if file_bytes.len() > MAX_JSON_BYTES { + return Err("Markdown file is too large (max 5 MB).".to_string()); + } + let mut preview = parse_md_persona(&file_bytes)?; + preview.source_file = file_name; + return Ok(ParsePersonaFilesResult { + personas: vec![preview], + skipped: vec![], + }); } - } - if first_byte == JSON_OPEN_BRACE { - if file_bytes.len() > MAX_JSON_BYTES { - return Err("JSON file is too large (max 5 MB).".to_string()); + // If it's a .md file but not .persona.md, give a specific hint. + if lower_name.ends_with(".md") { + return Err( + "Only .persona.md files are supported. Rename to .persona.md".to_string(), + ); } - let mut preview = parse_json_persona(&file_bytes)?; - preview.source_file = file_name; - return Ok(ParsePersonaFilesResult { - personas: vec![preview], - skipped: vec![], - }); - } - // .persona.md: YAML frontmatter starts with "---" - let lower_name = file_name.to_ascii_lowercase(); - if lower_name.ends_with(".persona.md") { - if file_bytes.len() > MAX_JSON_BYTES { - return Err("Markdown file is too large (max 5 MB).".to_string()); - } - let mut preview = parse_md_persona(&file_bytes)?; - preview.source_file = file_name; - return Ok(ParsePersonaFilesResult { - personas: vec![preview], - skipped: vec![], - }); - } - - // If it's a .md file but not .persona.md, give a specific hint. - if lower_name.ends_with(".md") { - return Err( - "Only .persona.md files are supported. Rename to .persona.md".to_string(), - ); - } - - Err( - "Unsupported file format. Expected .persona.md, .persona.png, .persona.json, or .zip" - .to_string(), - ) + Err( + "Unsupported file format. Expected .persona.md, .persona.png, .persona.json, or .zip" + .to_string(), + ) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index 3666d21bd..aeeddc7e5 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -336,27 +336,31 @@ pub async fn export_team_to_json( const MAX_TEAM_ZIP_BYTES: usize = 100 * 1024 * 1024; #[tauri::command] -pub fn parse_team_file( +pub async fn parse_team_file( file_bytes: Vec, _file_name: String, ) -> Result { - if file_bytes.is_empty() { - return Err("File is empty.".to_string()); - } - - // Detect zip files (persona packs) BEFORE the JSON size check — zips can be larger. - if file_bytes.len() >= 4 && file_bytes[..4] == [0x50, 0x4B, 0x03, 0x04] { - if file_bytes.len() > MAX_TEAM_ZIP_BYTES { - return Err("ZIP file is too large (max 100 MB).".to_string()); + tokio::task::spawn_blocking(move || { + if file_bytes.is_empty() { + return Err("File is empty.".to_string()); } - return parse_team_from_pack_zip(&file_bytes); - } - if file_bytes.len() > MAX_TEAM_JSON_BYTES { - return Err("File is too large (max 5 MB).".to_string()); - } + // Detect zip files (persona packs) BEFORE the JSON size check — zips can be larger. + if file_bytes.len() >= 4 && file_bytes[..4] == [0x50, 0x4B, 0x03, 0x04] { + if file_bytes.len() > MAX_TEAM_ZIP_BYTES { + return Err("ZIP file is too large (max 100 MB).".to_string()); + } + return parse_team_from_pack_zip(&file_bytes); + } - parse_team_json(&file_bytes) + if file_bytes.len() > MAX_TEAM_JSON_BYTES { + return Err("File is too large (max 5 MB).".to_string()); + } + + parse_team_json(&file_bytes) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } /// Parse a persona pack zip as a team: pack name → team name, personas → members. diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index e0ab14a43..56100acf0 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,6 +1,6 @@ use nostr::Keys; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use crate::app_state::AppState; use crate::managed_agents::{ @@ -71,13 +71,17 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result Result<(), String> { - let trimmed = dir.trim(); - if trimmed.is_empty() { - return Ok(()); - } - let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; - crate::managed_agents::validate_repos_dir(&nest, trimmed).map(|_| ()) +pub async fn validate_repos_dir(dir: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let trimmed = dir.trim(); + if trimmed.is_empty() { + return Ok(()); + } + let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; + crate::managed_agents::validate_repos_dir(&nest, trimmed).map(|_| ()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } /// Apply a workspace's configuration to the backend session. @@ -94,73 +98,78 @@ pub fn validate_repos_dir(dir: String) -> Result<(), String> { /// already block a bad path at Save (`validate_repos_dir`); this fallback only /// catches a value that went bad after save (deleted dir, unmounted volume). #[tauri::command] -pub fn apply_workspace( +pub async fn apply_workspace( relay_url: String, nsec: Option, repos_dir: Option, app: AppHandle, - state: State<'_, AppState>, ) -> Result<(), String> { - // ── Validate before mutating ────────────────────────────────────────── - let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) { - Some(nsec_trimmed) => { - Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?) - } - None => None, - }; + tokio::task::spawn_blocking(move || { + let state = app.state::(); - // Decide the effective repos_dir from the candidate. A bad path does NOT - // reject — it is treated as if no override were set: relay/keys still - // apply, the bad value is not persisted, and a `repos-dir-error` surfaces - // the reason. Persisting a bad path would make every later boot read it, - // fail to resolve the symlink, and silently skip agent restore. One - // validate (inside `effective_repos_dir`) drives both the emit and the - // persisted value. `nest` is resolved softly: when absent there is nothing - // to persist or symlink, and relay/keys must still apply unconditionally. - let nest = nest_dir(); - let effective_repos_dir = match nest.as_deref() { - Some(nest) => match effective_repos_dir(nest, repos_dir.as_deref()) { - Ok(value) => value, - Err(error) => { - let _ = app.emit("repos-dir-error", error); - None + // ── Validate before mutating ────────────────────────────────────────── + let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(nsec_trimmed) => { + Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?) } - }, - None => None, - }; + None => None, + }; - // ── Apply all state changes (nothing below can fail) ────────────────── - { - let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; - *override_guard = Some(relay_url); - } + // Decide the effective repos_dir from the candidate. A bad path does NOT + // reject — it is treated as if no override were set: relay/keys still + // apply, the bad value is not persisted, and a `repos-dir-error` surfaces + // the reason. Persisting a bad path would make every later boot read it, + // fail to resolve the symlink, and silently skip agent restore. One + // validate (inside `effective_repos_dir`) drives both the emit and the + // persisted value. `nest` is resolved softly: when absent there is nothing + // to persist or symlink, and relay/keys must still apply unconditionally. + let nest = nest_dir(); + let effective_repos_dir = match nest.as_deref() { + Some(nest) => match effective_repos_dir(nest, repos_dir.as_deref()) { + Ok(value) => value, + Err(error) => { + let _ = app.emit("repos-dir-error", error); + None + } + }, + None => None, + }; - if let Some(keys) = parsed_keys { - let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?; - *keys_guard = keys; - } - - // ── Filesystem side-effect (non-fatal) ──────────────────────────────── - // Persist the *effective* repos_dir (None when the candidate failed - // validation) for the backend to read at boot, then re-point REPOS to - // match. Persisting first makes the dotfile authoritative even if the - // symlink apply fails here (e.g. a non-empty real REPOS): the next boot - // reads the persisted value and resolves the symlink before any agent can - // clone into REPOS. A bad candidate persists `None`, so the next boot is - // clean and agent restore proceeds. Failure of either must NOT fail the - // command — relay/keys are already applied. Surface symlink errors via - // `repos-dir-error`. - if let Some(nest) = nest.as_deref() { - if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) { - eprintln!("buzz-desktop: persist repos dir failed: {error}"); + // ── Apply all state changes (nothing below can fail) ────────────────── + { + let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; + *override_guard = Some(relay_url); } - if let Err(error) = ensure_repos_symlink(nest, effective_repos_dir.as_deref()) { - eprintln!("buzz-desktop: repos dir setup failed: {error}"); - let _ = app.emit("repos-dir-error", error); + + if let Some(keys) = parsed_keys { + let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?; + *keys_guard = keys; } - } - try_regenerate_nest(&app); + // ── Filesystem side-effect (non-fatal) ──────────────────────────────── + // Persist the *effective* repos_dir (None when the candidate failed + // validation) for the backend to read at boot, then re-point REPOS to + // match. Persisting first makes the dotfile authoritative even if the + // symlink apply fails here (e.g. a non-empty real REPOS): the next boot + // reads the persisted value and resolves the symlink before any agent can + // clone into REPOS. A bad candidate persists `None`, so the next boot is + // clean and agent restore proceeds. Failure of either must NOT fail the + // command — relay/keys are already applied. Surface symlink errors via + // `repos-dir-error`. + if let Some(nest) = nest.as_deref() { + if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) { + eprintln!("buzz-desktop: persist repos dir failed: {error}"); + } + if let Err(error) = ensure_repos_symlink(nest, effective_repos_dir.as_deref()) { + eprintln!("buzz-desktop: repos dir setup failed: {error}"); + let _ = app.emit("repos-dir-error", error); + } + } - Ok(()) + try_regenerate_nest(&app); + + Ok(()) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? }