diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 6d83ff2376..a9c8532584 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -70,7 +70,7 @@ const overrides = new Map([ ["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow ["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs ["src-tauri/src/commands/channels.rs", 670], // serverless membership now carries roles (creator=owner) in kind:39002 p-tags + serverless membership read-modify-write of kind:39002 (join/leave/add/remove publish member list directly) + create_channel branches serverless (publish 39000+39002 directly) vs server (kind 9007 command) + channel CRUD commands // create_channel branches serverless (publish 39000+39002 directly) vs server (kind 9007 command) + channel CRUD commands - ["src-tauri/src/events.rs", 1070], // + role-aware serverless members builder (owner role) + tests // + self-p-tag regression tests (allow_self_tagging on 39000/39002) // serverless addressable builders (build_channel_metadata_serverless 39000 + build_channel_members_serverless 39002) + their tests + event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + NIP-IA identity-archive builders (9035/9036) + .allow_self_tagging() guards (nostr 0.44 strips self-`p` by default; self-archive/unarchive needs it preserved) + spec vector 1 layout test + ["src-tauri/src/events.rs", 1110], // + serverless channel delete (NIP-09 kind 5 over 39000/39002 coords) + role-aware serverless members builder (owner role) + tests // + self-p-tag regression tests (allow_self_tagging on 39000/39002) // serverless addressable builders (build_channel_metadata_serverless 39000 + build_channel_members_serverless 39002) + their tests + event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + NIP-IA identity-archive builders (9035/9036) + .allow_self_tagging() guards (nostr 0.44 strips self-`p` by default; self-archive/unarchive needs it preserved) + spec vector 1 layout test ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring ["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block ["src-tauri/src/huddle/stt.rs", 580], // STT pipeline + PTT edge-detection flush + PTT gating (is_speech AND ptt_active) + barge-in for VAD mode + rubato resampler + earshot VAD + sherpa-onnx transcription diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index efc4ed308b..a83018f668 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -308,6 +308,16 @@ pub fn discover_managed_agent_prereqs( #[tauri::command] pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { + // Relay-side agent discovery (kind:10100 registry) is a Sprout-server + // concept. In serverless mode, managed agents are purely local desktop + // subprocesses — there is no server registry to discover, and querying a + // generic public relay for kind:10100 returns foreign/malformed events + // (the "agent parse failed: missing field `name`" bug). The real agent + // list comes from `list_managed_agents`; this overlay is simply empty. + if state.is_serverless() { + return Ok(Vec::new()); + } + // Query kind:10100 agent profile events from the relay. let events = query_relay( &state, diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 3e2c2a47c9..6def953082 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -505,6 +505,19 @@ pub async fn unarchive_channel( #[tauri::command] pub async fn delete_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> { let uuid = parse_channel_uuid(&channel_id)?; + if state.is_serverless() { + // No relay to process the kind:9008 delete command. Publish a NIP-09 + // (kind 5) deletion targeting the channel's addressable 39000/39002 + // coordinates so relays drop them. Only the owner (signer of those + // events) can effectively delete. + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + let builder = events::build_delete_channel_serverless(&uuid.to_string(), &my_pubkey)?; + submit_event(builder, &state).await?; + return Ok(()); + } let builder = events::build_delete_channel(uuid)?; submit_event(builder, &state).await?; Ok(()) diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 200dfa9e4d..7a3e7d0de9 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -299,6 +299,22 @@ pub fn build_message_edit( Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags)) } +/// Kind 5 — NIP-09 deletion of a serverless channel. Targets the addressable +/// `a` coordinates of the channel's kind:39000 metadata and kind:39002 member +/// list (both signed by `owner_pubkey_hex`), which generic relays honor. +pub fn build_delete_channel_serverless( + channel_id: &str, + owner_pubkey_hex: &str, +) -> Result { + let meta_coord = format!("39000:{owner_pubkey_hex}:{channel_id}"); + let members_coord = format!("39002:{owner_pubkey_hex}:{channel_id}"); + let tags = vec![ + tag(vec!["a", &meta_coord])?, + tag(vec!["a", &members_coord])?, + ]; + Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags)) +} + /// Kind 5 — NIP-09 deletion (messages). pub fn build_delete_compat(target_event_id: EventId) -> Result { let tags = vec![tag(vec!["e", &target_event_id.to_hex()])?]; @@ -999,6 +1015,26 @@ mod tests { ); } + #[test] + fn serverless_delete_channel_targets_addressable_coords() { + // Deletion must reference both the 39000 metadata and 39002 members + // addressable coordinates so a generic relay drops them. + let event = sign(build_delete_channel_serverless("chan-del", PK_A).unwrap()); + assert_eq!(event.kind, Kind::Custom(5)); + let coords: Vec = event + .tags + .iter() + .filter_map(|t| { + let s = t.as_slice(); + (s.first().map(String::as_str) == Some("a")) + .then(|| s.get(1).cloned()) + .flatten() + }) + .collect(); + assert!(coords.contains(&format!("39000:{PK_A}:chan-del"))); + assert!(coords.contains(&format!("39002:{PK_A}:chan-del"))); + } + #[test] fn serverless_members_with_roles_records_owner() { // The creator must be recorded as `owner` (4th element of the p tag) so diff --git a/desktop/src-tauri/src/ws_relay.rs b/desktop/src-tauri/src/ws_relay.rs index 0b4d0a1c09..39a9a958a1 100644 --- a/desktop/src-tauri/src/ws_relay.rs +++ b/desktop/src-tauri/src/ws_relay.rs @@ -184,20 +184,45 @@ pub async fn submit_event_ws( .sign_with_keys(&keys) .map_err(|e| format!("failed to sign event: {e}"))?; - let futures = relay_urls - .iter() - .map(|url| submit_event_ws_one(&event, &keys, url)); - let results = futures_util::future::join_all(futures).await; + // Publish to all relays, succeeding if any accepts. Public relays often + // rate-limit bursts ("noting too much"); since the same event going to N + // relays is idempotent (dedup by id), a transient rate-limit on one relay + // is fine as long as another accepts. If ALL relays rate-limit, retry once + // after a short backoff — adding an agent fires several writes in quick + // succession, which is the usual trigger. + for attempt in 0..2 { + let futures = relay_urls + .iter() + .map(|url| submit_event_ws_one(&event, &keys, url)); + let results = futures_util::future::join_all(futures).await; - let mut last_err = None; - for r in results { - match r { - Ok(resp) if resp.accepted => return Ok(resp), - Ok(resp) => last_err = Some(format!("relay rejected event: {}", resp.message)), - Err(e) => last_err = Some(e), + let mut last_err = None; + let mut all_rate_limited = !results.is_empty(); + for r in results { + match r { + Ok(resp) if resp.accepted => return Ok(resp), + Ok(resp) => { + if !resp.message.contains("rate-limit") { + all_rate_limited = false; + } + last_err = Some(format!("relay rejected event: {}", resp.message)); + } + Err(e) => { + all_rate_limited = false; + last_err = Some(e); + } + } } + + // Only the rate-limit case is worth retrying; other rejections won't + // change on retry. + if attempt == 0 && all_rate_limited { + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + continue; + } + return Err(last_err.unwrap_or_else(|| "all relays failed".to_string())); } - Err(last_err.unwrap_or_else(|| "all relays failed".to_string())) + unreachable!("loop returns on both attempts") } async fn submit_event_ws_one(