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