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