1	//! The broker↔brain message catalog: the kind discriminators and payload
2	//! shapes that ride inside the forward-compatible [`Envelope`].
3	//!
4	//! Split out from [`crate::frame`] (which owns the *envelope mechanics* and the
5	//! version handshake) so the message *vocabulary* can grow additively without
6	//! touching the wire-compat machinery. Every payload is a plain serde struct
7	//! with serde's default unknown-field tolerance, so a newer brain may add a
8	//! field and an older broker still decodes the message (KNOWN-HAZARDS 2.3).
9	//!
10	//! Bytes (PTY input/output) ride as base64 strings — JSON has no native byte
11	//! type, and base64 keeps the single envelope/codec path rather than forking a
12	//! binary frame format.
13	// [impl->REQ-DAEMON-2]
14
15	use base64::engine::general_purpose::STANDARD as B64;
16	use base64::Engine;
17	use serde::{Deserialize, Serialize};
18
19	use crate::frame::Envelope;
20
21	/// Brain→broker: spawn a child under a fresh broker-owned PTY.
22	pub const KIND_SPAWN: &str = "spawn";
23	/// Broker→brain: a spawn succeeded; carries the assigned session id + child pid.
24	pub const KIND_SPAWNED: &str = "spawned";
25	/// Brain→broker: (re)subscribe to a session's output from a given sequence.
26	pub const KIND_SUBSCRIBE: &str = "subscribe";
27	/// Brain→broker: write raw bytes to a session's input (send-keys).
28	pub const KIND_INPUT: &str = "input";
29	/// Brain→broker: resize a session's PTY.
30	pub const KIND_RESIZE: &str = "resize";
31	/// Brain→broker: terminate a session's child.
32	pub const KIND_KILL: &str = "kill";
33	/// Broker→brain: a chunk of a session's output (pushed as the child produces it).
34	pub const KIND_OUTPUT: &str = "output";
35	/// Broker→brain: a session's child exited.
36	pub const KIND_EXIT: &str = "exit";
37	/// Broker→brain: an idempotent effect (an `input` carrying an `op_id`) is durably
38	/// applied **or** was already applied (deduped). The exactly-once ack: a brain
39	/// learns its effect landed, and a replay after a crash is acknowledged the same
40	/// way so the brain can retire it (B5 / KNOWN-HAZARDS 7.2).
41	pub const KIND_APPLIED: &str = "applied";
42	/// Broker→brain: a command failed (carries an operator-facing message).
43	pub const KIND_ERROR: &str = "error";
44
45	/// `spawn` payload — the program + args + initial geometry. The broker spawns
46	/// it under a PTY (no shell interpretation; argv is passed through).
47	#[derive(Debug, Clone, Serialize, Deserialize)]
48	pub struct SpawnReq {
49	    /// The program to exec (resolved on `PATH` by the OS spawn).
50	    pub program: String,
51	    /// Its arguments, verbatim.
52	    #[serde(default)]
53	    pub args: Vec<String>,
54	    /// Initial PTY rows.
55	    pub rows: u16,
56	    /// Initial PTY cols.
57	    pub cols: u16,
58	}
59
60	/// `spawned` payload — the broker-minted session id + the child's OS pid.
61	#[derive(Debug, Clone, Serialize, Deserialize)]
62	pub struct Spawned {
63	    /// The id the brain uses to address this session in later commands.
64	    pub session_id: u64,
65	    /// The child's OS pid, if the backend exposes one.
66	    #[serde(default)]
67	    pub pid: Option<u32>,
68	}
69
70	/// `subscribe` payload — attach this connection as a session's output
71	/// subscriber, replaying buffered chunks with `seq >= from_seq` before
72	/// streaming live ones. A reconnecting (handed-off) brain passes its
73	/// last-seen `seq + 1` here for gapless, exactly-once resume (2.3 / Spike #1).
74	#[derive(Debug, Clone, Serialize, Deserialize)]
75	pub struct SubscribeReq {
76	    /// Target session.
77	    pub session_id: u64,
78	    /// Lowest output sequence the subscriber still needs (replay floor).
79	    pub from_seq: u64,
80	}
81
82	/// `input` payload — bytes to inject into a session's stdin (base64).
83	#[derive(Debug, Clone, Serialize, Deserialize)]
84	pub struct InputReq {
85	    /// Target session.
86	    pub session_id: u64,
87	    /// The raw input bytes, base64-encoded.
88	    pub data_b64: String,
89	    /// The durable op id for exactly-once delivery (B5). `Some(id)` routes the
90	    /// write through the broker's [`crate::effect::EffectJournal`]: a replay of
91	    /// the same `(session_id, id)` after a brain crash is deduped, and the broker
92	    /// replies [`KIND_APPLIED`]. `None` is the legacy direct write (no dedup) —
93	    /// kept for callers that don't carry a durable source id. Defaulted so an
94	    /// older brain's `input` (no `op_id`) still decodes (2.3 forward-compat).
95	    #[serde(default, skip_serializing_if = "Option::is_none")]
96	    pub op_id: Option<u64>,
97	}
98
99	/// `resize` payload.
100	#[derive(Debug, Clone, Serialize, Deserialize)]
101	pub struct ResizeReq {
102	    /// Target session.
103	    pub session_id: u64,
104	    /// New rows.
105	    pub rows: u16,
106	    /// New cols.
107	    pub cols: u16,
108	}
109
110	/// `kill` payload.
111	#[derive(Debug, Clone, Serialize, Deserialize)]
112	pub struct KillReq {
113	    /// Target session.
114	    pub session_id: u64,
115	}
116
117	/// `output` event — a chunk of a session's PTY output (base64).
118	#[derive(Debug, Clone, Serialize, Deserialize)]
119	pub struct OutputEvent {
120	    /// Originating session.
121	    pub session_id: u64,
122	    /// The broker-assigned, per-session monotonic sequence of this chunk. The
123	    /// resume cursor: a reconnecting brain subscribes from its last seen
124	    /// `seq + 1`, and dedups by ignoring any `seq` it has already processed.
125	    pub seq: u64,
126	    /// The raw output bytes, base64-encoded.
127	    pub data_b64: String,
128	}
129
130	/// `exit` event — a session's child has been reaped.
131	#[derive(Debug, Clone, Serialize, Deserialize)]
132	pub struct ExitEvent {
133	    /// Originating session.
134	    pub session_id: u64,
135	    /// The child's exit code, if known.
136	    #[serde(default)]
137	    pub code: Option<i32>,
138	}
139
140	/// `applied` event — an idempotent effect is durably applied or was already
141	/// applied. Carries the durable id so the brain can correlate it to the effect
142	/// it issued (or replayed). The exactly-once ack (B5).
143	#[derive(Debug, Clone, Serialize, Deserialize)]
144	pub struct AppliedEvent {
145	    /// The session the effect targeted.
146	    pub session_id: u64,
147	    /// The durable op id of the effect.
148	    pub op_id: u64,
149	    /// Whether the effect ran on this delivery (`true`) or was deduped because it
150	    /// had already landed (`false`). Informational — both mean "durably applied".
151	    #[serde(default)]
152	    pub applied_now: bool,
153	}
154
155	/// `error` event — a command the broker could not satisfy.
156	#[derive(Debug, Clone, Serialize, Deserialize)]
157	pub struct ErrorEvent {
158	    /// Operator-facing description.
159	    pub message: String,
160	}
161
162	/// Encode raw bytes for an `input`/`output` payload.
163	pub fn encode_bytes(bytes: &[u8]) -> String {
164	    B64.encode(bytes)
165	}
166
167	/// Decode an `input`/`output` payload's base64 back to raw bytes.
168	pub fn decode_bytes(s: &str) -> Result<Vec<u8>, base64::DecodeError> {
169	    B64.decode(s)
170	}
171
172	/// Build an output event envelope for `session_id` / `seq` carrying `bytes`.
173	pub fn output_envelope(session_id: u64, seq: u64, bytes: &[u8]) -> Envelope {
174	    Envelope::new(
175	        KIND_OUTPUT,
176	        serde_json::to_value(OutputEvent { session_id, seq, data_b64: encode_bytes(bytes) })
177	            .expect("OutputEvent serializes"),
178	    )
179	}
180
181	/// Build an `applied` ack envelope for `session_id` / `op_id`; `applied_now`
182	/// distinguishes a fresh apply from a deduped replay.
183	pub fn applied_envelope(session_id: u64, op_id: u64, applied_now: bool) -> Envelope {
184	    Envelope::new(
185	        KIND_APPLIED,
186	        serde_json::to_value(AppliedEvent { session_id, op_id, applied_now })
187	            .expect("AppliedEvent serializes"),
188	    )
189	}
190
191	#[cfg(test)]
192	mod tests {
193	    use super::*;
194	    use serde_json::json;
195
196	    // [unit->REQ-DAEMON-2] bytes round-trip through the base64 payload encoding.
197	    #[test]
198	    fn bytes_round_trip_through_payload() {
199	        let raw = b"\x1b[6n binary\x00\xff bytes";
200	        let enc = encode_bytes(raw);
201	        assert_eq!(decode_bytes(&enc).unwrap(), raw);
202	    }
203
204	    // [unit->REQ-DAEMON-2] a spawn request decodes; a newer brain's extra field
205	    // is tolerated (forward-compat carries into the message catalog, not just
206	    // the envelope).
207	    #[test]
208	    fn spawn_req_tolerates_unknown_field() {
209	        let wire = json!({
210	            "program": "cat",
211	            "args": [],
212	            "rows": 24,
213	            "cols": 80,
214	            "env_added_in_a_future_release": { "TERM": "xterm" }
215	        });
216	        let req: SpawnReq = serde_json::from_value(wire).unwrap();
217	        assert_eq!(req.program, "cat");
218	        assert_eq!(req.rows, 24);
219	    }
220
221	    // [unit->REQ-DAEMON-2] the output-envelope helper produces a decodable event.
222	    #[test]
223	    fn output_envelope_round_trips() {
224	        let env = output_envelope(7, 42, b"hello");
225	        assert_eq!(env.kind, KIND_OUTPUT);
226	        let ev: OutputEvent = serde_json::from_value(env.payload).unwrap();
227	        assert_eq!(ev.session_id, 7);
228	        assert_eq!(ev.seq, 42);
229	        assert_eq!(decode_bytes(&ev.data_b64).unwrap(), b"hello");
230	    }
231	}
232

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

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

