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