1	//! The broker-owned network host (D4a) — ADR-0004 §B made real for the wire.
2	//!
3	//! [`NetHost`] is the broker's ownership of the node's live network presence:
4	//! the iroh endpoint ([`spt_net::net::endpoint::NetEndpoint`]) and every
5	//! accepted/dialed QUIC connection. A live cross-node connection is exactly the
6	//! kind of resource ADR-0004 §B assigns to the broker — a consumer would lose
7	//! continuity on brain restart — so the endpoint is constructed *here*, in the
8	//! stable kernel, never in the brain. The brain drives net operations over the
9	//! versioned IPC ([`crate::msg::KIND_NET_STATUS`] / [`crate::msg::KIND_NET_DIAL`])
10	//! and may detach/reattach freely; connections persist in the broker's table.
11	//!
12	//! ## Runtime placement (M4-D4-PLAN locked decision)
13	//!
14	//! The broker is otherwise sync/thread-based; iroh requires tokio. So the host
15	//! owns a **dedicated multi-thread tokio runtime** on named worker threads
16	//! (`spt-broker-net`): QUIC objects live in structures only the runtime's tasks
17	//! and the host's own `block_on` bridges touch, and the runtime drops with the
18	//! host (broker teardown stops all net tasks). Spike #3's broker binary proved
19	//! exactly this shape — a broker-owned endpoint surviving brain kill/restart.
20	//!
21	//! ## Ownership closes two §B rows by construction
22	//!
23	//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
24	//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
25	//! "mDNS socket / relay session" row with no extra machinery: there is no
26	//! separate socket to own.
27	// [impl->REQ-DAEMON-2]
28
29	use std::collections::{HashMap, VecDeque};
30	use std::io;
31	use std::sync::atomic::{AtomicU64, Ordering};
32	use std::sync::{Arc, Mutex};
33
34	use spt_net::net::endpoint::{
35	    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
36	    SendStream, SPT_NET_ALPN,
37	};
38	use spt_proto::identity::{Identity, PublicKey};
39
40	use crate::broker::SharedSend;
41	use crate::codec::write_frame;
42	use crate::msg::{net_stream_data_envelope, net_stream_eof_envelope, NetStreamInfo};
43
44	/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped
45	/// effects (a dial has no PTY session). Broker session ids are minted from 1
46	/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
47	pub const NET_EFFECT_SESSION: u64 = 0;
48
49	/// Everything needed to stand the broker's endpoint up: the node's identity
50	/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
51	/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
52	/// tests), and whether mDNS LAN discovery rides along.
53	pub struct NetConfig {
54	    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
55	    pub identity: Identity,
56	    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
57	    pub relay: RelayPolicy,
58	    /// mDNS LAN discovery on/off (off in hermetic tests).
59	    pub discovery: LocalDiscovery,
60	    /// Socket bind scope: [`BindScope::All`] in production;
61	    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
62	    /// no per-test-binary Windows firewall prompt).
63	    pub scope: BindScope,
64	}
65
66	/// Default bounded stream-read ring depth (chunks) — same sizing rationale as
67	/// the PTY `OutputLog`: large enough that a brain-restart window never evicts
68	/// unreplayed chunks in practice; bounded so an unsubscribed stream can't
69	/// buffer without limit.
70	const DEFAULT_STREAM_RING_CHUNKS: usize = 4096;
71
72	/// Max bytes pulled per stream read (one ring chunk).
73	const STREAM_READ_CHUNK: usize = 64 * 1024;
74
75	/// One live QUIC connection the broker holds.
76	struct ConnEntry {
77	    /// The connection itself — kept alive by ownership here; dropping it closes
78	    /// the QUIC connection. Cloned (cheap handle) for stream opens.
79	    conn: Connection,
80	    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.
81	    remote_id_hex: String,
82	}
83
84	/// A bounded, sequenced read log for one QUIC stream — the PTY `OutputLog`
85	/// discipline applied to the network (D4b): the stream's single read pump is
86	/// the only producer; a brain connection attaches as the subscriber; every
87	/// subscriber send happens under the enclosing `Mutex`, so live chunks and a
88	/// replay can never interleave out of `seq` order. Chunks produced while no
89	/// brain is attached sit in the ring and replay on the next subscribe — a
90	/// brain restart loses nothing (Spike #3 invariant B/D).
91	// [impl->REQ-DAEMON-2]
92	struct StreamLog {
93	    stream_id: u64,
94	    next_seq: u64,
95	    ring: VecDeque<(u64, Vec<u8>)>,
96	    cap_chunks: usize,
97	    subscriber: Option<SharedSend>,
98	    /// Read side finished cleanly (peer called finish / conn ended).
99	    finished: bool,
100	}
101
102	impl StreamLog {
103	    fn new(stream_id: u64, cap_chunks: usize) -> Self {
104	        StreamLog {
105	            stream_id,
106	            next_seq: 0,
107	            ring: VecDeque::new(),
108	            cap_chunks,
109	            subscriber: None,
110	            finished: false,
111	        }
112	    }
113
114	    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
115	    /// bounded ring, live-send to the current subscriber. Read-pump only.
116	    fn append(&mut self, bytes: &[u8]) {
117	        let seq = self.next_seq;
118	        self.next_seq += 1;
119	        self.ring.push_back((seq, bytes.to_vec()));
120	        while self.ring.len() > self.cap_chunks {
121	            self.ring.pop_front();
122	        }
123	        if let Some(sub) = &self.subscriber {
124	            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
125	            if let Ok(mut s) = sub.lock() {
126	                let _ = write_frame(&mut *s, &frame);
127	            }
128	        }
129	    }
130
131	    /// Mark the read side finished and tell the subscriber (with the final
132	    /// cursor, so it can verify completeness).
133	    fn finish(&mut self) {
134	        self.finished = true;
135	        if let Some(sub) = &self.subscriber {
136	            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
137	            if let Ok(mut s) = sub.lock() {
138	                let _ = write_frame(&mut *s, &frame);
139	            }
140	        }
141	    }
142
143	    /// Attach `sub` as the subscriber, replaying every retained chunk with
144	    /// `seq >= from_seq` (then eof, if already finished) before live streaming.
145	    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
146	        self.subscriber = Some(Arc::clone(&sub));
147	        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
148	            let frame = net_stream_data_envelope(self.stream_id, *seq, bytes);
149	            if let Ok(mut s) = sub.lock() {
150	                let _ = write_frame(&mut *s, &frame);
151	            }
152	        }
153	        if self.finished {
154	            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
155	            if let Ok(mut s) = sub.lock() {
156	                let _ = write_frame(&mut *s, &frame);
157	            }
158	        }
159	    }
160
161	    /// Detach `sub` iff it is the current subscriber (a late disconnect must
162	    /// not unsubscribe a newer brain).
163	    fn detach_if(&mut self, sub: &SharedSend) {
164	        if let Some(cur) = &self.subscriber {
165	            if Arc::ptr_eq(cur, sub) {
166	                self.subscriber = None;
167	            }
168	        }
169	    }
170	}
171
172	/// One live QUIC stream the broker holds: the send half (brain writes ride it
173	/// over IPC) + the sequenced read log.
174	struct StreamEntry {
175	    conn_id: u64,
176	    remote_id_hex: String,
177	    /// The send half, serialized behind an async mutex (writes happen on the
178	    /// host's runtime via `block_on`).
179	    send: tokio::sync::Mutex<SendStream>,
180	    log: Arc<Mutex<StreamLog>>,
181	}
182
183	/// Tables shared between the host's sync surface and its runtime tasks
184	/// (accept loop, per-conn stream acceptors, per-stream read pumps).
185	struct NetShared {
186	    next_conn_id: AtomicU64,
187	    conns: Mutex<HashMap<u64, ConnEntry>>,
188	    next_stream_id: AtomicU64,
189	    streams: Mutex<HashMap<u64, Arc<StreamEntry>>>,
190	    ring_cap: usize,
191	}
192
193	impl NetShared {
194	    fn new() -> Self {
195	        NetShared {
196	            next_conn_id: AtomicU64::new(1),
197	            conns: Mutex::new(HashMap::new()),
198	            next_stream_id: AtomicU64::new(1),
199	            streams: Mutex::new(HashMap::new()),
200	            ring_cap: DEFAULT_STREAM_RING_CHUNKS,
201	        }
202	    }
203	}
204
205	/// Register a live connection: mint its broker id and spawn its inbound-stream
206	/// acceptor. Must run inside the host's runtime context (the accept loop, or a
207	/// `block_on` future) — it spawns tasks.
208	fn register_conn(shared: &Arc<NetShared>, conn: Connection) -> (u64, String) {
209	    let remote_id_hex = PublicKey::from_bytes(conn.remote_id().as_bytes())
210	        .expect("an iroh EndpointId is always a valid ed25519 public key")
211	        .to_hex();
212	    let id = shared.next_conn_id.fetch_add(1, Ordering::Relaxed);
213	    shared
214	        .conns
215	        .lock()
216	        .unwrap()
217	        .insert(id, ConnEntry { conn: conn.clone(), remote_id_hex: remote_id_hex.clone() });
218
219	    // Inbound-stream acceptor: every peer-opened bidi stream joins the table
220	    // and gets a read pump. (QUIC streams are lazy — the peer's open becomes
221	    // visible here on its first write.) Ends when the connection does.
222	    let sh = Arc::clone(shared);
223	    let hex = remote_id_hex.clone();
224	    tokio::spawn(async move {
225	        while let Ok((send, recv)) = conn.accept_bi().await {
226	            register_stream(&sh, id, &hex, send, recv);
227	        }
228	    });
229
230	    (id, remote_id_hex)
231	}
232
233	/// Register a live stream (opened or accepted): mint its broker id and spawn
234	/// its read pump. Must run inside the host's runtime context.
235	fn register_stream(
236	    shared: &Arc<NetShared>,
237	    conn_id: u64,
238	    remote_id_hex: &str,
239	    send: SendStream,
240	    recv: RecvStream,
241	) -> u64 {
242	    let id = shared.next_stream_id.fetch_add(1, Ordering::Relaxed);
243	    let log = Arc::new(Mutex::new(StreamLog::new(id, shared.ring_cap)));
244	    shared.streams.lock().unwrap().insert(
245	        id,
246	        Arc::new(StreamEntry {
247	            conn_id,
248	            remote_id_hex: remote_id_hex.to_string(),
249	            send: tokio::sync::Mutex::new(send),
250	            log: Arc::clone(&log),
251	        }),
252	    );
253
254	    // The read pump — the stream's single producer. A clean end (peer finish)
255	    // and a torn end (conn lost) both finish the log; the buffered chunks stay
256	    // replayable either way.
257	    let mut recv = recv;
258	    tokio::spawn(async move {
259	        loop {
260	            match recv.read_chunk(STREAM_READ_CHUNK).await {
261	                Ok(Some(chunk)) => log.lock().unwrap().append(&chunk.bytes),
262	                Ok(None) | Err(_) => {
263	                    log.lock().unwrap().finish();
264	                    break;
265	                }
266	            }
267	        }
268	    });
269
270	    id
271	}
272
273	/// The broker-owned net endpoint + live connection/stream tables (see module
274	/// docs).
275	pub struct NetHost {
276	    /// The dedicated tokio runtime hosting the endpoint and its accept loop.
277	    /// Dropping the host drops the runtime, stopping every net task with it.
278	    runtime: tokio::runtime::Runtime,
279	    endpoint: Arc<NetEndpoint>,
280	    shared: Arc<NetShared>,
281	    /// `op_id → conn_id` for exactly-once dial replies: a deduped replay of a
282	    /// journaled dial is answered with the connection the first apply opened.
283	    dial_ops: Mutex<HashMap<u64, u64>>,
284	    /// `op_id → stream_id` for exactly-once stream-open replies (D4b), same
285	    /// discipline as `dial_ops`. Shares the one net op-id namespace.
286	    stream_ops: Mutex<HashMap<u64, u64>>,
287	}
288
289	impl NetHost {
290	    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
291	    /// accept loop (every inbound `SPT_NET_ALPN` connection joins the table).
292	    // [impl->REQ-DAEMON-2]
293	    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {
294	        let runtime = tokio::runtime::Builder::new_multi_thread()
295	            .worker_threads(2)
296	            .thread_name("spt-broker-net")
297	            .enable_all()
298	            .build()?;
299	        let endpoint = runtime
300	            .block_on(NetEndpoint::bind_scoped(
301	                &cfg.identity,
302	                cfg.relay,
303	                cfg.discovery,
304	                vec![SPT_NET_ALPN.to_vec()],
305	                cfg.scope,
306	            ))
307	            .map_err(|e| io::Error::other(e.to_string()))?;
308	        let endpoint = Arc::new(endpoint);
309	        let shared = Arc::new(NetShared::new());
310
311	        // Accept loop: lives for the endpoint's lifetime on the host's runtime.
312	        // Each handshake completes on its own task so a slow peer can't stall
313	        // the loop; a failed handshake is simply not registered.
314	        let ep = Arc::clone(&endpoint);
315	        let sh = Arc::clone(&shared);
316	        runtime.spawn(async move {
317	            while let Some(incoming) = ep.accept().await {
318	                let sh = Arc::clone(&sh);
319	                tokio::spawn(async move {
320	                    let Ok(connecting) = incoming.accept() else { return };
321	                    let Ok(conn) = connecting.await else { return };
322	                    register_conn(&sh, conn);
323	                });
324	            }
325	        });
326
327	        Ok(NetHost {
328	            runtime,
329	            endpoint,
330	            shared,
331	            dial_ops: Mutex::new(HashMap::new()),
332	            stream_ops: Mutex::new(HashMap::new()),
333	        })
334	    }
335
336	    /// The node's stable name (lowercase-hex pubkey == iroh EndpointId).
337	    pub fn node_id_hex(&self) -> String {
338	        self.endpoint.node_id_hex()
339	    }
340
341	    /// The endpoint's dialable address (id + currently-known direct/relay paths).
342	    pub fn addr(&self) -> EndpointAddr {
343	        self.endpoint.addr()
344	    }
345
346	    /// [`Self::addr`] as the opaque JSON the IPC status reply carries.
347	    pub fn addr_json(&self) -> serde_json::Value {
348	        serde_json::to_value(self.endpoint.addr()).unwrap_or(serde_json::Value::Null)
349	    }
350
351	    /// Live connections (accepted + dialed) currently held.
352	    pub fn conn_count(&self) -> u64 {
353	        self.shared.conns.lock().unwrap().len() as u64
354	    }
355
356	    /// Dial a peer over `SPT_NET_ALPN`, registering the connection in the table.
357	    /// Bridges sync→async via the host's own runtime (the broker's dispatch
358	    /// threads block here; the future runs on the net worker threads).
359	    // [impl->REQ-DAEMON-2]
360	    pub fn dial(&self, addr: EndpointAddr) -> io::Result<(u64, String)> {
361	        let endpoint = Arc::clone(&self.endpoint);
362	        let shared = Arc::clone(&self.shared);
363	        self.runtime.handle().block_on(async move {
364	            let conn = endpoint
365	                .connect(addr)
366	                .await
367	                .map_err(|e| io::Error::other(e.to_string()))?;
368	            Ok(register_conn(&shared, conn))
369	        })
370	    }
371
372	    /// Record that journaled dial `op_id` opened `conn_id` (called inside the
373	    /// `apply_once` closure, so a concurrent deduped replay always finds it).
374	    pub fn record_dial_op(&self, op_id: u64, conn_id: u64) {
375	        self.dial_ops.lock().unwrap().insert(op_id, conn_id);
376	    }
377
378	    /// The connection a journaled dial `op_id` opened, if this broker process
379	    /// still holds it (a broker restart drops connections; the journal alone
380	    /// survives — see `Broker::dispatch_net_dial`).
381	    pub fn dial_op_conn(&self, op_id: u64) -> Option<u64> {
382	        self.dial_ops.lock().unwrap().get(&op_id).copied()
383	    }
384
385	    /// The remote node id (hex) of a held connection.
386	    pub fn conn_remote_hex(&self, conn_id: u64) -> Option<String> {
387	        self.shared.conns.lock().unwrap().get(&conn_id).map(|e| e.remote_id_hex.clone())
388	    }
389
390	    /// Open a bidi stream on a held connection, registering it (with its read
391	    /// pump) in the stream table (D4b). Note QUIC streams are lazy: the peer
392	    /// sees the stream on its first write.
393	    // [impl->REQ-DAEMON-2]
394	    pub fn open_stream(&self, conn_id: u64) -> io::Result<u64> {
395	        let (conn, remote_id_hex) = {
396	            let conns = self.shared.conns.lock().unwrap();
397	            let e = conns.get(&conn_id).ok_or_else(|| {
398	                io::Error::new(io::ErrorKind::NotFound, format!("no such conn {conn_id}"))
399	            })?;
400	            (e.conn.clone(), e.remote_id_hex.clone())
401	        };
402	        let shared = Arc::clone(&self.shared);
403	        self.runtime.handle().block_on(async move {
404	            let (send, recv) = conn
405	                .open_bi()
406	                .await
407	                .map_err(|e| io::Error::other(e.to_string()))?;
408	            Ok(register_stream(&shared, conn_id, &remote_id_hex, send, recv))
409	        })
410	    }
411
412	    /// Record that journaled open `op_id` minted `stream_id` (inside the
413	    /// `apply_once` closure — same discipline as [`Self::record_dial_op`]).
414	    pub fn record_stream_op(&self, op_id: u64, stream_id: u64) {
415	        self.stream_ops.lock().unwrap().insert(op_id, stream_id);
416	    }
417
418	    /// The stream a journaled open `op_id` minted, if still held.
419	    pub fn stream_op_id(&self, op_id: u64) -> Option<u64> {
420	        self.stream_ops.lock().unwrap().get(&op_id).copied()
421	    }
422
423	    /// A held stream's `(conn_id, remote_id_hex)`.
424	    pub fn stream_conn(&self, stream_id: u64) -> Option<(u64, String)> {
425	        self.shared
426	            .streams
427	            .lock()
428	            .unwrap()
429	            .get(&stream_id)
430	            .map(|e| (e.conn_id, e.remote_id_hex.clone()))
431	    }
432
433	    /// Write `bytes` to a held stream's send side; `finish` closes the send
434	    /// side after the write (the peer's read pump then sees a clean end).
435	    // [impl->REQ-DAEMON-2]
436	    pub fn send_stream(&self, stream_id: u64, bytes: &[u8], finish: bool) -> io::Result<()> {
437	        let entry = {
438	            let streams = self.shared.streams.lock().unwrap();
439	            Arc::clone(streams.get(&stream_id).ok_or_else(|| {
440	                io::Error::new(io::ErrorKind::NotFound, format!("no such stream {stream_id}"))
441	            })?)
442	        };
443	        let bytes = bytes.to_vec();
444	        self.runtime.handle().block_on(async move {
445	            let mut send = entry.send.lock().await;
446	            if !bytes.is_empty() {
447	                send.write_all(&bytes)
448	                    .await
449	                    .map_err(|e| io::Error::other(e.to_string()))?;
450	            }
451	            if finish {
452	                send.finish().map_err(|e| io::Error::other(e.to_string()))?;
453	            }
454	            Ok(())
455	        })
456	    }
457
458	    /// Every held stream's info — the query a re-attaching brain uses to find
459	    /// streams (including peer-initiated ones that arrived while it was down).
460	    pub fn stream_infos(&self) -> Vec<NetStreamInfo> {
461	        let streams = self.shared.streams.lock().unwrap();
462	        streams
463	            .iter()
464	            .map(|(id, e)| {
465	                let log = e.log.lock().unwrap();
466	                NetStreamInfo {
467	                    stream_id: *id,
468	                    conn_id: e.conn_id,
469	                    remote_id_hex: e.remote_id_hex.clone(),
470	                    next_seq: log.next_seq,
471	                    finished: log.finished,
472	                }
473	            })
474	            .collect()
475	    }
476
477	    /// Attach `sub` as a stream's read subscriber, replaying from `from_seq`
478	    /// (gapless resume — the PTY `subscribe` discipline on a QUIC stream).
479	    // [impl->REQ-DAEMON-2]
480	    pub(crate) fn subscribe_stream(
481	        &self,
482	        stream_id: u64,
483	        sub: SharedSend,
484	        from_seq: u64,
485	    ) -> io::Result<()> {
486	        let log = {
487	            let streams = self.shared.streams.lock().unwrap();
488	            let e = streams.get(&stream_id).ok_or_else(|| {
489	                io::Error::new(io::ErrorKind::NotFound, format!("no such stream {stream_id}"))
490	            })?;
491	            Arc::clone(&e.log)
492	        };
493	        log.lock().unwrap().attach(sub, from_seq);
494	        Ok(())
495	    }
496
497	    /// Detach `sub` from a stream iff it is the current subscriber (brain
498	    /// disconnect cleanup; the stream + its ring persist).
499	    pub(crate) fn detach_stream_sub(&self, stream_id: u64, sub: &SharedSend) {
500	        let log = {
501	            let streams = self.shared.streams.lock().unwrap();
502	            streams.get(&stream_id).map(|e| Arc::clone(&e.log))
503	        };
504	        if let Some(log) = log {
505	            log.lock().unwrap().detach_if(sub);
506	        }
507	    }
508	}
509
510	#[cfg(test)]
511	mod tests {
512	    use super::*;
513
514	    fn hermetic(identity: &Identity) -> NetConfig {
515	        NetConfig {
516	            identity: Identity::from_seed(&identity.seed()),
517	            relay: RelayPolicy::Disabled,
518	            discovery: LocalDiscovery::Off,
519	            scope: BindScope::Loopback,
520	        }
521	    }
522
523	    // [unit->REQ-DAEMON-2] the endpoint is constructed broker-side (inside the
524	    // host's dedicated runtime) with relays disabled, bound to the node's own
525	    // identity, with an empty conn table.
526	    #[test]
527	    fn host_binds_endpoint_to_node_identity() {
528	        let id = Identity::generate();
529	        let host = NetHost::start(hermetic(&id)).expect("host start");
530	        assert_eq!(host.node_id_hex(), id.public_key().to_hex());
531	        assert_eq!(host.conn_count(), 0);
532	        assert!(!host.addr_json().is_null(), "dialable address serializes");
533	    }
534
535	    // [unit->REQ-DAEMON-2] a broker-owned host dials a second in-process host
536	    // over hermetic loopback QUIC; both sides register the connection — the
537	    // dialer in its table, the acceptor via its accept loop.
538	    #[test]
539	    fn two_hosts_dial_over_loopback() {
540	        let a = NetHost::start(hermetic(&Identity::generate())).expect("host a");
541	        let b_id = Identity::generate();
542	        let b = NetHost::start(hermetic(&b_id)).expect("host b");
543
544	        let (conn_id, remote) = a.dial(b.addr()).expect("dial");
545	        assert_eq!(conn_id, 1, "first broker-minted conn id");
546	        assert_eq!(remote, b_id.public_key().to_hex(), "handshake proves the peer");
547	        assert_eq!(a.conn_count(), 1);
548
549	        // The acceptor's loop registers the inbound connection (async — poll).
550	        for _ in 0..400 {
551	            if b.conn_count() == 1 {
552	                break;
553	            }
554	            std::thread::sleep(std::time::Duration::from_millis(5));
555	        }
556	        assert_eq!(b.conn_count(), 1, "accept loop registered the inbound conn");
557	        assert_eq!(
558	            b.conn_remote_hex(1).as_deref(),
559	            Some(a.node_id_hex().as_str()),
560	            "acceptor sees the dialer's identity"
561	        );
562	    }
563	}
564