use crate::frame::Envelope;

/// Brain→broker: spawn a child under a fresh broker-owned PTY.
pub const KIND_SPAWN: &str = "spawn";
/// Broker→brain: a spawn succeeded; carries the assigned session id + child pid.
pub const KIND_SPAWNED: &str = "spawned";
/// Brain→broker: (re)subscribe to a session's output from a given sequence.
pub const KIND_SUBSCRIBE: &str = "subscribe";
/// Brain→broker: write raw bytes to a session's input (send-keys).
pub const KIND_INPUT: &str = "input";
/// Brain→broker: resize a session's PTY.
pub const KIND_RESIZE: &str = "resize";
/// Brain→broker: terminate a session's child.
pub const KIND_KILL: &str = "kill";
/// Broker→brain: a chunk of a session's output (pushed as the child produces it).
pub const KIND_OUTPUT: &str = "output";
/// Broker→brain: a session's child exited.
pub const KIND_EXIT: &str = "exit";
/// Broker→brain: an idempotent effect (an `input` carrying an `op_id`) is durably
/// applied **or** was already applied (deduped). The exactly-once ack: a brain
/// learns its effect landed, and a replay after a crash is acknowledged the same
/// way so the brain can retire it (B5 / KNOWN-HAZARDS 7.2).
pub const KIND_APPLIED: &str = "applied";
/// Broker→brain: a command failed (carries an operator-facing message).
pub const KIND_ERROR: &str = "error";

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


NOTE FROM THE USER: proceed with nextsteps.


Read spt main + broker + relay + config + lifecycle to find daemon process entrypoint + how api dispatches.


crates\spt\src\cli.rs
crates\spt\src\testutil.rs
crates\spt\src\main.rs
crates\spt\src\api\auth.rs
crates\spt\src\api\reporting.rs
crates\spt\src\api\delivery.rs
crates\spt\src\api\mod.rs
crates\spt\src\api\startup.rs
crates\spt\src\api\worker.rs
crates\spt\src\api\live.rs
crates\spt\src\roster.rs


