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