Skip to main content

shep_core/protocol/
request.rs

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