Skip to main content

alba_engine/
event.rs

1//! What a run tells the outside world: the [`RunEvent`] stream a run emits
2//! while it happens, and the [`RunSummary`] it ends with.
3//!
4//! This is the only contract between execution and display: the headless
5//! CLI renderer today, and a TUI later, are two consumers of the same
6//! stream. Nothing here knows how a
7//! beam runs — [`crate::scheduler`] produces these values, and the channel
8//! they travel on is unbounded, so a slow consumer never stalls the run.
9
10use std::time::Duration;
11
12use alba_core::BeamId;
13use alba_executors::OutputLine;
14
15/// One thing that happened during a run.
16///
17/// Per beam, the order is `BeamStarted`, then every `BeamOutput`, then
18/// `BeamFinished` — a beam that runs several commands still emits exactly
19/// one started/finished pair, since the individual commands are an
20/// implementation detail of the beam. A cache hit follows the same shape
21/// with `BeamCached` in place of `BeamStarted`: the same replayed
22/// `BeamOutput` lines, then `BeamFinished` with [`BeamStatus::Cached`]. A
23/// beam that never ran (cancelled before it acquired a slot, or skipped
24/// because a dependency failed) emits only `BeamFinished` with
25/// [`BeamStatus::Cancelled`]. Events from different beams interleave
26/// freely; `RunStarted` is first *per run*, before any
27/// `BeamStarted`/`BeamCached`; `RunFinished` is last *per run* — in a watch
28/// session, it is followed by `WatchWaiting` and, once a change is seen,
29/// `WatchTriggered` before the next run's events begin.
30#[derive(Debug, Clone)]
31pub enum RunEvent {
32    /// A run is beginning. First event of every run, before any
33    /// `BeamStarted`/`BeamCached`, carrying the snapshot of what this run
34    /// will schedule: the targets, the union of their subgraphs in plan
35    /// order, and the dependency edges between members of that subgraph. A
36    /// snapshot rather than something the consumer looks up, because a
37    /// watch session reloads the Beamfile mid-session — a run's graph is
38    /// not known once and for all.
39    RunStarted {
40        /// The beams the run was asked for: one for `alba run <beam>`,
41        /// any number for an affected run, none for an affected run that
42        /// found nothing to do.
43        targets: Vec<BeamId>,
44        /// The git reference an affected run was computed against, when
45        /// it was one.
46        affected_by: Option<String>,
47        beams: Vec<BeamId>,
48        /// `(beam, dependency)`: the first needs the second. Both ends are
49        /// always members of `beams` — the subgraph is transitively closed.
50        edges: Vec<(BeamId, BeamId)>,
51    },
52    BeamStarted {
53        id: BeamId,
54    },
55    /// A cache hit: emitted instead of `BeamStarted` for a beam that is
56    /// skipped, followed by its replayed `BeamOutput` lines and a
57    /// `BeamFinished` with [`BeamStatus::Cached`] carrying the original
58    /// run's duration.
59    BeamCached {
60        id: BeamId,
61    },
62    BeamOutput {
63        id: BeamId,
64        line: OutputLine,
65        /// True for a line replayed from the cache rather than produced
66        /// by a live command — CI tooling parsing the JSON stream needs
67        /// to tell the two apart.
68        replayed: bool,
69    },
70    BeamFinished {
71        id: BeamId,
72        status: BeamStatus,
73        duration: Duration,
74    },
75    RunFinished {
76        summary: RunSummary,
77    },
78    /// Watch mode only: the session is waiting, having finished a run or
79    /// given up on starting one. `files` is how many files the watched
80    /// `inputs` currently resolve to — the status line's number, and `0`
81    /// for a session parked on a project that will not load, which
82    /// resolves nothing. The first one a session emits also announces the
83    /// session itself.
84    WatchWaiting {
85        files: usize,
86    },
87    /// Watch mode only: relevant changes started a new run. `paths` are
88    /// project-root-relative display strings; empty means the watcher
89    /// overflowed and could not say what changed (rescan). Emitted after
90    /// the previous run's `RunFinished` — when that summary is full of
91    /// cancelled beams, this event is what marks the run as interrupted
92    /// by the watch rather than abandoned by the user.
93    WatchTriggered {
94        paths: Vec<String>,
95    },
96    /// The session cannot work from its project: the Beamfile stopped
97    /// loading, or a run could not be carried out (unknown target after a
98    /// rename, an unschedulable beam). Carries the *rendered* diagnostic —
99    /// caret, spans, help — because rendering lives above this crate and is
100    /// done by the session's `render_error` callback; the event exists so a
101    /// stream consumer that never sees stderr still learns why nothing runs.
102    ProjectBroken {
103        diagnostic: String,
104    },
105}
106
107/// How a beam ended.
108///
109/// `Failed` and `FailedAllowed` are the same event seen through the beam's
110/// `allow_failure` flag: a `FailedAllowed` beam counts as satisfied for its
111/// dependents, never triggers fail-fast, and does not affect the run's exit
112/// code. `Cancelled` covers both reasons a beam can be skipped: a
113/// dependency that failed or was itself cancelled, and a run-wide stop
114/// (fail-fast, or the caller's cancellation token).
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum BeamStatus {
117    Succeeded,
118    /// A cache hit: counts as satisfied for this beam's dependents and as
119    /// success for the run's exit code, exactly like `Succeeded`.
120    Cached,
121    Failed {
122        exit_code: i32,
123    },
124    FailedAllowed {
125        exit_code: i32,
126    },
127    Cancelled,
128}
129
130/// What a whole run amounts to: every beam of the target's subgraph sorted
131/// into the bucket it ended in, plus how long the run took.
132///
133/// The five vectors are in the project's declaration order, never in
134/// completion order — a run is a concurrent thing, and ordering the summary
135/// by whichever task happened to finish first would make it differ from run
136/// to run for the same project.
137#[derive(Debug, Clone, Default)]
138pub struct RunSummary {
139    pub succeeded: Vec<BeamId>,
140    pub cached: Vec<BeamId>,
141    pub failed: Vec<BeamId>,
142    pub failed_allowed: Vec<BeamId>,
143    pub cancelled: Vec<BeamId>,
144    pub duration: Duration,
145}
146
147impl RunSummary {
148    /// The process exit code this run should produce: 0 unless a beam
149    /// failed outright. An allowed failure and a cancellation are both
150    /// deliberately 0 here; Alba's own errors (a Beamfile that does not
151    /// load, an unschedulable run) exit 2 and never reach a summary.
152    pub fn exit_code(&self) -> i32 {
153        if self.failed.is_empty() { 0 } else { 1 }
154    }
155
156    /// Files `id` under `status`. The single place a status becomes a
157    /// summary bucket, so the mapping cannot drift between call sites.
158    pub(crate) fn record(&mut self, id: BeamId, status: &BeamStatus) {
159        match status {
160            BeamStatus::Succeeded => self.succeeded.push(id),
161            BeamStatus::Cached => self.cached.push(id),
162            BeamStatus::Failed { .. } => self.failed.push(id),
163            BeamStatus::FailedAllowed { .. } => self.failed_allowed.push(id),
164            BeamStatus::Cancelled => self.cancelled.push(id),
165        }
166    }
167}