1	//! The versioned broker↔brain IPC frame schema and connection handshake.
2	//!
3	//! This is the single most update-frequency-sensitive contract in spt-core
4	//! (KNOWN-HAZARDS 2.3): self-update (ADR-0004) swaps the brain routinely while
5	//! the broker persists, so **a newer brain must talk to an older broker**. Two
6	//! mechanisms make that hold by construction:
7	//!
8	//! 1. **Forward-compatible envelopes.** Every frame is an [`Envelope`] with an
9	//!    explicit `protocol_version`, a *string* `kind` discriminator, and an
10	//!    opaque `payload` ([`serde_json::Value`]). A string kind means an older
11	//!    broker decoding a frame whose `kind` it does not recognize gets a clean
12	//!    `Envelope` — an unknown *kind* is a logic-layer concern, never a decode
13	//!    error. serde's default (no `deny_unknown_fields`) means unknown *trailing
14	//!    fields* a newer brain adds are silently ignored on the older side.
15	//! 2. **A version handshake.** On connect, the brain sends a [`Hello`]; the
16	//!    broker accepts iff the brain's `protocol_version >= MIN_COMPATIBLE_VERSION`.
17	//!    A newer brain (higher version) is accepted by an older broker; a brain
18	//!    too old to satisfy the broker's floor is rejected with a typed error
19	//!    rather than a silent misparse.
20	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
21
22	use serde::{Deserialize, Serialize};
23
24	/// The IPC protocol version this build speaks. Bump **only** on a breaking
25	/// frame-schema change (a field whose absence an older peer cannot tolerate).
26	/// Additive changes — new `kind`s, new optional fields — do **not** bump this.
27	pub const IPC_PROTOCOL_VERSION: u32 = 1;
28
29	/// The oldest peer `protocol_version` this build will still handshake with.
30	/// Held at `1` for v1; raised only when an old version is genuinely retired.
31	pub const MIN_COMPATIBLE_VERSION: u32 = 1;
32
33	/// Which side of the IPC a connecting peer is. The broker is the stable kernel;
34	/// the brain is the restartable logic layer (ADR-0004).
35	#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36	#[serde(rename_all = "snake_case")]
37	pub enum Role {
38	    /// The stable kernel that holds the un-transferable resources.
39	    Broker,
40	    /// The restartable logic layer that attaches over the IPC.
41	    Brain,
42	}
43
44	/// The first frame a connecting peer sends. Intentionally minimal and
45	/// append-only: future fields must be added with `#[serde(default)]` so an
46	/// older peer decoding a newer `Hello` still succeeds (2.3).
47	#[derive(Debug, Clone, Serialize, Deserialize)]
48	pub struct Hello {
49	    /// The protocol version the sender speaks ([`IPC_PROTOCOL_VERSION`]).
50	    pub protocol_version: u32,
51	    /// Which side the sender is.
52	    pub role: Role,
53	}
54
55	impl Hello {
56	    /// A `Hello` for this build, declaring [`IPC_PROTOCOL_VERSION`].
57	    pub fn current(role: Role) -> Self {
58	        Hello { protocol_version: IPC_PROTOCOL_VERSION, role }
59	    }
60	}
61
62	/// Why a handshake was refused — surfaced typed rather than as a silent
63	/// misparse so an incompatible peer fails loudly (the 2.3 failure mode is a
64	/// peer dying *unlogged* on an arity/version mismatch).
65	#[derive(Debug, Clone, PartialEq, Eq)]
66	pub enum HandshakeError {
67	    /// The peer's `protocol_version` is below this build's compatibility floor.
68	    IncompatibleVersion { peer: u32, min_supported: u32 },
69	    /// The peer announced a role we did not expect on this connection.
70	    UnexpectedRole { expected: Role, got: Role },
71	}
72
73	impl std::fmt::Display for HandshakeError {
74	    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75	        match self {
76	            HandshakeError::IncompatibleVersion { peer, min_supported } => write!(
77	                f,
78	                "peer protocol version {peer} below minimum supported {min_supported}"
79	            ),
80	            HandshakeError::UnexpectedRole { expected, got } => {
81	                write!(f, "expected peer role {expected:?}, got {got:?}")
82	            }
83	        }
84	    }
85	}
86
87	impl std::error::Error for HandshakeError {}
88
89	/// Decide whether to accept a peer's [`Hello`] on a connection expecting
90	/// `expected_peer`. Accepts any `protocol_version >= MIN_COMPATIBLE_VERSION`
91	/// (a *newer* brain is fine against an *older* broker) and rejects below the
92	/// floor or on a role mismatch. This is the whole of the version-tolerance
93	/// policy — kept in one place so the rule is auditable.
94	// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
95	pub fn accept_hello(hello: &Hello, expected_peer: Role) -> Result<(), HandshakeError> {
96	    if hello.protocol_version < MIN_COMPATIBLE_VERSION {
97	        return Err(HandshakeError::IncompatibleVersion {
98	            peer: hello.protocol_version,
99	            min_supported: MIN_COMPATIBLE_VERSION,
100	        });
101	    }
102	    if hello.role != expected_peer {
103	        return Err(HandshakeError::UnexpectedRole { expected: expected_peer, got: hello.role });
104	    }
105	    Ok(())
106	}
107
108	/// One IPC message. The forward-compatible envelope: `protocol_version` lets a
109	/// reader scope its interpretation, `kind` is a *string* so an unknown message
110	/// type decodes cleanly (the logic layer decides what to do with an unknown
111	/// kind), and `payload` is opaque JSON so each kind owns its own shape without
112	/// the envelope needing to know it. Unknown trailing fields are ignored on
113	/// decode (serde default), so a newer brain may extend a frame and an older
114	/// broker still parses it.
115	#[derive(Debug, Clone, Serialize, Deserialize)]
116	pub struct Envelope {
117	    /// The protocol version of the sender (for scoped interpretation).
118	    pub protocol_version: u32,
119	    /// The message-kind discriminator. A string, not an enum, so an unrecognized
120	    /// kind is never a decode failure on an older peer.
121	    pub kind: String,
122	    /// The kind-specific body. Absent bodies default to JSON `null`.
123	    #[serde(default)]
124	    pub payload: serde_json::Value,
125	}
126
127	impl Envelope {
128	    /// Build an envelope for this build's protocol version with a JSON payload.
129	    pub fn new(kind: impl Into<String>, payload: serde_json::Value) -> Self {
130	        Envelope { protocol_version: IPC_PROTOCOL_VERSION, kind: kind.into(), payload }
131	    }
132	}
133
134	#[cfg(test)]
135	mod tests {
136	    use super::*;
137	    use serde_json::json;
138
139	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
140	    // The load-bearing forward-compat property: a frame a *newer* brain emits —
141	    // carrying an unknown trailing field — must decode cleanly on an *older*
142	    // broker that has never heard of that field. We simulate the newer wire form
143	    // with raw JSON (a superset of today's Envelope) and decode into the current
144	    // struct; the unknown field is dropped, not rejected.
145	    #[test]
146	    fn envelope_with_unknown_trailing_field_decodes_on_older_peer() {
147	        let newer_wire = json!({
148	            "protocol_version": 2,
149	            "kind": "attach",
150	            "payload": { "session": "abc" },
151	            "future_field_an_older_broker_never_heard_of": { "nested": [1, 2, 3] }
152	        })
153	        .to_string();
154
155	        let decoded: Envelope =
156	            serde_json::from_str(&newer_wire).expect("older peer must tolerate unknown fields");
157	        assert_eq!(decoded.protocol_version, 2);
158	        assert_eq!(decoded.kind, "attach");
159	        assert_eq!(decoded.payload, json!({ "session": "abc" }));
160	    }
161
162	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
163	    // An unknown *kind* is not a decode error — the envelope parses and the
164	    // logic layer is what decides it cannot handle the kind.
165	    #[test]
166	    fn unknown_kind_is_not_a_decode_error() {
167	        let wire = json!({ "protocol_version": 1, "kind": "a_kind_from_the_future" }).to_string();
168	        let decoded: Envelope = serde_json::from_str(&wire).unwrap();
169	        assert_eq!(decoded.kind, "a_kind_from_the_future");
170	        assert!(decoded.payload.is_null()); // payload defaulted
171	    }
172
173	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
174	    // A newer brain (higher protocol version, extra Hello fields) is accepted by
175	    // an older broker — the exact "newer brain ↔ older broker" direction 2.3
176	    // demands.
177	    #[test]
178	    fn handshake_accepts_newer_brain_on_older_broker() {
179	        let newer_hello_wire = json!({
180	            "protocol_version": IPC_PROTOCOL_VERSION + 5,
181	            "role": "brain",
182	            "added_in_a_future_release": true
183	        })
184	        .to_string();
185	        let hello: Hello = serde_json::from_str(&newer_hello_wire).unwrap();
186	        assert_eq!(accept_hello(&hello, Role::Brain), Ok(()));
187	    }
188
189	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
190	    // A peer below the compatibility floor is rejected loudly, not misparsed.
191	    #[test]
192	    fn handshake_rejects_below_floor() {
193	        let hello = Hello { protocol_version: MIN_COMPATIBLE_VERSION - 1, role: Role::Brain };
194	        assert!(matches!(
195	            accept_hello(&hello, Role::Brain),
196	            Err(HandshakeError::IncompatibleVersion { .. })
197	        ));
198	    }
199
200	    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] role mismatch is a typed refusal.
201	    #[test]
202	    fn handshake_rejects_role_mismatch() {
203	        let hello = Hello::current(Role::Broker);
204	        assert!(matches!(
205	            accept_hello(&hello, Role::Brain),
206	            Err(HandshakeError::UnexpectedRole { .. })
207	        ));
208	    }
209	}
210

//! The versioned broker↔brain IPC frame schema and connection handshake.
//!
//! This is the single most update-frequency-sensitive contract in spt-core
//! (KNOWN-HAZARDS 2.3): self-update (ADR-0004) swaps the brain routinely while
//! the broker persists, so **a newer brain must talk to an older broker**. Two
//! mechanisms make that hold by construction:
//!
//! 1. **Forward-compatible envelopes.** Every frame is an [`Envelope`] with an
//!    explicit `protocol_version`, a *string* `kind` discriminator, and an
//!    opaque `payload` ([`serde_json::Value`]). A string kind means an older
//!    broker decoding a frame whose `kind` it does not recognize gets a clean
//!    `Envelope` — an unknown *kind* is a logic-layer concern, never a decode
//!    error. serde's default (no `deny_unknown_fields`) means unknown *trailing
//!    fields* a newer brain adds are silently ignored on the older side.
//! 2. **A version handshake.** On connect, the brain sends a [`Hello`]; the
//!    broker accepts iff the brain's `protocol_version >= MIN_COMPATIBLE_VERSION`.
//!    A newer brain (higher version) is accepted by an older broker; a brain
//!    too old to satisfy the broker's floor is rejected with a typed error
//!    rather than a silent misparse.
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]

