onevcs/event.rs
1//! The NDJSON event envelope this crate writes, and the filter a consumer reads it
2//! through.
3//!
4//! The *shape* of an envelope — a version, a stamp, a stream, a sequence number, a
5//! source, a kind, the labels, the payload, the artifacts — and the filter grammar
6//! over it are `onemessagebus`'s, and `onemessagebus`'s `docs/contract.md` is the
7//! one source of that shape. The **words** are this crate's: `src/vocabulary.rs`
8//! declares the source, the phases, the reserved labels and what a matcher may ask
9//! of them, and the types re-exported below are the bus core's generic ones over
10//! that vocabulary. This crate holds its own bytes to the copy in
11//! `docs/contract.md`, so an envelope that stopped being what this crate always
12//! wrote fails here rather than downstream.
13//!
14//! What stays in this module is the rest of that vocabulary — the closed
15//! [`EventKind`], and the phase each kind belongs to — and how a line of a stream
16//! this crate wrote is read back.
17
18use onemessagebus::Kind;
19use serde::{Deserialize, Serialize};
20
21pub use crate::vocabulary::{
22 ArtifactRef, Dimensions, Envelope, EventFilter, EventMatcher, Labels, MatchFields, Phase,
23 Source, VcsEvents, DIMENSIONS, RESERVED_LABELS, SOURCE_WORD,
24};
25
26/// What an event says happened.
27///
28/// These are the kinds `onevcs` produces. A consumer merging several sources
29/// reads each source's own kinds; nothing here promises to name another
30/// library's.
31///
32/// **A kind is retired by keeping it recognised and inert, never by deleting it.**
33/// A stream is a record, and this enum is what says whether a line of one can be
34/// read at all: delete a variant and every line any earlier build wrote with it
35/// stops parsing, in a build that has no way to know what it lost. That is not
36/// hypothetical — `gate-started` and `gate-verdict` went with the host-run gate in
37/// 0.11.0, and 30% of the streams on the host that consumes this crate turned into
38/// one refusal per line, most expensively in a status read that walks every stream
39/// there is. So a kind nothing emits any more keeps its variant, documented as
40/// retired and produced by nothing, and a reader goes on being able to say what
41/// that line recorded.
42///
43/// The permissive read this crate's stream readers go through is the other half of
44/// that rule rather than a substitute for it. It makes a kind this build has *never
45/// had* — one a later build wrote — cheap to pass over, which is the case no
46/// vocabulary here can cover. It cannot give back the meaning of a kind that was
47/// deleted, which is why the two retired above are a loss this crate carries rather
48/// than one it recovered.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum EventKind {
52 /// A session was opened over a clone and worktree, on a pool slot or a run root.
53 SessionOpened,
54 /// Objects were fetched, deliberately outside any exclusive section.
55 Fetch,
56 /// Waiting on an identity's lock; carries the identity, elapsed, and queue
57 /// position.
58 LockWait,
59 /// The identity's lock was acquired.
60 LockAcquired,
61 /// Work was committed onto a preserved branch; carries the provenance kind.
62 CommitPreserved,
63 /// A branch was put on its identity's origin under its own name, without being
64 /// published; carries the branch, the identity, the origin URL, the commit, and
65 /// which of the three things `onevcs preserve` found to do.
66 ///
67 /// Deliberately **not** [`Push`](EventKind::Push), and that is the whole reason it
68 /// is its own kind. The one producer of `push` is a publication, so a reader
69 /// counting pushes to find publications must never meet a preservation — and a
70 /// preservation is the opposite of one: no change request is opened, no base is
71 /// touched, and the branch is exactly as recoverable afterwards as it was before.
72 BranchPreserved,
73 /// A branch was pushed.
74 Push,
75 /// A change request was opened; carries its URL and the host kind.
76 ChangeOpened,
77 /// A change request was opened as a **draft**, and this is why it is not ready;
78 /// carries its URL, the host's identifier for it, its base, and the reason — its
79 /// `kind`, and every field of that kind: for `awaiting-release` the repository
80 /// whose release is awaited, which target of it, the reference the change is
81 /// pinned to, and the one line a person reads; for `held` that one line alone.
82 ///
83 /// Beside [`ChangeOpened`](EventKind::ChangeOpened) rather than instead of it: the
84 /// change request *was* opened, and the link from its URL back to the branch is
85 /// what that kind records. This one records the state it was opened in.
86 ChangeDrafted,
87 /// The draft was lifted and the change request is open for review; carries its
88 /// URL and the host's identifier for it.
89 ///
90 /// The reason is not repeated here: it is on the
91 /// [`ChangeDrafted`](EventKind::ChangeDrafted) this answers, and the publication
92 /// that lifts a draft is one carrying none — on green checks, early when they
93 /// never ran on the draft, or at once with the lifecycle off. `onevcs change ready`
94 /// emits the same kind, because it is the same lift asked for as a verb.
95 DraftLifted,
96 /// A draft the publication opened while its required checks ran was lifted
97 /// **before** any of them had run on it, because the grace window elapsed and
98 /// the repository's checks skip drafts; carries its URL, identifier and base,
99 /// the required checks that had not run, the grace window in seconds, and whether
100 /// the warning line was printed. Emitted beside the
101 /// [`DraftLifted`](EventKind::DraftLifted) every lift emits.
102 DraftLiftedEarly,
103 /// Green required checks left a `change-open` change whose approvals are required
104 /// a draft, for its own user's review rather than the team's; carries its URL,
105 /// identifier and base.
106 DraftKeptForReview,
107 /// A publication's watch saw every required check stop blocking; carries the
108 /// change request's URL and identifier, the commit watched, the verdict —
109 /// `passed`, or `passed-with-skipped` where a required check concluded skipped —
110 /// and the skipped required checks by name.
111 ChecksSettled,
112 /// The change request's description was replaced after it was opened; carries
113 /// its URL, the host's identifier for it, its base, the title where the
114 /// description replaced it, and the artifact the body was stored as.
115 ///
116 /// The body is not inlined: it is prose of unbounded size, and the stream bounds
117 /// payload text. The artifact is the record — `onevcs artifact cat ID` reads back
118 /// exactly what was written — and the event references it as every other large
119 /// piece of evidence is referenced.
120 ChangeDescribed,
121 /// A check moved; carries its name, whether it is required, the status
122 /// transition, the conclusion, and its log as an artifact once complete.
123 ChangeCheck,
124 /// The change request merged.
125 ChangeMerged,
126 /// The change request entered the host's merge queue.
127 MergeQueued,
128 /// The merge the host had queued completed.
129 MergeCompleted,
130 /// Preserved work that carried an incomplete-step marker was verified and
131 /// attested.
132 RecoveryAttested,
133 /// The base moved under a publication and the bounded resolve-and-requeue did
134 /// not converge.
135 SyncConflict,
136 /// The session's worktree and lease were released.
137 SessionClosed,
138 /// An **automated** release target's probe was run; carries the identity, the
139 /// target, which form of probe it was, what it answered, the version where it
140 /// answered one, and how long it took. A human-step target never produces one,
141 /// and that absence is the observable proof that no probe ran for it.
142 ReleaseProbed,
143 /// Somebody recorded a release of a human-step target; carries the identity,
144 /// the target, the version, the landing commit, the actor, and the version it
145 /// superseded where it replaced one.
146 ReleaseAcknowledged,
147 /// A landing was released, the first time it was: its baseline passed for an
148 /// automated target, its acknowledgement recorded for a human-step one.
149 ReleaseObserved,
150 /// A branch was recorded as superseded by a retry whose landing reached the base;
151 /// carries the identity, the branch, the branch that superseded it, that
152 /// branch's landing — a commit or a change request's URL — and the labels its
153 /// caller stamped.
154 ///
155 /// A record and nothing else: the superseded branch is left exactly where it is.
156 /// What it changes is how the branch is *classified* — `superseded-with-changes`
157 /// where it still differs from the base, which only `onevcs reclaim` acts on.
158 BranchSuperseded,
159 /// A finished branch was deleted everywhere this host holds it; carries the
160 /// identity, the branch, the tip it was deleted at, the class and proof that
161 /// permitted it, which verb or moment acted, and what was deleted and what was
162 /// not — so a partial retirement names the holder a re-run still has to reach.
163 BranchRetired,
164}
165
166impl EventKind {
167 /// The kebab-case string this kind travels as, which is what a filter's
168 /// `kind` glob is matched against and what an envelope's `kind` carries.
169 ///
170 /// The spelling is serde's, and `the_wire_spelling_of_every_kind_is_the_one_a_filter_matches`
171 /// in `tests/contract.rs` holds the two together — a match rather than a
172 /// serialization so that stamping an event cannot fail, and exhaustive so that a
173 /// kind added to the enum cannot reach a stream unnamed.
174 fn wire(self) -> &'static str {
175 match self {
176 EventKind::SessionOpened => "session-opened",
177 EventKind::Fetch => "fetch",
178 EventKind::LockWait => "lock-wait",
179 EventKind::LockAcquired => "lock-acquired",
180 EventKind::CommitPreserved => "commit-preserved",
181 EventKind::BranchPreserved => "branch-preserved",
182 EventKind::Push => "push",
183 EventKind::ChangeOpened => "change-opened",
184 EventKind::ChangeDrafted => "change-drafted",
185 EventKind::DraftLifted => "draft-lifted",
186 EventKind::DraftLiftedEarly => "draft-lifted-early",
187 EventKind::DraftKeptForReview => "draft-kept-for-review",
188 EventKind::ChecksSettled => "checks-settled",
189 EventKind::ChangeDescribed => "change-described",
190 EventKind::ChangeCheck => "change-check",
191 EventKind::ChangeMerged => "change-merged",
192 EventKind::MergeQueued => "merge-queued",
193 EventKind::MergeCompleted => "merge-completed",
194 EventKind::RecoveryAttested => "recovery-attested",
195 EventKind::SyncConflict => "sync-conflict",
196 EventKind::SessionClosed => "session-closed",
197 EventKind::ReleaseProbed => "release-probed",
198 EventKind::ReleaseAcknowledged => "release-acknowledged",
199 EventKind::ReleaseObserved => "release-observed",
200 EventKind::BranchSuperseded => "branch-superseded",
201 EventKind::BranchRetired => "branch-retired",
202 }
203 }
204
205 /// The kind an envelope's wire `kind` names, where this build has a word for it.
206 ///
207 /// Read through serde's own spelling rather than a second table, so the kinds
208 /// this build can read back are exactly the kinds it can write.
209 fn named(kind: &Kind) -> Option<Self> {
210 Self::deserialize(
211 serde::de::value::StrDeserializer::<serde::de::value::Error>::new(kind.as_str()),
212 )
213 .ok()
214 }
215}
216
217/// An envelope's `kind` is open on the wire, and this crate's closed vocabulary is
218/// what it writes into it.
219impl From<EventKind> for Kind {
220 fn from(kind: EventKind) -> Self {
221 Kind::from(kind.wire())
222 }
223}
224
225/// The phase an event of one of this crate's kinds belongs to, where the kind alone
226/// decides it.
227///
228/// A trait rather than an inherent method because the four phases are declared in
229/// `src/vocabulary.rs`, beside the rest of the wire words, and which of them an
230/// `onevcs` kind is in is a fact about the kinds declared here. With it in scope
231/// the call is spelled `Phase::of(kind)`, as it always was.
232pub trait PhaseOf {
233 /// The phase an event of this kind belongs to, where the kind alone decides it.
234 ///
235 /// `None` for exactly one kind, and it is not an omission: a
236 /// [`Push`](EventKind::Push) of the session's own branch is
237 /// [`Development`](Phase::Development) and a push of anything else — the base a
238 /// `local-direct` squash lands on, the base a merge train advanced — is
239 /// [`Integrate`](Phase::Integrate). Which of the two it was is a fact about the
240 /// push rather than about the kind, so the producer stamps it and this answers
241 /// that it cannot.
242 fn of(kind: EventKind) -> Option<Phase>;
243}
244
245impl PhaseOf for Phase {
246 fn of(kind: EventKind) -> Option<Phase> {
247 Some(match kind {
248 EventKind::SessionOpened
249 | EventKind::Fetch
250 | EventKind::LockWait
251 | EventKind::LockAcquired
252 | EventKind::CommitPreserved
253 // The work being made, kept: putting a branch on its origin under its own
254 // name proposes nothing and integrates nothing, and a `local-direct`
255 // identity has no Review phase for it to be in.
256 | EventKind::BranchPreserved
257 // The repair of a preserved branch, and therefore the work being made:
258 // it puts the branch back into a state its merge path can rule on, and
259 // it happens before that branch may enter one at all.
260 | EventKind::RecoveryAttested
261 | EventKind::SessionClosed => Phase::Development,
262 EventKind::MergeQueued | EventKind::MergeCompleted | EventKind::SyncConflict => {
263 Phase::Integrate
264 }
265 // What became of a branch once its work reached the base, or once a retry
266 // of it did: the base is what decides both, so both are that work being
267 // integrated rather than made or proposed.
268 EventKind::BranchSuperseded | EventKind::BranchRetired => Phase::Integrate,
269 EventKind::ChangeOpened
270 | EventKind::ChangeDrafted
271 | EventKind::DraftLifted
272 | EventKind::DraftLiftedEarly
273 | EventKind::DraftKeptForReview
274 | EventKind::ChecksSettled
275 | EventKind::ChangeDescribed
276 | EventKind::ChangeCheck
277 | EventKind::ChangeMerged => Phase::Review,
278 EventKind::ReleaseProbed
279 | EventKind::ReleaseAcknowledged
280 | EventKind::ReleaseObserved => Phase::Release,
281 EventKind::Push => return None,
282 })
283 }
284}
285
286/// The phase an event of this kind is stamped at when its producer names none.
287///
288/// The one kind whose phase its producer decides is a push, and
289/// [`Stream::emit_push`](crate::stream::Stream::emit_push) is where every push in
290/// this crate decides it. A push reaching here instead — or a line an older build
291/// wrote before there was a phase — is read at the phase a session's own stream is
292/// in, which is what all but one producer of a push stamps.
293pub(crate) fn phase_of(kind: EventKind) -> Phase {
294 Phase::of(kind).unwrap_or(Phase::Development)
295}
296
297/// The id of a stored artifact.
298///
299/// This crate's own rather than the bus's: `onemessagebus` 0.4.0 carries an
300/// artifact's id as a plain string on [`ArtifactRef`], and the host seam's
301/// [`check_log`](crate::RemoteHost::check_log) answers a typed one.
302#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
303#[serde(transparent)]
304pub struct ArtifactId(pub String);
305
306/// One line of a stream, read as far as this build's vocabulary reaches.
307///
308/// The distinction the readers of a stream are held to. A line that is *not what a
309/// writer left* — torn, of an envelope version this build does not read, stamped in
310/// a way nothing can order — is a gap, and saying so is not negotiable: reporting
311/// "could not look" as "there is none" is how a report about half a record reads as
312/// a report about all of it. A line that is a perfectly good envelope recording
313/// something this build has no word for is not that. There is nothing to conclude
314/// from it and nothing missing from the read either, so it is passed over rather
315/// than announced — which is what a reader of streams a *later* build wrote needs,
316/// and what a reader of streams an earlier one wrote needed and did not have.
317pub(crate) enum Line {
318 /// An envelope of a kind this build knows, and can therefore act on.
319 ///
320 /// Boxed: an envelope carries three strings, a map and a vector, and the other
321 /// variant is a header — so an unboxed enum would make every line of every
322 /// stream cost the larger of the two.
323 Known(Box<Known>),
324 /// A well-formed envelope of a kind this build has no word for.
325 Unknown(UnknownKind),
326}
327
328/// An envelope whose kind this build has a word for, with that word beside it.
329pub(crate) struct Known {
330 /// The kind the envelope's wire `kind` names.
331 pub kind: EventKind,
332 /// The envelope itself, at the phase its producer stamped — or, for a line an
333 /// older build wrote before there was a phase, the phase its kind decides.
334 pub envelope: Envelope,
335}
336
337/// A line whose kind this build cannot name, as much of it as such a reader can use.
338///
339/// Its header and nothing else, deliberately. Whether the line is a *gap* is
340/// decided by the envelope's own fields — the version it declares, the stamp it is
341/// ordered by, the stream it belongs to — and those mean the same thing whatever
342/// happened, so every check a known kind gets this one gets too. The payload is the
343/// part the kind is the key to, so there is nothing here to offer of it.
344pub(crate) struct UnknownKind {
345 /// The envelope schema version the line declares.
346 pub v: u32,
347 /// When it was stamped, unparsed: ordering it is the reader's own check.
348 pub ts: String,
349 /// The stream it names, so attribution is asked of it as of any other line.
350 pub stream: String,
351 /// The kind it carries, as it was spelled.
352 pub kind: Kind,
353}
354
355impl Line {
356 /// One line of a stream, tolerant of its kind and of nothing else.
357 // llmlint: ignore[boundary_inputs_validated] the envelope's *shape* is validated here,
358 // by the agent profile's envelope: an unknown top-level key, a `seq` that is not a
359 // u64, a source outside the three families, and a missing required field are each
360 // refused. The two semantic checks — that `v` is the version this build reads, and
361 // that `ts` is a stamp it can order — are deliberately not here, for the reason the
362 // envelope has never made them either: what to do about one is the *reader's*, and
363 // the readers disagree. `status` reports each as a gap in its notes rather than
364 // refusing, because it is asked what became of a piece of work and must not answer
365 // "there is none" for "could not look" — and it applies both to a line whose kind has
366 // no word here exactly as to one it knows, which is what `UnknownKind` carries the
367 // header for. Deciding either here would take that choice away from the one caller
368 // that has to report rather than refuse.
369 pub(crate) fn read(line: &str) -> serde_json::Result<Self> {
370 serde_json::from_str(line).map(Self::of)
371 }
372
373 /// An envelope a reader has already parsed, read as far as this build's
374 /// vocabulary reaches.
375 pub(crate) fn of(mut envelope: Envelope) -> Self {
376 let Some(kind) = EventKind::named(&envelope.kind) else {
377 return Line::Unknown(UnknownKind {
378 v: envelope.v,
379 ts: envelope.ts,
380 stream: envelope.stream,
381 kind: envelope.kind,
382 });
383 };
384 // A line an older build wrote carries no phase, and the phase is additive
385 // inside `v: 1`, so it reads at the phase its kind decides. A `push` among
386 // them named no target, so there is nothing to recover the producer's
387 // classification from — it reads as the phase a push of the session's own
388 // branch is, which is a reading of a record rather than a claim about the
389 // push, and why the phase is stamped from here on.
390 envelope
391 .dimensions
392 .phase
393 .get_or_insert_with(|| phase_of(kind));
394 Line::Known(Box::new(Known { kind, envelope }))
395 }
396
397 /// The stream this line names, whether or not its kind has a word here.
398 pub(crate) fn stream(&self) -> &str {
399 match self {
400 Line::Known(known) => &known.envelope.stream,
401 Line::Unknown(header) => &header.stream,
402 }
403 }
404}