mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): move blocking commands off the UI thread
Finding [L1] commands/** — several Tauri commands still performed filesystem, process discovery, sqlite/local-storage reads, zip/json parsing, managed-agent store work, and workspace symlink updates synchronously on the command thread. Convert the affected commands to async command handlers with explicit spawn_blocking around the blocking sections so the UI thread is not responsible for those operations. Reacquire AppState from the owned AppHandle inside blocking closures instead of moving borrowed State<'_, AppState> or non-Send guards across await points. Keep existing store mutex serialization inside the blocking closures and preserve command-specific ordering: identity import persists before swapping in-memory keys, workspace apply validates before mutation and persists the effective repos dir before symlink updates, and repos-dir-error emissions still use the AppHandle. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
co-authored by
Tyler Longwell
parent
4aac3916a2
commit
77bd0e700f
@@ -165,8 +165,10 @@ fn retag_persona_default(field: &mut Option<NormalizedField>) {
|
|||||||
/// Returns `null` when the runtime has no config file or it cannot be parsed.
|
/// Returns `null` when the runtime has no config file or it cannot be parsed.
|
||||||
/// Currently only "goose" is supported; other runtimes return `null`.
|
/// Currently only "goose" is supported; other runtimes return `null`.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_runtime_file_config(runtime_id: String) -> Option<RuntimeFileConfigSubset> {
|
pub async fn get_runtime_file_config(
|
||||||
match runtime_id.as_str() {
|
runtime_id: String,
|
||||||
|
) -> Result<Option<RuntimeFileConfigSubset>, String> {
|
||||||
|
tokio::task::spawn_blocking(move || match runtime_id.as_str() {
|
||||||
"goose" => {
|
"goose" => {
|
||||||
let cfg = read_goose_file_config()?;
|
let cfg = read_goose_file_config()?;
|
||||||
let satisfied_env_keys = cfg
|
let satisfied_env_keys = cfg
|
||||||
@@ -182,7 +184,9 @@ pub fn get_runtime_file_config(runtime_id: String) -> Option<RuntimeFileConfigSu
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the key names of all non-empty baked build env vars.
|
/// Return the key names of all non-empty baked build env vars.
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn discover_acp_providers() -> Vec<AcpRuntimeCatalogEntry> {
|
pub async fn discover_acp_providers() -> Result<Vec<AcpRuntimeCatalogEntry>, String> {
|
||||||
crate::managed_agents::clear_resolve_cache();
|
tokio::task::spawn_blocking(|| {
|
||||||
crate::managed_agents::discover_acp_runtimes()
|
crate::managed_agents::clear_resolve_cache();
|
||||||
|
crate::managed_agents::discover_acp_runtimes()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -292,26 +296,30 @@ fn floor_char_boundary(s: &str, mut index: usize) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn discover_managed_agent_prereqs(
|
pub async fn discover_managed_agent_prereqs(
|
||||||
input: DiscoverManagedAgentPrereqsRequest,
|
input: DiscoverManagedAgentPrereqsRequest,
|
||||||
) -> ManagedAgentPrereqsInfo {
|
) -> Result<ManagedAgentPrereqsInfo, String> {
|
||||||
let acp_command = input
|
tokio::task::spawn_blocking(move || {
|
||||||
.acp_command
|
let acp_command = input
|
||||||
.as_deref()
|
.acp_command
|
||||||
.map(str::trim)
|
.as_deref()
|
||||||
.filter(|value| !value.is_empty())
|
.map(str::trim)
|
||||||
.unwrap_or(DEFAULT_ACP_COMMAND);
|
.filter(|value| !value.is_empty())
|
||||||
let mcp_command = input
|
.unwrap_or(DEFAULT_ACP_COMMAND);
|
||||||
.mcp_command
|
let mcp_command = input
|
||||||
.as_deref()
|
.mcp_command
|
||||||
.map(str::trim)
|
.as_deref()
|
||||||
.filter(|value| !value.is_empty())
|
.map(str::trim)
|
||||||
.unwrap_or("");
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
ManagedAgentPrereqsInfo {
|
ManagedAgentPrereqsInfo {
|
||||||
acp: command_availability(acp_command),
|
acp: command_availability(acp_command),
|
||||||
mcp: command_availability(mcp_command),
|
mcp: command_availability(mcp_command),
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -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<u32>,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> Result<ManagedAgentLogResponse, String> {
|
||||||
|
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 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}"))?
|
||||||
|
}
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo};
|
use crate::managed_agents::{discover_provider_candidates, invoke_provider, BackendProviderInfo};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn discover_backend_providers() -> Vec<BackendProviderInfo> {
|
pub async fn discover_backend_providers() -> Result<Vec<BackendProviderInfo>, String> {
|
||||||
discover_provider_candidates()
|
tokio::task::spawn_blocking(|| {
|
||||||
.into_iter()
|
discover_provider_candidates()
|
||||||
.map(|(id, path)| BackendProviderInfo {
|
.into_iter()
|
||||||
id,
|
.map(|(id, path)| BackendProviderInfo {
|
||||||
binary_path: path.display().to_string(),
|
id,
|
||||||
})
|
binary_path: path.display().to_string(),
|
||||||
.collect()
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app_state::AppState,
|
app_state::AppState,
|
||||||
@@ -11,42 +11,46 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_managed_agent_start_on_app_launch(
|
pub async fn set_managed_agent_start_on_app_launch(
|
||||||
pubkey: String,
|
pubkey: String,
|
||||||
start_on_app_launch: bool,
|
start_on_app_launch: bool,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<ManagedAgentSummary, String> {
|
) -> Result<ManagedAgentSummary, String> {
|
||||||
let _store_guard = state
|
tokio::task::spawn_blocking(move || {
|
||||||
.managed_agents_store_lock
|
let state = app.state::<AppState>();
|
||||||
.lock()
|
let _store_guard = state
|
||||||
.map_err(|error| error.to_string())?;
|
.managed_agents_store_lock
|
||||||
let mut records = load_managed_agents(&app)?;
|
.lock()
|
||||||
let mut runtimes = state
|
.map_err(|error| error.to_string())?;
|
||||||
.managed_agent_processes
|
let mut records = load_managed_agents(&app)?;
|
||||||
.lock()
|
let mut runtimes = state
|
||||||
.map_err(|error| error.to_string())?;
|
.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)?;
|
save_managed_agents(&app, &records)?;
|
||||||
}
|
let record = records
|
||||||
for pubkey in &exited_pubkeys {
|
.iter()
|
||||||
state.clear_session_cache(pubkey);
|
.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 = find_managed_agent_mut(&mut records, &pubkey)?;
|
})
|
||||||
record.start_on_app_launch = start_on_app_launch;
|
.await
|
||||||
record.updated_at = now_iso();
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ use crate::{
|
|||||||
managed_agents::{
|
managed_agents::{
|
||||||
build_managed_agent_summary, current_instance_id, discover_provider_candidates,
|
build_managed_agent_summary, current_instance_id, discover_provider_candidates,
|
||||||
ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas,
|
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,
|
managed_agent_avatar_url, managed_agents_base_dir, normalize_agent_args, provider_deploy,
|
||||||
normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary,
|
resolve_provider_binary, save_managed_agents, start_managed_agent_process,
|
||||||
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
|
stop_managed_agent_process, sync_managed_agent_processes, try_regenerate_nest,
|
||||||
sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
|
validate_provider_config, BackendKind, CreateManagedAgentRequest,
|
||||||
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse,
|
CreateManagedAgentResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
|
||||||
ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND,
|
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
||||||
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
|
|
||||||
},
|
},
|
||||||
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
|
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
|
||||||
util::now_iso,
|
util::now_iso,
|
||||||
@@ -1250,33 +1249,6 @@ pub async fn delete_managed_agent(
|
|||||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn get_managed_agent_log(
|
|
||||||
pubkey: String,
|
|
||||||
line_count: Option<u32>,
|
|
||||||
app: AppHandle,
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<ManagedAgentLogResponse, String> {
|
|
||||||
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:
|
// Remote agent shutdown is handled entirely by the frontend:
|
||||||
// 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key)
|
// 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key)
|
||||||
// 2. Harness sees it, exits gracefully, sets presence to "offline"
|
// 2. Harness sees it, exits gracefully, sets presence to "offline"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, Manager};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -44,142 +44,157 @@ fn validate_visibility(value: &str) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_channel_templates(
|
pub async fn list_channel_templates(app: AppHandle) -> Result<Vec<ChannelTemplateRecord>, String> {
|
||||||
app: AppHandle,
|
tokio::task::spawn_blocking(move || {
|
||||||
state: State<'_, AppState>,
|
let state = app.state::<AppState>();
|
||||||
) -> Result<Vec<ChannelTemplateRecord>, String> {
|
let _store_guard = state
|
||||||
let _store_guard = state
|
.channel_templates_store_lock
|
||||||
.channel_templates_store_lock
|
.lock()
|
||||||
.lock()
|
.map_err(|error| error.to_string())?;
|
||||||
.map_err(|error| error.to_string())?;
|
load_channel_templates(&app)
|
||||||
load_channel_templates(&app)
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn create_channel_template(
|
pub async fn create_channel_template(
|
||||||
input: CreateChannelTemplateRequest,
|
input: CreateChannelTemplateRequest,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<ChannelTemplateRecord, String> {
|
) -> Result<ChannelTemplateRecord, String> {
|
||||||
let name = trim_required(&input.name, "Template name")?;
|
tokio::task::spawn_blocking(move || {
|
||||||
let description = trim_optional(input.description);
|
let name = trim_required(&input.name, "Template name")?;
|
||||||
let canvas_template = trim_optional(input.canvas_template);
|
let description = trim_optional(input.description);
|
||||||
let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string());
|
let canvas_template = trim_optional(input.canvas_template);
|
||||||
let visibility = input.visibility.unwrap_or_else(|| "open".to_string());
|
let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string());
|
||||||
validate_channel_type(&channel_type)?;
|
let visibility = input.visibility.unwrap_or_else(|| "open".to_string());
|
||||||
validate_visibility(&visibility)?;
|
validate_channel_type(&channel_type)?;
|
||||||
let now = now_iso();
|
validate_visibility(&visibility)?;
|
||||||
|
let now = now_iso();
|
||||||
|
|
||||||
let _store_guard = state
|
let state = app.state::<AppState>();
|
||||||
.channel_templates_store_lock
|
let _store_guard = state
|
||||||
.lock()
|
.channel_templates_store_lock
|
||||||
.map_err(|error| error.to_string())?;
|
.lock()
|
||||||
let mut templates = load_channel_templates(&app)?;
|
.map_err(|error| error.to_string())?;
|
||||||
|
let mut templates = load_channel_templates(&app)?;
|
||||||
|
|
||||||
let template = ChannelTemplateRecord {
|
let template = ChannelTemplateRecord {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
channel_type,
|
channel_type,
|
||||||
visibility,
|
visibility,
|
||||||
canvas_template,
|
canvas_template,
|
||||||
agents: input.agents,
|
agents: input.agents,
|
||||||
is_builtin: false,
|
is_builtin: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
templates.push(template.clone());
|
templates.push(template.clone());
|
||||||
save_channel_templates(&app, &templates)?;
|
save_channel_templates(&app, &templates)?;
|
||||||
Ok(template)
|
Ok(template)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn update_channel_template(
|
pub async fn update_channel_template(
|
||||||
input: UpdateChannelTemplateRequest,
|
input: UpdateChannelTemplateRequest,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<ChannelTemplateRecord, String> {
|
) -> Result<ChannelTemplateRecord, String> {
|
||||||
let name = trim_required(&input.name, "Template name")?;
|
tokio::task::spawn_blocking(move || {
|
||||||
let description = trim_optional(input.description);
|
let name = trim_required(&input.name, "Template name")?;
|
||||||
let canvas_template = trim_optional(input.canvas_template);
|
let description = trim_optional(input.description);
|
||||||
let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string());
|
let canvas_template = trim_optional(input.canvas_template);
|
||||||
let visibility = input.visibility.unwrap_or_else(|| "open".to_string());
|
let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string());
|
||||||
validate_channel_type(&channel_type)?;
|
let visibility = input.visibility.unwrap_or_else(|| "open".to_string());
|
||||||
validate_visibility(&visibility)?;
|
validate_channel_type(&channel_type)?;
|
||||||
|
validate_visibility(&visibility)?;
|
||||||
|
|
||||||
let _store_guard = state
|
let state = app.state::<AppState>();
|
||||||
.channel_templates_store_lock
|
let _store_guard = state
|
||||||
.lock()
|
.channel_templates_store_lock
|
||||||
.map_err(|error| error.to_string())?;
|
.lock()
|
||||||
let mut templates = load_channel_templates(&app)?;
|
.map_err(|error| error.to_string())?;
|
||||||
let template = templates
|
let mut templates = load_channel_templates(&app)?;
|
||||||
.iter_mut()
|
let template = templates
|
||||||
.find(|record| record.id == input.id)
|
.iter_mut()
|
||||||
.ok_or_else(|| format!("template {} not found", input.id))?;
|
.find(|record| record.id == input.id)
|
||||||
|
.ok_or_else(|| format!("template {} not found", input.id))?;
|
||||||
|
|
||||||
template.name = name;
|
template.name = name;
|
||||||
template.description = description;
|
template.description = description;
|
||||||
template.channel_type = channel_type;
|
template.channel_type = channel_type;
|
||||||
template.visibility = visibility;
|
template.visibility = visibility;
|
||||||
template.canvas_template = canvas_template;
|
template.canvas_template = canvas_template;
|
||||||
template.agents = input.agents;
|
template.agents = input.agents;
|
||||||
template.updated_at = now_iso();
|
template.updated_at = now_iso();
|
||||||
|
|
||||||
let updated = template.clone();
|
let updated = template.clone();
|
||||||
save_channel_templates(&app, &templates)?;
|
save_channel_templates(&app, &templates)?;
|
||||||
Ok(updated)
|
Ok(updated)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_channel_template(
|
pub async fn delete_channel_template(id: String, app: AppHandle) -> Result<(), String> {
|
||||||
id: String,
|
tokio::task::spawn_blocking(move || {
|
||||||
app: AppHandle,
|
let state = app.state::<AppState>();
|
||||||
state: State<'_, AppState>,
|
let _store_guard = state
|
||||||
) -> Result<(), String> {
|
.channel_templates_store_lock
|
||||||
let _store_guard = state
|
.lock()
|
||||||
.channel_templates_store_lock
|
.map_err(|error| error.to_string())?;
|
||||||
.lock()
|
let mut templates = load_channel_templates(&app)?;
|
||||||
.map_err(|error| error.to_string())?;
|
let template = templates
|
||||||
let mut templates = load_channel_templates(&app)?;
|
.iter()
|
||||||
let template = templates
|
.find(|record| record.id == id)
|
||||||
.iter()
|
.ok_or_else(|| format!("template {id} not found"))?;
|
||||||
.find(|record| record.id == id)
|
validate_channel_template_deletion(template)?;
|
||||||
.ok_or_else(|| format!("template {id} not found"))?;
|
templates.retain(|record| record.id != id);
|
||||||
validate_channel_template_deletion(template)?;
|
save_channel_templates(&app, &templates)
|
||||||
templates.retain(|record| record.id != id);
|
})
|
||||||
save_channel_templates(&app, &templates)
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn duplicate_channel_template(
|
pub async fn duplicate_channel_template(
|
||||||
id: String,
|
id: String,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<ChannelTemplateRecord, String> {
|
) -> Result<ChannelTemplateRecord, String> {
|
||||||
let now = now_iso();
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let now = now_iso();
|
||||||
|
|
||||||
let _store_guard = state
|
let state = app.state::<AppState>();
|
||||||
.channel_templates_store_lock
|
let _store_guard = state
|
||||||
.lock()
|
.channel_templates_store_lock
|
||||||
.map_err(|error| error.to_string())?;
|
.lock()
|
||||||
let mut templates = load_channel_templates(&app)?;
|
.map_err(|error| error.to_string())?;
|
||||||
let source = templates
|
let mut templates = load_channel_templates(&app)?;
|
||||||
.iter()
|
let source = templates
|
||||||
.find(|record| record.id == id)
|
.iter()
|
||||||
.ok_or_else(|| format!("template {id} not found"))?
|
.find(|record| record.id == id)
|
||||||
.clone();
|
.ok_or_else(|| format!("template {id} not found"))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
let duplicate = ChannelTemplateRecord {
|
let duplicate = ChannelTemplateRecord {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
name: format!("{} (Copy)", source.name),
|
name: format!("{} (Copy)", source.name),
|
||||||
is_builtin: false,
|
is_builtin: false,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
..source
|
..source
|
||||||
};
|
};
|
||||||
|
|
||||||
templates.push(duplicate.clone());
|
templates.push(duplicate.clone());
|
||||||
save_channel_templates(&app, &templates)?;
|
save_channel_templates(&app, &templates)?;
|
||||||
Ok(duplicate)
|
Ok(duplicate)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,43 +165,48 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn import_identity(
|
pub async fn import_identity(
|
||||||
nsec: String,
|
nsec: String,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<IdentityInfo, String> {
|
) -> Result<IdentityInfo, String> {
|
||||||
let trimmed = nsec.trim();
|
tokio::task::spawn_blocking(move || {
|
||||||
let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?;
|
let trimmed = nsec.trim();
|
||||||
|
let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?;
|
||||||
|
|
||||||
// Persist to identity.key
|
// Persist to identity.key before swapping in-memory state. If the disk
|
||||||
let data_dir = app_handle
|
// write fails, the running app keeps the old identity.
|
||||||
.path()
|
let data_dir = app_handle
|
||||||
.app_data_dir()
|
.path()
|
||||||
.map_err(|e| format!("app data dir: {e}"))?;
|
.app_data_dir()
|
||||||
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
|
.map_err(|e| format!("app data dir: {e}"))?;
|
||||||
let key_path = data_dir.join("identity.key");
|
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
|
||||||
crate::app_state::save_key_file(&key_path, &keys)?;
|
let key_path = data_dir.join("identity.key");
|
||||||
|
crate::app_state::save_key_file(&key_path, &keys)?;
|
||||||
|
|
||||||
// Update in-memory keys
|
// Update in-memory keys only after persistence succeeds.
|
||||||
let pubkey = keys.public_key();
|
let state = app_handle.state::<AppState>();
|
||||||
*state.keys.lock().map_err(|e| e.to_string())? = keys;
|
let pubkey = keys.public_key();
|
||||||
|
*state.keys.lock().map_err(|e| e.to_string())? = keys;
|
||||||
|
|
||||||
let pubkey_hex = pubkey.to_hex();
|
let pubkey_hex = pubkey.to_hex();
|
||||||
let bech32 = pubkey
|
let bech32 = pubkey
|
||||||
.to_bech32()
|
.to_bech32()
|
||||||
.map_err(|error| format!("bech32 encode failed: {error}"))?;
|
.map_err(|error| format!("bech32 encode failed: {error}"))?;
|
||||||
let display_name = if bech32.len() > 16 {
|
let display_name = if bech32.len() > 16 {
|
||||||
format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..])
|
format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..])
|
||||||
} else {
|
} else {
|
||||||
bech32
|
bech32
|
||||||
};
|
};
|
||||||
|
|
||||||
eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex);
|
eprintln!("buzz-desktop: imported identity pubkey {}", pubkey_hex);
|
||||||
|
|
||||||
Ok(IdentityInfo {
|
Ok(IdentityInfo {
|
||||||
pubkey: pubkey_hex,
|
pubkey: pubkey_hex,
|
||||||
display_name,
|
display_name,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -171,34 +171,39 @@ fn merge_legacy_workspace_storage(
|
|||||||
/// under `~/Library/WebKit/<identifier>/...` on macOS and is not included in the
|
/// under `~/Library/WebKit/<identifier>/...` on macOS and is not included in the
|
||||||
/// app data directory.
|
/// app data directory.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_legacy_workspace_storage(
|
pub async fn get_legacy_workspace_storage(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
) -> Result<LegacyWorkspaceStorage, String> {
|
) -> Result<LegacyWorkspaceStorage, String> {
|
||||||
let Some(identifier) = legacy_identifier(&app.config().identifier) else {
|
let identifier = app.config().identifier.clone();
|
||||||
return Ok(LegacyWorkspaceStorage::default());
|
tokio::task::spawn_blocking(move || {
|
||||||
};
|
let Some(identifier) = legacy_identifier(&identifier) else {
|
||||||
let Some(root) = legacy_webkit_data_root(&identifier) else {
|
return Ok(LegacyWorkspaceStorage::default());
|
||||||
return Ok(LegacyWorkspaceStorage::default());
|
};
|
||||||
};
|
let Some(root) = legacy_webkit_data_root(&identifier) else {
|
||||||
if !root.exists() {
|
return Ok(LegacyWorkspaceStorage::default());
|
||||||
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()
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod agent_config;
|
mod agent_config;
|
||||||
mod agent_discovery;
|
mod agent_discovery;
|
||||||
|
mod agent_logs;
|
||||||
mod agent_metric_archive;
|
mod agent_metric_archive;
|
||||||
mod agent_models;
|
mod agent_models;
|
||||||
mod agent_providers;
|
mod agent_providers;
|
||||||
@@ -43,6 +44,7 @@ mod workspace;
|
|||||||
|
|
||||||
pub use agent_config::*;
|
pub use agent_config::*;
|
||||||
pub use agent_discovery::*;
|
pub use agent_discovery::*;
|
||||||
|
pub use agent_logs::*;
|
||||||
pub use agent_metric_archive::*;
|
pub use agent_metric_archive::*;
|
||||||
pub use agent_models::*;
|
pub use agent_models::*;
|
||||||
pub use agent_providers::*;
|
pub use agent_providers::*;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::export_util::save_json_with_dialog;
|
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 —
|
/// a pending local edit leaves the local record — and its queued publish —
|
||||||
/// untouched.
|
/// untouched.
|
||||||
#[tauri::command]
|
#[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,
|
event_json: String,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use crate::managed_agents::{
|
use crate::managed_agents::{
|
||||||
agent_events::managed_agent_content_from_event,
|
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 buzz_core_pkg::kind::{KIND_DELETION, KIND_MANAGED_AGENT, KIND_PERSONA, KIND_TEAM};
|
||||||
use nostr::JsonUtil;
|
use nostr::JsonUtil;
|
||||||
|
|
||||||
|
let state = app.state::<AppState>();
|
||||||
let event = nostr::Event::from_json(&event_json)
|
let event = nostr::Event::from_json(&event_json)
|
||||||
.map_err(|e| format!("failed to parse inbound event: {e}"))?;
|
.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;
|
const JSON_OPEN_BRACE: u8 = 0x7B;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn parse_persona_files(
|
pub async fn parse_persona_files(
|
||||||
file_bytes: Vec<u8>,
|
file_bytes: Vec<u8>,
|
||||||
file_name: String,
|
file_name: String,
|
||||||
) -> Result<ParsePersonaFilesResult, String> {
|
) -> Result<ParsePersonaFilesResult, String> {
|
||||||
if file_bytes.len() > MAX_ZIP_BYTES {
|
tokio::task::spawn_blocking(move || {
|
||||||
return Err("File is too large (max 100 MB).".to_string());
|
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());
|
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 {
|
if file_bytes.len() >= 4 {
|
||||||
let magic: [u8; 4] = file_bytes[..4]
|
let magic: [u8; 4] = file_bytes[..4]
|
||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|_| "Failed to read file header".to_string())?;
|
.map_err(|_| "Failed to read file header".to_string())?;
|
||||||
|
|
||||||
if magic == PNG_MAGIC {
|
if magic == PNG_MAGIC {
|
||||||
if file_bytes.len() > MAX_PNG_BYTES {
|
if file_bytes.len() > MAX_PNG_BYTES {
|
||||||
return Err("PNG file is too large (max 10 MB).".to_string());
|
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;
|
preview.source_file = file_name;
|
||||||
return Ok(ParsePersonaFilesResult {
|
return Ok(ParsePersonaFilesResult {
|
||||||
personas: vec![preview],
|
personas: vec![preview],
|
||||||
@@ -877,48 +904,34 @@ pub fn parse_persona_files(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if magic == ZIP_MAGIC {
|
// .persona.md: YAML frontmatter starts with "---"
|
||||||
return parse_zip_personas(&file_bytes);
|
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 it's a .md file but not .persona.md, give a specific hint.
|
||||||
if file_bytes.len() > MAX_JSON_BYTES {
|
if lower_name.ends_with(".md") {
|
||||||
return Err("JSON file is too large (max 5 MB).".to_string());
|
return Err(
|
||||||
|
"Only .persona.md files are supported. Rename to <name>.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 "---"
|
Err(
|
||||||
let lower_name = file_name.to_ascii_lowercase();
|
"Unsupported file format. Expected .persona.md, .persona.png, .persona.json, or .zip"
|
||||||
if lower_name.ends_with(".persona.md") {
|
.to_string(),
|
||||||
if file_bytes.len() > MAX_JSON_BYTES {
|
)
|
||||||
return Err("Markdown file is too large (max 5 MB).".to_string());
|
})
|
||||||
}
|
.await
|
||||||
let mut preview = parse_md_persona(&file_bytes)?;
|
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||||
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 <name>.persona.md".to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(
|
|
||||||
"Unsupported file format. Expected .persona.md, .persona.png, .persona.json, or .zip"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -336,27 +336,31 @@ pub async fn export_team_to_json(
|
|||||||
const MAX_TEAM_ZIP_BYTES: usize = 100 * 1024 * 1024;
|
const MAX_TEAM_ZIP_BYTES: usize = 100 * 1024 * 1024;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn parse_team_file(
|
pub async fn parse_team_file(
|
||||||
file_bytes: Vec<u8>,
|
file_bytes: Vec<u8>,
|
||||||
_file_name: String,
|
_file_name: String,
|
||||||
) -> Result<ParsedTeamPreview, String> {
|
) -> Result<ParsedTeamPreview, String> {
|
||||||
if file_bytes.is_empty() {
|
tokio::task::spawn_blocking(move || {
|
||||||
return Err("File is empty.".to_string());
|
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());
|
|
||||||
}
|
}
|
||||||
return parse_team_from_pack_zip(&file_bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
if file_bytes.len() > MAX_TEAM_JSON_BYTES {
|
// Detect zip files (persona packs) BEFORE the JSON size check — zips can be larger.
|
||||||
return Err("File is too large (max 5 MB).".to_string());
|
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.
|
/// Parse a persona pack zip as a team: pack name → team name, personas → members.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use nostr::Keys;
|
use nostr::Keys;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, Manager, State};
|
||||||
|
|
||||||
use crate::app_state::AppState;
|
use crate::app_state::AppState;
|
||||||
use crate::managed_agents::{
|
use crate::managed_agents::{
|
||||||
@@ -71,13 +71,17 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result<ActiveWorkspac
|
|||||||
/// "what's a valid repos dir". An empty/whitespace value clears the override
|
/// "what's a valid repos dir". An empty/whitespace value clears the override
|
||||||
/// and is valid. `Err` carries the human-readable reason for inline display.
|
/// and is valid. `Err` carries the human-readable reason for inline display.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn validate_repos_dir(dir: String) -> Result<(), String> {
|
pub async fn validate_repos_dir(dir: String) -> Result<(), String> {
|
||||||
let trimmed = dir.trim();
|
tokio::task::spawn_blocking(move || {
|
||||||
if trimmed.is_empty() {
|
let trimmed = dir.trim();
|
||||||
return Ok(());
|
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(|_| ())
|
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.
|
/// 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
|
/// 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).
|
/// catches a value that went bad after save (deleted dir, unmounted volume).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn apply_workspace(
|
pub async fn apply_workspace(
|
||||||
relay_url: String,
|
relay_url: String,
|
||||||
nsec: Option<String>,
|
nsec: Option<String>,
|
||||||
repos_dir: Option<String>,
|
repos_dir: Option<String>,
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// ── Validate before mutating ──────────────────────────────────────────
|
tokio::task::spawn_blocking(move || {
|
||||||
let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
let state = app.state::<AppState>();
|
||||||
Some(nsec_trimmed) => {
|
|
||||||
Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?)
|
|
||||||
}
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Decide the effective repos_dir from the candidate. A bad path does NOT
|
// ── Validate before mutating ──────────────────────────────────────────
|
||||||
// reject — it is treated as if no override were set: relay/keys still
|
let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
// apply, the bad value is not persisted, and a `repos-dir-error` surfaces
|
Some(nsec_trimmed) => {
|
||||||
// the reason. Persisting a bad path would make every later boot read it,
|
Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?)
|
||||||
// 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,
|
||||||
None => None,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// ── Apply all state changes (nothing below can fail) ──────────────────
|
// 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
|
||||||
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
|
// apply, the bad value is not persisted, and a `repos-dir-error` surfaces
|
||||||
*override_guard = Some(relay_url);
|
// 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 {
|
// ── Apply all state changes (nothing below can fail) ──────────────────
|
||||||
let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
|
{
|
||||||
*keys_guard = keys;
|
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
|
||||||
}
|
*override_guard = Some(relay_url);
|
||||||
|
|
||||||
// ── 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}");
|
if let Some(keys) = parsed_keys {
|
||||||
let _ = app.emit("repos-dir-error", error);
|
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}"))?
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user