use serde::{Deserialize, Serialize};

/// The IPC protocol version this build speaks. Bump **only** on a breaking
/// frame-schema change (a field whose absence an older peer cannot tolerate).
/// Additive changes — new `kind`s, new optional fields — do **not** bump this.
pub const IPC_PROTOCOL_VERSION: u32 = 1;

/// The oldest peer `protocol_version` this build will still handshake with.
/// Held at `1` for v1; raised only when an old version is genuinely retired.
pub const MIN_COMPATIBLE_VERSION: u32 = 1;

/// Which side of the IPC a connecting peer is. The broker is the stable kernel;
/// the brain is the restartable logic layer (ADR-0004).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
    /// The stable kernel that holds the un-transferable resources.
    Broker,
    /// The restartable logic layer that attaches over the IPC.
    Brain,
}

/// The first frame a connecting peer sends. Intentionally minimal and
/// append-only: future fields must be added with `#[serde(default)]` so an
/// older peer decoding a newer `Hello` still succeeds (2.3).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
    /// The protocol version the sender speaks ([`IPC_PROTOCOL_VERSION`]).
    pub protocol_version: u32,
    /// Which side the sender is.
    pub role: Role,
}

impl Hello {
    /// A `Hello` for this build, declaring [`IPC_PROTOCOL_VERSION`].
    pub fn current(role: Role) -> Self {
        Hello { protocol_version: IPC_PROTOCOL_VERSION, role }
    }
}

/// Why a handshake was refused — surfaced typed rather than as a silent
/// misparse so an incompatible peer fails loudly (the 2.3 failure mode is a
/// peer dying *unlogged* on an arity/version mismatch).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HandshakeError {
    /// The peer's `protocol_version` is below this build's compatibility floor.
    IncompatibleVersion { peer: u32, min_supported: u32 },
    /// The peer announced a role we did not expect on this connection.
    UnexpectedRole { expected: Role, got: Role },
}

impl std::fmt::Display for HandshakeError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HandshakeError::IncompatibleVersion { peer, min_supported } => write!(
                f,
                "peer protocol version {peer} below minimum supported {min_supported}"
            ),
            HandshakeError::UnexpectedRole { expected, got } => {
                write!(f, "expected peer role {expected:?}, got {got:?}")
            }
        }
    }
}

impl std::error::Error for HandshakeError {}

/// Decide whether to accept a peer's [`Hello`] on a connection expecting
/// `expected_peer`. Accepts any `protocol_version >= MIN_COMPATIBLE_VERSION`
/// (a *newer* brain is fine against an *older* broker) and rejects below the
/// floor or on a role mismatch. This is the whole of the version-tolerance
/// policy — kept in one place so the rule is auditable.
// [impl->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
pub fn accept_hello(hello: &Hello, expected_peer: Role) -> Result<(), HandshakeError> {
    if hello.protocol_version < MIN_COMPATIBLE_VERSION {
        return Err(HandshakeError::IncompatibleVersion {
            peer: hello.protocol_version,
            min_supported: MIN_COMPATIBLE_VERSION,
        });
    }
    if hello.role != expected_peer {
        return Err(HandshakeError::UnexpectedRole { expected: expected_peer, got: hello.role });
    }
    Ok(())
}

/// One IPC message. The forward-compatible envelope: `protocol_version` lets a
/// reader scope its interpretation, `kind` is a *string* so an unknown message
/// type decodes cleanly (the logic layer decides what to do with an unknown
/// kind), and `payload` is opaque JSON so each kind owns its own shape without
/// the envelope needing to know it. Unknown trailing fields are ignored on
/// decode (serde default), so a newer brain may extend a frame and an older
/// broker still parses it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope {
    /// The protocol version of the sender (for scoped interpretation).
    pub protocol_version: u32,
    /// The message-kind discriminator. A string, not an enum, so an unrecognized
    /// kind is never a decode failure on an older peer.
    pub kind: String,
    /// The kind-specific body. Absent bodies default to JSON `null`.
    #[serde(default)]
    pub payload: serde_json::Value,
}