//! The broker-owned network host (D4a) — ADR-0004 §B made real for the wire.
//!
//! [`NetHost`] is the broker's ownership of the node's live network presence:
//! the iroh endpoint ([`spt_net::net::endpoint::NetEndpoint`]) and every
//! accepted/dialed QUIC connection. A live cross-node connection is exactly the
//! kind of resource ADR-0004 §B assigns to the broker — a consumer would lose
//! continuity on brain restart — so the endpoint is constructed *here*, in the
//! stable kernel, never in the brain. The brain drives net operations over the
//! versioned IPC ([`crate::msg::KIND_NET_STATUS`] / [`crate::msg::KIND_NET_DIAL`])
//! and may detach/reattach freely; connections persist in the broker's table.
//!
//! ## Runtime placement (M4-D4-PLAN locked decision)
//!
//! The broker is otherwise sync/thread-based; iroh requires tokio. So the host
//! owns a **dedicated multi-thread tokio runtime** on named worker threads
//! (`spt-broker-net`): QUIC objects live in structures only the runtime's tasks
//! and the host's own `block_on` bridges touch, and the runtime drops with the
//! host (broker teardown stops all net tasks). Spike #3's broker binary proved
//! exactly this shape — a broker-owned endpoint surviving brain kill/restart.
//!
//! ## Ownership closes two §B rows by construction
//!
//! `MdnsAddressLookup` and the relay session are both constructed *inside* the
//! iroh endpoint, so broker ownership of the endpoint closes the ADR-0004 §B
//! "mDNS socket / relay session" row with no extra machinery: there is no
//! separate socket to own.
// [impl->REQ-DAEMON-2]

