shep_core/protocol/events.rs
1//! Bus events broadcast to subscribed clients
2
3use std::borrow::Cow;
4
5use serde::{Deserialize, Serialize};
6
7use crate::protocol::channel::ChildMessage;
8use crate::protocol::request::ProcessInfo;
9
10/// What happened to a sheep
11// wire format: changing existing variants is a breaking change
12//
13// A NEW variant is additive for Rust and for the protocol version, but it is
14// not free for a subscriber that predates it, and this enum is the one place
15// in the protocol where that is true. There is no `#[serde(other)]` fallback,
16// and every variant's topic is `process.<something>`, which the `process.*`
17// glob an existing subscriber already uses matches — so an older client is
18// sent a frame it cannot decode. It drops that frame; it is not sent
19// anything it asked for and lost. The same does not arise for `Request` or
20// `Response`, where an old client never sends the verb whose answer it could
21// not read. Weigh that cost against the alternative before adding one:
22// reusing an existing kind and leaving subscribers to infer the event is the
23// other option, and it was the losing one for reload only because a reload's
24// reply is an acceptance, which leaves the bus as the only place its outcome
25// is ever reported.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28#[non_exhaustive]
29pub enum ProcessEventKind {
30 /// Spawn initiated
31 Start,
32 /// Became ready/online
33 Online,
34 /// Process exited
35 Exit,
36 /// Restart initiated
37 Restart,
38 /// A reload is replacing this instance: its replacement has been spawned
39 /// into the same instance slot, and this one will be asked to go once
40 /// that replacement is serving
41 Reload,
42 /// This instance has replaced the one it was spawned to drain, and that
43 /// one is gone — the swap is over
44 Reloaded,
45 /// A reload gave up, so the instances it had not reached are left alone
46 ///
47 /// The instance named is the one the abandonment left holding the slot,
48 /// and whether that one is serving depends on which abandonment it was.
49 /// Where the reload gave up on replacing an instance, that instance is
50 /// named and is still the app's live one. Where it gave up because the
51 /// replacement went down instead, the replacement is named. As with every
52 /// event here, `info` is that instance as it stood when the event was
53 /// raised, so read `info.status` rather than assuming a live one.
54 ReloadAbandoned,
55 /// Stopped by request
56 Stop,
57 /// Deregistered
58 Delete,
59 /// Restart budget exhausted
60 Errored,
61}
62
63/// One event on the daemon bus
64///
65/// Uses adjacently tagged serde format with `event` discriminator and `data` wrapper.
66/// Subscription TOPICS are the dotted strings from [`BusEvent::topic`]
67/// (`process.exit`, `log.out`, `daemon.*` — spec §6 grammar).
68/// The daemon's server-side filter globs against `topic()`.
69// wire format: changing existing variants is a breaking change
70//
71// `large_enum_variant` allowed, not fixed, for the same reason
72// `Response`'s own `#[allow]` gives (see that type's doc): `Process` holds
73// a whole `ProcessInfo` inline, and task 12's own `pending`/`overridden`
74// fields are what pushed the spread past the lint's threshold this time.
75// Boxing would be a source break for every `BusEvent::Process { info, .. }`
76// in and out of this workspace, for nothing: an event is built once per
77// transition and serialized onto the bus immediately, so the size it
78// occupies on one stack frame in between is not a cost anybody pays.
79#[allow(clippy::large_enum_variant)]
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81// Adjacent tagging chosen because internally-tagged form cannot compile:
82// the `Process` variant has its own `event: ProcessEventKind` field, which
83// collides with an internal tag named `event` (serde_derive rejects this).
84// Adjacently tagging (with `content = "data"`) avoids the collision while
85// matching `Response`'s serde convention. Wire shape pinned by snapshot.
86#[serde(tag = "event", content = "data", rename_all = "snake_case")]
87#[non_exhaustive]
88pub enum BusEvent {
89 /// Lifecycle event for one sheep
90 Process {
91 /// What happened
92 event: ProcessEventKind,
93 /// Sheep snapshot at event time
94 info: ProcessInfo,
95 /// True when a user action caused it
96 manually: bool,
97 /// Unix millis
98 at_ms: u64,
99 },
100 /// One stdout line from a sheep
101 LogOut {
102 /// Sheep id
103 id: u32,
104 /// The line (no trailing newline)
105 line: String,
106 },
107 /// One stderr line from a sheep
108 LogErr {
109 /// Sheep id
110 id: u32,
111 /// The line
112 line: String,
113 },
114 /// One message a sheep wrote on its shepherd channel (fd 3).
115 ///
116 /// Child->shepherd only. The shepherd's own writes — the
117 /// `{"kind":"shutdown"}` of `shutdown_with_message`, and an `action` a
118 /// `Trigger` dispatched — are deliberately not here. Every one of them is
119 /// something an operator or the daemon just did and already has a
120 /// reporter: a shutdown message is followed by `process.stop`, and an
121 /// action is answered to the caller that sent it by
122 /// `Response::Triggered`. Putting them here as well would make this the
123 /// only event on the bus reporting a REQUEST rather than an outcome, and
124 /// would loop a dog that both subscribes and triggers back onto its own
125 /// dispatches. Adding the outbound half later stays additive — another
126 /// variant, more `channel.` topics, no version bump — so this is a
127 /// narrowing, not a door closed.
128 ///
129 /// `message` is the app's own text, whole and unredacted, unlike the
130 /// `[dog.<name>]` config that travels as [`DogSectionToml`]. Nothing on
131 /// this wire is a credential: `Ready` is empty, `Metric` is a name and a
132 /// float, and an `ActionReply` body is text the app chose to publish to
133 /// whoever triggered it. That is what makes a derived `Debug` safe here.
134 ///
135 /// [`DogSectionToml`]: crate::protocol::DogSectionToml
136 Channel {
137 /// The sheep that wrote it.
138 id: u32,
139 /// The message, exactly as it came off fd 3.
140 message: ChildMessage,
141 },
142 /// The bounded queue dropped this many events for this subscriber
143 Dropped {
144 /// Dropped-event count since last notice
145 count: u64,
146 },
147 /// Daemon is shutting down
148 DaemonShutdown,
149 /// A dog's section in `dogs.toml` changed. Published under
150 /// `config.dog.<name>`, so a dog subscribes to its own name and hears
151 /// nobody else's.
152 ///
153 /// Says THAT it changed and nothing about what it means. A dog that
154 /// wants the values re-asks with
155 /// [`Request::DogConfig`](crate::protocol::Request::DogConfig) and
156 /// decides for itself what to do with them: swap values in place,
157 /// rebind a listener, or ask for its own restart. Putting a live
158 /// versus needs-restart axis here would mean shep knowing what a
159 /// third-party dog's fields mean, which is the one thing this whole
160 /// contract refuses.
161 ///
162 /// # What it carries, and what it must never carry
163 ///
164 /// A NAME, and nothing else. The bus is a broadcast: a `config.*`
165 /// subscriber sees every dog's frames, and a `[bark]` section
166 /// routinely holds a webhook URL that is itself a bearer credential,
167 /// which is why [`DogSectionToml`] redacts its own `Debug`.
168 /// `Request::DogConfig` answers a dog for its OWN section, so
169 /// re-asking is the whole design rather than an extra round trip. A
170 /// derived `Debug` is safe here for the same reason: a dog's name is
171 /// what an operator typed into `enabled_dogs`.
172 ///
173 /// [`DogSectionToml`]: crate::protocol::DogSectionToml
174 DogConfigChanged {
175 /// The dog whose section changed.
176 dog: String,
177 },
178}
179
180impl BusEvent {
181 /// The dotted subscription topic for this event (spec §6 grammar)
182 ///
183 /// A [`Cow`] rather than a `&'static str`, because one topic is not
184 /// fixed: [`Self::DogConfigChanged`] names its dog in the topic
185 /// itself, which is what lets a dog subscribe to its own config and
186 /// hear nobody else's. Every other variant is still a borrowed
187 /// literal and allocates nothing.
188 #[must_use]
189 pub fn topic(&self) -> Cow<'static, str> {
190 let fixed = match self {
191 Self::Process { event, .. } => match event {
192 ProcessEventKind::Start => "process.start",
193 ProcessEventKind::Online => "process.online",
194 ProcessEventKind::Exit => "process.exit",
195 ProcessEventKind::Restart => "process.restart",
196 ProcessEventKind::Reload => "process.reload",
197 ProcessEventKind::Reloaded => "process.reloaded",
198 ProcessEventKind::ReloadAbandoned => "process.reload_abandoned",
199 ProcessEventKind::Stop => "process.stop",
200 ProcessEventKind::Delete => "process.delete",
201 ProcessEventKind::Errored => "process.errored",
202 },
203 Self::LogOut { .. } => "log.out",
204 Self::LogErr { .. } => "log.err",
205 // Total over `ChildMessage`, with no wildcard, and that is the
206 // point of leaving that enum exhaustive (see its module doc): a
207 // fourth kind on fd 3 fails to compile here until someone decides
208 // what its topic is, rather than defaulting into a topic no
209 // subscriber ever asked for.
210 Self::Channel { message, .. } => match message {
211 ChildMessage::Ready => "channel.ready",
212 ChildMessage::Metric { .. } => "channel.metric",
213 ChildMessage::ActionReply { .. } => "channel.action_reply",
214 },
215 Self::Dropped { .. } => "daemon.dropped",
216 Self::DaemonShutdown => "daemon.shutdown",
217 // The one topic built rather than named. `config.dog.` is the
218 // prefix a subscriber globs on; the dog's own name is the last
219 // segment, so `config.dog.bark` reaches one dog and `config.*`
220 // reaches all of them.
221 Self::DogConfigChanged { dog } => return Cow::Owned(format!("config.dog.{dog}")),
222 };
223 Cow::Borrowed(fixed)
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::protocol::request::{ExitInfo, ProcessInfo};
231 use crate::status::ProcStatus;
232
233 #[test]
234 fn bus_event_wire_snapshots() {
235 let mut events = vec![
236 BusEvent::Process {
237 event: ProcessEventKind::Exit,
238 info: ProcessInfo {
239 id: 3,
240 name: "web".to_string(),
241 status: ProcStatus::WaitingRestart,
242 pid: None,
243 restarts: 2,
244 uptime_ms: 500,
245 fold: None,
246 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
247 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
248 // A bus event is built from the actor's own snapshot,
249 // which never carries a resource reading.
250 cpu_percent: None,
251 memory_bytes: None,
252 dog: None,
253 lambs: None,
254 // `restarts: 2` already says this is not this sheep's
255 // first exit; `handle_exited` sets `last_exit` before it
256 // decides what to do with the exit, so the `Exit` event
257 // this row pins carries the very outcome it announces.
258 last_exit: Some(ExitInfo {
259 code: Some(1),
260 signal: None,
261 }),
262 // A non-ASCII marker on purpose: this snapshot is what
263 // pins the encoding a subscriber reads, and a smit is
264 // the one field on this row a third party writes.
265 smit: Some("\u{25b2} main@a1b2c3".to_string()),
266 instance: None,
267 handshook: None,
268 dog_stale: None,
269 pending: None,
270 overridden: None,
271 },
272 manually: false,
273 at_ms: 1_700_000_000_000,
274 },
275 BusEvent::LogOut {
276 id: 3,
277 line: "listening on :8080".to_string(),
278 },
279 BusEvent::Dropped { count: 17 },
280 // The row above pins `instance`'s absent shape; every lifecycle
281 // row below reuses it via `sample`. This is the only place the
282 // present shape (a live slot on a scaled app) is on the wire.
283 BusEvent::Process {
284 event: ProcessEventKind::Online,
285 info: ProcessInfo::builder(4, "web", ProcStatus::Online)
286 .pid(Some(5150))
287 .instance(Some(2))
288 .build(),
289 manually: false,
290 at_ms: 1_700_000_000_000,
291 },
292 ];
293
294 // The lifecycle kinds exercised here (the reload trio has its own
295 // fixture), over one
296 // identical `info`, so the snapshot rows differ by their `event` tag
297 // and by nothing else. These are the ordinary events a real
298 // integration — a dashboard, a bark rule — depends on first, and a
299 // Rust-identifier rename on any of them would change the wire string
300 // mechanically, compile clean, and break that integration silently.
301 let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
302 .restarts(2)
303 .uptime_ms(500)
304 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
305 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
306 // Reused below for `Stop` and `Delete` too — the two operator-
307 // caused endings, and proof that a `shep stop`/`shep delete`
308 // still carries the exit that produced them rather than losing
309 // it because an operator asked for it.
310 .last_exit(Some(ExitInfo {
311 code: Some(1),
312 signal: None,
313 }))
314 .build();
315
316 let lifecycle = [
317 ProcessEventKind::Start,
318 ProcessEventKind::Online,
319 ProcessEventKind::Restart,
320 ProcessEventKind::Stop,
321 ProcessEventKind::Delete,
322 ProcessEventKind::Errored,
323 ]
324 .map(|event| BusEvent::Process {
325 event,
326 info: sample.clone(),
327 manually: false,
328 at_ms: 1_700_000_000_000,
329 });
330
331 events.extend(lifecycle);
332
333 // All three shepherd-channel topics, over one sheep id, because the
334 // adjacent-tagged shape puts the message's own `kind` INSIDE `data`
335 // next to `id` — a nesting that is easy to get wrong by hand and
336 // invisible in a round-trip test, which only proves this crate agrees
337 // with itself.
338 events.extend([
339 BusEvent::Channel {
340 id: 3,
341 message: ChildMessage::Ready,
342 },
343 BusEvent::Channel {
344 id: 3,
345 message: ChildMessage::Metric {
346 name: "rps".to_string(),
347 value: 42.0,
348 },
349 },
350 BusEvent::Channel {
351 id: 3,
352 message: ChildMessage::ActionReply {
353 action: "gc".to_string(),
354 body: "freed 12MB".to_string(),
355 id: Some(7),
356 },
357 },
358 ]);
359
360 // Last, so every row above keeps its index. The one topic that is
361 // not a fixed string, and the one frame a dog subscribes to on its
362 // own name: an operator's `dogs.toml` edit reaches a running dog
363 // through this shape or through nothing.
364 events.push(BusEvent::DogConfigChanged {
365 dog: "bark".to_string(),
366 });
367
368 insta::assert_json_snapshot!("bus_event_wire_v3", events);
369 }
370
371 #[test]
372 fn topics_follow_the_dotted_grammar() {
373 // spec §6: process.* / log.out / log.err / daemon.*
374 let e = BusEvent::LogOut {
375 id: 1,
376 line: String::new(),
377 };
378 assert_eq!(e.topic(), "log.out");
379 assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
380 }
381
382 /// The three kinds a reload reports itself with, pinned as topic strings
383 /// and as wire strings.
384 ///
385 /// Fails if [`BusEvent::topic`] maps any of them to the wrong dotted
386 /// string — a typo there is invisible to a `process.*` subscriber, which
387 /// matches anything under `process.`, and silently unreachable to one
388 /// that named the topic it wanted. Fails too if a variant's serde
389 /// spelling drifts from its snake_case default (a stray
390 /// `#[serde(rename)]`, or a variant renamed without its topic): a reload's
391 /// reply is an acceptance, so these frames are the whole of what a client
392 /// ever learns about how the reload went, and a client matching on the
393 /// wire string would stop recognising them.
394 #[test]
395 fn a_reload_reports_itself_under_three_topics() {
396 for (kind, topic, wire) in [
397 (ProcessEventKind::Reload, "process.reload", "\"reload\""),
398 (
399 ProcessEventKind::Reloaded,
400 "process.reloaded",
401 "\"reloaded\"",
402 ),
403 (
404 ProcessEventKind::ReloadAbandoned,
405 "process.reload_abandoned",
406 "\"reload_abandoned\"",
407 ),
408 ] {
409 let event = BusEvent::Process {
410 event: kind,
411 info: ProcessInfo {
412 id: 3,
413 name: "web".to_string(),
414 status: ProcStatus::Stopping,
415 pid: Some(4242),
416 restarts: 0,
417 uptime_ms: 0,
418 fold: None,
419 out_file: None,
420 err_file: None,
421 cpu_percent: None,
422 memory_bytes: None,
423 dog: None,
424 lambs: None,
425 last_exit: None,
426 smit: None,
427 instance: None,
428 handshook: None,
429 dog_stale: None,
430 pending: None,
431 overridden: None,
432 },
433 manually: true,
434 at_ms: 0,
435 };
436 assert_eq!(event.topic(), topic, "{kind:?}");
437 assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
438 }
439 }
440
441 #[test]
442 fn v1_bus_event_fixture_still_deserializes() {
443 // Adjacent-tagged shape pinned as a byte fixture (IR-35).
444 let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
445 let ev: BusEvent = serde_json::from_str(fixture).unwrap();
446 assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
447 }
448
449 /// fails if a shepherd-channel message maps to the wrong dotted topic. A
450 /// subscriber that asked for `channel.metric` and silently receives nothing
451 /// has no other way to find out, and `channel.*` matches whatever typo is
452 /// there — so the exact strings are the contract, not the prefix.
453 #[test]
454 fn every_shepherd_channel_message_has_its_own_topic() {
455 for (message, topic) in [
456 (ChildMessage::Ready, "channel.ready"),
457 (
458 ChildMessage::Metric {
459 name: "rps".to_string(),
460 value: 42.0,
461 },
462 "channel.metric",
463 ),
464 (
465 ChildMessage::ActionReply {
466 action: "gc".to_string(),
467 body: "ok".to_string(),
468 id: Some(7),
469 },
470 "channel.action_reply",
471 ),
472 ] {
473 let event = BusEvent::Channel {
474 id: 3,
475 message: message.clone(),
476 };
477 assert_eq!(event.topic(), topic, "{message:?}");
478 }
479 }
480
481 /// fails if `channel.*` stops reaching every one of the three. The glob a
482 /// dashboard writes is the prefix, so a topic that drifted out from under it
483 /// (`channel_ready`, say) would be unreachable by the only pattern anyone
484 /// actually subscribes with.
485 #[test]
486 fn the_channel_glob_reaches_all_three_topics() {
487 for message in [
488 ChildMessage::Ready,
489 ChildMessage::Metric {
490 name: "rps".to_string(),
491 value: 1.0,
492 },
493 ChildMessage::ActionReply {
494 action: "gc".to_string(),
495 body: String::new(),
496 id: None,
497 },
498 ] {
499 let topic = BusEvent::Channel { id: 1, message }.topic();
500 assert!(
501 topic.starts_with("channel."),
502 "`{topic}` is not under the channel.* glob"
503 );
504 }
505 }
506
507 /// fails if the event stops carrying the message body. The whole argument for
508 /// putting the real message on the bus rather than a summary is that nothing
509 /// on this wire is a credential — a reply body that arrived truncated or
510 /// replaced would make the topic useless for the case it exists for, a
511 /// dashboard watching what apps actually say.
512 #[test]
513 fn a_channel_event_carries_the_message_verbatim() {
514 let event = BusEvent::Channel {
515 id: 3,
516 message: ChildMessage::ActionReply {
517 action: "gc".to_string(),
518 body: "freed 12MB".to_string(),
519 id: Some(7),
520 },
521 };
522 let json = serde_json::to_string(&event).unwrap();
523 assert!(json.contains("freed 12MB"), "{json}");
524 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
525 }
526
527 /// fails if a dog's config topic stops naming the dog. The topic is the
528 /// whole of what a dog subscribes with, so a name that did not reach it
529 /// (or reached it under a different separator) leaves the dog attached
530 /// to a topic nothing ever publishes and silently running on config an
531 /// operator has already changed.
532 #[test]
533 fn a_dog_config_event_names_the_dog_in_its_topic() {
534 for dog in ["bark", "metrics", "otel-shipper"] {
535 let event = BusEvent::DogConfigChanged {
536 dog: dog.to_string(),
537 };
538 assert_eq!(event.topic(), format!("config.dog.{dog}"));
539 }
540 }
541
542 /// fails if the event grows a payload. shep says THAT a section changed
543 /// and nothing about what it means or what is in it: a dog re-asks with
544 /// `Request::DogConfig`, which is the one path that answers a dog for
545 /// its own section only. A value on this wire would put another dog's
546 /// webhook credential in front of every subscriber on `config.*`.
547 #[test]
548 fn a_dog_config_event_carries_the_name_and_nothing_else() {
549 let event = BusEvent::DogConfigChanged {
550 dog: "bark".to_string(),
551 };
552 let json = serde_json::to_string(&event).unwrap();
553 assert_eq!(
554 json,
555 r#"{"event":"dog_config_changed","data":{"dog":"bark"}}"#
556 );
557 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
558 }
559}