impl Envelope {
    /// Build an envelope for this build's protocol version with a JSON payload.
    pub fn new(kind: impl Into<String>, payload: serde_json::Value) -> Self {
        Envelope { protocol_version: IPC_PROTOCOL_VERSION, kind: kind.into(), payload }
    }
}

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

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // The load-bearing forward-compat property: a frame a *newer* brain emits —
    // carrying an unknown trailing field — must decode cleanly on an *older*
    // broker that has never heard of that field. We simulate the newer wire form
    // with raw JSON (a superset of today's Envelope) and decode into the current
    // struct; the unknown field is dropped, not rejected.
    #[test]
    fn envelope_with_unknown_trailing_field_decodes_on_older_peer() {
        let newer_wire = json!({
            "protocol_version": 2,
            "kind": "attach",
            "payload": { "session": "abc" },
            "future_field_an_older_broker_never_heard_of": { "nested": [1, 2, 3] }
        })
        .to_string();

        let decoded: Envelope =
            serde_json::from_str(&newer_wire).expect("older peer must tolerate unknown fields");
        assert_eq!(decoded.protocol_version, 2);
        assert_eq!(decoded.kind, "attach");
        assert_eq!(decoded.payload, json!({ "session": "abc" }));
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // An unknown *kind* is not a decode error — the envelope parses and the
    // logic layer is what decides it cannot handle the kind.
    #[test]
    fn unknown_kind_is_not_a_decode_error() {
        let wire = json!({ "protocol_version": 1, "kind": "a_kind_from_the_future" }).to_string();
        let decoded: Envelope = serde_json::from_str(&wire).unwrap();
        assert_eq!(decoded.kind, "a_kind_from_the_future");
        assert!(decoded.payload.is_null()); // payload defaulted
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // A newer brain (higher protocol version, extra Hello fields) is accepted by
    // an older broker — the exact "newer brain ↔ older broker" direction 2.3
    // demands.
    #[test]
    fn handshake_accepts_newer_brain_on_older_broker() {
        let newer_hello_wire = json!({
            "protocol_version": IPC_PROTOCOL_VERSION + 5,
            "role": "brain",
            "added_in_a_future_release": true
        })
        .to_string();
        let hello: Hello = serde_json::from_str(&newer_hello_wire).unwrap();
        assert_eq!(accept_hello(&hello, Role::Brain), Ok(()));
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT]
    // A peer below the compatibility floor is rejected loudly, not misparsed.
    #[test]
    fn handshake_rejects_below_floor() {
        let hello = Hello { protocol_version: MIN_COMPATIBLE_VERSION - 1, role: Role::Brain };
        assert!(matches!(
            accept_hello(&hello, Role::Brain),
            Err(HandshakeError::IncompatibleVersion { .. })
        ));
    }

    // [unit->REQ-HAZARD-HANDOFF-ARGV-COMPAT] role mismatch is a typed refusal.
    #[test]
    fn handshake_rejects_role_mismatch() {
        let hello = Hello::current(Role::Broker);
        assert!(matches!(
            accept_hello(&hello, Role::Brain),
            Err(HandshakeError::UnexpectedRole { .. })
        ));
    }
}


Bash

ls crates && grep -rn "serve_attach" crates --include=*.rs | grep -v "attach.rs:" | head

List crates; find serve_attach call sites


spt
spt-daemon
spt-live
spt-msg
spt-net
spt-proto
spt-runtime
spt-store
spt-term
crates/spt-daemon/src/lib.rs:134:pub use attach::{request_attach, send_attach_input, serve_attach, AttachServeOutcome};


ls crates/spt/src && grep -rn "driven\|file-transfer\|file transfer" CONTEXT.md | head -20

List spt binary sources; find CONTEXT.md sections


api
cli.rs
elevation.rs
main.rs
roster.rs
testutil.rs
205:**Shell** (first-class concept — model locked, concrete types deferred past v1): a *driven surface*, not an agent. Nothing intelligent runs at a Shell; a remote agent (on another node) drives it. A Shell is a "self-documenting" endpoint that advertises a **typed capability toolset** and a 2-way interaction relationship with the user. It may live behind a node on a platform with zero conventional-harness compatibility (e.g. a gaming handheld). Examples (all deferred): `GameRobot` (a 2D-world avatar — move, gesture, alert-symbol, request-screenshot, preload-message), an OS-notification target, an in-session-inject target. The earlier "presence blueprint" idea collapses into this: a blueprint *is* a Shell type + its capabilities.
275:The same `spt shutdown` / `api owner-shutdown` / avatar-wake machinery composes into a full sleep/wake cycle driven from either end.
373:At most one instance of an endpoint is typically the *actively-driven* one; the others rest. Two resting states:
385:**Default policy:** warm (dormant) is the default when undriven. **Auto-suspend is opt-in, default OFF globally**, but **node-overridable** (a handheld / Pi / resource-constrained node may default it ON) and **per-endpoint overridable** (e.g. a billable harness whose warm session holds a cost footprint). Thresholds (`auto-suspend-after`) are config knobs: global default → node override → endpoint override.
400:- **Remote-control (Shell-like):** attach a control/view surface to an instance *running on another node* — compute + files stay remote; you are a viewport (the byte-stream terminal attach, daemon-to-daemon over Iroh). Used when you specifically want *that machine's* environment. This is effectively a Shell (a driven surface, user→agent direction), separate from the instance concept itself.
437:- **detect** that it is being driven remotely (and from which node) — observable from the `spt-daemon`, which knows whether the driving session-surface stream is local or network-attached; (v1)
443:- **In v1:** the endpoint-ID / instance data-model split; the subnet registry; resolution policy; remote-drive of *already-running* instances (network session-surface); cross-node Psyche sync (full — replaces gh-repo-sync); cross-instance context-freshness auto-feed; off-node file transfer; off-node remote-drive detection.
484:Every node in a subnet holds that subnet's seed, so the user can fetch the *current* code for **any subnet the node belongs to** from any node in it — no phone required if a trusted node is handy. Because a node may be in several subnets (*subnet membership*), the fetch is **per-subnet**: CLI prompts *"Show TOTP for which subnet? [subnet-1 … subnet-n | create new subnet]"*, with **`--subnet <name>` / `--create-new`** flags to bypass the prompt (the agent-driven path). The code is offered optionally as a **QR / `otpauth://` URI** so the seed can be stored directly in an authenticator app (Google Authenticator etc.).
527:**file-transfer progress**:
528:Every file transfer over the substrate is addressable and **progress-queryable mid-flight** by both the agent and the SPT binary (for the GUI) — at any point during the transfer. A substrate-wide requirement, not shell-specific (the Shell text+file channel inherits it).


NOTE FROM THE USER: proceed with next steps.


1	//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
2	//!
3	//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
4	//! would lose continuity on if the logic restarted: the PTY master+writer and
5	//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
6	//! client / listening sockets. It runs **no logic** — no routing, registry,
7	//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
8	//! forward its output to the brain, inject the brain's input, report exit.
9	//!
10	//! ## Gapless handoff (B2 — the load-bearing invariant)
11	//!
12	//! A brain may be killed and restarted at any moment (a routine self-update, a
13	//! crash). The broker — and every hosted child — must survive that untouched,
14	//! and the new brain must re-attach to the output stream **gaplessly and
15	//! exactly-once** (Spike #1 made real). Two mechanisms:
16	//!
17	//! - **Sequenced, buffered output.** Each session's output is appended to an
18	//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
19	//!   in a bounded ring. The current subscriber (a brain connection) receives
20	//!   chunks live; all subscriber writes happen *under the log lock*, so frames
21	//!   reach the brain in strict `seq` order with no interleave.
22	//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
23	//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
24	//!   live. The brain dedups by `seq`. Output produced *while no brain was
25	//!   attached* sits in the ring and is replayed on the next subscribe — so a
26	//!   brain restart loses nothing.
27	//!
28	//! Sessions are therefore **broker-owned and outlive any one connection**: a
29	//! brain disconnect only detaches the subscriber; the child, its PTY, and the
30	//! output log persist. A session leaves the table only when its child exits
31	//! (reaped by the exit waiter) or is explicitly killed.
32	// [impl->REQ-DAEMON-2]
33
34	use std::collections::{HashMap, VecDeque};
35	use std::io;
36	use std::sync::atomic::{AtomicU64, Ordering};
37	use std::sync::{Arc, Mutex};
38	use std::thread::{self, JoinHandle};
39
40	use interprocess::local_socket::{prelude::*, SendHalf, Stream};
41	use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};
42
43	use spt_net::net::endpoint::EndpointAddr;
44
45	use crate::codec::{read_frame, write_frame};
46	use crate::effect::{EffectJournal, EffectKind, Outcome};
47	use crate::frame::{Envelope, Role};
48	use crate::msg::{
49	    applied_envelope, decode_bytes, net_dialed_envelope, output_envelope, ErrorEvent, ExitEvent,
50	    InputReq, KillReq, NetDialReq, NetPresenceSubscribeReq, NetSent, NetStatusReply,
51	    NetStreamOpenReq, NetStreamOpened, NetStreamSendReq, NetStreamSubscribeReq, NetStreamsReply,
52	    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL,
53	    KIND_NET_DIAL, KIND_NET_PRESENCE_SUBSCRIBE, KIND_NET_SENT, KIND_NET_STATUS,
54	    KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY, KIND_NET_STREAM_OPEN,
55	    KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE, KIND_RESIZE,
56	    KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
57	};
58	use crate::nethost::{NetHost, NET_EFFECT_SESSION};
59	use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};
60
61	/// The shared, serialized send side of one brain connection. Output frames,
62	/// replay frames, command acks, and exit/error events all write through this.
63	/// `pub(crate)`: the net host's stream logs (D4b) live-send through the same
64	/// handle under the same lock discipline.
65	pub(crate) type SharedSend = Arc<Mutex<SendHalf>>;
66
67	/// Default bounded output-log depth (chunks). Large enough that a brain restart
68	/// window never evicts unreplayed output in practice; the bound is what keeps a
69	/// disconnected session from buffering without limit.
70	const DEFAULT_LOG_CHUNKS: usize = 4096;
71
72	/// A bounded, sequenced output log for one session — the gapless-resume buffer.
73	///
74	/// The session's single drain thread is the only producer ([`OutputLog::append`]);
75	/// a brain connection attaches as the subscriber ([`OutputLog::attach`]). Every
76	/// send to the subscriber happens while the enclosing `Mutex<OutputLog>` is
77	/// held, so the live stream and a replay can never interleave out of `seq`
78	/// order.
79	struct OutputLog {
80	    session_id: u64,
81	    next_seq: u64,
82	    ring: VecDeque<(u64, Vec<u8>)>,
83	    cap_chunks: usize,
84	    subscriber: Option<SharedSend>,
85	}
86
87	impl OutputLog {
88	    fn new(session_id: u64, cap_chunks: usize) -> Self {
89	        OutputLog {
90	            session_id,
91	            next_seq: 0,
92	            ring: VecDeque::new(),
93	            cap_chunks,
94	            subscriber: None,
95	        }
96	    }
97
98	    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
99	    /// in the bounded ring, and live-send it to the current subscriber (if any).
100	    /// Called only from the session's drain thread.
101	    fn append(&mut self, bytes: &[u8]) {
102	        let seq = self.next_seq;
103	        self.next_seq += 1;
104	        self.ring.push_back((seq, bytes.to_vec()));
105	        while self.ring.len() > self.cap_chunks {
106	            self.ring.pop_front();
107	        }
108	        if let Some(sub) = &self.subscriber {
109	            let frame = output_envelope(self.session_id, seq, bytes);
110	            if let Ok(mut s) = sub.lock() {
111	                let _ = write_frame(&mut *s, &frame);
112	            }
113	        }
114	    }
115
116	    /// Attach `sub` as the subscriber and replay every retained chunk with
117	    /// `seq >= from_seq` before live streaming resumes. Replaces any prior
118	    /// subscriber (a dead brain).
119	    fn attach(&mut self, sub: SharedSend, from_seq: u64) {
120	        self.subscriber = Some(Arc::clone(&sub));
121	        for (seq, bytes) in self.ring.iter().filter(|(s, _)| *s >= from_seq) {
122	            let frame = output_envelope(self.session_id, *seq, bytes);
123	            if let Ok(mut s) = sub.lock() {
124	                let _ = write_frame(&mut *s, &frame);
125	            }
126	        }
127	    }
128
129	    /// Detach `sub` *iff* it is the current subscriber. A no-op if a newer brain
130	    /// has already taken over (so a late disconnect can't unsubscribe the live
131	    /// brain).
132	    fn detach_if(&mut self, sub: &SharedSend) {
133	        if let Some(cur) = &self.subscriber {
134	            if Arc::ptr_eq(cur, sub) {
135	                self.subscriber = None;
136	            }
137	        }
138	    }
139	}
140
141	/// A child the broker hosts under a PTY — the §B-owned resources for one
142	/// session, plus its gapless-resume output log.
143	struct HostedSession {
144	    /// The PTY + child (PTY master/writer + spawned child process).
145	    session: Arc<PtySession>,
146	    /// The output drain pump (kept so teardown can stop it).
147	    #[allow(dead_code)] // held for ownership/teardown; not read directly
148	    drain: Drain,
149	    /// The sequenced, buffered output + current subscriber.
150	    log: Arc<Mutex<OutputLog>>,
151	}
152
153	/// The broker process kernel. Owns the listening socket, the table of hosted
154	/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
155	/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
156	/// recovery anchor a brain crash must not lose, so it lives in the broker, not
157	/// the brain (Spike #6 constraint 1).
158	pub struct Broker {
159	    listener: LocalSocketTransport,
160	    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
161	    next_id: AtomicU64,
162	    log_cap_chunks: usize,
163	    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
164	    journal: Arc<EffectJournal>,
165	    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
166	    /// `None` when bound without a network host (net-disabled / most tests).
167	    net: Option<NetHost>,
168	}
169
170	impl Broker {
171	    /// Bind the broker's listening endpoint at the namespaced `name`, with the
172	    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
173	    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
174	        Self::bind_in(name, default_journal_path())
175	    }
176
177	    /// Bind with an explicit effect-journal path — the testable seam (tests point
178	    /// the broker-owned anchor at a temp file rather than the shared home).
179	    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
180	        Self::bind_in_with_net(name, journal_path, None)
181	    }
182
183	    /// Bind with an already-started broker-owned network host (D4a). Taking the
184	    /// host (not its config) keeps a net bind failure separable from a listener
185	    /// bind failure — the daemon degrades to a net-less broker rather than
186	    /// losing PTY hosting (see `Daemon::run`).
187	    // [impl->REQ-DAEMON-2]
188	    pub fn bind_in_with_net(
189	        name: &str,
190	        journal_path: std::path::PathBuf,
191	        net: Option<NetHost>,
192	    ) -> io::Result<Arc<Self>> {
193	        Ok(Arc::new(Broker {
194	            listener: LocalSocketTransport::bind(name)?,
195	            sessions: Arc::new(Mutex::new(HashMap::new())),
196	            next_id: AtomicU64::new(1),
197	            log_cap_chunks: DEFAULT_LOG_CHUNKS,
198	            journal: Arc::new(EffectJournal::open(journal_path)?),
199	            net,
200	        }))
201	    }
202
203	    /// The broker-owned exactly-once anchor (for introspection / tests).
204	    pub fn journal(&self) -> &Arc<EffectJournal> {
205	        &self.journal
206	    }
207
208	    /// The broker-owned network host, if this broker was bound with one.
209	    pub fn net(&self) -> Option<&NetHost> {
210	        self.net.as_ref()
211	    }
212
213	    /// Accept connections forever, handling each on its own thread. The broker
214	    /// is the long-lived process; this is its main loop.
215	    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
216	        loop {
217	            let conn = self.listener.accept()?;
218	            let me = Arc::clone(self);
219	            thread::spawn(move || {
220	                let _ = me.handle_conn(conn);
221	            });
222	        }
223	    }
224
225	    /// Accept exactly one connection and handle it on a spawned thread, returning
226	    /// that thread's handle. The single-brain entry point the tests exercise.
227	    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
228	        let conn = self.listener.accept()?;
229	        let me = Arc::clone(self);
230	        Ok(thread::spawn(move || {
231	            let _ = me.handle_conn(conn);
232	        }))
233	    }
234
235	    /// Number of currently hosted sessions (for tests / introspection).
236	    pub fn session_count(&self) -> usize {
237	        self.sessions.lock().unwrap().len()
238	    }
239
240	    /// The child pid of a hosted session, if present (tests assert it is stable
241	    /// across brain restarts).
242	    pub fn session_pid(&self, id: u64) -> Option<u32> {
243	        self.sessions.lock().unwrap().get(&id).and_then(|h| h.session.process_id())
244	    }
245
246	    /// Drive one brain connection: handshake, then dispatch its command frames
247	    /// until it disconnects. On disconnect, detach this connection from any
248	    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
249	    /// restart must not disturb the hosted children).
250	    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
251	        let (mut recv, send) = conn.split();
252	        let send: SharedSend = Arc::new(Mutex::new(send));
253
254	        // Version handshake: this connection must be a brain (2.3).
255	        recv_hello(&mut recv, Role::Brain)?;
256
257	        let mut my_subs: Vec<u64> = Vec::new();
258	        let mut my_stream_subs: Vec<u64> = Vec::new();
259	        let mut my_presence_sub = false;
260
261	        loop {
262	            let env = match read_frame(&mut recv) {
263	                Ok(e) => e,
264	                // Any read failure (EOF included) ends the connection.
265	                Err(_) => break,
266	            };
267	            match env.kind.as_str() {
268	                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
269	                    Ok(id) => my_subs.push(id),
270	                    Err(msg) => send_error(&send, &msg),
271	                },
272	                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
273	                    Ok(id) => my_subs.push(id),
274	                    Err(msg) => send_error(&send, &msg),
275	                },
276	                KIND_INPUT => {
277	                    if let Err(msg) = self.dispatch_input(env, &send) {
278	                        send_error(&send, &msg);
279	                    }
280	                }
281	                KIND_RESIZE => {
282	                    if let Err(msg) = self.dispatch_resize(env) {
283	                        send_error(&send, &msg);
284	                    }
285	                }
286	                KIND_KILL => {
287	                    if let Err(msg) = self.dispatch_kill(env) {
288	                        send_error(&send, &msg);
289	                    }
290	                }
291	                KIND_NET_STATUS => self.dispatch_net_status(&send),
292	                KIND_NET_DIAL => {
293	                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
294	                        send_error(&send, &msg);
295	                    }
296	                }
297	                KIND_NET_STREAM_OPEN => {
298	                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
299	                        send_error(&send, &msg);
300	                    }
301	                }
302	                KIND_NET_STREAM_SEND => {
303	                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
304	                        send_error(&send, &msg);
305	                    }
306	                }
307	                KIND_NET_STREAMS => {
308	                    if let Err(msg) = self.dispatch_net_streams(&send) {
309	                        send_error(&send, &msg);
310	                    }
311	                }
312	                KIND_NET_STREAM_SUBSCRIBE => {
313	                    match self.dispatch_net_stream_subscribe(env, &send) {
314	                        Ok(id) => my_stream_subs.push(id),
315	                        Err(msg) => send_error(&send, &msg),
316	                    }
317	                }
318	                KIND_NET_PRESENCE_SUBSCRIBE => {
319	                    match self.dispatch_net_presence_subscribe(env, &send) {
320	                        Ok(()) => my_presence_sub = true,
321	                        Err(msg) => send_error(&send, &msg),
322	                    }
323	                }
324	                // An unknown kind is tolerated (forward-compat): report, don't die.
325	                other => send_error(&send, &format!("unknown command kind {other:?}")),
326	            }
327	        }
328
329	        // Brain gone: detach as subscriber but leave the sessions running.
330	        let sessions = self.sessions.lock().unwrap();
331	        for id in &my_subs {
332	            if let Some(h) = sessions.get(id) {
333	                h.log.lock().unwrap().detach_if(&send);
334	            }
335	        }
336	        drop(sessions);
337	        // Same for net streams: the streams + their rings persist (D4b).
338	        if let Some(host) = &self.net {
339	            for id in &my_stream_subs {
340	                host.detach_stream_sub(*id, &send);
341	            }
342	            // And presence: the liveness log + its ring persist (D4c).
343	            if my_presence_sub {
344	                host.detach_presence_sub(&send);
345	            }
346	        }
347	        Ok(())
348	    }
349
350	    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
351	    /// connection, start its output drain and exit waiter, and reply `spawned`.
352	    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
353	        let req: SpawnReq = serde_json::from_value(env.payload)
354	            .map_err(|e| format!("bad spawn payload: {e}"))?;
355	        let session = PtySession::spawn_program(
356	            &req.program,
357	            &req.args,
358	            SurfaceSize::new(req.rows, req.cols),
359	        )
360	        .map_err(|e| format!("spawn failed: {e}"))?;
361	        let session = Arc::new(session);
362	        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
363	        let pid = session.process_id();
364
365	        // The output log, with the spawning connection pre-attached so no chunk
366	        // is missed before an explicit subscribe.
367	        let log = Arc::new(Mutex::new(OutputLog::new(id, self.log_cap_chunks)));
368	        log.lock().unwrap().attach(Arc::clone(send), 0);
369
370	        // Output drain → appends to the log (which live-sends to the subscriber).
371	        let log_drain = Arc::clone(&log);
372	        let drain = session
373	            .drain(move |chunk| {
374	                log_drain.lock().unwrap().append(chunk);
375	            })
376	            .map_err(|e| format!("drain failed: {e}"))?;
377
378	        // Exit waiter → one `exit` frame when the child is reaped, then the dead
379	        // session is removed from the table. Runs on its own thread; `wait`
380	        // holds the child lock but `kill` uses a split killer.
381	        let sessions_exit = Arc::clone(&self.sessions);
382	        let log_exit = Arc::clone(&log);
383	        let waiter = Arc::clone(&session);
384	        thread::spawn(move || {
385	            if let Ok(status) = waiter.wait() {
386	                let ev = ExitEvent { session_id: id, code: Some(status.exit_code() as i32) };
387	                let frame = Envelope::new(
388	                    KIND_EXIT,
389	                    serde_json::to_value(ev).expect("ExitEvent serializes"),
390	                );
391	                // Send to whoever is currently subscribed (under the log lock).
392	                if let Some(sub) = log_exit.lock().unwrap().subscriber.clone() {
393	                    if let Ok(mut s) = sub.lock() {
394	                        let _ = write_frame(&mut *s, &frame);
395	                    }
396	                }
397	            }
398	            // Reap the dead session from the table.
399	            sessions_exit.lock().unwrap().remove(&id);
400	        });
401
402	        self.sessions.lock().unwrap().insert(id, HostedSession { session, drain, log });
403
404	        let resp = Spawned { session_id: id, pid };
405	        let frame =
406	            Envelope::new(KIND_SPAWNED, serde_json::to_value(resp).expect("Spawned serializes"));
407	        send_frame(send, &frame);
408	        Ok(id)
409	    }
410
411	    /// Re-attach a (handed-off) brain to a session's output, replaying from
412	    /// `from_seq` for gapless, exactly-once resume.
413	    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
414	        let req: SubscribeReq = serde_json::from_value(env.payload)
415	            .map_err(|e| format!("bad subscribe payload: {e}"))?;
416	        // Clone the log Arc out from under the sessions lock, then replay under
417	        // the log lock — never hold both locks at once.
418	        let log = {
419	            let sessions = self.sessions.lock().unwrap();
420	            let h = sessions
421	                .get(&req.session_id)
422	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
423	            Arc::clone(&h.log)
424	        };
425	        log.lock().unwrap().attach(Arc::clone(send), req.from_seq);
426	        Ok(req.session_id)
427	    }
428
429	    /// Inject input bytes into a session's PTY.
430	    ///
431	    /// With an `op_id` the write is routed through the broker-owned
432	    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
433	    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
434	    /// written twice), and either outcome — fresh apply or dedup — is acked back
435	    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
436	    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
437	    /// direct write (no dedup, no ack).
438	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
439	    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
440	        let req: InputReq = serde_json::from_value(env.payload)
441	            .map_err(|e| format!("bad input payload: {e}"))?;
442	        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
443	        // Clone the session Arc out from under the sessions lock so the PTY write
444	        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
445	        let session = {
446	            let sessions = self.sessions.lock().unwrap();
447	            let h = sessions
448	                .get(&req.session_id)
449	                .ok_or_else(|| format!("no such session {}", req.session_id))?;
450	            Arc::clone(&h.session)
451	        };
452	        match req.op_id {
453	            Some(op) => {
454	                let key = (req.session_id, op);
455	                let outcome = self
456	                    .journal
457	                    .apply_once(key, EffectKind::PtyWrite, || {
458	                        session.write_input(&bytes).map_err(|e| io::Error::other(e.to_string()))
459	                    })
460	                    .map_err(|e| format!("write_input failed: {e}"))?;
461	                // Ack exactly-once: both Applied and Deduped mean "durably landed".
462	                send_frame(send, &applied_envelope(req.session_id, op, outcome == Outcome::Applied));
463	                Ok(())
464	            }
465	            None => session.write_input(&bytes).map_err(|e| format!("write_input failed: {e}")),
466	        }
467	    }
468
469	    /// Resize a session's PTY.
470	    fn dispatch_resize(&self, env: Envelope) -> Result<(), String> {
471	        let req: ResizeReq = serde_json::from_value(env.payload)
472	            .map_err(|e| format!("bad resize payload: {e}"))?;
473	        let sessions = self.sessions.lock().unwrap();
474	        let h = sessions
475	            .get(&req.session_id)
476	            .ok_or_else(|| format!("no such session {}", req.session_id))?;
477	        h.session
478	            .resize(SurfaceSize::new(req.rows, req.cols))
479	            .map_err(|e| format!("resize failed: {e}"))
480	    }
481
482	    /// Report the broker-owned net endpoint's status (D4a). Answered even when
483	    /// the broker has no network host (`enabled: false`) so a brain can probe
484	    /// capability without treating absence as an error.
485	    // [impl->REQ-DAEMON-2]
486	    fn dispatch_net_status(&self, send: &SharedSend) {
487	        let reply = match &self.net {
488	            Some(host) => NetStatusReply {
489	                enabled: true,
490	                node_id_hex: Some(host.node_id_hex()),
491	                addr: host.addr_json(),
492	                conn_count: host.conn_count(),
493	            },
494	            None => NetStatusReply {
495	                enabled: false,
496	                node_id_hex: None,
497	                addr: serde_json::Value::Null,
498	                conn_count: 0,
499	            },
500	        };
501	        let frame = Envelope::new(
502	            KIND_NET_STATUS_REPLY,
503	            serde_json::to_value(reply).expect("NetStatusReply serializes"),
504	        );
505	        send_frame(send, &frame);
506	    }
507
508	    /// Dial a peer over the broker-owned endpoint (D4a).
509	    ///
510	    /// With an `op_id` the dial is routed through the broker-owned
511	    /// [`EffectJournal`] under the reserved [`NET_EFFECT_SESSION`] namespace,
512	    /// exactly like an idempotent PTY write: a replay of the same id after a
513	    /// brain crash is deduped (no second connection) and answered with the
514	    /// connection the first apply opened. The `conn_id` is recorded *inside*
515	    /// the journaled closure, so a concurrent deduped replay always finds it.
516	    /// A deduped id whose connection this process no longer holds (a broker
517	    /// restart — the journal survives, connections don't) is a typed error
518	    /// telling the brain to dial under a fresh id.
519	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
520	    fn dispatch_net_dial(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
521	        let req: NetDialReq = serde_json::from_value(env.payload)
522	            .map_err(|e| format!("bad net-dial payload: {e}"))?;
523	        let host = self
524	            .net
525	            .as_ref()
526	            .ok_or("net disabled: this broker was bound without a network host")?;
527	        let addr: EndpointAddr = serde_json::from_value(req.addr)
528	            .map_err(|e| format!("bad net-dial addr: {e}"))?;
529	        match req.op_id {
530	            None => {
531	                // Direct (non-journaled) dial — no dedup, like a no-op_id input.
532	                let (conn_id, remote) = host.dial(addr).map_err(|e| e.to_string())?;
533	                send_frame(send, &net_dialed_envelope(conn_id, remote, None, true));
534	                Ok(())
535	            }
536	            Some(op) => {
537	                let outcome = self
538	                    .journal
539	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetDial, || {
540	                        let (conn_id, _) = host.dial(addr)?;
541	                        host.record_dial_op(op, conn_id);
542	                        Ok(())
543	                    })
544	                    .map_err(|e| format!("net-dial failed: {e}"))?;
545	                let conn_id = host.dial_op_conn(op).ok_or(
546	                    "net-dial op already applied but its connection is no longer held \
547	                     (broker restarted); retry with a fresh op_id",
548	                )?;
549	                let remote = host
550	                    .conn_remote_hex(conn_id)
551	                    .ok_or("dialed connection missing from the conn table")?;
552	                let applied_now = outcome == Outcome::Applied;
553	                send_frame(send, &net_dialed_envelope(conn_id, remote, Some(op), applied_now));
554	                Ok(())
555	            }
556	        }
557	    }
558
559	    /// Open a bidi stream on a held connection (D4b). Journaled exactly-once
560	    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
561	    /// `stream_id` is recorded inside the `apply_once` closure, and a deduped
562	    /// replay is answered with the stream the first apply opened.
563	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
564	    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
565	        let req: NetStreamOpenReq = serde_json::from_value(env.payload)
566	            .map_err(|e| format!("bad net-stream-open payload: {e}"))?;
567	        let host = self.net.as_ref().ok_or("net disabled")?;
568	        let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {
569	            Envelope::new(
570	                KIND_NET_STREAM_OPENED,
571	                serde_json::to_value(NetStreamOpened { stream_id, conn_id, op_id, applied_now })
572	                    .expect("NetStreamOpened serializes"),
573	            )
574	        };
575	        match req.op_id {
576	            None => {
577	                let stream_id = host.open_stream(req.conn_id).map_err(|e| e.to_string())?;
578	                send_frame(send, &build(stream_id, req.conn_id, None, true));
579	                Ok(())
580	            }
581	            Some(op) => {
582	                let outcome = self
583	                    .journal
584	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
585	                        let stream_id = host.open_stream(req.conn_id)?;
586	                        host.record_stream_op(op, stream_id);
587	                        Ok(())
588	                    })
589	                    .map_err(|e| format!("net-stream-open failed: {e}"))?;
590	                let stream_id = host.stream_op_id(op).ok_or(
591	                    "stream-open op already applied but its stream is no longer held \
592	                     (broker restarted); retry with a fresh op_id",
593	                )?;
594	                let applied_now = outcome == Outcome::Applied;
595	                send_frame(send, &build(stream_id, req.conn_id, Some(op), applied_now));
596	                Ok(())
597	            }
598	        }
599	    }
600
601	    /// Write bytes to a held stream's send side (D4b). With an `op_id` the
602	    /// write is exactly-once through the broker-owned journal (a replayed
603	    /// chunk after a brain crash is not written twice — Spike #3 invariant D
604	    /// on the send path) and acked with `net-sent`; without one it is a direct
605	    /// write (no dedup, no ack), mirroring the PTY `input` contract.
606	    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
607	    fn dispatch_net_stream_send(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
608	        let req: NetStreamSendReq = serde_json::from_value(env.payload)
609	            .map_err(|e| format!("bad net-stream-send payload: {e}"))?;
610	        let bytes =
611	            decode_bytes(&req.data_b64).map_err(|e| format!("bad net-stream-send base64: {e}"))?;
612	        let host = self.net.as_ref().ok_or("net disabled")?;
613	        match req.op_id {
614	            None => host
615	                .send_stream(req.stream_id, &bytes, req.finish)
616	                .map_err(|e| e.to_string()),
617	            Some(op) => {
618	                let outcome = self
619	                    .journal
620	                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
621	                        host.send_stream(req.stream_id, &bytes, req.finish)
622	                    })
623	                    .map_err(|e| format!("net-stream-send failed: {e}"))?;
624	                let frame = Envelope::new(
625	                    KIND_NET_SENT,
626	                    serde_json::to_value(NetSent {
627	                        stream_id: req.stream_id,
628	                        op_id: op,
629	                        applied_now: outcome == Outcome::Applied,
630	                    })
631	                    .expect("NetSent serializes"),
632	                );
633	                send_frame(send, &frame);
634	                Ok(())
635	            }
636	        }
637	    }
638
639	    /// List every stream the broker holds (D4b) — the query a re-attaching
640	    /// brain uses to find streams that arrived while it was down.
641	    fn dispatch_net_streams(&self, send: &SharedSend) -> Result<(), String> {
642	        let host = self.net.as_ref().ok_or("net disabled")?;
643	        let frame = Envelope::new(
644	            KIND_NET_STREAMS_REPLY,
645	            serde_json::to_value(NetStreamsReply { streams: host.stream_infos() })
646	                .expect("NetStreamsReply serializes"),
647	        );
648	        send_frame(send, &frame);
649	        Ok(())
650	    }
651
652	    /// Re-attach a brain to a stream's buffered reads from a cursor (D4b) —
653	    /// the gapless-resume `subscribe` discipline on a QUIC stream.
654	    // [impl->REQ-DAEMON-2]
655	    fn dispatch_net_stream_subscribe(
656	        &self,
657	        env: Envelope,
658	        send: &SharedSend,
659	    ) -> Result<u64, String> {
660	        let req: NetStreamSubscribeReq = serde_json::from_value(env.payload)
661	            .map_err(|e| format!("bad net-stream-subscribe payload: {e}"))?;
662	        let host = self.net.as_ref().ok_or("net disabled")?;
663	        host.subscribe_stream(req.stream_id, Arc::clone(send), req.from_seq)
664	            .map_err(|e| e.to_string())?;
665	        Ok(req.stream_id)
666	    }
667
668	    /// Attach a brain to the conn-liveness event log from a cursor (D4c) — the
669	    /// PresenceChannel broker seam: gapless resume over connection presence,
670	    /// same discipline as a stream subscribe.
671	    // [impl->REQ-EP-4]
672	    fn dispatch_net_presence_subscribe(
673	        &self,
674	        env: Envelope,
675	        send: &SharedSend,
676	    ) -> Result<(), String> {
677	        let req: NetPresenceSubscribeReq = serde_json::from_value(env.payload)
678	            .map_err(|e| format!("bad net-presence-subscribe payload: {e}"))?;
679	        let host = self.net.as_ref().ok_or("net disabled")?;
680	        host.subscribe_presence(Arc::clone(send), req.from_seq);
681	        Ok(())
682	    }
683
684	    /// Terminate a session's child. The exit waiter emits the `exit` frame and
685	    /// reaps the session once the child is dead.
686	    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
687	        let req: KillReq = serde_json::from_value(env.payload)
688	            .map_err(|e| format!("bad kill payload: {e}"))?;
689	        let sessions = self.sessions.lock().unwrap();
690	        let h = sessions
691	            .get(&req.session_id)
692	            .ok_or_else(|| format!("no such session {}", req.session_id))?;
693	        h.session.kill().map_err(|e| format!("kill failed: {e}"))
694	    }
695	}
696
697	/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
698	pub(crate) fn default_journal_path() -> std::path::PathBuf {
699	    spt_store::perch::spt_home().join("daemon-effects.log")
700	}
701
702	/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
703	fn send_frame(send: &SharedSend, frame: &Envelope) {
704	    if let Ok(mut s) = send.lock() {
705	        let _ = write_frame(&mut *s, frame);
706	    }
707	}
708
709	/// Send an `error` event to the brain (best-effort).
710	fn send_error(send: &SharedSend, message: &str) {
711	    let frame = Envelope::new(
712	        KIND_ERROR,
713	        serde_json::to_value(ErrorEvent { message: message.to_string() })
714	            .expect("ErrorEvent serializes"),
715	    );
716	    send_frame(send, &frame);
717	}
718