use std::collections::{HashMap, VecDeque};
use std::io;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};

use spt_net::net::endpoint::{
    BindScope, Connection, EndpointAddr, LocalDiscovery, NetEndpoint, RecvStream, RelayPolicy,
    SendStream, SPT_NET_ALPN,
};
use spt_proto::identity::{Identity, PublicKey};

use crate::broker::SharedSend;
use crate::codec::write_frame;
use crate::msg::{net_stream_data_envelope, net_stream_eof_envelope, NetStreamInfo};

/// The reserved [`crate::effect::EffectKey`] session namespace for net-scoped
/// effects (a dial has no PTY session). Broker session ids are minted from 1
/// (`Broker::next_id` starts at 1), so `0` can never collide with a real session.
pub const NET_EFFECT_SESSION: u64 = 0;

/// Everything needed to stand the broker's endpoint up: the node's identity
/// (the endpoint *is* the node's name — EndpointId == pubkey), the relay stance
/// (from `daemon.json` in production; [`RelayPolicy::Disabled`] in hermetic
/// tests), and whether mDNS LAN discovery rides along.
pub struct NetConfig {
    /// The node identity the endpoint binds as (seed → same ed25519 keypair).
    pub identity: Identity,
    /// Relay stance (ADR-0002): n0 default / self-host / disabled.
    pub relay: RelayPolicy,
    /// mDNS LAN discovery on/off (off in hermetic tests).
    pub discovery: LocalDiscovery,
    /// Socket bind scope: [`BindScope::All`] in production;
    /// [`BindScope::Loopback`] in hermetic tests (no real interface touched,
    /// no per-test-binary Windows firewall prompt).
    pub scope: BindScope,
}

/// Default bounded stream-read ring depth (chunks) — same sizing rationale as
/// the PTY `OutputLog`: large enough that a brain-restart window never evicts
/// unreplayed chunks in practice; bounded so an unsubscribed stream can't
/// buffer without limit.
const DEFAULT_STREAM_RING_CHUNKS: usize = 4096;

/// Max bytes pulled per stream read (one ring chunk).
const STREAM_READ_CHUNK: usize = 64 * 1024;

/// One live QUIC connection the broker holds.
struct ConnEntry {
    /// The connection itself — kept alive by ownership here; dropping it closes
    /// the QUIC connection. Cloned (cheap handle) for stream opens.
    conn: Connection,
    /// The remote node's stable name (hex pubkey), proven by the QUIC handshake.
    remote_id_hex: String,
}

/// A bounded, sequenced read log for one QUIC stream — the PTY `OutputLog`
/// discipline applied to the network (D4b): the stream's single read pump is
/// the only producer; a brain connection attaches as the subscriber; every
/// subscriber send happens under the enclosing `Mutex`, so live chunks and a
/// replay can never interleave out of `seq` order. Chunks produced while no
/// brain is attached sit in the ring and replay on the next subscribe — a
/// brain restart loses nothing (Spike #3 invariant B/D).
// [impl->REQ-DAEMON-2]
struct StreamLog {
    stream_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
    /// Read side finished cleanly (peer called finish / conn ended).
    finished: bool,
}

impl StreamLog {
    fn new(stream_id: u64, cap_chunks: usize) -> Self {
        StreamLog {
            stream_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
            finished: false,
        }
    }