1	//! The thin, stateless in-session relay (B3) — the only residue of the interim
2	//! `api listen` runner that stays in the harness-hosted session (topology 1).
3	//!
4	//! Consolidation (REQ-DAEMON-1) moves the *lifecycle* — Psyche spawn, pulse,
5	//! echo, drop-ingest — into the daemon brain as scheduled in-process loops. What
6	//! remains running inside the agent's own session is just this relay: it forwards
7	//! inbound messages (the offline spool backlog + live poll deliveries) to a sink
8	//! (stdout for the real session) and holds **no state of its own**.
9	//!
10	//! Because it is stateless and every undelivered message is durable in the perch
11	//! spool, the relay is **freely killable and respawnable without losing state**:
12	//! a killed relay leaves its backlog in the spool, and a fresh relay re-drains it
13	//! on its next pump. That is the property the daemon needs — a routine that kills
14	//! the in-session relay (e.g. to swap it) never drops a message.
15	// [impl->REQ-DAEMON-1]
16
17	use std::path::{Path, PathBuf};
18	use std::sync::atomic::{AtomicBool, Ordering};
19	use std::time::Duration;
20
21	use spt_msg::listener::PollListener;
22	use spt_store::spool;
23
24	/// Poll window for the relay's blocking accept — bounds each cycle so the loop
25	/// stays responsive to a stop signal (mirrors the interim listener cadence).
26	pub const DEFAULT_RELAY_POLL: Duration = Duration::from_millis(200);
27
28	/// A stateless message relay for one perch: a poll listener bound to the id plus
29	/// the perch path it drains. It carries no message buffer — every byte it
30	/// forwards comes straight from the live poll or the durable spool.
31	pub struct Relay {
32	    id: String,
33	    perch_path: PathBuf,
34	    listener: PollListener,
35	}
36
37	impl Relay {
38	    /// Bind a relay for `id` draining `perch_path`, with the default poll window.
39	    pub fn bind(id: &str, perch_path: &Path) -> std::io::Result<Self> {
40	        Self::bind_with(id, perch_path, DEFAULT_RELAY_POLL)
41	    }
42
43	    /// Bind a relay with an explicit poll window.
44	    pub fn bind_with(id: &str, perch_path: &Path, poll: Duration) -> std::io::Result<Self> {
45	        let listener = PollListener::bind_blocking(id, poll)
46	            .map_err(|e| std::io::Error::other(e.to_string()))?;
47	        Ok(Relay { id: id.to_string(), perch_path: perch_path.to_path_buf(), listener })
48	    }
49
50	    /// The relayed endpoint id.
51	    pub fn id(&self) -> &str {
52	        &self.id
53	    }
54
55	    /// Drain the durable offline backlog (non-deferred spool rows) to `sink`.
56	    /// Idempotent across respawns: a fresh relay re-drains whatever a killed one
57	    /// left behind. Returns the number of messages forwarded.
58	    pub fn drain_backlog<F: FnMut(&str)>(&self, mut sink: F) -> usize {
59	        let msgs = spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default();
60	        let n = msgs.len();
61	        for m in &msgs {
62	            sink(m);
63	        }
64	        n
65	    }
66
67	    /// One relay cycle: forward a single live poll delivery (if one arrives in the
68	    /// poll window) then drain the offline backlog. Holds no state between calls.
69	    pub fn pump_once<F: FnMut(&str)>(&self, mut sink: F) {
70	        if let Some(msg) = self.listener.check_message_blocking() {
71	            sink(&msg);
72	        }
73	        self.drain_backlog(&mut sink);
74	    }
75
76	    /// Run the relay until `stop` is set, forwarding every delivery to `sink`.
77	    /// The blocking poll bounds each iteration, so a kill signal is honored within
78	    /// one poll window. State loss is impossible: the loop buffers nothing.
79	    pub fn run<F: FnMut(&str)>(&self, stop: &AtomicBool, mut sink: F) {
80	        // Drain whatever a predecessor relay left behind before streaming live.
81	        self.drain_backlog(&mut sink);
82	        while !stop.load(Ordering::Relaxed) {
83	            self.pump_once(&mut sink);
84	        }
85	    }
86	}
87
88	#[cfg(test)]
89	mod tests {
90	    use super::*;
91	    use crate::test_home::with_home;
92	    use spt_store::perch::{resolve_perch_path, ParentHint};
93
94	    fn perch_for(id: &str) -> PathBuf {
95	        let p = resolve_perch_path(id, ParentHint::Infer);
96	        std::fs::create_dir_all(&p).unwrap();
97	        p
98	    }
99
100	    #[test] // [unit->REQ-DAEMON-1] a respawned relay re-drains a killed relay's backlog — no loss.
101	    fn respawn_loses_no_spooled_message() {
102	        with_home(|_| {
103	            let perch = perch_for("doyle");
104	            spool::spool_message_at(&perch, "alice", "m1").unwrap();
105	            spool::spool_message_at(&perch, "alice", "m2").unwrap();
106
107	            // Relay A binds then is "killed" (dropped) WITHOUT pumping. The two
108	            // messages remain durable in the spool — the relay held no state.
109	            {
110	                let _a = Relay::bind("doyle", &perch).unwrap();
111	            }
112	            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 2);
113
114	            // Relay B (respawn) drains the full backlog — nothing was lost.
115	            let b = Relay::bind("doyle", &perch).unwrap();
116	            let mut got = Vec::new();
117	            let n = b.drain_backlog(|m| got.push(m.to_string()));
118	            assert_eq!(n, 2);
119	            assert!(got.iter().any(|m| m.contains("m1")));
120	            assert!(got.iter().any(|m| m.contains("m2")));
121	            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 0);
122	        });
123	    }
124
125	    #[test] // [unit->REQ-DAEMON-1] run() exits promptly when the stop flag is set.
126	    fn run_honors_stop() {
127	        with_home(|_| {
128	            let perch = perch_for("doyle");
129	            let relay = Relay::bind_with("doyle", &perch, Duration::from_millis(20)).unwrap();
130	            let stop = AtomicBool::new(true); // already stopped: drains once, returns.
131	            let mut got = Vec::new();
132	            spool::spool_message_at(&perch, "alice", "hi").unwrap();
133	            relay.run(&stop, |m| got.push(m.to_string()));
134	            assert_eq!(got.len(), 1, "the pre-stream backlog drain still fires");
135	        });
136	    }
137	}
138

//! The thin, stateless in-session relay (B3) — the only residue of the interim
//! `api listen` runner that stays in the harness-hosted session (topology 1).
//!
//! Consolidation (REQ-DAEMON-1) moves the *lifecycle* — Psyche spawn, pulse,
//! echo, drop-ingest — into the daemon brain as scheduled in-process loops. What
//! remains running inside the agent's own session is just this relay: it forwards
//! inbound messages (the offline spool backlog + live poll deliveries) to a sink
//! (stdout for the real session) and holds **no state of its own**.
//!
//! Because it is stateless and every undelivered message is durable in the perch
//! spool, the relay is **freely killable and respawnable without losing state**:
//! a killed relay leaves its backlog in the spool, and a fresh relay re-drains it
//! on its next pump. That is the property the daemon needs — a routine that kills
//! the in-session relay (e.g. to swap it) never drops a message.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use spt_msg::listener::PollListener;
use spt_store::spool;

/// Poll window for the relay's blocking accept — bounds each cycle so the loop
/// stays responsive to a stop signal (mirrors the interim listener cadence).
pub const DEFAULT_RELAY_POLL: Duration = Duration::from_millis(200);

/// A stateless message relay for one perch: a poll listener bound to the id plus
/// the perch path it drains. It carries no message buffer — every byte it
/// forwards comes straight from the live poll or the durable spool.
pub struct Relay {
    id: String,
    perch_path: PathBuf,
    listener: PollListener,
}