//! The broker kernel (B1 + B2) — the stable half of the broker/brain split.
//!
//! Per ADR-0004 §B the broker holds **only** the resources a live consumer
//! would lose continuity on if the logic restarted: the PTY master+writer and
//! the spawned child (both via [`spt_term::PtySession`]), and the accepted
//! client / listening sockets. It runs **no logic** — no routing, registry,
//! manifest parse, or lifecycle. It is a relay: spawn a child under a PTY,
//! forward its output to the brain, inject the brain's input, report exit.
//!
//! ## Gapless handoff (B2 — the load-bearing invariant)
//!
//! A brain may be killed and restarted at any moment (a routine self-update, a
//! crash). The broker — and every hosted child — must survive that untouched,
//! and the new brain must re-attach to the output stream **gaplessly and
//! exactly-once** (Spike #1 made real). Two mechanisms:
//!
//! - **Sequenced, buffered output.** Each session's output is appended to an
//!   [`OutputLog`]: every chunk gets a monotonic per-session `seq` and is held
//!   in a bounded ring. The current subscriber (a brain connection) receives
//!   chunks live; all subscriber writes happen *under the log lock*, so frames
//!   reach the brain in strict `seq` order with no interleave.
//! - **Resume from a cursor.** A reconnecting brain sends `subscribe` with its
//!   last-seen `seq + 1`; the broker replays the ring from there, then streams
//!   live. The brain dedups by `seq`. Output produced *while no brain was
//!   attached* sits in the ring and is replayed on the next subscribe — so a
//!   brain restart loses nothing.
//!
//! Sessions are therefore **broker-owned and outlive any one connection**: a
//! brain disconnect only detaches the subscriber; the child, its PTY, and the
//! output log persist. A session leaves the table only when its child exits
//! (reaped by the exit waiter) or is explicitly killed.
// [impl->REQ-DAEMON-2]

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