    /// Append a freshly-read chunk: assign the next `seq`, retain it in the
    /// bounded ring, live-send to the current subscriber. Read-pump only.
    fn append(&mut self, bytes: &[u8]) {
        let seq = self.next_seq;
        self.next_seq += 1;
        self.ring.push_back((seq, bytes.to_vec()));
        while self.ring.len() > self.cap_chunks {
            self.ring.pop_front();
        }
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_data_envelope(self.stream_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Mark the read side finished and tell the subscriber (with the final
    /// cursor, so it can verify completeness).
    fn finish(&mut self) {
        self.finished = true;
        if let Some(sub) = &self.subscriber {
            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Attach `sub` as the subscriber, replaying every retained chunk with
    /// `seq >= from_seq` (then eof, if already finished) before live streaming.
    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
        self.subscriber = Some(Arc::clone(&sub));
        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
            let frame = net_stream_data_envelope(self.stream_id, *seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
        if self.finished {
            let frame = net_stream_eof_envelope(self.stream_id, self.next_seq);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Detach `sub` iff it is the current subscriber (a late disconnect must
    /// not unsubscribe a newer brain).
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.subscriber {
            if Arc::ptr_eq(cur, sub) {
                self.subscriber = None;
            }
        }
    }
}

/// One live QUIC stream the broker holds: the send half (brain writes ride it
/// over IPC) + the sequenced read log.
struct StreamEntry {
    conn_id: u64,
    remote_id_hex: String,
    /// The send half, serialized behind an async mutex (writes happen on the
    /// host's runtime via `block_on`).
    send: tokio::sync::Mutex<SendStream>,
    log: Arc<Mutex<StreamLog>>,
}

/// Tables shared between the host's sync surface and its runtime tasks
/// (accept loop, per-conn stream acceptors, per-stream read pumps).
struct NetShared {
    next_conn_id: AtomicU64,
    conns: Mutex<HashMap<u64, ConnEntry>>,
    next_stream_id: AtomicU64,
    streams: Mutex<HashMap<u64, Arc<StreamEntry>>>,
    ring_cap: usize,
}

impl NetShared {
    fn new() -> Self {
        NetShared {
            next_conn_id: AtomicU64::new(1),
            conns: Mutex::new(HashMap::new()),
            next_stream_id: AtomicU64::new(1),
            streams: Mutex::new(HashMap::new()),
            ring_cap: DEFAULT_STREAM_RING_CHUNKS,
        }
    }
}

/// Register a live connection: mint its broker id and spawn its inbound-stream
/// acceptor. Must run inside the host's runtime context (the accept loop, or a
/// `block_on` future) — it spawns tasks.
fn register_conn(shared: &Arc<NetShared>, conn: Connection) -> (u64, String) {
    let remote_id_hex = PublicKey::from_bytes(conn.remote_id().as_bytes())
        .expect("an iroh EndpointId is always a valid ed25519 public key")
        .to_hex();
    let id = shared.next_conn_id.fetch_add(1, Ordering::Relaxed);
    shared
        .conns
        .lock()
        .unwrap()
        .insert(id, ConnEntry { conn: conn.clone(), remote_id_hex: remote_id_hex.clone() });

    // Inbound-stream acceptor: every peer-opened bidi stream joins the table
    // and gets a read pump. (QUIC streams are lazy — the peer's open becomes
    // visible here on its first write.) Ends when the connection does.
    let sh = Arc::clone(shared);
    let hex = remote_id_hex.clone();
    tokio::spawn(async move {
        while let Ok((send, recv)) = conn.accept_bi().await {
            register_stream(&sh, id, &hex, send, recv);
        }
    });

    (id, remote_id_hex)
}

/// Register a live stream (opened or accepted): mint its broker id and spawn
/// its read pump. Must run inside the host's runtime context.
fn register_stream(
    shared: &Arc<NetShared>,
    conn_id: u64,
    remote_id_hex: &str,
    send: SendStream,
    recv: RecvStream,
) -> u64 {
    let id = shared.next_stream_id.fetch_add(1, Ordering::Relaxed);
    let log = Arc::new(Mutex::new(StreamLog::new(id, shared.ring_cap)));
    shared.streams.lock().unwrap().insert(
        id,
        Arc::new(StreamEntry {
            conn_id,
            remote_id_hex: remote_id_hex.to_string(),
            send: tokio::sync::Mutex::new(send),
            log: Arc::clone(&log),
        }),
    );

    // The read pump — the stream's single producer. A clean end (peer finish)
    // and a torn end (conn lost) both finish the log; the buffered chunks stay
    // replayable either way.
    let mut recv = recv;
    tokio::spawn(async move {
        loop {
            match recv.read_chunk(STREAM_READ_CHUNK).await {
                Ok(Some(chunk)) => log.lock().unwrap().append(&chunk.bytes),
                Ok(None) | Err(_) => {
                    log.lock().unwrap().finish();
                    break;
                }
            }
        }
    });

    id
}

/// The broker-owned net endpoint + live connection/stream tables (see module
/// docs).
pub struct NetHost {
    /// The dedicated tokio runtime hosting the endpoint and its accept loop.
    /// Dropping the host drops the runtime, stopping every net task with it.
    runtime: tokio::runtime::Runtime,
    endpoint: Arc<NetEndpoint>,
    shared: Arc<NetShared>,
    /// `op_id → conn_id` for exactly-once dial replies: a deduped replay of a
    /// journaled dial is answered with the connection the first apply opened.
    dial_ops: Mutex<HashMap<u64, u64>>,
    /// `op_id → stream_id` for exactly-once stream-open replies (D4b), same
    /// discipline as `dial_ops`. Shares the one net op-id namespace.
    stream_ops: Mutex<HashMap<u64, u64>>,
}

impl NetHost {
    /// Bind the broker's endpoint on a fresh dedicated runtime and start the
    /// accept loop (every inbound `SPT_NET_ALPN` connection joins the table).
    // [impl->REQ-DAEMON-2]
    pub fn start(cfg: NetConfig) -> io::Result<NetHost> {
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .worker_threads(2)
            .thread_name("spt-broker-net")
            .enable_all()
            .build()?;
        let endpoint = runtime
            .block_on(NetEndpoint::bind_scoped(
                &cfg.identity,
                cfg.relay,
                cfg.discovery,
                vec![SPT_NET_ALPN.to_vec()],
                cfg.scope,
            ))
            .map_err(|e| io::Error::other(e.to_string()))?;
        let endpoint = Arc::new(endpoint);
        let shared = Arc::new(NetShared::new());

        // Accept loop: lives for the endpoint's lifetime on the host's runtime.
        // Each handshake completes on its own task so a slow peer can't stall
        // the loop; a failed handshake is simply not registered.
        let ep = Arc::clone(&endpoint);
        let sh = Arc::clone(&shared);
        runtime.spawn(async move {
            while let Some(incoming) = ep.accept().await {
                let sh = Arc::clone(&sh);
                tokio::spawn(async move {
                    let Ok(connecting) = incoming.accept() else { return };
                    let Ok(conn) = connecting.await else { return };
                    register_conn(&sh, conn);
                });
            }
        });

        Ok(NetHost {
            runtime,
            endpoint,
            shared,
            dial_ops: Mutex::new(HashMap::new()),
            stream_ops: Mutex::new(HashMap::new()),
        })
    }

    /// The node's stable name (lowercase-hex pubkey == iroh EndpointId).
    pub fn node_id_hex(&self) -> String {
        self.endpoint.node_id_hex()
    }

    /// The endpoint's dialable address (id + currently-known direct/relay paths).
    pub fn addr(&self) -> EndpointAddr {
        self.endpoint.addr()
    }

    /// [`Self::addr`] as the opaque JSON the IPC status reply carries.
    pub fn addr_json(&self) -> serde_json::Value {
        serde_json::to_value(self.endpoint.addr()).unwrap_or(serde_json::Value::Null)
    }

    /// Live connections (accepted + dialed) currently held.
    pub fn conn_count(&self) -> u64 {
        self.shared.conns.lock().unwrap().len() as u64
    }

    /// Dial a peer over `SPT_NET_ALPN`, registering the connection in the table.
    /// Bridges sync→async via the host's own runtime (the broker's dispatch
    /// threads block here; the future runs on the net worker threads).
    // [impl->REQ-DAEMON-2]
    pub fn dial(&self, addr: EndpointAddr) -> io::Result<(u64, String)> {
        let endpoint = Arc::clone(&self.endpoint);
        let shared = Arc::clone(&self.shared);
        self.runtime.handle().block_on(async move {
            let conn = endpoint
                .connect(addr)
                .await
                .map_err(|e| io::Error::other(e.to_string()))?;
            Ok(register_conn(&shared, conn))
        })
    }

    /// Record that journaled dial `op_id` opened `conn_id` (called inside the
    /// `apply_once` closure, so a concurrent deduped replay always finds it).
    pub fn record_dial_op(&self, op_id: u64, conn_id: u64) {
        self.dial_ops.lock().unwrap().insert(op_id, conn_id);
    }

    /// The connection a journaled dial `op_id` opened, if this broker process
    /// still holds it (a broker restart drops connections; the journal alone
    /// survives — see `Broker::dispatch_net_dial`).
    pub fn dial_op_conn(&self, op_id: u64) -> Option<u64> {
        self.dial_ops.lock().unwrap().get(&op_id).copied()
    }

    /// The remote node id (hex) of a held connection.
    pub fn conn_remote_hex(&self, conn_id: u64) -> Option<String> {
        self.shared.conns.lock().unwrap().get(&conn_id).map(|e| e.remote_id_hex.clone())
    }

    /// Open a bidi stream on a held connection, registering it (with its read
    /// pump) in the stream table (D4b). Note QUIC streams are lazy: the peer
    /// sees the stream on its first write.
    // [impl->REQ-DAEMON-2]
    pub fn open_stream(&self, conn_id: u64) -> io::Result<u64> {
        let (conn, remote_id_hex) = {
            let conns = self.shared.conns.lock().unwrap();
            let e = conns.get(&conn_id).ok_or_else(|| {
                io::Error::new(io::ErrorKind::NotFound, format!("no such conn {conn_id}"))
            })?;
            (e.conn.clone(), e.remote_id_hex.clone())
        };
        let shared = Arc::clone(&self.shared);
        self.runtime.handle().block_on(async move {
            let (send, recv) = conn
                .open_bi()
                .await
                .map_err(|e| io::Error::other(e.to_string()))?;
            Ok(register_stream(&shared, conn_id, &remote_id_hex, send, recv))
        })
    }

    /// Record that journaled open `op_id` minted `stream_id` (inside the
    /// `apply_once` closure — same discipline as [`Self::record_dial_op`]).
    pub fn record_stream_op(&self, op_id: u64, stream_id: u64) {
        self.stream_ops.lock().unwrap().insert(op_id, stream_id);
    }

    /// The stream a journaled open `op_id` minted, if still held.
    pub fn stream_op_id(&self, op_id: u64) -> Option<u64> {
        self.stream_ops.lock().unwrap().get(&op_id).copied()
    }

    /// A held stream's `(conn_id, remote_id_hex)`.
    pub fn stream_conn(&self, stream_id: u64) -> Option<(u64, String)> {
        self.shared
            .streams
            .lock()
            .unwrap()
            .get(&stream_id)
            .map(|e| (e.conn_id, e.remote_id_hex.clone()))
    }

    /// Write `bytes` to a held stream's send side; `finish` closes the send
    /// side after the write (the peer's read pump then sees a clean end).
    // [impl->REQ-DAEMON-2]
    pub fn send_stream(&self, stream_id: u64, bytes: &[u8], finish: bool) -> io::Result<()> {
        let entry = {
            let streams = self.shared.streams.lock().unwrap();
            Arc::clone(streams.get(&stream_id).ok_or_else(|| {
                io::Error::new(io::ErrorKind::NotFound, format!("no such stream {stream_id}"))
            })?)
        };
        let bytes = bytes.to_vec();
        self.runtime.handle().block_on(async move {
            let mut send = entry.send.lock().await;
            if !bytes.is_empty() {
                send.write_all(&bytes)
                    .await
                    .map_err(|e| io::Error::other(e.to_string()))?;
            }
            if finish {
                send.finish().map_err(|e| io::Error::other(e.to_string()))?;
            }
            Ok(())
        })
    }

    /// Every held stream's info — the query a re-attaching brain uses to find
    /// streams (including peer-initiated ones that arrived while it was down).
    pub fn stream_infos(&self) -> Vec<NetStreamInfo> {
        let streams = self.shared.streams.lock().unwrap();
        streams
            .iter()
            .map(|(id, e)| {
                let log = e.log.lock().unwrap();
                NetStreamInfo {
                    stream_id: *id,
                    conn_id: e.conn_id,
                    remote_id_hex: e.remote_id_hex.clone(),
                    next_seq: log.next_seq,
                    finished: log.finished,
                }
            })
            .collect()
    }

    /// Attach `sub` as a stream's read subscriber, replaying from `from_seq`
    /// (gapless resume — the PTY `subscribe` discipline on a QUIC stream).
    // [impl->REQ-DAEMON-2]
    pub(crate) fn subscribe_stream(
        &self,
        stream_id: u64,
        sub: SharedSend,
        from_seq: u64,
    ) -> io::Result<()> {
        let log = {
            let streams = self.shared.streams.lock().unwrap();
            let e = streams.get(&stream_id).ok_or_else(|| {
                io::Error::new(io::ErrorKind::NotFound, format!("no such stream {stream_id}"))
            })?;
            Arc::clone(&e.log)
        };
        log.lock().unwrap().attach(sub, from_seq);
        Ok(())
    }

    /// Detach `sub` from a stream iff it is the current subscriber (brain
    /// disconnect cleanup; the stream + its ring persist).
    pub(crate) fn detach_stream_sub(&self, stream_id: u64, sub: &SharedSend) {
        let log = {
            let streams = self.shared.streams.lock().unwrap();
            streams.get(&stream_id).map(|e| Arc::clone(&e.log))
        };
        if let Some(log) = log {
            log.lock().unwrap().detach_if(sub);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn hermetic(identity: &Identity) -> NetConfig {
        NetConfig {
            identity: Identity::from_seed(&identity.seed()),
            relay: RelayPolicy::Disabled,
            discovery: LocalDiscovery::Off,
            scope: BindScope::Loopback,
        }
    }

    // [unit->REQ-DAEMON-2] the endpoint is constructed broker-side (inside the
    // host's dedicated runtime) with relays disabled, bound to the node's own
    // identity, with an empty conn table.
    #[test]
    fn host_binds_endpoint_to_node_identity() {
        let id = Identity::generate();
        let host = NetHost::start(hermetic(&id)).expect("host start");
        assert_eq!(host.node_id_hex(), id.public_key().to_hex());
        assert_eq!(host.conn_count(), 0);
        assert!(!host.addr_json().is_null(), "dialable address serializes");
    }

    // [unit->REQ-DAEMON-2] a broker-owned host dials a second in-process host
    // over hermetic loopback QUIC; both sides register the connection — the
    // dialer in its table, the acceptor via its accept loop.
    #[test]
    fn two_hosts_dial_over_loopback() {
        let a = NetHost::start(hermetic(&Identity::generate())).expect("host a");
        let b_id = Identity::generate();
        let b = NetHost::start(hermetic(&b_id)).expect("host b");

        let (conn_id, remote) = a.dial(b.addr()).expect("dial");
        assert_eq!(conn_id, 1, "first broker-minted conn id");
        assert_eq!(remote, b_id.public_key().to_hex(), "handshake proves the peer");
        assert_eq!(a.conn_count(), 1);

        // The acceptor's loop registers the inbound connection (async — poll).
        for _ in 0..400 {
            if b.conn_count() == 1 {
                break;
            }
            std::thread::sleep(std::time::Duration::from_millis(5));
        }
        assert_eq!(b.conn_count(), 1, "accept loop registered the inbound conn");
        assert_eq!(
            b.conn_remote_hex(1).as_deref(),
            Some(a.node_id_hex().as_str()),
            "acceptor sees the dialer's identity"
        );
    }
}


1	//! The broker↔brain message catalog: the kind discriminators and payload
2	//! shapes that ride inside the forward-compatible [`Envelope`].
3	//!
4	//! Split out from [`crate::frame`] (which owns the *envelope mechanics* and the
5	//! version handshake) so the message *vocabulary* can grow additively without
6	//! touching the wire-compat machinery. Every payload is a plain serde struct
7	//! with serde's default unknown-field tolerance, so a newer brain may add a
8	//! field and an older broker still decodes the message (KNOWN-HAZARDS 2.3).
9	//!
10	//! Bytes (PTY input/output) ride as base64 strings — JSON has no native byte
11	//! type, and base64 keeps the single envelope/codec path rather than forking a
12	//! binary frame format.
13	// [impl->REQ-DAEMON-2]
14
15	use base64::engine::general_purpose::STANDARD as B64;
16	use base64::Engine;
17	use serde::{Deserialize, Serialize};
18
19	use crate::frame::Envelope;
20
21	/// Brain→broker: spawn a child under a fresh broker-owned PTY.
22	pub const KIND_SPAWN: &str = "spawn";
23	/// Broker→brain: a spawn succeeded; carries the assigned session id + child pid.
24	pub const KIND_SPAWNED: &str = "spawned";
25	/// Brain→broker: (re)subscribe to a session's output from a given sequence.
26	pub const KIND_SUBSCRIBE: &str = "subscribe";
27	/// Brain→broker: write raw bytes to a session's input (send-keys).
28	pub const KIND_INPUT: &str = "input";
29	/// Brain→broker: resize a session's PTY.
30	pub const KIND_RESIZE: &str = "resize";
31	/// Brain→broker: terminate a session's child.
32	pub const KIND_KILL: &str = "kill";
33	/// Broker→brain: a chunk of a session's output (pushed as the child produces it).
34	pub const KIND_OUTPUT: &str = "output";
35	/// Broker→brain: a session's child exited.
36	pub const KIND_EXIT: &str = "exit";
37	/// Broker→brain: an idempotent effect (an `input` carrying an `op_id`) is durably
38	/// applied **or** was already applied (deduped). The exactly-once ack: a brain
39	/// learns its effect landed, and a replay after a crash is acknowledged the same
40	/// way so the brain can retire it (B5 / KNOWN-HAZARDS 7.2).
41	pub const KIND_APPLIED: &str = "applied";
42	/// Broker→brain: a command failed (carries an operator-facing message).
43	pub const KIND_ERROR: &str = "error";
44	/// Brain→broker: report the broker-owned net endpoint's status (D4a). The brain
45	/// holds no endpoint of its own — the broker owns the node's network presence
46	/// (ADR-0004 §B), so status (node id, dialable address, conn count) is an IPC ask.
47	pub const KIND_NET_STATUS: &str = "net-status";
48	/// Broker→brain: the net endpoint status reply.
49	pub const KIND_NET_STATUS_REPLY: &str = "net-status-reply";
50	/// Brain→broker: dial a peer over the broker-owned endpoint (D4a). With an
51	/// `op_id` the dial is exactly-once (journaled like an idempotent `input`).
52	pub const KIND_NET_DIAL: &str = "net-dial";
53	/// Broker→brain: a dial durably landed — fresh or deduped (`applied_now`
54	/// distinguishes, mirroring [`KIND_APPLIED`]). Carries the broker-minted conn id.
55	pub const KIND_NET_DIALED: &str = "net-dialed";
56	/// Brain→broker: open a bidi QUIC stream on a held connection (D4b). With an
57	/// `op_id` the open is exactly-once (journaled like a dial).
58	pub const KIND_NET_STREAM_OPEN: &str = "net-stream-open";
59	/// Broker→brain: a stream open durably landed (fresh or deduped).
60	pub const KIND_NET_STREAM_OPENED: &str = "net-stream-opened";
61	/// Brain→broker: write bytes to a held stream's send side (D4b). With an
62	/// `op_id` the write is exactly-once (journaled; acked with [`KIND_NET_SENT`]).
63	pub const KIND_NET_STREAM_SEND: &str = "net-stream-send";
64	/// Broker→brain: a journaled stream send durably landed (fresh or deduped).
65	pub const KIND_NET_SENT: &str = "net-sent";
66	/// Brain→broker: list the streams the broker currently holds (D4b). The query
67	/// model a re-attaching brain uses to find streams that arrived while it was
68	/// down — no announce buffer to miss.
69	pub const KIND_NET_STREAMS: &str = "net-streams";
70	/// Broker→brain: the held-streams reply.
71	pub const KIND_NET_STREAMS_REPLY: &str = "net-streams-reply";
72	/// Brain→broker: (re)subscribe to a stream's buffered reads from a cursor —
73	/// the PTY `subscribe` discipline applied to a QUIC stream (gapless resume).
74	pub const KIND_NET_STREAM_SUBSCRIBE: &str = "net-stream-subscribe";
75	/// Broker→brain: a sequenced chunk of a stream's reads (live or replayed).
76	pub const KIND_NET_STREAM_DATA: &str = "net-stream-data";
77	/// Broker→brain: a stream's read side finished cleanly; `next_seq` tells the
78	/// brain the final cursor so it can verify it missed nothing.
79	pub const KIND_NET_STREAM_EOF: &str = "net-stream-eof";
80
81	/// `spawn` payload — the program + args + initial geometry. The broker spawns
82	/// it under a PTY (no shell interpretation; argv is passed through).
83	#[derive(Debug, Clone, Serialize, Deserialize)]
84	pub struct SpawnReq {
85	    /// The program to exec (resolved on `PATH` by the OS spawn).
86	    pub program: String,
87	    /// Its arguments, verbatim.
88	    #[serde(default)]
89	    pub args: Vec<String>,
90	    /// Initial PTY rows.
91	    pub rows: u16,
92	    /// Initial PTY cols.
93	    pub cols: u16,
94	}
95
96	/// `spawned` payload — the broker-minted session id + the child's OS pid.
97	#[derive(Debug, Clone, Serialize, Deserialize)]
98	pub struct Spawned {
99	    /// The id the brain uses to address this session in later commands.
100	    pub session_id: u64,
101	    /// The child's OS pid, if the backend exposes one.
102	    #[serde(default)]
103	    pub pid: Option<u32>,
104	}
105
106	/// `subscribe` payload — attach this connection as a session's output
107	/// subscriber, replaying buffered chunks with `seq >= from_seq` before
108	/// streaming live ones. A reconnecting (handed-off) brain passes its
109	/// last-seen `seq + 1` here for gapless, exactly-once resume (2.3 / Spike #1).
110	#[derive(Debug, Clone, Serialize, Deserialize)]
111	pub struct SubscribeReq {
112	    /// Target session.
113	    pub session_id: u64,
114	    /// Lowest output sequence the subscriber still needs (replay floor).
115	    pub from_seq: u64,
116	}
117
118	/// `input` payload — bytes to inject into a session's stdin (base64).
119	#[derive(Debug, Clone, Serialize, Deserialize)]
120	pub struct InputReq {
121	    /// Target session.
122	    pub session_id: u64,
123	    /// The raw input bytes, base64-encoded.
124	    pub data_b64: String,
125	    /// The durable op id for exactly-once delivery (B5). `Some(id)` routes the
126	    /// write through the broker's [`crate::effect::EffectJournal`]: a replay of
127	    /// the same `(session_id, id)` after a brain crash is deduped, and the broker
128	    /// replies [`KIND_APPLIED`]. `None` is the legacy direct write (no dedup) —
129	    /// kept for callers that don't carry a durable source id. Defaulted so an
130	    /// older brain's `input` (no `op_id`) still decodes (2.3 forward-compat).
131	    #[serde(default, skip_serializing_if = "Option::is_none")]
132	    pub op_id: Option<u64>,
133	}
134
135	/// `resize` payload.
136	#[derive(Debug, Clone, Serialize, Deserialize)]
137	pub struct ResizeReq {
138	    /// Target session.
139	    pub session_id: u64,
140	    /// New rows.
141	    pub rows: u16,
142	    /// New cols.
143	    pub cols: u16,
144	}
145
146	/// `kill` payload.
147	#[derive(Debug, Clone, Serialize, Deserialize)]
148	pub struct KillReq {
149	    /// Target session.
150	    pub session_id: u64,
151	}
152
153	/// `output` event — a chunk of a session's PTY output (base64).
154	#[derive(Debug, Clone, Serialize, Deserialize)]
155	pub struct OutputEvent {
156	    /// Originating session.
157	    pub session_id: u64,
158	    /// The broker-assigned, per-session monotonic sequence of this chunk. The
159	    /// resume cursor: a reconnecting brain subscribes from its last seen
160	    /// `seq + 1`, and dedups by ignoring any `seq` it has already processed.
161	    pub seq: u64,
162	    /// The raw output bytes, base64-encoded.
163	    pub data_b64: String,
164	}
165
166	/// `exit` event — a session's child has been reaped.
167	#[derive(Debug, Clone, Serialize, Deserialize)]
168	pub struct ExitEvent {
169	    /// Originating session.
170	    pub session_id: u64,
171	    /// The child's exit code, if known.
172	    #[serde(default)]
173	    pub code: Option<i32>,
174	}
175
176	/// `applied` event — an idempotent effect is durably applied or was already
177	/// applied. Carries the durable id so the brain can correlate it to the effect
178	/// it issued (or replayed). The exactly-once ack (B5).
179	#[derive(Debug, Clone, Serialize, Deserialize)]
180	pub struct AppliedEvent {
181	    /// The session the effect targeted.
182	    pub session_id: u64,
183	    /// The durable op id of the effect.
184	    pub op_id: u64,
185	    /// Whether the effect ran on this delivery (`true`) or was deduped because it
186	    /// had already landed (`false`). Informational — both mean "durably applied".
187	    #[serde(default)]
188	    pub applied_now: bool,
189	}
190
191	/// `error` event — a command the broker could not satisfy.
192	#[derive(Debug, Clone, Serialize, Deserialize)]
193	pub struct ErrorEvent {
194	    /// Operator-facing description.
195	    pub message: String,
196	}
197
198	/// `net-status-reply` — the broker-owned endpoint's status (D4a). All fields
199	/// after `enabled` are defaulted so a broker bound without a network host (or an
200	/// older broker) still produces/decodes a coherent reply (2.3 forward-compat).
201	#[derive(Debug, Clone, Serialize, Deserialize)]
202	pub struct NetStatusReply {
203	    /// Whether this broker hosts a net endpoint at all.
204	    pub enabled: bool,
205	    /// The node's stable name (lowercase-hex Ed25519 pubkey == iroh EndpointId).
206	    #[serde(default, skip_serializing_if = "Option::is_none")]
207	    pub node_id_hex: Option<String>,
208	    /// The endpoint's dialable address — a serde-serialized iroh `EndpointAddr`,
209	    /// opaque to this catalog (the net host owns its shape). `null` when disabled.
210	    #[serde(default)]
211	    pub addr: serde_json::Value,
212	    /// Live connections (accepted + dialed) in the broker's conn table.
213	    #[serde(default)]
214	    pub conn_count: u64,
215	}
216
217	/// `net-dial` payload — dial a peer over the broker-owned endpoint (D4a).
218	#[derive(Debug, Clone, Serialize, Deserialize)]
219	pub struct NetDialReq {
220	    /// The peer's dialable address — a serde-serialized iroh `EndpointAddr`
221	    /// (opaque JSON here; the broker's net host decodes it).
222	    pub addr: serde_json::Value,
223	    /// The durable op id for exactly-once dialing. `Some(id)` routes the dial
224	    /// through the broker's [`crate::effect::EffectJournal`] (keyed under the
225	    /// reserved [`crate::nethost::NET_EFFECT_SESSION`] namespace): a replay of
226	    /// the same id after a brain crash is deduped and answered with the
227	    /// already-open connection instead of dialing twice. `None` dials directly.
228	    #[serde(default, skip_serializing_if = "Option::is_none")]
229	    pub op_id: Option<u64>,
230	}
231
232	/// `net-dialed` event — a dial durably landed (fresh or deduped).
233	#[derive(Debug, Clone, Serialize, Deserialize)]
234	pub struct NetDialed {
235	    /// The broker-minted id addressing this connection in later net commands.
236	    pub conn_id: u64,
237	    /// The remote node's stable name (lowercase-hex pubkey), proven by the QUIC
238	    /// handshake (the EndpointId *is* the key).
239	    pub remote_id_hex: String,
240	    /// Echoes the request's durable op id, when one was carried.
241	    #[serde(default, skip_serializing_if = "Option::is_none")]
242	    pub op_id: Option<u64>,
243	    /// Whether the dial ran on this delivery (`true`) or was deduped (`false`).
244	    /// Both mean "durably landed" — mirrors [`AppliedEvent::applied_now`].
245	    #[serde(default)]
246	    pub applied_now: bool,
247	}
248
249	/// `net-stream-open` payload — open a bidi stream on a held connection (D4b).
250	#[derive(Debug, Clone, Serialize, Deserialize)]
251	pub struct NetStreamOpenReq {
252	    /// The connection (from [`NetDialed::conn_id`]) to open the stream on.
253	    pub conn_id: u64,
254	    /// Durable op id for an exactly-once open (shares the net op-id namespace
255	    /// with dials/sends — one durable counter across all net effects).
256	    #[serde(default, skip_serializing_if = "Option::is_none")]
257	    pub op_id: Option<u64>,
258	}
259
260	/// `net-stream-opened` event — a stream open durably landed.
261	#[derive(Debug, Clone, Serialize, Deserialize)]
262	pub struct NetStreamOpened {
263	    /// The broker-minted id addressing this stream in later commands.
264	    pub stream_id: u64,
265	    /// The connection it rides.
266	    pub conn_id: u64,
267	    /// Echoes the request's durable op id, when one was carried.
268	    #[serde(default, skip_serializing_if = "Option::is_none")]
269	    pub op_id: Option<u64>,
270	    /// Fresh open (`true`) vs deduped replay (`false`).
271	    #[serde(default)]
272	    pub applied_now: bool,
273	}
274
275	/// `net-stream-send` payload — write bytes to a stream's send side (D4b).
276	#[derive(Debug, Clone, Serialize, Deserialize)]
277	pub struct NetStreamSendReq {
278	    /// Target stream.
279	    pub stream_id: u64,
280	    /// The bytes, base64.
281	    pub data_b64: String,
282	    /// Durable op id for exactly-once delivery; `None` writes directly (no
283	    /// dedup, no ack) — mirrors [`InputReq::op_id`].
284	    #[serde(default, skip_serializing_if = "Option::is_none")]
285	    pub op_id: Option<u64>,
286	    /// Finish the send side after this write (may carry empty bytes): the
287	    /// peer's read pump sees a clean end and emits [`KIND_NET_STREAM_EOF`].
288	    #[serde(default)]
289	    pub finish: bool,
290	}
291
292	/// `net-sent` event — a journaled stream send durably landed.
293	#[derive(Debug, Clone, Serialize, Deserialize)]
294	pub struct NetSent {
295	    /// The stream the write targeted.
296	    pub stream_id: u64,
297	    /// The durable op id of the write.
298	    pub op_id: u64,
299	    /// Fresh apply (`true`) vs deduped replay (`false`).
300	    #[serde(default)]
301	    pub applied_now: bool,
302	}
303
304	/// One held stream in a `net-streams-reply` (D4b).
305	#[derive(Debug, Clone, Serialize, Deserialize)]
306	pub struct NetStreamInfo {
307	    /// The broker-minted stream id.
308	    pub stream_id: u64,
309	    /// The connection it rides.
310	    pub conn_id: u64,
311	    /// The remote node's stable name (hex pubkey).
312	    pub remote_id_hex: String,
313	    /// The next read sequence the broker will assign (= chunks buffered so far).
314	    pub next_seq: u64,
315	    /// Whether the read side finished cleanly.
316	    #[serde(default)]
317	    pub finished: bool,
318	}
319
320	/// `net-streams-reply` payload — every stream the broker currently holds.
321	#[derive(Debug, Clone, Serialize, Deserialize)]
322	pub struct NetStreamsReply {
323	    /// Held streams (opened + peer-initiated), unordered.
324	    #[serde(default)]
325	    pub streams: Vec<NetStreamInfo>,
326	}
327
328	/// `net-stream-subscribe` payload — attach this connection as a stream's read
329	/// subscriber, replaying buffered chunks with `seq >= from_seq` first (D4b).
330	#[derive(Debug, Clone, Serialize, Deserialize)]
331	pub struct NetStreamSubscribeReq {
332	    /// Target stream.
333	    pub stream_id: u64,
334	    /// Lowest read sequence the subscriber still needs (replay floor).
335	    pub from_seq: u64,
336	}
337
338	/// `net-stream-data` event — one sequenced chunk of a stream's reads.
339	#[derive(Debug, Clone, Serialize, Deserialize)]
340	pub struct NetStreamData {
341	    /// Originating stream.
342	    pub stream_id: u64,
343	    /// The broker-assigned, per-stream monotonic sequence (the resume cursor).
344	    pub seq: u64,
345	    /// The chunk bytes, base64.
346	    pub data_b64: String,
347	}
348
349	/// `net-stream-eof` event — a stream's read side finished cleanly.
350	#[derive(Debug, Clone, Serialize, Deserialize)]
351	pub struct NetStreamEof {
352	    /// The finished stream.
353	    pub stream_id: u64,
354	    /// One past the final chunk's seq — the subscriber's completeness check.
355	    pub next_seq: u64,
356	}
357
358	/// Encode raw bytes for an `input`/`output` payload.
359	pub fn encode_bytes(bytes: &[u8]) -> String {
360	    B64.encode(bytes)
361	}
362
363	/// Decode an `input`/`output` payload's base64 back to raw bytes.
364	pub fn decode_bytes(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
365	    B64.decode(s)
366	}
367
368	/// Build an output event envelope for `session_id` / `seq` carrying `bytes`.
369	pub fn output_envelope(session_id: u64, seq: u64, bytes: &[u8]) -> Envelope {
370	    Envelope::new(
371	        KIND_OUTPUT,
372	        serde_json::to_value(OutputEvent { session_id, seq, data_b64: encode_bytes(bytes) })
373	            .expect("OutputEvent serializes"),
374	    )
375	}
376
377	/// Build an `applied` ack envelope for `session_id` / `op_id`; `applied_now`
378	/// distinguishes a fresh apply from a deduped replay.
379	pub fn applied_envelope(session_id: u64, op_id: u64, applied_now: bool) -> Envelope {
380	    Envelope::new(
381	        KIND_APPLIED,
382	        serde_json::to_value(AppliedEvent { session_id, op_id, applied_now })
383	            .expect("AppliedEvent serializes"),
384	    )
385	}
386
387	/// Build a `net-stream-data` event envelope (D4b) — the stream-read sibling of
388	/// [`output_envelope`].
389	pub fn net_stream_data_envelope(stream_id: u64, seq: u64, bytes: &[u8]) -> Envelope {
390	    Envelope::new(
391	        KIND_NET_STREAM_DATA,
392	        serde_json::to_value(NetStreamData { stream_id, seq, data_b64: encode_bytes(bytes) })
393	            .expect("NetStreamData serializes"),
394	    )
395	}
396
397	/// Build a `net-stream-eof` event envelope (D4b).
398	pub fn net_stream_eof_envelope(stream_id: u64, next_seq: u64) -> Envelope {
399	    Envelope::new(
400	        KIND_NET_STREAM_EOF,
401	        serde_json::to_value(NetStreamEof { stream_id, next_seq })
402	            .expect("NetStreamEof serializes"),
403	    )
404	}
405
406	/// Build a `net-dialed` ack envelope (D4a); `applied_now` distinguishes a fresh
407	/// dial from a deduped replay of the same durable op id.
408	pub fn net_dialed_envelope(
409	    conn_id: u64,
410	    remote_id_hex: String,
411	    op_id: Option<u64>,
412	    applied_now: bool,
413	) -> Envelope {
414	    Envelope::new(
415	        KIND_NET_DIALED,
416	        serde_json::to_value(NetDialed { conn_id, remote_id_hex, op_id, applied_now })
417	            .expect("NetDialed serializes"),
418	    )
419	}
420
421	#[cfg(test)]
422	mod tests {
423	    use super::*;
424	    use serde_json::json;
425
426	    // [unit->REQ-DAEMON-2] bytes round-trip through the base64 payload encoding.
427	    #[test]
428	    fn bytes_round_trip_through_payload() {
429	        let raw = b"\x1b[6n binary\x00\xff bytes";
430	        let enc = encode_bytes(raw);
431	        assert_eq!(decode_bytes(&enc).unwrap(), raw);
432	    }
433
434	    // [unit->REQ-DAEMON-2] a spawn request decodes; a newer brain's extra field
435	    // is tolerated (forward-compat carries into the message catalog, not just
436	    // the envelope).
437	    #[test]
438	    fn spawn_req_tolerates_unknown_field() {
439	        let wire = json!({
440	            "program": "cat",
441	            "args": [],
442	            "rows": 24,
443	            "cols": 80,
444	            "env_added_in_a_future_release": { "TERM": "xterm" }
445	        });
446	        let req: SpawnReq = serde_json::from_value(wire).unwrap();
447	        assert_eq!(req.program, "cat");
448	        assert_eq!(req.rows, 24);
449	    }
450
451	    // [unit->REQ-DAEMON-2] a net-dial request decodes with an unknown future field
452	    // (the D4a net vocabulary inherits the catalog's forward-compat), and the
453	    // dialed ack round-trips through its envelope helper.
454	    #[test]
455	    fn net_frames_round_trip_and_tolerate_unknown_fields() {
456	        let wire = json!({
457	            "addr": { "id": "ab", "addrs": [] },
458	            "op_id": 9,
459	            "stream_kind_added_at_d4b": "uni"
460	        });
461	        let req: NetDialReq = serde_json::from_value(wire).unwrap();
462	        assert_eq!(req.op_id, Some(9));
463
464	        let env = net_dialed_envelope(3, "cafe".into(), Some(9), false);
465	        assert_eq!(env.kind, KIND_NET_DIALED);
466	        let ev: NetDialed = serde_json::from_value(env.payload).unwrap();
467	        assert_eq!((ev.conn_id, ev.op_id, ev.applied_now), (3, Some(9), false));
468	        assert_eq!(ev.remote_id_hex, "cafe");
469	    }
470
471	    // [unit->REQ-DAEMON-2] the output-envelope helper produces a decodable event.
472	    #[test]
473	    fn output_envelope_round_trips() {
474	        let env = output_envelope(7, 42, b"hello");
475	        assert_eq!(env.kind, KIND_OUTPUT);
476	        let ev: OutputEvent = serde_json::from_value(env.payload).unwrap();
477	        assert_eq!(ev.session_id, 7);
478	        assert_eq!(ev.seq, 42);
479	        assert_eq!(decode_bytes(&ev.data_b64).unwrap(), b"hello");
480	    }
481	}
482

//! The broker↔brain message catalog: the kind discriminators and payload
//! shapes that ride inside the forward-compatible [`Envelope`].
//!
//! Split out from [`crate::frame`] (which owns the *envelope mechanics* and the
//! version handshake) so the message *vocabulary* can grow additively without
//! touching the wire-compat machinery. Every payload is a plain serde struct
//! with serde's default unknown-field tolerance, so a newer brain may add a
//! field and an older broker still decodes the message (KNOWN-HAZARDS 2.3).
//!
//! Bytes (PTY input/output) ride as base64 strings — JSON has no native byte
//! type, and base64 keeps the single envelope/codec path rather than forking a
//! binary frame format.
// [impl->REQ-DAEMON-2]

use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::{Deserialize, Serialize};

use crate::frame::Envelope;

/// Brain→broker: spawn a child under a fresh broker-owned PTY.
pub const KIND_SPAWN: &str = "spawn";
/// Broker→brain: a spawn succeeded; carries the assigned session id + child pid.
pub const KIND_SPAWNED: &str = "spawned";
/// Brain→broker: (re)subscribe to a session's output from a given sequence.
pub const KIND_SUBSCRIBE: &str = "subscribe";
/// Brain→broker: write raw bytes to a session's input (send-keys).
pub const KIND_INPUT: &str = "input";
/// Brain→broker: resize a session's PTY.
pub const KIND_RESIZE: &str = "resize";
/// Brain→broker: terminate a session's child.
pub const KIND_KILL: &str = "kill";
/// Broker→brain: a chunk of a session's output (pushed as the child produces it).
pub const KIND_OUTPUT: &str = "output";
/// Broker→brain: a session's child exited.
pub const KIND_EXIT: &str = "exit";
/// Broker→brain: an idempotent effect (an `input` carrying an `op_id`) is durably
/// applied **or** was already applied (deduped). The exactly-once ack: a brain
/// learns its effect landed, and a replay after a crash is acknowledged the same
/// way so the brain can retire it (B5 / KNOWN-HAZARDS 7.2).
pub const KIND_APPLIED: &str = "applied";
/// Broker→brain: a command failed (carries an operator-facing message).
pub const KIND_ERROR: &str = "error";
/// Brain→broker: report the broker-owned net endpoint's status (D4a). The brain
/// holds no endpoint of its own — the broker owns the node's network presence
/// (ADR-0004 §B), so status (node id, dialable address, conn count) is an IPC ask.
pub const KIND_NET_STATUS: &str = "net-status";
/// Broker→brain: the net endpoint status reply.
pub const KIND_NET_STATUS_REPLY: &str = "net-status-reply";
/// Brain→broker: dial a peer over the broker-owned endpoint (D4a). With an
/// `op_id` the dial is exactly-once (journaled like an idempotent `input`).
pub const KIND_NET_DIAL: &str = "net-dial";
/// Broker→brain: a dial durably landed — fresh or deduped (`applied_now`
/// distinguishes, mirroring [`KIND_APPLIED`]). Carries the broker-minted conn id.
pub const KIND_NET_DIALED: &str = "net-dialed";
/// Brain→broker: open a bidi QUIC stream on a held connection (D4b). With an
/// `op_id` the open is exactly-once (journaled like a dial).
pub const KIND_NET_STREAM_OPEN: &str = "net-stream-open";
/// Broker→brain: a stream open durably landed (fresh or deduped).
pub const KIND_NET_STREAM_OPENED: &str = "net-stream-opened";
/// Brain→broker: write bytes to a held stream's send side (D4b). With an
/// `op_id` the write is exactly-once (journaled; acked with [`KIND_NET_SENT`]).
pub const KIND_NET_STREAM_SEND: &str = "net-stream-send";
/// Broker→brain: a journaled stream send durably landed (fresh or deduped).
pub const KIND_NET_SENT: &str = "net-sent";
/// Brain→broker: list the streams the broker currently holds (D4b). The query
/// model a re-attaching brain uses to find streams that arrived while it was
/// down — no announce buffer to miss.
pub const KIND_NET_STREAMS: &str = "net-streams";
/// Broker→brain: the held-streams reply.
pub const KIND_NET_STREAMS_REPLY: &str = "net-streams-reply";
/// Brain→broker: (re)subscribe to a stream's buffered reads from a cursor —
/// the PTY `subscribe` discipline applied to a QUIC stream (gapless resume).
pub const KIND_NET_STREAM_SUBSCRIBE: &str = "net-stream-subscribe";
/// Broker→brain: a sequenced chunk of a stream's reads (live or replayed).
pub const KIND_NET_STREAM_DATA: &str = "net-stream-data";
/// Broker→brain: a stream's read side finished cleanly; `next_seq` tells the
/// brain the final cursor so it can verify it missed nothing.
pub const KIND_NET_STREAM_EOF: &str = "net-stream-eof";

/// `spawn` payload — the program + args + initial geometry. The broker spawns
/// it under a PTY (no shell interpretation; argv is passed through).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpawnReq {
    /// The program to exec (resolved on `PATH` by the OS spawn).
    pub program: String,
    /// Its arguments, verbatim.
    #[serde(default)]
    pub args: Vec<String>,
    /// Initial PTY rows.
    pub rows: u16,
    /// Initial PTY cols.
    pub cols: u16,
}

/// `spawned` payload — the broker-minted session id + the child's OS pid.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Spawned {
    /// The id the brain uses to address this session in later commands.
    pub session_id: u64,
    /// The child's OS pid, if the backend exposes one.
    #[serde(default)]
    pub pid: Option<u32>,
}

/// `subscribe` payload — attach this connection as a session's output
/// subscriber, replaying buffered chunks with `seq >= from_seq` before
/// streaming live ones. A reconnecting (handed-off) brain passes its
/// last-seen `seq + 1` here for gapless, exactly-once resume (2.3 / Spike #1).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscribeReq {
    /// Target session.
    pub session_id: u64,
    /// Lowest output sequence the subscriber still needs (replay floor).
    pub from_seq: u64,
}

/// `input` payload — bytes to inject into a session's stdin (base64).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputReq {
    /// Target session.
    pub session_id: u64,
    /// The raw input bytes, base64-encoded.
    pub data_b64: String,
    /// The durable op id for exactly-once delivery (B5). `Some(id)` routes the
    /// write through the broker's [`crate::effect::EffectJournal`]: a replay of
    /// the same `(session_id, id)` after a brain crash is deduped, and the broker
    /// replies [`KIND_APPLIED`]. `None` is the legacy direct write (no dedup) —
    /// kept for callers that don't carry a durable source id. Defaulted so an
    /// older brain's `input` (no `op_id`) still decodes (2.3 forward-compat).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
}

/// `resize` payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResizeReq {
    /// Target session.
    pub session_id: u64,
    /// New rows.
    pub rows: u16,
    /// New cols.
    pub cols: u16,
}

/// `kill` payload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KillReq {
    /// Target session.
    pub session_id: u64,
}

/// `output` event — a chunk of a session's PTY output (base64).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputEvent {
    /// Originating session.
    pub session_id: u64,
    /// The broker-assigned, per-session monotonic sequence of this chunk. The
    /// resume cursor: a reconnecting brain subscribes from its last seen
    /// `seq + 1`, and dedups by ignoring any `seq` it has already processed.
    pub seq: u64,
    /// The raw output bytes, base64-encoded.
    pub data_b64: String,
}

/// `exit` event — a session's child has been reaped.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExitEvent {
    /// Originating session.
    pub session_id: u64,
    /// The child's exit code, if known.
    #[serde(default)]
    pub code: Option<i32>,
}

/// `applied` event — an idempotent effect is durably applied or was already
/// applied. Carries the durable id so the brain can correlate it to the effect
/// it issued (or replayed). The exactly-once ack (B5).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppliedEvent {
    /// The session the effect targeted.
    pub session_id: u64,
    /// The durable op id of the effect.
    pub op_id: u64,
    /// Whether the effect ran on this delivery (`true`) or was deduped because it
    /// had already landed (`false`). Informational — both mean "durably applied".
    #[serde(default)]
    pub applied_now: bool,
}