impl Relay {
    /// Bind a relay for `id` draining `perch_path`, with the default poll window.
    pub fn bind(id: &str, perch_path: &Path) -> std::io::Result<Self> {
        Self::bind_with(id, perch_path, DEFAULT_RELAY_POLL)
    }

    /// Bind a relay with an explicit poll window.
    pub fn bind_with(id: &str, perch_path: &Path, poll: Duration) -> std::io::Result<Self> {
        let listener = PollListener::bind_blocking(id, poll)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        Ok(Relay { id: id.to_string(), perch_path: perch_path.to_path_buf(), listener })
    }

    /// The relayed endpoint id.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// Drain the durable offline backlog (non-deferred spool rows) to `sink`.
    /// Idempotent across respawns: a fresh relay re-drains whatever a killed one
    /// left behind. Returns the number of messages forwarded.
    pub fn drain_backlog<F: FnMut(&str)>(&self, mut sink: F) -> usize {
        let msgs = spool::drain_non_deferred_at(&self.perch_path).unwrap_or_default();
        let n = msgs.len();
        for m in &msgs {
            sink(m);
        }
        n
    }

    /// One relay cycle: forward a single live poll delivery (if one arrives in the
    /// poll window) then drain the offline backlog. Holds no state between calls.
    pub fn pump_once<F: FnMut(&str)>(&self, mut sink: F) {
        if let Some(msg) = self.listener.check_message_blocking() {
            sink(&msg);
        }
        self.drain_backlog(&mut sink);
    }

    /// Run the relay until `stop` is set, forwarding every delivery to `sink`.
    /// The blocking poll bounds each iteration, so a kill signal is honored within
    /// one poll window. State loss is impossible: the loop buffers nothing.
    pub fn run<F: FnMut(&str)>(&self, stop: &AtomicBool, mut sink: F) {
        // Drain whatever a predecessor relay left behind before streaming live.
        self.drain_backlog(&mut sink);
        while !stop.load(Ordering::Relaxed) {
            self.pump_once(&mut sink);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_home::with_home;
    use spt_store::perch::{resolve_perch_path, ParentHint};

    fn perch_for(id: &str) -> PathBuf {
        let p = resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&p).unwrap();
        p
    }

    #[test] // [unit->REQ-DAEMON-1] a respawned relay re-drains a killed relay's backlog — no loss.
    fn respawn_loses_no_spooled_message() {
        with_home(|_| {
            let perch = perch_for("doyle");
            spool::spool_message_at(&perch, "alice", "m1").unwrap();
            spool::spool_message_at(&perch, "alice", "m2").unwrap();

            // Relay A binds then is "killed" (dropped) WITHOUT pumping. The two
            // messages remain durable in the spool — the relay held no state.
            {
                let _a = Relay::bind("doyle", &perch).unwrap();
            }
            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 2);

            // Relay B (respawn) drains the full backlog — nothing was lost.
            let b = Relay::bind("doyle", &perch).unwrap();
            let mut got = Vec::new();
            let n = b.drain_backlog(|m| got.push(m.to_string()));
            assert_eq!(n, 2);
            assert!(got.iter().any(|m| m.contains("m1")));
            assert!(got.iter().any(|m| m.contains("m2")));
            assert_eq!(spool::pending_count_non_deferred_at(&perch).unwrap(), 0);
        });
    }

    #[test] // [unit->REQ-DAEMON-1] run() exits promptly when the stop flag is set.
    fn run_honors_stop() {
        with_home(|_| {
            let perch = perch_for("doyle");
            let relay = Relay::bind_with("doyle", &perch, Duration::from_millis(20)).unwrap();
            let stop = AtomicBool::new(true); // already stopped: drains once, returns.
            let mut got = Vec::new();
            spool::spool_message_at(&perch, "alice", "hi").unwrap();
            relay.run(&stop, |m| got.push(m.to_string()));
            assert_eq!(got.len(), 1, "the pre-stream backlog drain still fires");
        });
    }
}


1	//! Durable daemon configuration (B3) — the configurable pulse period that
2	//! replaces the interim 5s constant.
3	//!
4	//! In the no-daemon interim model the pulse cadence was a hard-coded
5	//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
6	//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
7	//! period is **durable and configurable**, sourced from a small JSON file under
8	//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
9	//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
10	//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
11	//! from config, never a constant.
12	// [impl->REQ-DAEMON-1]
13
14	use std::path::{Path, PathBuf};
15	use std::time::Duration;
16
17	use serde::{Deserialize, Serialize};
18
19	/// The interim default pulse period carried forward as the config default. The
20	/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
21	/// override it per machine.
22	pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);
23
24	/// Default drop-file precedence protection window (epoch-ms), mirroring the
25	/// interim `spt_live` guard. Surfaced here so the one config source owns it.
26	pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;
27
28	/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
29	/// partial file still loads (forward/back-compat) — a missing field takes the
30	/// default. Stored as plain integers (ms) to keep the file human-editable.
31	#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32	struct DaemonConfigFile {
33	    #[serde(default, skip_serializing_if = "Option::is_none")]
34	    pulse_period_ms: Option<u64>,
35	    #[serde(default, skip_serializing_if = "Option::is_none")]
36	    protection_window_ms: Option<u64>,
37	}
38
39	/// Resolved daemon configuration. The brain's scheduled loops read their cadence
40	/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
41	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42	pub struct DaemonConfig {
43	    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
44	    pub pulse_period: Duration,
45	    /// The drop-file precedence protection window in epoch-ms (6.5).
46	    pub protection_window_ms: u64,
47	}
48
49	impl Default for DaemonConfig {
50	    fn default() -> Self {
51	        DaemonConfig {
52	            pulse_period: DEFAULT_PULSE_PERIOD,
53	            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
54	        }
55	    }
56	}
57
58	impl DaemonConfig {
59	    /// The canonical config path: `<spt_home>/daemon.json`.
60	    pub fn default_path() -> PathBuf {
61	        spt_store::perch::spt_home().join("daemon.json")
62	    }
63
64	    /// Load the daemon config from the canonical path, falling back to the
65	    /// default if the file is absent or unreadable.
66	    pub fn load() -> Self {
67	        Self::load_from(&Self::default_path())
68	    }
69
70	    /// Load from an explicit path. A missing file → default; a present-but-partial
71	    /// file fills only the named fields and defaults the rest; a malformed file
72	    /// degrades to the default (a broken config must never wedge the daemon).
73	    pub fn load_from(path: &Path) -> Self {
74	        let Ok(raw) = std::fs::read_to_string(path) else {
75	            return Self::default();
76	        };
77	        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
78	            Ok(f) => f,
79	            Err(_) => return Self::default(),
80	        };
81	        Self::from_file(file)
82	    }
83
84	    fn from_file(file: DaemonConfigFile) -> Self {
85	        let d = DaemonConfig::default();
86	        DaemonConfig {
87	            pulse_period: file
88	                .pulse_period_ms
89	                .map(Duration::from_millis)
90	                .unwrap_or(d.pulse_period),
91	            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
92	        }
93	    }
94
95	    /// Persist this config to an explicit path (for `spt` to write the knob).
96	    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
97	        let file = DaemonConfigFile {
98	            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
99	            protection_window_ms: Some(self.protection_window_ms),
100	        };
101	        let json = serde_json::to_string_pretty(&file)
102	            .map_err(|e| std::io::Error::other(e.to_string()))?;
103	        std::fs::write(path, json)
104	    }
105	}
106
107	#[cfg(test)]
108	mod tests {
109	    use super::*;
110
111	    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
112	    fn missing_file_is_default() {
113	        let dir = tempfile::tempdir().unwrap();
114	        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
115	        assert_eq!(cfg, DaemonConfig::default());
116	        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
117	    }
118
119	    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
120	    fn pulse_period_is_config_driven() {
121	        let dir = tempfile::tempdir().unwrap();
122	        let path = dir.path().join("daemon.json");
123	        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();
124
125	        let cfg = DaemonConfig::load_from(&path);
126	        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
127	        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
128	        // The unspecified field keeps its default.
129	        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
130	    }
131
132	    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
133	    fn malformed_file_degrades_to_default() {
134	        let dir = tempfile::tempdir().unwrap();
135	        let path = dir.path().join("daemon.json");
136	        std::fs::write(&path, "not json {{{").unwrap();
137	        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
138	    }
139
140	    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
141	    fn save_load_roundtrip() {
142	        let dir = tempfile::tempdir().unwrap();
143	        let path = dir.path().join("daemon.json");
144	        let cfg = DaemonConfig { pulse_period: Duration::from_millis(750), protection_window_ms: 99 };
145	        cfg.save_to(&path).unwrap();
146	        assert_eq!(DaemonConfig::load_from(&path), cfg);
147	    }
148	}
149

