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, Deserializer, 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    // Both field names are part of the wire contract, and the byte shape is
49    // pinned by `request_wire_v2`, so renaming either breaks that snapshot
50    // rather than sliding through unnoticed.
51    /// By app name and instance slot
52    ///
53    /// Added in protocol 2, and the reason that version exists: a daemon
54    /// built against protocol 1 has no such variant and refuses a client
55    /// that sends one at the handshake, rather than failing to decode it
56    /// later. On the wire this is `{"kind":"instance","value":{"name":
57    /// "web","slot":2}}`, following the enum's own `kind`/`value` tagging.
58    Instance {
59        /// The app name
60        name: String,
61        /// The instance slot, counting from 0
62        slot: u32,
63    },
64}
65
66/// A short marker a dog attaches to a sheep for `shep flock` to paint.
67///
68/// shep stores one and prints it. It does not parse it, has no opinion about
69/// what it means, and never will: `▲ main@a1b2c3` is a deploy tool's
70/// sentence, not shep's, and keeping it that way is what makes this a general
71/// mechanism rather than one feature's field.
72///
73/// # The rule
74///
75/// A smit is non-empty once whitespace is discounted, at most
76/// [`Self::MAX_CHARS`] characters, and carries no [`char::is_control`]
77/// character — `\u{1b}` included, which `is_control` already covers and which
78/// is named separately in this doc anyway, because it is the one an attacker
79/// reaches for and a reader should not have to know the classification to see
80/// that it is handled.
81///
82/// Refused, never repaired. The text is stored exactly as it arrived: shep
83/// does not trim it, strip from it, or otherwise hand back something the
84/// publisher did not send. `crate::kv`'s key grammar and value cap set the
85/// same precedent for the same kind of value, and the publisher here is a
86/// program, so a refusal it can see and fix beats mangling it cannot.
87///
88/// # Why the cap counts characters
89///
90/// [`Self::MAX_CHARS`] is a count of `char`s, not of bytes and not of display
91/// columns. Bytes would refuse a legitimate CJK smit at roughly a third of
92/// its apparent length. Display columns are what a table renderer measures,
93/// but they depend on the terminal and on a width table this parser has no
94/// business carrying. Characters are the honest thing a validator can promise
95/// cheaply. 48 is measured against the reference smit `▲ main@a1b2c3` at
96/// thirteen: room for a long branch name, without letting one column swallow
97/// the table.
98///
99/// # Why validation lives here rather than at the renderer
100///
101/// `shep`'s own `output::width::sanitize_cell` deliberately KEEPS a
102/// well-formed CSI sequence, because shep's colouring is made of them, so it
103/// is not a guard against a third party's string. Refusing here means `shep
104/// flock`, `shep describe`, `--format json`, the lookout, the MCP tool schema
105/// and every bus subscriber are safe by construction instead of six places
106/// each remembering.
107///
108/// `Debug` is derived, and that is the deliberate decision (IR-41): a smit
109/// carries no environment and no secret. It is a string a dog asked to have
110/// painted in public, so redacting it would hide the thing an operator is
111/// debugging.
112///
113/// # Example
114/// ```
115/// use shep_core::protocol::Smit;
116///
117/// assert_eq!("▲ main@a1b2c3".parse::<Smit>()?.as_str(), "▲ main@a1b2c3");
118/// assert!("\u{1b}[2Jgone".parse::<Smit>().is_err()); // no escapes
119/// # Ok::<(), shep_core::protocol::SmitError>(())
120/// ```
121// wire format: changing this is a breaking change
122#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
123pub struct Smit(String);
124
125impl Smit {
126    /// The longest a smit may be, in characters. See the type doc for why
127    /// characters rather than bytes or display columns.
128    pub const MAX_CHARS: usize = 48;
129
130    /// The marker as text, exactly as its publisher sent it.
131    #[must_use]
132    pub fn as_str(&self) -> &str {
133        &self.0
134    }
135}
136
137impl fmt::Display for Smit {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.write_str(&self.0)
140    }
141}
142
143impl core::str::FromStr for Smit {
144    type Err = SmitError;
145
146    /// # Errors
147    /// - [`SmitError::Empty`] — nothing but whitespace.
148    /// - [`SmitError::TooLong`] — over [`Self::MAX_CHARS`] characters.
149    /// - [`SmitError::Unprintable`] — a control character, `\u{1b}` included.
150    fn from_str(text: &str) -> Result<Self, Self::Err> {
151        if text.trim().is_empty() {
152            return Err(SmitError::Empty);
153        }
154        let chars = text.chars().count();
155        if chars > Self::MAX_CHARS {
156            return Err(SmitError::TooLong { chars });
157        }
158        if text.chars().any(char::is_control) {
159            return Err(SmitError::Unprintable);
160        }
161        Ok(Self(text.to_string()))
162    }
163}
164
165/// Hand-written rather than derived, and that is the whole security property.
166///
167/// A derived impl would accept anything a `String` accepts, so a smit
168/// carrying `\u{1b}[2J` would reach the daemon's memory and every listing
169/// built from it. `docs/dogs.md` tells dog authors to speak this wire
170/// directly, so a dog written in another language never runs
171/// [`core::str::FromStr`] — the daemon has to validate what it decodes, not
172/// trust that it was constructed properly.
173impl<'de> Deserialize<'de> for Smit {
174    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
175        // String, not &str: a non-borrowing deserializer cannot always borrow
176        let text = String::deserialize(deserializer)?;
177        text.parse().map_err(serde::de::Error::custom)
178    }
179}
180
181/// Why a string is not a [`Smit`].
182///
183/// `#[non_exhaustive]`: shep-core is a published library and a further
184/// refusal — a grapheme-cluster cap, say, or a bidi-override rule — must not
185/// break an out-of-tree consumer's `match` (IR-20).
186#[non_exhaustive]
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum SmitError {
189    /// Over [`Smit::MAX_CHARS`] characters; carries the count that was sent.
190    TooLong {
191        /// How many characters the string held.
192        chars: usize,
193    },
194    /// A control character, `\u{1b}` included — the sequence that would let a
195    /// third party's string drive an operator's terminal.
196    Unprintable,
197    /// Empty, or nothing but whitespace.
198    Empty,
199}
200
201impl fmt::Display for SmitError {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        match self {
204            Self::TooLong { chars } => write!(
205                f,
206                "a smit is at most {} characters; this one is {chars}",
207                Smit::MAX_CHARS
208            ),
209            Self::Unprintable => {
210                f.write_str("a smit may not contain a control character, an escape included")
211            }
212            Self::Empty => f.write_str("a smit may not be empty"),
213        }
214    }
215}
216
217impl core::error::Error for SmitError {}
218
219/// One RPC request
220// wire format: changing existing variants is a breaking change
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222#[serde(tag = "kind", rename_all = "snake_case")]
223#[non_exhaustive]
224pub enum Request {
225    /// Liveness check
226    Ping,
227    /// Full flock listing
228    ListFlock,
229    /// Detailed info for matching sheep
230    Describe {
231        /// Which sheep
232        selector: SelectorSpec,
233    },
234    /// Register + start apps
235    Start {
236        /// App configs — the daemon MUST re-normalize (peer input is
237        /// untrusted); failures return [`RpcErrorCode::InvalidConfig`]
238        apps: Vec<AppConfig>,
239    },
240    /// Ask which of `apps` name a sheep the flock already has under a
241    /// different config
242    ///
243    /// Read-only: nothing is registered, started, or changed. [`Self::Start`]
244    /// on an already-registered name adds instances rather than reconciling
245    /// config, which is what `shep stock` relies on; this is how a caller
246    /// finds out that an edit it just read from a Flockfile is one `Start`
247    /// will not apply, instead of the edit vanishing without a word.
248    ///
249    /// Answers [`Response::Drifted`] with one [`SheepDrift`] per app that is
250    /// both registered and different. An app the flock does not have is
251    /// absent from the answer, not reported as unchanged: `Start` will
252    /// register it, so there is nothing to warn about.
253    ConfigDrift {
254        /// The configs to compare against, exactly as [`Self::Start`] would
255        /// carry them. The daemon MUST re-normalize (peer input is
256        /// untrusted, and an unnormalized config would report every default
257        /// it has not spelled out as a difference); failures return
258        /// [`RpcErrorCode::InvalidConfig`].
259        apps: Vec<AppConfig>,
260    },
261    /// Stop matching sheep (stay registered)
262    Stop {
263        /// Which sheep
264        selector: SelectorSpec,
265    },
266    /// Restart matching sheep
267    Restart {
268        /// Which sheep
269        selector: SelectorSpec,
270    },
271    /// Replace each matching sheep with a fresh instance of the same app, one
272    /// instance of an app at a time, so the app has a window in which it can
273    /// stay reachable across the swap
274    Reload {
275        /// Which sheep. No default anywhere in the stack — a reload replaces
276        /// running processes, so the operator names the target, exactly as
277        /// `stop`/`restart`/`delete` do (see `shep reload`).
278        selector: SelectorSpec,
279    },
280    /// Stop + deregister matching sheep
281    Delete {
282        /// Which sheep
283        selector: SelectorSpec,
284    },
285    /// Set how many instances one app runs (see `shep stock`).
286    ///
287    /// # Why a name and not a selector
288    ///
289    /// Every other verb here takes a [`SelectorSpec`], and this one
290    /// deliberately does not. `instances` is a per-app number and instance
291    /// slots are allocated against the same-name group
292    /// (`shep_daemon::assemble::instance_slots`), so a selector matching two
293    /// apps would have to mean either "four of each" or "four in total", and
294    /// neither reading is more obviously right than the other. A name has one
295    /// meaning.
296    ///
297    /// # Why absolute and not a delta
298    ///
299    /// There is no `+N`/`-N` form and there will not be one. An absolute count
300    /// is idempotent — run it twice, get the same flock — where two operators
301    /// sending `+2` against the same app get a number neither of them asked
302    /// for. This project's own trace notes also record a crash on pm2's
303    /// relative-remove path, and those notes exist so shep does not reproduce
304    /// what they record.
305    Scale {
306        /// The app's name, exactly as its config spells it. Not a selector: no
307        /// `all`, no regex, no `fold:`.
308        name: String,
309        /// How many instances the app has when this returns. `0` is refused
310        /// with [`RpcErrorCode::InvalidConfig`] — `normalize` rejects
311        /// `instances == 0` for every other path into the daemon, and `shep
312        /// delete` is the verb for removing an app.
313        count: u32,
314    },
315    /// Attach a short marker to `sheep` for `shep flock` to paint, or clear
316    /// it with `None`.
317    ///
318    /// By NAME rather than a selector, for [`Self::Scale`]'s reason (see its
319    /// own doc above): a smit belongs to a sheep, not to one of its
320    /// instances, and every instance of that name shows it — including one
321    /// spawned after the smit was painted.
322    ///
323    /// Held in memory and scoped to the connection that sent it. When that
324    /// connection closes, for any reason, the smits it painted go with it. A
325    /// publisher therefore republishes rather than publishing on change.
326    ///
327    /// shep does not parse it and has no opinion about what it means.
328    SetSmit {
329        /// Which sheep.
330        sheep: String,
331        /// The marker, or `None` to clear it.
332        smit: Option<Smit>,
333    },
334    /// Reopen every matched sheep's log files, for an external rotator that
335    /// has renamed them (`create`-mode rotation)
336    Reopen {
337        /// Which sheep
338        selector: SelectorSpec,
339    },
340    /// Empty every matched sheep's log files: flush what is still pending,
341    /// then truncate the recorded paths
342    Flush {
343        /// Which sheep. No default anywhere in the stack — this destroys
344        /// log data, so the operator names the target (see `shep flush`).
345        selector: SelectorSpec,
346    },
347    /// Send a named action to every matched sheep over its shepherd channel
348    /// and report what each app says back (see `shep trigger`).
349    Trigger {
350        /// Which sheep. No default anywhere in the stack, matching
351        /// `stop`/`restart`/`reload`/`delete`/`flush`: an operator names the
352        /// target rather than trigger an action against the whole flock by
353        /// accident.
354        selector: SelectorSpec,
355        /// The action name. Free-form — the daemon never declares, parses,
356        /// or validates it; an app that does not recognize the name is
357        /// expected to say so in its own reply rather than stay silent.
358        action: String,
359        /// Argument text for the action, passed through to the app
360        /// verbatim. One opaque string, not structured data: the daemon
361        /// holds no schema for it, matching the shepherd channel's own
362        /// `action` message this ultimately becomes.
363        params: Option<String>,
364    },
365    /// Deliver one signal to every matched sheep's OWN process — never its
366    /// process group (see `shep signal`).
367    Signal {
368        /// Which sheep. No default anywhere in the stack, matching every
369        /// other verb that reaches a running process: an operator names the
370        /// target rather than signal the whole flock by accident.
371        selector: SelectorSpec,
372        /// The signal's name, as
373        /// [`OperatorSignal`](crate::signals::OperatorSignal) spells it — the
374        /// `SIG` prefix and the case are both optional.
375        ///
376        /// A `String` rather than the enum, for the reason
377        /// [`AppConfig::kill_signal`](crate::config::AppConfig::kill_signal)
378        /// is one: the wire stays plain text a person can read in a capture,
379        /// and the daemon re-validates regardless, because peer input is
380        /// untrusted. A name outside the grammar answers
381        /// [`RpcErrorCode::InvalidConfig`].
382        signal: String,
383    },
384    /// Write one line to every matched sheep's stdin (see `shep whisper`).
385    SendLine {
386        /// Which sheep. No default, matching every other verb that reaches a
387        /// running process.
388        selector: SelectorSpec,
389        /// The line, WITHOUT its terminator — the shepherd appends exactly one
390        /// `\n` when it writes. Carrying the terminator here would leave "did
391        /// the caller include one" as a question every hop has to re-answer,
392        /// and a caller that included two would send an empty line the app
393        /// never asked for.
394        ///
395        /// A line containing an embedded newline is refused
396        /// ([`RpcErrorCode::InvalidConfig`]): it would deliver two commands
397        /// where the operator typed one.
398        line: String,
399    },
400    /// Write the muster roll now, bypassing the snapshot writer's debounce
401    SaveRoll,
402    /// Assemble the flock from the muster roll on disk: start every app the
403    /// roll recorded running, leaving every app the flock already has exactly
404    /// as it stands
405    Muster,
406    /// Ask for one dog's `[dog.<name>]` section, as the dog itself parses it
407    DogConfig {
408        /// The dog's name — the config key, not a selector
409        name: String,
410    },
411    /// Start one dog now, marking it as coming from `source`
412    EnableDog {
413        /// The dog's name
414        name: String,
415        /// Where its binary comes from
416        source: DogSource,
417    },
418    /// Stop and deregister one dog
419    ///
420    /// Answers [`Response::Deleted`], the same reply `Delete` gives: disabling
421    /// deregisters exactly as `Delete` does, so this is the same fact and not
422    /// a coincidence of shape. A variant of its own (`DogDisabled`, say) would
423    /// carry nothing `Deleted` does not.
424    DisableDog {
425        /// The dog's name
426        name: String,
427    },
428    /// Ask whether this daemon could hand its flock to a successor in place,
429    /// rather than stopping it and starting it again (`shep daemon reload`).
430    ///
431    /// Read-only, and nothing here triggers a handover. The trigger is a
432    /// signal and always was: a socket request cannot be the trigger, because
433    /// the case that most needs a reload is the one where the daemon refuses
434    /// the client at the handshake. What travels over the socket is the
435    /// DECISION, for a reason a signal cannot serve (spec H3a) -- a signal
436    /// carries no reply, so a daemon that took one, refused, and fell back to
437    /// its own graceful stop would leave the client polling for a successor
438    /// nobody started, with the flock down and staying down.
439    ///
440    /// Answers [`Response::HandoverFitness`]. Every refusal is a feature the
441    /// running daemon cannot yet carry, not an error: the caller falls back
442    /// to a stop-and-start, which is correct behaviour rather than a degraded
443    /// one, and prints the reason to the operator who asked for the reload.
444    ///
445    /// # Why this variant does not move `PROTOCOL_VERSION`
446    ///
447    /// An older daemon cannot deserialize a variant it has never seen, which
448    /// is normally what a bump is for. It is never sent to one. `daemon
449    /// reload` is an exempt verb, so it connects to a mismatched daemon
450    /// deliberately, learns the daemon's crate version from the handshake,
451    /// and takes the stop arm for anything predating the handover without
452    /// ever asking. shep-cli's `commands::daemon` holds that gate and a test
453    /// of its own pins it.
454    HandoverFitness,
455    /// Graceful daemon shutdown
456    KillDaemon,
457    /// Subscribe this connection to bus topics (glob patterns)
458    Subscribe {
459        /// Topic globs, e.g. `process.*`
460        topics: Vec<String>,
461    },
462}
463
464/// Where a dog came from: this binary, or one an operator adopted.
465///
466/// The one thing an operator wants when a dog misbehaves, which is why it
467/// is a column rather than a detail. Carried on [`ProcessInfo::dog`], so a
468/// listing distinguishes the two populations without a second request.
469///
470/// `#[non_exhaustive]`: a future source — a dog fetched from a registry,
471/// say — must not need a protocol version bump (IR-20).
472// wire format: changing existing variants is a breaking change
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474#[serde(tag = "kind", rename_all = "snake_case")]
475#[non_exhaustive]
476pub enum DogSource {
477    /// An argv branch of the shep binary itself (`shep dog <name>`).
478    BuiltIn,
479    /// A binary an operator adopted, run at the daemon's own trust level.
480    Adopted {
481        /// The binary's path, exactly as the operator gave it to `adopt`.
482        path: String,
483    },
484}
485
486/// One process the OS reports as a descendant of a sheep.
487///
488/// # What this is not
489///
490/// It is **not** the set of processes that die with the sheep, and nothing here
491/// should be read as promising that. The list is built by walking the OS's
492/// parent-pid links; the stop ladder acts on the process GROUP, and the two
493/// units diverge in both directions — a lamb that forks and exits leaves its
494/// own children re-parented to init, out of this list and still in the group,
495/// while a `setsid()` grandchild stays in this list and leaves the group.
496/// shep-daemon's `limits` module doc has the full account, and it is the
497/// authority; this is a pointer to it, not a second copy free to drift.
498///
499/// # Why a name and not a command line
500///
501/// `name` is the executable's name as the OS reports it (`node`, `sh`,
502/// `python3`), never its argument vector. A process's argv routinely carries
503/// credentials — a `--password=` flag, a URL with a token in the query string —
504/// and this field rides in `shep describe --format json`, which is output
505/// people paste into bug reports. A pid alone would be safe too, and was
506/// considered; it was rejected because a tree of bare integers sends the
507/// operator to `ps`, which is the work the tree exists to save.
508///
509/// # Why no memory figure
510///
511/// The sheep's own row already reports its whole tree's resident size
512/// ([`ProcessInfo::memory_bytes`]), and a per-lamb breakdown is a profiler's
513/// job. `deferred.md`'s note on this struct's growth asks for exactly this
514/// restraint.
515///
516/// `#[non_exhaustive]`: shep-core is a published library, this type is new, and
517/// the two obvious next fields (a parent pid, so a deep tree can be nested
518/// rather than flattened; a start time) would otherwise be breaking additions
519/// (IR-20). Build one with [`Self::new`].
520// wire format: changing this is a breaking change
521#[non_exhaustive]
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523pub struct Lamb {
524    /// The lamb's own pid.
525    pub pid: u32,
526    /// The executable's name, as the OS reports it. Never its command line.
527    pub name: String,
528}
529
530impl Lamb {
531    /// One lamb.
532    ///
533    /// A plain constructor rather than a builder, unlike [`ProcessInfo`]: both
534    /// fields are required and neither is optional or derived, which is the
535    /// case a builder buys nothing for.
536    #[must_use]
537    pub fn new(pid: u32, name: impl Into<String>) -> Self {
538        Self {
539            pid,
540            name: name.into(),
541        }
542    }
543}
544
545/// Why a sheep's process most recently stopped existing under this daemon.
546///
547/// Not shep-daemon's own `ExitOutcome` reused directly: that type lives
548/// behind the spawn-runner seam (`ProcessRunner::wait`, in shep-daemon's
549/// `runner` module) and is free to grow with whatever the real runner needs
550/// to observe next without dragging a breaking wire change behind it — this
551/// one only ever grows on its own say-so. The two happen to carry the same
552/// two fields today because the runner's own observation IS the honest exit
553/// outcome; shep-daemon converts one into the other at the point it records
554/// it (`Actor::handle_exited`), rather than this crate depending on
555/// shep-daemon's internals to reuse its type.
556///
557/// A struct, not two flat `Option<i32>` fields on [`ProcessInfo`] directly:
558/// with two flat fields, "this sheep has never exited under this daemon"
559/// and "it exited, killed by a signal this daemon did not name" are both
560/// all-`None`, and a reader cannot tell those apart. Nested behind
561/// [`ProcessInfo::last_exit`]'s own `Option`, that ambiguity moves up a
562/// level where it belongs: `None` there means "never exited"; `Some` means
563/// "exited, and here is what this daemon knows about it" — which itself
564/// mirrors the OS's own exited-normally/killed-by-signal split
565/// (`WIFEXITED`/`WIFSIGNALED`): ordinarily exactly one of `code`/`signal` is
566/// `Some`. A reader must not assume both can never be `None` together,
567/// though — that would still mean "this daemon recorded an exit; it could
568/// not characterize how" rather than "this sheep never exited", and this
569/// type does not forbid it.
570///
571/// No `#[non_exhaustive]`, unlike every other struct on this wire: those
572/// grow because a discriminator does (`ProcessInfo`'s own doc lists its
573/// four so far); `code`/`signal` is already the complete
574/// exited-normally/killed-by-signal split the runner exposes, this crate
575/// has no libc to derive a richer wait-status decomposition from even if it
576/// wanted one, and there is no forecast next field. Adding the attribute
577/// back later is a compatible change the day a real need appears (IR-16
578/// style); carrying it now on nothing but "might grow" is the exact
579/// speculative case IR-20 warns against.
580// wire format: changing this is a breaking change
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
582pub struct ExitInfo {
583    /// The process's own exit code, set on a normal exit (`WIFEXITED`).
584    pub code: Option<i32>,
585    /// The raw unix signal number that ended the process, set when it did
586    /// not exit on its own (`WIFSIGNALED`) — an operator's own `shep stop`
587    /// or `shep delete` included: the process still genuinely stopped by a
588    /// signal, and that stays true information even though shep, not a
589    /// crash, is what asked for it. Raw and platform-specific for the same
590    /// reason [`crate::signals::OperatorSignal`] carries no such accessor —
591    /// see that type's own module doc — so rendering this as a name
592    /// (`SIGKILL` rather than `9`) is a job for whichever OS-aware layer
593    /// reads it, never for this crate.
594    pub signal: Option<i32>,
595}
596
597/// Snapshot of one sheep for listings and events
598// wire format: changing this is a breaking change
599//
600// `out_file`/`err_file` are `Option<String>`, and both halves of that are
601// deliberate:
602//
603// String, not PathBuf. Every path already on this wire travels as a string
604// (`AppConfig::script`, `cwd`, `out_file`, `err_file`, all of which ride in
605// `Request::Start`), so this matches the established representation. It is
606// also the safer failure mode: serde's `PathBuf` impl REFUSES a non-UTF-8
607// path, and that refusal is not local — it aborts the whole `Reply`, so one
608// sheep with an odd log path would blank the entire `ListFlock` for every
609// other sheep. Lossy conversion daemon-side degrades exactly one field of
610// one sheep instead.
611//
612// Option, not a bare String. Semantically the daemon always resolves both
613// paths, so a required field is tempting. But the handshake only compares
614// `PROTOCOL_VERSION` (see shep-daemon's `server.rs`), and adding these
615// fields deliberately does NOT bump it — the evolution rule in this
616// module's parent says additive fields keep the version. A daemon built
617// before this field and a client built after it therefore both announce
618// protocol 1 and connect happily, and that daemon's replies carry no
619// `out_file` key at all. A required `String` would fail to deserialize
620// there, so a new client could not list against an old daemon. `None`
621// means precisely "this peer predates the field" — which readers must
622// render as unknown, NOT as "this sheep has no log file".
623//
624// `cpu_percent`/`memory_bytes` are optional for that same skew reason and
625// for one of their own: a sheep that is not running has no resource use to
626// report, and one that has been up for less than a sampling window has no
627// honest CPU figure. All three cases render as unknown, never as zero.
628//
629// No `Eq`, which every other wire struct in this module derives:
630// `cpu_percent` is an `f32` and floats are only partially ordered. Nothing
631// compares a `ProcessInfo` for total equality — `assert_eq!` needs only
632// `PartialEq`, and no listing is keyed on, hashed by, or sorted by a whole
633// row.
634/// `#[non_exhaustive]`: this struct grows fields over time with no hand-edit
635/// sweep needed across OUT-OF-TREE callers — it forbids a struct literal
636/// outside this crate, not inside it. `sample_info()` and
637/// [`ProcessInfoBuilder`] both still name every field and both still need
638/// updating the day a field is added; what the attribute buys is that
639/// nothing downstream does. `deferred.md`'s own `ProcessInfo` entry defers
640/// SPLITTING it into several smaller types, not growing it — this attribute
641/// plus [`ProcessInfo::builder`] is "deliberately the opposite of forcing
642/// the split early," which is what makes a field like `last_exit` cheap to
643/// add for a concrete operator need, not a reason to withhold one. Use
644/// [`ProcessInfo::builder`] to construct one; the fields stay `pub`, so
645/// reading them and assigning to them are both unchanged.
646#[non_exhaustive]
647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
648pub struct ProcessInfo {
649    /// Stable numeric id
650    pub id: u32,
651    /// Sheep name
652    pub name: String,
653    /// Lifecycle status
654    pub status: ProcStatus,
655    /// OS pid while running
656    pub pid: Option<u32>,
657    /// Restart count since registration
658    pub restarts: u32,
659    /// Milliseconds since last successful start
660    pub uptime_ms: u64,
661    /// Fold membership
662    pub fold: Option<String>,
663    /// Resolved stdout log path: the app's explicit
664    /// [`AppConfig::out_file`] when it set one, else the daemon-derived
665    /// default. `None` only when the peer daemon predates this field.
666    pub out_file: Option<String>,
667    /// Resolved stderr log path, resolved exactly as [`Self::out_file`]
668    pub err_file: Option<String>,
669    /// Tree CPU as a percentage of one core, over the window since the
670    /// daemon's last periodic sample. `None` when the sheep is not running,
671    /// when it has been up for less than one sampling window, or when the
672    /// peer daemon predates this field — all three of which a reader
673    /// renders as unknown, never as zero.
674    ///
675    /// A value over 100 is a tree using more than one core, not a bug.
676    pub cpu_percent: Option<f32>,
677    /// Tree resident set size in bytes, current as of the reply. `None`
678    /// under the same three conditions as [`Self::cpu_percent`], minus the
679    /// window one — memory needs no baseline.
680    pub memory_bytes: Option<u64>,
681    /// Set when this entry is a dog, naming where the dog came from;
682    /// `None` for a sheep.
683    ///
684    /// Unlike [`Self::cpu_percent`], `None` here does not need to enumerate
685    /// three cases. A daemon built before dogs existed has none, so "not a
686    /// dog" is the true answer whether this peer predates the field or the
687    /// entry is genuinely a sheep — there is no resource-usage-style claim
688    /// a stale zero could get wrong. Do not "fix" this into three cases.
689    pub dog: Option<DogSource>,
690    /// The processes the OS reports as descendants of this sheep, or `None`
691    /// when this reply did not walk for them.
692    ///
693    /// `None` covers two cases and is deliberately not a third: this reply is
694    /// not a `Describe` (only `Describe` walks — the walk costs a second pass
695    /// over the machine's process table, and a flock listing is the thing an
696    /// operator leaves running in a loop), or the peer daemon predates the
697    /// field. `Some(vec![])` is the third case, and the one that means what it
698    /// looks like: walked, and this sheep has no children.
699    ///
700    /// Read [`Lamb`]'s own doc before rendering this. The list is a parent-pid
701    /// walk and is NOT the set of processes a stop kills; any output built from
702    /// it has to say so where the operator will see it.
703    pub lambs: Option<Vec<Lamb>>,
704    /// How this sheep's process most recently stopped existing under this
705    /// daemon. `None` while it has never exited under this daemon — either
706    /// it has not been started yet, or it is still on its very first run —
707    /// and also when the peer daemon predates this field, the same skew
708    /// rule [`Self::out_file`] documents for itself.
709    ///
710    /// Sticky across a respawn, deliberately: this is the daemon's answer
711    /// to "why did it last stop", not "is it stopped right now" — `status`
712    /// and `pid` already answer that, and a sheep back `Online` after a
713    /// crash still has a true story to tell about the crash that restarted
714    /// it. It updates only on the next exit, never cleared by one starting
715    /// back up.
716    pub last_exit: Option<ExitInfo>,
717    /// The marker a dog has asked to have painted beside this sheep, or
718    /// `None` when no dog has painted one — which also covers a peer daemon
719    /// that predates the field, the same skew rule [`Self::out_file`]
720    /// documents for itself.
721    ///
722    /// A `String` rather than a [`Smit`], deliberately: a client decoding a
723    /// listing from a daemon that already validated the text should not have
724    /// to re-run the parser, and [`ProcessInfo`] is a report rather than an
725    /// input. The validation that makes this safe to print happened at the
726    /// daemon's ingress — see [`Smit`] for why there and not at the renderer.
727    ///
728    /// Every instance of a name shows the same marker: smits are keyed by
729    /// sheep name, not by instance id.
730    pub smit: Option<String>,
731    /// Which instance slot of its app this sheep occupies, counting from 0.
732    ///
733    /// `None` when the peer daemon predates the field, the same skew rule
734    /// [`Self::out_file`] documents for itself. Deliberately not a bare
735    /// `u32` defaulted to 0: an app stocked to four instances would then
736    /// report four rows all claiming slot 0, which is the silently-wrong
737    /// zero [`Self::dog`] warns against. A reader that finds `None` should
738    /// render exactly what it rendered before this field existed.
739    pub instance: Option<u32>,
740}
741
742/// Orders one flock listing the way every operator-facing surface presents
743/// one: by name, then by instance slot, then by id.
744///
745/// # Why name first
746///
747/// An id is assigned at registration, so ordering by it sorts the flock by
748/// an accident of history rather than by anything an operator is looking
749/// for. It is not stable either: a `delete all` followed by a fresh start
750/// moved a real thirteen-app flock from ids 0-10 to 11-21 with nothing
751/// about the apps having changed. A name is what an operator scans a long
752/// listing for, and it survives that churn.
753///
754/// # Why id breaks the tie
755///
756/// A name is unique to an APP, not to a sheep: an app stocked to four
757/// instances puts four rows under one name. Name alone is therefore not a
758/// total order, and an unstable sort would let those four shuffle between
759/// refreshes — visible in `shep flock` and worse in `shep lookout`, which
760/// repolls every two seconds. The id keeps its other job unchanged: it is
761/// still how an operator addresses one instance at `shep stop 11`. It stops
762/// being a sort key and stays an addressing key.
763///
764/// This is the ONLY ordering rule in shep, and the daemon's own
765/// `snapshot_all` calls this function rather than restating it. The order is
766/// `(name, instance, id)`: [`ProcessInfo::instance`] now carries the slot a
767/// row occupies, so a reload that hands slot 0 a fresh id no longer moves
768/// that row out of place. A listing whose rows all carry `None` (an older
769/// peer daemon, or a row with no slot to report) collapses to the same
770/// `(name, id)` order this function used before the field existed, because
771/// `None` sorts before every `Some` and every row in that listing shares it.
772pub fn sort_flock(listing: &mut [ProcessInfo]) {
773    listing.sort_unstable_by(|a, b| {
774        (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
775    });
776}
777
778impl ProcessInfo {
779    /// Starts a builder for one sheep's row.
780    ///
781    /// The three required arguments are the three fields no row can omit and
782    /// no reader can default: which sheep this is, what it is called, and
783    /// what state it is in. Everything else is optional, derived, or
784    /// meaningfully absent, which is exactly the shape a builder is for —
785    /// a nine-argument `new` would put `Option<String>, Option<String>,
786    /// Option<f32>, Option<u64>` next to each other at every call site and
787    /// invite a silent transposition the type system could not catch.
788    ///
789    /// No `#[must_use]` here: [`ProcessInfoBuilder`] already carries one,
790    /// which clippy's `double_must_use` lint treats as covering this
791    /// function's return too.
792    pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
793        ProcessInfoBuilder {
794            info: Self {
795                id,
796                name: name.into(),
797                status,
798                pid: None,
799                restarts: 0,
800                uptime_ms: 0,
801                fold: None,
802                out_file: None,
803                err_file: None,
804                cpu_percent: None,
805                memory_bytes: None,
806                dog: None,
807                lambs: None,
808                last_exit: None,
809                smit: None,
810                instance: None,
811            },
812        }
813    }
814}
815
816/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
817/// written as a struct literal outside this crate.
818///
819/// Every setter takes the field's own type, `Option` included, rather than
820/// the unwrapped value. That is deliberate and it is the difference between a
821/// straight port and a rewrite: the daemon already holds `Option<u32>` for a
822/// pid and `Option<f32>` for a CPU reading, so `.pid(entry.pid())` carries
823/// across unchanged where `.pid(u32)` would put an `if let` ladder at every
824/// call site. A setter is skipped, not passed `None`, when a row genuinely
825/// has nothing to say about that field.
826///
827/// Defaults for the skipped fields are the ones a not-yet-running sheep has:
828/// no pid, no uptime, no restarts, no resource reading, not a dog, never
829/// exited.
830#[derive(Debug, Clone)]
831#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
832pub struct ProcessInfoBuilder {
833    info: ProcessInfo,
834}
835
836impl ProcessInfoBuilder {
837    /// Sets the OS pid; `None` while the sheep is not running.
838    pub fn pid(mut self, pid: Option<u32>) -> Self {
839        self.info.pid = pid;
840        self
841    }
842
843    /// Sets the restart count since registration.
844    pub fn restarts(mut self, restarts: u32) -> Self {
845        self.info.restarts = restarts;
846        self
847    }
848
849    /// Sets milliseconds since the last successful start.
850    pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
851        self.info.uptime_ms = uptime_ms;
852        self
853    }
854
855    /// Sets fold membership.
856    pub fn fold(mut self, fold: Option<String>) -> Self {
857        self.info.fold = fold;
858        self
859    }
860
861    /// Sets the resolved stdout log path.
862    pub fn out_file(mut self, out_file: Option<String>) -> Self {
863        self.info.out_file = out_file;
864        self
865    }
866
867    /// Sets the resolved stderr log path.
868    pub fn err_file(mut self, err_file: Option<String>) -> Self {
869        self.info.err_file = err_file;
870        self
871    }
872
873    /// Sets tree CPU as a percentage of one core.
874    pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
875        self.info.cpu_percent = cpu_percent;
876        self
877    }
878
879    /// Sets tree resident set size in bytes.
880    pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
881        self.info.memory_bytes = memory_bytes;
882        self
883    }
884
885    /// Marks this row a dog and names where the dog came from.
886    pub fn dog(mut self, dog: Option<DogSource>) -> Self {
887        self.info.dog = dog;
888        self
889    }
890
891    /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
892    pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
893        self.info.lambs = lambs;
894        self
895    }
896
897    /// Sets how this sheep's process most recently stopped; `None` while it
898    /// has never exited under this daemon.
899    pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
900        self.info.last_exit = last_exit;
901        self
902    }
903
904    /// Sets the marker a dog has painted on this sheep; `None` when none has.
905    pub fn smit(mut self, smit: Option<String>) -> Self {
906        self.info.smit = smit;
907        self
908    }
909
910    /// Sets the instance slot; `None` when the peer daemon predates the field.
911    pub fn instance(mut self, instance: Option<u32>) -> Self {
912        self.info.instance = instance;
913        self
914    }
915
916    /// Finishes the row.
917    #[must_use]
918    pub fn build(self) -> ProcessInfo {
919        self.info
920    }
921}
922
923/// What happened when the daemon tried to deliver one sheep's triggered
924/// action.
925///
926/// `#[non_exhaustive]`: a future outcome — distinguishing a malformed reply
927/// from a well-formed one, say, or a second trigger already in flight for
928/// the same sheep — must not need a protocol version bump (IR-20).
929// wire format: changing existing variants is a breaking change
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
931#[serde(tag = "kind", rename_all = "snake_case")]
932#[non_exhaustive]
933pub enum ActionOutcome {
934    /// The app answered on the shepherd channel.
935    Replied {
936        /// The reply body, exactly as the app sent it.
937        body: String,
938    },
939    /// The sheep had no reachable shepherd channel for the daemon to
940    /// deliver the action over.
941    NoChannel,
942    /// The sheep is a reload drainee — mid-swap, on its way out — and the
943    /// daemon skipped it rather than deliver the action to a process
944    /// already being replaced.
945    Skipped,
946    /// The daemon delivered the action, but no reply arrived before the
947    /// app's configured action timeout elapsed.
948    TimedOut,
949}
950
951/// One matched sheep's row in a `Trigger` reply.
952///
953/// `EmptiedFile` (`crates/shep-cli/src/output/rows.rs`) is the precedent for
954/// a non-`ProcessInfo` row: a reply body has nowhere to live on
955/// [`ProcessInfo`], and [`Self::outcome`] is per-row rather than a
956/// whole-request refusal because spec §9's selector grammar (`all`,
957/// `/regex/`, `fold:`) makes a mixed flock the normal case — the same reason
958/// `Reopen`/`Flush` report per-item failure inside a success rather than
959/// failing the whole request.
960// wire format: changing this is a breaking change
961#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
962pub struct ActionReply {
963    /// The sheep's stable id.
964    pub id: u32,
965    /// The sheep's name.
966    pub name: String,
967    /// What happened when the daemon tried to deliver the action.
968    pub outcome: ActionOutcome,
969}
970
971/// What happened when the shepherd tried to deliver one signal.
972///
973/// `#[non_exhaustive]`: a future outcome — a sheep refused because it is a dog,
974/// say, or a delivery held while a stop ladder runs — must not need a protocol
975/// version bump (IR-20).
976// wire format: changing existing variants is a breaking change
977#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
978#[serde(tag = "kind", rename_all = "snake_case")]
979#[non_exhaustive]
980pub enum SignalOutcome {
981    /// The kernel accepted the signal for this sheep's pid.
982    ///
983    /// Says the signal was delivered, not that the app did anything with it.
984    /// A signal the app blocks, ignores, or has no handler for is `Delivered`
985    /// exactly like one it acts on — there is nothing on this path that could
986    /// tell the difference, and pretending otherwise would be the dishonest
987    /// half of an honest report.
988    Delivered,
989    /// The sheep is registered but has no live process to signal — stopped,
990    /// errored, or waiting out a restart backoff.
991    NotRunning,
992    /// The kernel refused the delivery; carries its reason (`ESRCH` for a
993    /// process reaped between the lookup and the syscall, `EPERM` for one this
994    /// daemon may not signal).
995    Failed {
996        /// The refusal, as the OS worded it.
997        reason: String,
998    },
999}
1000
1001/// One matched sheep's row in a `Signal` reply.
1002///
1003/// Shaped exactly like [`ActionReply`] and for the same reason: spec §9's
1004/// selector grammar (`all`, `/regex/`, `fold:`) makes a mixed flock the normal
1005/// case, so a per-row outcome beats a whole-request refusal that would leave
1006/// the operator unable to tell which half was taken.
1007// wire format: changing this is a breaking change
1008#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1009pub struct SignalReply {
1010    /// The sheep's stable id.
1011    pub id: u32,
1012    /// The sheep's name.
1013    pub name: String,
1014    /// What happened when the shepherd tried to deliver the signal.
1015    pub outcome: SignalOutcome,
1016}
1017
1018/// What happened when the shepherd tried to write one line to a sheep's stdin.
1019///
1020/// `#[non_exhaustive]`: a future outcome — a sheep refused because its pipe is
1021/// backed up, say — must not need a protocol version bump (IR-20).
1022// wire format: changing existing variants is a breaking change
1023#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1024#[serde(tag = "kind", rename_all = "snake_case")]
1025#[non_exhaustive]
1026pub enum LineOutcome {
1027    /// The line was written to the pipe and flushed.
1028    ///
1029    /// Says the bytes left the shepherd, not that the app read them. A pipe
1030    /// holds 64 KiB before it blocks, so a short line to an app that never
1031    /// reads its stdin is `Sent` — which is honest, because there is nothing
1032    /// on this path that could tell the difference and a supervisor inventing
1033    /// one would be guessing.
1034    Sent,
1035    /// The sheep has no stdin pipe: its config does not set `stdin = true`, or
1036    /// it is not running.
1037    ///
1038    /// One outcome for two causes, deliberately. The row is read to answer
1039    /// "why did my line not arrive", and both answers are "there is no pipe
1040    /// here"; splitting them would put the operator in front of a distinction
1041    /// with the same fix behind it. A sheep that is not running is visible as
1042    /// such in `shep flock`, which is where that question belongs.
1043    NoStdin,
1044    /// The shepherd had a pipe and did not confirm a write to it; carries
1045    /// why.
1046    ///
1047    /// Three shapes reach it: the write failed (the far end is gone —
1048    /// normally the app exiting between the lookup and the write), the line
1049    /// arrived to find the sheep's queue already full, or the write did not
1050    /// finish inside the shepherd's own bound. The reason names which,
1051    /// because the operator's next move differs.
1052    ///
1053    /// # The last shape does not promise the line was never written
1054    ///
1055    /// "Did not confirm", not "could not write", and the difference is the
1056    /// operator's whole decision about retrying. A write that timed out is a
1057    /// write the shepherd stopped WAITING for: the bytes may be part-written
1058    /// into a pipe the app is not draining, and they land in full the moment
1059    /// it does. There is no way to take them back — abandoning a write
1060    /// halfway would leave a partial line in the pipe, which is worse than a
1061    /// slow one.
1062    ///
1063    /// What the shepherd does do is drop a line still QUEUED behind that one
1064    /// once its caller has given up, so retrying a `sendline` cannot pile
1065    /// duplicates up behind a wedged pipe and deliver them together later.
1066    /// The first line of a retry sequence is the one that can still arrive
1067    /// late; treat a retry as a second command, not a repeat of the first.
1068    NotWritten {
1069        /// What went wrong, in plain English.
1070        reason: String,
1071    },
1072}
1073
1074/// One matched sheep's row in a `SendLine` reply.
1075///
1076/// Same shape and same argument as [`ActionReply`] and [`SignalReply`]: spec
1077/// §9's selector grammar makes a mixed flock the normal case, so an outcome
1078/// per row beats a whole-request refusal.
1079// wire format: changing this is a breaking change
1080#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1081pub struct LineReply {
1082    /// The sheep's stable id.
1083    pub id: u32,
1084    /// The sheep's name.
1085    pub name: String,
1086    /// What happened.
1087    pub outcome: LineOutcome,
1088}
1089
1090/// A dog's `[dog.<name>]` config section, carried as TOML text.
1091///
1092/// This travels over the socket rather than the child's environment for
1093/// exactly one reason: a dog's section routinely holds webhook credentials
1094/// (a Discord or Slack URL with a bearer token embedded), and the socket
1095/// path keeps that out of the process table and out of crash dumps. A
1096/// derived `Debug` on [`Response`] would undo that the moment something
1097/// logs a reply — see the manual `Debug` below, which prints only a length.
1098///
1099/// `#[serde(transparent)]` makes the wire representation identical to a
1100/// bare `String`: this newtype changes nothing about
1101/// [`crate::protocol::PROTOCOL_VERSION`] or the pinned snapshot fixtures.
1102#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1103#[serde(transparent)]
1104pub struct DogSectionToml(String);
1105
1106impl DogSectionToml {
1107    /// The TOML text, empty when the file has no such section.
1108    #[must_use]
1109    pub fn as_str(&self) -> &str {
1110        &self.0
1111    }
1112}
1113
1114impl From<String> for DogSectionToml {
1115    fn from(toml: String) -> Self {
1116        Self(toml)
1117    }
1118}
1119
1120impl core::ops::Deref for DogSectionToml {
1121    type Target = str;
1122
1123    fn deref(&self) -> &str {
1124        &self.0
1125    }
1126}
1127
1128/// Debug does not print the section body (IR-41) — see the type doc for why.
1129/// Exact-string-tested below (`dog_section_toml_debug_does_not_leak`) so a
1130/// future `#[derive(Debug)]` fails that test instead of silently reopening
1131/// the leak.
1132impl fmt::Debug for DogSectionToml {
1133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1134        write!(f, "DogSectionToml(<{} bytes>)", self.0.len())
1135    }
1136}
1137
1138/// One registered sheep whose stored config differs from a caller's copy:
1139/// the answer [`Request::ConfigDrift`] is asking for
1140///
1141/// Field NAMES only, never their values. This is built to be printed at an
1142/// operator, and [`AppConfig::env`](crate::config::AppConfig::env) carries
1143/// secrets, so a differing `env` reports `"env"` and nothing more (IR-41).
1144/// `Debug` is derived for that reason: there is nothing here to redact.
1145// wire format: changing field names is a breaking change
1146//
1147// `#[non_exhaustive]`: shep-core is a published library, an out-of-tree
1148// consumer can match or construct this exhaustively today, and a third field
1149// (which side is newer, say) would break them with no version bump to say
1150// so (IR-20). [`SheepDrift::new`] is how the daemon builds one.
1151#[non_exhaustive]
1152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1153pub struct SheepDrift {
1154    /// The sheep's name. Both configs share it by construction: it is what
1155    /// matched them to each other.
1156    pub name: String,
1157    /// The [`AppConfig`] fields that differ, in
1158    /// field-name order. Never empty: a sheep with nothing to report is left
1159    /// out of the answer entirely.
1160    pub fields: Vec<String>,
1161}
1162
1163impl SheepDrift {
1164    /// Builds one sheep's report.
1165    ///
1166    /// No builder, unlike [`ProcessInfo`]: both fields are required and
1167    /// neither can be defaulted, so there is no optional surface for one to
1168    /// spare a caller.
1169    #[must_use]
1170    pub fn new(name: impl Into<String>, fields: Vec<String>) -> Self {
1171        Self {
1172            name: name.into(),
1173            fields,
1174        }
1175    }
1176}
1177
1178/// One RPC response (pairs with [`Request`] variants)
1179///
1180/// Ten variants carry a bare `Vec<ProcessInfo>` (`Flock`, `Described`,
1181/// `Started`, `Stopped`, `Restarted`, `Reloading`, `Scaled`, `Reopened`,
1182/// `Flushed`, `Mustered`), and that repetition is intentional — do not
1183/// collapse them into one. Each names which request it answers, which is what
1184/// lets a variant diverge later without a protocol bump: `Reloading` already
1185/// means an acceptance rather than a result, `Scaled` already means only the
1186/// survivors on a scale-down rather than every matched row, and `Mustered`
1187/// already means "every sheep of every restored app" rather than "what this
1188/// call started". A single `Listing(Vec<ProcessInfo>)` would have to
1189/// relitigate all three as a breaking change.
1190// wire format: changing existing variants is a breaking change
1191//
1192// `large_enum_variant` allowed, not fixed: `DogStarted` holds a whole
1193// `ProcessInfo` inline where every other variant holds a `Vec` of them, and
1194// adding `smit` to that struct is what pushed the spread past the lint's
1195// threshold. Clippy's remedy is to box the payload, which would be a source
1196// break for every `Response::DogStarted(info)` in and out of this workspace
1197// — for nothing: a `Response` is built once per reply and serialized
1198// immediately, so the size it occupies on one stack frame in between is not
1199// a cost anybody pays. The wire shape is identical either way.
1200#[allow(clippy::large_enum_variant)]
1201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1202#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
1203#[non_exhaustive]
1204pub enum Response {
1205    /// Answer to `Ping`
1206    Pong,
1207    /// Answer to `ListFlock`
1208    Flock(Vec<ProcessInfo>),
1209    /// Answer to `Describe`
1210    Described(Vec<ProcessInfo>),
1211    /// Answer to `Start`
1212    Started(Vec<ProcessInfo>),
1213    /// Answer to `ConfigDrift`: one entry per app that is registered under a
1214    /// config different from the one asked about, and no entry for anything
1215    /// else. An empty vector means every app asked about either matches or
1216    /// is not registered at all.
1217    Drifted(Vec<SheepDrift>),
1218    /// Answer to `Stop`
1219    Stopped(Vec<ProcessInfo>),
1220    /// Answer to `Restart`
1221    Restarted(Vec<ProcessInfo>),
1222    /// Answer to `Reload` — an ACCEPTANCE, not a result, and the only reply
1223    /// in this enum carrying a flock listing that names one rather than
1224    /// finished work. [`Self::ShuttingDown`] is an acceptance too, sent
1225    /// before the daemon actually goes down, but it carries nothing.
1226    ///
1227    /// One instance costs a readiness wait plus a drain in the worst case, so
1228    /// a clustered app outlasts any deadline a client is allowed to ask for.
1229    /// The daemon therefore answers as soon as the reload is accepted, with
1230    /// the matched sheep as they stood at that moment, and the swaps report
1231    /// themselves on the bus — `process.reload`, `process.reloaded`,
1232    /// `process.reload_abandoned`. A matched sheep with nothing to replace is
1233    /// listed here as the no-op success it is, so this carries the same
1234    /// matches `Describe` would.
1235    Reloading(Vec<ProcessInfo>),
1236    // The order is cited as [`sort_flock`]'s shared rule rather than restated
1237    // as this reply's own, so the two cannot drift apart.
1238    /// Answer to `Scale` — the app's instances that will REMAIN, one row
1239    /// each, by name, then by instance slot, then by id ([`sort_flock`]).
1240    /// Every row shares one name here, so in practice that is slot order,
1241    /// with the id breaking a tie only where two rows report the same slot.
1242    ///
1243    /// Scaling up, these are the instances that exist, the new ones included,
1244    /// and the answer is complete.
1245    ///
1246    /// Scaling down, these are the survivors and the departing instances are
1247    /// deliberately absent, even though they are still running their kill
1248    /// ladders as this reply is written. The operator asked for a number; this
1249    /// is that number of rows. Listing the departing ones as well would answer
1250    /// a `scale web 2` with four rows, which is the one thing the reply must
1251    /// not do. The departures report themselves on the bus as `process.delete`
1252    /// — the same split `Reloading` already makes between an acceptance and
1253    /// the swaps that follow it.
1254    Scaled(Vec<ProcessInfo>),
1255    /// Answer to `SetSmit` — every instance of the named sheep, one row
1256    /// each, each carrying the smit as it now stands.
1257    ///
1258    /// Its own variant rather than one of the ten above, on this enum's own
1259    /// stated terms: each of them names which request it answers so that one
1260    /// can diverge later without a protocol bump. A future `SetSmit` reply
1261    /// that also reported which connection holds the mark would have nowhere
1262    /// to go if this shared `Scaled`.
1263    SmitPainted(Vec<ProcessInfo>),
1264    /// Answer to `Delete` — ids removed
1265    Deleted(Vec<u32>),
1266    /// Answer to `Reopen` — every matched sheep, running or not. A sheep with
1267    /// no live log pump has nothing to reopen and is reported as a success,
1268    /// so this carries the same matches `Describe` would.
1269    Reopened(Vec<ProcessInfo>),
1270    /// Answer to `Flush` — one row per matched sheep, running or not, exactly
1271    /// as [`Self::Reopened`].
1272    ///
1273    /// One row per SHEEP, not per file emptied. Several sheep can share one
1274    /// log path (`merge_logs`, or an explicit `out_file` on a multi-instance
1275    /// app), and the daemon truncates each distinct path once — but the
1276    /// selector names sheep, so the answer names sheep, and the count here
1277    /// matches what `Describe` would return for the same selector.
1278    Flushed(Vec<ProcessInfo>),
1279    /// Answer to `Trigger` — one [`ActionReply`] row per matched sheep,
1280    /// carrying what each one answered rather than a flock listing:
1281    /// `ProcessInfo` has nowhere to hold a reply body.
1282    Triggered(Vec<ActionReply>),
1283    /// Answer to `Signal` — one [`SignalReply`] row per matched sheep.
1284    ///
1285    /// Not a flock listing: what a caller wants back is per-instance delivery,
1286    /// and [`ProcessInfo`] has nowhere to hold it. Same reasoning, and the
1287    /// same row-shaped answer, as [`Self::Triggered`].
1288    Signalled(Vec<SignalReply>),
1289    /// Answer to `SendLine` — one [`LineReply`] row per matched sheep.
1290    SentLine(Vec<LineReply>),
1291    /// Answer to `SaveRoll`
1292    RollSaved {
1293        /// Absolute path of the roll the daemon wrote
1294        path: String,
1295        /// How many apps that roll records
1296        apps: u32,
1297    },
1298    /// Answer to `Muster` — every sheep of every app the roll restored, not
1299    /// only the ones this call spawned.
1300    ///
1301    /// The distinction is the whole point of the reply. Assembling a flock
1302    /// that is already assembled starts nothing, so a listing of what this
1303    /// call spawned would be empty there — indistinguishable from an empty
1304    /// roll, which is the one outcome an operator needs to tell apart.
1305    Mustered(Vec<ProcessInfo>),
1306    /// Answer to `DogConfig` — the dog's own section, rendered back to TOML.
1307    ///
1308    /// `toml` is [`DogSectionToml`], not a bare `String`: this text
1309    /// routinely carries webhook credentials, and the newtype's manual
1310    /// `Debug` keeps them out of a `{:?}`-formatted `Response` — see that
1311    /// type's docs for why the section travels over the socket at all.
1312    DogSection {
1313        /// The `[dog.<name>]` table as TOML text, empty when the file has
1314        /// no such section
1315        toml: DogSectionToml,
1316    },
1317    /// Answer to `EnableDog` — the dog as it stands now
1318    DogStarted(ProcessInfo),
1319    /// Answer to `HandoverFitness`: `None` when the whole flock can be
1320    /// carried across a daemon handover, and otherwise the sentence saying
1321    /// which sheep cannot be and why.
1322    ///
1323    /// A rendered sentence rather than a structured reason, deliberately. The
1324    /// set of things a handover cannot yet carry is exactly the set of things
1325    /// that phase has not built, so it changes with every phase that widens
1326    /// it, and a wire enum would make each of those a protocol change for a
1327    /// string the client does nothing with but print. The daemon owns the
1328    /// wording because the daemon owns the gate.
1329    HandoverFitness {
1330        /// Why the flock cannot be handed over in place, or `None` when it
1331        /// can.
1332        refusal: Option<String>,
1333    },
1334    /// Answer to `Subscribe`
1335    Subscribed,
1336    /// Answer to `KillDaemon`
1337    ShuttingDown,
1338}
1339
1340/// A request frame
1341// wire format: changing this is a breaking change
1342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1343pub struct Envelope {
1344    /// Per-connection request id
1345    pub id: u64,
1346    /// Client-imposed deadline (daemon aborts work past it)
1347    pub deadline_ms: Option<u64>,
1348    /// The request
1349    pub body: Request,
1350}
1351
1352/// A reply frame
1353///
1354/// `result` uses serde's stock `Result` representation — the wire carries
1355/// `{"Ok": ...}` / `{"Err": ...}` (capitalized keys). Deliberate, pinned by
1356/// snapshot: stock serde beats a custom enum the client would convert anyway.
1357// wire format: changing this is a breaking change
1358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1359pub struct Reply {
1360    /// Echoes [`Envelope::id`]
1361    pub id: u64,
1362    /// The outcome
1363    pub result: Result<Response, RpcError>,
1364}
1365
1366/// Handshake outcome: `HelloAck` or a typed refusal (spec §6 —
1367/// version skew is an error, not silence). Same `Ok`/`Err` wire shape
1368/// as [`Reply::result`]; refusals use [`RpcErrorCode::ProtocolMismatch`].
1369pub type HelloReply = Result<HelloAck, RpcError>;
1370
1371/// Structured RPC failure
1372// wire format: changing this is a breaking change
1373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1374pub struct RpcError {
1375    /// Machine-readable code
1376    pub code: RpcErrorCode,
1377    /// Human-readable message (plain English, no theme)
1378    pub message: String,
1379    /// The daemon's own crate version, when it chose to name it.
1380    ///
1381    /// Set on a [`RpcErrorCode::ProtocolMismatch`] refusal, where it is the
1382    /// only place a client can learn it: the refusal reports the daemon's
1383    /// PROTOCOL, and [`HelloAck::daemon_version`] never arrives. `shep
1384    /// daemon reload` picks its mechanism by version, and a protocol bump is
1385    /// exactly when that choice matters.
1386    ///
1387    /// `None` on every other error, and on any refusal from a daemon built
1388    /// before this field existed — which no upgrade can change, so a reader
1389    /// must treat `None` as "unknown" and take the conservative path.
1390    ///
1391    /// Additive by construction: absent on the wire rather than `null`, and
1392    /// ignored by a client too old to know it, so
1393    /// [`crate::protocol::PROTOCOL_VERSION`] does not move for it.
1394    #[serde(default, skip_serializing_if = "Option::is_none")]
1395    pub daemon_version: Option<String>,
1396}
1397
1398/// Machine-readable RPC error codes
1399// wire format: changing existing variants is a breaking change
1400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1401#[serde(rename_all = "snake_case")]
1402#[non_exhaustive]
1403pub enum RpcErrorCode {
1404    /// Selector matched nothing
1405    NotFound,
1406    /// Config failed validation daemon-side
1407    InvalidConfig,
1408    /// Spawn failed (exec error, permissions)
1409    SpawnFailed,
1410    /// Handshake protocol version mismatch
1411    ProtocolMismatch,
1412    /// Unexpected daemon-side failure
1413    Internal,
1414    /// The request's deadline expired before the daemon finished it
1415    DeadlineExceeded,
1416}
1417
1418impl RpcErrorCode {
1419    /// Every variant, for code that needs to iterate them all.
1420    ///
1421    /// `#[non_exhaustive]` forces a `_` arm on any match written outside
1422    /// this crate, which would silently swallow a variant added here and
1423    /// never updated there (shep-cli's exit-code mapping test is the
1424    /// motivating case — see `crates/shep-cli/src/exit.rs`). Downstream
1425    /// crates should iterate `ALL` instead of hand-writing their own list
1426    /// that the compiler can't check.
1427    ///
1428    /// Kept honest by a private `assert_all_lists_every_variant` fn right
1429    /// below: read that doc for how a forgotten variant is caught here,
1430    /// where `#[non_exhaustive]` has no effect.
1431    pub const ALL: [Self; 6] = [
1432        Self::NotFound,
1433        Self::InvalidConfig,
1434        Self::SpawnFailed,
1435        Self::ProtocolMismatch,
1436        Self::Internal,
1437        Self::DeadlineExceeded,
1438    ];
1439
1440    /// Never called; exists purely so this crate fails to build if a
1441    /// variant is added to [`RpcErrorCode`] without also adding it to
1442    /// [`Self::ALL`].
1443    ///
1444    /// `#[non_exhaustive]` only forces a wildcard arm on matches written
1445    /// *outside* this crate — inside the crate that defines the enum, a
1446    /// match with no `_` arm is still checked for exhaustiveness (E0004),
1447    /// so a new variant breaks this build until it gets an arm here. Each
1448    /// arm indexes a fixed literal position into [`Self::ALL`], so growing
1449    /// the enum without growing the array is caught too: rustc denies an
1450    /// out-of-bounds constant array index by default.
1451    #[allow(dead_code)]
1452    const fn assert_all_lists_every_variant(code: Self) -> Self {
1453        match code {
1454            Self::NotFound => Self::ALL[0],
1455            Self::InvalidConfig => Self::ALL[1],
1456            Self::SpawnFailed => Self::ALL[2],
1457            Self::ProtocolMismatch => Self::ALL[3],
1458            Self::Internal => Self::ALL[4],
1459            Self::DeadlineExceeded => Self::ALL[5],
1460        }
1461    }
1462}
1463
1464#[cfg(test)]
1465mod tests {
1466    use super::*;
1467    use crate::config::AppConfig;
1468    use crate::protocol::PROTOCOL_VERSION;
1469    use crate::status::ProcStatus;
1470
1471    fn sample_info() -> ProcessInfo {
1472        ProcessInfo {
1473            id: 3,
1474            name: "web".to_string(),
1475            status: ProcStatus::Online,
1476            pid: Some(4242),
1477            restarts: 1,
1478            uptime_ms: 60_000,
1479            fold: Some("backend".to_string()),
1480            out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
1481            err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
1482            // 12.5 rather than a rounder-looking 12.3: an insta JSON
1483            // snapshot is only stable across platforms for a float the
1484            // binary representation holds exactly.
1485            cpu_percent: Some(12.5),
1486            memory_bytes: Some(48 * 1024 * 1024),
1487            dog: None,
1488            lambs: None,
1489            // `restarts: 1` above already says this sheep crashed once and
1490            // came back; a code rather than `None` is the honest exit that
1491            // caused it, not a fact this fixture invents.
1492            last_exit: Some(ExitInfo {
1493                code: Some(1),
1494                signal: None,
1495            }),
1496            smit: None,
1497            instance: None,
1498        }
1499    }
1500
1501    /// fails if the builder's defaults drift from what a registered-but-not-yet
1502    /// running sheep actually looks like. A builder that quietly defaulted
1503    /// `uptime_ms` to something non-zero, or `restarts` to 1, would put a wrong
1504    /// number in front of an operator with nothing to compare it against.
1505    #[test]
1506    fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
1507        let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
1508
1509        assert_eq!(info.id, 3);
1510        assert_eq!(info.name, "web");
1511        assert_eq!(info.status, ProcStatus::Stopped);
1512        assert_eq!(info.pid, None);
1513        assert_eq!(info.restarts, 0);
1514        assert_eq!(info.uptime_ms, 0);
1515        assert_eq!(info.fold, None);
1516        assert_eq!(info.out_file, None);
1517        assert_eq!(info.err_file, None);
1518        assert_eq!(info.cpu_percent, None);
1519        assert_eq!(info.memory_bytes, None);
1520        assert_eq!(info.dog, None);
1521        assert_eq!(info.lambs, None);
1522        assert_eq!(info.last_exit, None);
1523    }
1524
1525    /// fails if any setter writes a field other than its own — the failure a
1526    /// twelve-field builder is most likely to ship, and one no individual
1527    /// round-trip test would catch. Every field is given a value distinct from
1528    /// every other field's default, so a copy-pasted setter body shows up as a
1529    /// mismatch rather than as a coincidence.
1530    #[test]
1531    fn every_setter_writes_its_own_field_and_no_other() {
1532        let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
1533            .pid(Some(4242))
1534            .restarts(1)
1535            .uptime_ms(60_000)
1536            .fold(Some("backend".to_string()))
1537            .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
1538            .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
1539            .cpu_percent(Some(12.5))
1540            .memory_bytes(Some(48 * 1024 * 1024))
1541            .dog(None)
1542            .last_exit(Some(ExitInfo {
1543                code: Some(1),
1544                signal: None,
1545            }))
1546            .build();
1547
1548        // `sample_info()` is still a struct literal, on purpose: it is the one
1549        // place in the workspace that names every field by hand, so this
1550        // comparison fails the day the struct grows a field the builder cannot
1551        // set. That is the point of comparing against it rather than against
1552        // another builder call.
1553        assert_eq!(built, sample_info());
1554
1555        // `dog` is the one field the comparison above cannot speak for, and it
1556        // is the field the whole dogs subsystem reads. `sample_info()`'s `dog`
1557        // is `None`, which is also the builder's default, so a `dog` setter with
1558        // an EMPTY BODY passes the assert_eq! above and passes it for the wrong
1559        // reason. `sample_info()` cannot be changed to `Some(..)` to fix that —
1560        // it feeds `reply_wire_snapshots` and `bus_event_wire_snapshots`, so
1561        // altering it moves pinned bytes. So the field gets its own line, with a
1562        // value nothing defaults to.
1563        assert_eq!(
1564            ProcessInfo::builder(1, "metrics", ProcStatus::Online)
1565                .dog(Some(DogSource::BuiltIn))
1566                .build()
1567                .dog,
1568            Some(DogSource::BuiltIn),
1569            "an empty `dog` setter body is invisible to the comparison above"
1570        );
1571
1572        // `lambs` is the second field the comparison above cannot speak for,
1573        // for the identical reason `dog` is the first: `sample_info()`'s value
1574        // is `None`, which is also the builder's default, so an EMPTY `lambs`
1575        // setter body passes the `assert_eq!` above. And `sample_info()` still
1576        // cannot be changed to a `Some(..)` — it feeds `reply_wire_snapshots`
1577        // and `bus_event_wire_snapshots`, so altering it moves pinned bytes.
1578        assert_eq!(
1579            ProcessInfo::builder(1, "web", ProcStatus::Online)
1580                .lambs(Some(vec![Lamb::new(4243, "node")]))
1581                .build()
1582                .lambs,
1583            Some(vec![Lamb::new(4243, "node")]),
1584            "an empty `lambs` setter body is invisible to the comparison above"
1585        );
1586
1587        // `smit` is the third, on the same terms, and it is the field a
1588        // third party writes — so an empty setter body here would silently
1589        // drop every dog's mark rather than merely lose a decoration.
1590        assert_eq!(
1591            ProcessInfo::builder(1, "web", ProcStatus::Online)
1592                .smit(Some("\u{25b2} main@a1b2c3".to_string()))
1593                .build()
1594                .smit
1595                .as_deref(),
1596            Some("\u{25b2} main@a1b2c3"),
1597            "an empty `smit` setter body is invisible to the comparison above"
1598        );
1599    }
1600
1601    /// fails if `lambs` collapses to a bare `Vec`. The three states are the point:
1602    /// a peer that predates the field and a reply that did not walk the tree are
1603    /// both `None`, and a sheep that really has no children is `Some(vec![])`. A
1604    /// `Vec` would render the first two as "this sheep has no lambs", which is a
1605    /// claim neither of them makes.
1606    #[test]
1607    fn lambs_distinguishes_not_walked_from_walked_and_empty() {
1608        let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
1609        assert_eq!(not_walked.lambs, None);
1610
1611        let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
1612            .lambs(Some(Vec::new()))
1613            .build();
1614        assert_eq!(walked_empty.lambs, Some(Vec::new()));
1615    }
1616
1617    /// fails if a `ProcessInfo` from a daemon that predates the field stops
1618    /// deserializing. That is the whole reason the field is optional and the reason
1619    /// `PROTOCOL_VERSION` does not move for it — an old daemon's reply carries no
1620    /// `lambs` key at all, and a required field there would mean a new client could
1621    /// not list against an old daemon.
1622    #[test]
1623    fn a_process_info_without_a_lambs_key_still_deserializes() {
1624        let fixture = r#"{
1625            "id": 3, "name": "web", "status": "online", "pid": 4242,
1626            "restarts": 0, "uptime_ms": 100, "fold": null,
1627            "out_file": null, "err_file": null,
1628            "cpu_percent": null, "memory_bytes": null, "dog": null
1629        }"#;
1630        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1631        assert_eq!(info.lambs, None);
1632    }
1633
1634    /// fails if a lamb stops carrying its name, or starts carrying a command line.
1635    /// The name is `sysinfo`'s executable name, never argv — argv routinely holds
1636    /// credentials (`--password=`, `?token=`) and `shep describe --format json` is
1637    /// output people paste into issues.
1638    #[test]
1639    fn a_lamb_is_a_pid_and_an_executable_name() {
1640        let lamb = Lamb::new(4243, "node");
1641        let json = serde_json::to_string(&lamb).unwrap();
1642        assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
1643        assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
1644    }
1645
1646    /// fails if `DogSource` loses its `tag = "kind"` or its snake_case
1647    /// rename, and fails if `Adopted`'s `path` is renamed — any of the three
1648    /// changes one of these two strings while every type-level test in this
1649    /// module keeps passing. The marker is what the CLI splits two tables on
1650    /// and what the metrics dog reports a health gauge from, so a silent
1651    /// rename here is a silently empty dogs table.
1652    #[test]
1653    fn a_dog_source_serializes_snake_case_under_its_kind() {
1654        assert_eq!(
1655            serde_json::to_string(&DogSource::BuiltIn).unwrap(),
1656            r#"{"kind":"built_in"}"#
1657        );
1658        let adopted = DogSource::Adopted {
1659            path: "/usr/local/bin/shep-otel".to_string(),
1660        };
1661        let wire = r#"{"kind":"adopted","path":"/usr/local/bin/shep-otel"}"#;
1662        assert_eq!(serde_json::to_string(&adopted).unwrap(), wire);
1663        assert_eq!(serde_json::from_str::<DogSource>(wire).unwrap(), adopted);
1664    }
1665
1666    /// fails if `dog` stops being optional. A daemon built before dogs
1667    /// sends a reply with no such key and still announces protocol 1, so a
1668    /// required field would make a current client unable to list against it
1669    /// at all — the same skew rule `out_file` and `cpu_percent` are pinned
1670    /// under, and the same committed-byte-fixture proof.
1671    #[test]
1672    fn v1_process_info_without_a_dog_marker_still_deserializes() {
1673        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}"#;
1674        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1675        assert_eq!(info.dog, None);
1676    }
1677
1678    /// fails if `last_exit` stops being optional. A daemon built before this
1679    /// field sends a reply with no such key and still announces protocol 1 —
1680    /// the same skew rule every other field added after `Hello`/`HelloAck`
1681    /// were fixed is pinned under.
1682    ///
1683    /// This is also the empirical proof of a subtle point: none of
1684    /// `ProcessInfo`'s fields carry `#[serde(default)]`, and there is
1685    /// no container-level one either, yet the doc comments on `out_file` and
1686    /// `cpu_percent` both claim "`None` only when the peer daemon predates
1687    /// this field" as though one existed. Serde's `Deserialize` derive
1688    /// special-cases a field whose type is syntactically `Option<...>`: a
1689    /// missing key resolves to `None` without `#[serde(default)]` doing
1690    /// anything, because the derive macro recognizes the `Option` wrapper
1691    /// itself and generates that fallback for it. Those doc comments were
1692    /// right; they just named the wrong mechanism, or none. This test pins
1693    /// the real one for `last_exit` specifically — with `dog` and `lambs`
1694    /// present but `last_exit` genuinely absent from the JSON below — rather
1695    /// than leaving it as an inference from `v1_process_info_without_a_dog_
1696    /// marker_still_deserializes` above.
1697    #[test]
1698    fn a_process_info_without_a_last_exit_key_still_deserializes() {
1699        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}"#;
1700        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1701        assert_eq!(info.last_exit, None);
1702    }
1703
1704    /// fails if a `Signal` frame stops carrying the signal name as plain text, or
1705    /// if the outcome rows stop distinguishing their three cases. The name travels
1706    /// as a `String` on purpose (`AppConfig::kill_signal` does the same): the wire
1707    /// stays readable and the daemon re-validates, which it has to do anyway
1708    /// because peer input is untrusted.
1709    #[test]
1710    fn a_signal_request_and_its_reply_round_trip() {
1711        let request = Request::Signal {
1712            selector: SelectorSpec::Name("web".to_string()),
1713            signal: "SIGHUP".to_string(),
1714        };
1715        let json = serde_json::to_string(&request).unwrap();
1716        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1717
1718        let reply = Response::Signalled(vec![
1719            SignalReply {
1720                id: 1,
1721                name: "web".to_string(),
1722                outcome: SignalOutcome::Delivered,
1723            },
1724            SignalReply {
1725                id: 2,
1726                name: "web".to_string(),
1727                outcome: SignalOutcome::NotRunning,
1728            },
1729            SignalReply {
1730                id: 3,
1731                name: "api".to_string(),
1732                outcome: SignalOutcome::Failed {
1733                    reason: "no such process".to_string(),
1734                },
1735            },
1736        ]);
1737        let json = serde_json::to_string(&reply).unwrap();
1738        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1739        // The three tags, spelled out: a variant renamed in Rust changes these
1740        // strings mechanically, compiles clean, and breaks a client matching on
1741        // them with nothing to say why.
1742        assert!(json.contains(r#""kind":"delivered""#), "{json}");
1743        assert!(json.contains(r#""kind":"not_running""#), "{json}");
1744        assert!(json.contains(r#""kind":"failed""#), "{json}");
1745    }
1746
1747    /// fails if `Scale` grows a selector. It takes an app NAME, and that is the
1748    /// design: `instances` is a per-app number and instance slots are allocated
1749    /// per name-group, so `shep stock /web.*/ 4` would have to mean either four
1750    /// each or four total and there is no reading of it that is not a guess.
1751    #[test]
1752    fn a_scale_request_names_one_app_and_a_count() {
1753        let request = Request::Scale {
1754            name: "web".to_string(),
1755            count: 4,
1756        };
1757        let json = serde_json::to_string(&request).unwrap();
1758        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1759        assert!(json.contains(r#""kind":"scale""#), "{json}");
1760        assert!(json.contains(r#""name":"web""#), "{json}");
1761        // No `selector` key at all — the shape that says this verb is not one of
1762        // the selector-taking family.
1763        assert!(!json.contains("selector"), "{json}");
1764    }
1765
1766    /// fails if `Scaled` stops being distinguishable from the eight other replies
1767    /// carrying a bare `Vec<ProcessInfo>`. Each of those names which request it
1768    /// answers precisely so it can diverge later without a protocol bump — the
1769    /// enum's own doc says not to collapse them, and this is the test that notices.
1770    #[test]
1771    fn a_scaled_reply_carries_its_own_tag() {
1772        let json = serde_json::to_string(&Response::Scaled(vec![])).unwrap();
1773        assert_eq!(json, r#"{"kind":"scaled","data":[]}"#);
1774    }
1775
1776    /// fails if the three outcomes stop being tellable apart on the wire, or if
1777    /// `NotWritten` stops carrying its reason. That reason is the only thing that
1778    /// distinguishes "the app is not reading its stdin" from "the pipe broke", and
1779    /// the operator's next move differs between them.
1780    #[test]
1781    fn a_send_line_request_and_its_reply_round_trip() {
1782        let request = Request::SendLine {
1783            selector: SelectorSpec::Name("repl".to_string()),
1784            line: "reload-config".to_string(),
1785        };
1786        let json = serde_json::to_string(&request).unwrap();
1787        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1788
1789        let reply = Response::SentLine(vec![
1790            LineReply {
1791                id: 1,
1792                name: "repl".to_string(),
1793                outcome: LineOutcome::Sent,
1794            },
1795            LineReply {
1796                id: 2,
1797                name: "web".to_string(),
1798                outcome: LineOutcome::NoStdin,
1799            },
1800            LineReply {
1801                id: 3,
1802                name: "stuck".to_string(),
1803                outcome: LineOutcome::NotWritten {
1804                    reason: "the app did not read its stdin within 2s".to_string(),
1805                },
1806            },
1807        ]);
1808        let json = serde_json::to_string(&reply).unwrap();
1809        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1810        assert!(json.contains(r#""kind":"sent""#), "{json}");
1811        assert!(json.contains(r#""kind":"no_stdin""#), "{json}");
1812        assert!(json.contains("did not read its stdin"), "{json}");
1813    }
1814
1815    /// fails if a newline can ride inside the line. The wire carries ONE line and
1816    /// the writer appends the terminator, so an embedded newline would deliver two
1817    /// commands where the operator typed one — the shape that turns a typo into an
1818    /// unintended second instruction to a REPL.
1819    #[test]
1820    fn a_line_carrying_a_newline_is_still_one_field_on_the_wire() {
1821        let request = Request::SendLine {
1822            selector: SelectorSpec::All,
1823            line: "a\nb".to_string(),
1824        };
1825        let json = serde_json::to_string(&request).unwrap();
1826        // Escaped, not literal: the frame stays one JSON object. Rejecting it is
1827        // the daemon's job (see `shep whisper`), not serde's, and this pins that
1828        // the wire itself does not quietly split it.
1829        assert!(json.contains(r#""line":"a\nb""#), "{json}");
1830        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1831    }
1832
1833    #[test]
1834    fn request_wire_snapshots() {
1835        let requests = vec![
1836            Envelope {
1837                id: 1,
1838                deadline_ms: Some(5000),
1839                body: Request::Ping,
1840            },
1841            Envelope {
1842                id: 2,
1843                deadline_ms: None,
1844                body: Request::ListFlock,
1845            },
1846            Envelope {
1847                id: 3,
1848                deadline_ms: None,
1849                body: Request::Stop {
1850                    selector: SelectorSpec::Name("web".to_string()),
1851                },
1852            },
1853            Envelope {
1854                id: 4,
1855                deadline_ms: None,
1856                body: Request::Start {
1857                    apps: vec![AppConfig::minimal("web", "./srv")],
1858                },
1859            },
1860            // `All` rather than a named sheep: it is the selector `shep
1861            // reopen` sends when given no argument, and the one a signal can
1862            // ever mean, so it is the row worth pinning.
1863            Envelope {
1864                id: 5,
1865                deadline_ms: None,
1866                body: Request::Reopen {
1867                    selector: SelectorSpec::All,
1868                },
1869            },
1870            // Deliberately the same selector as the row above, so the two
1871            // log-plane rows differ by their `kind` and by nothing else: a
1872            // `Flush` that serialized under `reopen`'s tag — the shape a
1873            // copy-pasted variant takes — shows up here as two identical
1874            // objects rather than as a diff a reader has to compare field by
1875            // field. `shep flush` demands an explicit selector, so `all` is
1876            // not a default here the way it is for `reopen`; it is simply the
1877            // widest thing an operator can type.
1878            Envelope {
1879                id: 6,
1880                deadline_ms: None,
1881                body: Request::Flush {
1882                    selector: SelectorSpec::All,
1883                },
1884            },
1885            // The same selector as the `stop` row above, for the reason the
1886            // pair above share theirs: `reload` is the third verb that
1887            // demands an explicit selector and replaces what it matches, so
1888            // the variant it would be copy-pasted from is `stop`. Serialized
1889            // under `stop`'s tag it shows up here as two identical objects
1890            // rather than as a diff a reader has to compare field by field.
1891            Envelope {
1892                id: 7,
1893                deadline_ms: None,
1894                body: Request::Reload {
1895                    selector: SelectorSpec::Name("web".to_string()),
1896                },
1897            },
1898            // `action`/`params` here match the spec's own §9 example
1899            // (`trigger web set-log-level debug`) and channel.rs's
1900            // with-params fixture verbatim, so a reader tracing a trigger
1901            // from the CLI through the client↔daemon wire to the fd-3 wire
1902            // sees the same two strings at every hop rather than three
1903            // unrelated examples.
1904            Envelope {
1905                id: 8,
1906                deadline_ms: None,
1907                body: Request::Trigger {
1908                    selector: SelectorSpec::Name("web".to_string()),
1909                    action: "set-log-level".to_string(),
1910                    params: Some("debug".to_string()),
1911                },
1912            },
1913            // The first fieldless verb added since `Ping`/`ListFlock`, and
1914            // pinned for that reason: a fieldless variant serializes as a
1915            // bare `{"kind":"..."}` with no `selector` key at all, so a
1916            // reader comparing this row against `stop`'s sees the whole
1917            // difference between the two shapes in one place.
1918            Envelope {
1919                id: 9,
1920                deadline_ms: None,
1921                body: Request::SaveRoll,
1922            },
1923            // Paired with the `save_roll` row above so the two halves of the
1924            // roll — the direction that writes it and the direction that
1925            // assembles from it — sit next to each other, differing by their
1926            // `kind` and by nothing else.
1927            Envelope {
1928                id: 10,
1929                deadline_ms: None,
1930                body: Request::Muster,
1931            },
1932            // The three dog verbs together, in the order an operator meets
1933            // them: ask for a section, start a dog, stop one. Adjacent on
1934            // purpose — `enable_dog` and `disable_dog` differ by their
1935            // `kind` and by `source`, so a `DisableDog` accidentally given
1936            // `EnableDog`'s tag shows up here as two near-identical objects
1937            // rather than as a diff a reader has to compare field by field.
1938            Envelope {
1939                id: 11,
1940                deadline_ms: None,
1941                body: Request::DogConfig {
1942                    name: "bark".to_string(),
1943                },
1944            },
1945            Envelope {
1946                id: 12,
1947                deadline_ms: None,
1948                body: Request::EnableDog {
1949                    name: "metrics".to_string(),
1950                    source: DogSource::BuiltIn,
1951                },
1952            },
1953            Envelope {
1954                id: 13,
1955                deadline_ms: None,
1956                body: Request::DisableDog {
1957                    name: "metrics".to_string(),
1958                },
1959            },
1960            // Grouped and adjacent on purpose: `Id`, `Regex` and `Fold` are
1961            // three newtypes over three different inner types, and the wire
1962            // tells them apart only by their own `kind` tag — a `Fold` that
1963            // serialized under `regex`'s tag is a `shep restart fold:api`
1964            // that silently becomes a regex match, which is a wrong set of
1965            // sheep restarted and not an error anyone sees.
1966            Envelope {
1967                id: 14,
1968                deadline_ms: None,
1969                body: Request::Describe {
1970                    selector: SelectorSpec::Id(7),
1971                },
1972            },
1973            Envelope {
1974                id: 15,
1975                deadline_ms: None,
1976                body: Request::Describe {
1977                    selector: SelectorSpec::Regex("^web-".to_string()),
1978                },
1979            },
1980            Envelope {
1981                id: 16,
1982                deadline_ms: None,
1983                body: Request::Describe {
1984                    selector: SelectorSpec::Fold("api".to_string()),
1985                },
1986            },
1987            // `SIGHUP` rather than `SIGTERM`: TERM is what the stop ladder
1988            // already sends, so a fixture using it could not tell a `signal`
1989            // frame from a stop's. HUP is the signal this verb exists for.
1990            Envelope {
1991                id: 17,
1992                deadline_ms: None,
1993                body: Request::Signal {
1994                    selector: SelectorSpec::Name("web".to_string()),
1995                    signal: "SIGHUP".to_string(),
1996                },
1997            },
1998            // The one verb in this enum whose body has no `selector` key at
1999            // all — a reader comparing this row against `stop`'s sees the
2000            // whole difference in one place.
2001            Envelope {
2002                id: 18,
2003                deadline_ms: None,
2004                body: Request::Scale {
2005                    name: "web".to_string(),
2006                    count: 4,
2007                },
2008            },
2009            // `SelectorSpec::All` rather than a named sheep, mirroring the
2010            // `reopen`/`flush` rows above: it is the widest thing an operator
2011            // can type, and the line carries no terminator on the wire — the
2012            // shepherd appends it — so a fixture with one proves that half of
2013            // the contract too.
2014            Envelope {
2015                id: 19,
2016                deadline_ms: None,
2017                body: Request::SendLine {
2018                    selector: SelectorSpec::All,
2019                    line: "reload-config".to_string(),
2020                },
2021            },
2022            // The second verb here with no `selector` key, and the only one
2023            // whose payload a third party writes. Both halves of its
2024            // `Option` are pinned — a paint and a clear — because a dog
2025            // author reading this fixture needs the clear frame's exact
2026            // shape and would otherwise have to guess `null`.
2027            Envelope {
2028                id: 20,
2029                deadline_ms: None,
2030                body: Request::SetSmit {
2031                    sheep: "web".to_string(),
2032                    smit: Some(
2033                        "\u{25b2} main@a1b2c3"
2034                            .parse()
2035                            .expect("the reference smit is valid"),
2036                    ),
2037                },
2038            },
2039            Envelope {
2040                id: 21,
2041                deadline_ms: None,
2042                body: Request::SetSmit {
2043                    sheep: "web".to_string(),
2044                    smit: None,
2045                },
2046            },
2047            // An EMPTY `apps`, unlike `start`'s row above. The two carry the
2048            // identical payload type, so a second `AppConfig` blob here would
2049            // pin nothing `start`'s blob does not already pin, at fifty lines
2050            // of snapshot. What is genuinely this row's own is the tag and
2051            // the key the list travels under, and an empty list shows both.
2052            Envelope {
2053                id: 22,
2054                deadline_ms: None,
2055                body: Request::ConfigDrift { apps: Vec::new() },
2056            },
2057            // The only STRUCT-shaped `SelectorSpec` variant, and the one
2058            // whose serialized shape moved `PROTOCOL_VERSION` from 1 to 2.
2059            // Every other selector on this wire is a unit or a newtype, both
2060            // already pinned by the rows above, so this row is the only place
2061            // `"kind":"instance"` and the `slot` key are held to anything.
2062            // Without it, renaming the field or flattening the variant turned
2063            // nothing red on the exact type the version bump was for.
2064            Envelope {
2065                id: 23,
2066                deadline_ms: None,
2067                body: Request::Restart {
2068                    selector: SelectorSpec::Instance {
2069                        name: "web".to_string(),
2070                        slot: 2,
2071                    },
2072                },
2073            },
2074            // The one request in this enum that an older daemon must never
2075            // be sent, so the one whose exact tag matters most: shep-cli
2076            // gates it on the daemon's crate version, and a rename here
2077            // would be a variant nothing on either side recognises.
2078            Envelope {
2079                id: 24,
2080                deadline_ms: None,
2081                body: Request::HandoverFitness,
2082            },
2083        ];
2084        insta::assert_json_snapshot!("request_wire_v2", requests);
2085    }
2086
2087    #[test]
2088    fn reply_wire_snapshots() {
2089        let replies = vec![
2090            Reply {
2091                id: 1,
2092                result: Ok(Response::Pong),
2093            },
2094            Reply {
2095                id: 2,
2096                result: Ok(Response::Flock(vec![sample_info()])),
2097            },
2098            Reply {
2099                id: 3,
2100                result: Err(RpcError {
2101                    code: RpcErrorCode::NotFound,
2102                    message: "no sheep matches `web`".to_string(),
2103                    daemon_version: None,
2104                }),
2105            },
2106            // Unlike `Reopened`/`Flushed`/`Reloading` above (all wire-identical
2107            // to `Flock`, just under a different `kind` tag, so pinning `Flock`
2108            // once already covers their shape), `Triggered` carries a genuinely
2109            // different row — `ActionReply` is not a `ProcessInfo` — so it earns
2110            // its own entry. `Replied` is the struct-shaped variant of
2111            // `ActionOutcome`, and so the one worth pinning here: the three
2112            // unit variants serialize as bare `{"kind":"..."}`, a shape already
2113            // proven by every fieldless variant elsewhere on this wire.
2114            Reply {
2115                id: 4,
2116                result: Ok(Response::Triggered(vec![ActionReply {
2117                    id: 3,
2118                    name: "web".to_string(),
2119                    outcome: ActionOutcome::Replied {
2120                        body: "ok".to_string(),
2121                    },
2122                }])),
2123            },
2124            // The only struct-shaped `Response` variant, so the one worth
2125            // pinning here: every other variant on this wire is a newtype
2126            // over a Vec or a unit, both shapes already proven above.
2127            Reply {
2128                id: 5,
2129                result: Ok(Response::RollSaved {
2130                    path: "/home/ada/.shep/flock.json".to_string(),
2131                    apps: 2,
2132                }),
2133            },
2134            // `sample_info()` above pins the absent marker (a sheep's
2135            // `"dog": null`); this row is the only place the present one is
2136            // pinned, and `Adopted` rather than `BuiltIn` because it is the
2137            // variant carrying a payload — the unit variant's shape is
2138            // already proven by every fieldless variant on this wire.
2139            Reply {
2140                id: 6,
2141                result: Ok(Response::Flock(vec![ProcessInfo {
2142                    id: 7,
2143                    name: "otel".to_string(),
2144                    dog: Some(DogSource::Adopted {
2145                        path: "/usr/local/bin/shep-otel".to_string(),
2146                    }),
2147                    ..sample_info()
2148                }])),
2149            },
2150            // The opaque blob, pinned as a blob: the daemon renders a TOML
2151            // table into a string and never a typed structure, so what this
2152            // row proves is that the section crosses the wire as text.
2153            Reply {
2154                id: 7,
2155                result: Ok(Response::DogSection {
2156                    toml: "port = 9615\n".to_string().into(),
2157                }),
2158            },
2159            // The only `Response` variant carrying a BARE `ProcessInfo`
2160            // rather than a `Vec` of them: `enable` starts exactly one dog,
2161            // and a one-element list would invite a reader to wonder when it
2162            // holds two.
2163            Reply {
2164                id: 8,
2165                result: Ok(Response::DogStarted(ProcessInfo {
2166                    id: 4,
2167                    name: "metrics".to_string(),
2168                    dog: Some(DogSource::BuiltIn),
2169                    ..sample_info()
2170                })),
2171            },
2172            // The existing comment on the `Triggered` row is right that pinning
2173            // `Flock` once already proves the `Vec<ProcessInfo>` SHAPE — but
2174            // it does not prove any of these variants' own `kind` tags, and
2175            // three of them are not `Vec<ProcessInfo>`-shaped at all
2176            // (`Deleted` is a `Vec<u32>`, `Subscribed` and `ShuttingDown`
2177            // carry nothing). Each row below therefore carries the smallest
2178            // body that shows its wire shape — empty where empty is legal,
2179            // `Deleted`'s two ids where the shape needs elements: what is
2180            // being pinned here is the tag, and a body repeated eight times
2181            // would bury it.
2182            Reply {
2183                id: 9,
2184                result: Ok(Response::Described(vec![])),
2185            },
2186            Reply {
2187                id: 10,
2188                result: Ok(Response::Started(vec![])),
2189            },
2190            Reply {
2191                id: 11,
2192                result: Ok(Response::Stopped(vec![])),
2193            },
2194            Reply {
2195                id: 12,
2196                result: Ok(Response::Restarted(vec![])),
2197            },
2198            Reply {
2199                id: 13,
2200                result: Ok(Response::Reloading(vec![])),
2201            },
2202            Reply {
2203                id: 14,
2204                result: Ok(Response::Deleted(vec![7, 8])),
2205            },
2206            Reply {
2207                id: 15,
2208                result: Ok(Response::Reopened(vec![])),
2209            },
2210            Reply {
2211                id: 16,
2212                result: Ok(Response::Flushed(vec![])),
2213            },
2214            Reply {
2215                id: 17,
2216                result: Ok(Response::Mustered(vec![])),
2217            },
2218            Reply {
2219                id: 18,
2220                result: Ok(Response::Subscribed),
2221            },
2222            Reply {
2223                id: 19,
2224                result: Ok(Response::ShuttingDown),
2225            },
2226            // `Signalled`, mirroring the `Triggered` row above: three rows,
2227            // one per `SignalOutcome` variant, so a reader sees the whole
2228            // shape of the reply in one pinned fixture rather than one row
2229            // that happens to hit `Delivered` and leaves the other two tags
2230            // unproven.
2231            Reply {
2232                id: 20,
2233                result: Ok(Response::Signalled(vec![
2234                    SignalReply {
2235                        id: 1,
2236                        name: "web".to_string(),
2237                        outcome: SignalOutcome::Delivered,
2238                    },
2239                    SignalReply {
2240                        id: 2,
2241                        name: "web".to_string(),
2242                        outcome: SignalOutcome::NotRunning,
2243                    },
2244                    SignalReply {
2245                        id: 3,
2246                        name: "api".to_string(),
2247                        outcome: SignalOutcome::Failed {
2248                            reason: "no such process".to_string(),
2249                        },
2250                    },
2251                ])),
2252            },
2253            Reply {
2254                id: 21,
2255                result: Ok(Response::Scaled(vec![sample_info()])),
2256            },
2257            // `SentLine`, mirroring the `Signalled` row above: three rows, one
2258            // per `LineOutcome` variant, so a reader sees the whole shape of
2259            // the reply in one pinned fixture rather than one row that
2260            // happens to hit `Sent` and leaves the other two tags unproven.
2261            Reply {
2262                id: 22,
2263                result: Ok(Response::SentLine(vec![
2264                    LineReply {
2265                        id: 1,
2266                        name: "repl".to_string(),
2267                        outcome: LineOutcome::Sent,
2268                    },
2269                    LineReply {
2270                        id: 2,
2271                        name: "web".to_string(),
2272                        outcome: LineOutcome::NoStdin,
2273                    },
2274                    LineReply {
2275                        id: 3,
2276                        name: "stuck".to_string(),
2277                        outcome: LineOutcome::NotWritten {
2278                            reason: "the app did not read its stdin within 2s".to_string(),
2279                        },
2280                    },
2281                ])),
2282            },
2283            // A `Described` row with a real lamb tree. The `null` shape is pinned
2284            // on every other row here; this is the one that pins what a walked
2285            // sheep serializes as, which is the shape a `describe` consumer
2286            // actually parses.
2287            Reply {
2288                id: 23,
2289                result: Ok(Response::Described(vec![
2290                    ProcessInfo::builder(3, "web", ProcStatus::Online)
2291                        .pid(Some(4242))
2292                        .lambs(Some(vec![Lamb::new(4243, "node"), Lamb::new(4244, "sh")]))
2293                        .build(),
2294                ])),
2295            },
2296            // `sample_info()` pins `last_exit`'s "exited normally" shape
2297            // (`code` set, `signal` absent) on every row above; this is the
2298            // only place the other one — killed by a signal, `code` absent
2299            // — is pinned. `SIGTERM`'s raw number (15) rather than a
2300            // symbolic one, because [`ExitInfo::signal`]'s own doc says this
2301            // crate carries no name for it; naming one is a job for
2302            // whichever OS-aware layer renders this field.
2303            Reply {
2304                id: 24,
2305                result: Ok(Response::Flock(vec![
2306                    ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
2307                        .restarts(1)
2308                        .last_exit(Some(ExitInfo {
2309                            code: None,
2310                            signal: Some(15),
2311                        }))
2312                        .build(),
2313                ])),
2314            },
2315            // The one row that pins a smit on the wire. `sample_info()`
2316            // carries none, deliberately (see `every_setter_writes_its_own_
2317            // field_and_no_other` for why it cannot), so without this row
2318            // the field is pinned only in its absent shape — and the absent
2319            // shape is not the one a dog's reader has to parse.
2320            Reply {
2321                id: 25,
2322                result: Ok(Response::SmitPainted(vec![
2323                    ProcessInfo::builder(3, "web", ProcStatus::Online)
2324                        .pid(Some(4242))
2325                        .smit(Some("\u{25b2} main@a1b2c3".to_string()))
2326                        .build(),
2327                ])),
2328            },
2329            // Two entries in one reply, and each is the shape the other is
2330            // not: a sheep drifting in one field and a sheep drifting in
2331            // several. `env` is deliberately one of them, because reporting
2332            // it as a bare NAME is the whole security property of this row
2333            // (IR-41) and a fixture is where an out-of-tree reader learns
2334            // that no value ever travels with it.
2335            Reply {
2336                id: 26,
2337                result: Ok(Response::Drifted(vec![
2338                    SheepDrift::new("web", vec!["cwd".to_string()]),
2339                    SheepDrift::new(
2340                        "api",
2341                        vec!["args".to_string(), "env".to_string(), "script".to_string()],
2342                    ),
2343                ])),
2344            },
2345            // `sample_info()` pins `instance`'s absent shape (`None`, an old
2346            // peer or a single-instance app); every row above reuses it, so
2347            // without this row the present shape (`Some(2)`, a live slot on
2348            // a scaled app) is never on the wire at all.
2349            Reply {
2350                id: 27,
2351                result: Ok(Response::Flock(vec![
2352                    ProcessInfo::builder(9, "web", ProcStatus::Online)
2353                        .pid(Some(5150))
2354                        .instance(Some(2))
2355                        .build(),
2356                ])),
2357            },
2358            // Both shapes of the handover answer, because the difference
2359            // between them is a `null` and a caller that read the key's
2360            // presence rather than its value would pass on one and refuse
2361            // every flock on the other.
2362            Reply {
2363                id: 28,
2364                result: Ok(Response::HandoverFitness { refusal: None }),
2365            },
2366            Reply {
2367                id: 29,
2368                result: Ok(Response::HandoverFitness {
2369                    refusal: Some("sheep 'web' has a shepherd channel".to_string()),
2370                }),
2371            },
2372        ];
2373        insta::assert_json_snapshot!("reply_wire_v2", replies);
2374    }
2375
2376    /// fails if the new field breaks an older peer, on the same terms as
2377    /// `last_exit` and `lambs` before it. A daemon that predates smits sends
2378    /// no `smit` key, and this decoding to `None` rather than erroring is
2379    /// what keeps `PROTOCOL_VERSION` at 2 rather than needing another bump.
2380    #[test]
2381    fn a_process_info_without_a_smit_key_still_deserializes() {
2382        let fixture = r#"{"id":1,"name":"web","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,"lambs":null,"last_exit":null}"#;
2383        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2384        assert_eq!(info.smit, None);
2385    }
2386
2387    /// fails if the daemon accepts a smit it should refuse. [`Smit`] must
2388    /// validate on the way IN, not only in `FromStr`: `docs/dogs.md` tells
2389    /// dog authors to speak this wire directly, so a dog written in another
2390    /// language never runs our parser.
2391    #[test]
2392    fn a_smit_is_validated_when_it_is_deserialized_not_only_when_parsed() {
2393        for bad in [
2394            r#""\u001b[2Jgone""#.to_string(),                    // an escape
2395            r#""a\nb""#.to_string(),                             // a newline
2396            r#""""#.to_string(),                                 // empty
2397            r#""   ""#.to_string(),                              // whitespace
2398            format!(r#""{}""#, "x".repeat(Smit::MAX_CHARS + 1)), // too long
2399        ] {
2400            assert!(
2401                serde_json::from_str::<Smit>(&bad).is_err(),
2402                "a daemon must refuse this on the wire: {bad}"
2403            );
2404        }
2405        assert!(serde_json::from_str::<Smit>(r#""\u25b2 main@a1b2c3""#).is_ok());
2406    }
2407
2408    /// fails if a smit stops travelling as a bare JSON string. It is a
2409    /// newtype with a hand-written `Deserialize`, and the pair only agrees
2410    /// with itself if the serialize side stays transparent — a `Smit` that
2411    /// serialized as `{"0":"..."}` would round-trip through nothing.
2412    #[test]
2413    fn a_smit_travels_as_a_bare_string() {
2414        let smit: Smit = "\u{25b2} main@a1b2c3".parse().expect("valid");
2415        let json = serde_json::to_string(&smit).unwrap();
2416        assert_eq!(json, "\"\u{25b2} main@a1b2c3\"");
2417        assert_eq!(serde_json::from_str::<Smit>(&json).unwrap(), smit);
2418    }
2419
2420    /// fails if the cap starts counting bytes or display columns. Forty-eight
2421    /// CJK characters are 144 bytes and roughly 96 columns, and all three
2422    /// numbers disagree — a byte cap would refuse this legitimate smit at a
2423    /// third of its apparent length.
2424    #[test]
2425    fn a_smit_is_capped_in_characters_not_bytes() {
2426        let cjk = "\u{7f8a}".repeat(Smit::MAX_CHARS);
2427        assert_eq!(cjk.len(), Smit::MAX_CHARS * 3);
2428        assert!(cjk.parse::<Smit>().is_ok(), "{cjk}");
2429        assert_eq!(
2430            "x".repeat(Smit::MAX_CHARS + 1).parse::<Smit>(),
2431            Err(SmitError::TooLong {
2432                chars: Smit::MAX_CHARS + 1
2433            })
2434        );
2435    }
2436
2437    /// fails if a smit is repaired rather than refused. Trimming or stripping
2438    /// would hand an operator a mark its publisher never sent, and would put
2439    /// shep in the business of editing a string it has agreed not to
2440    /// understand.
2441    #[test]
2442    fn a_smit_is_stored_exactly_as_it_arrived() {
2443        let padded: Smit = "  main@a1b2c3  ".parse().expect("valid");
2444        assert_eq!(padded.as_str(), "  main@a1b2c3  ");
2445        assert_eq!(padded.to_string(), "  main@a1b2c3  ");
2446    }
2447
2448    #[test]
2449    fn v1_fixture_still_deserializes() {
2450        // Committed byte fixture from protocol v1 — if this breaks, bump
2451        // PROTOCOL_VERSION and record it in the CHANGELOG (IR-35).
2452        let fixture = r#"{"id":7,"deadline_ms":null,"body":{"kind":"stop","selector":{"kind":"name","value":"web"}}}"#;
2453        let env: Envelope = serde_json::from_str(fixture).unwrap();
2454        assert_eq!(env.id, 7);
2455        assert!(matches!(
2456            env.body,
2457            Request::Stop { selector: SelectorSpec::Name(ref n) } if n == "web"
2458        ));
2459    }
2460
2461    #[test]
2462    fn hello_handshake_shape() {
2463        let hello = Hello {
2464            client_version: "0.1.0".to_string(),
2465            protocol: PROTOCOL_VERSION,
2466        };
2467        let json = serde_json::to_string(&hello).unwrap();
2468        assert_eq!(json, r#"{"client_version":"0.1.0","protocol":2}"#);
2469    }
2470
2471    #[test]
2472    fn hello_reply_carries_typed_skew_error() {
2473        let refusal: HelloReply = Err(RpcError {
2474            code: RpcErrorCode::ProtocolMismatch,
2475            message: "daemon speaks protocol 1, client sent 2".to_string(),
2476            daemon_version: None,
2477        });
2478        let json = serde_json::to_string(&refusal).unwrap();
2479        assert_eq!(
2480            json,
2481            r#"{"Err":{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}}"#
2482        );
2483        let back: HelloReply = serde_json::from_str(&json).unwrap();
2484        assert_eq!(back, refusal);
2485    }
2486
2487    #[test]
2488    fn v1_reply_fixture_still_deserializes() {
2489        // Committed byte fixture, protocol v1 (IR-35).
2490        let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
2491        let reply: Reply = serde_json::from_str(ok).unwrap();
2492        assert!(matches!(reply.result, Ok(Response::Pong)));
2493        let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
2494        let reply: Reply = serde_json::from_str(err).unwrap();
2495        assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
2496    }
2497
2498    #[test]
2499    fn v1_hello_ack_fixture_still_deserializes() {
2500        let fixture = r#"{"Ok":{"daemon_version":"0.1.0","protocol":1,"pid":4242}}"#;
2501        let ack: HelloReply = serde_json::from_str(fixture).unwrap();
2502        assert_eq!(ack.unwrap().pid, 4242);
2503    }
2504
2505    /// fails if the two fields stop being optional. A daemon built before
2506    /// them sends a reply with no such keys, and both peers still announce
2507    /// protocol 1 — a required field would make a current client unable to
2508    /// list against that daemon at all.
2509    #[test]
2510    fn v1_process_info_without_stats_still_deserializes() {
2511        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"}"#;
2512        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2513        assert_eq!(info.cpu_percent, None);
2514        assert_eq!(info.memory_bytes, None);
2515    }
2516
2517    #[test]
2518    fn v1_process_info_without_log_paths_still_deserializes() {
2519        // Committed byte fixture from before `out_file`/`err_file` existed
2520        // (IR-35). The handshake only compares PROTOCOL_VERSION, which this
2521        // addition deliberately did not bump, so a daemon built at this
2522        // vintage still connects to a current client and sends exactly these
2523        // bytes. Absent keys must land as `None`, not as a decode error.
2524        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
2525        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2526        assert_eq!(info.id, 3);
2527        assert_eq!(info.out_file, None);
2528        assert_eq!(info.err_file, None);
2529    }
2530
2531    #[test]
2532    fn an_old_client_still_decodes_a_new_process_info() {
2533        // The other skew direction: a client built before the fields reads a
2534        // current daemon's reply. `ProcessInfo` carries no
2535        // `deny_unknown_fields` (unlike the config types in
2536        // `crate::config`), so the two extra keys are ignored rather than
2537        // refused — which is what makes this addition version-preserving.
2538        #[derive(Deserialize)]
2539        struct V1ProcessInfo {
2540            id: u32,
2541            fold: Option<String>,
2542        }
2543
2544        let current = serde_json::to_string(&sample_info()).unwrap();
2545        let old: V1ProcessInfo = serde_json::from_str(&current).unwrap();
2546        assert_eq!(old.id, 3);
2547        assert_eq!(old.fold.as_deref(), Some("backend"));
2548    }
2549
2550    #[test]
2551    fn an_rpc_error_without_a_daemon_version_serializes_exactly_as_before() {
2552        // `skip_serializing_if` is what makes this addition free: a daemon
2553        // with nothing to say puts the same bytes on the wire it always did,
2554        // not a `"daemon_version":null` key an older client would have to
2555        // ignore. Pinned as an exact string, because "additive" is a claim
2556        // about bytes.
2557        let plain = RpcError {
2558            code: RpcErrorCode::NotFound,
2559            message: "no sheep".to_string(),
2560            daemon_version: None,
2561        };
2562        assert_eq!(
2563            serde_json::to_string(&plain).unwrap(),
2564            r#"{"code":"not_found","message":"no sheep"}"#
2565        );
2566    }
2567
2568    #[test]
2569    fn a_v1_rpc_error_fixture_deserializes_with_no_daemon_version() {
2570        // Committed byte fixture from before this field existed (IR-35): the
2571        // skew direction that matters, a CURRENT client reading an OLD
2572        // daemon's refusal. It must read as `None` rather than failing to
2573        // decode, or the field breaks the upgrade it exists to smooth.
2574        let fixture =
2575            r#"{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}"#;
2576        let err: RpcError = serde_json::from_str(fixture).unwrap();
2577        assert_eq!(err.code, RpcErrorCode::ProtocolMismatch);
2578        assert_eq!(err.daemon_version, None);
2579    }
2580
2581    #[test]
2582    fn an_old_client_ignores_an_rpc_error_field_it_has_never_seen() {
2583        // Step 1 of the handover work: proof that `RpcError` may grow an
2584        // optional field WITHOUT moving `PROTOCOL_VERSION`. Like
2585        // `ProcessInfo` above, `RpcError` carries no `deny_unknown_fields`,
2586        // so a client built before a field decodes a daemon that sends it
2587        // rather than failing the handshake on it.
2588        #[derive(Deserialize)]
2589        struct OldRpcError {
2590            code: RpcErrorCode,
2591            message: String,
2592        }
2593
2594        let current = serde_json::to_string(&RpcError {
2595            code: RpcErrorCode::ProtocolMismatch,
2596            message: "daemon speaks protocol 1, client sent 2".to_string(),
2597            daemon_version: Some("0.1.16".to_string()),
2598        })
2599        .unwrap();
2600        let old: OldRpcError = serde_json::from_str(&current).expect("must tolerate");
2601        assert_eq!(old.code, RpcErrorCode::ProtocolMismatch);
2602        assert_eq!(old.message, "daemon speaks protocol 1, client sent 2");
2603    }
2604
2605    #[test]
2606    fn deadline_exceeded_code_serializes_snake_case() {
2607        // Additive variant (evolution rule): the existing codes keep their
2608        // strings, so v1 byte fixtures above still deserialize unchanged.
2609        assert_eq!(
2610            serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
2611            "\"deadline_exceeded\""
2612        );
2613        assert_eq!(
2614            serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
2615            RpcErrorCode::DeadlineExceeded
2616        );
2617    }
2618
2619    #[test]
2620    fn action_outcome_kinds_serialize_snake_case_and_round_trip() {
2621        // The shared snapshots above exercise exactly one `ActionOutcome`
2622        // variant (`Replied`, the only struct-shaped one, in
2623        // `reply_wire_snapshots`) — nothing else there would catch a rename
2624        // of `no_channel`, `skipped`, or `timed_out`. Pinned here instead,
2625        // the same way `deadline_exceeded_code_serializes_snake_case` pins a
2626        // lone `RpcErrorCode` variant above.
2627        let cases = [
2628            (
2629                ActionOutcome::Replied {
2630                    body: "pong".to_string(),
2631                },
2632                r#"{"kind":"replied","body":"pong"}"#,
2633            ),
2634            (ActionOutcome::NoChannel, r#"{"kind":"no_channel"}"#),
2635            (ActionOutcome::Skipped, r#"{"kind":"skipped"}"#),
2636            (ActionOutcome::TimedOut, r#"{"kind":"timed_out"}"#),
2637        ];
2638        for (outcome, wire) in cases {
2639            assert_eq!(
2640                serde_json::to_string(&outcome).unwrap(),
2641                wire,
2642                "{outcome:?}"
2643            );
2644            assert_eq!(
2645                serde_json::from_str::<ActionOutcome>(wire).unwrap(),
2646                outcome
2647            );
2648        }
2649    }
2650
2651    /// fails if `SaveRoll` or `RollSaved` is given a `rename`, or if
2652    /// `Response`'s `content = "data"` is dropped — either changes these two
2653    /// strings while every type-level test in this module keeps passing.
2654    #[test]
2655    fn save_roll_serializes_snake_case_with_its_payload_under_data() {
2656        assert_eq!(
2657            serde_json::to_string(&Request::SaveRoll).unwrap(),
2658            r#"{"kind":"save_roll"}"#
2659        );
2660        let reply = Response::RollSaved {
2661            path: "/tmp/flock.json".to_string(),
2662            apps: 3,
2663        };
2664        let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
2665        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
2666        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
2667    }
2668
2669    /// fails if `Muster` or `Mustered` is given a `rename`, or if `Mustered`
2670    /// is declared fieldless — any of the three changes one of these two
2671    /// strings while every type-level test in this module keeps passing.
2672    ///
2673    /// The listing is empty on purpose. `Mustered` carries the same
2674    /// `Vec<ProcessInfo>` `Flock` does, and `reply_wire_snapshots` already
2675    /// pins that row field by field; what is unpinned until here is this
2676    /// variant's own tag and whether its payload lands under `data` at all.
2677    #[test]
2678    fn muster_serializes_snake_case_with_its_listing_under_data() {
2679        assert_eq!(
2680            serde_json::to_string(&Request::Muster).unwrap(),
2681            r#"{"kind":"muster"}"#
2682        );
2683        let reply = Response::Mustered(Vec::new());
2684        let wire = r#"{"kind":"mustered","data":[]}"#;
2685        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
2686        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
2687    }
2688
2689    /// fails if any of the three verbs or either reply is given a `rename`,
2690    /// or if `Response`'s `content = "data"` is dropped. `disable_dog`'s
2691    /// answer is `Deleted`, which no other test in this module pairs with
2692    /// this verb — a handler wired to answer `Deleted` for `EnableDog` would
2693    /// still round-trip, and this is where the pairing is written down.
2694    #[test]
2695    fn the_dog_verbs_serialize_snake_case_with_their_payloads_under_data() {
2696        assert_eq!(
2697            serde_json::to_string(&Request::DogConfig {
2698                name: "bark".to_string()
2699            })
2700            .unwrap(),
2701            r#"{"kind":"dog_config","name":"bark"}"#
2702        );
2703        assert_eq!(
2704            serde_json::to_string(&Request::DisableDog {
2705                name: "bark".to_string()
2706            })
2707            .unwrap(),
2708            r#"{"kind":"disable_dog","name":"bark"}"#
2709        );
2710        let section = Response::DogSection {
2711            toml: "port = 9615\n".to_string().into(),
2712        };
2713        let wire = r#"{"kind":"dog_section","data":{"toml":"port = 9615\n"}}"#;
2714        assert_eq!(serde_json::to_string(&section).unwrap(), wire);
2715        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), section);
2716    }
2717
2718    #[test]
2719    fn dog_section_toml_debug_does_not_leak() {
2720        // IR-41: a dog's `[dog.<name>]` section routinely holds webhook
2721        // credentials (a Discord/Slack URL with a bearer token embedded).
2722        // `Response` derives `Debug`, so this is the one thing standing
2723        // between that token and any future `tracing::debug!("{:?}", reply)`.
2724        // Exact string pinned so a lazy `#[derive(Debug)]` refactor on
2725        // `DogSectionToml` fails this test instead of silently reopening
2726        // the leak.
2727        let toml: DogSectionToml =
2728            "webhook_url = \"https://discord.com/api/webhooks/1/super-secret-token\"\n"
2729                .to_string()
2730                .into();
2731        assert_eq!(format!("{toml:?}"), "DogSectionToml(<70 bytes>)");
2732
2733        let response = Response::DogSection { toml };
2734        assert_eq!(
2735            format!("{response:?}"),
2736            "DogSection { toml: DogSectionToml(<70 bytes>) }"
2737        );
2738    }
2739
2740    /// The fixture is built so the two candidate orders CANNOT agree: read
2741    /// by id it is `web/1, api/2, web/0`, read by name it is
2742    /// `api, web, web`. A listing that happened to be alphabetical already,
2743    /// or whose ids happened to ascend with its names, would pass under
2744    /// either rule and prove nothing.
2745    ///
2746    /// The two `web` rows are the tiebreak half, and they are the reason a
2747    /// multi-instance fixture is required: their ids are seeded out of order
2748    /// (1 before 0), so a sort keyed on name alone would leave them as it
2749    /// found them and fail the last assertion while passing the first.
2750    #[test]
2751    fn a_listing_sorts_by_name_then_by_id() {
2752        let mut listing = vec![
2753            ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
2754            ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
2755            ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
2756        ];
2757        sort_flock(&mut listing);
2758
2759        let seen: Vec<(&str, u32)> = listing
2760            .iter()
2761            .map(|info| (info.name.as_str(), info.id))
2762            .collect();
2763        assert_eq!(
2764            seen,
2765            vec![("api", 2), ("web", 0), ("web", 1)],
2766            "name first, then id inside a name"
2767        );
2768    }
2769
2770    #[test]
2771    fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
2772        let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
2773            .instance(Some(2))
2774            .build();
2775        assert_eq!(with.instance, Some(2));
2776
2777        let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
2778        assert_eq!(
2779            without.instance, None,
2780            "a row nobody set a slot on says so, rather than claiming slot 0"
2781        );
2782    }
2783
2784    #[test]
2785    fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
2786        // The skew case the Option exists for: an older shepherd's JSON has no
2787        // `instance` key at all.
2788        let json = r#"{"id":1,"name":"web","status":"online","pid":null,
2789            "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
2790            "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
2791            "lambs":null,"last_exit":null,"smit":null}"#;
2792        let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
2793        assert_eq!(info.instance, None);
2794    }
2795
2796    #[test]
2797    fn sort_flock_orders_by_slot_before_id() {
2798        // A reload gave slot 0 a fresh, higher id. Slot order must still win.
2799        let mut listing = vec![
2800            ProcessInfo::builder(9, "web", ProcStatus::Online)
2801                .instance(Some(0))
2802                .build(),
2803            ProcessInfo::builder(2, "web", ProcStatus::Online)
2804                .instance(Some(1))
2805                .build(),
2806        ];
2807        sort_flock(&mut listing);
2808        assert_eq!(
2809            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
2810            vec![9, 2],
2811            "slot 0 leads even though its id is higher"
2812        );
2813    }
2814
2815    #[test]
2816    fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
2817        let mut listing = vec![
2818            ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
2819            ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
2820        ];
2821        sort_flock(&mut listing);
2822        assert_eq!(
2823            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
2824            vec![3, 5],
2825            "an older daemon's listing sorts exactly as it does today"
2826        );
2827    }
2828}