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