//! Durable daemon configuration (B3) — the configurable pulse period that
//! replaces the interim 5s constant.
//!
//! In the no-daemon interim model the pulse cadence was a hard-coded
//! `spt_live::pulse::DEFAULT_PULSE_PERIOD` (5s). Consolidation (REQ-DAEMON-1)
//! folds the pulse loop into the brain as a *scheduled* in-process loop whose
//! period is **durable and configurable**, sourced from a small JSON file under
//! the spt home (`<spt_home>/daemon.json`) and falling back to the default when
//! absent. Kept deliberately tiny — one struct, JSON via the crate's existing
//! `serde_json` dep (no new TOML edge) — so the brain's loop reads its cadence
//! from config, never a constant.
// [impl->REQ-DAEMON-1]

use std::path::{Path, PathBuf};
use std::time::Duration;

use serde::{Deserialize, Serialize};

/// The interim default pulse period carried forward as the config default. The
/// daemon now treats this as a *fallback*, not a constant: `daemon.json` may
/// override it per machine.
pub const DEFAULT_PULSE_PERIOD: Duration = Duration::from_secs(5);

/// Default drop-file precedence protection window (epoch-ms), mirroring the
/// interim `spt_live` guard. Surfaced here so the one config source owns it.
pub const DEFAULT_PROTECTION_WINDOW_MS: u64 = 2_000;

/// The on-disk shape (`<spt_home>/daemon.json`). Every field is optional so a
/// partial file still loads (forward/back-compat) — a missing field takes the
/// default. Stored as plain integers (ms) to keep the file human-editable.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonConfigFile {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pulse_period_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    protection_window_ms: Option<u64>,
}

/// Resolved daemon configuration. The brain's scheduled loops read their cadence
/// from `pulse_period`; the drop-ingest guard reads `protection_window_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DaemonConfig {
    /// The pulse loop period (config-driven; defaults to [`DEFAULT_PULSE_PERIOD`]).
    pub pulse_period: Duration,
    /// The drop-file precedence protection window in epoch-ms (6.5).
    pub protection_window_ms: u64,
}

impl Default for DaemonConfig {
    fn default() -> Self {
        DaemonConfig {
            pulse_period: DEFAULT_PULSE_PERIOD,
            protection_window_ms: DEFAULT_PROTECTION_WINDOW_MS,
        }
    }
}

impl DaemonConfig {
    /// The canonical config path: `<spt_home>/daemon.json`.
    pub fn default_path() -> PathBuf {
        spt_store::perch::spt_home().join("daemon.json")
    }

    /// Load the daemon config from the canonical path, falling back to the
    /// default if the file is absent or unreadable.
    pub fn load() -> Self {
        Self::load_from(&Self::default_path())
    }

    /// Load from an explicit path. A missing file → default; a present-but-partial
    /// file fills only the named fields and defaults the rest; a malformed file
    /// degrades to the default (a broken config must never wedge the daemon).
    pub fn load_from(path: &Path) -> Self {
        let Ok(raw) = std::fs::read_to_string(path) else {
            return Self::default();
        };
        let file: DaemonConfigFile = match serde_json::from_str(&raw) {
            Ok(f) => f,
            Err(_) => return Self::default(),
        };
        Self::from_file(file)
    }

    fn from_file(file: DaemonConfigFile) -> Self {
        let d = DaemonConfig::default();
        DaemonConfig {
            pulse_period: file
                .pulse_period_ms
                .map(Duration::from_millis)
                .unwrap_or(d.pulse_period),
            protection_window_ms: file.protection_window_ms.unwrap_or(d.protection_window_ms),
        }
    }

    /// Persist this config to an explicit path (for `spt` to write the knob).
    pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
        let file = DaemonConfigFile {
            pulse_period_ms: Some(self.pulse_period.as_millis() as u64),
            protection_window_ms: Some(self.protection_window_ms),
        };
        let json = serde_json::to_string_pretty(&file)
            .map_err(|e| std::io::Error::other(e.to_string()))?;
        std::fs::write(path, json)
    }
}

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

    #[test] // [unit->REQ-DAEMON-1] an absent config file yields the default period.
    fn missing_file_is_default() {
        let dir = tempfile::tempdir().unwrap();
        let cfg = DaemonConfig::load_from(&dir.path().join("nope.json"));
        assert_eq!(cfg, DaemonConfig::default());
        assert_eq!(cfg.pulse_period, DEFAULT_PULSE_PERIOD);
    }

    #[test] // [unit->REQ-DAEMON-1] the pulse period is config-driven, not a constant.
    fn pulse_period_is_config_driven() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, r#"{"pulse_period_ms": 250}"#).unwrap();

        let cfg = DaemonConfig::load_from(&path);
        assert_eq!(cfg.pulse_period, Duration::from_millis(250));
        assert_ne!(cfg.pulse_period, DEFAULT_PULSE_PERIOD, "must override the default");
        // The unspecified field keeps its default.
        assert_eq!(cfg.protection_window_ms, DEFAULT_PROTECTION_WINDOW_MS);
    }

    #[test] // [unit->REQ-DAEMON-1] a malformed config degrades to default, never wedges.
    fn malformed_file_degrades_to_default() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        std::fs::write(&path, "not json {{{").unwrap();
        assert_eq!(DaemonConfig::load_from(&path), DaemonConfig::default());
    }

    #[test] // [unit->REQ-DAEMON-1] save then load round-trips the configured period.
    fn save_load_roundtrip() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("daemon.json");
        let cfg = DaemonConfig { pulse_period: Duration::from_millis(750), protection_window_ms: 99 };
        cfg.save_to(&path).unwrap();
        assert_eq!(DaemonConfig::load_from(&path), cfg);
    }
}


