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::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}