/// `error` event — a command the broker could not satisfy.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorEvent {
    /// Operator-facing description.
    pub message: String,
}

/// `net-status-reply` — the broker-owned endpoint's status (D4a). All fields
/// after `enabled` are defaulted so a broker bound without a network host (or an
/// older broker) still produces/decodes a coherent reply (2.3 forward-compat).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStatusReply {
    /// Whether this broker hosts a net endpoint at all.
    pub enabled: bool,
    /// The node's stable name (lowercase-hex Ed25519 pubkey == iroh EndpointId).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_id_hex: Option<String>,
    /// The endpoint's dialable address — a serde-serialized iroh `EndpointAddr`,
    /// opaque to this catalog (the net host owns its shape). `null` when disabled.
    #[serde(default)]
    pub addr: serde_json::Value,
    /// Live connections (accepted + dialed) in the broker's conn table.
    #[serde(default)]
    pub conn_count: u64,
}

/// `net-dial` payload — dial a peer over the broker-owned endpoint (D4a).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetDialReq {
    /// The peer's dialable address — a serde-serialized iroh `EndpointAddr`
    /// (opaque JSON here; the broker's net host decodes it).
    pub addr: serde_json::Value,
    /// The durable op id for exactly-once dialing. `Some(id)` routes the dial
    /// through the broker's [`crate::effect::EffectJournal`] (keyed under the
    /// reserved [`crate::nethost::NET_EFFECT_SESSION`] namespace): a replay of
    /// the same id after a brain crash is deduped and answered with the
    /// already-open connection instead of dialing twice. `None` dials directly.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
}

