Skip to main content

shep_core/protocol/
request.rs

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