use interprocess::local_socket::{prelude::*, SendHalf, Stream};
use spt_term::{Drain, PtySession, SessionSurface, SurfaceSize};

use spt_net::net::endpoint::EndpointAddr;

use crate::codec::{read_frame, write_frame};
use crate::effect::{EffectJournal, EffectKind, Outcome};
use crate::frame::{Envelope, Role};
use crate::msg::{
    applied_envelope, decode_bytes, net_dialed_envelope, output_envelope, ErrorEvent, ExitEvent,
    InputReq, KillReq, NetDialReq, NetPresenceSubscribeReq, NetSent, NetStatusReply,
    NetStreamOpenReq, NetStreamOpened, NetStreamSendReq, NetStreamSubscribeReq, NetStreamsReply,
    ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL,
    KIND_NET_DIAL, KIND_NET_PRESENCE_SUBSCRIBE, KIND_NET_SENT, KIND_NET_STATUS,
    KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY, KIND_NET_STREAM_OPEN,
    KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND, KIND_NET_STREAM_SUBSCRIBE, KIND_RESIZE,
    KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
};
use crate::nethost::{NetHost, NET_EFFECT_SESSION};
use crate::transport::{recv_hello, DaemonTransport, LocalSocketTransport};

/// The shared, serialized send side of one brain connection. Output frames,
/// replay frames, command acks, and exit/error events all write through this.
/// `pub(crate)`: the net host's stream logs (D4b) live-send through the same
/// handle under the same lock discipline.
pub(crate) type SharedSend = Arc<Mutex<SendHalf>>;