/// `net-dialed` event — a dial durably landed (fresh or deduped).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetDialed {
    /// The broker-minted id addressing this connection in later net commands.
    pub conn_id: u64,
    /// The remote node's stable name (lowercase-hex pubkey), proven by the QUIC
    /// handshake (the EndpointId *is* the key).
    pub remote_id_hex: String,
    /// Echoes the request's durable op id, when one was carried.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
    /// Whether the dial ran on this delivery (`true`) or was deduped (`false`).
    /// Both mean "durably landed" — mirrors [`AppliedEvent::applied_now`].
    #[serde(default)]
    pub applied_now: bool,
}

/// `net-stream-open` payload — open a bidi stream on a held connection (D4b).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamOpenReq {
    /// The connection (from [`NetDialed::conn_id`]) to open the stream on.
    pub conn_id: u64,
    /// Durable op id for an exactly-once open (shares the net op-id namespace
    /// with dials/sends — one durable counter across all net effects).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
}

/// `net-stream-opened` event — a stream open durably landed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamOpened {
    /// The broker-minted id addressing this stream in later commands.
    pub stream_id: u64,
    /// The connection it rides.
    pub conn_id: u64,
    /// Echoes the request's durable op id, when one was carried.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
    /// Fresh open (`true`) vs deduped replay (`false`).
    #[serde(default)]
    pub applied_now: bool,
}