Read spt cli + main + broker + lifecycle to see if a daemon process command exists + how auto-start would spawn it.


1	//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
2	//!
3	//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
4	//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
5	//! structured outcome, return an exit code. No messaging logic lives in this
6	//! layer.
7	//!
8	//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
9	//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
10	//! (clap, for unknown/missing args).
11	// [impl->REQ-MSG-2]
12
13	use std::io::{Read, Write};
14	use std::time::Duration;
15
16	use clap::{Parser, Subcommand};
17	use spt_msg::deliver::{self, SendOutcome};
18	use spt_msg::ready::ReadyAgent;
19	use spt_msg::ring::{self, RingOutcome};
20	use spt_store::perch::{self, ParentHint};
21	use spt_store::registry;
22
23	use crate::roster;
24
25	#[derive(Parser)]
26	#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
27	struct Cli {
28	    #[command(subcommand)]
29	    cmd: Cmd,
30	}
31
32	#[derive(Subcommand)]
33	enum Cmd {
34	    /// Send a message (body read from stdin). Fire-and-forget.
35	    Send {
36	        /// Target perch id. May be omitted when --reply-to is given.
37	        target: Option<String>,
38	        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
39	        #[arg(long)]
40	        from: Option<String>,
41	        /// Reply to this sender; sets the target and labels output REPLIED.
42	        #[arg(long)]
43	        reply_to: Option<String>,
44	        /// Spool only (deferred / hook channel) — no live wake.
45	        #[arg(long)]
46	        deferred: bool,
47	    },
48	    /// Send and block for a reply (body read from stdin; reply printed to stdout).
49	    Ring {
50	        /// Target perch id.
51	        target: String,
52	        /// Sender id (auto-detected from session if omitted).
53	        #[arg(long)]
54	        from: Option<String>,
55	        /// Seconds to wait for a reply before giving up.
56	        #[arg(long, default_value = "60")]
57	        timeout: u64,
58	    },
59	    /// Become reachable: register the perch and listen for messages (blocks).
60	    Ready {
61	        /// This agent's perch id.
62	        id: String,
63	    },
64	    /// Receive messages: drain backlog then listen, printing each to stdout.
65	    Poll {
66	        /// This agent's perch id.
67	        id: String,
68	        /// Run a single drain+receive cycle, then exit (legacy one-shot).
69	        #[arg(long)]
70	        once: bool,
71	    },
72	    /// List perches under the owlery (id, state, address, ready, alive).
73	    List,
74	    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
75	    Stop {
76	        /// Perch id to stop.
77	        id: String,
78	    },
79	    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
80	    Whoami,
81	    /// Harness-contract inbound surface (REQ-API-1): the entry points a
82	    /// harness's hooks fire to mutate on-disk SPT state.
83	    Api(crate::api::ApiArgs),
84	}
85
86	/// Parse args and dispatch. Returns the process exit code.
87	pub fn run() -> i32 {
88	    let cli = Cli::parse();
89	    match cli.cmd {
90	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
91	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
92	        Cmd::Ready { id } => run_listen(&id, false),
93	        Cmd::Poll { id, once } => run_listen(&id, once),
94	        Cmd::List => cmd_list(),
95	        Cmd::Stop { id } => cmd_stop(&id),
96	        Cmd::Whoami => cmd_whoami(),
97	        Cmd::Api(args) => crate::api::run(args),
98	    }
99	}
100
101	/// Read stdin to a string (message body source for send/ring).
102	fn read_stdin() -> String {
103	    let mut body = String::new();
104	    let _ = std::io::stdin().read_to_string(&mut body);
105	    body
106	}
107
108	/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
109	fn resolve_from(from: Option<String>) -> String {
110	    from.or_else(roster::detect_self_id).unwrap_or_default()
111	}
112
113	fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
114	    let is_reply = reply_to.is_some();
115	    // --reply-to provides the target when no positional target is given.
116	    let target = match target.or_else(|| reply_to.clone()) {
117	        Some(t) => t,
118	        None => {
119	            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
120	            return 2;
121	        }
122	    };
123	    let from = resolve_from(from);
124	    let body = read_stdin();
125	    let owlery = perch::owlery_dir();
126
127	    let outcome = if deferred {
128	        deliver::send_deferred(&target, &from, &body)
129	    } else {
130	        deliver::send(&target, &from, &body, &owlery)
131	    };
132
133	    match outcome {
134	        SendOutcome::Sent => {
135	            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
136	            0
137	        }
138	        SendOutcome::Queued => {
139	            let label = if deferred {
140	                "DEFERRED"
141	            } else if is_reply {
142	                "REPLIED(QUEUED)"
143	            } else {
144	                "QUEUED"
145	            };
146	            eprintln!("{}:{}", label, target);
147	            0
148	        }
149	        SendOutcome::NoPerch => {
150	            eprintln!("NO_PERCH:{} is not listening", target);
151	            1
152	        }
153	        SendOutcome::Empty => {
154	            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
155	            1
156	        }
157	    }
158	}
159
160	fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
161	    let from = resolve_from(from);
162	    if from.is_empty() {
163	        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
164	        return 1;
165	    }
166	    let body = read_stdin();
167	    let owlery = perch::owlery_dir();
168
169	    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
170	        RingOutcome::Replied(reply) => {
171	            print!("{}", reply);
172	            let _ = std::io::stdout().flush();
173	            0
174	        }
175	        RingOutcome::TimedOut => {
176	            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
177	            0
178	        }
179	        RingOutcome::Queued => {
180	            eprintln!("QUEUED:{}", target);
181	            0
182	        }
183	        RingOutcome::Delivered => {
184	            eprintln!("SENT:{} (reply will arrive on your listener)", target);
185	            0
186	        }
187	        RingOutcome::NoPerch => {
188	            eprintln!("NO_PERCH:{} is not listening", target);
189	            1
190	        }
191	        RingOutcome::Empty => {
192	            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
193	            1
194	        }
195	    }
196	}
197
198	/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
199	/// until the process is killed unless `once` is set. Each received message is
200	/// printed framed to stdout; status goes to stderr.
201	///
202	/// Interim M1 model: the perch is reachable only while this process runs. M3's
203	/// spt-daemon decouples registration from the live process.
204	fn run_listen(id: &str, once: bool) -> i32 {
205	    let (agent, backlog) = match ReadyAgent::start(id) {
206	        Ok(pair) => pair,
207	        Err(e) => {
208	            eprintln!("READY_FAIL:{}: {}", id, e);
209	            return 1;
210	        }
211	    };
212	    eprintln!("READY:{}", id);
213	    for msg in backlog {
214	        emit(&msg);
215	    }
216
217	    if once {
218	        if let Some(msg) = agent.recv() {
219	            emit(&msg);
220	        }
221	        for msg in agent.drain_spool() {
222	            emit(&msg);
223	        }
224	        return 0;
225	    }
226
227	    loop {
228	        if let Some(msg) = agent.recv() {
229	            emit(&msg);
230	        }
231	        for msg in agent.drain_spool() {
232	            emit(&msg);
233	        }
234	    }
235	}
236
237	/// Print a framed message to stdout and flush (one delivery per emit).
238	fn emit(msg: &str) {
239	    print!("{}", msg);
240	    let _ = std::io::stdout().flush();
241	}
242
243	fn cmd_list() -> i32 {
244	    let perches = roster::enumerate();
245	    if perches.is_empty() {
246	        eprintln!("(no perches)");
247	        return 0;
248	    }
249	    for p in perches {
250	        println!(
251	            "{:<28} {:<14} {:<22} ready={} alive={}",
252	            p.id,
253	            p.state,
254	            p.address.as_deref().unwrap_or("-"),
255	            p.ready,
256	            p.alive
257	        );
258	    }
259	    0
260	}
261
262	fn cmd_stop(id: &str) -> i32 {
263	    let owlery = perch::owlery_dir();
264	    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
265	    // unregister the address; spool.db and info.json are preserved.
266	    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
267	    let _ = registry::unregister_address(id, &owlery);
268	    if removed {
269	        eprintln!("STOPPED:{}", id);
270	    } else {
271	        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
272	    }
273	    0
274	}
275
276	fn cmd_whoami() -> i32 {
277	    match roster::detect_self_id() {
278	        Some(id) => {
279	            println!("{}", id);
280	            0
281	        }
282	        None => {
283	            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
284	            1
285	        }
286	    }
287	}
288
289	#[cfg(test)]
290	mod tests {
291	    use super::*;
292
293	    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
294	        Cli::try_parse_from(args)
295	    }
296
297	    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
298	    #[test]
299	    fn subcommands_parse() {
300	        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
301	        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
302	        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
303	        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
304	        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
305	        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
306	        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
307	        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
308	    }
309
310	    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
311	    #[test]
312	    fn ring_timeout_defaults() {
313	        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
314	            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
315	            _ => panic!("expected Ring"),
316	        }
317	    }
318
319	    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
320	    #[test]
321	    fn send_target_optional_with_reply_to() {
322	        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
323	            Cmd::Send { target, reply_to, .. } => {
324	                assert!(target.is_none());
325	                assert_eq!(reply_to.as_deref(), Some("alice"));
326	            }
327	            _ => panic!("expected Send"),
328	        }
329	    }
330
331	    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
332	    #[test]
333	    fn invalid_usage_errors() {
334	        assert!(parse(&["spt", "bogus"]).is_err());
335	        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
336	    }
337	}
338