/// Default bounded output-log depth (chunks). Large enough that a brain restart
/// window never evicts unreplayed output in practice; the bound is what keeps a
/// disconnected session from buffering without limit.
const DEFAULT_LOG_CHUNKS: usize = 4096;

/// A bounded, sequenced output log for one session — the gapless-resume buffer.
///
/// The session's single drain thread is the only producer ([`OutputLog::append`]);
/// a brain connection attaches as the subscriber ([`OutputLog::attach`]). Every
/// send to the subscriber happens while the enclosing `Mutex<OutputLog>` is
/// held, so the live stream and a replay can never interleave out of `seq`
/// order.
struct OutputLog {
    session_id: u64,
    next_seq: u64,
    ring: VecDeque<(u64, Vec<u8>)>,
    cap_chunks: usize,
    subscriber: Option<SharedSend>,
}

impl OutputLog {
    fn new(session_id: u64, cap_chunks: usize) -> Self {
        OutputLog {
            session_id,
            next_seq: 0,
            ring: VecDeque::new(),
            cap_chunks,
            subscriber: None,
        }
    }

    /// Append a freshly-produced output chunk: assign the next `seq`, retain it
    /// in the bounded ring, and live-send it to the current subscriber (if any).
    /// Called only from the session's drain thread.
    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 = output_envelope(self.session_id, seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Attach `sub` as the subscriber and replay every retained chunk with
    /// `seq >= from_seq` before live streaming resumes. Replaces any prior
    /// subscriber (a dead brain).
    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 = output_envelope(self.session_id, *seq, bytes);
            if let Ok(mut s) = sub.lock() {
                let _ = write_frame(&mut *s, &frame);
            }
        }
    }

    /// Detach `sub` *iff* it is the current subscriber. A no-op if a newer brain
    /// has already taken over (so a late disconnect can't unsubscribe the live
    /// brain).
    fn detach_if(&mut self, sub: &SharedSend) {
        if let Some(cur) = &self.subscriber {
            if Arc::ptr_eq(cur, sub) {
                self.subscriber = None;
            }
        }
    }
}

/// A child the broker hosts under a PTY — the §B-owned resources for one
/// session, plus its gapless-resume output log.
struct HostedSession {
    /// The PTY + child (PTY master/writer + spawned child process).
    session: Arc<PtySession>,
    /// The output drain pump (kept so teardown can stop it).
    #[allow(dead_code)] // held for ownership/teardown; not read directly
    drain: Drain,
    /// The sequenced, buffered output + current subscriber.
    log: Arc<Mutex<OutputLog>>,
}

/// The broker process kernel. Owns the listening socket, the table of hosted
/// sessions, and the broker-owned idempotency anchor (B5); holds no logic state
/// (ADR-0004 §B). The [`EffectJournal`] is a §B resource — the durable
/// recovery anchor a brain crash must not lose, so it lives in the broker, not
/// the brain (Spike #6 constraint 1).
pub struct Broker {
    listener: LocalSocketTransport,
    sessions: Arc<Mutex<HashMap<u64, HostedSession>>>,
    next_id: AtomicU64,
    log_cap_chunks: usize,
    /// The exactly-once anchor for idempotent (`op_id`-carrying) effects.
    journal: Arc<EffectJournal>,
    /// The broker-owned net endpoint + live QUIC connections (D4a, ADR-0004 §B).
    /// `None` when bound without a network host (net-disabled / most tests).
    net: Option<NetHost>,
}

impl Broker {
    /// Bind the broker's listening endpoint at the namespaced `name`, with the
    /// effect journal at the default path (`<spt_home>/daemon-effects.log`).
    pub fn bind(name: &str) -> io::Result<Arc<Self>> {
        Self::bind_in(name, default_journal_path())
    }

    /// Bind with an explicit effect-journal path — the testable seam (tests point
    /// the broker-owned anchor at a temp file rather than the shared home).
    pub fn bind_in(name: &str, journal_path: std::path::PathBuf) -> io::Result<Arc<Self>> {
        Self::bind_in_with_net(name, journal_path, None)
    }

    /// Bind with an already-started broker-owned network host (D4a). Taking the
    /// host (not its config) keeps a net bind failure separable from a listener
    /// bind failure — the daemon degrades to a net-less broker rather than
    /// losing PTY hosting (see `Daemon::run`).
    // [impl->REQ-DAEMON-2]
    pub fn bind_in_with_net(
        name: &str,
        journal_path: std::path::PathBuf,
        net: Option<NetHost>,
    ) -> io::Result<Arc<Self>> {
        Ok(Arc::new(Broker {
            listener: LocalSocketTransport::bind(name)?,
            sessions: Arc::new(Mutex::new(HashMap::new())),
            next_id: AtomicU64::new(1),
            log_cap_chunks: DEFAULT_LOG_CHUNKS,
            journal: Arc::new(EffectJournal::open(journal_path)?),
            net,
        }))
    }

    /// The broker-owned exactly-once anchor (for introspection / tests).
    pub fn journal(&self) -> &Arc<EffectJournal> {
        &self.journal
    }

    /// The broker-owned network host, if this broker was bound with one.
    pub fn net(&self) -> Option<&NetHost> {
        self.net.as_ref()
    }

    /// Accept connections forever, handling each on its own thread. The broker
    /// is the long-lived process; this is its main loop.
    pub fn serve(self: &Arc<Self>) -> io::Result<()> {
        loop {
            let conn = self.listener.accept()?;
            let me = Arc::clone(self);
            thread::spawn(move || {
                let _ = me.handle_conn(conn);
            });
        }
    }

    /// Accept exactly one connection and handle it on a spawned thread, returning
    /// that thread's handle. The single-brain entry point the tests exercise.
    pub fn serve_once(self: &Arc<Self>) -> io::Result<JoinHandle<()>> {
        let conn = self.listener.accept()?;
        let me = Arc::clone(self);
        Ok(thread::spawn(move || {
            let _ = me.handle_conn(conn);
        }))
    }

    /// Number of currently hosted sessions (for tests / introspection).
    pub fn session_count(&self) -> usize {
        self.sessions.lock().unwrap().len()
    }

    /// The child pid of a hosted session, if present (tests assert it is stable
    /// across brain restarts).
    pub fn session_pid(&self, id: u64) -> Option<u32> {
        self.sessions.lock().unwrap().get(&id).and_then(|h| h.session.process_id())
    }