/// `net-stream-send` payload — write bytes to a stream's send side (D4b).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamSendReq {
    /// Target stream.
    pub stream_id: u64,
    /// The bytes, base64.
    pub data_b64: String,
    /// Durable op id for exactly-once delivery; `None` writes directly (no
    /// dedup, no ack) — mirrors [`InputReq::op_id`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub op_id: Option<u64>,
    /// Finish the send side after this write (may carry empty bytes): the
    /// peer's read pump sees a clean end and emits [`KIND_NET_STREAM_EOF`].
    #[serde(default)]
    pub finish: bool,
}

/// `net-sent` event — a journaled stream send durably landed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetSent {
    /// The stream the write targeted.
    pub stream_id: u64,
    /// The durable op id of the write.
    pub op_id: u64,
    /// Fresh apply (`true`) vs deduped replay (`false`).
    #[serde(default)]
    pub applied_now: bool,
}

/// One held stream in a `net-streams-reply` (D4b).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamInfo {
    /// The broker-minted stream id.
    pub stream_id: u64,
    /// The connection it rides.
    pub conn_id: u64,
    /// The remote node's stable name (hex pubkey).
    pub remote_id_hex: String,
    /// The next read sequence the broker will assign (= chunks buffered so far).
    pub next_seq: u64,
    /// Whether the read side finished cleanly.
    #[serde(default)]
    pub finished: bool,
}

