fix(serverless): local-only agents, rate-limit retry, channel delete

- list_relay_agents returns empty in serverless: managed agents are local
  desktop subprocesses, not a relay registry. Fixes 'agent parse failed:
  missing field name' from foreign kind-10100 events on public relays.
- submit_event_ws retries once on all-relays-rate-limited (public relays
  throttle the write burst when adding an agent). Succeeds if any relay
  accepts. Fixes 'Failed to add goose: rate-limited'.
- delete_channel publishes NIP-09 (kind 5) deletion over the 39000/39002
  addressable coordinates in serverless mode (the kind-9008 command is a
  no-op on a generic relay). Owner-only.
- Tests + file-size exception.
This commit is contained in:
Michael Neale
2026-06-02 14:37:02 +10:00
parent 6b756dae1e
commit 8e8f93a85b
5 changed files with 96 additions and 12 deletions

View File

@@ -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

View File

@@ -308,6 +308,16 @@ pub fn discover_managed_agent_prereqs(
#[tauri::command]
pub async fn list_relay_agents(state: State<'_, AppState>) -> Result<Vec<RelayAgentInfo>, 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,

View File

@@ -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(())

View File

@@ -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<EventBuilder, String> {
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<EventBuilder, String> {
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<String> = 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

View File

@@ -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(