diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 325eb9aa67..ecf2fdbee0 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1076,6 +1076,7 @@ dependencies = [ "security-framework 3.7.0", "serde", "serde_json", + "serde_path_to_error", "serde_yaml", "sha2 0.11.0", "sherpa-onnx", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6f3c03c5a5..684bd80499 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -84,6 +84,9 @@ opus = "0.3" neteq = { version = "0.8", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" +# Names the failing field path in directory-parse skip warns (already in the +# tree as a transitive dep). +serde_path_to_error = "0.1" serde_yaml = "0.9" toml = "0.8" nostr = { version = "0.44", features = ["nip44", "nip49"] } diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..d44d3169c8 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1068,13 +1068,107 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result Vec { + let serde_json::Value::Array(items) = agents else { + return Vec::new(); + }; + let total = items.len(); + let parsed: Vec = items + .into_iter() + .filter_map(|item| { + let pubkey_hint = item + .get("pubkey") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + .to_owned(); + // serde_path_to_error names the offending field (e.g. + // `channel_add_policy: invalid type: integer`) so operators can + // spot schema drift from the warn alone. + match serde_path_to_error::deserialize::<_, RelayAgentInfo>(item) { + Ok(agent) => Some(agent), + Err(e) => { + tracing::warn!( + "list_relay_agents: skipping unparseable agent profile ({pubkey_hint}): {e}" + ); + None + } + } + }) + .collect(); + // One aggregate line per query so a poisoned directory is visible at a + // glance without counting per-entry warns. + let skipped = total - parsed.len(); + if skipped > 0 { + tracing::warn!( + "list_relay_agents: kept {kept} of {total} agent profiles, skipped {skipped} unparseable", + kept = parsed.len() + ); + } + parsed } #[cfg(test)] mod tests { use super::*; + // ── parse_relay_agents ──────────────────────────────────────────────────── + + fn directory_entry(name: &str) -> serde_json::Value { + serde_json::json!({ + "pubkey": "ab".repeat(32), + "name": name, + "agent_type": "agent", + "channels": [], + "capabilities": [], + "status": "online", + }) + } + + #[test] + fn parse_relay_agents_parses_valid_entries() { + let parsed = parse_relay_agents(serde_json::json!([ + directory_entry("Scout"), + directory_entry("Rover"), + ])); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].name, "Scout"); + assert_eq!(parsed[1].name, "Rover"); + } + + #[test] + fn parse_relay_agents_skips_malformed_entries_and_keeps_the_rest() { + // One profile with a mistyped field (respond_to must be a string enum) + // must not blank the directory — the other agents still parse. + let mut poisoned = directory_entry("Broken"); + poisoned["respond_to"] = serde_json::json!(123); + + let parsed = parse_relay_agents(serde_json::json!([ + directory_entry("Scout"), + poisoned, + directory_entry("Rover"), + ])); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0].name, "Scout"); + assert_eq!(parsed[1].name, "Rover"); + } + + #[test] + fn parse_relay_agents_returns_empty_for_non_array_input() { + assert!(parse_relay_agents(serde_json::json!(null)).is_empty()); + assert!(parse_relay_agents(serde_json::json!({})).is_empty()); + } + // ── is_npm_global_install ───────────────────────────────────────────────── #[test]