/// `net-streams-reply` payload — every stream the broker currently holds.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamsReply {
    /// Held streams (opened + peer-initiated), unordered.
    #[serde(default)]
    pub streams: Vec<NetStreamInfo>,
}

/// `net-stream-subscribe` payload — attach this connection as a stream's read
/// subscriber, replaying buffered chunks with `seq >= from_seq` first (D4b).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamSubscribeReq {
    /// Target stream.
    pub stream_id: u64,
    /// Lowest read sequence the subscriber still needs (replay floor).
    pub from_seq: u64,
}

/// `net-stream-data` event — one sequenced chunk of a stream's reads.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamData {
    /// Originating stream.
    pub stream_id: u64,
    /// The broker-assigned, per-stream monotonic sequence (the resume cursor).
    pub seq: u64,
    /// The chunk bytes, base64.
    pub data_b64: String,
}

/// `net-stream-eof` event — a stream's read side finished cleanly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetStreamEof {
    /// The finished stream.
    pub stream_id: u64,
    /// One past the final chunk's seq — the subscriber's completeness check.
    pub next_seq: u64,
}

/// Encode raw bytes for an `input`/`output` payload.
pub fn encode_bytes(bytes: &[u8]) -> String {
    B64.encode(bytes)
}

/// Decode an `input`/`output` payload's base64 back to raw bytes.
pub fn decode_bytes(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
    B64.decode(s)
}

/// Build an output event envelope for `session_id` / `seq` carrying `bytes`.
pub fn output_envelope(session_id: u64, seq: u64, bytes: &[u8]) -> Envelope {
    Envelope::new(
        KIND_OUTPUT,
        serde_json::to_value(OutputEvent { session_id, seq, data_b64: encode_bytes(bytes) })
            .expect("OutputEvent serializes"),
    )
}

/// Build an `applied` ack envelope for `session_id` / `op_id`; `applied_now`
/// distinguishes a fresh apply from a deduped replay.
pub fn applied_envelope(session_id: u64, op_id: u64, applied_now: bool) -> Envelope {
    Envelope::new(
        KIND_APPLIED,
        serde_json::to_value(AppliedEvent { session_id, op_id, applied_now })
            .expect("AppliedEvent serializes"),
    )
}

/// Build a `net-stream-data` event envelope (D4b) — the stream-read sibling of
/// [`output_envelope`].
pub fn net_stream_data_envelope(stream_id: u64, seq: u64, bytes: &[u8]) -> Envelope {
    Envelope::new(
        KIND_NET_STREAM_DATA,
        serde_json::to_value(NetStreamData { stream_id, seq, data_b64: encode_bytes(bytes) })
            .expect("NetStreamData serializes"),
    )
}

/// Build a `net-stream-eof` event envelope (D4b).
pub fn net_stream_eof_envelope(stream_id: u64, next_seq: u64) -> Envelope {
    Envelope::new(
        KIND_NET_STREAM_EOF,
        serde_json::to_value(NetStreamEof { stream_id, next_seq })
            .expect("NetStreamEof serializes"),
    )
}

/// Build a `net-dialed` ack envelope (D4a); `applied_now` distinguishes a fresh
/// dial from a deduped replay of the same durable op id.
pub fn net_dialed_envelope(
    conn_id: u64,
    remote_id_hex: String,
    op_id: Option<u64>,
    applied_now: bool,
) -> Envelope {
    Envelope::new(
        KIND_NET_DIALED,
        serde_json::to_value(NetDialed { conn_id, remote_id_hex, op_id, applied_now })
            .expect("NetDialed serializes"),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    // [unit->REQ-DAEMON-2] bytes round-trip through the base64 payload encoding.
    #[test]
    fn bytes_round_trip_through_payload() {
        let raw = b"\x1b[6n binary\x00\xff bytes";
        let enc = encode_bytes(raw);
        assert_eq!(decode_bytes(&enc).unwrap(), raw);
    }

    // [unit->REQ-DAEMON-2] a spawn request decodes; a newer brain's extra field
    // is tolerated (forward-compat carries into the message catalog, not just
    // the envelope).
    #[test]
    fn spawn_req_tolerates_unknown_field() {
        let wire = json!({
            "program": "cat",
            "args": [],
            "rows": 24,
            "cols": 80,
            "env_added_in_a_future_release": { "TERM": "xterm" }
        });
        let req: SpawnReq = serde_json::from_value(wire).unwrap();
        assert_eq!(req.program, "cat");
        assert_eq!(req.rows, 24);
    }

    // [unit->REQ-DAEMON-2] a net-dial request decodes with an unknown future field
    // (the D4a net vocabulary inherits the catalog's forward-compat), and the
    // dialed ack round-trips through its envelope helper.
    #[test]
    fn net_frames_round_trip_and_tolerate_unknown_fields() {
        let wire = json!({
            "addr": { "id": "ab", "addrs": [] },
            "op_id": 9,
            "stream_kind_added_at_d4b": "uni"
        });
        let req: NetDialReq = serde_json::from_value(wire).unwrap();
        assert_eq!(req.op_id, Some(9));

        let env = net_dialed_envelope(3, "cafe".into(), Some(9), false);
        assert_eq!(env.kind, KIND_NET_DIALED);
        let ev: NetDialed = serde_json::from_value(env.payload).unwrap();
        assert_eq!((ev.conn_id, ev.op_id, ev.applied_now), (3, Some(9), false));
        assert_eq!(ev.remote_id_hex, "cafe");
    }

    // [unit->REQ-DAEMON-2] the output-envelope helper produces a decodable event.
    #[test]
    fn output_envelope_round_trips() {
        let env = output_envelope(7, 42, b"hello");
        assert_eq!(env.kind, KIND_OUTPUT);
        let ev: OutputEvent = serde_json::from_value(env.payload).unwrap();
        assert_eq!(ev.session_id, 7);
        assert_eq!(ev.seq, 42);
        assert_eq!(decode_bytes(&ev.data_b64).unwrap(), b"hello");
    }
}