mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): GUI performance sweep — async offload, poll reduction, render stabilization (#1641)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
Tyler Longwell
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6
npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf
npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr
npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta
parent
11608ef675
commit
2cc0eb5396
@@ -57,6 +57,19 @@ fn now_secs() -> i64 {
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
async fn run_archive_db_task<T, F>(task: F) -> Result<T, String>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&Connection) -> Result<T, String> + Send + 'static,
|
||||
{
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = open_db()?;
|
||||
task(&conn)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
// ── Scope type ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// The three supported archive scope discriminants.
|
||||
@@ -118,9 +131,9 @@ pub struct ArchiveBatchResult {
|
||||
///
|
||||
/// # Send-safety
|
||||
///
|
||||
/// `rusqlite::Connection` is `!Send`. All DB work is bracketed in scoped
|
||||
/// `{ let conn = open_db()?; ... }` blocks that drop the connection before any
|
||||
/// `.await`, exactly matching the pattern in `managed_agents/persona_events.rs`.
|
||||
/// SQLite planning and commit run on the blocking pool. Each phase opens and
|
||||
/// drops its own `rusqlite::Connection` inside the blocking closure; no
|
||||
/// connection, transaction, or lock is held across the relay-query await.
|
||||
#[tauri::command]
|
||||
pub async fn archive_events(
|
||||
state: State<'_, AppState>,
|
||||
@@ -130,37 +143,39 @@ pub async fn archive_events(
|
||||
let relay_url = relay_ws_url_with_override(&state);
|
||||
let now = now_secs();
|
||||
|
||||
// ── Phase 1: plan (sync) ─────────────────────────────────────────────────
|
||||
// Read subscriptions and build relay filters. Connection dropped before
|
||||
// any .await.
|
||||
let plan = {
|
||||
let conn = open_db()?;
|
||||
plan_archive(candidates, &identity_pk, &relay_url, &conn)?
|
||||
// conn drops here
|
||||
};
|
||||
// ── Phase 1: plan (blocking SQLite) ─────────────────────────────────────
|
||||
let plan_identity_pk = identity_pk.clone();
|
||||
let plan_relay_url = relay_url.clone();
|
||||
let plan = run_archive_db_task(move |conn| {
|
||||
plan_archive(candidates, &plan_identity_pk, &plan_relay_url, conn)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// ── Phase 2: relay queries (async) ───────────────────────────────────────
|
||||
// No Connection in scope — future is Send.
|
||||
let state_ref: &AppState = &state;
|
||||
let bucket_results = query_buckets(plan.buckets, state_ref).await;
|
||||
|
||||
// ── Phase 3: persist (sync) ──────────────────────────────────────────────
|
||||
let conn = open_db()?;
|
||||
// ── Phase 3: persist (blocking SQLite) ──────────────────────────────────
|
||||
let owner_keys = {
|
||||
let keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
keys_guard.clone()
|
||||
// guard drops here
|
||||
// guard drops here, before awaiting the blocking commit task.
|
||||
};
|
||||
commit_archive(
|
||||
bucket_results,
|
||||
plan.ephemeral,
|
||||
plan.pre_dropped,
|
||||
&identity_pk,
|
||||
&relay_url,
|
||||
&owner_keys,
|
||||
now,
|
||||
&conn,
|
||||
)
|
||||
let commit_identity_pk = identity_pk.clone();
|
||||
let commit_relay_url = relay_url.clone();
|
||||
run_archive_db_task(move |conn| {
|
||||
commit_archive(
|
||||
bucket_results,
|
||||
plan.ephemeral,
|
||||
plan.pre_dropped,
|
||||
&commit_identity_pk,
|
||||
&commit_relay_url,
|
||||
&owner_keys,
|
||||
now,
|
||||
conn,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Validate an ephemeral observer frame (kind 24200) against ALL local rules.
|
||||
@@ -398,7 +413,10 @@ async fn probe_event_readable(state: &AppState, event_id: &str) -> Result<(), St
|
||||
/// `useAgentMetricArchiveSeed` (kind 44200) instead of the former
|
||||
/// list → merge-in-TS → create pattern.
|
||||
#[tauri::command]
|
||||
pub fn merge_save_subscription_kinds(state: State<'_, AppState>, kind: u32) -> Result<(), String> {
|
||||
pub async fn merge_save_subscription_kinds(
|
||||
state: State<'_, AppState>,
|
||||
kind: u32,
|
||||
) -> Result<(), String> {
|
||||
if kind > u32::from(u16::MAX) {
|
||||
return Err(format!("kind {kind} is out of the valid range 0..=65535"));
|
||||
}
|
||||
@@ -406,8 +424,11 @@ pub fn merge_save_subscription_kinds(state: State<'_, AppState>, kind: u32) -> R
|
||||
let identity_pk = identity_pubkey(&state)?;
|
||||
let relay_url = relay_ws_url_with_override(&state);
|
||||
let now = now_secs();
|
||||
let conn = open_db()?;
|
||||
store::merge_owner_p_kinds(&conn, &identity_pk, &relay_url, &identity_pk, kind, now)
|
||||
let owner_pk = identity_pk.clone();
|
||||
run_archive_db_task(move |conn| {
|
||||
store::merge_owner_p_kinds(conn, &identity_pk, &relay_url, &owner_pk, kind, now)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// ── remove_save_subscription_kind ────────────────────────────────────────────
|
||||
@@ -425,28 +446,34 @@ pub fn merge_save_subscription_kinds(state: State<'_, AppState>, kind: u32) -> R
|
||||
/// former TS-side read-modify-overwrite + whole-row `deleteSaveSubscription`
|
||||
/// which would drop the *other* kind if `subs` state was stale.
|
||||
#[tauri::command]
|
||||
pub fn remove_save_subscription_kind(state: State<'_, AppState>, kind: u32) -> Result<(), String> {
|
||||
pub async fn remove_save_subscription_kind(
|
||||
state: State<'_, AppState>,
|
||||
kind: u32,
|
||||
) -> Result<(), String> {
|
||||
if kind > u32::from(u16::MAX) {
|
||||
return Err(format!("kind {kind} is out of the valid range 0..=65535"));
|
||||
}
|
||||
|
||||
let identity_pk = identity_pubkey(&state)?;
|
||||
let relay_url = relay_ws_url_with_override(&state);
|
||||
let conn = open_db()?;
|
||||
store::remove_owner_p_kind(&conn, &identity_pk, &relay_url, &identity_pk, kind)
|
||||
let owner_pk = identity_pk.clone();
|
||||
run_archive_db_task(move |conn| {
|
||||
store::remove_owner_p_kind(conn, &identity_pk, &relay_url, &owner_pk, kind)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// ── list_save_subscriptions ──────────────────────────────────────────────────
|
||||
|
||||
/// List all save subscriptions for the current identity + relay.
|
||||
#[tauri::command]
|
||||
pub fn list_save_subscriptions(
|
||||
pub async fn list_save_subscriptions(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<store::SaveSubscription>, String> {
|
||||
let identity_pk = identity_pubkey(&state)?;
|
||||
let relay_url = relay_ws_url_with_override(&state);
|
||||
let conn = open_db()?;
|
||||
store::list_save_subscriptions(&conn, &identity_pk, &relay_url)
|
||||
run_archive_db_task(move |conn| store::list_save_subscriptions(conn, &identity_pk, &relay_url))
|
||||
.await
|
||||
}
|
||||
|
||||
// ── delete_save_subscription ─────────────────────────────────────────────────
|
||||
@@ -496,7 +523,7 @@ const DEFAULT_READ_LIMIT: i64 = 50;
|
||||
/// caller doing `Event::from_json` on an unfiltered read must filter by kind
|
||||
/// first (today's only reader filters `kinds: [24200]`).
|
||||
#[tauri::command]
|
||||
pub fn read_archived_events(
|
||||
pub async fn read_archived_events(
|
||||
state: State<'_, AppState>,
|
||||
scope_type: ScopeType,
|
||||
scope_value: String,
|
||||
@@ -507,18 +534,22 @@ pub fn read_archived_events(
|
||||
) -> Result<Vec<String>, String> {
|
||||
let identity_pk = identity_pubkey(&state)?;
|
||||
let relay_url = relay_ws_url_with_override(&state);
|
||||
let conn = open_db()?;
|
||||
store::read_archived_events(
|
||||
&conn,
|
||||
&identity_pk,
|
||||
&relay_url,
|
||||
scope_type.as_str(),
|
||||
&scope_value,
|
||||
kinds.as_deref(),
|
||||
before_created_at,
|
||||
before_id.as_deref(),
|
||||
limit.unwrap_or(DEFAULT_READ_LIMIT),
|
||||
)
|
||||
let scope_type_str = scope_type.as_str().to_string();
|
||||
let read_limit = limit.unwrap_or(DEFAULT_READ_LIMIT);
|
||||
run_archive_db_task(move |conn| {
|
||||
store::read_archived_events(
|
||||
conn,
|
||||
&identity_pk,
|
||||
&relay_url,
|
||||
&scope_type_str,
|
||||
&scope_value,
|
||||
kinds.as_deref(),
|
||||
before_created_at,
|
||||
before_id.as_deref(),
|
||||
read_limit,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
/// Currently only "goose" is supported; other runtimes return `null`.
|
||||
#[tauri::command]
|
||||
pub fn get_runtime_file_config(runtime_id: String) -> Option<RuntimeFileConfigSubset> {
|
||||
match runtime_id.as_str() {
|
||||
pub async fn get_runtime_file_config(
|
||||
runtime_id: String,
|
||||
) -> Result<Option<RuntimeFileConfigSubset>, 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<RuntimeFileConfigSu
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))
|
||||
}
|
||||
|
||||
/// 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]
|
||||
pub fn discover_acp_providers() -> Vec<AcpRuntimeCatalogEntry> {
|
||||
crate::managed_agents::clear_resolve_cache();
|
||||
crate::managed_agents::discover_acp_runtimes()
|
||||
pub async fn discover_acp_providers() -> Result<Vec<AcpRuntimeCatalogEntry>, 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<ManagedAgentPrereqsInfo, String> {
|
||||
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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
#[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()
|
||||
pub async fn discover_backend_providers() -> Result<Vec<BackendProviderInfo>, 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]
|
||||
|
||||
@@ -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<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())?;
|
||||
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, ¤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}"))?
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -1249,33 +1248,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<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:
|
||||
// 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key)
|
||||
// 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 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<Vec<ChannelTemplateRecord>, 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<Vec<ChannelTemplateRecord>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let state = app.state::<AppState>();
|
||||
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<ChannelTemplateRecord, String> {
|
||||
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::<AppState>();
|
||||
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<ChannelTemplateRecord, String> {
|
||||
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::<AppState>();
|
||||
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::<AppState>();
|
||||
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<ChannelTemplateRecord, String> {
|
||||
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::<AppState>();
|
||||
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}"))?
|
||||
}
|
||||
|
||||
@@ -165,43 +165,48 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result<String, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_identity(
|
||||
pub async fn import_identity(
|
||||
nsec: String,
|
||||
app_handle: tauri::AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<IdentityInfo, String> {
|
||||
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::<AppState>();
|
||||
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]
|
||||
|
||||
@@ -171,34 +171,39 @@ fn merge_legacy_workspace_storage(
|
||||
/// under `~/Library/WebKit/<identifier>/...` 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<LegacyWorkspaceStorage, String> {
|
||||
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)]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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::<AppState>();
|
||||
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<u8>,
|
||||
file_name: String,
|
||||
) -> Result<ParsePersonaFilesResult, 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());
|
||||
}
|
||||
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 <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 "---"
|
||||
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 <name>.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]
|
||||
|
||||
@@ -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<u8>,
|
||||
_file_name: String,
|
||||
) -> Result<ParsedTeamPreview, 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());
|
||||
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.
|
||||
|
||||
@@ -66,6 +66,36 @@ pub async fn get_channel_workflows(
|
||||
Ok(events.iter().map(workflow_from_event).collect())
|
||||
}
|
||||
|
||||
/// Fetch workflows across many channels in a single relay round-trip.
|
||||
///
|
||||
/// The Workflows overview screen previously issued one `get_channel_workflows`
|
||||
/// query per member channel (`Promise.all` fanout in `WorkflowsView`), i.e. N
|
||||
/// relay POSTs. A nostr `#h` filter matches ANY of its listed values, so one
|
||||
/// query with all channel ids returns the same set. Each `WorkflowWire` carries
|
||||
/// its own `channel_id` (from the event's `h` tag), so the frontend can still
|
||||
/// group results by channel. Neither this nor the per-channel command sets a
|
||||
/// `limit`, so batching does not change result completeness.
|
||||
#[tauri::command]
|
||||
pub async fn get_channels_workflows(
|
||||
channel_ids: Vec<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<WorkflowWire>, String> {
|
||||
if channel_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let events = query_relay(
|
||||
&state,
|
||||
&[serde_json::json!({
|
||||
"kinds": [30620],
|
||||
"#h": channel_ids,
|
||||
})],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(events.iter().map(workflow_from_event).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_workflow(
|
||||
workflow_id: String,
|
||||
|
||||
@@ -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<ActiveWorkspac
|
||||
/// "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.
|
||||
#[tauri::command]
|
||||
pub fn validate_repos_dir(dir: String) -> 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<String>,
|
||||
repos_dir: Option<String>,
|
||||
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::<AppState>();
|
||||
|
||||
// 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}"))?
|
||||
}
|
||||
|
||||
@@ -19,6 +19,24 @@ pub fn run_event_sync(app: &tauri::AppHandle, owner_keys: &nostr::Keys) {
|
||||
crate::managed_agents::reconcile::reconcile_agents_to_events(app, owner_keys);
|
||||
}
|
||||
|
||||
/// Spawn the best-effort event reconcile off the synchronous Tauri setup path.
|
||||
///
|
||||
/// The owner keys are cloned before spawning so the task never touches the
|
||||
/// `AppState::keys` mutex. The reconcile itself is still synchronous JSON,
|
||||
/// SQLite, and signing work, so it runs on the blocking pool rather than an
|
||||
/// async worker.
|
||||
pub fn spawn_event_sync(app: tauri::AppHandle, owner_keys: nostr::Keys) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = tauri::async_runtime::spawn_blocking(move || {
|
||||
run_event_sync(&app, &owner_keys);
|
||||
})
|
||||
.await
|
||||
{
|
||||
eprintln!("buzz-desktop: event-sync: spawn_blocking failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reconcile `personas.json` into the persona-event retention store.
|
||||
///
|
||||
/// Must run AFTER `migrate_packs_to_teams` (depends on field renames being
|
||||
|
||||
@@ -6,7 +6,13 @@ use crate::app_state::AppState;
|
||||
|
||||
/// List available audio output devices. Returns (name, is_default) pairs.
|
||||
#[tauri::command]
|
||||
pub fn list_audio_output_devices() -> Result<Vec<AudioOutputDevice>, String> {
|
||||
pub async fn list_audio_output_devices() -> Result<Vec<AudioOutputDevice>, String> {
|
||||
tokio::task::spawn_blocking(list_audio_output_devices_blocking)
|
||||
.await
|
||||
.map_err(|e| format!("spawn_blocking failed: {e}"))?
|
||||
}
|
||||
|
||||
fn list_audio_output_devices_blocking() -> Result<Vec<AudioOutputDevice>, String> {
|
||||
use rodio::cpal::traits::HostTrait;
|
||||
use rodio::DeviceTrait;
|
||||
|
||||
|
||||
@@ -24,14 +24,18 @@ pub(crate) async fn post_connect_setup(
|
||||
state: &AppState,
|
||||
ephemeral_channel_id: &str,
|
||||
) -> Result<(), String> {
|
||||
// Hydrate agent pubkeys from relay (authoritative — overrides local guess).
|
||||
if let Ok(agents) = fetch_channel_members(ephemeral_channel_id, Some("bot"), state).await {
|
||||
// Hydrate agent pubkeys and participants from relay in parallel
|
||||
// (authoritative — overrides local guesses).
|
||||
let (agents_result, all_members_result) = tokio::join!(
|
||||
fetch_channel_members(ephemeral_channel_id, Some("bot"), state),
|
||||
fetch_channel_members(ephemeral_channel_id, None, state),
|
||||
);
|
||||
if let Ok(agents) = agents_result {
|
||||
let hs = state.huddle()?;
|
||||
*hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents;
|
||||
}
|
||||
|
||||
// Hydrate participants from relay (authoritative state).
|
||||
if let Ok(all_members) = fetch_channel_members(ephemeral_channel_id, None, state).await {
|
||||
if let Ok(all_members) = all_members_result {
|
||||
if !all_members.is_empty() {
|
||||
let mut hs = state.huddle()?;
|
||||
hs.participants = all_members;
|
||||
@@ -133,15 +137,23 @@ pub(crate) async fn maybe_start_stt_pipeline(
|
||||
// Drop the old pipeline OUTSIDE the lock — thread join happens here.
|
||||
drop(old_stt);
|
||||
|
||||
let (pipeline, text_rx) =
|
||||
match stt::SttPipeline::new(model_dir, tts_active, tts_cancel, ptt_active_for_stt) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let hs = state.huddle()?;
|
||||
hs.stt_starting.store(false, Ordering::Release);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let constructed = tokio::task::spawn_blocking(move || {
|
||||
stt::SttPipeline::new(model_dir, tts_active, tts_cancel, ptt_active_for_stt)
|
||||
})
|
||||
.await;
|
||||
let (pipeline, text_rx) = match constructed {
|
||||
Ok(Ok(p)) => p,
|
||||
Ok(Err(e)) => {
|
||||
let hs = state.huddle()?;
|
||||
hs.stt_starting.store(false, Ordering::Release);
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => {
|
||||
let hs = state.huddle()?;
|
||||
hs.stt_starting.store(false, Ordering::Release);
|
||||
return Err(format!("spawn_blocking failed: {e}"));
|
||||
}
|
||||
};
|
||||
let pipeline = Arc::new(pipeline);
|
||||
|
||||
{
|
||||
@@ -204,13 +216,22 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result<bool, S
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
let pipeline = match tts::TtsPipeline::new(model_dir, tts_active, tts_cancel, output_device) {
|
||||
Ok(p) => Arc::new(p),
|
||||
Err(e) => {
|
||||
let constructed = tokio::task::spawn_blocking(move || {
|
||||
tts::TtsPipeline::new(model_dir, tts_active, tts_cancel, output_device)
|
||||
})
|
||||
.await;
|
||||
let pipeline = match constructed {
|
||||
Ok(Ok(p)) => Arc::new(p),
|
||||
Ok(Err(e)) => {
|
||||
let hs = state.huddle()?;
|
||||
hs.tts_starting.store(false, Ordering::Release);
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => {
|
||||
let hs = state.huddle()?;
|
||||
hs.tts_starting.store(false, Ordering::Release);
|
||||
return Err(format!("spawn_blocking failed: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
|
||||
@@ -226,14 +226,13 @@ pub fn run() {
|
||||
resolve_persisted_identity(&app_handle, &state)
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
||||
|
||||
// Sync team-dir edits and reconcile persona/team events. Needs the
|
||||
// resolved owner keys, so it runs after identity resolution.
|
||||
// Snapshot owner keys after identity resolution; the best-effort
|
||||
// event reconcile itself runs off the synchronous setup path below.
|
||||
let owner_keys = state
|
||||
.keys
|
||||
.lock()
|
||||
.map(|k| k.clone())
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e.to_string().into() })?;
|
||||
event_sync::run_event_sync(&app_handle, &owner_keys);
|
||||
|
||||
// Backfill the pinned persona snapshot for any pre-existing agent
|
||||
// that predates the record-authoritative-spawn cutover (persona_id
|
||||
@@ -332,6 +331,12 @@ pub fn run() {
|
||||
|
||||
try_regenerate_nest(&app_handle);
|
||||
|
||||
// Sync team-dir edits and reconcile persona/team/agent events after
|
||||
// setup can continue. It is best-effort retention backfill, unlike
|
||||
// identity resolution above, so JSON/SQLite/signing work must not
|
||||
// hold the boot path hostage.
|
||||
event_sync::spawn_event_sync(app_handle.clone(), owner_keys);
|
||||
|
||||
if let Some(mgr) = huddle::models::global_model_manager() {
|
||||
mgr.start_stt_download(state.http_client.clone());
|
||||
mgr.start_tts_download(state.http_client.clone());
|
||||
@@ -570,6 +575,7 @@ pub fn run() {
|
||||
parse_persona_files,
|
||||
export_persona_to_json,
|
||||
get_channel_workflows,
|
||||
get_channels_workflows,
|
||||
get_workflow,
|
||||
create_workflow,
|
||||
update_workflow,
|
||||
|
||||
@@ -160,10 +160,16 @@ export function AppShell() {
|
||||
// guard here would drop managed-agent coverage during startup.
|
||||
useAgentObserverIngestion();
|
||||
useArchiveSync();
|
||||
useObserverArchiveSeed(identityQuery.data?.pubkey);
|
||||
useAgentMetricArchiveSeed(identityQuery.data?.pubkey);
|
||||
const profileQuery = useProfileQuery();
|
||||
// Defer the archive *seeds* until startup is idle: they're first-run catch-up
|
||||
// config (a one-shot mergeSaveSubscriptionKinds), not live-ingest — that's
|
||||
// useArchiveSync's job, which stays eager above. Passing deferredPubkey makes
|
||||
// each seed hook wait on its own `if (!pubkey) return` guard until the shell
|
||||
// is interactive, so their IPC + sqlite archive open doesn't compete with
|
||||
// first paint. The explicit-choice guard inside each hook is unchanged.
|
||||
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
|
||||
useObserverArchiveSeed(deferredPubkey);
|
||||
useAgentMetricArchiveSeed(deferredPubkey);
|
||||
const profileQuery = useProfileQuery();
|
||||
useRelayAutoHeal();
|
||||
usePresenceSubscription();
|
||||
useUserStatusSubscription();
|
||||
|
||||
@@ -176,7 +176,10 @@ export function usePersonasQuery() {
|
||||
queryKey: personasQueryKey,
|
||||
queryFn: listPersonas,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
// No refetchInterval: inbound relay changes to personas emit
|
||||
// `agents-data-changed`, which `useAgentsDataRefresh` coalesces into an
|
||||
// invalidate (200ms window). The 30s poll was belt-and-suspenders on top of
|
||||
// that event path — redundant disk-read IPC.
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,7 +212,16 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) {
|
||||
queryKey: relayAgentsQueryKey,
|
||||
queryFn: listRelayAgents,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
// Relay agent profiles (kind:10100) are near-static and the backing
|
||||
// `list_relay_agents` command is an unfiltered relay query for the whole
|
||||
// profile set — mounted on ~13 always-live surfaces (channel screen,
|
||||
// members bar, mentions, sidebar, profile popovers), so a tight interval
|
||||
// re-pulls the full set app-wide. This poll is also the ONLY refresh path:
|
||||
// the `agents-data-changed` event fires only for local persona/team/managed
|
||||
// reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we
|
||||
// keep polling but at a relaxed cadence and pause it while backgrounded.
|
||||
refetchInterval: 5 * 60_000,
|
||||
refetchIntervalInBackground: false,
|
||||
enabled: options?.enabled,
|
||||
});
|
||||
}
|
||||
@@ -222,12 +234,14 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) {
|
||||
staleTime: 5_000,
|
||||
refetchInterval: (query) => {
|
||||
const agents = query.state.data as ManagedAgent[] | undefined;
|
||||
// Only local "running" agents need fast polling (process state can
|
||||
// change). "deployed" is static control-plane state — presence polling
|
||||
// handles the live signal for remote agents separately.
|
||||
// Only local "running" agents need polling: process state can change
|
||||
// with no relay event to signal it, so this poll is the only liveness
|
||||
// path for them. When nothing is running there IS an event path —
|
||||
// `agents-data-changed` (control-plane changes) — so the idle branch
|
||||
// drops its poll entirely rather than falling back to 30s.
|
||||
return agents?.some((agent) => agent.status === "running")
|
||||
? 5_000
|
||||
: 30_000;
|
||||
: false;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -680,7 +694,9 @@ export function useTeamsQuery() {
|
||||
queryKey: teamsQueryKey,
|
||||
queryFn: listTeams,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
// No refetchInterval: inbound relay team changes emit `agents-data-changed`
|
||||
// (handled by useAgentsDataRefresh). Same redundant-poll removal as
|
||||
// usePersonasQuery.
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,10 @@ import { useThreadReplies } from "@/features/messages/useThreadReplies";
|
||||
import { useChannelTyping } from "@/features/messages/useChannelTyping";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
mergeCurrentProfileIntoLookup,
|
||||
profileLookupsEqual,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types";
|
||||
import { useChannelFind } from "@/features/search/useChannelFind";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
@@ -324,17 +327,6 @@ export function ChannelScreen({
|
||||
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
|
||||
enabled: messageProfilePubkeys.length > 0,
|
||||
});
|
||||
const agentPubkeys = React.useMemo(() => {
|
||||
const pubkeys = new Set(knownAgentPubkeys);
|
||||
for (const [pubkey, profile] of Object.entries(
|
||||
messageProfilesQuery.data?.profiles ?? {},
|
||||
)) {
|
||||
if (profile.isAgent) {
|
||||
pubkeys.add(normalizePubkey(pubkey));
|
||||
}
|
||||
}
|
||||
return pubkeys;
|
||||
}, [knownAgentPubkeys, messageProfilesQuery.data]);
|
||||
const agentPubkeysPending =
|
||||
activeChannel?.channelType === "dm" &&
|
||||
(channelMembersQuery.isPending ||
|
||||
@@ -360,7 +352,7 @@ export function ChannelScreen({
|
||||
// Observer ingestion (frame decryption + derived active-turn liveness) is
|
||||
// owner-global — mounted once in AppShell via useAgentObserverIngestion —
|
||||
// so this screen no longer mounts its own observer/turns bridges.
|
||||
const messageProfiles = React.useMemo(() => {
|
||||
const messageProfilesRaw = React.useMemo(() => {
|
||||
const base =
|
||||
mergeCurrentProfileIntoLookup(
|
||||
messageProfilesQuery.data?.profiles,
|
||||
@@ -379,6 +371,28 @@ export function ChannelScreen({
|
||||
messageProfilesQuery.data?.profiles,
|
||||
relayAgents,
|
||||
]);
|
||||
// Stabilise the merged lookup's reference across renders when no profile
|
||||
// value changed. `messageProfilesRaw` gets a fresh identity whenever the
|
||||
// `users-batch` query re-keys — which typing churn triggers constantly — and
|
||||
// that identity flows to MessageRow's `prev.profiles === next.profiles` memo
|
||||
// check, so an unstable reference re-renders the whole timeline per keystroke.
|
||||
const messageProfilesRef = React.useRef(messageProfilesRaw);
|
||||
if (!profileLookupsEqual(messageProfilesRef.current, messageProfilesRaw)) {
|
||||
messageProfilesRef.current = messageProfilesRaw;
|
||||
}
|
||||
const messageProfiles = messageProfilesRef.current;
|
||||
// Derived from the stabilised lookup so this Set only churns when a profile
|
||||
// value actually changed — MessageRow compares `agentPubkeys` by reference,
|
||||
// and each row previously re-derived this same scan locally (removed).
|
||||
const agentPubkeys = React.useMemo(() => {
|
||||
const pubkeys = new Set(knownAgentPubkeys);
|
||||
for (const [pubkey, profile] of Object.entries(messageProfiles)) {
|
||||
if (profile.isAgent) {
|
||||
pubkeys.add(normalizePubkey(pubkey));
|
||||
}
|
||||
}
|
||||
return pubkeys;
|
||||
}, [knownAgentPubkeys, messageProfiles]);
|
||||
const personasQuery = usePersonasQuery();
|
||||
const { personaLookup, respondToLookup } = React.useMemo(() => {
|
||||
const agents = managedAgentsQuery.data ?? [];
|
||||
|
||||
@@ -22,6 +22,7 @@ type HuddleJoinInfo = {
|
||||
type VoiceInputMode = "push_to_talk" | "voice_activity";
|
||||
|
||||
const MIC_ANALYSER_UPDATE_INTERVAL_MS = 33;
|
||||
const PIPELINE_HOTSTART_INTERVAL_MS = 15_000;
|
||||
const MIC_INITIAL_NOISE_FLOOR = 0.01;
|
||||
const MIC_VOICE_GATE_ON_RMS = 0.018;
|
||||
const MIC_VOICE_GATE_OFF_RMS = 0.012;
|
||||
@@ -522,7 +523,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
|
||||
invoke("check_pipeline_hotstart").catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
}, 5_000);
|
||||
}, PIPELINE_HOTSTART_INTERVAL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [ephemeralChannelId]);
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ type HuddleBarProps = {
|
||||
};
|
||||
|
||||
const HUDDLE_DRAWER_EXIT_MS = 260;
|
||||
const HUDDLE_STATE_FALLBACK_INTERVAL_MS = 30_000;
|
||||
const HUDDLE_MODEL_STATUS_INTERVAL_MS = 10_000;
|
||||
const HUDDLE_REACTION_NAME_MAX = 48;
|
||||
|
||||
function isVisibleHuddleState(state: HuddleState | null) {
|
||||
@@ -212,7 +214,7 @@ export function HuddleBar({
|
||||
}
|
||||
setState(nextState);
|
||||
}, []);
|
||||
// Huddle state: event-driven + 10s fallback poll.
|
||||
// Huddle state: event-driven + slow fallback poll.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
@@ -246,8 +248,12 @@ export function HuddleBar({
|
||||
else unlisten = fn;
|
||||
});
|
||||
|
||||
// Fallback: 10s poll in case events are missed
|
||||
const id = window.setInterval(() => void fetchState(), 10_000);
|
||||
// Fallback in case events are missed; keep it slow so normal huddle use is
|
||||
// event-driven and does not keep a sync IPC command warm on the main thread.
|
||||
const id = window.setInterval(
|
||||
() => void fetchState(),
|
||||
HUDDLE_STATE_FALLBACK_INTERVAL_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -293,7 +299,10 @@ export function HuddleBar({
|
||||
}
|
||||
|
||||
void pollModels();
|
||||
const id = window.setInterval(() => void pollModels(), 3_000);
|
||||
const id = window.setInterval(
|
||||
() => void pollModels(),
|
||||
HUDDLE_MODEL_STATUS_INTERVAL_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as React from "react";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
|
||||
const AGENT_PUBKEY_REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Subscribe to agent TTS messages on the ephemeral huddle channel.
|
||||
* Pipes agent kind:9 messages to `speak_agent_message` on the Rust backend.
|
||||
@@ -51,7 +53,7 @@ export function useTtsSubscription(
|
||||
void loadAgentPubkeys();
|
||||
const agentRefreshId = window.setInterval(() => {
|
||||
void loadAgentPubkeys();
|
||||
}, 10_000);
|
||||
}, AGENT_PUBKEY_REFRESH_INTERVAL_MS);
|
||||
|
||||
// ── Live-only subscription ───────────────────────────────────────────
|
||||
// subscribeToChannelLive uses `since: now` — the relay never sends
|
||||
|
||||
@@ -365,7 +365,7 @@ function isCustomEmojiShortcode(emoji: string) {
|
||||
return emoji.startsWith(":") && emoji.endsWith(":");
|
||||
}
|
||||
|
||||
export function MessageActionBar({
|
||||
export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
channelId,
|
||||
message,
|
||||
onDelete,
|
||||
@@ -588,4 +588,6 @@ export function MessageActionBar({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
MessageActionBar.displayName = "MessageActionBar";
|
||||
|
||||
@@ -43,6 +43,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
|
||||
/** Stable empty fallback so rows without an agent-pubkey set keep a constant
|
||||
* reference (a fresh `new Set()` per render would defeat downstream memos). */
|
||||
const EMPTY_AGENT_PUBKEYS: ReadonlySet<string> = new Set();
|
||||
|
||||
export type ThreadDepthGuideAction = {
|
||||
active?: boolean;
|
||||
depth: number;
|
||||
@@ -149,6 +153,17 @@ export const MessageRow = React.memo(
|
||||
} = useReactionHandler(message, onToggleReaction);
|
||||
const { openReminder, activeReminderEventIds } = useRemindLater();
|
||||
const hasActiveReminder = activeReminderEventIds.has(message.id);
|
||||
const handleRemindLater = React.useCallback(
|
||||
(msg: TimelineMessage) => {
|
||||
openReminder({
|
||||
eventId: msg.id,
|
||||
channelId: channelId ?? "",
|
||||
preview: msg.body.slice(0, 100),
|
||||
authorPubkey: msg.pubkey ?? "",
|
||||
});
|
||||
},
|
||||
[channelId, openReminder],
|
||||
);
|
||||
const mentionNames = React.useMemo(
|
||||
() => resolveMentionNames(message.tags, profiles),
|
||||
[profiles, message.tags],
|
||||
@@ -157,17 +172,11 @@ export const MessageRow = React.memo(
|
||||
() => resolveMentionPubkeysByName(message.tags, profiles),
|
||||
[profiles, message.tags],
|
||||
);
|
||||
const resolvedAgentPubkeys = React.useMemo(() => {
|
||||
const pubkeys = new Set(agentPubkeys ?? []);
|
||||
|
||||
for (const [pubkey, profile] of Object.entries(profiles ?? {})) {
|
||||
if (profile.isAgent) {
|
||||
pubkeys.add(normalizePubkey(pubkey));
|
||||
}
|
||||
}
|
||||
|
||||
return pubkeys;
|
||||
}, [agentPubkeys, profiles]);
|
||||
// The agent-pubkey set is computed once by the parent (ChannelScreen)
|
||||
// from the same profile lookup and passed down already normalised — no
|
||||
// per-row rescan of `profiles` (that duplicated the parent's work in every
|
||||
// mounted row and re-ran on each profile-lookup change).
|
||||
const resolvedAgentPubkeys = agentPubkeys ?? EMPTY_AGENT_PUBKEYS;
|
||||
const profilePopoverRole =
|
||||
message.role === "bot" ||
|
||||
(message.pubkey &&
|
||||
@@ -200,11 +209,7 @@ export const MessageRow = React.memo(
|
||||
);
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
|
||||
|
||||
const { channels } = useChannelNavigation();
|
||||
const channelNames = React.useMemo(
|
||||
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
|
||||
[channels],
|
||||
);
|
||||
const { nonDmChannelNames: channelNames } = useChannelNavigation();
|
||||
|
||||
const indentRem = getThreadReplyIndentRem(message.depth);
|
||||
const descendantGuideOffsetRem = connectDescendants
|
||||
@@ -452,14 +457,7 @@ export const MessageRow = React.memo(
|
||||
onReactionSelect={
|
||||
canToggleReactions ? handleReactionSelect : undefined
|
||||
}
|
||||
onRemindLater={(msg) => {
|
||||
openReminder({
|
||||
eventId: msg.id,
|
||||
channelId: channelId ?? "",
|
||||
preview: msg.body.slice(0, 100),
|
||||
authorPubkey: msg.pubkey ?? "",
|
||||
});
|
||||
}}
|
||||
onRemindLater={handleRemindLater}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
*/
|
||||
export function getConfigNudgeAuthorPubkey(
|
||||
message: Pick<TimelineMessage, "kind" | "signerPubkey">,
|
||||
resolvedAgentPubkeys: Set<string>,
|
||||
resolvedAgentPubkeys: ReadonlySet<string>,
|
||||
): string | undefined {
|
||||
if (
|
||||
message.kind === KIND_STREAM_MESSAGE &&
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { profileLookupsEqual } from "./identity.ts";
|
||||
|
||||
const summary = (over = {}) => ({
|
||||
displayName: "Ada",
|
||||
avatarUrl: "https://x/a.png",
|
||||
nip05Handle: "ada@x",
|
||||
ownerPubkey: null,
|
||||
isAgent: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: same reference is equal", () => {
|
||||
const a = { p1: summary() };
|
||||
assert.equal(profileLookupsEqual(a, a), true);
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: distinct objects, identical values are equal", () => {
|
||||
assert.equal(profileLookupsEqual({ p1: summary() }, { p1: summary() }), true);
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: different key count is not equal", () => {
|
||||
assert.equal(
|
||||
profileLookupsEqual({ p1: summary() }, { p1: summary(), p2: summary() }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: same count, different keys is not equal", () => {
|
||||
assert.equal(
|
||||
profileLookupsEqual({ p1: summary() }, { p2: summary() }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: a changed field is not equal", () => {
|
||||
for (const field of [
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"nip05Handle",
|
||||
"ownerPubkey",
|
||||
"isAgent",
|
||||
]) {
|
||||
assert.equal(
|
||||
profileLookupsEqual(
|
||||
{ p1: summary() },
|
||||
{ p1: summary({ [field]: field === "isAgent" ? true : "changed" }) },
|
||||
),
|
||||
false,
|
||||
`field ${field} should break equality`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("profileLookupsEqual: two empty lookups are equal", () => {
|
||||
assert.equal(profileLookupsEqual({}, {}), true);
|
||||
});
|
||||
|
||||
// Render-count proof for the Tier-1 typing-storm fix (#1533 discipline).
|
||||
// MessageRow re-renders iff `prev.profiles === next.profiles` fails, so the
|
||||
// stabiliser's job is: hold the reference across value-equal re-derives (the
|
||||
// per-keystroke churn) and release it only on a real value change. This
|
||||
// replays the exact ChannelScreen ref idiom against a sequence of freshly
|
||||
// built lookups and asserts reference identity == render decision.
|
||||
function makeStabiliser() {
|
||||
let ref;
|
||||
let first = true;
|
||||
return (raw) => {
|
||||
if (first || !profileLookupsEqual(ref, raw)) {
|
||||
ref = raw;
|
||||
}
|
||||
first = false;
|
||||
return ref;
|
||||
};
|
||||
}
|
||||
|
||||
test("stabiliser: value-equal re-derives keep the same reference (no re-render)", () => {
|
||||
const stabilise = makeStabiliser();
|
||||
// Each entry is a fresh object identity — exactly what a users-batch re-key
|
||||
// produces on every keystroke-adjacent typing event.
|
||||
const first = stabilise({ p1: summary() });
|
||||
const churnA = stabilise({ p1: summary() });
|
||||
const churnB = stabilise({ p1: summary() });
|
||||
assert.equal(churnA, first, "value-equal churn must not swap the reference");
|
||||
assert.equal(churnB, first, "repeated churn must not swap the reference");
|
||||
});
|
||||
|
||||
test("stabiliser: a real profile change swaps the reference (re-render fires)", () => {
|
||||
const stabilise = makeStabiliser();
|
||||
const first = stabilise({ p1: summary() });
|
||||
const changed = stabilise({ p1: summary({ displayName: "Grace" }) });
|
||||
assert.notEqual(
|
||||
changed,
|
||||
first,
|
||||
"a real value change must swap the reference",
|
||||
);
|
||||
// ...and then re-stabilises around the new value.
|
||||
const held = stabilise({ p1: summary({ displayName: "Grace" }) });
|
||||
assert.equal(held, changed, "must re-stabilise around the new value");
|
||||
});
|
||||
@@ -5,6 +5,50 @@ export type UserProfileLookup = Record<string, UserProfileSummary>;
|
||||
|
||||
export { truncatePubkey };
|
||||
|
||||
/**
|
||||
* Deep-equal two profile lookups by value. Used to stabilise the merged
|
||||
* `messageProfiles` reference at the ChannelScreen boundary: the underlying
|
||||
* `users-batch` query re-keys on the full sorted pubkey set, so typing churn
|
||||
* (a transient typing-only pubkey entering/leaving the set) produces a fresh
|
||||
* lookup object identity even when no profile value actually changed. That new
|
||||
* reference fails MessageRow's `prev.profiles === next.profiles` memo check and
|
||||
* re-renders the entire timeline on every keystroke-adjacent typing event.
|
||||
* Returning the previous reference when this reports equal keeps the memo
|
||||
* intact. Consumers read profiles by pubkey value only, never treating identity
|
||||
* as a change signal, so returning the stale-but-value-identical reference is
|
||||
* safe.
|
||||
*/
|
||||
export function profileLookupsEqual(
|
||||
a: UserProfileLookup,
|
||||
b: UserProfileLookup,
|
||||
): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const aKeys = Object.keys(a);
|
||||
if (aKeys.length !== Object.keys(b).length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of aKeys) {
|
||||
const prev = a[key];
|
||||
const next = b[key];
|
||||
if (
|
||||
next === undefined ||
|
||||
prev.displayName !== next.displayName ||
|
||||
prev.avatarUrl !== next.avatarUrl ||
|
||||
prev.nip05Handle !== next.nip05Handle ||
|
||||
prev.ownerPubkey !== next.ownerPubkey ||
|
||||
prev.isAgent !== next.isAgent
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getResolvedProfile(
|
||||
pubkey: string,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
getGlobalNotes,
|
||||
@@ -13,6 +14,37 @@ import { allPulseTimelinesQueryKey } from "@/features/profile/hooks";
|
||||
import { withoutProjectComments } from "@/features/pulse/lib/projectComments";
|
||||
import type { UserNote, UserNotesResponse } from "@/shared/api/socialTypes";
|
||||
|
||||
function isDocumentVisible() {
|
||||
return typeof document === "undefined"
|
||||
? true
|
||||
: document.visibilityState === "visible";
|
||||
}
|
||||
|
||||
function useDocumentVisible() {
|
||||
const [visible, setVisible] = React.useState(isDocumentVisible);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
setVisible(isDocumentVisible());
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
function useVisibleRefetchInterval(intervalMs: number) {
|
||||
return useDocumentVisible() ? intervalMs : false;
|
||||
}
|
||||
|
||||
// ── Query keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const pulseQueryKeys = {
|
||||
@@ -31,6 +63,8 @@ export const pulseQueryKeys = {
|
||||
// ── Own notes ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useLikedNotesQuery(pubkey?: string, enabled = true) {
|
||||
const refetchInterval = useVisibleRefetchInterval(30_000);
|
||||
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.likedNotes(pubkey ?? ""),
|
||||
queryFn: async () =>
|
||||
@@ -39,11 +73,13 @@ export function useLikedNotesQuery(pubkey?: string, enabled = true) {
|
||||
enabled: enabled && !!pubkey,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyNotesQuery(pubkey?: string) {
|
||||
const refetchInterval = useVisibleRefetchInterval(30_000);
|
||||
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.myNotes(pubkey ?? ""),
|
||||
queryFn: async () =>
|
||||
@@ -52,13 +88,15 @@ export function useMyNotesQuery(pubkey?: string) {
|
||||
enabled: !!pubkey,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Timeline (notes from contacts) ─────────────────────────────────────────
|
||||
|
||||
export function useTimelineQuery(contactPubkeys: string[], enabled: boolean) {
|
||||
const refetchInterval = useVisibleRefetchInterval(30_000);
|
||||
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.timeline(contactPubkeys),
|
||||
queryFn: async () =>
|
||||
@@ -66,7 +104,7 @@ export function useTimelineQuery(contactPubkeys: string[], enabled: boolean) {
|
||||
enabled: enabled && contactPubkeys.length > 0,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +117,8 @@ export function usePulseReactionsQuery(
|
||||
noteIds: string[],
|
||||
currentPubkey?: string,
|
||||
) {
|
||||
const refetchInterval = useVisibleRefetchInterval(60_000);
|
||||
|
||||
return useQuery<Map<string, PulseReactionState>>({
|
||||
queryKey: pulseQueryKeys.reactions(noteIds),
|
||||
queryFn: async () => {
|
||||
@@ -100,7 +140,7 @@ export function usePulseReactionsQuery(
|
||||
enabled: noteIds.length > 0,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 60_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,6 +155,8 @@ export function useNoteByIdQuery(noteId: string | null) {
|
||||
}
|
||||
|
||||
export function useGlobalNotesQuery(enabled: boolean) {
|
||||
const refetchInterval = useVisibleRefetchInterval(30_000);
|
||||
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.globalNotes,
|
||||
queryFn: async () =>
|
||||
@@ -122,7 +164,7 @@ export function useGlobalNotesQuery(enabled: boolean) {
|
||||
enabled,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -102,12 +102,19 @@ export function useSearchResults({
|
||||
.slice(0, 5);
|
||||
}, [channelLabels, channels, debouncedQuery]);
|
||||
|
||||
const hasSearchQuery = debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH;
|
||||
const searchBackedQueriesEnabled = enabled && hasSearchQuery;
|
||||
|
||||
const userSearchQuery = useUserSearchQuery(debouncedQuery, {
|
||||
enabled: enabled && debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH,
|
||||
enabled: searchBackedQueriesEnabled,
|
||||
limit,
|
||||
});
|
||||
const managedAgentsQuery = useManagedAgentsQuery({ enabled });
|
||||
const relayAgentsQuery = useRelayAgentsQuery({ enabled });
|
||||
const managedAgentsQuery = useManagedAgentsQuery({
|
||||
enabled: searchBackedQueriesEnabled,
|
||||
});
|
||||
const relayAgentsQuery = useRelayAgentsQuery({
|
||||
enabled: searchBackedQueriesEnabled,
|
||||
});
|
||||
const managedAgentPubkeys = React.useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
|
||||
@@ -10,7 +10,7 @@ import { WorkflowDialog } from "@/features/workflows/ui/WorkflowDialog";
|
||||
import type { Channel, Workflow } from "@/shared/api/types";
|
||||
import {
|
||||
deleteWorkflow,
|
||||
getChannelWorkflows,
|
||||
getChannelsWorkflows,
|
||||
triggerWorkflow,
|
||||
} from "@/shared/api/tauriWorkflows";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -83,15 +83,21 @@ export function WorkflowsView({
|
||||
const allWorkflowsQuery = useQuery({
|
||||
queryKey: allWorkflowsQueryKey(channelIdKey),
|
||||
queryFn: async () => {
|
||||
const results: WorkflowWithChannel[] = [];
|
||||
await Promise.all(
|
||||
memberChannels.map(async (channel) => {
|
||||
const workflows = await getChannelWorkflows(channel.id);
|
||||
for (const workflow of workflows) {
|
||||
results.push({ workflow, channelName: channel.name });
|
||||
}
|
||||
}),
|
||||
// Single batched relay query for all member channels, then group by the
|
||||
// channel_id each workflow carries — replaces the per-channel fanout.
|
||||
const channelNameById = new Map(
|
||||
memberChannels.map((channel) => [channel.id, channel.name]),
|
||||
);
|
||||
const workflows = await getChannelsWorkflows(channelIds);
|
||||
const results: WorkflowWithChannel[] = [];
|
||||
for (const workflow of workflows) {
|
||||
results.push({
|
||||
workflow,
|
||||
channelName: workflow.channelId
|
||||
? (channelNameById.get(workflow.channelId) ?? "")
|
||||
: "",
|
||||
});
|
||||
}
|
||||
return results;
|
||||
},
|
||||
enabled: memberChannels.length > 0,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export async function collectWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const workerCount = Math.min(Math.max(1, concurrency), items.length);
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const currentIndex = nextIndex++;
|
||||
results[currentIndex] = await worker(items[currentIndex]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
buildChannelMentionFilter,
|
||||
buildGlobalStreamFilter,
|
||||
} from "@/shared/api/relayChannelFilters";
|
||||
import { collectWithConcurrency } from "@/shared/api/concurrency";
|
||||
import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay";
|
||||
import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter";
|
||||
import {
|
||||
@@ -41,7 +42,8 @@ import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 1_000,
|
||||
RECONNECT_MAX_DELAY_MS = 30_000,
|
||||
EVENT_BATCH_MS = 16;
|
||||
EVENT_BATCH_MS = 16,
|
||||
AUX_BACKFILL_CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* Passive liveness check. The relay sends heartbeat pings every 30s; if no
|
||||
@@ -217,10 +219,11 @@ export class RelayClient {
|
||||
chunks.push(eventIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
const batches: RelayEvent[][] = [];
|
||||
for (const ids of chunks) {
|
||||
batches.push(await this.requestHistory(buildFilter(channelId, ids)));
|
||||
}
|
||||
const batches = await collectWithConcurrency(
|
||||
chunks,
|
||||
AUX_BACKFILL_CONCURRENCY,
|
||||
(ids) => this.requestHistory(buildFilter(channelId, ids)),
|
||||
);
|
||||
|
||||
return batches.flat();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isRelayDependentQueryKey } from "./relayQueryInvalidation.ts";
|
||||
|
||||
test("relay invalidation includes relay-backed channel and profile queries", () => {
|
||||
for (const queryKey of [
|
||||
["channels"],
|
||||
["channels", "channel-1", "members"],
|
||||
["channel-messages", "channel-1"],
|
||||
["thread-replies", "channel-1", "root-1"],
|
||||
["forum-posts", "channel-1"],
|
||||
["home-feed"],
|
||||
["users-batch", "alice"],
|
||||
["presence", "alice"],
|
||||
["user-status", "alice"],
|
||||
["relay-agents"],
|
||||
["relayMembers"],
|
||||
["archivedIdentities"],
|
||||
["oaOwner", "alice"],
|
||||
]) {
|
||||
assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/"));
|
||||
}
|
||||
});
|
||||
|
||||
test("relay invalidation includes social/workflow relay queries", () => {
|
||||
for (const queryKey of [
|
||||
["global-notes"],
|
||||
["liked-notes", "alice"],
|
||||
["pulse-reactions", "note-1"],
|
||||
["workflows", "channel-1"],
|
||||
["workflows-all", "channel-1"],
|
||||
["workflow-runs", "workflow-1"],
|
||||
["run-approvals", "workflow-1", "run-1"],
|
||||
["reminders", "alice"],
|
||||
["custom-emoji"],
|
||||
]) {
|
||||
assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/"));
|
||||
}
|
||||
});
|
||||
|
||||
test("relay invalidation excludes local Tauri and disk-only query roots", () => {
|
||||
for (const queryKey of [
|
||||
["identity"],
|
||||
["managed-agents"],
|
||||
["personas"],
|
||||
["teams"],
|
||||
["acp-runtimes"],
|
||||
["backend-providers"],
|
||||
["managed-agent-log", "agent-1", 200],
|
||||
["workspace-icon", "wss://relay.example"],
|
||||
["agent-memory", "agent-1"],
|
||||
]) {
|
||||
assert.equal(isRelayDependentQueryKey(queryKey), false, queryKey.join("/"));
|
||||
}
|
||||
});
|
||||
|
||||
test("relay invalidation separates relay project queries from local repo work", () => {
|
||||
for (const queryKey of [
|
||||
["projects"],
|
||||
["project", "project-1"],
|
||||
["project", "project-1", "issues"],
|
||||
["project", "project-1", "pull-requests"],
|
||||
["projects", "issues", ["project-1"]],
|
||||
["projects", "activity-summaries", ["addr-1"]],
|
||||
]) {
|
||||
assert.equal(isRelayDependentQueryKey(queryKey), true, queryKey.join("/"));
|
||||
}
|
||||
|
||||
for (const queryKey of [
|
||||
["project", "project-1", "repo-state"],
|
||||
["project", "project-1", "repo-snapshot", "main"],
|
||||
["project", "project-1", "repo-diff", "main"],
|
||||
["project", "project-1", "local-repo-diff"],
|
||||
["project", "project-1", "commit-diff", "remote"],
|
||||
["projects", "local-repositories", "default"],
|
||||
["projects", "repo-snapshots", "default", ["project-1"]],
|
||||
]) {
|
||||
assert.equal(isRelayDependentQueryKey(queryKey), false, queryKey.join("/"));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const RELAY_QUERY_ROOTS = new Set<string>([
|
||||
"archivedIdentities",
|
||||
"channel-canvas",
|
||||
"channel-messages",
|
||||
"channels",
|
||||
"contact-list",
|
||||
"custom-emoji",
|
||||
"custom-emoji-own",
|
||||
"forum-posts",
|
||||
"forum-thread",
|
||||
"global-notes",
|
||||
"home-feed",
|
||||
"liked-notes",
|
||||
"my-notes",
|
||||
"myRelayMembership",
|
||||
"oaOwner",
|
||||
"presence",
|
||||
"profile",
|
||||
"pulse-note",
|
||||
"pulse-reactions",
|
||||
"pulse-timeline",
|
||||
"relay-agents",
|
||||
"relayMembers",
|
||||
"reminders",
|
||||
"run-approvals",
|
||||
"search-messages",
|
||||
"thread-replies",
|
||||
"user-profile",
|
||||
"user-search",
|
||||
"user-status",
|
||||
"users-batch",
|
||||
"workflow",
|
||||
"workflow-runs",
|
||||
"workflows",
|
||||
"workflows-all",
|
||||
]);
|
||||
|
||||
const RELAY_PROJECT_QUERY_PARTS = new Set<string>([
|
||||
"activity-summaries",
|
||||
"issues",
|
||||
"pull-requests",
|
||||
]);
|
||||
|
||||
const LOCAL_PROJECT_QUERY_PARTS = new Set<string>([
|
||||
"commit-diff",
|
||||
"local-repo-diff",
|
||||
"local-repo-snapshot",
|
||||
"local-repositories",
|
||||
"repo-diff",
|
||||
"repo-snapshot",
|
||||
"repo-state",
|
||||
"repo-sync-status",
|
||||
]);
|
||||
|
||||
function isRelayDependentProjectQueryKey(queryKey: readonly unknown[]) {
|
||||
if (queryKey[0] === "projects") {
|
||||
const scope = queryKey[1];
|
||||
if (scope === undefined) return true;
|
||||
if (typeof scope !== "string") return false;
|
||||
if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false;
|
||||
return RELAY_PROJECT_QUERY_PARTS.has(scope);
|
||||
}
|
||||
|
||||
if (queryKey[0] === "project") {
|
||||
const scope = queryKey[2];
|
||||
if (scope === undefined) return true;
|
||||
if (typeof scope !== "string") return false;
|
||||
if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false;
|
||||
return RELAY_PROJECT_QUERY_PARTS.has(scope);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isRelayDependentQueryKey(queryKey: readonly unknown[]) {
|
||||
const root = queryKey[0];
|
||||
if (typeof root !== "string") return false;
|
||||
if (RELAY_QUERY_ROOTS.has(root)) return true;
|
||||
return isRelayDependentProjectQueryKey(queryKey);
|
||||
}
|
||||
|
||||
export function isRelayDependentQuery(query: { queryKey: readonly unknown[] }) {
|
||||
return isRelayDependentQueryKey(query.queryKey);
|
||||
}
|
||||
@@ -172,3 +172,81 @@ test("channel reconnect replay pages the missed window until a short page", asyn
|
||||
]);
|
||||
assert.equal(delivered.length, 1008);
|
||||
});
|
||||
|
||||
test("reconnect replay starts live REQs in parallel and preserves per-sub page order", async () => {
|
||||
const sentPayloads = [];
|
||||
const sendResolvers = [];
|
||||
const historyFiltersByChannel = {
|
||||
"channel-1": [],
|
||||
"channel-2": [],
|
||||
};
|
||||
const pagesByChannel = {
|
||||
"channel-1": [
|
||||
eventRange("c1-full", 1501, 500),
|
||||
eventRange("c1-short", 1490, 2),
|
||||
],
|
||||
"channel-2": [
|
||||
eventRange("c2-full", 1701, 500),
|
||||
eventRange("c2-short", 1690, 2),
|
||||
],
|
||||
};
|
||||
const subscriptions = new Map([
|
||||
[
|
||||
"live-1",
|
||||
{
|
||||
mode: "live",
|
||||
filter: buildChannelFilter("channel-1", 50),
|
||||
onEvent: () => {},
|
||||
lastSeenCreatedAt: 1000,
|
||||
},
|
||||
],
|
||||
[
|
||||
"live-2",
|
||||
{
|
||||
mode: "live",
|
||||
filter: buildChannelFilter("channel-2", 50),
|
||||
onEvent: () => {},
|
||||
lastSeenCreatedAt: 1000,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const replayPromise = replayLiveSubscriptions({
|
||||
subscriptions,
|
||||
now: 2000,
|
||||
pageReplayConcurrency: 2,
|
||||
sendRaw: (payload) => {
|
||||
sentPayloads.push(payload);
|
||||
return new Promise((resolve) => {
|
||||
sendResolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
requestHistory: async (filter) => {
|
||||
const channelId = filter["#h"]?.[0];
|
||||
historyFiltersByChannel[channelId].push(filter.until);
|
||||
return pagesByChannel[channelId].shift() ?? [];
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(
|
||||
sentPayloads.map((payload) => payload[1]),
|
||||
["live-1", "live-2"],
|
||||
);
|
||||
assert.equal(sendResolvers.length, 2);
|
||||
assert.deepEqual(historyFiltersByChannel, {
|
||||
"channel-1": [],
|
||||
"channel-2": [],
|
||||
});
|
||||
|
||||
for (const resolve of sendResolvers) {
|
||||
resolve();
|
||||
}
|
||||
await replayPromise;
|
||||
|
||||
assert.deepEqual(historyFiltersByChannel, {
|
||||
"channel-1": [2000, 1501],
|
||||
"channel-2": [2000, 1701],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,25 @@ import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
const RECONNECT_REPLAY_SKEW_SECS = 5;
|
||||
export const RECONNECT_REPLAY_PAGE_LIMIT = 500;
|
||||
export const RECONNECT_REPLAY_PAGE_CONCURRENCY = 4;
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T) => Promise<void>,
|
||||
) {
|
||||
const workerCount = Math.min(Math.max(1, concurrency), items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const item = items[nextIndex++];
|
||||
await worker(item);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildReconnectReplayFilter(
|
||||
filter: RelaySubscriptionFilter,
|
||||
@@ -84,34 +103,60 @@ export async function replayLiveSubscriptions({
|
||||
sendRaw,
|
||||
requestHistory,
|
||||
now = Math.floor(Date.now() / 1_000),
|
||||
pageReplayConcurrency = RECONNECT_REPLAY_PAGE_CONCURRENCY,
|
||||
}: {
|
||||
subscriptions: Map<string, RelaySubscription>;
|
||||
sendRaw: (payload: unknown[]) => Promise<void>;
|
||||
requestHistory: (filter: RelaySubscriptionFilter) => Promise<RelayEvent[]>;
|
||||
now?: number;
|
||||
pageReplayConcurrency?: number;
|
||||
}) {
|
||||
for (const [subId, subscription] of subscriptions) {
|
||||
if (subscription.mode !== "live") continue;
|
||||
const replayRequests = Array.from(subscriptions.entries())
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is [string, Extract<RelaySubscription, { mode: "live" }>] =>
|
||||
entry[1].mode === "live",
|
||||
)
|
||||
.map(([subId, subscription]) => {
|
||||
const replaySince =
|
||||
subscription.lastSeenCreatedAt === undefined
|
||||
? undefined
|
||||
: Math.max(
|
||||
0,
|
||||
subscription.lastSeenCreatedAt - RECONNECT_REPLAY_SKEW_SECS,
|
||||
);
|
||||
const shouldPageReplay =
|
||||
replaySince !== undefined &&
|
||||
shouldPageReconnectReplay(subscription.filter);
|
||||
|
||||
const replaySince =
|
||||
subscription.lastSeenCreatedAt === undefined
|
||||
? undefined
|
||||
: Math.max(
|
||||
0,
|
||||
subscription.lastSeenCreatedAt - RECONNECT_REPLAY_SKEW_SECS,
|
||||
);
|
||||
const shouldPageReplay =
|
||||
replaySince !== undefined &&
|
||||
shouldPageReconnectReplay(subscription.filter);
|
||||
await sendRaw([
|
||||
"REQ",
|
||||
subId,
|
||||
shouldPageReplay
|
||||
? subscription.filter
|
||||
: buildReconnectReplayFilter(subscription.filter, replaySince),
|
||||
]);
|
||||
return { subId, subscription, replaySince, shouldPageReplay };
|
||||
});
|
||||
|
||||
if (shouldPageReplay) {
|
||||
await Promise.all(
|
||||
replayRequests.map(
|
||||
({ subId, subscription, replaySince, shouldPageReplay }) =>
|
||||
sendRaw([
|
||||
"REQ",
|
||||
subId,
|
||||
shouldPageReplay
|
||||
? subscription.filter
|
||||
: buildReconnectReplayFilter(subscription.filter, replaySince),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await runWithConcurrency(
|
||||
replayRequests.filter(
|
||||
(
|
||||
request,
|
||||
): request is typeof request & {
|
||||
replaySince: number;
|
||||
shouldPageReplay: true;
|
||||
} => request.shouldPageReplay && request.replaySince !== undefined,
|
||||
),
|
||||
pageReplayConcurrency,
|
||||
async ({ subId, subscription, replaySince }) => {
|
||||
await replayReconnectHistoryPages({
|
||||
subscription,
|
||||
since: replaySince,
|
||||
@@ -119,6 +164,6 @@ export async function replayLiveSubscriptions({
|
||||
isActive: () => subscriptions.get(subId) === subscription,
|
||||
requestHistory,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,6 +169,22 @@ export async function getChannelWorkflows(
|
||||
return raw.map(fromRawWorkflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workflows across many channels in a single relay round-trip.
|
||||
*
|
||||
* Replaces the per-channel `Promise.all(getChannelWorkflows)` fanout on the
|
||||
* Workflows overview: the backend `#h` filter matches any listed channel, and
|
||||
* each returned workflow carries its own `channelId` so callers can group.
|
||||
*/
|
||||
export async function getChannelsWorkflows(
|
||||
channelIds: string[],
|
||||
): Promise<Workflow[]> {
|
||||
const raw = await invokeTauri<RawWorkflow[]>("get_channels_workflows", {
|
||||
channelIds,
|
||||
});
|
||||
return raw.map(fromRawWorkflow);
|
||||
}
|
||||
|
||||
export async function getWorkflow(workflowId: string): Promise<Workflow> {
|
||||
const raw = await invokeTauri<RawWorkflow>("get_workflow", { workflowId });
|
||||
return fromRawWorkflow(raw);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
|
||||
import { relayReconnectController } from "@/shared/api/relayReconnectController";
|
||||
|
||||
function buildDeps(onSuccess: () => void, onBackstop: () => void) {
|
||||
@@ -59,12 +60,14 @@ export function useReconnectRelay(): {
|
||||
onSuccessRef.current = React.useCallback(() => {
|
||||
// Defer query invalidation so callers render the recovered state first.
|
||||
window.setTimeout(() => {
|
||||
void queryClient.invalidateQueries().catch((err) => {
|
||||
console.error(
|
||||
"[useReconnectRelay] failed to refresh queries after reconnect:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
void queryClient
|
||||
.invalidateQueries({ predicate: isRelayDependentQuery })
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
"[useReconnectRelay] failed to refresh queries after reconnect:",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}, 0);
|
||||
}, [queryClient]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
import { isRelayDependentQuery } from "@/shared/api/relayQueryInvalidation";
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
@@ -82,12 +83,12 @@ export class RelayAutoHealScheduler {
|
||||
|
||||
/**
|
||||
* Auto-heal: when the connection recovers from a degraded state, invalidate
|
||||
* all queries so errored queries (e.g. messages, which don't poll) refetch
|
||||
* automatically without requiring a manual reconnect action.
|
||||
* relay-dependent queries so errored queries (e.g. messages, which don't poll)
|
||||
* refetch automatically without requiring a manual reconnect action.
|
||||
*
|
||||
* Rate-limited to prevent a flappy connection (e.g. VPN toggling) from
|
||||
* firing an unfiltered invalidation — ~20-40 requests across active queries
|
||||
* with retry:1 — every time the relay briefly recovers.
|
||||
* firing a relay-wide invalidation across active queries with retry:1 every
|
||||
* time the relay briefly recovers.
|
||||
*
|
||||
* When a recovery is suppressed by the rate limiter (an earlier flap consumed
|
||||
* the budget), a deferred heal is scheduled for the remaining window so the
|
||||
@@ -101,7 +102,10 @@ export function useRelayAutoHeal(): void {
|
||||
|
||||
if (schedulerRef.current === null) {
|
||||
schedulerRef.current = new RelayAutoHealScheduler(
|
||||
() => void queryClient.invalidateQueries(),
|
||||
() =>
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: isRelayDependentQuery,
|
||||
}),
|
||||
AUTO_HEAL_MIN_INTERVAL_MS,
|
||||
window.setTimeout.bind(window),
|
||||
window.clearTimeout.bind(window),
|
||||
|
||||
@@ -4,11 +4,15 @@ import type { Channel } from "@/shared/api/types";
|
||||
|
||||
type ChannelNavigationContextValue = {
|
||||
channels: Channel[];
|
||||
/** Names of non-DM channels, memoised once so render-hot consumers (message
|
||||
* rows) don't each re-filter the full channel list on every render. */
|
||||
nonDmChannelNames: string[];
|
||||
};
|
||||
|
||||
const ChannelNavigationContext =
|
||||
React.createContext<ChannelNavigationContextValue>({
|
||||
channels: [],
|
||||
nonDmChannelNames: [],
|
||||
});
|
||||
|
||||
export function ChannelNavigationProvider({
|
||||
@@ -18,7 +22,15 @@ export function ChannelNavigationProvider({
|
||||
channels: Channel[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const value = React.useMemo(() => ({ channels }), [channels]);
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
channels,
|
||||
nonDmChannelNames: channels
|
||||
.filter((c) => c.channelType !== "dm")
|
||||
.map((c) => c.name),
|
||||
}),
|
||||
[channels],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChannelNavigationContext.Provider value={value}>
|
||||
|
||||
@@ -2372,6 +2372,13 @@ function handleGetChannelWorkflows(args: { channelId: string }) {
|
||||
return mockWorkflows.filter((w) => w.channel_id === args.channelId);
|
||||
}
|
||||
|
||||
function handleGetChannelsWorkflows(args: { channelIds: string[] }) {
|
||||
const ids = new Set(args.channelIds);
|
||||
return mockWorkflows.filter(
|
||||
(w) => w.channel_id != null && ids.has(w.channel_id),
|
||||
);
|
||||
}
|
||||
|
||||
function handleGetWorkflow(args: { workflowId: string }) {
|
||||
const workflow = mockWorkflows.find((w) => w.id === args.workflowId);
|
||||
if (!workflow) throw new Error(`Workflow ${args.workflowId} not found`);
|
||||
@@ -8810,6 +8817,10 @@ export function maybeInstallE2eTauriMocks() {
|
||||
return handleGetChannelWorkflows(
|
||||
payload as Parameters<typeof handleGetChannelWorkflows>[0],
|
||||
);
|
||||
case "get_channels_workflows":
|
||||
return handleGetChannelsWorkflows(
|
||||
payload as Parameters<typeof handleGetChannelsWorkflows>[0],
|
||||
);
|
||||
case "get_workflow":
|
||||
return handleGetWorkflow(
|
||||
payload as Parameters<typeof handleGetWorkflow>[0],
|
||||
|
||||
Reference in New Issue
Block a user