//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
//!
//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
//! structured outcome, return an exit code. No messaging logic lives in this
//! layer.
//!
//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
//! (clap, for unknown/missing args).
// [impl->REQ-MSG-2]

use std::io::{Read, Write};
use std::time::Duration;

use clap::{Parser, Subcommand};
use spt_msg::deliver::{self, SendOutcome};
use spt_msg::ready::ReadyAgent;
use spt_msg::ring::{self, RingOutcome};
use spt_store::perch::{self, ParentHint};
use spt_store::registry;

use crate::roster;

#[derive(Parser)]
#[command(name = "spt", version, about = "spt-core local messaging", long_about = None)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Send a message (body read from stdin). Fire-and-forget.
    Send {
        /// Target perch id. May be omitted when --reply-to is given.
        target: Option<String>,
        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Reply to this sender; sets the target and labels output REPLIED.
        #[arg(long)]
        reply_to: Option<String>,
        /// Spool only (deferred / hook channel) — no live wake.
        #[arg(long)]
        deferred: bool,
    },
    /// Send and block for a reply (body read from stdin; reply printed to stdout).
    Ring {
        /// Target perch id.
        target: String,
        /// Sender id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Seconds to wait for a reply before giving up.
        #[arg(long, default_value = "60")]
        timeout: u64,
    },
    /// Become reachable: register the perch and listen for messages (blocks).
    Ready {
        /// This agent's perch id.
        id: String,
    },
    /// Receive messages: drain backlog then listen, printing each to stdout.
    Poll {
        /// This agent's perch id.
        id: String,
        /// Run a single drain+receive cycle, then exit (legacy one-shot).
        #[arg(long)]
        once: bool,
    },
    /// List perches under the owlery (id, state, address, ready, alive).
    List,
    /// Soft-stop a perch: remove the ready marker + unregister (spool preserved).
    Stop {
        /// Perch id to stop.
        id: String,
    },
    /// Print this session's own perch id (from $OWL_SESSION_ID / $SPT_AGENT_ID).
    Whoami,
    /// Harness-contract inbound surface (REQ-API-1): the entry points a
    /// harness's hooks fire to mutate on-disk SPT state.
    Api(crate::api::ApiArgs),
}

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {
    let cli = Cli::parse();
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id } => run_listen(&id, false),
        Cmd::Poll { id, once } => run_listen(&id, once),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Api(args) => crate::api::run(args),
    }
}

/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}

/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
fn resolve_from(from: Option<String>) -> String {
    from.or_else(roster::detect_self_id).unwrap_or_default()
}

fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
    let is_reply = reply_to.is_some();
    // --reply-to provides the target when no positional target is given.
    let target = match target.or_else(|| reply_to.clone()) {
        Some(t) => t,
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    match outcome {
        SendOutcome::Sent => {
            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
            0
        }
        SendOutcome::Queued => {
            let label = if deferred {
                "DEFERRED"
            } else if is_reply {
                "REPLIED(QUEUED)"
            } else {
                "QUEUED"
            };
            eprintln!("{}:{}", label, target);
            0
        }
        SendOutcome::NoPerch => {
            eprintln!("NO_PERCH:{} is not listening", target);
            1
        }
        SendOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to send empty message to {}", target);
            1
        }
    }
}

