Skip to main content

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}