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