Skip to main content

shep_core/protocol/
request.rs

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