Skip to main content

shep_core/protocol/
request.rs

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