Skip to main content

shep_core/protocol/
events.rs

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