Skip to main content

wire/cli/
relay.rs

1use anyhow::{Context, Result, anyhow, bail};
2use serde_json::{Value, json};
3
4use super::setup;
5use crate::{config, signing::sign_message_v31};
6
7// ---------- mcp / relay-server stubs ----------
8
9pub(super) fn cmd_mcp() -> Result<()> {
10    crate::mcp::run()
11}
12
13pub(super) fn cmd_relay_server(
14    bind: &str,
15    local_only: bool,
16    uds: Option<&std::path::Path>,
17) -> Result<()> {
18    // v0.7.0-alpha.16: --uds <path> takes the UDS transport path,
19    // overriding --bind. Implies --local-only semantics. Routed to a
20    // separate serve_uds entry point with a manual hyper accept loop
21    // (axum 0.7's `serve` is TcpListener-only).
22    if let Some(socket_path) = uds {
23        let base = if let Ok(home) = std::env::var("WIRE_HOME") {
24            std::path::PathBuf::from(home)
25                .join("state")
26                .join("wire-relay")
27                .join("uds")
28        } else {
29            dirs::state_dir()
30                .or_else(dirs::data_local_dir)
31                .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
32                .join("wire-relay")
33                .join("uds")
34        };
35        let runtime = tokio::runtime::Builder::new_multi_thread()
36            .enable_all()
37            .build()?;
38        return runtime.block_on(crate::relay_server::serve_uds(
39            socket_path.to_path_buf(),
40            base,
41        ));
42    }
43    // v0.5.17: --local-only refuses non-loopback binds. Catches the
44    // "wait did I just bind a publicly-reachable local-only relay" mistake
45    // at startup rather than discovering it via an empty phonebook later.
46    if local_only {
47        validate_loopback_bind(bind)?;
48    }
49    // Default state dir for the relay process: $WIRE_HOME/state/wire-relay
50    // (or `dirs::state_dir()/wire-relay`). Distinct from the CLI's state dir
51    // so a single user can run both client and server on one machine.
52    // For --local-only, suffix with /local so a single operator can run
53    // both a federation relay and a local-only relay without state collision.
54    let base = if let Ok(home) = std::env::var("WIRE_HOME") {
55        std::path::PathBuf::from(home)
56            .join("state")
57            .join("wire-relay")
58    } else {
59        dirs::state_dir()
60            .or_else(dirs::data_local_dir)
61            .ok_or_else(|| anyhow::anyhow!("could not resolve XDG_STATE_HOME — set WIRE_HOME"))?
62            .join("wire-relay")
63    };
64    let state_dir = if local_only { base.join("local") } else { base };
65    let runtime = tokio::runtime::Builder::new_multi_thread()
66        .enable_all()
67        .build()?;
68    runtime.block_on(crate::relay_server::serve_with_mode(
69        bind,
70        state_dir,
71        crate::relay_server::ServerMode { local_only },
72    ))
73}
74
75/// v0.5.17 loopback-bind guard. Refuses any address whose host portion
76/// resolves to something outside `127.0.0.0/8` or `::1`.
77///
78/// v0.7.0-alpha.11: relaxed to also accept RFC 1918 private IPv4
79/// (10/8, 172.16/12, 192.168/16) so `wire relay-server --bind
80/// <LAN-IP>:8772 --local-only` works for the alpha.9 LAN feature.
81///
82/// v0.7.0-alpha.15: also accept RFC 6598 CGNAT (100.64.0.0/10), which
83/// is the IP range Tailscale uses for tailnet addresses. Lets operators
84/// pair wire across machines using their tailnet IPs (e.g. Mac at
85/// 100.96.234.16, Spark at 100.91.57.17) — Tailscale handles
86/// auth + encryption + NAT traversal, wire handles protocol + identity.
87/// Sidesteps host firewall config entirely (utun interface bypass).
88///
89/// Still refuses: public IPv4/IPv6, wildcards (0.0.0.0/::), link-local,
90/// multicast, broadcast. Those would publish a "local-only" relay to
91/// the global internet — the v0.5.17 security gate's whole point.
92fn validate_loopback_bind(bind: &str) -> Result<()> {
93    // Split host:port. IPv6 literals use `[::]:port` form.
94    let host = if let Some(stripped) = bind.strip_prefix('[') {
95        let close = stripped
96            .find(']')
97            .ok_or_else(|| anyhow::anyhow!("malformed IPv6 bind {bind:?}"))?;
98        stripped[..close].to_string()
99    } else {
100        bind.rsplit_once(':')
101            .map(|(h, _)| h.to_string())
102            .unwrap_or_else(|| bind.to_string())
103    };
104    use std::net::{IpAddr, ToSocketAddrs};
105    let probe = format!("{host}:0");
106    let resolved: Vec<_> = probe
107        .to_socket_addrs()
108        .with_context(|| format!("resolving bind host {host:?}"))?
109        .collect();
110    if resolved.is_empty() {
111        bail!("--local-only: bind host {host:?} resolved to no addresses");
112    }
113    for addr in &resolved {
114        let ip = addr.ip();
115        let is_acceptable = match ip {
116            IpAddr::V4(v4) => {
117                v4.is_loopback() || v4.is_private() || {
118                    // RFC 6598 CGNAT / Tailscale range: 100.64.0.0/10
119                    let octets = v4.octets();
120                    octets[0] == 100 && (64..=127).contains(&octets[1])
121                }
122            }
123            IpAddr::V6(v6) => v6.is_loopback(), // ULA + Tailscale-v6 deferred
124        };
125        if !is_acceptable {
126            bail!(
127                "--local-only refuses non-private bind: {host:?} resolves to {ip} \
128                 which is not loopback (127/8, ::1), RFC 1918 private \
129                 (10/8, 172.16/12, 192.168/16), or RFC 6598 CGNAT/Tailscale \
130                 (100.64.0.0/10). Remove --local-only to bind publicly."
131            );
132        }
133    }
134    Ok(())
135}
136
137// ---------- bind-relay ----------
138
139fn parse_scope(s: &str) -> Result<crate::endpoints::EndpointScope> {
140    use crate::endpoints::EndpointScope;
141    match s.to_lowercase().as_str() {
142        "federation" | "fed" => Ok(EndpointScope::Federation),
143        "local" => Ok(EndpointScope::Local),
144        "lan" => Ok(EndpointScope::Lan),
145        "uds" => Ok(EndpointScope::Uds),
146        other => bail!("unknown --scope `{other}` (expected federation|local|lan|uds)"),
147    }
148}
149
150/// v0.12: bind a relay slot. ADDITIVE by default — the new slot is
151/// appended to `self.endpoints[]`, keeping any existing slots so an agent
152/// can hold a local relay AND a federation relay simultaneously without
153/// black-holing pinned peers. `--replace` restores the pre-v0.12
154/// destructive single-slot behavior (guarded by issue #7).
155pub(crate) fn cmd_bind_relay(
156    url: &str,
157    scope: Option<&str>,
158    replace: bool,
159    migrate_pinned: bool,
160    as_json: bool,
161) -> Result<()> {
162    use crate::endpoints::{Endpoint, self_endpoints};
163
164    if !config::is_initialized()? {
165        bail!("not initialized — run `wire up` first");
166    }
167    let card = config::read_agent_card()?;
168    let did = card.get("did").and_then(Value::as_str).unwrap_or("");
169    let handle = crate::agent_card::display_handle_from_did(did).to_string();
170
171    let normalized_raw = url.trim_end_matches('/');
172    // Refuse to record/publish a relay endpoint that embeds userinfo —
173    // `https://<handle>@<host>` 4xxes every inbound event POST. Strip and
174    // warn so operators learn the right shape without losing the call.
175    let normalized_owned = setup::strip_relay_url_userinfo(normalized_raw);
176    let normalized = normalized_owned.as_str();
177    // Belt-and-suspenders: confirm the post-strip URL is clean before any
178    // persist / publish. A future code path that bypasses the strip filter
179    // MUST NOT be able to leak userinfo into the signed agent-card.
180    setup::assert_relay_url_clean_for_publish(normalized)?;
181    let new_scope = match scope {
182        Some(s) => parse_scope(s)?,
183        None => crate::endpoints::infer_scope_from_url(normalized),
184    };
185
186    let existing = config::read_relay_state().unwrap_or_else(|_| json!({}));
187    let pinned: Vec<String> = existing
188        .get("peers")
189        .and_then(|p| p.as_object())
190        .map(|o| o.keys().cloned().collect())
191        .unwrap_or_default();
192
193    let existing_eps = self_endpoints(&existing);
194    let is_rebind_same = existing_eps.iter().any(|e| e.relay_url == normalized);
195
196    // Destructive paths that black-hole pinned peers (issue #7):
197    //   • `--replace` drops every other slot.
198    //   • re-binding the SAME relay rotates that slot in place.
199    // An additive bind of a NEW relay keeps existing slots, so peers stay
200    // reachable — no acknowledgement required. This is the v0.12 default
201    // that unblocks simultaneous local + remote.
202    let destructive = replace || is_rebind_same;
203    if destructive && !pinned.is_empty() && !migrate_pinned {
204        let list = pinned.join(", ");
205        let why = if replace {
206            "`--replace` drops your other slot(s)"
207        } else {
208            "re-binding the same relay rotates its slot"
209        };
210        bail!(
211            "bind-relay would black-hole {n} pinned peer(s): {list}. {why}; they are \
212             pinned to your CURRENT slot and would keep pushing to a slot you no longer \
213             read.\n\n\
214             SAFE PATHS:\n\
215             • Default (omit `--replace`) ADDITIVELY binds a NEW relay, keeping existing \
216             slots — no black-hole.\n\
217             • `wire rotate-slot` — same-relay rotation that emits wire_close to peers.\n\
218             • `wire bind-relay {url} --migrate-pinned` — proceed anyway; re-pair each \
219             peer out-of-band.\n\n\
220             Issue #7 (silent black-hole on relay change) caught this.",
221            n = pinned.len(),
222        );
223    }
224
225    let client = crate::relay_client::RelayClient::new(normalized);
226    client.check_healthz()?;
227    let alloc = client.allocate_slot(Some(&handle))?;
228
229    if destructive && !pinned.is_empty() {
230        eprintln!(
231            "wire bind-relay: {mode} with {n} pinned peer(s) — they will black-hole \
232             until they re-pin: {peers}",
233            mode = if replace { "replacing" } else { "rotating" },
234            n = pinned.len(),
235            peers = pinned.join(", "),
236        );
237    }
238
239    // Write the new slot via the single source of truth for the self-slot
240    // shape. Additive by default; --replace starts from an empty self so
241    // only this slot remains.
242    let mut state = existing;
243    if replace {
244        state["self"] = Value::Null;
245    }
246    crate::endpoints::upsert_self_endpoint(
247        &mut state,
248        Endpoint {
249            relay_url: normalized.to_string(),
250            slot_id: alloc.slot_id.clone(),
251            slot_token: alloc.slot_token.clone(),
252            scope: new_scope,
253        },
254    );
255    config::write_relay_state(&state)?;
256    let eps = self_endpoints(&state);
257
258    let scope_str = format!("{new_scope:?}").to_lowercase();
259    if as_json {
260        println!(
261            "{}",
262            serde_json::to_string(&json!({
263                "relay_url": normalized,
264                "slot_id": alloc.slot_id,
265                "scope": scope_str,
266                "endpoints": eps.len(),
267                "additive": !replace,
268                "slot_token_present": true,
269            }))?
270        );
271    } else {
272        println!(
273            "bound {scope_str} slot on {normalized} (slot {})",
274            alloc.slot_id
275        );
276        println!(
277            "self now has {n} endpoint(s): {list}",
278            n = eps.len(),
279            list = eps
280                .iter()
281                .map(|e| format!("{}({:?})", e.relay_url, e.scope))
282                .collect::<Vec<_>>()
283                .join(", "),
284        );
285    }
286    Ok(())
287}
288
289// ---------- add-peer-slot ----------
290
291pub(super) fn cmd_add_peer_slot(
292    handle: &str,
293    url: &str,
294    slot_id: &str,
295    slot_token: &str,
296    as_json: bool,
297) -> Result<()> {
298    use crate::endpoints::{Endpoint, infer_scope_from_url, pin_peer_endpoints};
299    let mut state = config::read_relay_state()?;
300
301    // E3 (v0.13.2): ADD this slot to the peer's endpoint set — don't REPLACE
302    // the whole entry. The old flat `peers.insert` clobbered an existing
303    // peer's federation endpoint when pinning a local slot, silently dropping
304    // the federation route (glossy-magnolia + wisp-blossom repro: pinning a
305    // loopback slot made the peer flat loopback-only). Mirror bind-relay's
306    // additive semantics: upsert by relay_url into the peer's endpoints[].
307    let new_ep = Endpoint {
308        relay_url: url.to_string(),
309        slot_id: slot_id.to_string(),
310        slot_token: slot_token.to_string(),
311        scope: infer_scope_from_url(url),
312    };
313    // RFC-006 Part B: `endpoints[]` is the single peer-routing source — no flat
314    // fallback (every pin carries `endpoints[]`).
315    let mut endpoints: Vec<Endpoint> = state
316        .get("peers")
317        .and_then(|p| p.get(handle))
318        .and_then(|e| e.get("endpoints"))
319        .and_then(|a| serde_json::from_value::<Vec<Endpoint>>(a.clone()).ok())
320        .unwrap_or_default();
321    // Upsert by relay_url: refresh in place if already pinned, else append.
322    if let Some(existing) = endpoints
323        .iter_mut()
324        .find(|e| e.relay_url == new_ep.relay_url)
325    {
326        *existing = new_ep;
327    } else {
328        endpoints.push(new_ep);
329    }
330    let n = endpoints.len();
331    pin_peer_endpoints(&mut state, handle, &endpoints)?;
332    config::write_relay_state(&state)?;
333    if as_json {
334        println!(
335            "{}",
336            serde_json::to_string(&json!({
337                "handle": handle,
338                "relay_url": url,
339                "slot_id": slot_id,
340                "added": true,
341                "endpoint_count": n,
342            }))?
343        );
344    } else {
345        println!(
346            "pinned peer slot for {handle} at {url} ({slot_id}) — peer now has {n} endpoint(s)"
347        );
348    }
349    Ok(())
350}
351
352// ---------- push ----------
353
354pub(super) fn cmd_push(peer_filter: Option<&str>, as_json: bool) -> Result<()> {
355    let mut state = config::read_relay_state()?;
356    let peers = state["peers"].as_object().cloned().unwrap_or_default();
357    if peers.is_empty() {
358        bail!(
359            "no peer slots pinned — run `wire add-peer-slot <handle> <url> <slot_id> <token>` first"
360        );
361    }
362    let outbox_dir = config::outbox_dir()?;
363    // v0.5.13 loud-fail: warn on outbox files that don't match a pinned peer.
364    // Pre-v0.5.13 `wire send peer@relay` wrote to `peer@relay.jsonl` while
365    // push only enumerated bare-handle files. After upgrade, stale FQDN-named
366    // files sit on disk forever; warn so operator can `cat fqdn.jsonl >> handle.jsonl`.
367    if outbox_dir.exists() {
368        let pinned: std::collections::HashSet<String> = peers.keys().cloned().collect();
369        for entry in std::fs::read_dir(&outbox_dir)?.flatten() {
370            let path = entry.path();
371            if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
372                continue;
373            }
374            let stem = match path.file_stem().and_then(|s| s.to_str()) {
375                Some(s) => s.to_string(),
376                None => continue,
377            };
378            if pinned.contains(&stem) {
379                continue;
380            }
381            // Try the bare-handle of the orphaned stem — if THAT matches a
382            // pinned peer, the stem is a stale FQDN-suffixed file.
383            let bare = crate::agent_card::bare_handle(&stem);
384            if pinned.contains(bare) {
385                eprintln!(
386                    "wire push: WARN stale outbox file `{}.jsonl` not enumerated (pinned peer is `{bare}`). \
387                     Merge with: `cat {} >> {}` then delete the FQDN file.",
388                    stem,
389                    path.display(),
390                    outbox_dir.join(format!("{bare}.jsonl")).display(),
391                );
392            }
393        }
394    }
395    if !outbox_dir.exists() {
396        if as_json {
397            println!(
398                "{}",
399                serde_json::to_string(&json!({"pushed": [], "skipped": []}))?
400            );
401        } else {
402            println!("phyllis: nothing to dial out — write a message first with `wire send`");
403        }
404        return Ok(());
405    }
406
407    let mut pushed = Vec::new();
408    let mut skipped = Vec::new();
409
410    // Issue #15: track which peers we've already re-resolved this push call
411    // so we don't whois more than once per peer per push (the rate limit the
412    // issue specifies). Lifetime is the whole `cmd_push` invocation; clears
413    // every time the operator (or daemon) runs `wire push` again.
414    let mut rotated_this_push: std::collections::HashSet<String> = std::collections::HashSet::new();
415    // Track whether we mutated `state` so we can write it back exactly
416    // once at the end (avoids a write per peer).
417    let mut state_dirty = false;
418
419    // v0.5.17: walk each peer's pinned endpoints in priority order (local
420    // first if we share a local relay, federation second). Try POST on the
421    // first endpoint; on transport failure, fall through to the next.
422    // Falls back to the v0.5.16 legacy single-endpoint code path when the
423    // peer record carries no `endpoints[]` array (back-compat).
424    for (peer_handle, _) in peers.iter() {
425        if let Some(want) = peer_filter
426            && peer_handle != want
427        {
428            continue;
429        }
430        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
431        if !outbox.exists() {
432            continue;
433        }
434        let mut ordered_endpoints =
435            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
436        if ordered_endpoints.is_empty() {
437            // Unreachable peer (no federation endpoint AND our local
438            // relay doesn't match the peer's). Skip with a loud reason
439            // rather than silently dropping events.
440            for line in std::fs::read_to_string(&outbox).unwrap_or_default().lines() {
441                let event: Value = match serde_json::from_str(line) {
442                    Ok(v) => v,
443                    Err(_) => continue,
444                };
445                let event_id = event
446                    .get("event_id")
447                    .and_then(Value::as_str)
448                    .unwrap_or("")
449                    .to_string();
450                skipped.push(json!({
451                    "peer": peer_handle,
452                    "event_id": event_id,
453                    "reason": "no reachable endpoint pinned for peer",
454                }));
455            }
456            continue;
457        }
458        let body = std::fs::read_to_string(&outbox)?;
459        for line in body.lines() {
460            let event: Value = match serde_json::from_str(line) {
461                Ok(v) => v,
462                Err(_) => continue,
463            };
464            let event_id = event
465                .get("event_id")
466                .and_then(Value::as_str)
467                .unwrap_or("")
468                .to_string();
469
470            // Capture the most recent per-endpoint error reason via a RefCell
471            // so we can preserve cmd_push's pre-existing "last-error wins"
472            // semantics for the skipped-with-reason path. The shared
473            // try_post_event_with_failover helper (from #62) handles iteration,
474            // priority order, and early-return on first success; the closure
475            // applies the existing `format_transport_error` formatting on
476            // each individual error so the operator sees the same diagnostic
477            // text as before the dedup.
478            let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
479            match crate::relay_client::try_post_event_with_failover(
480                &ordered_endpoints,
481                &event,
482                |endpoint, ev| {
483                    let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
484                    match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
485                        Ok(resp) => Ok(resp),
486                        Err(e) => {
487                            *last_err.borrow_mut() =
488                                Some(crate::relay_client::format_transport_error(&e));
489                            Err(e)
490                        }
491                    }
492                },
493            ) {
494                Ok((endpoint, resp)) => {
495                    if resp.status == "duplicate" {
496                        skipped.push(json!({
497                            "peer": peer_handle,
498                            "event_id": event_id,
499                            "reason": "duplicate",
500                            "endpoint": endpoint.relay_url,
501                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
502                        }));
503                    } else {
504                        pushed.push(json!({
505                            "peer": peer_handle,
506                            "event_id": event_id,
507                            "endpoint": endpoint.relay_url,
508                            "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
509                        }));
510                    }
511                }
512                Err(_) => {
513                    // Issue #15: before reporting the event as skipped, see
514                    // if the failure smelled like a slot-rotation (4xx 404 /
515                    // 410). If yes AND we haven't already re-resolved this
516                    // peer in this push call, attempt one whois lookup. On
517                    // a real rotation, the helper updates `state.peers[peer]`
518                    // in place; we refresh `ordered_endpoints` from the
519                    // mutated state and retry the same event once. Composes
520                    // with the doctor #14 staleness check from PR #68: #14
521                    // surfaces the symptom, #15 closes the loop.
522                    let last_err_text = last_err.borrow().clone().unwrap_or_default();
523                    let mut delivered_via_retry: Option<(crate::endpoints::Endpoint, _)> = None;
524                    match try_reresolve_peer_on_slot_4xx(
525                        &mut state,
526                        peer_handle,
527                        &last_err_text,
528                        &rotated_this_push,
529                    ) {
530                        Ok(true) => {
531                            // Mark this peer as already re-resolved this push.
532                            rotated_this_push.insert(peer_handle.clone());
533                            state_dirty = true;
534                            // Refresh endpoints from the updated state and
535                            // retry exactly once. last_err is also reset so
536                            // the retry's error (if any) replaces the prior
537                            // one in the eventual skipped reason.
538                            ordered_endpoints = crate::endpoints::peer_endpoints_in_priority_order(
539                                &state,
540                                peer_handle,
541                            );
542                            *last_err.borrow_mut() = None;
543                            if let Ok((endpoint, resp)) =
544                                crate::relay_client::try_post_event_with_failover(
545                                    &ordered_endpoints,
546                                    &event,
547                                    |endpoint, ev| {
548                                        let client = crate::relay_client::RelayClient::new(
549                                            &endpoint.relay_url,
550                                        );
551                                        match client.post_event(
552                                            &endpoint.slot_id,
553                                            &endpoint.slot_token,
554                                            ev,
555                                        ) {
556                                            Ok(resp) => Ok(resp),
557                                            Err(e) => {
558                                                *last_err.borrow_mut() = Some(
559                                                    crate::relay_client::format_transport_error(&e),
560                                                );
561                                                Err(e)
562                                            }
563                                        }
564                                    },
565                                )
566                            {
567                                delivered_via_retry = Some((endpoint, resp));
568                            }
569                        }
570                        Ok(false) => {
571                            // Either not a slot-rotation shape, or already
572                            // re-resolved this push, or slot id unchanged —
573                            // fall through to the original skipped path.
574                        }
575                        Err(e) => {
576                            // Re-resolve itself failed (DNS down, relay 5xx,
577                            // handle unclaimed, etc.). Don't fail the push —
578                            // fall through to skipped with the resolve error
579                            // appended for diagnostic context.
580                            *last_err.borrow_mut() = Some(format!(
581                                "{}; re-resolve also failed: {e:#}",
582                                last_err.borrow().clone().unwrap_or_default()
583                            ));
584                            // Mark as tried so we don't loop on the next event.
585                            rotated_this_push.insert(peer_handle.clone());
586                        }
587                    }
588                    if let Some((endpoint, resp)) = delivered_via_retry {
589                        if resp.status == "duplicate" {
590                            skipped.push(json!({
591                                "peer": peer_handle,
592                                "event_id": event_id,
593                                "reason": "duplicate",
594                                "endpoint": endpoint.relay_url,
595                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
596                                "via": "slot_reresolve_retry",
597                            }));
598                        } else {
599                            pushed.push(json!({
600                                "peer": peer_handle,
601                                "event_id": event_id,
602                                "endpoint": endpoint.relay_url,
603                                "scope": serde_json::to_value(endpoint.scope).unwrap_or(json!("?")),
604                                "via": "slot_reresolve_retry",
605                            }));
606                        }
607                    } else {
608                        // Every endpoint failed even after (any) retry.
609                        // Preserve the prior "last reason is what gets
610                        // reported" UX (the closure captured the last per-
611                        // endpoint error via `last_err`).
612                        skipped.push(json!({
613                            "peer": peer_handle,
614                            "event_id": event_id,
615                            "reason": last_err
616                                .borrow()
617                                .clone()
618                                .unwrap_or_else(|| "all endpoints failed".to_string()),
619                        }));
620                    }
621                }
622            }
623        }
624        // Drain delivered events from this peer's outbox (same as run_sync_push):
625        // stops re-POSTing delivered events on the next push and bounds the file
626        // to the genuine backlog. Best-effort.
627        if let Err(e) = config::drain_outbox_delivered(peer_handle) {
628            eprintln!("wire push: WARN outbox drain for {peer_handle} failed: {e:#}");
629        }
630    }
631
632    // Issue #15: persist any in-place slot rotations from the per-peer loop
633    // exactly once at the end. Best-effort: if the write fails the operator
634    // still gets a valid push report, and the next push will re-attempt the
635    // resolve (cheap) before retrying delivery.
636    if state_dirty && let Err(e) = config::write_relay_state(&state) {
637        eprintln!(
638            "wire push: WARN failed to persist rotated peer slots: {e:#}. \
639             Slot rotation will be re-attempted on next push."
640        );
641    }
642
643    if as_json {
644        println!(
645            "{}",
646            serde_json::to_string(&json!({"pushed": pushed, "skipped": skipped}))?
647        );
648    } else {
649        println!(
650            "pushed {} event(s); skipped {} ({})",
651            pushed.len(),
652            skipped.len(),
653            if skipped.is_empty() {
654                "none"
655            } else {
656                "see --json for detail"
657            }
658        );
659    }
660    Ok(())
661}
662
663// ---------- pull ----------
664
665pub(super) fn cmd_pull(as_json: bool) -> Result<()> {
666    let state = config::read_relay_state()?;
667    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
668    if self_state.is_null() {
669        bail!("self slot not bound — run `wire bind-relay <url>` first");
670    }
671
672    // v0.5.17: pull from every endpoint in self.endpoints (federation +
673    // optional local). Each endpoint has its own per-scope cursor so we
674    // don't re-pull events we've already seen on that path. Events from
675    // all endpoints feed into the same inbox JSONL via process_events;
676    // dedup by event_id is the last line of defense.
677    // Falls back to a single federation endpoint synthesized from the
678    // top-level legacy fields when self.endpoints is absent (v0.5.16
679    // back-compat).
680    let endpoints = crate::endpoints::self_endpoints(&state);
681    if endpoints.is_empty() {
682        bail!("self.relay_url / slot_id / slot_token missing in relay_state.json");
683    }
684
685    let inbox_dir = config::inbox_dir()?;
686    config::ensure_dirs()?;
687
688    let mut total_seen = 0usize;
689    let mut all_written: Vec<Value> = Vec::new();
690    let mut all_rejected: Vec<Value> = Vec::new();
691    let mut all_blocked = false;
692    let mut all_advance_cursor_to: Option<String> = None;
693
694    for endpoint in &endpoints {
695        let cursor_key = endpoint_cursor_key(endpoint.scope);
696        let last_event_id = self_state
697            .get(&cursor_key)
698            .and_then(Value::as_str)
699            .map(str::to_string);
700        let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
701        let events = match client.list_events(
702            &endpoint.slot_id,
703            &endpoint.slot_token,
704            last_event_id.as_deref(),
705            Some(1000),
706        ) {
707            Ok(ev) => ev,
708            Err(e) => {
709                // One endpoint's failure shouldn't kill the whole pull.
710                // The local-relay-down case in particular needs to
711                // gracefully continue against federation.
712                eprintln!(
713                    "wire pull: endpoint {} ({:?}) errored: {}; continuing",
714                    endpoint.relay_url,
715                    endpoint.scope,
716                    crate::relay_client::format_transport_error(&e),
717                );
718                continue;
719            }
720        };
721        total_seen += events.len();
722        let result = crate::pull::process_events(&events, last_event_id.clone(), &inbox_dir)?;
723        // RFC-004 AC-HP2: auto-respond to inbound probes from the daemon's pull
724        // cycle — no LLM/MCP in the loop. Rate-limited + best-effort inside.
725        crate::probe::respond_to_probes(&result.probes);
726        all_written.extend(result.written.iter().cloned());
727        all_rejected.extend(result.rejected.iter().cloned());
728        if result.blocked {
729            all_blocked = true;
730        }
731        // Advance per-endpoint cursor. The cursor key is scope-specific
732        // so federation and local don't trample each other.
733        if let Some(eid) = result.advance_cursor_to.clone() {
734            if endpoint.scope == crate::endpoints::EndpointScope::Federation {
735                all_advance_cursor_to = Some(eid.clone());
736            }
737            let key = cursor_key.clone();
738            config::update_relay_state(|state| {
739                if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
740                    self_obj.insert(key, Value::String(eid));
741                }
742                Ok(())
743            })?;
744        }
745    }
746
747    // Compatibility shim for the legacy single-cursor code paths below:
748    // `result` used to come from one process_events call; we now have
749    // per-endpoint results aggregated into the all_* accumulators.
750    // Reconstruct a synthetic result for the remaining display logic.
751    let result = crate::pull::PullResult {
752        written: all_written,
753        rejected: all_rejected,
754        blocked: all_blocked,
755        advance_cursor_to: all_advance_cursor_to,
756        // Probes were already auto-responded to inside the per-endpoint loop
757        // above; this aggregate result doesn't re-carry them.
758        probes: Vec::new(),
759    };
760    let events_len = total_seen;
761
762    // Cursor advance happened per-endpoint above; no aggregate cursor
763    // write needed here.
764
765    if as_json {
766        println!(
767            "{}",
768            serde_json::to_string(&json!({
769                "written": result.written,
770                "rejected": result.rejected,
771                "total_seen": events_len,
772                "cursor_blocked": result.blocked,
773                "cursor_advanced_to": result.advance_cursor_to,
774            }))?
775        );
776    } else {
777        let blocking = result
778            .rejected
779            .iter()
780            .filter(|r| r.get("blocks_cursor").and_then(Value::as_bool) == Some(true))
781            .count();
782        if blocking > 0 {
783            println!(
784                "pulled {} event(s); wrote {}; rejected {} ({} BLOCKING cursor — see `wire pull --json`)",
785                events_len,
786                result.written.len(),
787                result.rejected.len(),
788                blocking,
789            );
790        } else {
791            println!(
792                "pulled {} event(s); wrote {}; rejected {}",
793                events_len,
794                result.written.len(),
795                result.rejected.len(),
796            );
797        }
798    }
799    Ok(())
800}
801
802/// v0.5.17: cursor key for an endpoint's per-scope read position.
803/// Federation keeps the v0.5.16 legacy key `last_pulled_event_id` for
804/// back-compat with on-disk relay_state files; local uses a
805/// `_local` suffix.
806fn endpoint_cursor_key(scope: crate::endpoints::EndpointScope) -> String {
807    match scope {
808        crate::endpoints::EndpointScope::Federation => "last_pulled_event_id".to_string(),
809        crate::endpoints::EndpointScope::Local => "last_pulled_event_id_local".to_string(),
810        crate::endpoints::EndpointScope::Lan => "last_pulled_event_id_lan".to_string(),
811        crate::endpoints::EndpointScope::Uds => "last_pulled_event_id_uds".to_string(),
812    }
813}
814
815// ---------- rotate-slot ----------
816
817pub(super) fn cmd_rotate_slot(no_announce: bool, as_json: bool) -> Result<()> {
818    if !config::is_initialized()? {
819        bail!("not initialized — run `wire up` first");
820    }
821    let mut state = config::read_relay_state()?;
822    let self_state = state.get("self").cloned().unwrap_or(Value::Null);
823    if self_state.is_null() {
824        bail!("self slot not bound — run `wire bind-relay <url>` first (nothing to rotate)");
825    }
826    // v0.9: route through self_primary_endpoint so v0.5.17+ sessions
827    // (which write only self.endpoints[]) can rotate. Pre-v0.9 read
828    // top-level legacy fields directly and bailed for those sessions.
829    let primary = crate::endpoints::self_primary_endpoint(&state)
830        .ok_or_else(|| anyhow!("self has no resolvable inbound endpoint to rotate"))?;
831    let url = primary.relay_url.clone();
832    let old_slot_id = primary.slot_id.clone();
833    let old_slot_token = primary.slot_token.clone();
834
835    // Read identity to sign the announcement.
836    let card = config::read_agent_card()?;
837    let did = card
838        .get("did")
839        .and_then(Value::as_str)
840        .unwrap_or("")
841        .to_string();
842    let handle = crate::agent_card::display_handle_from_did(&did).to_string();
843    let pk_b64 = card
844        .get("verify_keys")
845        .and_then(Value::as_object)
846        .and_then(|m| m.values().next())
847        .and_then(|v| v.get("key"))
848        .and_then(Value::as_str)
849        .ok_or_else(|| anyhow!("agent-card missing verify_keys[*].key"))?
850        .to_string();
851    let pk_bytes = crate::signing::b64decode(&pk_b64)?;
852    let sk_seed = config::read_private_key()?;
853
854    // Allocate new slot on the same relay.
855    let normalized = url.trim_end_matches('/').to_string();
856    let client = crate::relay_client::RelayClient::new(&normalized);
857    client
858        .check_healthz()
859        .context("aborting rotation; old slot still valid")?;
860    let alloc = client.allocate_slot(Some(&handle))?;
861    let new_slot_id = alloc.slot_id.clone();
862    let new_slot_token = alloc.slot_token.clone();
863
864    // Optionally announce the rotation to every paired peer via the OLD slot.
865    // Each peer's recipient-side `wire pull` will pick up this event before
866    // their daemon next polls the new slot — but auto-update of peer's
867    // relay.json from a wire_close event is a v0.2 daemon feature; for now
868    // peers see the event and an operator must manually `add-peer-slot` the
869    // new coords, OR re-pair via SAS.
870    let mut announced: Vec<String> = Vec::new();
871    if !no_announce {
872        let now = time::OffsetDateTime::now_utc()
873            .format(&time::format_description::well_known::Rfc3339)
874            .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
875        let body = json!({
876            "reason": "operator-initiated slot rotation",
877            "new_relay_url": url,
878            "new_slot_id": new_slot_id,
879            // NOTE: new_slot_token deliberately NOT shared in the broadcast.
880            // In v0.1 slot tokens are bilateral-shared, so peer can post via
881            // existing add-peer-slot flow if operator chooses to re-issue.
882        });
883        let peers = state["peers"].as_object().cloned().unwrap_or_default();
884        for (peer_handle, _peer_info) in peers.iter() {
885            let event = json!({
886                "schema_version": crate::signing::EVENT_SCHEMA_VERSION,
887                "timestamp": now.clone(),
888                "from": did,
889                "to": format!("did:wire:{peer_handle}"),
890                "type": "wire_close",
891                "kind": 1201,
892                "body": body.clone(),
893            });
894            let signed = match sign_message_v31(&event, &sk_seed, &pk_bytes, &handle) {
895                Ok(s) => s,
896                Err(e) => {
897                    eprintln!("warn: could not sign wire_close for {peer_handle}: {e}");
898                    continue;
899                }
900            };
901            // To reach peer A we post to peer A's slot (they read from their
902            // OWN slot via `wire pull`). RFC-006 Part B: resolve that slot from
903            // the peer's `endpoints[]` (the single routing source) — reading the
904            // legacy flat peers[h].relay_url/slot_id/slot_token silently skipped
905            // EVERY peer pinned after #268 (they carry endpoints[] only), so the
906            // rotation announce reached nobody (the #339 bug class).
907            let ep = match crate::endpoints::peer_primary_endpoint(&state, peer_handle) {
908                Some(e) if !e.slot_id.is_empty() && !e.slot_token.is_empty() => e,
909                _ => continue, // no reachable endpoint pinned for this peer
910            };
911            let peer_client = if ep.relay_url == url {
912                client.clone()
913            } else {
914                crate::relay_client::RelayClient::new(&ep.relay_url)
915            };
916            match peer_client.post_event(&ep.slot_id, &ep.slot_token, &signed) {
917                Ok(_) => announced.push(peer_handle.clone()),
918                Err(e) => eprintln!("warn: announce to {peer_handle} failed: {e}"),
919            }
920        }
921    }
922
923    // Swap the rotated slot to the new one. Use the ADDITIVE helper instead of
924    // overwriting `state["self"]` wholesale: a flat-triple clobber deleted
925    // self.endpoints[], silently dropping every local / LAN / UDS slot of a
926    // dual-slot session (`wire session new --with-local/--with-lan-relay/--with-uds`)
927    // — federation survived only via back-compat synthesis. upsert keys by
928    // relay_url, so it replaces the rotated endpoint in place, preserves the
929    // others, and rebuilds the legacy flat fields. Preserve the rotated
930    // endpoint's scope rather than assuming federation.
931    crate::endpoints::upsert_self_endpoint(
932        &mut state,
933        crate::endpoints::Endpoint {
934            relay_url: url.clone(),
935            slot_id: new_slot_id.clone(),
936            slot_token: new_slot_token.clone(),
937            scope: primary.scope,
938        },
939    );
940    config::write_relay_state(&state)?;
941
942    if as_json {
943        println!(
944            "{}",
945            serde_json::to_string(&json!({
946                "rotated": true,
947                "old_slot_id": old_slot_id,
948                "new_slot_id": new_slot_id,
949                "relay_url": url,
950                "announced_to": announced,
951            }))?
952        );
953    } else {
954        println!("rotated slot on {url}");
955        println!(
956            "  old slot_id: {old_slot_id} (orphaned — abusive bearer-holders lose their leverage)"
957        );
958        println!("  new slot_id: {new_slot_id}");
959        if !announced.is_empty() {
960            println!(
961                "  announced wire_close (kind=1201) to: {}",
962                announced.join(", ")
963            );
964        }
965        println!();
966        println!("next steps:");
967        println!("  - peers see the wire_close event in their next `wire pull`");
968        println!(
969            "  - paired peers must re-issue: tell them to run `wire add-peer-slot {handle} {url} {new_slot_id} <new-token>`"
970        );
971        println!("    (or full re-pair via `wire dial <handle>@<relay>`)");
972        println!("  - until they do, you'll receive but they won't be able to reach you");
973        // Suppress unused warning
974        let _ = old_slot_token;
975    }
976    Ok(())
977}
978
979// ---------- forget-peer ----------
980
981pub(super) fn cmd_forget_peer(handle: &str, purge: bool, as_json: bool) -> Result<()> {
982    let mut trust = config::read_trust()?;
983    let mut removed_from_trust = false;
984    if let Some(agents) = trust.get_mut("agents").and_then(Value::as_object_mut)
985        && agents.remove(handle).is_some()
986    {
987        removed_from_trust = true;
988    }
989    config::write_trust(&trust)?;
990
991    let mut state = config::read_relay_state()?;
992    let mut removed_from_relay = false;
993    if let Some(peers) = state.get_mut("peers").and_then(Value::as_object_mut)
994        && peers.remove(handle).is_some()
995    {
996        removed_from_relay = true;
997    }
998    config::write_relay_state(&state)?;
999
1000    let mut purged: Vec<String> = Vec::new();
1001    if purge {
1002        for dir in [config::inbox_dir()?, config::outbox_dir()?] {
1003            let path = dir.join(format!("{handle}.jsonl"));
1004            if path.exists() {
1005                std::fs::remove_file(&path).with_context(|| format!("removing {path:?}"))?;
1006                purged.push(path.to_string_lossy().into());
1007            }
1008        }
1009    }
1010
1011    if !removed_from_trust && !removed_from_relay {
1012        if as_json {
1013            println!(
1014                "{}",
1015                serde_json::to_string(&json!({
1016                    "removed": false,
1017                    "reason": format!("peer {handle:?} not pinned"),
1018                }))?
1019            );
1020        } else {
1021            eprintln!("peer {handle:?} not found in trust or relay state — nothing to forget");
1022        }
1023        return Ok(());
1024    }
1025
1026    if as_json {
1027        println!(
1028            "{}",
1029            serde_json::to_string(&json!({
1030                "handle": handle,
1031                "removed_from_trust": removed_from_trust,
1032                "removed_from_relay_state": removed_from_relay,
1033                "purged_files": purged,
1034            }))?
1035        );
1036    } else {
1037        println!("forgot peer {handle:?}");
1038        if removed_from_trust {
1039            println!("  - removed from trust.json");
1040        }
1041        if removed_from_relay {
1042            println!("  - removed from relay.json");
1043        }
1044        if !purged.is_empty() {
1045            for p in &purged {
1046                println!("  - deleted {p}");
1047            }
1048        } else if !purge {
1049            println!("  (inbox/outbox files preserved; pass --purge to delete them)");
1050        }
1051    }
1052    Ok(())
1053}
1054
1055// ---------- daemon (long-lived push+pull sync) ----------
1056
1057pub(super) fn cmd_daemon(
1058    interval_secs: u64,
1059    once: bool,
1060    all_sessions: bool,
1061    session: Option<String>,
1062    as_json: bool,
1063) -> Result<()> {
1064    // v0.14.2 (#162): supervisor mode is mutually exclusive with --once and
1065    // --session — the supervisor IS the multi-session orchestrator, and
1066    // --once is a single-cycle exit (no supervision). Surface loudly
1067    // rather than silently picking one branch.
1068    if all_sessions {
1069        if once {
1070            bail!("--all-sessions and --once are mutually exclusive (supervisor runs forever)");
1071        }
1072        if session.is_some() {
1073            bail!(
1074                "--all-sessions and --session are mutually exclusive (supervisor manages every session, not a single named one)"
1075            );
1076        }
1077        return crate::daemon_supervisor::run_supervisor(interval_secs, as_json);
1078    }
1079    // v0.14.2 (#162): pin this process's WIRE_HOME to the named session's
1080    // home dir BEFORE any config read. Used by the supervisor when it
1081    // fork-execs children, and operator-facing when running a one-session
1082    // foreground daemon outside launchd.
1083    if let Some(ref name) = session {
1084        // v0.14.2 #44: resolve via the layout-aware helper so v0.13
1085        // by-key sessions (where the on-disk dir is a hash and the
1086        // operator-typed name is the persona handle, e.g.
1087        // "coral-weasel") work as well as legacy v0.6 top-level
1088        // sessions. Pre-fix: `session_dir(name)` only resolved the
1089        // legacy form → operator running `wire daemon --session
1090        // coral-weasel` in a tmux pane saw "session not found" even
1091        // though `wire session list` clearly enumerated it.
1092        let home = crate::session::find_session_home_by_name(name)
1093            .with_context(|| format!("resolving session home for --session {name}"))?
1094            .ok_or_else(|| {
1095                anyhow!(
1096                    "session '{name}' not found — run `wire session list` to see initialized sessions"
1097                )
1098            })?;
1099        // SAFETY: cmd_daemon is the one process-lifetime entrypoint that
1100        // chooses a session. No other thread reads WIRE_HOME yet.
1101        unsafe {
1102            std::env::set_var("WIRE_HOME", &home);
1103        }
1104        if !as_json {
1105            eprintln!(
1106                "wire daemon: pinned to session '{name}' (WIRE_HOME={})",
1107                home.display()
1108            );
1109        }
1110    }
1111    if !config::is_initialized()? {
1112        bail!("not initialized — run `wire up` first");
1113    }
1114    // v0.14.2 (#162): pidfile singleton on the persistent daemon. If
1115    // another live `wire daemon` already owns the pidfile, exit 0 with a
1116    // human/JSON message instead of starting a second polling loop —
1117    // honey-pine's report observed 3 concurrent daemons polling the same
1118    // slot, wasteful and a possible source of duplicate-pull races.
1119    // `--once` is a single sync cycle and doesn't own the cursor; the
1120    // singleton check is skipped for it (matches the existing collision
1121    // warning's `--once` carve-out). Test escape hatch:
1122    // `WIRE_DAEMON_NO_SINGLETON=1`.
1123    let _pid_guard = if !once && std::env::var("WIRE_DAEMON_NO_SINGLETON").is_err() {
1124        if let Some(holder_pid) = crate::ensure_up::daemon_singleton_holder() {
1125            if as_json {
1126                println!(
1127                    "{}",
1128                    serde_json::to_string(&json!({
1129                        "status": "skipped",
1130                        "reason": "daemon already running",
1131                        "holder_pid": holder_pid,
1132                    }))?
1133                );
1134            } else {
1135                eprintln!(
1136                    "wire daemon: another daemon is already running (pid {holder_pid}); not starting a second polling loop. Set WIRE_DAEMON_NO_SINGLETON=1 to override."
1137                );
1138            }
1139            return Ok(());
1140        }
1141        Some(crate::ensure_up::claim_daemon_singleton()?)
1142    } else {
1143        None
1144    };
1145    // v0.13.x identity work: a long-running daemon racing another wire
1146    // process for the same inbox cursor silently loses messages. Surface
1147    // the collision the same way `wire mcp` does. Skipped under `--once`:
1148    // a single sync cycle is atomic and doesn't own the cursor.
1149    if !once {
1150        // #284.4: surface "launcher didn't pass a session-key" before
1151        // the collision check fires.
1152        crate::session::warn_if_unexpected_session_source("daemon");
1153        crate::session::warn_on_identity_collision(std::process::id(), "daemon");
1154    }
1155    let interval = std::time::Duration::from_secs(interval_secs.max(1));
1156
1157    if !as_json {
1158        if once {
1159            eprintln!("wire daemon: single sync cycle, then exit");
1160        } else {
1161            eprintln!("wire daemon: syncing every {interval_secs}s. SIGINT to stop.");
1162        }
1163    }
1164
1165    // Claim the daemon pidfile for this process so `wire status` / doctor /
1166    // the singleton guard can see us when started directly (not via
1167    // ensure_background). Best-effort.
1168    if let Err(e) = crate::ensure_up::write_self_daemon_pid() {
1169        eprintln!("daemon: pidfile write error: {e:#}");
1170    }
1171
1172    // R1 phase 2: spawn the SSE stream subscriber. On every event pushed
1173    // to our slot, the subscriber signals `wake_rx`; we use it as the
1174    // sleep-or-wake gate of the polling loop. Polling stays as the
1175    // safety net — stream errors fall back transparently to the existing
1176    // interval-based cadence.
1177    let (wake_tx, wake_rx) = std::sync::mpsc::channel::<()>();
1178    if !once {
1179        crate::daemon_stream::spawn_stream_subscriber(wake_tx);
1180    }
1181
1182    // Arm inbound-message OS toasts inside the always-on daemon: fold the
1183    // `wire notify` sweep into the sync loop so the default `wire up` path
1184    // delivers toasts for incoming messages (previously nothing ever started
1185    // a notify sweep — inbound messages arrived silently). `--once` is a
1186    // single atomic cycle that doesn't own the cursor, so it stays opt-out.
1187    let mut notify_state: Option<(crate::inbox_watch::InboxWatcher, std::path::PathBuf)> = if once {
1188        None
1189    } else {
1190        let cursor_path = config::state_dir()?.join("notify.cursor");
1191        match crate::inbox_watch::InboxWatcher::from_cursor_file(&cursor_path) {
1192            Ok(w) => Some((w, cursor_path)),
1193            Err(e) => {
1194                // Non-fatal: the sync loop is the daemon's core job; toasts
1195                // are a side channel. Degrade to no toasts, keep syncing.
1196                eprintln!("daemon: notify watcher init failed, toasts disabled: {e:#}");
1197                None
1198            }
1199        }
1200    };
1201
1202    loop {
1203        let pushed = run_sync_push().unwrap_or_else(|e| {
1204            eprintln!("daemon: push error: {e:#}");
1205            json!({"pushed": [], "skipped": [{"error": e.to_string()}]})
1206        });
1207        let pulled = run_sync_pull().unwrap_or_else(|e| {
1208            eprintln!("daemon: pull error: {e:#}");
1209            json!({"written": [], "rejected": [], "total_seen": 0, "error": e.to_string()})
1210        });
1211
1212        // Toast any newly-arrived inbox events (folded-in `wire notify`).
1213        if let Some((ref mut watcher, ref cursor_path)) = notify_state {
1214            match super::comms::notify_sweep_new_events(watcher, cursor_path) {
1215                Ok(events) => super::comms::toast_inbox_events(&events),
1216                Err(e) => eprintln!("daemon: notify sweep error: {e:#}"),
1217            }
1218        }
1219
1220        // v0.14.2 (#162): persist a `last_sync.json` record after every
1221        // cycle (including --once + cycles that pushed/pulled zero events
1222        // — the "idle daemon is alive" signal is exactly what the
1223        // detection layers need). Readers: `wire status`,
1224        // `mcp__wire__wire_status`, `mcp__wire__wire_send` annotations.
1225        // Best-effort: errors log + don't abort the loop.
1226        let cycle_push_n = pushed["pushed"].as_array().map(|a| a.len()).unwrap_or(0);
1227        let cycle_pull_n = pulled["written"].as_array().map(|a| a.len()).unwrap_or(0);
1228        let cycle_rejected_n = pulled["rejected"].as_array().map(|a| a.len()).unwrap_or(0);
1229        crate::ensure_up::write_last_sync_record(cycle_push_n, cycle_pull_n, cycle_rejected_n);
1230
1231        if as_json {
1232            println!(
1233                "{}",
1234                serde_json::to_string(&json!({
1235                    "ts": time::OffsetDateTime::now_utc()
1236                        .format(&time::format_description::well_known::Rfc3339)
1237                        .unwrap_or_default(),
1238                    "push": pushed,
1239                    "pull": pulled,
1240                }))?
1241            );
1242        } else if cycle_push_n > 0 || cycle_pull_n > 0 || cycle_rejected_n > 0 {
1243            eprintln!(
1244                "daemon: pushed={cycle_push_n} pulled={cycle_pull_n} rejected={cycle_rejected_n}"
1245            );
1246        }
1247
1248        if once {
1249            return Ok(());
1250        }
1251        // Wait either for the next poll-interval tick OR for a stream
1252        // wake signal — whichever comes first. Drain any additional
1253        // wake-ups that accumulated during the previous cycle since one
1254        // pull catches up everything.
1255        //
1256        // v0.13.2 (wisp-blossom): if the stream subscriber thread has gone
1257        // away, `wake_rx` is Disconnected and `recv_timeout` returns
1258        // INSTANTLY — which would busy-spin the sync loop (hammering push/pull
1259        // + the relay with zero delay). Fall back to a plain sleep so a dead
1260        // stream degrades to normal polling and never kills or pegs the
1261        // daemon. (Realizes the "decouple stream from sync" hardening — a
1262        // stream failure must never affect the push/pull loop.)
1263        match wake_rx.recv_timeout(interval) {
1264            Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
1265            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
1266                std::thread::sleep(interval);
1267            }
1268        }
1269        while wake_rx.try_recv().is_ok() {}
1270    }
1271}
1272
1273/// Programmatic push (no stdout, no exit on errors). Returns the same JSON
1274/// shape `wire push --json` emits.
1275pub fn run_sync_push() -> Result<Value> {
1276    let state = config::read_relay_state()?;
1277    let peers = state["peers"].as_object().cloned().unwrap_or_default();
1278    if peers.is_empty() {
1279        return Ok(json!({"pushed": [], "skipped": []}));
1280    }
1281    let outbox_dir = config::outbox_dir()?;
1282    if !outbox_dir.exists() {
1283        return Ok(json!({"pushed": [], "skipped": []}));
1284    }
1285    let mut pushed = Vec::new();
1286    let mut skipped = Vec::new();
1287    for (peer_handle, _slot_info) in peers.iter() {
1288        let outbox = outbox_dir.join(format!("{peer_handle}.jsonl"));
1289        if !outbox.exists() {
1290            continue;
1291        }
1292        // v0.16 (RFC-006 Part B): resolve via `endpoints[]` in priority order —
1293        // the canonical routing source. The pre-0.16 flat relay_url/slot_id/
1294        // slot_token are no longer written for new pins, so reading them here
1295        // made the daemon push a SILENT no-op (pushed=0) for every
1296        // endpoints[]-only peer — the exact gap `run_sync_pull` was fixed for in
1297        // v0.9. Mirror `cmd_push`'s endpoint-failover routing (minus the CLI-only
1298        // whois slot-rotation retry: the daemon re-attempts every cycle anyway).
1299        let ordered_endpoints =
1300            crate::endpoints::peer_endpoints_in_priority_order(&state, peer_handle);
1301        let body = std::fs::read_to_string(&outbox)?;
1302        for line in body.lines() {
1303            let event: Value = match serde_json::from_str(line) {
1304                Ok(v) => v,
1305                Err(_) => continue,
1306            };
1307            let event_id = event
1308                .get("event_id")
1309                .and_then(Value::as_str)
1310                .unwrap_or("")
1311                .to_string();
1312            if ordered_endpoints.is_empty() {
1313                // No reachable endpoint pinned — surface a loud reason rather
1314                // than silently dropping the event (the old flat-field bug).
1315                skipped.push(json!({
1316                    "peer": peer_handle,
1317                    "event_id": event_id,
1318                    "reason": "no reachable endpoint pinned for peer",
1319                }));
1320                continue;
1321            }
1322            let last_err: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);
1323            match crate::relay_client::try_post_event_with_failover(
1324                &ordered_endpoints,
1325                &event,
1326                |endpoint, ev| {
1327                    let client = crate::relay_client::RelayClient::new(&endpoint.relay_url);
1328                    match client.post_event(&endpoint.slot_id, &endpoint.slot_token, ev) {
1329                        Ok(resp) => Ok(resp),
1330                        Err(e) => {
1331                            // v0.5.13: flatten the anyhow chain so TLS / DNS /
1332                            // timeout errors aren't hidden behind the URL string.
1333                            *last_err.borrow_mut() =
1334                                Some(crate::relay_client::format_transport_error(&e));
1335                            Err(e)
1336                        }
1337                    }
1338                },
1339            ) {
1340                Ok((endpoint, resp)) => {
1341                    // v0.14.2 (#162 fix #2): record the queued → pushed
1342                    // transition in the per-peer lifecycle log. Both `ok` and
1343                    // `duplicate` count as pushed — the relay has the event
1344                    // either way. Failure here is non-fatal.
1345                    let now = time::OffsetDateTime::now_utc()
1346                        .format(&time::format_description::well_known::Rfc3339)
1347                        .unwrap_or_default();
1348                    if let Err(e) = config::append_pushed_log(peer_handle, &event_id, &now) {
1349                        eprintln!(
1350                            "daemon: pushed-log append for {peer_handle}/{event_id} failed (non-fatal): {e:#}"
1351                        );
1352                    }
1353                    if resp.status == "duplicate" {
1354                        skipped.push(json!({"peer": peer_handle, "event_id": event_id, "reason": "duplicate", "endpoint": endpoint.relay_url}));
1355                    } else {
1356                        pushed.push(json!({"peer": peer_handle, "event_id": event_id, "endpoint": endpoint.relay_url}));
1357                    }
1358                }
1359                Err(_) => {
1360                    let reason = last_err
1361                        .borrow()
1362                        .clone()
1363                        .unwrap_or_else(|| "all endpoints failed".to_string());
1364                    skipped
1365                        .push(json!({"peer": peer_handle, "event_id": event_id, "reason": reason}));
1366                }
1367            }
1368        }
1369        // Drain delivered events from this peer's outbox so the daemon stops
1370        // re-POSTing them every cycle (the relay-load blast) and the file stays
1371        // bounded to the genuine backlog. Best-effort: a drain failure must not
1372        // fail the sync (the next cycle retries; the relay dedups regardless).
1373        if let Err(e) = config::drain_outbox_delivered(peer_handle) {
1374            eprintln!("daemon: outbox drain for {peer_handle} failed (non-fatal): {e:#}");
1375        }
1376    }
1377    Ok(json!({"pushed": pushed, "skipped": skipped}))
1378}
1379
1380/// Programmatic pull. Same shape as `wire pull --json`.
1381///
1382/// v0.9: routes through `endpoints::self_primary_endpoint` so sessions
1383/// created via `wire session new --with-local` (which only writes
1384/// `self.endpoints[]`, not the legacy top-level fields) actually pull.
1385/// Pre-v0.9 this function read only the top-level fields and silently
1386/// returned `{}` for any v0.5.17+ session.
1387/// `wire ping <peer>` (RFC-004 Tier-1) — send a liveness probe and wait for the
1388/// peer's daemon to auto-respond, reporting the round-trip. Does its own
1389/// synchronous pull (works even if our local daemon is down — it's the PEER's
1390/// daemon liveness we're measuring). Trust-neutral: never mutates any tier.
1391pub fn cmd_ping(peer: &str, as_json: bool) -> Result<()> {
1392    use std::time::{Duration, Instant};
1393    let bare = crate::agent_card::bare_handle(peer);
1394    let nonce = hex::encode(rand::random::<[u8; 8]>());
1395    let inbox_path = config::inbox_dir()?.join(format!("{bare}.jsonl"));
1396
1397    let start = Instant::now();
1398    crate::probe::send_probe(peer, &nonce).with_context(|| format!("sending probe to {peer}"))?;
1399
1400    let deadline = start + Duration::from_secs(5);
1401    let mut rtt_ms: Option<u128> = None;
1402    while Instant::now() < deadline {
1403        // Pull our slot(s) so an auto-responded ack lands in the inbox.
1404        let _ = run_sync_pull();
1405        if inbox_contains_probe_ack(&inbox_path, &nonce) {
1406            rtt_ms = Some(start.elapsed().as_millis());
1407            break;
1408        }
1409        std::thread::sleep(Duration::from_millis(200));
1410    }
1411
1412    match rtt_ms {
1413        Some(ms) => {
1414            if as_json {
1415                println!(
1416                    "{}",
1417                    serde_json::to_string(&json!({
1418                        "peer": bare, "alive": true, "rtt_ms": ms,
1419                    }))?
1420                );
1421            } else {
1422                println!("{bare}: alive — probe round-trip {ms}ms");
1423            }
1424            Ok(())
1425        }
1426        None => {
1427            if as_json {
1428                println!(
1429                    "{}",
1430                    serde_json::to_string(&json!({
1431                        "peer": bare, "alive": false, "rtt_ms": null,
1432                        "reason": "no probe_ack within 5s",
1433                    }))?
1434                );
1435                Ok(())
1436            } else {
1437                bail!(
1438                    "{bare}: no response within 5s — their daemon may be down, unreachable, or not yet on a probe-capable build"
1439                )
1440            }
1441        }
1442    }
1443}
1444
1445/// Scan an inbox JSONL file for a probe_ack carrying `nonce`. Best-effort:
1446/// unreadable file / unparsable lines are skipped.
1447fn inbox_contains_probe_ack(path: &std::path::Path, nonce: &str) -> bool {
1448    let Ok(body) = std::fs::read_to_string(path) else {
1449        return false;
1450    };
1451    body.lines()
1452        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
1453        .any(|e| crate::probe::is_probe_ack_for(&e, nonce))
1454}
1455
1456/// RFC-007 D3 pull-loop helper: the distinct Nostr relays to pull our inbound
1457/// from. Two sources, deduped:
1458/// 1. `self.nostr_relays[]` — relays WE are reachable on (recorded when we
1459///    `wire nostr pair/accept/fetch --relay X`). A peer sends to us by
1460///    publishing to a relay *we're* reachable on, so this is the authoritative
1461///    set — correct even when pairing is asymmetric.
1462/// 2. `peers[*].nostr_transport.relay` — relays we reach *peers* on. Covers the
1463///    symmetric same-relay case even before a self-relay was recorded.
1464///
1465/// Pure — unit-tested.
1466fn nostr_relays_from_peers(state: &Value) -> Vec<String> {
1467    let mut relays: Vec<String> = Vec::new();
1468    let mut push_distinct = |r: &str| {
1469        if !r.is_empty() && !relays.iter().any(|x| x == r) {
1470            relays.push(r.to_string());
1471        }
1472    };
1473    for r in crate::endpoints::self_nostr_relays(state) {
1474        push_distinct(&r);
1475    }
1476    if let Some(peers) = state.get("peers").and_then(Value::as_object) {
1477        for p in peers.values() {
1478            if let Some(r) = p
1479                .get("nostr_transport")
1480                .and_then(|n| n.get("relay"))
1481                .and_then(Value::as_str)
1482            {
1483                push_distinct(r);
1484            }
1485        }
1486    }
1487    relays
1488}
1489
1490/// Pull wire events addressed to us (`#p: <my npub>`, `kind:1`) from each Nostr
1491/// relay, transport-verify them (`verify_and_decode` — recompute the NIP-01 id +
1492/// check the schnorr sig), and return the inner signed wire events. The caller
1493/// feeds these through the SAME `process_events` path as HTTP-pulled events, so
1494/// the inner Ed25519 signature + trust pin are verified there (transport-verified
1495/// here, identity-verified there). Per-relay errors are logged and skipped — one
1496/// dead relay can't black-hole the others. Sync wrapper over the async NostrWs
1497/// (the daemon loop is sync); mirrors the send-path `block_on` bridge.
1498fn pull_nostr_wire_events(relays: &[String], my_xonly: &[u8; 32]) -> Vec<Value> {
1499    let my_p_tag = hex::encode(my_xonly);
1500    let rt = match tokio::runtime::Builder::new_multi_thread()
1501        .enable_all()
1502        .build()
1503    {
1504        Ok(rt) => rt,
1505        Err(e) => {
1506            eprintln!("daemon: nostr pull runtime build failed: {e:#}");
1507            return Vec::new();
1508        }
1509    };
1510    rt.block_on(async {
1511        let mut out: Vec<Value> = Vec::new();
1512        for relay in relays {
1513            let filter = crate::nostr_relay::Filter {
1514                p_tags: vec![my_p_tag.clone()],
1515                kinds: vec![1],
1516                limit: Some(200),
1517                ..Default::default()
1518            };
1519            let events = match crate::nostr_ws::NostrWs::connect(relay).await {
1520                Ok(mut ws) => match ws.pull(filter).await {
1521                    Ok(evs) => evs,
1522                    Err(e) => {
1523                        eprintln!("daemon: nostr pull on {relay} failed (continuing): {e:#}");
1524                        continue;
1525                    }
1526                },
1527                Err(e) => {
1528                    eprintln!("daemon: nostr connect {relay} failed (continuing): {e:#}");
1529                    continue;
1530                }
1531            };
1532            for ev in &events {
1533                // verify_and_decode authenticates the transport hop only; the
1534                // inner wire event's Ed25519 sig + trust are checked downstream
1535                // in process_events.
1536                if let Ok(wire) = crate::nostr_event::verify_and_decode(ev) {
1537                    out.push(wire);
1538                }
1539            }
1540        }
1541        out
1542    })
1543}
1544
1545pub fn run_sync_pull() -> Result<Value> {
1546    let state = config::read_relay_state()?;
1547    if state.get("self").map(Value::is_null).unwrap_or(true) {
1548        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
1549    }
1550    // E2 (v0.13.2): pull EVERY self endpoint, not just the primary. A session
1551    // that bound a local slot (additive) alongside its federation slot used to
1552    // have the daemon pull ONLY the primary (federation) endpoint — the local
1553    // slot was never serviced, so same-box loopback delivery silently never
1554    // happened until a manual restart re-seeded the (startup-only) stream
1555    // subscriber. Now each endpoint is pulled with its OWN cursor.
1556    let endpoints = crate::endpoints::self_endpoints(&state);
1557    if endpoints.is_empty() {
1558        return Ok(json!({"written": [], "rejected": [], "total_seen": 0}));
1559    }
1560    let inbox_dir = config::inbox_dir()?;
1561    config::ensure_dirs()?;
1562
1563    // Per-slot cursors live at `self.cursors.<slot_id>`. The legacy global
1564    // `self.last_pulled_event_id` is migrated as the cursor for the PRIMARY
1565    // slot only (a federation event id won't match a local slot's log); other
1566    // slots start from None and `process_events` dedups against the inbox.
1567    let self_obj = state.get("self").cloned().unwrap_or(Value::Null);
1568    let legacy_cursor = self_obj
1569        .get("last_pulled_event_id")
1570        .and_then(Value::as_str)
1571        .map(str::to_string);
1572    let primary_slot = crate::endpoints::self_primary_endpoint(&state).map(|e| e.slot_id);
1573    let mut cursors: serde_json::Map<String, Value> = self_obj
1574        .get("cursors")
1575        .and_then(Value::as_object)
1576        .cloned()
1577        .unwrap_or_default();
1578
1579    let mut all_written: Vec<Value> = Vec::new();
1580    let mut all_rejected: Vec<Value> = Vec::new();
1581    let mut total_seen = 0usize;
1582    let mut blocked_any = false;
1583
1584    for ep in &endpoints {
1585        if ep.relay_url.is_empty() {
1586            continue;
1587        }
1588        let cursor = cursors
1589            .get(&ep.slot_id)
1590            .and_then(Value::as_str)
1591            .map(str::to_string)
1592            .or_else(|| {
1593                if Some(&ep.slot_id) == primary_slot.as_ref() {
1594                    legacy_cursor.clone()
1595                } else {
1596                    None
1597                }
1598            });
1599        let client = crate::relay_client::RelayClient::new(&ep.relay_url);
1600        // One endpoint erroring (relay down, slot gone) must NOT stop the
1601        // others — a dead local relay shouldn't black-hole federation pulls.
1602        let events =
1603            match client.list_events(&ep.slot_id, &ep.slot_token, cursor.as_deref(), Some(1000)) {
1604                Ok(e) => e,
1605                Err(e) => {
1606                    eprintln!(
1607                        "daemon: pull error on {} slot {} (continuing): {e:#}",
1608                        ep.relay_url, ep.slot_id
1609                    );
1610                    continue;
1611                }
1612            };
1613        total_seen += events.len();
1614        // P0.1 shared cursor-blocking logic (matches `wire pull`). A block on
1615        // one slot only stalls THAT slot's cursor; other slots keep flowing.
1616        let result = crate::pull::process_events(&events, cursor, &inbox_dir)?;
1617        // RFC-004 AC-HP2: daemon auto-responds to inbound probes (no LLM).
1618        crate::probe::respond_to_probes(&result.probes);
1619        if let Some(eid) = &result.advance_cursor_to {
1620            cursors.insert(ep.slot_id.clone(), Value::String(eid.clone()));
1621        }
1622        blocked_any |= result.blocked;
1623        all_written.extend(result.written);
1624        all_rejected.extend(result.rejected);
1625    }
1626
1627    // RFC-007 D3 pull-loop: also pull Nostr-delivered events. Additive — a
1628    // no-op when this session isn't `wire enroll nostr`'d or no peer carries a
1629    // nostr transport, so the HTTP-slot path above is byte-identical. We pull
1630    // from the relays peers are reachable on (symmetric pairing → where they
1631    // publish to us), transport-verify, then feed the SAME `process_events`
1632    // path (which re-verifies the inner Ed25519 sig + trust + dedups against
1633    // the inbox). Cursor None: Nostr re-pulls a recent window each cycle and
1634    // process_events dedups by event_id, so repeats are free.
1635    if let Ok(nsk) = config::read_nostr_key()
1636        && let Ok(my_xonly) = crate::nostr_key::xonly_from_secret(&nsk)
1637    {
1638        let relays = nostr_relays_from_peers(&state);
1639        if !relays.is_empty() {
1640            let wire_events = pull_nostr_wire_events(&relays, &my_xonly);
1641            if !wire_events.is_empty() {
1642                total_seen += wire_events.len();
1643                let result = crate::pull::process_events(&wire_events, None, &inbox_dir)?;
1644                crate::probe::respond_to_probes(&result.probes);
1645                all_written.extend(result.written);
1646                all_rejected.extend(result.rejected);
1647            }
1648        }
1649    }
1650
1651    // P0.3 flock-protected RMW: persist per-slot cursors + keep the legacy
1652    // global cursor in sync with the primary slot for back-compat with older
1653    // binaries that only read `last_pulled_event_id`.
1654    let primary_cursor = primary_slot
1655        .as_ref()
1656        .and_then(|s| cursors.get(s))
1657        .and_then(Value::as_str)
1658        .map(str::to_string);
1659    // v0.14.3 (#14): group `written` by sender handle, take max
1660    // timestamp, write to `peers[<handle>].last_inbound_event_at`.
1661    // RFC3339-comparable as lex sort (same offset, ISO 8601). This
1662    // is the daemon-written signal `check_peer_staleness` needs —
1663    // robust against backup/restore/`touch` that breaks inbox-mtime
1664    // detection. Additive field: pre-v0.14.3 readers ignore it,
1665    // older daemons just don't write it.
1666    let mut latest_inbound: std::collections::HashMap<String, String> =
1667        std::collections::HashMap::new();
1668    for w in &all_written {
1669        let from = match w.get("from").and_then(Value::as_str) {
1670            Some(s) => s.to_string(),
1671            None => continue,
1672        };
1673        let ts = match w.get("timestamp").and_then(Value::as_str) {
1674            Some(s) if !s.is_empty() => s.to_string(),
1675            _ => continue,
1676        };
1677        latest_inbound
1678            .entry(from)
1679            .and_modify(|existing| {
1680                if ts > *existing {
1681                    *existing = ts.clone();
1682                }
1683            })
1684            .or_insert(ts);
1685    }
1686    config::update_relay_state(|state| {
1687        if let Some(self_obj) = state.get_mut("self").and_then(Value::as_object_mut) {
1688            self_obj.insert("cursors".into(), Value::Object(cursors.clone()));
1689            if let Some(pc) = &primary_cursor {
1690                self_obj.insert("last_pulled_event_id".into(), Value::String(pc.clone()));
1691            }
1692        }
1693        if !latest_inbound.is_empty()
1694            && let Some(peers_obj) = state.get_mut("peers").and_then(Value::as_object_mut)
1695        {
1696            for (handle, ts) in &latest_inbound {
1697                let entry = peers_obj.entry(handle.clone()).or_insert_with(|| json!({}));
1698                if let Some(obj) = entry.as_object_mut() {
1699                    obj.insert("last_inbound_event_at".into(), Value::String(ts.clone()));
1700                }
1701            }
1702        }
1703        Ok(())
1704    })?;
1705
1706    Ok(json!({
1707        "written": all_written,
1708        "rejected": all_rejected,
1709        "total_seen": total_seen,
1710        "cursor_blocked": blocked_any,
1711        "endpoints_pulled": endpoints.len(),
1712    }))
1713}
1714
1715/// Issue #69 follow-up to #15: predicate "does this error smell like a
1716/// 4xx slot rotation?" — used by `try_reresolve_peer_on_slot_4xx` to
1717/// decide whether to spend a whois RTT on a re-resolve.
1718///
1719/// Original #15 implementation used `last_err.contains("410") ||
1720/// last_err.contains("404")`, which false-triggers on any unrelated
1721/// substring with `"410"`/`"404"` in it — e.g. `"slot 4101 expired"`,
1722/// `"request_id=410abc..."`, `"received 4040 bytes"`. False-trigger cost
1723/// is a single wasted whois per push call per peer (rate-limited by
1724/// `already_tried`), but it muddies the doctor diagnostic by inserting
1725/// spurious "peer slot rotated" log lines.
1726///
1727/// This predicate gates on the status code appearing as a *whole token*
1728/// — preceded by start-of-string / space / colon / tab / newline AND
1729/// followed by end-of-string / space / colon / tab / newline. That
1730/// matches both real-world shapes:
1731///
1732/// - `reqwest::StatusCode` Display, via `relay_client.rs` line ~339
1733///   `format!("post_event failed: {status}: {detail}")` →
1734///   `"post_event failed: 410 Gone: <body>"` (token `"410"` is followed
1735///   by space).
1736/// - UDS bare-`u16` Display, via `relay_client.rs` line ~227
1737///   `format!("post_event (uds {socket_path}) failed: {status}: ...")` →
1738///   `"post_event (uds /tmp/...sock) failed: 410: <body>"` (token
1739///   `"410"` is followed by colon).
1740///
1741/// And rejects the false-positive shapes documented in
1742/// `error_smells_like_slot_4xx_tests` below.
1743pub fn error_smells_like_slot_4xx(last_err: &str) -> bool {
1744    fn is_token_boundary(b: u8) -> bool {
1745        matches!(b, b' ' | b':' | b'\t' | b'\n' | b'\r')
1746    }
1747    let bytes = last_err.as_bytes();
1748    for code in ["410", "404"] {
1749        let code_bytes = code.as_bytes();
1750        let mut search_from = 0usize;
1751        while let Some(rel) = last_err[search_from..].find(code) {
1752            let abs = search_from + rel;
1753            let end = abs + code_bytes.len();
1754            let before_ok = abs == 0 || is_token_boundary(bytes[abs - 1]);
1755            let after_ok = end == bytes.len() || is_token_boundary(bytes[end]);
1756            if before_ok && after_ok {
1757                return true;
1758            }
1759            // Step past this candidate to find the next occurrence; using
1760            // `+ 1` (rather than `+ code_bytes.len()`) keeps the scan
1761            // cheap and guarantees forward progress even on overlap.
1762            search_from = abs + 1;
1763        }
1764    }
1765    false
1766}
1767
1768/// Issue #15: detect a 4xx-shaped push failure that smells like "slot
1769/// rotated by peer" and update the peer's pin in place with the freshly
1770/// resolved slot from the relay's handle directory.
1771///
1772/// Returns:
1773/// - `Ok(true)` — peer's pin was rotated; caller should refresh
1774///   `peer_endpoints_in_priority_order(&state, ...)` and retry.
1775/// - `Ok(false)` — re-resolve completed but the slot id was unchanged
1776///   (false-alarm 4xx, e.g. throttling); caller should NOT retry.
1777/// - `Err(e)` — re-resolve itself failed (network down, relay 5xx,
1778///   handle no longer claimed, etc.); caller should fall through to the
1779///   existing "skipped" path.
1780///
1781/// Only triggers when:
1782///   - The error string carries a 4xx slot-rotation status token (`410`/`404`)
1783///     as a *whole token* — preceded by start/space/colon/tab/newline and
1784///     followed by end/space/colon/tab/newline. This matches both the
1785///     `reqwest::StatusCode` Display shape (`": 410 Gone"`) and the UDS
1786///     bare-`u16` shape (`": 410:"`) emitted by `post_event` in
1787///     `src/relay_client.rs`, while rejecting substring false-positives
1788///     like `"slot 4101 expired"` or `"request_id=410abc..."`. See
1789///     `error_smells_like_slot_4xx` below.
1790///   - The peer has a pinned `relay_url` we can parse a handle@domain from.
1791///   - The caller hasn't already re-resolved this peer in the current push
1792///     call (caller's responsibility — pass `already_tried` from a set kept
1793///     in the outer per-peer loop). One whois per peer per push call,
1794///     exactly the rate limit the issue specifies.
1795///
1796/// Updates `state.peers[peer_handle]` in place (rotates the federation
1797/// endpoint's slot_id + slot_token to the fresh resolve), and emits a
1798/// stderr WARN so the operator can see the rotation event in their
1799/// terminal alongside the unrelated `wire push` output. Caller is
1800/// responsible for persisting `state` back to disk via
1801/// `config::write_relay_state` after all per-peer re-resolves settle.
1802fn try_reresolve_peer_on_slot_4xx(
1803    state: &mut Value,
1804    peer_handle: &str,
1805    last_err: &str,
1806    already_tried: &std::collections::HashSet<String>,
1807) -> Result<bool> {
1808    if !error_smells_like_slot_4xx(last_err) {
1809        // Not the slot-rotation shape. Don't waste a whois on this.
1810        return Ok(false);
1811    }
1812    if already_tried.contains(peer_handle) {
1813        // Rate limit: at most one whois per peer per push call.
1814        return Ok(false);
1815    }
1816    // Find the peer's pinned federation endpoint to re-resolve against.
1817    let peer_entry = state
1818        .get("peers")
1819        .and_then(|p| p.get(peer_handle))
1820        .ok_or_else(|| anyhow!("peer `{peer_handle}` not in relay_state"))?;
1821    let peer_relay = peer_entry
1822        .get("endpoints")
1823        .and_then(Value::as_array)
1824        .and_then(|arr| {
1825            arr.iter().find(|e| {
1826                e.get("scope").and_then(Value::as_str) == Some("federation")
1827                    || e.get("scope").and_then(Value::as_str) == Some("Federation")
1828            })
1829        })
1830        .and_then(|e| e.get("relay_url").and_then(Value::as_str))
1831        // RFC-006 Part B: `endpoints[]` is the only peer-routing source; the old
1832        // flat `peers[h].relay_url` fallback is gone (Part B stopped writing it).
1833        .ok_or_else(|| {
1834            anyhow!("peer `{peer_handle}` has no federation endpoint to re-resolve against")
1835        })?
1836        .to_string();
1837    // Strip scheme + path to get the relay domain. Same shape parse used by
1838    // pair_profile::resolve_handle's input contract.
1839    let domain = peer_relay
1840        .trim_start_matches("https://")
1841        .trim_start_matches("http://")
1842        .split('/')
1843        .next()
1844        .unwrap_or(&peer_relay)
1845        .to_string();
1846    let handle = crate::pair_profile::Handle {
1847        nick: peer_handle.to_string(),
1848        domain,
1849    };
1850    let resolved = crate::pair_profile::resolve_handle(&handle, Some(&peer_relay))?;
1851    let new_slot_id = resolved
1852        .get("slot_id")
1853        .and_then(Value::as_str)
1854        .ok_or_else(|| anyhow!("re-resolved payload missing slot_id"))?
1855        .to_string();
1856    // Compare against the currently-pinned federation slot.
1857    let peers = state
1858        .get_mut("peers")
1859        .and_then(Value::as_object_mut)
1860        .ok_or_else(|| anyhow!("relay_state.peers missing or wrong shape"))?;
1861    let peer_entry = peers
1862        .get_mut(peer_handle)
1863        .ok_or_else(|| anyhow!("peer `{peer_handle}` disappeared from state mid-resolve"))?;
1864    let current_slot_id = peer_entry
1865        .get("endpoints")
1866        .and_then(Value::as_array)
1867        .and_then(|arr| {
1868            arr.iter().find(|e| {
1869                let scope = e.get("scope").and_then(Value::as_str);
1870                scope == Some("federation") || scope == Some("Federation")
1871            })
1872        })
1873        .and_then(|e| e.get("slot_id").and_then(Value::as_str))
1874        .unwrap_or("")
1875        .to_string();
1876    if current_slot_id == new_slot_id {
1877        // Same slot — the 4xx was something else (rate limit, server burp).
1878        return Ok(false);
1879    }
1880    // Rotate in place. We update slot_id but DROP the slot_token: only the
1881    // peer's freshly-issued slot_token (which arrives via a new pair_drop_ack)
1882    // is valid. Sending against the new slot without a fresh token gets 401,
1883    // so the operator will see one more "skipped: 401" and the next pair
1884    // cycle (or a manual `wire add <peer>@<relay>` per the doctor #14 fix)
1885    // refreshes the token. This is the same trade-off the issue spells out:
1886    // auto-rotation closes the slot mismatch; token refresh still needs the
1887    // bilateral pair gate.
1888    if let Some(endpoints) = peer_entry
1889        .get_mut("endpoints")
1890        .and_then(Value::as_array_mut)
1891    {
1892        for ep in endpoints.iter_mut() {
1893            let scope = ep.get("scope").and_then(Value::as_str);
1894            if scope == Some("federation") || scope == Some("Federation") {
1895                ep["slot_id"] = Value::String(new_slot_id.clone());
1896                ep["slot_token"] = Value::String(String::new());
1897            }
1898        }
1899    }
1900    // Also update the legacy top-level fields for v0.5.16-era readers (the
1901    // same back-compat surface pair_drop_ack uses).
1902    peer_entry["slot_id"] = Value::String(new_slot_id.clone());
1903    peer_entry["slot_token"] = Value::String(String::new());
1904    eprintln!(
1905        "wire push: peer `{peer_handle}` rotated their relay slot (was `{current_slot_id}`, \
1906         now `{new_slot_id}`); pin updated in place. Re-pair via `wire add \
1907         {peer_handle}@<relay>` to refresh the slot_token."
1908    );
1909    Ok(true)
1910}
1911
1912#[cfg(test)]
1913mod slot_reresolve_tests {
1914    use super::*;
1915
1916    #[test]
1917    fn nostr_relays_from_peers_unions_self_and_peers_distinct() {
1918        let state = serde_json::json!({
1919            // Relays we're reachable on (the authoritative set) — incl. one no
1920            // peer-transport mentions (the asymmetric case).
1921            "self": { "nostr_relays": ["wss://self", "wss://r1"] },
1922            "peers": {
1923                "alice": { "nostr_transport": { "npub": "aa", "relay": "wss://r1" } },
1924                "bob":   { "nostr_transport": { "npub": "bb", "relay": "wss://r2" } },
1925                // same relay as alice → de-duped
1926                "carol": { "nostr_transport": { "npub": "cc", "relay": "wss://r1" } },
1927                // no nostr transport → skipped (HTTP-only peer)
1928                "dave":  { "endpoints": [] },
1929                // empty relay → skipped
1930                "erin":  { "nostr_transport": { "npub": "ee", "relay": "" } },
1931            }
1932        });
1933        let mut relays = nostr_relays_from_peers(&state);
1934        relays.sort();
1935        // self (wss://self, wss://r1) ∪ peers (r1 dup, r2) → 3 distinct.
1936        assert_eq!(
1937            relays,
1938            vec![
1939                "wss://r1".to_string(),
1940                "wss://r2".to_string(),
1941                "wss://self".to_string()
1942            ]
1943        );
1944        // No peers / no transports / no self → empty (the additive no-op case).
1945        assert!(nostr_relays_from_peers(&serde_json::json!({})).is_empty());
1946        assert!(nostr_relays_from_peers(&serde_json::json!({"peers": {}})).is_empty());
1947    }
1948
1949    /// Issue #15: the gating logic of try_reresolve_peer_on_slot_4xx
1950    /// must short-circuit BEFORE any network call when the error shape
1951    /// doesn't smell like slot rotation, when the peer was already
1952    /// re-resolved this push, or when there's no peer entry to work
1953    /// against. Three of those four short-circuit paths are testable
1954    /// without a mock relay; the fourth (the actual whois + slot
1955    /// comparison) requires either a live test server or a mock
1956    /// transport, so it's covered manually via the failover_tests
1957    /// helper + integration check in a separate PR.
1958    ///
1959    /// What these tests pin:
1960    ///   - 200/500/timeout-shape errors do NOT trigger a re-resolve
1961    ///     (avoids wasted whois RTTs and churn in steady-state).
1962    ///   - Same peer twice in one push call only attempts re-resolve
1963    ///     once (rate limit the issue specifies).
1964    ///   - Missing peer entry surfaces as an explicit error, NOT a
1965    ///     silent skip (operator can see the malformed state).
1966    ///   - Peer with no federation endpoint surfaces as an explicit
1967    ///     error (you can't re-resolve a slot you can't address).
1968
1969    #[test]
1970    fn try_reresolve_skips_when_error_is_not_4xx_shape() {
1971        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
1972        let already = std::collections::HashSet::new();
1973        // 200 OK shouldn't ever land in this path, but sanity check the
1974        // negative filter: any error string without "404"/"410" is a no-op.
1975        let res =
1976            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "post failed: 502", &already)
1977                .unwrap();
1978        assert!(!res, "502 must NOT trigger a re-resolve");
1979
1980        let res =
1981            try_reresolve_peer_on_slot_4xx(&mut state, "some-peer", "connection refused", &already)
1982                .unwrap();
1983        assert!(!res, "transport errors must NOT trigger a re-resolve");
1984
1985        let res = try_reresolve_peer_on_slot_4xx(
1986            &mut state,
1987            "some-peer",
1988            "post failed: 401 Unauthorized",
1989            &already,
1990        )
1991        .unwrap();
1992        assert!(
1993            !res,
1994            "401 (auth) is a token problem, not a slot rotation — must NOT trigger a re-resolve"
1995        );
1996    }
1997
1998    #[test]
1999    fn try_reresolve_rate_limits_one_attempt_per_peer_per_push() {
2000        // The issue's rate limit: "at most one whois per peer per push call."
2001        // Caller tracks via `already_tried`; helper must honor it BEFORE
2002        // attempting any I/O (otherwise a bad-state peer would burn a
2003        // network call per event in the outbox).
2004        let mut state = json!({"peers": {"some-peer": {"endpoints": []}}});
2005        let mut already = std::collections::HashSet::new();
2006        already.insert("some-peer".to_string());
2007        let res = try_reresolve_peer_on_slot_4xx(
2008            &mut state,
2009            "some-peer",
2010            "post failed: 410 Gone",
2011            &already,
2012        )
2013        .unwrap();
2014        assert!(
2015            !res,
2016            "peer already in `already_tried` must NOT trigger another re-resolve in the same push"
2017        );
2018    }
2019
2020    #[test]
2021    fn try_reresolve_errors_when_peer_missing_from_state() {
2022        // Surface state corruption explicitly rather than silently
2023        // returning Ok(false). If a peer disappeared from relay_state
2024        // mid-loop the operator needs to see it.
2025        let mut state = json!({"peers": {}});
2026        let already = std::collections::HashSet::new();
2027        let err = try_reresolve_peer_on_slot_4xx(
2028            &mut state,
2029            "missing-peer",
2030            "post failed: 410 Gone",
2031            &already,
2032        )
2033        .unwrap_err()
2034        .to_string();
2035        assert!(
2036            err.contains("missing-peer") && err.contains("not in relay_state"),
2037            "missing-peer error must name the peer + the failure: {err}"
2038        );
2039    }
2040
2041    #[test]
2042    fn try_reresolve_errors_when_peer_has_no_federation_endpoint() {
2043        // A peer with only local-scope endpoints (UDS / 127.0.0.1) has
2044        // no relay domain to whois against. Helper must surface this as
2045        // an actionable error, not a silent skip — the operator's
2046        // remediation is "pair via federation" or "you're on the same
2047        // box, the slot can't be 410'd by a peer who controls the
2048        // socket."
2049        let mut state = json!({
2050            "peers": {
2051                "local-only": {
2052                    "endpoints": [
2053                        {
2054                            "scope": "Local",
2055                            "relay_url": "http://127.0.0.1:8771",
2056                            "slot_id": "loc",
2057                            "slot_token": "tok"
2058                        }
2059                    ]
2060                }
2061            }
2062        });
2063        let already = std::collections::HashSet::new();
2064        let err = try_reresolve_peer_on_slot_4xx(
2065            &mut state,
2066            "local-only",
2067            "post failed: 410 Gone",
2068            &already,
2069        )
2070        .unwrap_err()
2071        .to_string();
2072        assert!(
2073            err.contains("federation endpoint"),
2074            "no-federation error must name the problem: {err}"
2075        );
2076    }
2077
2078    /// Issue #69: pin the word-boundary behavior of
2079    /// `error_smells_like_slot_4xx`. Prior implementation used a bare
2080    /// `contains("410") || contains("404")` substring match, which
2081    /// false-triggered on any unrelated error string containing those
2082    /// digits — e.g. slot ids that happen to start with `410`, request
2083    /// IDs, byte counts, etc.  Each false-positive cost a wasted whois
2084    /// per peer per push and a misleading "peer slot rotated" log line.
2085    ///
2086    /// These tests pin three classes:
2087    ///   - Real reqwest StatusCode Display shapes (`": 410 Gone"`,
2088    ///     `": 404 Not Found"`) trigger.
2089    ///   - Real UDS bare-`u16` shapes (`": 410:"`, `": 404:"`) trigger.
2090    ///   - Substring lookalikes (`"slot 4101 expired"`,
2091    ///     `"request_id=410abc"`, `"received 4040 bytes"`,
2092    ///     `"event 0x4104"`) do NOT trigger.
2093    #[test]
2094    fn error_smells_like_slot_4xx_matches_reqwest_status_display_shape() {
2095        // reqwest::StatusCode Display is "<u16> <reason>", embedded in
2096        // the post_event failure format string as "...failed: <status>: <detail>".
2097        assert!(error_smells_like_slot_4xx(
2098            "post_event failed: 410 Gone: slot rotated by peer"
2099        ));
2100        assert!(error_smells_like_slot_4xx(
2101            "post_event failed: 404 Not Found: handle no longer claimed"
2102        ));
2103    }
2104
2105    #[test]
2106    fn error_smells_like_slot_4xx_matches_uds_bare_u16_shape() {
2107        // UDS path formats status as a bare u16, so the shape is
2108        // "...failed: 410: <detail>" with the status flanked by spaces
2109        // and colons (no reason phrase).
2110        assert!(error_smells_like_slot_4xx(
2111            "post_event (uds /tmp/wire-relay.sock) failed: 410: gone"
2112        ));
2113        assert!(error_smells_like_slot_4xx(
2114            "post_event (uds /tmp/wire-relay.sock) failed: 404: not found"
2115        ));
2116    }
2117
2118    #[test]
2119    fn error_smells_like_slot_4xx_rejects_substring_lookalikes() {
2120        // The bug being fixed: the prior `contains("410")` predicate
2121        // matched ALL of these, burning a whois RTT and emitting a
2122        // spurious "peer slot rotated" log line each time.
2123        let false_positives = [
2124            "push aborted: slot 4101 expired",
2125            "post_event failed: 502 Bad Gateway: request_id=410abc-deadbeef",
2126            "post_event failed: 500: received 4040 bytes, expected envelope",
2127            "post_event failed: 500: event 0x4104 malformed",
2128            "post_event failed: 503: backlog=4102 entries pending",
2129            // 4044 is "received bytes" or anything containing 404 mid-token.
2130            "post_event failed: 500: tx_id=4044beef",
2131            // pure digit substrings inside identifiers / hashes:
2132            "post_event failed: 500: hash=abc410def",
2133        ];
2134        for case in false_positives {
2135            assert!(
2136                !error_smells_like_slot_4xx(case),
2137                "must NOT trigger re-resolve on substring lookalike: {case:?}"
2138            );
2139        }
2140    }
2141
2142    #[test]
2143    fn error_smells_like_slot_4xx_handles_edge_positions() {
2144        // Token at start of string (no preceding char).
2145        assert!(error_smells_like_slot_4xx("410 Gone"));
2146        assert!(error_smells_like_slot_4xx("404 Not Found"));
2147        // Token at end of string (no trailing char).
2148        assert!(error_smells_like_slot_4xx("got 410"));
2149        assert!(error_smells_like_slot_4xx("got 404"));
2150        // Tab and newline as separators (logs sometimes carry these).
2151        assert!(error_smells_like_slot_4xx("post_event failed:\t410\tGone"));
2152        assert!(error_smells_like_slot_4xx("post_event failed:\n410\nGone"));
2153        // Pure digit-only input that IS the code — token at start AND end.
2154        assert!(error_smells_like_slot_4xx("410"));
2155        assert!(error_smells_like_slot_4xx("404"));
2156        // Empty / no-match.
2157        assert!(!error_smells_like_slot_4xx(""));
2158        assert!(!error_smells_like_slot_4xx("no relevant status"));
2159        // 411-414, 401-403, 405-409 must NOT trigger (only 410/404 are
2160        // the slot-rotation shape per issue #15).
2161        assert!(!error_smells_like_slot_4xx(
2162            "post_event failed: 401 Unauthorized"
2163        ));
2164        assert!(!error_smells_like_slot_4xx(
2165            "post_event failed: 403 Forbidden"
2166        ));
2167        assert!(!error_smells_like_slot_4xx(
2168            "post_event failed: 411 Length Required"
2169        ));
2170    }
2171
2172    #[test]
2173    fn run_sync_push_routes_endpoints_only_peer_not_just_flat_fields() {
2174        // RFC-006 Part B regression: peers store `endpoints[]` ONLY (no flat
2175        // relay_url/slot_id/slot_token). The daemon push (`run_sync_push`) must
2176        // resolve via endpoints[] like `cmd_push` — pre-fix it read the
2177        // now-absent flat fields and silently skipped, stranding outboxes
2178        // (observed live: wildflower-gleam's message stuck at pushed=0).
2179        config::test_support::with_temp_home(|| {
2180            // endpoints[]-only peer; the endpoint points at a dead local port so
2181            // the POST fails FAST (connection refused). We assert the push
2182            // *attempts* delivery via endpoints[], not that it succeeds.
2183            let state = serde_json::json!({
2184                "self": { "endpoints": [] },
2185                "peers": {
2186                    "glossy-spindle": {
2187                        "endpoints": [{
2188                            "relay_url": "http://127.0.0.1:1",
2189                            "scope": "federation",
2190                            "slot_id": "8add6502f7cfec82ec2348a8c3263f08",
2191                            "slot_token": "deadbeef"
2192                        }]
2193                    }
2194                }
2195            });
2196            config::write_relay_state(&state).unwrap();
2197
2198            let outbox_dir = config::outbox_dir().unwrap();
2199            std::fs::create_dir_all(&outbox_dir).unwrap();
2200            std::fs::write(
2201                outbox_dir.join("glossy-spindle.jsonl"),
2202                "{\"event_id\":\"evt-regression-1\",\"kind\":1,\"body\":{}}\n",
2203            )
2204            .unwrap();
2205
2206            let out = run_sync_push().unwrap();
2207            let pushed = out["pushed"].as_array().unwrap();
2208            let skipped = out["skipped"].as_array().unwrap();
2209
2210            // Pre-fix (flat-field read): peer has no flat fields → loop
2211            // `continue`d → BOTH arrays empty (silent drop). Post-fix: resolves
2212            // the endpoint, attempts the POST, the dead port refuses → exactly
2213            // one skipped entry with the event_id and a real transport reason.
2214            assert!(pushed.is_empty(), "dead endpoint cannot succeed: {out}");
2215            assert_eq!(
2216                skipped.len(),
2217                1,
2218                "endpoints[]-only peer must be ATTEMPTED, not silently skipped: {out}"
2219            );
2220            assert_eq!(skipped[0]["event_id"], "evt-regression-1");
2221            let reason = skipped[0]["reason"].as_str().unwrap_or("");
2222            assert!(
2223                !reason.is_empty() && reason != "no reachable endpoint pinned for peer",
2224                "skip reason must be a transport error from the attempted POST, got: {reason:?}"
2225            );
2226        });
2227    }
2228}