fn cmd_ring(target: String, from: Option<String>, timeout: u64) -> i32 {
    let from = resolve_from(from);
    if from.is_empty() {
        eprintln!("NO_SELF: ring needs a sender id (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    match ring::ring(&target, &from, &body, Duration::from_secs(timeout), &owlery) {
        RingOutcome::Replied(reply) => {
            print!("{}", reply);
            let _ = std::io::stdout().flush();
            0
        }
        RingOutcome::TimedOut => {
            eprintln!("TIMEOUT:{} did not reply within {}s", target, timeout);
            0
        }
        RingOutcome::Queued => {
            eprintln!("QUEUED:{}", target);
            0
        }
        RingOutcome::Delivered => {
            eprintln!("SENT:{} (reply will arrive on your listener)", target);
            0
        }
        RingOutcome::NoPerch => {
            eprintln!("NO_PERCH:{} is not listening", target);
            1
        }
        RingOutcome::Empty => {
            eprintln!("EMPTY_MSG: refusing to ring {} with an empty message", target);
            1
        }
    }
}

/// Run the listener loop (REQ-MSG-3 lifecycle, driven from the CLI). Blocks
/// until the process is killed unless `once` is set. Each received message is
/// printed framed to stdout; status goes to stderr.
///
/// Interim M1 model: the perch is reachable only while this process runs. M3's
/// spt-daemon decouples registration from the live process.
fn run_listen(id: &str, once: bool) -> i32 {
    let (agent, backlog) = match ReadyAgent::start(id) {
        Ok(pair) => pair,
        Err(e) => {
            eprintln!("READY_FAIL:{}: {}", id, e);
            return 1;
        }
    };
    eprintln!("READY:{}", id);
    for msg in backlog {
        emit(&msg);
    }

    if once {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
        return 0;
    }

    loop {
        if let Some(msg) = agent.recv() {
            emit(&msg);
        }
        for msg in agent.drain_spool() {
            emit(&msg);
        }
    }
}

/// Print a framed message to stdout and flush (one delivery per emit).
fn emit(msg: &str) {
    print!("{}", msg);
    let _ = std::io::stdout().flush();
}

fn cmd_list() -> i32 {
    let perches = roster::enumerate();
    if perches.is_empty() {
        eprintln!("(no perches)");
        return 0;
    }
    for p in perches {
        println!(
            "{:<28} {:<14} {:<22} ready={} alive={}",
            p.id,
            p.state,
            p.address.as_deref().unwrap_or("-"),
            p.ready,
            p.alive
        );
    }
    0
}

fn cmd_stop(id: &str) -> i32 {
    let owlery = perch::owlery_dir();
    // Soft-cleanup (HAZARD-SOFT-CLEANUP 6.2): remove the ready marker +
    // unregister the address; spool.db and info.json are preserved.
    let removed = std::fs::remove_file(perch::resolve_ready_file(id, ParentHint::Infer)).is_ok();
    let _ = registry::unregister_address(id, &owlery);
    if removed {
        eprintln!("STOPPED:{}", id);
    } else {
        eprintln!("STOPPED:{} (no ready marker; address unregistered)", id);
    }
    0
}

fn cmd_whoami() -> i32 {
    match roster::detect_self_id() {
        Some(id) => {
            println!("{}", id);
            0
        }
        None => {
            eprintln!("UNKNOWN: no perch matches this session ($OWL_SESSION_ID / $SPT_AGENT_ID)");
            1
        }
    }
}

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

    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
        Cli::try_parse_from(args)
    }

    // [unit->REQ-MSG-2] the documented subcommands + arg shapes parse.
    #[test]
    fn subcommands_parse() {
        assert!(matches!(parse(&["spt", "send", "bob", "--from", "alice"]).unwrap().cmd, Cmd::Send { .. }));
        assert!(matches!(parse(&["spt", "send", "bob", "--deferred"]).unwrap().cmd, Cmd::Send { deferred: true, .. }));
        assert!(matches!(parse(&["spt", "ring", "bob", "--timeout", "5"]).unwrap().cmd, Cmd::Ring { .. }));
        assert!(matches!(parse(&["spt", "ready", "bob"]).unwrap().cmd, Cmd::Ready { .. }));
        assert!(matches!(parse(&["spt", "poll", "bob", "--once"]).unwrap().cmd, Cmd::Poll { once: true, .. }));
        assert!(matches!(parse(&["spt", "list"]).unwrap().cmd, Cmd::List));
        assert!(matches!(parse(&["spt", "stop", "bob"]).unwrap().cmd, Cmd::Stop { .. }));
        assert!(matches!(parse(&["spt", "whoami"]).unwrap().cmd, Cmd::Whoami));
    }

    // [unit->REQ-MSG-2] ring timeout defaults to 60s when unspecified.
    #[test]
    fn ring_timeout_defaults() {
        match parse(&["spt", "ring", "bob"]).unwrap().cmd {
            Cmd::Ring { timeout, .. } => assert_eq!(timeout, 60),
            _ => panic!("expected Ring"),
        }
    }

    // [unit->REQ-MSG-2] send target is optional (supplied via --reply-to instead).
    #[test]
    fn send_target_optional_with_reply_to() {
        match parse(&["spt", "send", "--reply-to", "alice"]).unwrap().cmd {
            Cmd::Send { target, reply_to, .. } => {
                assert!(target.is_none());
                assert_eq!(reply_to.as_deref(), Some("alice"));
            }
            _ => panic!("expected Send"),
        }
    }

    // [unit->REQ-MSG-2] unknown subcommand / missing required arg are usage errors.
    #[test]
    fn invalid_usage_errors() {
        assert!(parse(&["spt", "bogus"]).is_err());
        assert!(parse(&["spt", "ring"]).is_err()); // missing required target
    }
}


1	//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
2	//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
3	//! logic lives in the crate (clean-room separation per the M1 plan).
4	//!
5	//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
6	//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
7	//! the `api` group (REQ-API-1), dispatched from [`api`].
8	// [impl->REQ-MSG-2]
9
10	mod api;
11	mod cli;
12	mod roster;
13
14	#[cfg(test)]
15	mod testutil;
16
17	fn main() {
18	    let code = cli::run();
19	    std::process::exit(code);
20	}
21

//! `spt` — the single spt-core binary. A **thin** command layer over `spt-msg`:
//! it owns I/O (stdin/stdout/stderr) and process exit codes; all messaging
//! logic lives in the crate (clean-room separation per the M1 plan).
//!
//! Subcommands (REQ-MSG-2 stable surface): `send` · `ring` · `ready` · `poll` ·
//! `list` · `stop` · `whoami`. The harness-contract inbound surface lives under
//! the `api` group (REQ-API-1), dispatched from [`api`].
// [impl->REQ-MSG-2]

mod api;
mod cli;
mod roster;

#[cfg(test)]
mod testutil;

fn main() {
    let code = cli::run();
    std::process::exit(code);
}