Skip to main content

release_kit/
events.rs

1//! The NDJSON event envelope for long-running commands.
2//!
3//! Bounded commands emit one JSON object; long-running ones emit one
4//! complete event per line, opening with a [`EventKind::Schema`] event
5//! that names the version, so a consumer knows what it is reading before
6//! the first step event arrives. The compatibility rules are stated once
7//! and held by test: a consumer ignores unknown fields and unknown event
8//! types, field names are never renamed, and new event types append.
9
10use serde::Serialize;
11
12use crate::diagnostic::Reason;
13
14/// The version of the event envelope's shape.
15pub const EVENTS_SCHEMA: &str = "rk.events/1";
16
17/// Every event type the stream can carry; additions append, and no
18/// variant is ever renamed.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum EventKind {
22    /// The opening event naming the schema version.
23    Schema,
24    /// One step began.
25    StepStarted,
26    /// One step ended, with its status and duration.
27    StepFinished,
28    /// One chunk of a child process's output, tagged with its stream.
29    ChildOutput,
30    /// The run ended.
31    RunFinished,
32    /// One publish milestone a check read back, with its status.
33    Milestone,
34    /// A step saved the state it is about to change; the detail names
35    /// where.
36    Snapshot,
37}
38
39/// Which of a child's streams a chunk came from.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "snake_case")]
42pub enum ChildStream {
43    /// The child's stdout.
44    Stdout,
45    /// The child's stderr.
46    Stderr,
47}
48
49/// One event on the stream.
50///
51/// Every field is present on every event — absent values serialize as
52/// `null` rather than disappearing — so a consumer can parse one shape.
53#[derive(Debug, Serialize)]
54pub struct Event {
55    /// The envelope version, on every line.
56    pub schema: &'static str,
57    /// Monotonic sequence number within the run.
58    pub seq: u64,
59    /// Wall-clock UTC, RFC 3339.
60    pub time: String,
61    /// The run this event belongs to.
62    pub run_id: String,
63    /// The subcommand emitting the stream.
64    pub command: &'static str,
65    /// What happened.
66    #[serde(rename = "type")]
67    pub kind: EventKind,
68    /// The step concerned, where there is one.
69    pub step: Option<String>,
70    /// The step's result, on a finish event.
71    pub status: Option<String>,
72    /// The reason, on a failure.
73    pub reason: Option<Reason>,
74    /// The child's exit code, where one exists.
75    pub exit_code: Option<i32>,
76    /// How long the step took, on a finish event.
77    pub duration_ms: Option<u64>,
78    /// Which stream a `child_output` chunk came from.
79    pub stream: Option<ChildStream>,
80    /// The chunk's bytes, base64-encoded so invalid UTF-8 travels
81    /// losslessly; the journal transcript keeps the raw bytes in order.
82    pub data_b64: Option<String>,
83    /// The one line the human report prints beside a step's status, where
84    /// the emitting command has one. A field appends, so a consumer
85    /// reading an older shape is unaffected and the schema holds.
86    pub detail: Option<String>,
87}
88
89impl Event {
90    /// The opening event of a stream: everything nullable is null.
91    #[must_use]
92    pub const fn opening(seq: u64, time: String, run_id: String, command: &'static str) -> Self {
93        Self {
94            schema: EVENTS_SCHEMA,
95            seq,
96            time,
97            run_id,
98            command,
99            kind: EventKind::Schema,
100            step: None,
101            status: None,
102            reason: None,
103            exit_code: None,
104            duration_ms: None,
105            stream: None,
106            data_b64: None,
107            detail: None,
108        }
109    }
110
111    /// A `child_output` event carrying one chunk of a child's output.
112    #[must_use]
113    pub fn child_output(mut self, stream: ChildStream, bytes: &[u8]) -> Self {
114        self.kind = EventKind::ChildOutput;
115        self.stream = Some(stream);
116        self.data_b64 = Some(base64(bytes));
117        self
118    }
119}
120
121/// Standard base64 with padding, encode only — the one direction this
122/// binary needs, so no dependency earns its place for it.
123fn base64(bytes: &[u8]) -> String {
124    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
125    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
126    for chunk in bytes.chunks(3) {
127        let b = [
128            chunk[0],
129            *chunk.get(1).unwrap_or(&0),
130            *chunk.get(2).unwrap_or(&0),
131        ];
132        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
133        for (idx, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
134            if idx <= chunk.len() {
135                out.push(char::from(ALPHABET[(n >> shift) as usize & 0x3f]));
136            } else {
137                out.push('=');
138            }
139        }
140    }
141    out
142}
143
144#[cfg(test)]
145mod tests {
146    use super::{ChildStream, Event, EventKind, base64};
147
148    /// The `rk.events/1` schema, held by snapshot: a field rename fails
149    /// here first and becomes a schema-version bump, not a silent parser
150    /// break at some agent.
151    #[test]
152    fn the_event_schema_snapshot_holds() {
153        let mut event =
154            Event::opening(0, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup");
155        assert_eq!(
156            serde_json::to_string(&event).expect("an event serializes"),
157            r#"{"schema":"rk.events/1","seq":0,"time":"2026-08-29T14:10:31Z","run_id":"01K5NQ7X","command":"setup","type":"schema","step":null,"status":null,"reason":null,"exit_code":null,"duration_ms":null,"stream":null,"data_b64":null,"detail":null}"#
158        );
159        event.seq = 12;
160        event.kind = EventKind::StepFinished;
161        event.step = Some("protect-tags".into());
162        event.status = Some("satisfied".into());
163        event.exit_code = Some(0);
164        event.duration_ms = Some(418);
165        event.detail = Some("the tag ruleset protects refs/tags/v*".into());
166        assert_eq!(
167            serde_json::to_string(&event).expect("an event serializes"),
168            r#"{"schema":"rk.events/1","seq":12,"time":"2026-08-29T14:10:31Z","run_id":"01K5NQ7X","command":"setup","type":"step_finished","step":"protect-tags","status":"satisfied","reason":null,"exit_code":0,"duration_ms":418,"stream":null,"data_b64":null,"detail":"the tag ruleset protects refs/tags/v*"}"#
169        );
170    }
171
172    /// A child's chunk travels with its stream tag and its bytes intact —
173    /// invalid UTF-8 included, which is why the payload is base64.
174    #[test]
175    fn a_child_output_event_carries_the_chunk_losslessly() {
176        let event = Event::opening(3, "2026-08-29T14:10:31Z".into(), "01K5NQ7X".into(), "setup")
177            .child_output(ChildStream::Stderr, &[0x66, 0x6f, 0x6f, 0xff, 0xfe]);
178        assert_eq!(
179            serde_json::to_string(&event).expect("an event serializes"),
180            r#"{"schema":"rk.events/1","seq":3,"time":"2026-08-29T14:10:31Z","run_id":"01K5NQ7X","command":"setup","type":"child_output","step":null,"status":null,"reason":null,"exit_code":null,"duration_ms":null,"stream":"stderr","data_b64":"Zm9v//4=","detail":null}"#
181        );
182    }
183
184    /// The RFC 4648 vectors, so the hand-rolled encoder is checked against
185    /// values this crate did not compute.
186    #[test]
187    fn the_base64_encoder_matches_the_rfc_vectors() {
188        for (input, expected) in [
189            (&b""[..], ""),
190            (b"f", "Zg=="),
191            (b"fo", "Zm8="),
192            (b"foo", "Zm9v"),
193            (b"foob", "Zm9vYg=="),
194            (b"fooba", "Zm9vYmE="),
195            (b"foobar", "Zm9vYmFy"),
196        ] {
197            assert_eq!(base64(input), expected);
198        }
199    }
200}