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::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 free for a subscriber built from this point on: an
14// unrecognized string decodes as `Unrecognized` instead of failing the
15// whole frame.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18#[non_exhaustive]
19pub enum ProcessEventKind {
20 /// Spawn initiated
21 Start,
22 /// Became ready/online
23 Online,
24 /// Process exited
25 Exit,
26 /// Restart initiated
27 Restart,
28 /// A reload is replacing this instance: its replacement has been spawned
29 /// into the same instance slot, and this one will be asked to go once
30 /// that replacement is serving
31 Reload,
32 /// This instance has replaced the one it was spawned to drain; that one
33 /// is gone.
34 Reloaded,
35 /// A reload gave up, so the instances it had not reached are left alone
36 ///
37 /// The instance named is whichever one the abandonment left holding the
38 /// slot: still the app's live instance if the reload gave up before
39 /// replacing it, or the replacement if that one went down instead.
40 /// `info` reflects that instance's state at event time; read
41 /// `info.status` rather than assume it is live.
42 ReloadAbandoned,
43 /// Stopped by request
44 Stop,
45 /// Deregistered
46 Delete,
47 /// Restart budget exhausted
48 Errored,
49 /// An event kind this build has not been taught.
50 ///
51 /// Only ever produced by decoding: an unrecognized string falls through
52 /// to this variant via `#[serde(other)]` instead of failing the whole
53 /// frame. Nothing constructs one to send, and `bark`'s `is_known_kind`
54 /// refuses the spelling before a rule can name it. That is a call-site
55 /// invariant rather than a type-level one: `#[serde(other)]` governs
56 /// decoding only, so serializing this would emit `"unrecognized"`.
57 #[serde(other)]
58 Unrecognized,
59}
60
61/// One event on the daemon bus
62///
63/// Adjacently tagged: `event` discriminator, `data` wrapper. Subscription
64/// topics are the dotted strings from [`BusEvent::topic`] (`process.exit`,
65/// `log.out`, `daemon.*`); the daemon's filter globs against them.
66// wire format: changing existing variants is a breaking change
67//
68// `large_enum_variant` allowed: boxing `Process` would break every match on
69// it, for no benefit since an event is serialized immediately.
70#[allow(clippy::large_enum_variant)]
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72// Adjacently tagged, not internally: `Process`'s own `event` field would
73// collide with an internal tag named `event`, and serde_derive refuses
74// that.
75#[serde(tag = "event", content = "data", rename_all = "snake_case")]
76#[non_exhaustive]
77pub enum BusEvent {
78 /// Lifecycle event for one sheep
79 Process {
80 /// What happened
81 event: ProcessEventKind,
82 /// Sheep snapshot at event time
83 info: ProcessInfo,
84 /// True when a user action caused it
85 manually: bool,
86 /// Unix millis
87 at_ms: u64,
88 },
89 /// One stdout line from a sheep
90 LogOut {
91 /// Sheep id
92 id: u32,
93 /// The line (no trailing newline)
94 line: String,
95 },
96 /// One stderr line from a sheep
97 LogErr {
98 /// Sheep id
99 id: u32,
100 /// The line
101 line: String,
102 },
103 /// One message a sheep wrote on its shepherd channel (fd 3).
104 ///
105 /// Child -> shepherd only: the shepherd's own writes (a shutdown
106 /// message, a dispatched action) are reported elsewhere, by
107 /// `process.stop` and `Response::Triggered`.
108 ///
109 /// `message` is the app's own text, whole and unredacted. The daemon
110 /// adds nothing of its own; app-provided text must be safe for every
111 /// subscriber, since it is broadcast verbatim.
112 Channel {
113 /// The sheep that wrote it.
114 id: u32,
115 /// The message, exactly as it came off fd 3.
116 message: ChildMessage,
117 },
118 /// The bounded queue dropped this many events for this subscriber
119 Dropped {
120 /// Dropped-event count since last notice
121 count: u64,
122 },
123 /// Daemon is shutting down
124 DaemonShutdown,
125 /// A dog's section in `dogs.toml` changed. Published under
126 /// `config.dog.<name>`, so a dog subscribes to its own name and hears
127 /// nobody else's.
128 ///
129 /// Carries only the dog's name, nothing else: the bus is a broadcast,
130 /// and a `[bark]` section can hold a webhook URL as a bearer credential
131 /// (why [`DogSectionToml`] redacts its own `Debug`). A dog that wants
132 /// the values re-asks with
133 /// [`Request::DogConfig`](crate::protocol::Request::DogConfig), which
134 /// answers only that dog's own section.
135 ///
136 /// [`DogSectionToml`]: crate::protocol::DogSectionToml
137 DogConfigChanged {
138 /// The dog whose section changed.
139 dog: String,
140 },
141 /// A dog's `[app.dogs.<name>]` table changed on one sheep. Published
142 /// under `config.sheep.<dog>`, so a dog subscribes to its own name and
143 /// hears nobody else's, the way [`Self::DogConfigChanged`] does for
144 /// `dogs.toml`.
145 ///
146 /// Fires once per dog and sheep for a change that adds, removes or
147 /// edits that sheep's table for `dog`: a name gaining its first table,
148 /// the last instance of such a name being deleted, or a load,
149 /// `Request::SetSheepDogSettings` or `--reset` changing one. Never for
150 /// a handover, since that changes no answer.
151 ///
152 /// Carries only the sheep's name and the dog's: a table can hold a
153 /// credential, the same reason [`Self::DogConfigChanged`] carries only
154 /// the dog's own name. A dog that wants the values re-asks with
155 /// [`Request::DogSheepSettings`](crate::protocol::Request::DogSheepSettings),
156 /// which answers with that dog's tables.
157 DogSheepSettingsChanged {
158 /// The dog whose table changed.
159 dog: String,
160 /// The sheep whose table changed.
161 sheep: String,
162 },
163}
164
165impl BusEvent {
166 /// The dotted subscription topic for this event (spec §6 grammar)
167 ///
168 /// A [`Cow`] rather than a `&'static str`, because two topics are not
169 /// fixed: [`Self::DogConfigChanged`] and [`Self::DogSheepSettingsChanged`]
170 /// name their dog in the topic itself, which is what lets a dog
171 /// subscribe to its own config and hear nobody else's. Every other
172 /// variant is still a borrowed literal and allocates nothing.
173 #[must_use]
174 pub fn topic(&self) -> Cow<'static, str> {
175 let fixed = match self {
176 Self::Process { event, .. } => match event {
177 ProcessEventKind::Start => "process.start",
178 ProcessEventKind::Online => "process.online",
179 ProcessEventKind::Exit => "process.exit",
180 ProcessEventKind::Restart => "process.restart",
181 ProcessEventKind::Reload => "process.reload",
182 ProcessEventKind::Reloaded => "process.reloaded",
183 ProcessEventKind::ReloadAbandoned => "process.reload_abandoned",
184 ProcessEventKind::Stop => "process.stop",
185 ProcessEventKind::Delete => "process.delete",
186 ProcessEventKind::Errored => "process.errored",
187 // Decode-only, never emitted by this build. Given its own
188 // topic rather than acted on: a subscriber sees it pass
189 // through like any other kind it does not specifically
190 // filter for, instead of the daemon treating it as a real
191 // lifecycle transition.
192 ProcessEventKind::Unrecognized => "process.unrecognized",
193 },
194 Self::LogOut { .. } => "log.out",
195 Self::LogErr { .. } => "log.err",
196 // Total match over `ChildMessage`: a new kind on fd 3 fails
197 // to compile here until its topic is decided.
198 Self::Channel { message, .. } => match message {
199 ChildMessage::Ready => "channel.ready",
200 ChildMessage::Metric { .. } => "channel.metric",
201 ChildMessage::ActionReply { .. } => "channel.action_reply",
202 ChildMessage::LambLabel { .. } => "channel.lamb_label",
203 ChildMessage::Note { .. } => "channel.note",
204 },
205 Self::Dropped { .. } => "daemon.dropped",
206 Self::DaemonShutdown => "daemon.shutdown",
207 // Built rather than named. `config.dog.` is the prefix a
208 // subscriber globs on; the dog's own name is the last segment,
209 // so `config.dog.bark` reaches one dog and `config.*` reaches
210 // all of them.
211 Self::DogConfigChanged { dog } => return Cow::Owned(format!("config.dog.{dog}")),
212 // Named by the dog, like `DogConfigChanged` above: the sheep is
213 // the payload, not the topic, so a dog subscribes once for
214 // every sheep carrying its table.
215 Self::DogSheepSettingsChanged { dog, .. } => {
216 return Cow::Owned(format!("config.sheep.{dog}"));
217 }
218 };
219 Cow::Borrowed(fixed)
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::protocol::request::{ExitInfo, ProcessInfo};
227 use crate::protocol::{LambLabel, MetricType};
228 use crate::status::ProcStatus;
229
230 /// The comment above this enum said a new variant is not free because
231 /// there is no fallback. This is the fallback.
232 #[test]
233 fn an_unknown_process_event_decodes_as_unrecognized() {
234 assert_eq!(
235 serde_json::from_str::<ProcessEventKind>(r#""invented_next_year""#).unwrap(),
236 ProcessEventKind::Unrecognized
237 );
238 }
239
240 #[test]
241 fn every_known_process_event_still_round_trips() {
242 for kind in [
243 ProcessEventKind::Start,
244 ProcessEventKind::Online,
245 ProcessEventKind::Exit,
246 ProcessEventKind::Restart,
247 ProcessEventKind::Reload,
248 ProcessEventKind::Reloaded,
249 ProcessEventKind::ReloadAbandoned,
250 ProcessEventKind::Stop,
251 ProcessEventKind::Delete,
252 ProcessEventKind::Errored,
253 ] {
254 let json = serde_json::to_string(&kind).unwrap();
255 assert_eq!(
256 serde_json::from_str::<ProcessEventKind>(&json).unwrap(),
257 kind
258 );
259 }
260 }
261
262 #[test]
263 fn a_non_string_process_event_is_still_an_error() {
264 assert!(serde_json::from_str::<ProcessEventKind>("42").is_err());
265 }
266
267 #[test]
268 fn bus_event_wire_snapshots() {
269 let mut events = vec![
270 BusEvent::Process {
271 event: ProcessEventKind::Exit,
272 info: ProcessInfo {
273 id: 3,
274 name: "web".to_string(),
275 status: ProcStatus::WaitingRestart,
276 pid: None,
277 restarts: 2,
278 uptime_ms: 500,
279 fold: None,
280 depends_on: Vec::new(),
281 out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
282 err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
283 // A bus event is built from the actor's own snapshot,
284 // which never carries a resource reading.
285 cpu_percent: None,
286 memory_bytes: None,
287 cpu_ms: None,
288 dog: None,
289 lambs: None,
290 // `handle_exited` sets `last_exit` before deciding what
291 // to do with the exit, so this `Exit` row carries the
292 // outcome it announces.
293 last_exit: Some(ExitInfo {
294 code: Some(1),
295 signal: None,
296 }),
297 // A non-ASCII marker on purpose: this snapshot is what
298 // pins the encoding a subscriber reads, and a smit is
299 // the one field on this row a third party writes.
300 smit: Some("\u{25b2} main@a1b2c3".to_string()),
301 instance: None,
302 handshook: None,
303 dog_stale: None,
304 pending: None,
305 overridden: None,
306 max_memory: None,
307 level_rules: Vec::new(),
308 reload_deadline_ms: None,
309 disabled: None,
310 log_timestamps: None,
311 },
312 manually: false,
313 at_ms: 1_700_000_000_000,
314 },
315 BusEvent::LogOut {
316 id: 3,
317 line: "listening on :8080".to_string(),
318 },
319 BusEvent::Dropped { count: 17 },
320 // The row above pins `instance`'s absent shape; every lifecycle
321 // row below reuses it via `sample`. This is the only place the
322 // present shape (a live slot on a scaled app) is on the wire.
323 BusEvent::Process {
324 event: ProcessEventKind::Online,
325 info: ProcessInfo::builder(4, "web", ProcStatus::Online)
326 .pid(Some(5150))
327 .instance(Some(2))
328 .build(),
329 manually: false,
330 at_ms: 1_700_000_000_000,
331 },
332 ];
333
334 // One identical `info` reused below, so these rows differ only by
335 // their `event` tag: a variant rename changes the wire string
336 // silently otherwise.
337 let sample = ProcessInfo::builder(3, "web", ProcStatus::WaitingRestart)
338 .restarts(2)
339 .uptime_ms(500)
340 .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
341 .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
342 // Reused below for `Stop` and `Delete` too: both still carry the
343 // exit that produced them.
344 .last_exit(Some(ExitInfo {
345 code: Some(1),
346 signal: None,
347 }))
348 .build();
349
350 let lifecycle = [
351 ProcessEventKind::Start,
352 ProcessEventKind::Online,
353 ProcessEventKind::Restart,
354 ProcessEventKind::Stop,
355 ProcessEventKind::Delete,
356 ProcessEventKind::Errored,
357 ]
358 .map(|event| BusEvent::Process {
359 event,
360 info: sample.clone(),
361 manually: false,
362 at_ms: 1_700_000_000_000,
363 });
364
365 events.extend(lifecycle);
366
367 // The adjacent-tagged shape nests the message's own `kind` inside
368 // `data`, next to `id`: easy to get wrong by hand.
369 events.extend([
370 BusEvent::Channel {
371 id: 3,
372 message: ChildMessage::Ready,
373 },
374 BusEvent::Channel {
375 id: 3,
376 message: ChildMessage::Metric {
377 name: "rps".to_string(),
378 value: 42.0,
379 metric_type: None,
380 labels: std::collections::BTreeMap::new(),
381 help: None,
382 },
383 },
384 BusEvent::Channel {
385 id: 3,
386 message: ChildMessage::ActionReply {
387 action: "gc".to_string(),
388 body: "freed 12MB".to_string(),
389 id: Some(7),
390 },
391 },
392 ]);
393
394 // Last, so every row above keeps its index. The one topic that is
395 // not a fixed string, and the one frame a dog subscribes to on its
396 // own name: an operator's `dogs.toml` edit reaches a running dog
397 // through this shape or through nothing.
398 events.push(BusEvent::DogConfigChanged {
399 dog: "bark".to_string(),
400 });
401 // The second event whose topic is built rather than named, and the
402 // one that carries a sheep's name beside the dog's.
403 events.push(BusEvent::DogSheepSettingsChanged {
404 dog: "jobs".to_string(),
405 sheep: "web".to_string(),
406 });
407
408 insta::assert_json_snapshot!("bus_event_wire_v11", events);
409 }
410
411 #[test]
412 fn topics_follow_the_dotted_grammar() {
413 // spec §6: process.* / log.out / log.err / daemon.*
414 let e = BusEvent::LogOut {
415 id: 1,
416 line: String::new(),
417 };
418 assert_eq!(e.topic(), "log.out");
419 assert_eq!(BusEvent::DaemonShutdown.topic(), "daemon.shutdown");
420 }
421
422 /// The three kinds a reload reports itself with, pinned as topic
423 /// strings and wire strings: a reload's reply is an acceptance, so
424 /// these frames are the only place a client learns how it went.
425 #[test]
426 fn a_reload_reports_itself_under_three_topics() {
427 for (kind, topic, wire) in [
428 (ProcessEventKind::Reload, "process.reload", "\"reload\""),
429 (
430 ProcessEventKind::Reloaded,
431 "process.reloaded",
432 "\"reloaded\"",
433 ),
434 (
435 ProcessEventKind::ReloadAbandoned,
436 "process.reload_abandoned",
437 "\"reload_abandoned\"",
438 ),
439 ] {
440 let event = BusEvent::Process {
441 event: kind,
442 // Scaffolding: `topic()` reads only `event`, and nothing
443 // here is asserted. The builder says so; the exhaustive
444 // literal in `bus_event_wire_snapshots` says the opposite
445 // about its own row, deliberately.
446 info: ProcessInfo::builder(3, "web", ProcStatus::Stopping)
447 .pid(Some(4242))
448 .build(),
449 manually: true,
450 at_ms: 0,
451 };
452 assert_eq!(event.topic(), topic, "{kind:?}");
453 assert_eq!(serde_json::to_string(&kind).unwrap(), wire, "{kind:?}");
454 }
455 }
456
457 #[test]
458 fn v1_bus_event_fixture_still_deserializes() {
459 // Adjacent-tagged shape pinned as a byte fixture.
460 let fixture = r#"{"event":"log_out","data":{"id":3,"line":"ready"}}"#;
461 let ev: BusEvent = serde_json::from_str(fixture).unwrap();
462 assert!(matches!(ev, BusEvent::LogOut { id: 3, .. }));
463 }
464
465 /// The exact topic strings are the contract, not just the `channel.*`
466 /// prefix.
467 #[test]
468 fn every_shepherd_channel_message_has_its_own_topic() {
469 for (message, topic) in [
470 (ChildMessage::Ready, "channel.ready"),
471 (
472 ChildMessage::Metric {
473 name: "rps".to_string(),
474 value: 42.0,
475 metric_type: None,
476 labels: std::collections::BTreeMap::new(),
477 help: None,
478 },
479 "channel.metric",
480 ),
481 (
482 ChildMessage::ActionReply {
483 action: "gc".to_string(),
484 body: "ok".to_string(),
485 id: Some(7),
486 },
487 "channel.action_reply",
488 ),
489 (
490 ChildMessage::LambLabel {
491 pid: 4312,
492 label: LambLabel::new("worker 1").unwrap(),
493 },
494 "channel.lamb_label",
495 ),
496 (
497 ChildMessage::Note {
498 name: "lease.web.want.1".to_string(),
499 value: 1.0,
500 },
501 "channel.note",
502 ),
503 ] {
504 let event = BusEvent::Channel {
505 id: 3,
506 message: message.clone(),
507 };
508 assert_eq!(event.topic(), topic, "{message:?}");
509 }
510 }
511
512 /// `channel.*` is the only pattern anyone subscribes with; a topic that
513 /// drifts out from under it becomes unreachable.
514 #[test]
515 fn the_channel_glob_reaches_every_channel_topic() {
516 for message in [
517 ChildMessage::Ready,
518 ChildMessage::Metric {
519 name: "rps".to_string(),
520 value: 1.0,
521 metric_type: None,
522 labels: std::collections::BTreeMap::new(),
523 help: None,
524 },
525 ChildMessage::ActionReply {
526 action: "gc".to_string(),
527 body: String::new(),
528 id: None,
529 },
530 ChildMessage::LambLabel {
531 pid: 1,
532 label: LambLabel::new("").unwrap(),
533 },
534 ChildMessage::Note {
535 name: String::new(),
536 value: 0.0,
537 },
538 ] {
539 let topic = BusEvent::Channel { id: 1, message }.topic();
540 assert!(
541 topic.starts_with("channel."),
542 "`{topic}` is not under the channel.* glob"
543 );
544 }
545 }
546
547 /// The message carries verbatim: nothing on this wire is a credential.
548 #[test]
549 fn a_channel_event_carries_the_message_verbatim() {
550 let event = BusEvent::Channel {
551 id: 3,
552 message: ChildMessage::ActionReply {
553 action: "gc".to_string(),
554 body: "freed 12MB".to_string(),
555 id: Some(7),
556 },
557 };
558 let json = serde_json::to_string(&event).unwrap();
559 assert!(json.contains("freed 12MB"), "{json}");
560 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
561 }
562
563 /// The bus carries the whole message, new optional fields included.
564 #[test]
565 fn a_bus_event_carries_a_labelled_metric_through_the_wire() {
566 let event = BusEvent::Channel {
567 id: 3,
568 message: ChildMessage::Metric {
569 name: "queue_depth".to_string(),
570 value: 3.0,
571 metric_type: Some(MetricType::Counter),
572 labels: [("model".to_string(), "qwen".to_string())].into(),
573 help: Some("Requests waiting for the lease.".to_string()),
574 },
575 };
576 let json = serde_json::to_string(&event).unwrap();
577 assert!(json.contains(r#""type":"counter""#), "{json}");
578 assert!(json.contains(r#""labels":{"model":"qwen"}"#), "{json}");
579 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
580 }
581
582 /// The topic is the whole of what a dog subscribes with; a name that
583 /// misses it leaves the dog listening to nothing.
584 #[test]
585 fn a_dog_config_event_names_the_dog_in_its_topic() {
586 for dog in ["bark", "metrics", "otel-shipper"] {
587 let event = BusEvent::DogConfigChanged {
588 dog: dog.to_string(),
589 };
590 assert_eq!(event.topic(), format!("config.dog.{dog}"));
591 }
592 }
593
594 /// A value here would put another dog's webhook credential in front of
595 /// every subscriber on `config.*`.
596 #[test]
597 fn a_dog_config_event_carries_the_name_and_nothing_else() {
598 let event = BusEvent::DogConfigChanged {
599 dog: "bark".to_string(),
600 };
601 let json = serde_json::to_string(&event).unwrap();
602 assert_eq!(
603 json,
604 r#"{"event":"dog_config_changed","data":{"dog":"bark"}}"#
605 );
606 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
607 }
608
609 /// The topic names the dog, not the sheep, so one dog's subscription
610 /// reaches every sheep carrying its table.
611 #[test]
612 fn a_dog_sheep_settings_event_names_the_dog_in_its_topic() {
613 for dog in ["jobs", "metrics"] {
614 let event = BusEvent::DogSheepSettingsChanged {
615 dog: dog.to_string(),
616 sheep: "web".to_string(),
617 };
618 assert_eq!(event.topic(), format!("config.sheep.{dog}"));
619 }
620 }
621
622 /// `config.*` still reaches this event, beside `config.dog.*`: a
623 /// dashboard watching every dog's config must not lose this topic to a
624 /// glob that only matched the older shape.
625 #[test]
626 fn a_dog_sheep_settings_topic_is_still_under_the_config_glob() {
627 let event = BusEvent::DogSheepSettingsChanged {
628 dog: "jobs".to_string(),
629 sheep: "web".to_string(),
630 };
631 assert!(event.topic().starts_with("config."));
632 }
633
634 /// The sheep rides in the payload, not the topic: a dog subscribes once
635 /// on its own name and hears about every sheep carrying its table.
636 #[test]
637 fn a_dog_sheep_settings_event_carries_the_sheep_and_the_dog() {
638 let event = BusEvent::DogSheepSettingsChanged {
639 dog: "jobs".to_string(),
640 sheep: "web".to_string(),
641 };
642 let json = serde_json::to_string(&event).unwrap();
643 assert_eq!(
644 json,
645 r#"{"event":"dog_sheep_settings_changed","data":{"dog":"jobs","sheep":"web"}}"#
646 );
647 assert_eq!(serde_json::from_str::<BusEvent>(&json).unwrap(), event);
648 }
649}