    /// Drive one brain connection: handshake, then dispatch its command frames
    /// until it disconnects. On disconnect, detach this connection from any
    /// sessions it subscribed to — the sessions themselves persist (B2: a brain
    /// restart must not disturb the hosted children).
    fn handle_conn(self: &Arc<Self>, conn: Stream) -> io::Result<()> {
        let (mut recv, send) = conn.split();
        let send: SharedSend = Arc::new(Mutex::new(send));

        // Version handshake: this connection must be a brain (2.3).
        recv_hello(&mut recv, Role::Brain)?;

        let mut my_subs: Vec<u64> = Vec::new();
        let mut my_stream_subs: Vec<u64> = Vec::new();
        let mut my_presence_sub = false;

        loop {
            let env = match read_frame(&mut recv) {
                Ok(e) => e,
                // Any read failure (EOF included) ends the connection.
                Err(_) => break,
            };
            match env.kind.as_str() {
                KIND_SPAWN => match self.dispatch_spawn(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_SUBSCRIBE => match self.dispatch_subscribe(env, &send) {
                    Ok(id) => my_subs.push(id),
                    Err(msg) => send_error(&send, &msg),
                },
                KIND_INPUT => {
                    if let Err(msg) = self.dispatch_input(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_RESIZE => {
                    if let Err(msg) = self.dispatch_resize(env) {
                        send_error(&send, &msg);
                    }
                }
                KIND_KILL => {
                    if let Err(msg) = self.dispatch_kill(env) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STATUS => self.dispatch_net_status(&send),
                KIND_NET_DIAL => {
                    if let Err(msg) = self.dispatch_net_dial(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_OPEN => {
                    if let Err(msg) = self.dispatch_net_stream_open(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_SEND => {
                    if let Err(msg) = self.dispatch_net_stream_send(env, &send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAMS => {
                    if let Err(msg) = self.dispatch_net_streams(&send) {
                        send_error(&send, &msg);
                    }
                }
                KIND_NET_STREAM_SUBSCRIBE => {
                    match self.dispatch_net_stream_subscribe(env, &send) {
                        Ok(id) => my_stream_subs.push(id),
                        Err(msg) => send_error(&send, &msg),
                    }
                }
                KIND_NET_PRESENCE_SUBSCRIBE => {
                    match self.dispatch_net_presence_subscribe(env, &send) {
                        Ok(()) => my_presence_sub = true,
                        Err(msg) => send_error(&send, &msg),
                    }
                }
                // An unknown kind is tolerated (forward-compat): report, don't die.
                other => send_error(&send, &format!("unknown command kind {other:?}")),
            }
        }

        // Brain gone: detach as subscriber but leave the sessions running.
        let sessions = self.sessions.lock().unwrap();
        for id in &my_subs {
            if let Some(h) = sessions.get(id) {
                h.log.lock().unwrap().detach_if(&send);
            }
        }
        drop(sessions);
        // Same for net streams: the streams + their rings persist (D4b).
        if let Some(host) = &self.net {
            for id in &my_stream_subs {
                host.detach_stream_sub(*id, &send);
            }
            // And presence: the liveness log + its ring persist (D4c).
            if my_presence_sub {
                host.detach_presence_sub(&send);
            }
        }
        Ok(())
    }

    /// Spawn a child under a fresh PTY, register it, auto-subscribe the spawning
    /// connection, start its output drain and exit waiter, and reply `spawned`.
    fn dispatch_spawn(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SpawnReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad spawn payload: {e}"))?;
        let session = PtySession::spawn_program(
            &req.program,
            &req.args,
            SurfaceSize::new(req.rows, req.cols),
        )
        .map_err(|e| format!("spawn failed: {e}"))?;
        let session = Arc::new(session);
        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
        let pid = session.process_id();

        // The output log, with the spawning connection pre-attached so no chunk
        // is missed before an explicit subscribe.
        let log = Arc::new(Mutex::new(OutputLog::new(id, self.log_cap_chunks)));
        log.lock().unwrap().attach(Arc::clone(send), 0);

        // Output drain → appends to the log (which live-sends to the subscriber).
        let log_drain = Arc::clone(&log);
        let drain = session
            .drain(move |chunk| {
                log_drain.lock().unwrap().append(chunk);
            })
            .map_err(|e| format!("drain failed: {e}"))?;

        // Exit waiter → one `exit` frame when the child is reaped, then the dead
        // session is removed from the table. Runs on its own thread; `wait`
        // holds the child lock but `kill` uses a split killer.
        let sessions_exit = Arc::clone(&self.sessions);
        let log_exit = Arc::clone(&log);
        let waiter = Arc::clone(&session);
        thread::spawn(move || {
            if let Ok(status) = waiter.wait() {
                let ev = ExitEvent { session_id: id, code: Some(status.exit_code() as i32) };
                let frame = Envelope::new(
                    KIND_EXIT,
                    serde_json::to_value(ev).expect("ExitEvent serializes"),
                );
                // Send to whoever is currently subscribed (under the log lock).
                if let Some(sub) = log_exit.lock().unwrap().subscriber.clone() {
                    if let Ok(mut s) = sub.lock() {
                        let _ = write_frame(&mut *s, &frame);
                    }
                }
            }
            // Reap the dead session from the table.
            sessions_exit.lock().unwrap().remove(&id);
        });

        self.sessions.lock().unwrap().insert(id, HostedSession { session, drain, log });

        let resp = Spawned { session_id: id, pid };
        let frame =
            Envelope::new(KIND_SPAWNED, serde_json::to_value(resp).expect("Spawned serializes"));
        send_frame(send, &frame);
        Ok(id)
    }

    /// Re-attach a (handed-off) brain to a session's output, replaying from
    /// `from_seq` for gapless, exactly-once resume.
    fn dispatch_subscribe(&self, env: Envelope, send: &SharedSend) -> Result<u64, String> {
        let req: SubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad subscribe payload: {e}"))?;
        // Clone the log Arc out from under the sessions lock, then replay under
        // the log lock — never hold both locks at once.
        let log = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            Arc::clone(&h.log)
        };
        log.lock().unwrap().attach(Arc::clone(send), req.from_seq);
        Ok(req.session_id)
    }

    /// Inject input bytes into a session's PTY.
    ///
    /// With an `op_id` the write is routed through the broker-owned
    /// [`EffectJournal`] for exactly-once delivery: a replay of the same
    /// `(session_id, op_id)` after a brain crash is deduped (the PTY is not
    /// written twice), and either outcome — fresh apply or dedup — is acked back
    /// with an [`crate::msg::KIND_APPLIED`] frame so the brain can retire the
    /// effect (B5 / KNOWN-HAZARDS 7.2). Without an `op_id` it is the legacy
    /// direct write (no dedup, no ack).
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_input(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: InputReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad input payload: {e}"))?;
        let bytes = decode_bytes(&req.data_b64).map_err(|e| format!("bad input base64: {e}"))?;
        // Clone the session Arc out from under the sessions lock so the PTY write
        // + fsync happen without holding the table lock (mirrors dispatch_subscribe).
        let session = {
            let sessions = self.sessions.lock().unwrap();
            let h = sessions
                .get(&req.session_id)
                .ok_or_else(|| format!("no such session {}", req.session_id))?;
            Arc::clone(&h.session)
        };
        match req.op_id {
            Some(op) => {
                let key = (req.session_id, op);
                let outcome = self
                    .journal
                    .apply_once(key, EffectKind::PtyWrite, || {
                        session.write_input(&bytes).map_err(|e| io::Error::other(e.to_string()))
                    })
                    .map_err(|e| format!("write_input failed: {e}"))?;
                // Ack exactly-once: both Applied and Deduped mean "durably landed".
                send_frame(send, &applied_envelope(req.session_id, op, outcome == Outcome::Applied));
                Ok(())
            }
            None => session.write_input(&bytes).map_err(|e| format!("write_input failed: {e}")),
        }
    }

    /// Resize a session's PTY.
    fn dispatch_resize(&self, env: Envelope) -> Result<(), String> {
        let req: ResizeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad resize payload: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session
            .resize(SurfaceSize::new(req.rows, req.cols))
            .map_err(|e| format!("resize failed: {e}"))
    }

    /// Report the broker-owned net endpoint's status (D4a). Answered even when
    /// the broker has no network host (`enabled: false`) so a brain can probe
    /// capability without treating absence as an error.
    // [impl->REQ-DAEMON-2]
    fn dispatch_net_status(&self, send: &SharedSend) {
        let reply = match &self.net {
            Some(host) => NetStatusReply {
                enabled: true,
                node_id_hex: Some(host.node_id_hex()),
                addr: host.addr_json(),
                conn_count: host.conn_count(),
            },
            None => NetStatusReply {
                enabled: false,
                node_id_hex: None,
                addr: serde_json::Value::Null,
                conn_count: 0,
            },
        };
        let frame = Envelope::new(
            KIND_NET_STATUS_REPLY,
            serde_json::to_value(reply).expect("NetStatusReply serializes"),
        );
        send_frame(send, &frame);
    }

    /// Dial a peer over the broker-owned endpoint (D4a).
    ///
    /// With an `op_id` the dial is routed through the broker-owned
    /// [`EffectJournal`] under the reserved [`NET_EFFECT_SESSION`] namespace,
    /// exactly like an idempotent PTY write: a replay of the same id after a
    /// brain crash is deduped (no second connection) and answered with the
    /// connection the first apply opened. The `conn_id` is recorded *inside*
    /// the journaled closure, so a concurrent deduped replay always finds it.
    /// A deduped id whose connection this process no longer holds (a broker
    /// restart — the journal survives, connections don't) is a typed error
    /// telling the brain to dial under a fresh id.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_net_dial(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetDialReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-dial payload: {e}"))?;
        let host = self
            .net
            .as_ref()
            .ok_or("net disabled: this broker was bound without a network host")?;
        let addr: EndpointAddr = serde_json::from_value(req.addr)
            .map_err(|e| format!("bad net-dial addr: {e}"))?;
        match req.op_id {
            None => {
                // Direct (non-journaled) dial — no dedup, like a no-op_id input.
                let (conn_id, remote) = host.dial(addr).map_err(|e| e.to_string())?;
                send_frame(send, &net_dialed_envelope(conn_id, remote, None, true));
                Ok(())
            }
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetDial, || {
                        let (conn_id, _) = host.dial(addr)?;
                        host.record_dial_op(op, conn_id);
                        Ok(())
                    })
                    .map_err(|e| format!("net-dial failed: {e}"))?;
                let conn_id = host.dial_op_conn(op).ok_or(
                    "net-dial op already applied but its connection is no longer held \
                     (broker restarted); retry with a fresh op_id",
                )?;
                let remote = host
                    .conn_remote_hex(conn_id)
                    .ok_or("dialed connection missing from the conn table")?;
                let applied_now = outcome == Outcome::Applied;
                send_frame(send, &net_dialed_envelope(conn_id, remote, Some(op), applied_now));
                Ok(())
            }
        }
    }

    /// Open a bidi stream on a held connection (D4b). Journaled exactly-once
    /// under the same discipline as [`Self::dispatch_net_dial`]: the minted
    /// `stream_id` is recorded inside the `apply_once` closure, and a deduped
    /// replay is answered with the stream the first apply opened.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_net_stream_open(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetStreamOpenReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-stream-open payload: {e}"))?;
        let host = self.net.as_ref().ok_or("net disabled")?;
        let build = |stream_id: u64, conn_id: u64, op_id: Option<u64>, applied_now: bool| {
            Envelope::new(
                KIND_NET_STREAM_OPENED,
                serde_json::to_value(NetStreamOpened { stream_id, conn_id, op_id, applied_now })
                    .expect("NetStreamOpened serializes"),
            )
        };
        match req.op_id {
            None => {
                let stream_id = host.open_stream(req.conn_id).map_err(|e| e.to_string())?;
                send_frame(send, &build(stream_id, req.conn_id, None, true));
                Ok(())
            }
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
                        let stream_id = host.open_stream(req.conn_id)?;
                        host.record_stream_op(op, stream_id);
                        Ok(())
                    })
                    .map_err(|e| format!("net-stream-open failed: {e}"))?;
                let stream_id = host.stream_op_id(op).ok_or(
                    "stream-open op already applied but its stream is no longer held \
                     (broker restarted); retry with a fresh op_id",
                )?;
                let applied_now = outcome == Outcome::Applied;
                send_frame(send, &build(stream_id, req.conn_id, Some(op), applied_now));
                Ok(())
            }
        }
    }

    /// Write bytes to a held stream's send side (D4b). With an `op_id` the
    /// write is exactly-once through the broker-owned journal (a replayed
    /// chunk after a brain crash is not written twice — Spike #3 invariant D
    /// on the send path) and acked with `net-sent`; without one it is a direct
    /// write (no dedup, no ack), mirroring the PTY `input` contract.
    // [impl->REQ-HAZARD-RESTART-IDEMPOTENT]
    fn dispatch_net_stream_send(&self, env: Envelope, send: &SharedSend) -> Result<(), String> {
        let req: NetStreamSendReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-stream-send payload: {e}"))?;
        let bytes =
            decode_bytes(&req.data_b64).map_err(|e| format!("bad net-stream-send base64: {e}"))?;
        let host = self.net.as_ref().ok_or("net disabled")?;
        match req.op_id {
            None => host
                .send_stream(req.stream_id, &bytes, req.finish)
                .map_err(|e| e.to_string()),
            Some(op) => {
                let outcome = self
                    .journal
                    .apply_once((NET_EFFECT_SESSION, op), EffectKind::NetSend, || {
                        host.send_stream(req.stream_id, &bytes, req.finish)
                    })
                    .map_err(|e| format!("net-stream-send failed: {e}"))?;
                let frame = Envelope::new(
                    KIND_NET_SENT,
                    serde_json::to_value(NetSent {
                        stream_id: req.stream_id,
                        op_id: op,
                        applied_now: outcome == Outcome::Applied,
                    })
                    .expect("NetSent serializes"),
                );
                send_frame(send, &frame);
                Ok(())
            }
        }
    }

    /// List every stream the broker holds (D4b) — the query a re-attaching
    /// brain uses to find streams that arrived while it was down.
    fn dispatch_net_streams(&self, send: &SharedSend) -> Result<(), String> {
        let host = self.net.as_ref().ok_or("net disabled")?;
        let frame = Envelope::new(
            KIND_NET_STREAMS_REPLY,
            serde_json::to_value(NetStreamsReply { streams: host.stream_infos() })
                .expect("NetStreamsReply serializes"),
        );
        send_frame(send, &frame);
        Ok(())
    }

    /// Re-attach a brain to a stream's buffered reads from a cursor (D4b) —
    /// the gapless-resume `subscribe` discipline on a QUIC stream.
    // [impl->REQ-DAEMON-2]
    fn dispatch_net_stream_subscribe(
        &self,
        env: Envelope,
        send: &SharedSend,
    ) -> Result<u64, String> {
        let req: NetStreamSubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-stream-subscribe payload: {e}"))?;
        let host = self.net.as_ref().ok_or("net disabled")?;
        host.subscribe_stream(req.stream_id, Arc::clone(send), req.from_seq)
            .map_err(|e| e.to_string())?;
        Ok(req.stream_id)
    }

    /// Attach a brain to the conn-liveness event log from a cursor (D4c) — the
    /// PresenceChannel broker seam: gapless resume over connection presence,
    /// same discipline as a stream subscribe.
    // [impl->REQ-EP-4]
    fn dispatch_net_presence_subscribe(
        &self,
        env: Envelope,
        send: &SharedSend,
    ) -> Result<(), String> {
        let req: NetPresenceSubscribeReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad net-presence-subscribe payload: {e}"))?;
        let host = self.net.as_ref().ok_or("net disabled")?;
        host.subscribe_presence(Arc::clone(send), req.from_seq);
        Ok(())
    }

    /// Terminate a session's child. The exit waiter emits the `exit` frame and
    /// reaps the session once the child is dead.
    fn dispatch_kill(&self, env: Envelope) -> Result<(), String> {
        let req: KillReq = serde_json::from_value(env.payload)
            .map_err(|e| format!("bad kill payload: {e}"))?;
        let sessions = self.sessions.lock().unwrap();
        let h = sessions
            .get(&req.session_id)
            .ok_or_else(|| format!("no such session {}", req.session_id))?;
        h.session.kill().map_err(|e| format!("kill failed: {e}"))
    }
}

/// The default broker-owned effect-journal path: `<spt_home>/daemon-effects.log`.
pub(crate) fn default_journal_path() -> std::path::PathBuf {
    spt_store::perch::spt_home().join("daemon-effects.log")
}

/// Write a frame to the brain, ignoring a broken-pipe failure (brain gone).
fn send_frame(send: &SharedSend, frame: &Envelope) {
    if let Ok(mut s) = send.lock() {
        let _ = write_frame(&mut *s, frame);
    }
}

/// Send an `error` event to the brain (best-effort).
fn send_error(send: &SharedSend, message: &str) {
    let frame = Envelope::new(
        KIND_ERROR,
        serde_json::to_value(ErrorEvent { message: message.to_string() })
            .expect("ErrorEvent serializes"),
    );
    send_frame(send, &frame);
}


ls crates/spt/src/api && grep -n "pub fn\|pub struct\|pub enum\|KIND_" crates/spt-daemon/src/brain.rs | head -60

List api module; skim brain.rs surface


auth.rs
delivery.rs
live.rs
mod.rs
reporting.rs
startup.rs
worker.rs
39:    NetStreamsReply, OutputEvent, ResizeReq, SpawnReq, Spawned, SubscribeReq, KIND_APPLIED,
40:    KIND_ERROR, KIND_EXIT, KIND_INPUT, KIND_KILL, KIND_NET_DIAL, KIND_NET_DIALED,
41:    KIND_NET_PRESENCE_EVENT, KIND_NET_PRESENCE_SUBSCRIBE, KIND_NET_SENT, KIND_NET_STATUS,
42:    KIND_NET_STATUS_REPLY, KIND_NET_STREAMS, KIND_NET_STREAMS_REPLY, KIND_NET_STREAM_DATA,
43:    KIND_NET_STREAM_EOF, KIND_NET_STREAM_OPEN, KIND_NET_STREAM_OPENED, KIND_NET_STREAM_SEND,
44:    KIND_NET_STREAM_SUBSCRIBE, KIND_OUTPUT, KIND_RESIZE, KIND_SPAWN, KIND_SPAWNED, KIND_SUBSCRIBE,
50:pub fn now_ms() -> u64 {
62:pub struct BrainState {
76:pub enum BrokerEvent {
111:pub struct Brain {
133:    pub fn cold_start(name: &str, now_ms: u64) -> io::Result<Self> {
151:    pub fn handoff(name: &str, prior: BrainState, now_ms: u64) -> io::Result<Self> {
169:    pub fn gen_start_ms(&self) -> u64 {
174:    pub fn generation(&self) -> u64 {
179:    pub fn next_seq(&self) -> u64 {
184:    pub fn session_id(&self) -> Option<u64> {
189:    pub fn snapshot(&self) -> BrainState {
201:    pub fn spawn_session(&mut self, req: SpawnReq) -> io::Result<u64> {
202:        self.send(KIND_SPAWN, serde_json::to_value(req).expect("SpawnReq serializes"))?;
221:    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
224:            KIND_INPUT,
237:    pub fn send_effect(&mut self, op_id: u64, bytes: &[u8]) -> io::Result<()> {
240:            KIND_INPUT,
251:    pub fn resize(&mut self, rows: u16, cols: u16) -> io::Result<()> {
254:            KIND_RESIZE,
261:    pub fn kill_session(&mut self) -> io::Result<()> {
264:            KIND_KILL,
272:    pub fn read_output_until(&mut self, needle: &[u8], buf: &mut Vec<u8>) -> io::Result<u64> {
296:    pub fn read_event(&mut self) -> io::Result<BrokerEvent> {
300:                KIND_OUTPUT => {
317:                KIND_SPAWNED => {
322:                KIND_EXIT => {
327:                KIND_APPLIED => {
336:                KIND_NET_STATUS_REPLY => {
341:                KIND_NET_DIALED => {
346:                KIND_NET_STREAM_OPENED => {
351:                KIND_NET_SENT => {
356:                KIND_NET_STREAMS_REPLY => {
361:                KIND_NET_STREAM_DATA => {
389:                KIND_NET_STREAM_EOF => {
397:                KIND_NET_PRESENCE_EVENT => {
418:                KIND_ERROR => {
434:    pub fn attach(&mut self, session_id: u64, from_seq: u64) -> io::Result<()> {
449:    pub fn run_digest_feed(
475:    pub fn net_status(&mut self) -> io::Result<NetStatusReply> {
476:        self.send(KIND_NET_STATUS, serde_json::Value::Null)?;
492:    pub fn net_dial(
498:            KIND_NET_DIAL,
515:    pub fn net_open_stream(
521:            KIND_NET_STREAM_OPEN,
539:    pub fn net_stream_send(
547:            KIND_NET_STREAM_SEND,
570:    pub fn net_streams(&mut self) -> io::Result<NetStreamsReply> {
571:        self.send(KIND_NET_STREAMS, serde_json::Value::Null)?;
585:    pub fn net_stream_subscribe(&mut self, stream_id: u64, from_seq: u64) -> io::Result<()> {
588:            KIND_NET_STREAM_SUBSCRIBE,
596:    pub fn net_cursor(&self, stream_id: u64) -> Option<u64> {
604:    pub fn net_presence_subscribe(&mut self, from_seq: u64) -> io::Result<()> {
607:            KIND_NET_PRESENCE_SUBSCRIBE,
615:    pub fn presence_cursor(&self) -> Option<u64> {


grep -n "pub fn\|pub struct\|match\|=>" crates/spt/src/api/mod.rs | head -50 && echo ---- && grep -n "pub " crates/spt-store/src/info.rs | head -40

Skim api dispatch and info.json schema


38:pub struct ApiArgs {
176:pub struct AuthFlags {
180:    /// Session id proving association (matches the perch's info.json).
192:pub struct Ctx {
211:pub fn run(args: ApiArgs) -> i32 {
218:    let manifest = match &args.manifest {
219:        Some(path) => match load_manifest(path, &args.adapter) {
220:            Ok(m) => Some(m),
221:            Err(code) => return code,
223:        None => None,
227:    match args.cmd {
228:        ApiCmd::Capability => reporting::cmd_capability(&ctx),
231:        ApiCmd::Seed { pid, session_id } => startup::cmd_seed(&ctx, pid, session_id),
232:        ApiCmd::Listen { id, parent_pid, once } => {
235:        ApiCmd::Bind { id, bind_session, auth } => {
239:        ApiCmd::State { state, id, no_gate, auth } => {
242:        ApiCmd::EchoGate { action, id, auth } => {
245:        ApiCmd::Poll { id, include_deferred, auth } => gated(&id, &auth, |id| {
249:        ApiCmd::WorkerStart { parent, id, auth } => {
252:        ApiCmd::WorkerStop { id, auth } => gated(&id, &auth, worker::cmd_worker_stop),
253:        ApiCmd::WorkerPoll { id, auth } => {
257:        ApiCmd::Boundary { mode, id, to_session, auth } => {
260:        ApiCmd::SessionEnd { id, erase, auth } => {
263:        ApiCmd::Presence { id, auth } => gated(&id, &auth, reporting::cmd_presence),
264:        ApiCmd::HistoryLog { id, auth } => gated(&id, &auth, reporting::cmd_history_log),
265:        ApiCmd::Shutdown { id, auth } => {
269:        ApiCmd::Emit { id, r#type, auth } => {
275:/// Load + validate a manifest, checking it matches the declared adapter_name.
300:    match authenticate(target_id, &auth.proof()) {
301:        AuthResult::Ok => f(target_id),
302:        AuthResult::NoEndpoint => {
306:        AuthResult::Refused => {
307:            eprintln!("AUTH_REFUSED:{target_id} (need --token or matching --session-id)");
----
30:pub enum PidValue {
38:pub struct InfoJson {
40:    pub id: String,
42:    pub started: String,
44:    pub pid: PidValue,
46:    pub session_id: String,
48:    pub state: String,
50:    pub parent_pid: Option<u32>,
53:    pub cwd: Option<String>,
61:    pub status: Option<String>,
69:    pub last_active_ms: Option<u64>,
74:    pub fn new(id: &str, started: &str, pid: u32, session_id: &str, state: &str) -> InfoJson {
94:pub fn set_status(perch_path: &Path, status: &str) -> std::io::Result<()> {
107:pub fn set_last_active(perch_path: &Path, now_ms: u64) -> std::io::Result<()> {
121:pub fn write_info(perch_path: &Path, info: &InfoJson) -> std::io::Result<()> {
132:pub fn read_info(perch_path: &Path) -> Option<InfoJson> {
150:pub fn read_pid(perch_path: &Path) -> Option<u32> {


ls crates/spt-daemon/tests && grep -n "pub fn" crates/spt/src/api/reporting.rs | head

List daemon tests; reporting fns


attach.rs
brain_swap.rs
broker.rs
daemon_e2e.rs
digest.rs
handoff.rs
idempotent.rs
netbroker.rs
netstream.rs
presence.rs
replicate.rs
wanmsg.rs
37:pub fn cmd_boundary(id: &str, mode: &str, to_session: &str) -> i32 {
61:pub fn cmd_session_end(id: &str, erase: bool) -> i32 {
102:pub fn cmd_presence(id: &str) -> i32 {
116:pub fn append_history(id: &str, record: &str) -> std::io::Result<usize> {
124:pub fn cmd_history_log(id: &str) -> i32 {
148:pub fn cmd_emit(id: &str, sensory_type: &str) -> i32 {
159:pub fn resolve_filedrops(manifest: Option<&Manifest>, id: &str) -> (Option<PathBuf>, Option<PathBuf>) {
175:pub fn env_injections(manifest: Option<&Manifest>) -> Vec<(String, String)> {
187:pub fn cmd_capability(ctx: &Ctx) -> i32 {