Skip to main content

shep_core/protocol/
request.rs

1//! RPC frames: requests, responses, envelopes, and structured errors
2
3use core::fmt;
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::AppConfig;
8use crate::status::ProcStatus;
9
10/// Client's opening frame
11// wire format: changing this is a breaking change
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Hello {
14    /// Client crate version (semver string)
15    pub client_version: String,
16    /// [`crate::protocol::PROTOCOL_VERSION`] the client speaks
17    pub protocol: u32,
18}
19
20/// Daemon's handshake answer
21// wire format: changing this is a breaking change
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct HelloAck {
24    /// Daemon crate version
25    pub daemon_version: String,
26    /// Protocol version the daemon speaks
27    pub protocol: u32,
28    /// Daemon pid
29    pub pid: u32,
30}
31
32/// Serializable selector (mirror of [`crate::selector::ProcessSelector`];
33/// regex travels as its source string)
34// wire format: changing this is a breaking change
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
37pub enum SelectorSpec {
38    /// Every sheep
39    All,
40    /// By id
41    Id(u32),
42    /// By exact name
43    Name(String),
44    /// By regex source
45    Regex(String),
46    /// By fold name
47    Fold(String),
48}
49
50/// One RPC request (Phase 1 verb set; later phases extend)
51// wire format: changing existing variants is a breaking change
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54#[non_exhaustive]
55pub enum Request {
56    /// Liveness check
57    Ping,
58    /// Full flock listing
59    ListFlock,
60    /// Detailed info for matching sheep
61    Describe {
62        /// Which sheep
63        selector: SelectorSpec,
64    },
65    /// Register + start apps
66    Start {
67        /// App configs — the daemon MUST re-normalize (peer input is
68        /// untrusted); failures return [`RpcErrorCode::InvalidConfig`]
69        apps: Vec<AppConfig>,
70    },
71    /// Stop matching sheep (stay registered)
72    Stop {
73        /// Which sheep
74        selector: SelectorSpec,
75    },
76    /// Restart matching sheep
77    Restart {
78        /// Which sheep
79        selector: SelectorSpec,
80    },
81    /// Replace each matching sheep with a fresh instance of the same app, one
82    /// instance of an app at a time, so the app has a window in which it can
83    /// stay reachable across the swap
84    Reload {
85        /// Which sheep. No default anywhere in the stack — a reload replaces
86        /// running processes, so the operator names the target, exactly as
87        /// `stop`/`restart`/`delete` do (see `shep reload`).
88        selector: SelectorSpec,
89    },
90    /// Stop + deregister matching sheep
91    Delete {
92        /// Which sheep
93        selector: SelectorSpec,
94    },
95    /// Set how many instances one app runs (see `shep stock`).
96    ///
97    /// # Why a name and not a selector
98    ///
99    /// Every other verb here takes a [`SelectorSpec`], and this one
100    /// deliberately does not. `instances` is a per-app number and instance
101    /// slots are allocated against the same-name group
102    /// (`shep_daemon::assemble::instance_slots`), so a selector matching two
103    /// apps would have to mean either "four of each" or "four in total", and
104    /// neither reading is more obviously right than the other. A name has one
105    /// meaning.
106    ///
107    /// # Why absolute and not a delta
108    ///
109    /// There is no `+N`/`-N` form and there will not be one. An absolute count
110    /// is idempotent — run it twice, get the same flock — where two operators
111    /// sending `+2` against the same app get a number neither of them asked
112    /// for. This project's own trace notes also record a crash on pm2's
113    /// relative-remove path, and those notes exist so shep does not reproduce
114    /// what they record.
115    Scale {
116        /// The app's name, exactly as its config spells it. Not a selector: no
117        /// `all`, no regex, no `fold:`.
118        name: String,
119        /// How many instances the app has when this returns. `0` is refused
120        /// with [`RpcErrorCode::InvalidConfig`] — `normalize` rejects
121        /// `instances == 0` for every other path into the daemon, and `shep
122        /// delete` is the verb for removing an app.
123        count: u32,
124    },
125    /// Reopen every matched sheep's log files, for an external rotator that
126    /// has renamed them (`create`-mode rotation)
127    Reopen {
128        /// Which sheep
129        selector: SelectorSpec,
130    },
131    /// Empty every matched sheep's log files: flush what is still pending,
132    /// then truncate the recorded paths
133    Flush {
134        /// Which sheep. No default anywhere in the stack — this destroys
135        /// log data, so the operator names the target (see `shep flush`).
136        selector: SelectorSpec,
137    },
138    /// Send a named action to every matched sheep over its shepherd channel
139    /// and report what each app says back (see `shep trigger`).
140    Trigger {
141        /// Which sheep. No default anywhere in the stack, matching
142        /// `stop`/`restart`/`reload`/`delete`/`flush`: an operator names the
143        /// target rather than trigger an action against the whole flock by
144        /// accident.
145        selector: SelectorSpec,
146        /// The action name. Free-form — the daemon never declares, parses,
147        /// or validates it; an app that does not recognize the name is
148        /// expected to say so in its own reply rather than stay silent.
149        action: String,
150        /// Argument text for the action, passed through to the app
151        /// verbatim. One opaque string, not structured data: the daemon
152        /// holds no schema for it, matching the shepherd channel's own
153        /// `action` message this ultimately becomes.
154        params: Option<String>,
155    },
156    /// Deliver one signal to every matched sheep's OWN process — never its
157    /// process group (see `shep signal`).
158    Signal {
159        /// Which sheep. No default anywhere in the stack, matching every
160        /// other verb that reaches a running process: an operator names the
161        /// target rather than signal the whole flock by accident.
162        selector: SelectorSpec,
163        /// The signal's name, as
164        /// [`OperatorSignal`](crate::signals::OperatorSignal) spells it — the
165        /// `SIG` prefix and the case are both optional.
166        ///
167        /// A `String` rather than the enum, for the reason
168        /// [`AppConfig::kill_signal`](crate::config::AppConfig::kill_signal)
169        /// is one: the wire stays plain text a person can read in a capture,
170        /// and the daemon re-validates regardless, because peer input is
171        /// untrusted. A name outside the grammar answers
172        /// [`RpcErrorCode::InvalidConfig`].
173        signal: String,
174    },
175    /// Write one line to every matched sheep's stdin (see `shep whisper`).
176    SendLine {
177        /// Which sheep. No default, matching every other verb that reaches a
178        /// running process.
179        selector: SelectorSpec,
180        /// The line, WITHOUT its terminator — the shepherd appends exactly one
181        /// `\n` when it writes. Carrying the terminator here would leave "did
182        /// the caller include one" as a question every hop has to re-answer,
183        /// and a caller that included two would send an empty line the app
184        /// never asked for.
185        ///
186        /// A line containing an embedded newline is refused
187        /// ([`RpcErrorCode::InvalidConfig`]): it would deliver two commands
188        /// where the operator typed one.
189        line: String,
190    },
191    /// Write the muster roll now, bypassing the snapshot writer's debounce
192    SaveRoll,
193    /// Assemble the flock from the muster roll on disk: start every app the
194    /// roll recorded running, leaving every app the flock already has exactly
195    /// as it stands
196    Muster,
197    /// Ask for one dog's `[dog.<name>]` section, as the dog itself parses it
198    DogConfig {
199        /// The dog's name — the config key, not a selector
200        name: String,
201    },
202    /// Start one dog now, marking it as coming from `source`
203    EnableDog {
204        /// The dog's name
205        name: String,
206        /// Where its binary comes from
207        source: DogSource,
208    },
209    /// Stop and deregister one dog
210    ///
211    /// Answers [`Response::Deleted`], the same reply `Delete` gives: disabling
212    /// deregisters exactly as `Delete` does, so this is the same fact and not
213    /// a coincidence of shape. A variant of its own (`DogDisabled`, say) would
214    /// carry nothing `Deleted` does not.
215    DisableDog {
216        /// The dog's name
217        name: String,
218    },
219    /// Graceful daemon shutdown
220    KillDaemon,
221    /// Subscribe this connection to bus topics (glob patterns)
222    Subscribe {
223        /// Topic globs, e.g. `process.*`
224        topics: Vec<String>,
225    },
226}
227
228/// Where a dog came from: this binary, or one an operator adopted.
229///
230/// The one thing an operator wants when a dog misbehaves, which is why it
231/// is a column rather than a detail. Carried on [`ProcessInfo::dog`], so a
232/// listing distinguishes the two populations without a second request.
233///
234/// `#[non_exhaustive]`: a future source — a dog fetched from a registry,
235/// say — must not need a protocol version bump (IR-20).
236// wire format: changing existing variants is a breaking change
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(tag = "kind", rename_all = "snake_case")]
239#[non_exhaustive]
240pub enum DogSource {
241    /// An argv branch of the shep binary itself (`shep dog <name>`).
242    BuiltIn,
243    /// A binary an operator adopted, run at the daemon's own trust level.
244    Adopted {
245        /// The binary's path, exactly as the operator gave it to `adopt`.
246        path: String,
247    },
248}
249
250/// One process the OS reports as a descendant of a sheep.
251///
252/// # What this is not
253///
254/// It is **not** the set of processes that die with the sheep, and nothing here
255/// should be read as promising that. The list is built by walking the OS's
256/// parent-pid links; the stop ladder acts on the process GROUP, and the two
257/// units diverge in both directions — a lamb that forks and exits leaves its
258/// own children re-parented to init, out of this list and still in the group,
259/// while a `setsid()` grandchild stays in this list and leaves the group.
260/// shep-daemon's `limits` module doc has the full account, and it is the
261/// authority; this is a pointer to it, not a second copy free to drift.
262///
263/// # Why a name and not a command line
264///
265/// `name` is the executable's name as the OS reports it (`node`, `sh`,
266/// `python3`), never its argument vector. A process's argv routinely carries
267/// credentials — a `--password=` flag, a URL with a token in the query string —
268/// and this field rides in `shep describe --format json`, which is output
269/// people paste into bug reports. A pid alone would be safe too, and was
270/// considered; it was rejected because a tree of bare integers sends the
271/// operator to `ps`, which is the work the tree exists to save.
272///
273/// # Why no memory figure
274///
275/// The sheep's own row already reports its whole tree's resident size
276/// ([`ProcessInfo::memory_bytes`]), and a per-lamb breakdown is a profiler's
277/// job. `deferred.md`'s note on this struct's growth asks for exactly this
278/// restraint.
279///
280/// `#[non_exhaustive]`: shep-core is a published library, this type is new, and
281/// the two obvious next fields (a parent pid, so a deep tree can be nested
282/// rather than flattened; a start time) would otherwise be breaking additions
283/// (IR-20). Build one with [`Self::new`].
284// wire format: changing this is a breaking change
285#[non_exhaustive]
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287pub struct Lamb {
288    /// The lamb's own pid.
289    pub pid: u32,
290    /// The executable's name, as the OS reports it. Never its command line.
291    pub name: String,
292}
293
294impl Lamb {
295    /// One lamb.
296    ///
297    /// A plain constructor rather than a builder, unlike [`ProcessInfo`]: both
298    /// fields are required and neither is optional or derived, which is the
299    /// case a builder buys nothing for.
300    #[must_use]
301    pub fn new(pid: u32, name: impl Into<String>) -> Self {
302        Self {
303            pid,
304            name: name.into(),
305        }
306    }
307}
308
309/// Why a sheep's process most recently stopped existing under this daemon.
310///
311/// Not shep-daemon's own `ExitOutcome` reused directly: that type lives
312/// behind the spawn-runner seam (`ProcessRunner::wait`, in shep-daemon's
313/// `runner` module) and is free to grow with whatever the real runner needs
314/// to observe next without dragging a breaking wire change behind it — this
315/// one only ever grows on its own say-so. The two happen to carry the same
316/// two fields today because the runner's own observation IS the honest exit
317/// outcome; shep-daemon converts one into the other at the point it records
318/// it (`Actor::handle_exited`), rather than this crate depending on
319/// shep-daemon's internals to reuse its type.
320///
321/// A struct, not two flat `Option<i32>` fields on [`ProcessInfo`] directly:
322/// with two flat fields, "this sheep has never exited under this daemon"
323/// and "it exited, killed by a signal this daemon did not name" are both
324/// all-`None`, and a reader cannot tell those apart. Nested behind
325/// [`ProcessInfo::last_exit`]'s own `Option`, that ambiguity moves up a
326/// level where it belongs: `None` there means "never exited"; `Some` means
327/// "exited, and here is what this daemon knows about it" — which itself
328/// mirrors the OS's own exited-normally/killed-by-signal split
329/// (`WIFEXITED`/`WIFSIGNALED`): ordinarily exactly one of `code`/`signal` is
330/// `Some`. A reader must not assume both can never be `None` together,
331/// though — that would still mean "this daemon recorded an exit; it could
332/// not characterize how" rather than "this sheep never exited", and this
333/// type does not forbid it.
334///
335/// No `#[non_exhaustive]`, unlike every other struct on this wire: those
336/// grow because a discriminator does (`ProcessInfo`'s own doc lists its
337/// four so far); `code`/`signal` is already the complete
338/// exited-normally/killed-by-signal split the runner exposes, this crate
339/// has no libc to derive a richer wait-status decomposition from even if it
340/// wanted one, and there is no forecast next field. Adding the attribute
341/// back later is a compatible change the day a real need appears (IR-16
342/// style); carrying it now on nothing but "might grow" is the exact
343/// speculative case IR-20 warns against.
344// wire format: changing this is a breaking change
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
346pub struct ExitInfo {
347    /// The process's own exit code, set on a normal exit (`WIFEXITED`).
348    pub code: Option<i32>,
349    /// The raw unix signal number that ended the process, set when it did
350    /// not exit on its own (`WIFSIGNALED`) — an operator's own `shep stop`
351    /// or `shep delete` included: the process still genuinely stopped by a
352    /// signal, and that stays true information even though shep, not a
353    /// crash, is what asked for it. Raw and platform-specific for the same
354    /// reason [`crate::signals::OperatorSignal`] carries no such accessor —
355    /// see that type's own module doc — so rendering this as a name
356    /// (`SIGKILL` rather than `9`) is a job for whichever OS-aware layer
357    /// reads it, never for this crate.
358    pub signal: Option<i32>,
359}
360
361/// Snapshot of one sheep for listings and events
362// wire format: changing this is a breaking change
363//
364// `out_file`/`err_file` are `Option<String>`, and both halves of that are
365// deliberate:
366//
367// String, not PathBuf. Every path already on this wire travels as a string
368// (`AppConfig::script`, `cwd`, `out_file`, `err_file`, all of which ride in
369// `Request::Start`), so this matches the established representation. It is
370// also the safer failure mode: serde's `PathBuf` impl REFUSES a non-UTF-8
371// path, and that refusal is not local — it aborts the whole `Reply`, so one
372// sheep with an odd log path would blank the entire `ListFlock` for every
373// other sheep. Lossy conversion daemon-side degrades exactly one field of
374// one sheep instead.
375//
376// Option, not a bare String. Semantically the daemon always resolves both
377// paths, so a required field is tempting. But the handshake only compares
378// `PROTOCOL_VERSION` (see shep-daemon's `server.rs`), and adding these
379// fields deliberately does NOT bump it — the evolution rule in this
380// module's parent says additive fields keep the version. A daemon built
381// before this field and a client built after it therefore both announce
382// protocol 1 and connect happily, and that daemon's replies carry no
383// `out_file` key at all. A required `String` would fail to deserialize
384// there, so a new client could not list against an old daemon. `None`
385// means precisely "this peer predates the field" — which readers must
386// render as unknown, NOT as "this sheep has no log file".
387//
388// `cpu_percent`/`memory_bytes` are optional for that same skew reason and
389// for one of their own: a sheep that is not running has no resource use to
390// report, and one that has been up for less than a sampling window has no
391// honest CPU figure. All three cases render as unknown, never as zero.
392//
393// No `Eq`, which every other wire struct in this module derives:
394// `cpu_percent` is an `f32` and floats are only partially ordered. Nothing
395// compares a `ProcessInfo` for total equality — `assert_eq!` needs only
396// `PartialEq`, and no listing is keyed on, hashed by, or sorted by a whole
397// row.
398/// `#[non_exhaustive]`: this struct has now grown a field in five separate
399/// phases (`out_file`/`err_file`, then `cpu_percent`/`memory_bytes`, then
400/// `dog`, then `lambs`, then `last_exit`) with no hand-edit sweep across the
401/// workspace for any of them — the attribute is paying for itself exactly
402/// as advertised. Corrected from an earlier version of this comment, which
403/// overstated `deferred.md`'s own `ProcessInfo` entry as a warning against
404/// growing this struct at all: what that entry actually defers is
405/// SPLITTING it into several smaller types, and calls this attribute plus
406/// [`ProcessInfo::builder`] "deliberately the opposite of forcing the split
407/// early" — i.e. exactly what makes a field like `last_exit` cheap to add
408/// for a concrete operator need, not a reason to withhold one. Use
409/// [`ProcessInfo::builder`] to construct one; the fields stay `pub`, so
410/// reading them and assigning to them are both unchanged.
411#[non_exhaustive]
412#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
413pub struct ProcessInfo {
414    /// Stable numeric id
415    pub id: u32,
416    /// Sheep name
417    pub name: String,
418    /// Lifecycle status
419    pub status: ProcStatus,
420    /// OS pid while running
421    pub pid: Option<u32>,
422    /// Restart count since registration
423    pub restarts: u32,
424    /// Milliseconds since last successful start
425    pub uptime_ms: u64,
426    /// Fold membership
427    pub fold: Option<String>,
428    /// Resolved stdout log path: the app's explicit
429    /// [`AppConfig::out_file`] when it set one, else the daemon-derived
430    /// default. `None` only when the peer daemon predates this field.
431    pub out_file: Option<String>,
432    /// Resolved stderr log path, resolved exactly as [`Self::out_file`]
433    pub err_file: Option<String>,
434    /// Tree CPU as a percentage of one core, over the window since the
435    /// daemon's last periodic sample. `None` when the sheep is not running,
436    /// when it has been up for less than one sampling window, or when the
437    /// peer daemon predates this field — all three of which a reader
438    /// renders as unknown, never as zero.
439    ///
440    /// A value over 100 is a tree using more than one core, not a bug.
441    pub cpu_percent: Option<f32>,
442    /// Tree resident set size in bytes, current as of the reply. `None`
443    /// under the same three conditions as [`Self::cpu_percent`], minus the
444    /// window one — memory needs no baseline.
445    pub memory_bytes: Option<u64>,
446    /// Set when this entry is a dog, naming where the dog came from;
447    /// `None` for a sheep.
448    ///
449    /// Unlike [`Self::cpu_percent`], `None` here does not need to enumerate
450    /// three cases. A daemon built before dogs existed has none, so "not a
451    /// dog" is the true answer whether this peer predates the field or the
452    /// entry is genuinely a sheep — there is no resource-usage-style claim
453    /// a stale zero could get wrong. Do not "fix" this into three cases.
454    pub dog: Option<DogSource>,
455    /// The processes the OS reports as descendants of this sheep, or `None`
456    /// when this reply did not walk for them.
457    ///
458    /// `None` covers two cases and is deliberately not a third: this reply is
459    /// not a `Describe` (only `Describe` walks — the walk costs a second pass
460    /// over the machine's process table, and a flock listing is the thing an
461    /// operator leaves running in a loop), or the peer daemon predates the
462    /// field. `Some(vec![])` is the third case, and the one that means what it
463    /// looks like: walked, and this sheep has no children.
464    ///
465    /// Read [`Lamb`]'s own doc before rendering this. The list is a parent-pid
466    /// walk and is NOT the set of processes a stop kills; any output built from
467    /// it has to say so where the operator will see it.
468    pub lambs: Option<Vec<Lamb>>,
469    /// How this sheep's process most recently stopped existing under this
470    /// daemon. `None` while it has never exited under this daemon — either
471    /// it has not been started yet, or it is still on its very first run —
472    /// and also when the peer daemon predates this field, the same skew
473    /// rule [`Self::out_file`] documents for itself.
474    ///
475    /// Sticky across a respawn, deliberately: this is the daemon's answer
476    /// to "why did it last stop", not "is it stopped right now" — `status`
477    /// and `pid` already answer that, and a sheep back `Online` after a
478    /// crash still has a true story to tell about the crash that restarted
479    /// it. It updates only on the next exit, never cleared by one starting
480    /// back up.
481    pub last_exit: Option<ExitInfo>,
482}
483
484impl ProcessInfo {
485    /// Starts a builder for one sheep's row.
486    ///
487    /// The three required arguments are the three fields no row can omit and
488    /// no reader can default: which sheep this is, what it is called, and
489    /// what state it is in. Everything else is optional, derived, or
490    /// meaningfully absent, which is exactly the shape a builder is for —
491    /// a nine-argument `new` would put `Option<String>, Option<String>,
492    /// Option<f32>, Option<u64>` next to each other at every call site and
493    /// invite a silent transposition the type system could not catch.
494    ///
495    /// No `#[must_use]` here: [`ProcessInfoBuilder`] already carries one,
496    /// which clippy's `double_must_use` lint treats as covering this
497    /// function's return too.
498    pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
499        ProcessInfoBuilder {
500            info: Self {
501                id,
502                name: name.into(),
503                status,
504                pid: None,
505                restarts: 0,
506                uptime_ms: 0,
507                fold: None,
508                out_file: None,
509                err_file: None,
510                cpu_percent: None,
511                memory_bytes: None,
512                dog: None,
513                lambs: None,
514                last_exit: None,
515            },
516        }
517    }
518}
519
520/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
521/// written as a struct literal outside this crate.
522///
523/// Every setter takes the field's own type, `Option` included, rather than
524/// the unwrapped value. That is deliberate and it is the difference between a
525/// straight port and a rewrite: the daemon already holds `Option<u32>` for a
526/// pid and `Option<f32>` for a CPU reading, so `.pid(entry.pid())` carries
527/// across unchanged where `.pid(u32)` would put an `if let` ladder at every
528/// call site. A setter is skipped, not passed `None`, when a row genuinely
529/// has nothing to say about that field.
530///
531/// Defaults for the skipped fields are the ones a not-yet-running sheep has:
532/// no pid, no uptime, no restarts, no resource reading, not a dog, never
533/// exited.
534#[derive(Debug, Clone)]
535#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
536pub struct ProcessInfoBuilder {
537    info: ProcessInfo,
538}
539
540impl ProcessInfoBuilder {
541    /// Sets the OS pid; `None` while the sheep is not running.
542    pub fn pid(mut self, pid: Option<u32>) -> Self {
543        self.info.pid = pid;
544        self
545    }
546
547    /// Sets the restart count since registration.
548    pub fn restarts(mut self, restarts: u32) -> Self {
549        self.info.restarts = restarts;
550        self
551    }
552
553    /// Sets milliseconds since the last successful start.
554    pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
555        self.info.uptime_ms = uptime_ms;
556        self
557    }
558
559    /// Sets fold membership.
560    pub fn fold(mut self, fold: Option<String>) -> Self {
561        self.info.fold = fold;
562        self
563    }
564
565    /// Sets the resolved stdout log path.
566    pub fn out_file(mut self, out_file: Option<String>) -> Self {
567        self.info.out_file = out_file;
568        self
569    }
570
571    /// Sets the resolved stderr log path.
572    pub fn err_file(mut self, err_file: Option<String>) -> Self {
573        self.info.err_file = err_file;
574        self
575    }
576
577    /// Sets tree CPU as a percentage of one core.
578    pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
579        self.info.cpu_percent = cpu_percent;
580        self
581    }
582
583    /// Sets tree resident set size in bytes.
584    pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
585        self.info.memory_bytes = memory_bytes;
586        self
587    }
588
589    /// Marks this row a dog and names where the dog came from.
590    pub fn dog(mut self, dog: Option<DogSource>) -> Self {
591        self.info.dog = dog;
592        self
593    }
594
595    /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
596    pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
597        self.info.lambs = lambs;
598        self
599    }
600
601    /// Sets how this sheep's process most recently stopped; `None` while it
602    /// has never exited under this daemon.
603    pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
604        self.info.last_exit = last_exit;
605        self
606    }
607
608    /// Finishes the row.
609    #[must_use]
610    pub fn build(self) -> ProcessInfo {
611        self.info
612    }
613}
614
615/// What happened when the daemon tried to deliver one sheep's triggered
616/// action.
617///
618/// `#[non_exhaustive]`: a future outcome — distinguishing a malformed reply
619/// from a well-formed one, say, or a second trigger already in flight for
620/// the same sheep — must not need a protocol version bump (IR-20).
621// wire format: changing existing variants is a breaking change
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(tag = "kind", rename_all = "snake_case")]
624#[non_exhaustive]
625pub enum ActionOutcome {
626    /// The app answered on the shepherd channel.
627    Replied {
628        /// The reply body, exactly as the app sent it.
629        body: String,
630    },
631    /// The sheep had no reachable shepherd channel for the daemon to
632    /// deliver the action over.
633    NoChannel,
634    /// The sheep is a reload drainee — mid-swap, on its way out — and the
635    /// daemon skipped it rather than deliver the action to a process
636    /// already being replaced.
637    Skipped,
638    /// The daemon delivered the action, but no reply arrived before the
639    /// app's configured action timeout elapsed.
640    TimedOut,
641}
642
643/// One matched sheep's row in a `Trigger` reply.
644///
645/// `EmptiedFile` (`crates/shep-cli/src/output/rows.rs`) is the precedent for
646/// a non-`ProcessInfo` row: a reply body has nowhere to live on
647/// [`ProcessInfo`], and [`Self::outcome`] is per-row rather than a
648/// whole-request refusal because spec §9's selector grammar (`all`,
649/// `/regex/`, `fold:`) makes a mixed flock the normal case — the same reason
650/// `Reopen`/`Flush` report per-item failure inside a success rather than
651/// failing the whole request.
652// wire format: changing this is a breaking change
653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654pub struct ActionReply {
655    /// The sheep's stable id.
656    pub id: u32,
657    /// The sheep's name.
658    pub name: String,
659    /// What happened when the daemon tried to deliver the action.
660    pub outcome: ActionOutcome,
661}
662
663/// What happened when the shepherd tried to deliver one signal.
664///
665/// `#[non_exhaustive]`: a future outcome — a sheep refused because it is a dog,
666/// say, or a delivery held while a stop ladder runs — must not need a protocol
667/// version bump (IR-20).
668// wire format: changing existing variants is a breaking change
669#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
670#[serde(tag = "kind", rename_all = "snake_case")]
671#[non_exhaustive]
672pub enum SignalOutcome {
673    /// The kernel accepted the signal for this sheep's pid.
674    ///
675    /// Says the signal was delivered, not that the app did anything with it.
676    /// A signal the app blocks, ignores, or has no handler for is `Delivered`
677    /// exactly like one it acts on — there is nothing on this path that could
678    /// tell the difference, and pretending otherwise would be the dishonest
679    /// half of an honest report.
680    Delivered,
681    /// The sheep is registered but has no live process to signal — stopped,
682    /// errored, or waiting out a restart backoff.
683    NotRunning,
684    /// The kernel refused the delivery; carries its reason (`ESRCH` for a
685    /// process reaped between the lookup and the syscall, `EPERM` for one this
686    /// daemon may not signal).
687    Failed {
688        /// The refusal, as the OS worded it.
689        reason: String,
690    },
691}
692
693/// One matched sheep's row in a `Signal` reply.
694///
695/// Shaped exactly like [`ActionReply`] and for the same reason: spec §9's
696/// selector grammar (`all`, `/regex/`, `fold:`) makes a mixed flock the normal
697/// case, so a per-row outcome beats a whole-request refusal that would leave
698/// the operator unable to tell which half was taken.
699// wire format: changing this is a breaking change
700#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
701pub struct SignalReply {
702    /// The sheep's stable id.
703    pub id: u32,
704    /// The sheep's name.
705    pub name: String,
706    /// What happened when the shepherd tried to deliver the signal.
707    pub outcome: SignalOutcome,
708}
709
710/// What happened when the shepherd tried to write one line to a sheep's stdin.
711///
712/// `#[non_exhaustive]`: a future outcome — a sheep refused because its pipe is
713/// backed up, say — must not need a protocol version bump (IR-20).
714// wire format: changing existing variants is a breaking change
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
716#[serde(tag = "kind", rename_all = "snake_case")]
717#[non_exhaustive]
718pub enum LineOutcome {
719    /// The line was written to the pipe and flushed.
720    ///
721    /// Says the bytes left the shepherd, not that the app read them. A pipe
722    /// holds 64 KiB before it blocks, so a short line to an app that never
723    /// reads its stdin is `Sent` — which is honest, because there is nothing
724    /// on this path that could tell the difference and a supervisor inventing
725    /// one would be guessing.
726    Sent,
727    /// The sheep has no stdin pipe: its config does not set `stdin = true`, or
728    /// it is not running.
729    ///
730    /// One outcome for two causes, deliberately. The row is read to answer
731    /// "why did my line not arrive", and both answers are "there is no pipe
732    /// here"; splitting them would put the operator in front of a distinction
733    /// with the same fix behind it. A sheep that is not running is visible as
734    /// such in `shep flock`, which is where that question belongs.
735    NoStdin,
736    /// The shepherd had a pipe and did not confirm a write to it; carries
737    /// why.
738    ///
739    /// Three shapes reach it: the write failed (the far end is gone —
740    /// normally the app exiting between the lookup and the write), the line
741    /// arrived to find the sheep's queue already full, or the write did not
742    /// finish inside the shepherd's own bound. The reason names which,
743    /// because the operator's next move differs.
744    ///
745    /// # The last shape does not promise the line was never written
746    ///
747    /// "Did not confirm", not "could not write", and the difference is the
748    /// operator's whole decision about retrying. A write that timed out is a
749    /// write the shepherd stopped WAITING for: the bytes may be part-written
750    /// into a pipe the app is not draining, and they land in full the moment
751    /// it does. There is no way to take them back — abandoning a write
752    /// halfway would leave a partial line in the pipe, which is worse than a
753    /// slow one.
754    ///
755    /// What the shepherd does do is drop a line still QUEUED behind that one
756    /// once its caller has given up, so retrying a `sendline` cannot pile
757    /// duplicates up behind a wedged pipe and deliver them together later.
758    /// The first line of a retry sequence is the one that can still arrive
759    /// late; treat a retry as a second command, not a repeat of the first.
760    NotWritten {
761        /// What went wrong, in plain English.
762        reason: String,
763    },
764}
765
766/// One matched sheep's row in a `SendLine` reply.
767///
768/// Same shape and same argument as [`ActionReply`] and [`SignalReply`]: spec
769/// §9's selector grammar makes a mixed flock the normal case, so an outcome
770/// per row beats a whole-request refusal.
771// wire format: changing this is a breaking change
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773pub struct LineReply {
774    /// The sheep's stable id.
775    pub id: u32,
776    /// The sheep's name.
777    pub name: String,
778    /// What happened.
779    pub outcome: LineOutcome,
780}
781
782/// A dog's `[dog.<name>]` config section, carried as TOML text.
783///
784/// This travels over the socket rather than the child's environment for
785/// exactly one reason: a dog's section routinely holds webhook credentials
786/// (a Discord or Slack URL with a bearer token embedded), and the socket
787/// path keeps that out of the process table and out of crash dumps. A
788/// derived `Debug` on [`Response`] would undo that the moment something
789/// logs a reply — see the manual `Debug` below, which prints only a length.
790///
791/// `#[serde(transparent)]` makes the wire representation identical to a
792/// bare `String`: this newtype changes nothing about
793/// [`crate::protocol::PROTOCOL_VERSION`] or the pinned snapshot fixtures.
794#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
795#[serde(transparent)]
796pub struct DogSectionToml(String);
797
798impl DogSectionToml {
799    /// The TOML text, empty when the file has no such section.
800    #[must_use]
801    pub fn as_str(&self) -> &str {
802        &self.0
803    }
804}
805
806impl From<String> for DogSectionToml {
807    fn from(toml: String) -> Self {
808        Self(toml)
809    }
810}
811
812impl core::ops::Deref for DogSectionToml {
813    type Target = str;
814
815    fn deref(&self) -> &str {
816        &self.0
817    }
818}
819
820/// Debug does not print the section body (IR-41) — see the type doc for why.
821/// Exact-string-tested below (`dog_section_toml_debug_does_not_leak`) so a
822/// future `#[derive(Debug)]` fails that test instead of silently reopening
823/// the leak.
824impl fmt::Debug for DogSectionToml {
825    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
826        write!(f, "DogSectionToml(<{} bytes>)", self.0.len())
827    }
828}
829
830/// One RPC response (pairs with [`Request`] variants)
831///
832/// Ten variants carry a bare `Vec<ProcessInfo>` (`Flock`, `Described`,
833/// `Started`, `Stopped`, `Restarted`, `Reloading`, `Scaled`, `Reopened`,
834/// `Flushed`, `Mustered`), and that repetition is intentional — do not
835/// collapse them into one. Each names which request it answers, which is what
836/// lets a variant diverge later without a protocol bump: `Reloading` already
837/// means an acceptance rather than a result, `Scaled` already means only the
838/// survivors on a scale-down rather than every matched row, and `Mustered`
839/// already means "every sheep of every restored app" rather than "what this
840/// call started". A single `Listing(Vec<ProcessInfo>)` would have to
841/// relitigate all three as a breaking change.
842// wire format: changing existing variants is a breaking change
843#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
844#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
845#[non_exhaustive]
846pub enum Response {
847    /// Answer to `Ping`
848    Pong,
849    /// Answer to `ListFlock`
850    Flock(Vec<ProcessInfo>),
851    /// Answer to `Describe`
852    Described(Vec<ProcessInfo>),
853    /// Answer to `Start`
854    Started(Vec<ProcessInfo>),
855    /// Answer to `Stop`
856    Stopped(Vec<ProcessInfo>),
857    /// Answer to `Restart`
858    Restarted(Vec<ProcessInfo>),
859    /// Answer to `Reload` — an ACCEPTANCE, not a result, and the only reply
860    /// in this enum carrying a flock listing that names one rather than
861    /// finished work. [`Self::ShuttingDown`] is an acceptance too, sent
862    /// before the daemon actually goes down, but it carries nothing.
863    ///
864    /// One instance costs a readiness wait plus a drain in the worst case, so
865    /// a clustered app outlasts any deadline a client is allowed to ask for.
866    /// The daemon therefore answers as soon as the reload is accepted, with
867    /// the matched sheep as they stood at that moment, and the swaps report
868    /// themselves on the bus — `process.reload`, `process.reloaded`,
869    /// `process.reload_abandoned`. A matched sheep with nothing to replace is
870    /// listed here as the no-op success it is, so this carries the same
871    /// matches `Describe` would.
872    Reloading(Vec<ProcessInfo>),
873    /// Answer to `Scale` — the app's instances that will REMAIN, one row each,
874    /// in instance-slot order.
875    ///
876    /// Scaling up, these are the instances that exist, the new ones included,
877    /// and the answer is complete.
878    ///
879    /// Scaling down, these are the survivors and the departing instances are
880    /// deliberately absent, even though they are still running their kill
881    /// ladders as this reply is written. The operator asked for a number; this
882    /// is that number of rows. Listing the departing ones as well would answer
883    /// a `scale web 2` with four rows, which is the one thing the reply must
884    /// not do. The departures report themselves on the bus as `process.delete`
885    /// — the same split `Reloading` already makes between an acceptance and
886    /// the swaps that follow it.
887    Scaled(Vec<ProcessInfo>),
888    /// Answer to `Delete` — ids removed
889    Deleted(Vec<u32>),
890    /// Answer to `Reopen` — every matched sheep, running or not. A sheep with
891    /// no live log pump has nothing to reopen and is reported as a success,
892    /// so this carries the same matches `Describe` would.
893    Reopened(Vec<ProcessInfo>),
894    /// Answer to `Flush` — one row per matched sheep, running or not, exactly
895    /// as [`Self::Reopened`].
896    ///
897    /// One row per SHEEP, not per file emptied. Several sheep can share one
898    /// log path (`merge_logs`, or an explicit `out_file` on a multi-instance
899    /// app), and the daemon truncates each distinct path once — but the
900    /// selector names sheep, so the answer names sheep, and the count here
901    /// matches what `Describe` would return for the same selector.
902    Flushed(Vec<ProcessInfo>),
903    /// Answer to `Trigger` — one [`ActionReply`] row per matched sheep,
904    /// carrying what each one answered rather than a flock listing:
905    /// `ProcessInfo` has nowhere to hold a reply body.
906    Triggered(Vec<ActionReply>),
907    /// Answer to `Signal` — one [`SignalReply`] row per matched sheep.
908    ///
909    /// Not a flock listing: what a caller wants back is per-instance delivery,
910    /// and [`ProcessInfo`] has nowhere to hold it. Same reasoning, and the
911    /// same row-shaped answer, as [`Self::Triggered`].
912    Signalled(Vec<SignalReply>),
913    /// Answer to `SendLine` — one [`LineReply`] row per matched sheep.
914    SentLine(Vec<LineReply>),
915    /// Answer to `SaveRoll`
916    RollSaved {
917        /// Absolute path of the roll the daemon wrote
918        path: String,
919        /// How many apps that roll records
920        apps: u32,
921    },
922    /// Answer to `Muster` — every sheep of every app the roll restored, not
923    /// only the ones this call spawned.
924    ///
925    /// The distinction is the whole point of the reply. Assembling a flock
926    /// that is already assembled starts nothing, so a listing of what this
927    /// call spawned would be empty there — indistinguishable from an empty
928    /// roll, which is the one outcome an operator needs to tell apart.
929    Mustered(Vec<ProcessInfo>),
930    /// Answer to `DogConfig` — the dog's own section, rendered back to TOML.
931    ///
932    /// `toml` is [`DogSectionToml`], not a bare `String`: this text
933    /// routinely carries webhook credentials, and the newtype's manual
934    /// `Debug` keeps them out of a `{:?}`-formatted `Response` — see that
935    /// type's docs for why the section travels over the socket at all.
936    DogSection {
937        /// The `[dog.<name>]` table as TOML text, empty when the file has
938        /// no such section
939        toml: DogSectionToml,
940    },
941    /// Answer to `EnableDog` — the dog as it stands now
942    DogStarted(ProcessInfo),
943    /// Answer to `Subscribe`
944    Subscribed,
945    /// Answer to `KillDaemon`
946    ShuttingDown,
947}
948
949/// A request frame
950// wire format: changing this is a breaking change
951#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
952pub struct Envelope {
953    /// Per-connection request id
954    pub id: u64,
955    /// Client-imposed deadline (daemon aborts work past it)
956    pub deadline_ms: Option<u64>,
957    /// The request
958    pub body: Request,
959}
960
961/// A reply frame
962///
963/// `result` uses serde's stock `Result` representation — the wire carries
964/// `{"Ok": ...}` / `{"Err": ...}` (capitalized keys). Deliberate, pinned by
965/// snapshot: stock serde beats a custom enum the client would convert anyway.
966// wire format: changing this is a breaking change
967#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
968pub struct Reply {
969    /// Echoes [`Envelope::id`]
970    pub id: u64,
971    /// The outcome
972    pub result: Result<Response, RpcError>,
973}
974
975/// Handshake outcome: `HelloAck` or a typed refusal (spec §6 —
976/// version skew is an error, not silence). Same `Ok`/`Err` wire shape
977/// as [`Reply::result`]; refusals use [`RpcErrorCode::ProtocolMismatch`].
978pub type HelloReply = Result<HelloAck, RpcError>;
979
980/// Structured RPC failure
981// wire format: changing this is a breaking change
982#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
983pub struct RpcError {
984    /// Machine-readable code
985    pub code: RpcErrorCode,
986    /// Human-readable message (plain English, no theme)
987    pub message: String,
988}
989
990/// Machine-readable RPC error codes
991// wire format: changing existing variants is a breaking change
992#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
993#[serde(rename_all = "snake_case")]
994#[non_exhaustive]
995pub enum RpcErrorCode {
996    /// Selector matched nothing
997    NotFound,
998    /// Config failed validation daemon-side
999    InvalidConfig,
1000    /// Spawn failed (exec error, permissions)
1001    SpawnFailed,
1002    /// Handshake protocol version mismatch
1003    ProtocolMismatch,
1004    /// Unexpected daemon-side failure
1005    Internal,
1006    /// The request's deadline expired before the daemon finished it
1007    DeadlineExceeded,
1008}
1009
1010impl RpcErrorCode {
1011    /// Every variant, for code that needs to iterate them all.
1012    ///
1013    /// `#[non_exhaustive]` forces a `_` arm on any match written outside
1014    /// this crate, which would silently swallow a variant added here and
1015    /// never updated there (shep-cli's exit-code mapping test is the
1016    /// motivating case — see `crates/shep-cli/src/exit.rs`). Downstream
1017    /// crates should iterate `ALL` instead of hand-writing their own list
1018    /// that the compiler can't check.
1019    ///
1020    /// Kept honest by a private `assert_all_lists_every_variant` fn right
1021    /// below: read that doc for how a forgotten variant is caught here,
1022    /// where `#[non_exhaustive]` has no effect.
1023    pub const ALL: [Self; 6] = [
1024        Self::NotFound,
1025        Self::InvalidConfig,
1026        Self::SpawnFailed,
1027        Self::ProtocolMismatch,
1028        Self::Internal,
1029        Self::DeadlineExceeded,
1030    ];
1031
1032    /// Never called; exists purely so this crate fails to build if a
1033    /// variant is added to [`RpcErrorCode`] without also adding it to
1034    /// [`Self::ALL`].
1035    ///
1036    /// `#[non_exhaustive]` only forces a wildcard arm on matches written
1037    /// *outside* this crate — inside the crate that defines the enum, a
1038    /// match with no `_` arm is still checked for exhaustiveness (E0004),
1039    /// so a new variant breaks this build until it gets an arm here. Each
1040    /// arm indexes a fixed literal position into [`Self::ALL`], so growing
1041    /// the enum without growing the array is caught too: rustc denies an
1042    /// out-of-bounds constant array index by default.
1043    #[allow(dead_code)]
1044    const fn assert_all_lists_every_variant(code: Self) -> Self {
1045        match code {
1046            Self::NotFound => Self::ALL[0],
1047            Self::InvalidConfig => Self::ALL[1],
1048            Self::SpawnFailed => Self::ALL[2],
1049            Self::ProtocolMismatch => Self::ALL[3],
1050            Self::Internal => Self::ALL[4],
1051            Self::DeadlineExceeded => Self::ALL[5],
1052        }
1053    }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058    use super::*;
1059    use crate::config::AppConfig;
1060    use crate::protocol::PROTOCOL_VERSION;
1061    use crate::status::ProcStatus;
1062
1063    fn sample_info() -> ProcessInfo {
1064        ProcessInfo {
1065            id: 3,
1066            name: "web".to_string(),
1067            status: ProcStatus::Online,
1068            pid: Some(4242),
1069            restarts: 1,
1070            uptime_ms: 60_000,
1071            fold: Some("backend".to_string()),
1072            out_file: Some("/home/rin/.shep/logs/web-0-out.log".to_string()),
1073            err_file: Some("/home/rin/.shep/logs/web-0-err.log".to_string()),
1074            // 12.5 rather than a rounder-looking 12.3: an insta JSON
1075            // snapshot is only stable across platforms for a float the
1076            // binary representation holds exactly.
1077            cpu_percent: Some(12.5),
1078            memory_bytes: Some(48 * 1024 * 1024),
1079            dog: None,
1080            lambs: None,
1081            // `restarts: 1` above already says this sheep crashed once and
1082            // came back; a code rather than `None` is the honest exit that
1083            // caused it, not a fact this fixture invents.
1084            last_exit: Some(ExitInfo {
1085                code: Some(1),
1086                signal: None,
1087            }),
1088        }
1089    }
1090
1091    /// fails if the builder's defaults drift from what a registered-but-not-yet
1092    /// running sheep actually looks like. A builder that quietly defaulted
1093    /// `uptime_ms` to something non-zero, or `restarts` to 1, would put a wrong
1094    /// number in front of an operator with nothing to compare it against.
1095    #[test]
1096    fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
1097        let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
1098
1099        assert_eq!(info.id, 3);
1100        assert_eq!(info.name, "web");
1101        assert_eq!(info.status, ProcStatus::Stopped);
1102        assert_eq!(info.pid, None);
1103        assert_eq!(info.restarts, 0);
1104        assert_eq!(info.uptime_ms, 0);
1105        assert_eq!(info.fold, None);
1106        assert_eq!(info.out_file, None);
1107        assert_eq!(info.err_file, None);
1108        assert_eq!(info.cpu_percent, None);
1109        assert_eq!(info.memory_bytes, None);
1110        assert_eq!(info.dog, None);
1111        assert_eq!(info.lambs, None);
1112        assert_eq!(info.last_exit, None);
1113    }
1114
1115    /// fails if any setter writes a field other than its own — the failure a
1116    /// twelve-field builder is most likely to ship, and one no individual
1117    /// round-trip test would catch. Every field is given a value distinct from
1118    /// every other field's default, so a copy-pasted setter body shows up as a
1119    /// mismatch rather than as a coincidence.
1120    #[test]
1121    fn every_setter_writes_its_own_field_and_no_other() {
1122        let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
1123            .pid(Some(4242))
1124            .restarts(1)
1125            .uptime_ms(60_000)
1126            .fold(Some("backend".to_string()))
1127            .out_file(Some("/home/rin/.shep/logs/web-0-out.log".to_string()))
1128            .err_file(Some("/home/rin/.shep/logs/web-0-err.log".to_string()))
1129            .cpu_percent(Some(12.5))
1130            .memory_bytes(Some(48 * 1024 * 1024))
1131            .dog(None)
1132            .last_exit(Some(ExitInfo {
1133                code: Some(1),
1134                signal: None,
1135            }))
1136            .build();
1137
1138        // `sample_info()` is still a struct literal, on purpose: it is the one
1139        // place in the workspace that names every field by hand, so this
1140        // comparison fails the day the struct grows a field the builder cannot
1141        // set. That is the point of comparing against it rather than against
1142        // another builder call.
1143        assert_eq!(built, sample_info());
1144
1145        // `dog` is the one field the comparison above cannot speak for, and it
1146        // is the field the whole dogs subsystem reads. `sample_info()`'s `dog`
1147        // is `None`, which is also the builder's default, so a `dog` setter with
1148        // an EMPTY BODY passes the assert_eq! above and passes it for the wrong
1149        // reason. `sample_info()` cannot be changed to `Some(..)` to fix that —
1150        // it feeds `reply_wire_snapshots` and `bus_event_wire_snapshots`, so
1151        // altering it moves pinned bytes. So the field gets its own line, with a
1152        // value nothing defaults to.
1153        assert_eq!(
1154            ProcessInfo::builder(1, "metrics", ProcStatus::Online)
1155                .dog(Some(DogSource::BuiltIn))
1156                .build()
1157                .dog,
1158            Some(DogSource::BuiltIn),
1159            "an empty `dog` setter body is invisible to the comparison above"
1160        );
1161
1162        // `lambs` is the second field the comparison above cannot speak for,
1163        // for the identical reason `dog` is the first: `sample_info()`'s value
1164        // is `None`, which is also the builder's default, so an EMPTY `lambs`
1165        // setter body passes the `assert_eq!` above. And `sample_info()` still
1166        // cannot be changed to a `Some(..)` — it feeds `reply_wire_snapshots`
1167        // and `bus_event_wire_snapshots`, so altering it moves pinned bytes.
1168        assert_eq!(
1169            ProcessInfo::builder(1, "web", ProcStatus::Online)
1170                .lambs(Some(vec![Lamb::new(4243, "node")]))
1171                .build()
1172                .lambs,
1173            Some(vec![Lamb::new(4243, "node")]),
1174            "an empty `lambs` setter body is invisible to the comparison above"
1175        );
1176    }
1177
1178    /// fails if `lambs` collapses to a bare `Vec`. The three states are the point:
1179    /// a peer that predates the field and a reply that did not walk the tree are
1180    /// both `None`, and a sheep that really has no children is `Some(vec![])`. A
1181    /// `Vec` would render the first two as "this sheep has no lambs", which is a
1182    /// claim neither of them makes.
1183    #[test]
1184    fn lambs_distinguishes_not_walked_from_walked_and_empty() {
1185        let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
1186        assert_eq!(not_walked.lambs, None);
1187
1188        let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
1189            .lambs(Some(Vec::new()))
1190            .build();
1191        assert_eq!(walked_empty.lambs, Some(Vec::new()));
1192    }
1193
1194    /// fails if a `ProcessInfo` from a daemon that predates the field stops
1195    /// deserializing. That is the whole reason the field is optional and the reason
1196    /// `PROTOCOL_VERSION` does not move for it — an old daemon's reply carries no
1197    /// `lambs` key at all, and a required field there would mean a new client could
1198    /// not list against an old daemon.
1199    #[test]
1200    fn a_process_info_without_a_lambs_key_still_deserializes() {
1201        let fixture = r#"{
1202            "id": 3, "name": "web", "status": "online", "pid": 4242,
1203            "restarts": 0, "uptime_ms": 100, "fold": null,
1204            "out_file": null, "err_file": null,
1205            "cpu_percent": null, "memory_bytes": null, "dog": null
1206        }"#;
1207        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1208        assert_eq!(info.lambs, None);
1209    }
1210
1211    /// fails if a lamb stops carrying its name, or starts carrying a command line.
1212    /// The name is `sysinfo`'s executable name, never argv — argv routinely holds
1213    /// credentials (`--password=`, `?token=`) and `shep describe --format json` is
1214    /// output people paste into issues.
1215    #[test]
1216    fn a_lamb_is_a_pid_and_an_executable_name() {
1217        let lamb = Lamb::new(4243, "node");
1218        let json = serde_json::to_string(&lamb).unwrap();
1219        assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
1220        assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
1221    }
1222
1223    /// fails if `DogSource` loses its `tag = "kind"` or its snake_case
1224    /// rename, and fails if `Adopted`'s `path` is renamed — any of the three
1225    /// changes one of these two strings while every type-level test in this
1226    /// module keeps passing. The marker is what the CLI splits two tables on
1227    /// and what the metrics dog reports a health gauge from, so a silent
1228    /// rename here is a silently empty dogs table.
1229    #[test]
1230    fn a_dog_source_serializes_snake_case_under_its_kind() {
1231        assert_eq!(
1232            serde_json::to_string(&DogSource::BuiltIn).unwrap(),
1233            r#"{"kind":"built_in"}"#
1234        );
1235        let adopted = DogSource::Adopted {
1236            path: "/usr/local/bin/shep-otel".to_string(),
1237        };
1238        let wire = r#"{"kind":"adopted","path":"/usr/local/bin/shep-otel"}"#;
1239        assert_eq!(serde_json::to_string(&adopted).unwrap(), wire);
1240        assert_eq!(serde_json::from_str::<DogSource>(wire).unwrap(), adopted);
1241    }
1242
1243    /// fails if `dog` stops being optional. A daemon built before dogs
1244    /// sends a reply with no such key and still announces protocol 1, so a
1245    /// required field would make a current client unable to list against it
1246    /// at all — the same skew rule `out_file` and `cpu_percent` are pinned
1247    /// under, and the same committed-byte-fixture proof.
1248    #[test]
1249    fn v1_process_info_without_a_dog_marker_still_deserializes() {
1250        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648}"#;
1251        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1252        assert_eq!(info.dog, None);
1253    }
1254
1255    /// fails if `last_exit` stops being optional. A daemon built before this
1256    /// field sends a reply with no such key and still announces protocol 1 —
1257    /// the same skew rule every other field added after `Hello`/`HelloAck`
1258    /// were fixed is pinned under.
1259    ///
1260    /// This is also the empirical proof behind task 49's own open question:
1261    /// none of `ProcessInfo`'s fields carry `#[serde(default)]`, and there is
1262    /// no container-level one either, yet the doc comments on `out_file` and
1263    /// `cpu_percent` both claim "`None` only when the peer daemon predates
1264    /// this field" as though one existed. Serde's `Deserialize` derive
1265    /// special-cases a field whose type is syntactically `Option<...>`: a
1266    /// missing key resolves to `None` without `#[serde(default)]` doing
1267    /// anything, because the derive macro recognizes the `Option` wrapper
1268    /// itself and generates that fallback for it. Those doc comments were
1269    /// right; they just named the wrong mechanism, or none. This test pins
1270    /// the real one for `last_exit` specifically — with `dog` and `lambs`
1271    /// present but `last_exit` genuinely absent from the JSON below — rather
1272    /// than leaving it as an inference from `v1_process_info_without_a_dog_
1273    /// marker_still_deserializes` above.
1274    #[test]
1275    fn a_process_info_without_a_last_exit_key_still_deserializes() {
1276        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648,"dog":null,"lambs":null}"#;
1277        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1278        assert_eq!(info.last_exit, None);
1279    }
1280
1281    /// fails if a `Signal` frame stops carrying the signal name as plain text, or
1282    /// if the outcome rows stop distinguishing their three cases. The name travels
1283    /// as a `String` on purpose (`AppConfig::kill_signal` does the same): the wire
1284    /// stays readable and the daemon re-validates, which it has to do anyway
1285    /// because peer input is untrusted.
1286    #[test]
1287    fn a_signal_request_and_its_reply_round_trip() {
1288        let request = Request::Signal {
1289            selector: SelectorSpec::Name("web".to_string()),
1290            signal: "SIGHUP".to_string(),
1291        };
1292        let json = serde_json::to_string(&request).unwrap();
1293        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1294
1295        let reply = Response::Signalled(vec![
1296            SignalReply {
1297                id: 1,
1298                name: "web".to_string(),
1299                outcome: SignalOutcome::Delivered,
1300            },
1301            SignalReply {
1302                id: 2,
1303                name: "web".to_string(),
1304                outcome: SignalOutcome::NotRunning,
1305            },
1306            SignalReply {
1307                id: 3,
1308                name: "api".to_string(),
1309                outcome: SignalOutcome::Failed {
1310                    reason: "no such process".to_string(),
1311                },
1312            },
1313        ]);
1314        let json = serde_json::to_string(&reply).unwrap();
1315        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1316        // The three tags, spelled out: a variant renamed in Rust changes these
1317        // strings mechanically, compiles clean, and breaks a client matching on
1318        // them with nothing to say why.
1319        assert!(json.contains(r#""kind":"delivered""#), "{json}");
1320        assert!(json.contains(r#""kind":"not_running""#), "{json}");
1321        assert!(json.contains(r#""kind":"failed""#), "{json}");
1322    }
1323
1324    /// fails if `Scale` grows a selector. It takes an app NAME, and that is the
1325    /// design: `instances` is a per-app number and instance slots are allocated
1326    /// per name-group, so `shep stock /web.*/ 4` would have to mean either four
1327    /// each or four total and there is no reading of it that is not a guess.
1328    #[test]
1329    fn a_scale_request_names_one_app_and_a_count() {
1330        let request = Request::Scale {
1331            name: "web".to_string(),
1332            count: 4,
1333        };
1334        let json = serde_json::to_string(&request).unwrap();
1335        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1336        assert!(json.contains(r#""kind":"scale""#), "{json}");
1337        assert!(json.contains(r#""name":"web""#), "{json}");
1338        // No `selector` key at all — the shape that says this verb is not one of
1339        // the selector-taking family.
1340        assert!(!json.contains("selector"), "{json}");
1341    }
1342
1343    /// fails if `Scaled` stops being distinguishable from the eight other replies
1344    /// carrying a bare `Vec<ProcessInfo>`. Each of those names which request it
1345    /// answers precisely so it can diverge later without a protocol bump — the
1346    /// enum's own doc says not to collapse them, and this is the test that notices.
1347    #[test]
1348    fn a_scaled_reply_carries_its_own_tag() {
1349        let json = serde_json::to_string(&Response::Scaled(vec![])).unwrap();
1350        assert_eq!(json, r#"{"kind":"scaled","data":[]}"#);
1351    }
1352
1353    /// fails if the three outcomes stop being tellable apart on the wire, or if
1354    /// `NotWritten` stops carrying its reason. That reason is the only thing that
1355    /// distinguishes "the app is not reading its stdin" from "the pipe broke", and
1356    /// the operator's next move differs between them.
1357    #[test]
1358    fn a_send_line_request_and_its_reply_round_trip() {
1359        let request = Request::SendLine {
1360            selector: SelectorSpec::Name("repl".to_string()),
1361            line: "reload-config".to_string(),
1362        };
1363        let json = serde_json::to_string(&request).unwrap();
1364        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1365
1366        let reply = Response::SentLine(vec![
1367            LineReply {
1368                id: 1,
1369                name: "repl".to_string(),
1370                outcome: LineOutcome::Sent,
1371            },
1372            LineReply {
1373                id: 2,
1374                name: "web".to_string(),
1375                outcome: LineOutcome::NoStdin,
1376            },
1377            LineReply {
1378                id: 3,
1379                name: "stuck".to_string(),
1380                outcome: LineOutcome::NotWritten {
1381                    reason: "the app did not read its stdin within 2s".to_string(),
1382                },
1383            },
1384        ]);
1385        let json = serde_json::to_string(&reply).unwrap();
1386        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1387        assert!(json.contains(r#""kind":"sent""#), "{json}");
1388        assert!(json.contains(r#""kind":"no_stdin""#), "{json}");
1389        assert!(json.contains("did not read its stdin"), "{json}");
1390    }
1391
1392    /// fails if a newline can ride inside the line. The wire carries ONE line and
1393    /// the writer appends the terminator, so an embedded newline would deliver two
1394    /// commands where the operator typed one — the shape that turns a typo into an
1395    /// unintended second instruction to a REPL.
1396    #[test]
1397    fn a_line_carrying_a_newline_is_still_one_field_on_the_wire() {
1398        let request = Request::SendLine {
1399            selector: SelectorSpec::All,
1400            line: "a\nb".to_string(),
1401        };
1402        let json = serde_json::to_string(&request).unwrap();
1403        // Escaped, not literal: the frame stays one JSON object. Rejecting it is
1404        // the daemon's job (see `shep whisper`), not serde's, and this pins that
1405        // the wire itself does not quietly split it.
1406        assert!(json.contains(r#""line":"a\nb""#), "{json}");
1407        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1408    }
1409
1410    #[test]
1411    fn request_wire_snapshots() {
1412        let requests = vec![
1413            Envelope {
1414                id: 1,
1415                deadline_ms: Some(5000),
1416                body: Request::Ping,
1417            },
1418            Envelope {
1419                id: 2,
1420                deadline_ms: None,
1421                body: Request::ListFlock,
1422            },
1423            Envelope {
1424                id: 3,
1425                deadline_ms: None,
1426                body: Request::Stop {
1427                    selector: SelectorSpec::Name("web".to_string()),
1428                },
1429            },
1430            Envelope {
1431                id: 4,
1432                deadline_ms: None,
1433                body: Request::Start {
1434                    apps: vec![AppConfig::minimal("web", "./srv")],
1435                },
1436            },
1437            // `All` rather than a named sheep: it is the selector `shep
1438            // reopen` sends when given no argument, and the one a signal can
1439            // ever mean, so it is the row worth pinning.
1440            Envelope {
1441                id: 5,
1442                deadline_ms: None,
1443                body: Request::Reopen {
1444                    selector: SelectorSpec::All,
1445                },
1446            },
1447            // Deliberately the same selector as the row above, so the two
1448            // log-plane rows differ by their `kind` and by nothing else: a
1449            // `Flush` that serialized under `reopen`'s tag — the shape a
1450            // copy-pasted variant takes — shows up here as two identical
1451            // objects rather than as a diff a reader has to compare field by
1452            // field. `shep flush` demands an explicit selector, so `all` is
1453            // not a default here the way it is for `reopen`; it is simply the
1454            // widest thing an operator can type.
1455            Envelope {
1456                id: 6,
1457                deadline_ms: None,
1458                body: Request::Flush {
1459                    selector: SelectorSpec::All,
1460                },
1461            },
1462            // The same selector as the `stop` row above, for the reason the
1463            // pair above share theirs: `reload` is the third verb that
1464            // demands an explicit selector and replaces what it matches, so
1465            // the variant it would be copy-pasted from is `stop`. Serialized
1466            // under `stop`'s tag it shows up here as two identical objects
1467            // rather than as a diff a reader has to compare field by field.
1468            Envelope {
1469                id: 7,
1470                deadline_ms: None,
1471                body: Request::Reload {
1472                    selector: SelectorSpec::Name("web".to_string()),
1473                },
1474            },
1475            // `action`/`params` here match the spec's own §9 example
1476            // (`trigger web set-log-level debug`) and channel.rs's
1477            // with-params fixture verbatim, so a reader tracing a trigger
1478            // from the CLI through the client↔daemon wire to the fd-3 wire
1479            // sees the same two strings at every hop rather than three
1480            // unrelated examples.
1481            Envelope {
1482                id: 8,
1483                deadline_ms: None,
1484                body: Request::Trigger {
1485                    selector: SelectorSpec::Name("web".to_string()),
1486                    action: "set-log-level".to_string(),
1487                    params: Some("debug".to_string()),
1488                },
1489            },
1490            // The first fieldless verb added since `Ping`/`ListFlock`, and
1491            // pinned for that reason: a fieldless variant serializes as a
1492            // bare `{"kind":"..."}` with no `selector` key at all, so a
1493            // reader comparing this row against `stop`'s sees the whole
1494            // difference between the two shapes in one place.
1495            Envelope {
1496                id: 9,
1497                deadline_ms: None,
1498                body: Request::SaveRoll,
1499            },
1500            // Paired with the `save_roll` row above so the two halves of the
1501            // roll — the direction that writes it and the direction that
1502            // assembles from it — sit next to each other, differing by their
1503            // `kind` and by nothing else.
1504            Envelope {
1505                id: 10,
1506                deadline_ms: None,
1507                body: Request::Muster,
1508            },
1509            // The three dog verbs together, in the order an operator meets
1510            // them: ask for a section, start a dog, stop one. Adjacent on
1511            // purpose — `enable_dog` and `disable_dog` differ by their
1512            // `kind` and by `source`, so a `DisableDog` accidentally given
1513            // `EnableDog`'s tag shows up here as two near-identical objects
1514            // rather than as a diff a reader has to compare field by field.
1515            Envelope {
1516                id: 11,
1517                deadline_ms: None,
1518                body: Request::DogConfig {
1519                    name: "bark".to_string(),
1520                },
1521            },
1522            Envelope {
1523                id: 12,
1524                deadline_ms: None,
1525                body: Request::EnableDog {
1526                    name: "metrics".to_string(),
1527                    source: DogSource::BuiltIn,
1528                },
1529            },
1530            Envelope {
1531                id: 13,
1532                deadline_ms: None,
1533                body: Request::DisableDog {
1534                    name: "metrics".to_string(),
1535                },
1536            },
1537            // The three selector shapes no fixture reached before Phase 10.
1538            // Grouped and adjacent on purpose: `Id`, `Regex` and `Fold` are
1539            // three newtypes over three different inner types, and the wire
1540            // tells them apart only by their own `kind` tag — a `Fold` that
1541            // serialized under `regex`'s tag is a `shep restart fold:api`
1542            // that silently becomes a regex match, which is a wrong set of
1543            // sheep restarted and not an error anyone sees.
1544            Envelope {
1545                id: 14,
1546                deadline_ms: None,
1547                body: Request::Describe {
1548                    selector: SelectorSpec::Id(7),
1549                },
1550            },
1551            Envelope {
1552                id: 15,
1553                deadline_ms: None,
1554                body: Request::Describe {
1555                    selector: SelectorSpec::Regex("^web-".to_string()),
1556                },
1557            },
1558            Envelope {
1559                id: 16,
1560                deadline_ms: None,
1561                body: Request::Describe {
1562                    selector: SelectorSpec::Fold("api".to_string()),
1563                },
1564            },
1565            // `SIGHUP` rather than `SIGTERM`: TERM is what the stop ladder
1566            // already sends, so a fixture using it could not tell a `signal`
1567            // frame from a stop's. HUP is the signal this verb exists for.
1568            Envelope {
1569                id: 17,
1570                deadline_ms: None,
1571                body: Request::Signal {
1572                    selector: SelectorSpec::Name("web".to_string()),
1573                    signal: "SIGHUP".to_string(),
1574                },
1575            },
1576            // The one verb in this enum whose body has no `selector` key at
1577            // all — a reader comparing this row against `stop`'s sees the
1578            // whole difference in one place.
1579            Envelope {
1580                id: 18,
1581                deadline_ms: None,
1582                body: Request::Scale {
1583                    name: "web".to_string(),
1584                    count: 4,
1585                },
1586            },
1587            // `SelectorSpec::All` rather than a named sheep, mirroring the
1588            // `reopen`/`flush` rows above: it is the widest thing an operator
1589            // can type, and the line carries no terminator on the wire — the
1590            // shepherd appends it — so a fixture with one proves that half of
1591            // the contract too.
1592            Envelope {
1593                id: 19,
1594                deadline_ms: None,
1595                body: Request::SendLine {
1596                    selector: SelectorSpec::All,
1597                    line: "reload-config".to_string(),
1598                },
1599            },
1600        ];
1601        insta::assert_json_snapshot!("request_wire_v1", requests);
1602    }
1603
1604    #[test]
1605    fn reply_wire_snapshots() {
1606        let replies = vec![
1607            Reply {
1608                id: 1,
1609                result: Ok(Response::Pong),
1610            },
1611            Reply {
1612                id: 2,
1613                result: Ok(Response::Flock(vec![sample_info()])),
1614            },
1615            Reply {
1616                id: 3,
1617                result: Err(RpcError {
1618                    code: RpcErrorCode::NotFound,
1619                    message: "no sheep matches `web`".to_string(),
1620                }),
1621            },
1622            // Unlike `Reopened`/`Flushed`/`Reloading` above (all wire-identical
1623            // to `Flock`, just under a different `kind` tag, so pinning `Flock`
1624            // once already covers their shape), `Triggered` carries a genuinely
1625            // different row — `ActionReply` is not a `ProcessInfo` — so it earns
1626            // its own entry. `Replied` is the struct-shaped variant of
1627            // `ActionOutcome`, and so the one worth pinning here: the three
1628            // unit variants serialize as bare `{"kind":"..."}`, a shape already
1629            // proven by every fieldless variant elsewhere on this wire.
1630            Reply {
1631                id: 4,
1632                result: Ok(Response::Triggered(vec![ActionReply {
1633                    id: 3,
1634                    name: "web".to_string(),
1635                    outcome: ActionOutcome::Replied {
1636                        body: "ok".to_string(),
1637                    },
1638                }])),
1639            },
1640            // The only struct-shaped `Response` variant, so the one worth
1641            // pinning here: every other variant on this wire is a newtype
1642            // over a Vec or a unit, both shapes already proven above.
1643            Reply {
1644                id: 5,
1645                result: Ok(Response::RollSaved {
1646                    path: "/home/rin/.shep/flock.json".to_string(),
1647                    apps: 2,
1648                }),
1649            },
1650            // `sample_info()` above pins the absent marker (a sheep's
1651            // `"dog": null`); this row is the only place the present one is
1652            // pinned, and `Adopted` rather than `BuiltIn` because it is the
1653            // variant carrying a payload — the unit variant's shape is
1654            // already proven by every fieldless variant on this wire.
1655            Reply {
1656                id: 6,
1657                result: Ok(Response::Flock(vec![ProcessInfo {
1658                    id: 7,
1659                    name: "otel".to_string(),
1660                    dog: Some(DogSource::Adopted {
1661                        path: "/usr/local/bin/shep-otel".to_string(),
1662                    }),
1663                    ..sample_info()
1664                }])),
1665            },
1666            // The opaque blob, pinned as a blob: the daemon renders a TOML
1667            // table into a string and never a typed structure, so what this
1668            // row proves is that the section crosses the wire as text.
1669            Reply {
1670                id: 7,
1671                result: Ok(Response::DogSection {
1672                    toml: "port = 9615\n".to_string().into(),
1673                }),
1674            },
1675            // The only `Response` variant carrying a BARE `ProcessInfo`
1676            // rather than a `Vec` of them: `enable` starts exactly one dog,
1677            // and a one-element list would invite a reader to wonder when it
1678            // holds two.
1679            Reply {
1680                id: 8,
1681                result: Ok(Response::DogStarted(ProcessInfo {
1682                    id: 4,
1683                    name: "metrics".to_string(),
1684                    dog: Some(DogSource::BuiltIn),
1685                    ..sample_info()
1686                })),
1687            },
1688            // The eleven variants no fixture reached before Phase 10. The
1689            // existing comment on the `Triggered` row is right that pinning
1690            // `Flock` once already proves the `Vec<ProcessInfo>` SHAPE — but
1691            // it does not prove any of these variants' own `kind` tags, and
1692            // three of them are not `Vec<ProcessInfo>`-shaped at all
1693            // (`Deleted` is a `Vec<u32>`, `Subscribed` and `ShuttingDown`
1694            // carry nothing). Each row below therefore carries the emptiest
1695            // legal body: what is being pinned here is the tag, and a body
1696            // repeated eight times would bury it.
1697            Reply {
1698                id: 9,
1699                result: Ok(Response::Described(vec![])),
1700            },
1701            Reply {
1702                id: 10,
1703                result: Ok(Response::Started(vec![])),
1704            },
1705            Reply {
1706                id: 11,
1707                result: Ok(Response::Stopped(vec![])),
1708            },
1709            Reply {
1710                id: 12,
1711                result: Ok(Response::Restarted(vec![])),
1712            },
1713            Reply {
1714                id: 13,
1715                result: Ok(Response::Reloading(vec![])),
1716            },
1717            Reply {
1718                id: 14,
1719                result: Ok(Response::Deleted(vec![7, 8])),
1720            },
1721            Reply {
1722                id: 15,
1723                result: Ok(Response::Reopened(vec![])),
1724            },
1725            Reply {
1726                id: 16,
1727                result: Ok(Response::Flushed(vec![])),
1728            },
1729            Reply {
1730                id: 17,
1731                result: Ok(Response::Mustered(vec![])),
1732            },
1733            Reply {
1734                id: 18,
1735                result: Ok(Response::Subscribed),
1736            },
1737            Reply {
1738                id: 19,
1739                result: Ok(Response::ShuttingDown),
1740            },
1741            // `Signalled`, mirroring the `Triggered` row above: three rows,
1742            // one per `SignalOutcome` variant, so a reader sees the whole
1743            // shape of the reply in one pinned fixture rather than one row
1744            // that happens to hit `Delivered` and leaves the other two tags
1745            // unproven.
1746            Reply {
1747                id: 20,
1748                result: Ok(Response::Signalled(vec![
1749                    SignalReply {
1750                        id: 1,
1751                        name: "web".to_string(),
1752                        outcome: SignalOutcome::Delivered,
1753                    },
1754                    SignalReply {
1755                        id: 2,
1756                        name: "web".to_string(),
1757                        outcome: SignalOutcome::NotRunning,
1758                    },
1759                    SignalReply {
1760                        id: 3,
1761                        name: "api".to_string(),
1762                        outcome: SignalOutcome::Failed {
1763                            reason: "no such process".to_string(),
1764                        },
1765                    },
1766                ])),
1767            },
1768            Reply {
1769                id: 21,
1770                result: Ok(Response::Scaled(vec![sample_info()])),
1771            },
1772            // `SentLine`, mirroring the `Signalled` row above: three rows, one
1773            // per `LineOutcome` variant, so a reader sees the whole shape of
1774            // the reply in one pinned fixture rather than one row that
1775            // happens to hit `Sent` and leaves the other two tags unproven.
1776            Reply {
1777                id: 22,
1778                result: Ok(Response::SentLine(vec![
1779                    LineReply {
1780                        id: 1,
1781                        name: "repl".to_string(),
1782                        outcome: LineOutcome::Sent,
1783                    },
1784                    LineReply {
1785                        id: 2,
1786                        name: "web".to_string(),
1787                        outcome: LineOutcome::NoStdin,
1788                    },
1789                    LineReply {
1790                        id: 3,
1791                        name: "stuck".to_string(),
1792                        outcome: LineOutcome::NotWritten {
1793                            reason: "the app did not read its stdin within 2s".to_string(),
1794                        },
1795                    },
1796                ])),
1797            },
1798            // A `Described` row with a real lamb tree. The `null` shape is pinned
1799            // on every other row here; this is the one that pins what a walked
1800            // sheep serializes as, which is the shape a `describe` consumer
1801            // actually parses.
1802            Reply {
1803                id: 23,
1804                result: Ok(Response::Described(vec![
1805                    ProcessInfo::builder(3, "web", ProcStatus::Online)
1806                        .pid(Some(4242))
1807                        .lambs(Some(vec![Lamb::new(4243, "node"), Lamb::new(4244, "sh")]))
1808                        .build(),
1809                ])),
1810            },
1811            // `sample_info()` pins `last_exit`'s "exited normally" shape
1812            // (`code` set, `signal` absent) on every row above; this is the
1813            // only place the other one — killed by a signal, `code` absent
1814            // — is pinned. `SIGTERM`'s raw number (15) rather than a
1815            // symbolic one, because [`ExitInfo::signal`]'s own doc says this
1816            // crate carries no name for it; naming one is a job for
1817            // whichever OS-aware layer renders this field.
1818            Reply {
1819                id: 24,
1820                result: Ok(Response::Flock(vec![
1821                    ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
1822                        .restarts(1)
1823                        .last_exit(Some(ExitInfo {
1824                            code: None,
1825                            signal: Some(15),
1826                        }))
1827                        .build(),
1828                ])),
1829            },
1830        ];
1831        insta::assert_json_snapshot!("reply_wire_v1", replies);
1832    }
1833
1834    #[test]
1835    fn v1_fixture_still_deserializes() {
1836        // Committed byte fixture from protocol v1 — if this breaks, bump
1837        // PROTOCOL_VERSION and record it in the CHANGELOG (IR-35).
1838        let fixture = r#"{"id":7,"deadline_ms":null,"body":{"kind":"stop","selector":{"kind":"name","value":"web"}}}"#;
1839        let env: Envelope = serde_json::from_str(fixture).unwrap();
1840        assert_eq!(env.id, 7);
1841        assert!(matches!(
1842            env.body,
1843            Request::Stop { selector: SelectorSpec::Name(ref n) } if n == "web"
1844        ));
1845    }
1846
1847    #[test]
1848    fn hello_handshake_shape() {
1849        let hello = Hello {
1850            client_version: "0.1.0".to_string(),
1851            protocol: PROTOCOL_VERSION,
1852        };
1853        let json = serde_json::to_string(&hello).unwrap();
1854        assert_eq!(json, r#"{"client_version":"0.1.0","protocol":1}"#);
1855    }
1856
1857    #[test]
1858    fn hello_reply_carries_typed_skew_error() {
1859        let refusal: HelloReply = Err(RpcError {
1860            code: RpcErrorCode::ProtocolMismatch,
1861            message: "daemon speaks protocol 1, client sent 2".to_string(),
1862        });
1863        let json = serde_json::to_string(&refusal).unwrap();
1864        assert_eq!(
1865            json,
1866            r#"{"Err":{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}}"#
1867        );
1868        let back: HelloReply = serde_json::from_str(&json).unwrap();
1869        assert_eq!(back, refusal);
1870    }
1871
1872    #[test]
1873    fn v1_reply_fixture_still_deserializes() {
1874        // Committed byte fixture, protocol v1 (IR-35).
1875        let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
1876        let reply: Reply = serde_json::from_str(ok).unwrap();
1877        assert!(matches!(reply.result, Ok(Response::Pong)));
1878        let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
1879        let reply: Reply = serde_json::from_str(err).unwrap();
1880        assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
1881    }
1882
1883    #[test]
1884    fn v1_hello_ack_fixture_still_deserializes() {
1885        let fixture = r#"{"Ok":{"daemon_version":"0.1.0","protocol":1,"pid":4242}}"#;
1886        let ack: HelloReply = serde_json::from_str(fixture).unwrap();
1887        assert_eq!(ack.unwrap().pid, 4242);
1888    }
1889
1890    /// fails if the two fields stop being optional. A daemon built before
1891    /// them sends a reply with no such keys, and both peers still announce
1892    /// protocol 1 — a required field would make a current client unable to
1893    /// list against that daemon at all.
1894    #[test]
1895    fn v1_process_info_without_stats_still_deserializes() {
1896        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log"}"#;
1897        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1898        assert_eq!(info.cpu_percent, None);
1899        assert_eq!(info.memory_bytes, None);
1900    }
1901
1902    #[test]
1903    fn v1_process_info_without_log_paths_still_deserializes() {
1904        // Committed byte fixture from before `out_file`/`err_file` existed
1905        // (IR-35). The handshake only compares PROTOCOL_VERSION, which this
1906        // addition deliberately did not bump, so a daemon built at this
1907        // vintage still connects to a current client and sends exactly these
1908        // bytes. Absent keys must land as `None`, not as a decode error.
1909        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
1910        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1911        assert_eq!(info.id, 3);
1912        assert_eq!(info.out_file, None);
1913        assert_eq!(info.err_file, None);
1914    }
1915
1916    #[test]
1917    fn an_old_client_still_decodes_a_new_process_info() {
1918        // The other skew direction: a client built before the fields reads a
1919        // current daemon's reply. `ProcessInfo` carries no
1920        // `deny_unknown_fields` (unlike the config types in
1921        // `crate::config`), so the two extra keys are ignored rather than
1922        // refused — which is what makes this addition version-preserving.
1923        #[derive(Deserialize)]
1924        struct V1ProcessInfo {
1925            id: u32,
1926            fold: Option<String>,
1927        }
1928
1929        let current = serde_json::to_string(&sample_info()).unwrap();
1930        let old: V1ProcessInfo = serde_json::from_str(&current).unwrap();
1931        assert_eq!(old.id, 3);
1932        assert_eq!(old.fold.as_deref(), Some("backend"));
1933    }
1934
1935    #[test]
1936    fn deadline_exceeded_code_serializes_snake_case() {
1937        // Additive variant (evolution rule): the existing codes keep their
1938        // strings, so v1 byte fixtures above still deserialize unchanged.
1939        assert_eq!(
1940            serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
1941            "\"deadline_exceeded\""
1942        );
1943        assert_eq!(
1944            serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
1945            RpcErrorCode::DeadlineExceeded
1946        );
1947    }
1948
1949    #[test]
1950    fn action_outcome_kinds_serialize_snake_case_and_round_trip() {
1951        // The shared snapshots above exercise exactly one `ActionOutcome`
1952        // variant (`Replied`, the only struct-shaped one, in
1953        // `reply_wire_snapshots`) — nothing else there would catch a rename
1954        // of `no_channel`, `skipped`, or `timed_out`. Pinned here instead,
1955        // the same way `deadline_exceeded_code_serializes_snake_case` pins a
1956        // lone `RpcErrorCode` variant above.
1957        let cases = [
1958            (
1959                ActionOutcome::Replied {
1960                    body: "pong".to_string(),
1961                },
1962                r#"{"kind":"replied","body":"pong"}"#,
1963            ),
1964            (ActionOutcome::NoChannel, r#"{"kind":"no_channel"}"#),
1965            (ActionOutcome::Skipped, r#"{"kind":"skipped"}"#),
1966            (ActionOutcome::TimedOut, r#"{"kind":"timed_out"}"#),
1967        ];
1968        for (outcome, wire) in cases {
1969            assert_eq!(
1970                serde_json::to_string(&outcome).unwrap(),
1971                wire,
1972                "{outcome:?}"
1973            );
1974            assert_eq!(
1975                serde_json::from_str::<ActionOutcome>(wire).unwrap(),
1976                outcome
1977            );
1978        }
1979    }
1980
1981    /// fails if `SaveRoll` or `RollSaved` is given a `rename`, or if
1982    /// `Response`'s `content = "data"` is dropped — either changes these two
1983    /// strings while every type-level test in this module keeps passing.
1984    #[test]
1985    fn save_roll_serializes_snake_case_with_its_payload_under_data() {
1986        assert_eq!(
1987            serde_json::to_string(&Request::SaveRoll).unwrap(),
1988            r#"{"kind":"save_roll"}"#
1989        );
1990        let reply = Response::RollSaved {
1991            path: "/tmp/flock.json".to_string(),
1992            apps: 3,
1993        };
1994        let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
1995        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
1996        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
1997    }
1998
1999    /// fails if `Muster` or `Mustered` is given a `rename`, or if `Mustered`
2000    /// is declared fieldless — any of the three changes one of these two
2001    /// strings while every type-level test in this module keeps passing.
2002    ///
2003    /// The listing is empty on purpose. `Mustered` carries the same
2004    /// `Vec<ProcessInfo>` `Flock` does, and `reply_wire_snapshots` already
2005    /// pins that row field by field; what is unpinned until here is this
2006    /// variant's own tag and whether its payload lands under `data` at all.
2007    #[test]
2008    fn muster_serializes_snake_case_with_its_listing_under_data() {
2009        assert_eq!(
2010            serde_json::to_string(&Request::Muster).unwrap(),
2011            r#"{"kind":"muster"}"#
2012        );
2013        let reply = Response::Mustered(Vec::new());
2014        let wire = r#"{"kind":"mustered","data":[]}"#;
2015        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
2016        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
2017    }
2018
2019    /// fails if any of the three verbs or either reply is given a `rename`,
2020    /// or if `Response`'s `content = "data"` is dropped. `disable_dog`'s
2021    /// answer is `Deleted`, which no other test in this module pairs with
2022    /// this verb — a handler wired to answer `Deleted` for `EnableDog` would
2023    /// still round-trip, and this is where the pairing is written down.
2024    #[test]
2025    fn the_dog_verbs_serialize_snake_case_with_their_payloads_under_data() {
2026        assert_eq!(
2027            serde_json::to_string(&Request::DogConfig {
2028                name: "bark".to_string()
2029            })
2030            .unwrap(),
2031            r#"{"kind":"dog_config","name":"bark"}"#
2032        );
2033        assert_eq!(
2034            serde_json::to_string(&Request::DisableDog {
2035                name: "bark".to_string()
2036            })
2037            .unwrap(),
2038            r#"{"kind":"disable_dog","name":"bark"}"#
2039        );
2040        let section = Response::DogSection {
2041            toml: "port = 9615\n".to_string().into(),
2042        };
2043        let wire = r#"{"kind":"dog_section","data":{"toml":"port = 9615\n"}}"#;
2044        assert_eq!(serde_json::to_string(&section).unwrap(), wire);
2045        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), section);
2046    }
2047
2048    #[test]
2049    fn dog_section_toml_debug_does_not_leak() {
2050        // IR-41: a dog's `[dog.<name>]` section routinely holds webhook
2051        // credentials (a Discord/Slack URL with a bearer token embedded).
2052        // `Response` derives `Debug`, so this is the one thing standing
2053        // between that token and any future `tracing::debug!("{:?}", reply)`.
2054        // Exact string pinned so a lazy `#[derive(Debug)]` refactor on
2055        // `DogSectionToml` fails this test instead of silently reopening
2056        // the leak.
2057        let toml: DogSectionToml =
2058            "webhook_url = \"https://discord.com/api/webhooks/1/super-secret-token\"\n"
2059                .to_string()
2060                .into();
2061        assert_eq!(format!("{toml:?}"), "DogSectionToml(<70 bytes>)");
2062
2063        let response = Response::DogSection { toml };
2064        assert_eq!(
2065            format!("{response:?}"),
2066            "DogSection { toml: DogSectionToml(<70 bytes>) }"
2067        );
2068    }
2069}