onevcs/stream.rs
1//! Writing the event stream, and storing the evidence too large to travel in it.
2//!
3//! One NDJSON file per session, appended to. `seq` is monotonic per stream, so a
4//! consumer detects loss as a gap rather than by trusting the producer. Payload
5//! text fields truncate at 4096 bytes and say so; a push's output or a check log is an
6//! artifact instead, stored beside the stream and fetched through
7//! `onevcs artifact cat`.
8//!
9//! Stamping, numbering, bounding, redacting and writing an envelope are
10//! `onemessagebus`'s [`Emitter`], over this crate's own vocabulary, and splitting a
11//! stream back into its records is its [`BusReader`]. What stays here is where a
12//! stream lives, which labels it carries, and what a reader of *this* crate's
13//! streams refuses.
14//!
15//! Redaction happens before an event or an artifact leaves the library, because the
16//! thing being redacted arrives from outside it: a rejecting `pre-push` hook echoes
17//! whatever its own verification printed, credentials included.
18
19use std::collections::BTreeSet;
20use std::path::PathBuf;
21
22use onemessagebus::{EmitterError, Reading, Redactor};
23use serde_json::{Map, Value};
24
25use crate::error::{self, Result};
26use crate::event::{
27 phase_of, ArtifactRef, Dimensions, Envelope, EventFilter, EventKind, Known, Labels, Line,
28 Phase, Source, SOURCE_WORD,
29};
30use crate::git::ObjectId;
31use crate::landed::Landed;
32use crate::rules::MergePolicy;
33use crate::session::SessionToken;
34use crate::vocabulary::{BusReader, Emitter};
35use crate::{home, ids, policy, release, status, store, workspace};
36
37/// The envelope schema version this build emits.
38pub const ENVELOPE_VERSION: u32 = 1;
39
40/// One session's append-only NDJSON stream.
41#[derive(Debug)]
42pub struct Stream {
43 path: PathBuf,
44 id: String,
45 labels: Labels,
46 /// Numbers every envelope from what the file already holds, under the file's
47 /// own lock, and writes it inside that same turn.
48 ///
49 /// Shared for every stream, not only the ones several processes append to at
50 /// once. A session's stream is written by whichever process holds the session,
51 /// one at a time — `session open`, then `publish`, then `close` — and each of
52 /// them continues the series the last one left, so a number counted in memory
53 /// from the process's own start would restart it. A repository's release stream
54 /// is written by several `onevcs release status` processes together, where a
55 /// number any of them counted before the other wrote is the same number twice.
56 /// Numbering from the file under its lock answers both.
57 emitter: Emitter,
58 /// The publication attempt the gate runs recorded through this stream belong
59 /// to, once one has been recorded — see [`Stream::emit_gate_run`].
60 attempt: Option<u32>,
61}
62
63impl Stream {
64 /// Open (or create) the stream for a session token, continuing its sequence.
65 ///
66 /// The sequence resumes from what the file already holds, so a session adopted
67 /// by a second process keeps one monotonic series rather than restarting it and
68 /// making every later event look like a replay.
69 pub fn open(token: &str) -> Result<Self> {
70 let path = path_for(token)?;
71 home::ensure_dir(path.parent().expect("a stream lives in a directory"))?;
72 let mut labels = Labels::default();
73 labels
74 .extra
75 .insert("session".to_owned(), Value::String(token.to_owned()));
76 let emitter = emitter(token, &path, &labels);
77 Ok(Self {
78 path,
79 id: token.to_owned(),
80 labels,
81 emitter,
82 attempt: None,
83 })
84 }
85
86 /// Open (or create) the stream one repository's release activity is recorded
87 /// on.
88 ///
89 /// Releases happen long after the dispatch that produced the work has ended,
90 /// outside any session, so there is no session stream for them to go on. The
91 /// stream is per **identity** rather than per invocation, so everything this
92 /// host has ever learned about one repository's releases is one file in the
93 /// order it was learned. It is labelled with the identity for the same reason a
94 /// session's is labelled with its token: what a reader correlates it by.
95 pub fn releases(identity: &str) -> Result<Self> {
96 let mut stream = Self::open(&releases_token(identity))?;
97 stream.labels.extra.remove("session");
98 stream.label("identity", identity);
99 Ok(stream)
100 }
101
102 /// Stamp a label every later event of this stream carries.
103 pub fn label(&mut self, key: &str, value: &str) {
104 self.labels
105 .extra
106 .insert(key.to_owned(), Value::String(value.to_owned()));
107 // Rebuilt rather than derived: a derived emitter keeps every label its parent
108 // stamped, and a stream's labels are replaced here as well as added to.
109 self.emitter = emitter(&self.id, &self.path, &self.labels);
110 }
111
112 /// Append one event, at the phase its kind decides.
113 pub fn emit(&mut self, kind: EventKind, payload: Map<String, Value>) {
114 self.emit_with(kind, payload, Vec::new());
115 }
116
117 /// Append the record of one push, at the phase the branch it updated decides.
118 ///
119 /// The one kind whose phase is not a fact about the kind: a push of the session's
120 /// own branch is the work being made, and a push of the base a squash landed on
121 /// or of the base a merge train advanced is that work being integrated. Which of
122 /// the two it was is known where the push is made and nowhere else, so it arrives
123 /// from there rather than being inferred from a payload afterwards.
124 pub fn emit_push(
125 &mut self,
126 phase: Phase,
127 payload: Map<String, Value>,
128 artifacts: Vec<ArtifactRef>,
129 ) {
130 self.append_stamped(EventKind::Push, phase, payload, artifacts);
131 }
132
133 /// Start a publication attempt: the next gate run recorded here is numbered
134 /// afresh, and every one after it in the same attempt shares that number.
135 pub fn begin_attempt(&mut self) {
136 self.attempt = None;
137 }
138
139 /// Append the record of one completed gate run, at the phase its gate decides.
140 ///
141 /// Its `attempt` is fixed once per publication attempt, at the first gate run the
142 /// attempt records: one more than the highest attempt any earlier `gate-run` on
143 /// this stream carries. So a publication's `pre-push` run and its required-checks
144 /// run share one number, the next publication of the same session takes the next
145 /// one, and a publication that ended before any gate ran consumes none — what a
146 /// consumer counts is gate runs per change, not invocations.
147 pub(crate) fn emit_gate_run(&mut self, run: &crate::gate_run::Run) {
148 let attempt = match self.attempt {
149 Some(attempt) => attempt,
150 None => {
151 let attempt = self.recorded_attempts() + 1;
152 self.attempt = Some(attempt);
153 attempt
154 }
155 };
156 self.append_stamped(
157 EventKind::GateRun,
158 run.gate.phase(),
159 run.payload(attempt),
160 Vec::new(),
161 );
162 }
163
164 /// The highest attempt a `gate-run` already on this stream carries, or `0`.
165 ///
166 /// Read off the file rather than counted in memory, because one session's stream
167 /// is written by one process after another. A line this build cannot read is
168 /// passed over: what it would have said is not a number to continue from, and a
169 /// stream nobody can read at all starts at the first attempt.
170 fn recorded_attempts(&self) -> u32 {
171 let Ok(text) = std::fs::read_to_string(&self.path) else {
172 return 0;
173 };
174 text.lines()
175 .filter_map(|line| match Line::read(line).ok()? {
176 Line::Known(known) if known.kind == EventKind::GateRun => known
177 .envelope
178 .payload
179 .get("attempt")
180 .and_then(Value::as_u64)
181 .and_then(|attempt| u32::try_from(attempt).ok()),
182 _ => None,
183 })
184 .max()
185 .unwrap_or(0)
186 }
187
188 /// Append one event carrying artifact references.
189 ///
190 /// Deliberately infallible. This stream is the record of what a command did,
191 /// and a publication that reached its base is not undone by the record of it
192 /// failing to be written — reporting that as a failed merge would be a worse
193 /// lie than the missing line. A write that does fail says so on stderr, where
194 /// the operator running the command sees it.
195 pub fn emit_with(
196 &mut self,
197 kind: EventKind,
198 payload: Map<String, Value>,
199 artifacts: Vec<ArtifactRef>,
200 ) {
201 self.append_stamped(kind, phase_of(kind), payload, artifacts);
202 }
203
204 fn append_stamped(
205 &mut self,
206 kind: EventKind,
207 phase: Phase,
208 payload: Map<String, Value>,
209 artifacts: Vec<ArtifactRef>,
210 ) {
211 let Err(unrecorded) =
212 self.emitter
213 .try_emit_stamped(kind, Dimensions::at(phase), payload, artifacts)
214 else {
215 return;
216 };
217 // Said in this crate's words rather than the bus's, because this is the line
218 // an operator running `onevcs` reads.
219 let path = self.path.display();
220 match &unrecorded.error {
221 EmitterError::Lock { source, .. } => {
222 eprintln!("onevcs: warning: cannot order a {kind:?} event in {path}: {source}");
223 }
224 EmitterError::Write { source, .. } => {
225 eprintln!("onevcs: warning: cannot record a {kind:?} event in {path}: {source}");
226 }
227 EmitterError::Serialize { source, .. } => {
228 eprintln!("onevcs: warning: cannot record a {kind:?} event in {path}: {source}");
229 }
230 }
231 }
232}
233
234/// The emitter one stream writes through: this crate's source and envelope version,
235/// numbered from the file, stamping exactly `labels`.
236fn emitter(id: &str, path: &std::path::Path, labels: &Labels) -> Emitter {
237 Emitter::shared(id, Source::from(SOURCE_WORD), path)
238 .with_version(ENVELOPE_VERSION)
239 .with_labels(labels.clone())
240}
241
242/// A cursor over one session's stream, handing back only what is new.
243///
244/// The reading half of `onevcs events`: it resolves the file, refuses a token that
245/// names none, and remembers how far it has read so a second call answers with the
246/// records appended since the first. Both renderings — the command's bytes and
247/// [`EventStream`]'s values — are this one cursor, so neither can drift from the
248/// other about where a stream lives or when it has been read to its end.
249///
250/// Where one record ends is the bus reader's answer, so a final line no newline has
251/// ended yet is a record still being written rather than one to hand on: it is read
252/// whole once its writer finishes it.
253#[derive(Debug)]
254pub struct Reader {
255 path: PathBuf,
256 /// The byte position after the last whole record handed back.
257 position: u64,
258 /// How many records have been handed back, so a refusal names the line of the
259 /// file rather than of the batch it arrived in.
260 read: usize,
261}
262
263/// One whole line of a stream, as its writer left it and as the bus read it.
264pub(crate) struct Record {
265 /// Which line of the file it is, one-based.
266 pub number: usize,
267 /// The line's own bytes, without the newline that ended it.
268 pub text: String,
269 /// The envelope it held, or why it is not one.
270 pub read: std::result::Result<Envelope, String>,
271}
272
273impl Reader {
274 /// Open the stream a session token names.
275 pub fn open(token: &str) -> Result<Self> {
276 let path = path_for(token)?;
277 if !path.is_file() {
278 return Err(error::invalid(format!("no event stream for {token:?}")));
279 }
280 Ok(Self {
281 path,
282 position: 0,
283 read: 0,
284 })
285 }
286
287 /// The records appended since the last call, in the order they were written, each
288 /// with its own bytes and the envelope it held.
289 // llmlint: ignore[boundary_inputs_validated] the boundary this cursor owns is the
290 // *name*: the token arrives from outside and is joined under the state root, and
291 // `path_for` refuses one that is not a plain name before any file is opened. The
292 // envelope shape is checked by the caller that wants values, through
293 // `attributed_record`, which refuses a line it cannot parse and one attributed to
294 // another stream, naming the line — that is the typed surface, and it has a journey
295 // for both refusals. `onevcs events` is deliberately the other rendering: a reader of
296 // one file rather than a validator of it. A line this build cannot parse is the line
297 // an operator most needs to see, and the envelope is versioned, so a command that
298 // refused what it could not parse would stop reading a stream a later build wrote.
299 pub(crate) fn records(&mut self) -> Result<Vec<Record>> {
300 let reading =
301 BusReader::open_at(&self.path, self.position).map_err(error::at("read", &self.path))?;
302 // Read after the records were, so every byte a record's position names is
303 // already in it: a stream is only ever appended to past its last whole record.
304 let bytes = std::fs::read(&self.path).map_err(error::at("read", &self.path))?;
305 let mut records = Vec::new();
306 for read in reading {
307 let (end, envelope) = match read {
308 Reading::Record(record) => (record.position, Ok(record.envelope)),
309 Reading::Refused(refused) => (refused.position, Err(refused.reason)),
310 Reading::Torn(_) => break,
311 };
312 let text = line_between(&bytes, self.position, end);
313 self.position = end;
314 self.read += 1;
315 records.push(Record {
316 number: self.read,
317 text,
318 read: envelope,
319 });
320 }
321 Ok(records)
322 }
323}
324
325/// The line of `bytes` that starts at `start` and whose newline ends before `end`.
326fn line_between(bytes: &[u8], start: u64, end: u64) -> String {
327 let (start, end) = (to_index(start), to_index(end).saturating_sub(1));
328 String::from_utf8_lossy(bytes.get(start..end).unwrap_or_default()).into_owned()
329}
330
331/// A position in a file this process has read whole, as an index into its bytes.
332fn to_index(position: u64) -> usize {
333 usize::try_from(position).unwrap_or(usize::MAX)
334}
335
336/// One line of a session's event stream, as its writer left it.
337///
338/// The bytes are the answer: a reader of a *file* hands on the line its producer
339/// wrote rather than a re-serialization of what it parsed, so what a later build
340/// recorded and this one has no word for still reads, and a filtered read is a
341/// subset of an unfiltered one byte for byte.
342#[derive(Debug, Clone, PartialEq)]
343pub struct EventLine {
344 /// The line's own bytes, without the newline that ended it.
345 pub text: String,
346 /// The envelope it carries, where this build could read one belonging to this
347 /// session. `None` is a line that is not one — a kind this build has no word
348 /// for, a line no writer left, or an event of another stream — which only an
349 /// unfiltered read ever hands back, since a filter has to read an event to
350 /// judge it and refuses what it cannot.
351 pub envelope: Option<Envelope>,
352}
353
354/// A reader over one session's event stream **as text**, which is what `onevcs
355/// events` prints.
356///
357/// [`EventStream`] beside it is the same file read as *values*, and the difference
358/// is which of the two a caller needs. A consumer acting on what a session did
359/// wants envelopes, and a line that is not one is a gap it must be told about —
360/// that is `EventStream`, and it refuses. A consumer *showing* a session's stream
361/// wants the file: a line this build cannot parse is the line somebody most needs
362/// to see, and the envelope is versioned, so refusing it would stop a stream a
363/// later build wrote from being readable at all.
364///
365/// Opened with an [`EventFilter`], it is the second reading and not a third: an
366/// event has to be read to be judged, so a line that is not this session's envelope
367/// is refused there, naming it, rather than passed through — which would report an
368/// event the filter never admitted — or dropped, which would hide one. A kind this
369/// build has no word for is left out, because a filter is a statement about the
370/// events a consumer wants and that is not one of them however it is spelled.
371///
372/// Reading again yields whatever has been appended since, which is what `--follow`
373/// does with a loop around it.
374#[derive(Debug)]
375pub struct EventLines {
376 session: SessionToken,
377 reader: Reader,
378 filter: Option<EventFilter>,
379}
380
381impl EventLines {
382 /// Open the stream a session token names, optionally through a filter.
383 pub fn open(session: &SessionToken, filter: Option<EventFilter>) -> Result<Self> {
384 Ok(Self {
385 session: session.clone(),
386 reader: Reader::open(&session.0)?,
387 filter,
388 })
389 }
390
391 /// The session whose stream this reads, so a caller following several at once
392 /// can say whose lines it is holding.
393 pub fn session(&self) -> &SessionToken {
394 &self.session
395 }
396
397 /// The lines appended since the last call, in the order they were written.
398 pub fn read(&mut self) -> Result<Vec<EventLine>> {
399 let mut lines = Vec::new();
400 for record in self.reader.records()? {
401 let text = record.text.clone();
402 let Some(filter) = &self.filter else {
403 // Unfiltered, nothing here is judged and the line is handed on as the
404 // file's own bytes; the envelope is offered where it could be read and
405 // is not what the answer depends on.
406 let envelope = match attributed_record(record, &self.session.0) {
407 Ok(Line::Known(known)) => Some(known.envelope),
408 Ok(Line::Unknown(_)) | Err(_) => None,
409 };
410 lines.push(EventLine { text, envelope });
411 continue;
412 };
413 // Read as a value, and therefore checked as one — by the same seam
414 // `EventStream` reads through, so the two surfaces refuse the same line for
415 // the same reason.
416 let Line::Known(known) = attributed_record(record, &self.session.0)? else {
417 continue;
418 };
419 if !filter.matches(&known.envelope) {
420 continue;
421 }
422 lines.push(EventLine {
423 text,
424 envelope: Some(known.envelope),
425 });
426 }
427 Ok(lines)
428 }
429}
430
431/// A reader over one session's event stream, as values rather than as text.
432///
433/// What `onevcs events TOKEN` writes to stdout, handed back typed and attributed:
434/// every [`Envelope`] it yields belongs to the session it was opened for, checked
435/// rather than assumed, so a caller following several publications at once can tell
436/// whose event it is holding. Reading again yields whatever has been appended
437/// since, which is what `--follow` does with a loop around it.
438///
439/// A consumer that wants some of what a session writes opens it with an
440/// [`EventFilter`] instead, and gets the same stream with the events it did not ask
441/// for left out. Filtering belongs to the source rather than to whoever composes
442/// several of them: a monitor and a planner read the same session under different
443/// attention budgets, and neither of them should have to re-implement this.
444#[derive(Debug)]
445pub struct EventStream {
446 session: SessionToken,
447 reader: Reader,
448 filter: EventFilter,
449 /// The phases this session can produce, which every event handed back is in.
450 ///
451 /// Derived at open from what this host knows about the session's repository
452 /// rather than named by the caller: a `local-direct` repository opens no change
453 /// request and a repository with no release targets releases nothing, and a
454 /// consumer should not have to know either to write a filter that is not
455 /// silently empty.
456 phases: BTreeSet<Phase>,
457 /// The identity's release stream, where the release phase is one this session
458 /// has. [`None`] otherwise, which is also every session this host keeps no
459 /// record of.
460 releases: Option<Correlated>,
461}
462
463/// The identity's release stream, joined to one session by its landing commit.
464///
465/// The releases that follow a landing are recorded on the repository's own stream,
466/// outside every session, and nothing on them names a session — the landing commit
467/// is the only thing that correlates one to a piece of work. Both halves are already
468/// here, so the join is made here: a consumer neither derives nor spells the address
469/// of that second stream.
470#[derive(Debug)]
471struct Correlated {
472 /// The repository whose releases these are, which is what a refusal names.
473 identity: String,
474 /// The stream those releases are recorded on. Private, and never rendered.
475 token: String,
476 /// The commit this session's work landed at, once history records one. Absent
477 /// until it does, which is why nothing is handed back before then rather than
478 /// being handed back unmatched.
479 ///
480 /// An [`ObjectId`], as the value it is compared against is: both sides of this
481 /// correlation come from outside the process — one off a stream, one out of a
482 /// repository — and a value that is not a commit id cannot be the commit either
483 /// of them claims.
484 landing: Option<ObjectId>,
485 /// The events of that stream this reader has already accounted for, by the
486 /// producer's own `seq`.
487 ///
488 /// A count of lines read would not do: the landing commit becomes knowable long
489 /// after some of those lines were written, so a cursor that had advanced past
490 /// them while there was nothing to match would lose them for good.
491 handed: BTreeSet<u64>,
492}
493
494impl EventStream {
495 /// Open the stream one session writes.
496 ///
497 /// A session that has emitted nothing has no stream, and is refused by name
498 /// rather than answered with an empty reader that would never say why.
499 pub fn open(session: &SessionToken) -> Result<Self> {
500 Self::open_filtered(session, EventFilter::default())
501 }
502
503 /// Open the stream one session writes, reading it through a filter.
504 ///
505 /// The filter arrives as a value rather than as text so that a consumer
506 /// composing several sources — `onepipeline` follows sessions through this seam
507 /// — passes the one it was configured with straight through, instead of
508 /// spelling a spec for each source to parse again.
509 ///
510 /// [`open`](EventStream::open) is this with [`EventFilter::default`], which
511 /// admits everything: an unfiltered stream is the same stream it always was.
512 /// A phase this session cannot produce is refused where it is *named* and
513 /// dropped where it is not, which is the difference between a consumer having
514 /// asked for something and a consumer having asked for everything.
515 pub fn open_filtered(session: &SessionToken, filter: EventFilter) -> Result<Self> {
516 // The stream first, so a session that has emitted nothing is still refused by
517 // name rather than by whatever its repository's rules turn out to say.
518 let reader = Reader::open(&session.0)?;
519 let (phases, identity) = supported(session);
520 for named in filter
521 .include
522 .iter()
523 .chain(&filter.exclude)
524 .filter_map(|matcher| matcher.fields.phase)
525 {
526 if !phases.contains(&named) {
527 return Err(error::invalid(format!(
528 "the event filter names the {named} phase, which the session {session} does \
529 not have: it has {had}. A filter that named it would be answered with \
530 nothing, and nothing is what a filter for the wrong phase and a session \
531 that did nothing look alike as",
532 session = session.0,
533 had = listed(&phases),
534 )));
535 }
536 }
537 let releases = match (phases.contains(&Phase::Release), identity) {
538 (true, Some(identity)) => Some(Correlated {
539 token: releases_token(&identity),
540 identity,
541 landing: None,
542 handed: BTreeSet::new(),
543 }),
544 _ => None,
545 };
546 Ok(Self {
547 session: session.clone(),
548 reader,
549 filter,
550 phases,
551 releases,
552 })
553 }
554
555 /// The session this stream belongs to.
556 #[must_use]
557 pub fn session(&self) -> &SessionToken {
558 &self.session
559 }
560
561 /// The events appended since the last read, in order, that this stream's
562 /// filter admits.
563 pub fn read(&mut self) -> Result<Vec<Envelope>> {
564 let mut events = Vec::new();
565 for record in self.reader.records()? {
566 // A kind this build has no word for is passed over rather than handed
567 // on: a consumer reading through this type reads `onevcs`'s vocabulary,
568 // and nothing is lost by not, because it could not have named one either.
569 //
570 // llmlint: ignore[boundary_inputs_validated] the two refusals this reader owes
571 // are `attributed_record`'s, called below, and both have been asked of this line
572 // before the `else` arm can discard it: a line that is not an envelope, and
573 // one belonging to another stream. The envelope's *version* and its *stamp*
574 // are not checked here and
575 // never have been, for any line — this reader hands back what a writer left
576 // and `status` is the surface that reports a version it cannot read or a
577 // stamp it cannot order, as a gap in its notes. So passing over a kindless
578 // line removes no check a line with a kind gets; it removes a value nothing
579 // downstream could have named.
580 let Line::Known(known) = attributed_record(record, &self.session.0)? else {
581 continue;
582 };
583 let envelope = known.envelope;
584 // Filtered last, and only after both refusals above: a filter says which
585 // events a consumer wants, never which lines of the file are worth
586 // reading. A stream that is not what a writer left is a refusal whichever
587 // events were asked for.
588 if !self.filter.matches(&envelope) {
589 continue;
590 }
591 // Dropped in silence, and only ever a phase this session cannot produce:
592 // one a filter *named* was refused when the stream was opened. Nothing was
593 // asked for and nothing was denied, so there is nothing to say.
594 if !envelope
595 .dimensions
596 .phase
597 .is_some_and(|phase| self.phases.contains(&phase))
598 {
599 continue;
600 }
601 events.push(envelope);
602 }
603 if let Some(correlated) = &mut self.releases {
604 events.extend(correlated.fresh(&self.session, &self.filter)?);
605 }
606 Ok(events)
607 }
608}
609
610impl Correlated {
611 /// The releases of this identity that carried this session's landing and have
612 /// not been handed back yet.
613 ///
614 /// The whole file each time rather than a cursor over it, for the reason
615 /// [`handed`](Correlated::handed) states: what makes an event of this stream this
616 /// session's is a landing commit history may not record until long after the
617 /// event was written.
618 fn fresh(&mut self, session: &SessionToken, filter: &EventFilter) -> Result<Vec<Envelope>> {
619 let path = path_for(&self.token)?;
620 let reading = match BusReader::open(&path) {
621 Ok(reading) => reading,
622 // A repository nothing has recorded a release for yet has no such record,
623 // and that is an answer rather than a gap: the file is written by the
624 // first release verb that says anything about this identity.
625 Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
626 // Every *other* way the read fails is a record this host has and cannot
627 // see, and answering "no releases" from one would be this reader deciding
628 // that some of what it was asked for is not worth reading — which is the
629 // one thing a reader of values must not do.
630 Err(failure) => {
631 return Err(error::invalid(format!(
632 "the release record for {identity} cannot be read: {failure}",
633 identity = self.identity,
634 )))
635 }
636 };
637 let mut candidates = Vec::new();
638 for (index, read) in reading.enumerate() {
639 let known = match read {
640 Reading::Record(record) => self.attributed(Ok(record.envelope), index + 1)?,
641 Reading::Refused(refused) => self.attributed(Err(refused.reason), index + 1)?,
642 // A record its writer has not finished yet, which the next read sees
643 // whole.
644 Reading::Torn(_) => break,
645 };
646 // A probe is not a release. `release-probed` says what a target answered
647 // when it was asked, which a session's own stream already carries for the
648 // probes its publication ran — handing this stream's back too would
649 // report one ask as two.
650 if !matches!(
651 known.kind,
652 EventKind::ReleaseObserved | EventKind::ReleaseAcknowledged
653 ) || self.handed.contains(&known.envelope.seq)
654 {
655 continue;
656 }
657 candidates.push((index + 1, known.envelope));
658 }
659 // Nothing to correlate. The one thing this read is allowed to answer without
660 // asking history, because it is a fact about the record rather than about the
661 // landing: an identity with nothing un-handed on it has nothing that could
662 // become this session's, however the landing is decided.
663 if candidates.is_empty() {
664 return Ok(Vec::new());
665 }
666 let landing = match &self.landing {
667 Some(landing) => landing.clone(),
668 // Asked again on every read that has a candidate, and deliberately not
669 // memoized on the candidates it has already weighed. The two halves of
670 // this correlation become true independently — a release is recorded
671 // where it happens and a landing becomes readable where the history is —
672 // so a reader that remembered "these events did not match" would answer
673 // from the last time the *stream* moved rather than from what is now
674 // true, and a release recorded before the landing could be read would
675 // never be handed over at all. Deciding a landing opens repositories and
676 // runs git, and this is what that costs: one decision per read, only
677 // while something un-handed is waiting to be correlated and only until
678 // the landing is known, after which it is answered from here.
679 None => match landed_at(session) {
680 // Nothing records that this session's work reached its base yet, so
681 // nothing here can be said to be this session's. Not handed back and
682 // not accounted for, so the same events are weighed again on the next
683 // read — which is what finds out that history now records a landing.
684 None => return Ok(Vec::new()),
685 Some(landing) => {
686 self.landing = Some(landing.clone());
687 landing
688 }
689 },
690 };
691 let mut fresh = Vec::new();
692 for (line_number, envelope) in candidates {
693 // The one field that makes an event of this stream some *landing's*, and
694 // therefore the only thing a correlation can be wrong about. It arrives
695 // from a file whichever process wrote it, so an event that names no
696 // commit is refused where it is read rather than read as "not this
697 // session's" — that reading is indistinguishable from a release of
698 // another landing, and it is what a consumer would then wait on for ever.
699 let named = envelope
700 .payload
701 .get("landing_commit")
702 .and_then(Value::as_str)
703 .and_then(ObjectId::parse)
704 .ok_or_else(|| {
705 self.refusal(
706 line_number,
707 "records a release that names no landing commit, so nothing can be said \
708 about which work it released",
709 )
710 })?;
711 if named != landing {
712 continue;
713 }
714 self.handed.insert(envelope.seq);
715 if filter.matches(&envelope) {
716 fresh.push(envelope);
717 }
718 }
719 Ok(fresh)
720 }
721
722 /// One record of the identity's release stream, refused if it is not an
723 /// envelope of a kind this build names or belongs to another stream.
724 ///
725 /// The two refusals [`attributed`] gives, said in the identity's own terms: this
726 /// stream's name is not something a consumer of a *session* has, so naming it in
727 /// a refusal would hand over the one address this join exists to keep private.
728 /// A kind this build has no word for is refused here too, as it always was: every
729 /// line of this record is one of the release kinds this build writes, and a
730 /// release this reader could not name is one it would otherwise report as absent.
731 fn attributed(
732 &self,
733 read: std::result::Result<Envelope, String>,
734 line_number: usize,
735 ) -> Result<Known> {
736 let envelope = read.map_err(|failure| {
737 self.refusal(line_number, &format!("is not an event envelope: {failure}"))
738 })?;
739 if envelope.stream != self.token {
740 return Err(self.refusal(line_number, "carries an event of another stream"));
741 }
742 match Line::of(envelope) {
743 Line::Known(known) => Ok(*known),
744 Line::Unknown(header) => Err(self.refusal(
745 line_number,
746 &format!(
747 "is not an event envelope: unknown variant {:?} for an event kind",
748 header.kind.as_str()
749 ),
750 )),
751 }
752 }
753
754 /// One refusal about this stream, in the identity's own terms.
755 ///
756 /// This stream's name is not something a consumer of a *session* has, so naming
757 /// it in a refusal would hand over the one address this join exists to keep
758 /// private.
759 fn refusal(&self, line_number: usize, what: &str) -> crate::Error {
760 error::invalid(format!(
761 "line {line_number} of the release record for {identity} {what}",
762 identity = self.identity,
763 ))
764 }
765}
766
767/// Which phases one session can produce, and the identity its releases belong to.
768///
769/// Best effort in one direction only. Every answer this can fail to reach widens the
770/// set rather than narrowing it, because a read that quietly left events out would
771/// be indistinguishable from a session that never wrote them — which is the failure
772/// the whole scoping exists to prevent. So a session this host keeps no record of
773/// takes every phase, and so does one whose repository this host can no longer
774/// resolve.
775///
776/// The record is read directly rather than through [`crate::Vcs`] for the reason the
777/// stream file beside it is: this is the state root's own bookkeeping about a stream
778/// that lives here, and a `Vcs` that keeps its sessions elsewhere is exactly the case
779/// the absent-record answer above is for.
780fn supported(session: &SessionToken) -> (BTreeSet<Phase>, Option<String>) {
781 let every = || (BTreeSet::from(Phase::every()), None);
782 let (Ok(record), Ok(registry)) = (workspace::load(&session.0), store::load()) else {
783 return every();
784 };
785 let identity = record.identity.clone();
786 let (Ok(resolution), Ok((rules, source))) = (
787 store::resolve(®istry, &identity),
788 policy::load(®istry),
789 ) else {
790 return every();
791 };
792 let mut phases = BTreeSet::from([Phase::Development, Phase::Integrate]);
793 let resolved = policy::resolve(
794 &rules,
795 &source,
796 &store::normalize(&resolution.identity.origin),
797 &resolution.publication,
798 );
799 // The one policy that opens no change request, so the one that leaves this
800 // session with nothing to review.
801 if resolved.policy.publication != MergePolicy::LocalDirect {
802 phases.insert(Phase::Review);
803 }
804 // A repository that releases nothing has no release to wait for, which is the
805 // state every host is in until it configures one — and it is the *only* answer
806 // that rules the phase out. A release-targets document this build cannot read
807 // rules nothing out, so it widens like every other answer this cannot reach; the
808 // release verbs are where such a document is refused by name. A repository whose
809 // *own* declaration could not be read is the same state one step further in: no
810 // target resolved, and no reason to believe there is none — so it widens too,
811 // rather than reading an unanswered question as an answer.
812 let releases_nothing = release::for_repository(®istry, &identity).is_ok_and(|located| {
813 located.releases.targets.is_empty() && located.releases.declaration.unreadable().is_none()
814 });
815 if !releases_nothing {
816 phases.insert(Phase::Release);
817 }
818 (phases, Some(identity))
819}
820
821/// The commit this session's work reached its base at, where history records one.
822///
823/// The same decision `onevcs status` reports and `onevcs release status` compares a
824/// release against, through the same reader — so what a session's releases are
825/// correlated by is the landing the rest of this crate would name, retries followed
826/// and all.
827///
828/// Read through the conversion that decides what an object id is, for the reason
829/// [`crate::landed`] reads its own records through it: the evidence travels as a
830/// `Sha`, which the contract fixes as an unvalidated string, and a value that is not
831/// a commit id is no landing to correlate against rather than one to compare.
832fn landed_at(session: &SessionToken) -> Option<ObjectId> {
833 let registry = store::load().ok()?;
834 match status::landing_of(®istry, &session.0, None).ok()?.landed {
835 // A branch that has gone on since its landing reached the base at that
836 // landing all the same, and the releases carrying it are the ones this
837 // session's reader is waiting for.
838 Landed::Yes { evidence } | Landed::InPart { evidence, .. } => {
839 ObjectId::parse(evidence.commit())
840 }
841 Landed::No | Landed::Unknown => None,
842 }
843}
844
845/// The phases a refusal lists as the ones a session does have.
846fn listed(phases: &BTreeSet<Phase>) -> String {
847 phases
848 .iter()
849 .map(|phase| phase.as_str())
850 .collect::<Vec<&str>>()
851 .join(", ")
852}
853
854/// One line of a stream as the envelope it has to be, refused if it is not one or
855/// if it belongs to another session.
856///
857/// Every reader that takes a stream's *values* shares this: [`EventStream`], `onevcs
858/// events --filter`, which has to read an event to judge it, and `status`. Two
859/// refusals, and neither is a filter's business.
860///
861/// A blank line is not an event either, and skipping one would be a reader deciding
862/// that some of the file is not worth reading — the one thing a reader of values
863/// must not do. A writer appends whole envelopes, so a blank line is a stream that
864/// is not what any writer left.
865///
866/// The attribution is the point: an envelope naming another stream in this file is a
867/// record nothing can be concluded from, not one to hand on as this session's — and
868/// a *filter* judging one session's event against another session's is the shape a
869/// consumer following several publications can never detect afterwards.
870///
871/// Both refusals are asked of every line, including one whose kind has no word in
872/// this build: [`Line`] tolerates the kind and nothing else, and it is the caller
873/// that decides what to do with a line it has no word for.
874pub fn attributed(line: &str, session: &str, line_number: usize) -> Result<Line> {
875 attribute(
876 Line::read(line).map_err(|failure| failure.to_string()),
877 session,
878 line_number,
879 )
880}
881
882/// [`attributed`], for a record [`Reader`] has already read.
883pub(crate) fn attributed_record(record: Record, session: &str) -> Result<Line> {
884 attribute(record.read.map(Line::of), session, record.number)
885}
886
887fn attribute(
888 read: std::result::Result<Line, String>,
889 session: &str,
890 line_number: usize,
891) -> Result<Line> {
892 let read = read.map_err(|e| {
893 error::invalid(format!(
894 "line {line_number} of the stream for {session:?} is not an event envelope: {e}"
895 ))
896 })?;
897 if read.stream() != session {
898 return Err(error::invalid(format!(
899 "line {line_number} of the stream for {session:?} carries an event of stream {:?}",
900 read.stream()
901 )));
902 }
903 Ok(read)
904}
905
906/// The stream one repository's release activity is recorded on.
907///
908/// Spelled once, because both ends of the correlation resolve it: the writer that
909/// appends a release event, and the session reader that hands one back. It is
910/// deliberately not a consumer's to derive — nothing hands it out and no refusal
911/// names it — so a second spelling of it would be the address escaping by accident.
912fn releases_token(identity: &str) -> String {
913 format!("releases-{}", ids::short_digest(identity))
914}
915
916/// The file one session's stream lives in.
917pub fn path_for(token: &str) -> Result<PathBuf> {
918 if !ids::is_safe_name(token) {
919 return Err(error::invalid(format!("{token:?} is not a session token")));
920 }
921 Ok(home::streams_dir()?.join(format!("{token}.ndjson")))
922}
923
924/// Replace credential-shaped values with `[redacted]`.
925///
926/// Two sources, because neither covers the other. A value this process was handed
927/// in a credential-shaped environment variable is a credential whatever it looks
928/// like; a value spelled like a host's own token is one whatever it was named. Both
929/// tables are the bus's, so what an artifact is redacted of is exactly what an event
930/// is.
931pub fn redact(text: &str) -> String {
932 Redactor::from_env().redact(text)
933}
934
935/// Store evidence beside the stream and return the reference an event carries.
936pub fn store_artifact(kind: &str, contents: &str) -> Result<ArtifactRef> {
937 let id = ids::artifact_id();
938 let directory = home::artifacts_dir()?;
939 home::ensure_dir(&directory)?;
940 let clean = redact(contents);
941 let path = directory.join(&id);
942 std::fs::write(&path, &clean).map_err(error::at("store the artifact at", &path))?;
943 Ok(ArtifactRef {
944 id,
945 kind: kind.to_owned(),
946 bytes: clean.len() as u64,
947 })
948}
949
950/// Read a stored artifact back, for `onevcs artifact cat`.
951pub fn read_artifact(id: &str) -> Result<String> {
952 if !ids::is_safe_name(id) {
953 return Err(error::invalid(format!("{id:?} is not an artifact id")));
954 }
955 let path = home::artifacts_dir()?.join(id);
956 std::fs::read_to_string(&path)
957 .map_err(|_| error::invalid(format!("no artifact {id:?} is stored")))
958}
959
960#[cfg(test)]
961mod tests {
962 use serde_json::json;
963
964 use super::*;
965
966 /// Point this process's state root at a directory of its own.
967 ///
968 /// Safe as a process-wide write because `cargo nextest` runs each test in its own
969 /// process, which is what the end-to-end suite's in-process journeys rely on too.
970 fn inhabit() -> tempfile::TempDir {
971 let home = tempfile::tempdir().expect("a temporary state root");
972 std::env::set_var(home::HOME_ENV, home.path());
973 home
974 }
975
976 fn envelopes(written: &str) -> Vec<Value> {
977 written
978 .lines()
979 .map(|line| serde_json::from_str(line).expect("every line is an envelope"))
980 .collect()
981 }
982
983 #[test]
984 fn a_credential_nested_in_an_object_and_a_list_never_reaches_a_sessions_stream() {
985 // No kind this crate emits today nests an object in its payload, so no verb can
986 // be driven into writing one; the day one does, it is written by this call, the
987 // one every session event goes through. The stream is read back the two ways a
988 // consumer reads it: the file's own bytes, and `EventStream`.
989 let _home = inhabit();
990 let token = "nested-credentials";
991 let credentials = [
992 "ghp_0123456789abcdef",
993 "github_pat_0123456789abcdef",
994 "AKIA0123456789ABCDEF",
995 ];
996 let payload = json!({
997 "name": "check",
998 "detail": {
999 "said": format!("pushed with {}", credentials[0]),
1000 "attempts": [
1001 {"output": credentials[1]},
1002 ["retried", format!("{},", credentials[2])],
1003 ],
1004 },
1005 });
1006
1007 Stream::open(token)
1008 .expect("the session's stream")
1009 .emit_with(
1010 EventKind::ChangeCheck,
1011 payload.as_object().expect("an object").clone(),
1012 Vec::new(),
1013 );
1014
1015 let written = std::fs::read_to_string(path_for(token).expect("a stream path"))
1016 .expect("the stream was written");
1017 for credential in credentials {
1018 assert!(
1019 !written.contains(credential),
1020 "{credential:?} reached the stream file:\n{written}"
1021 );
1022 }
1023 let read = EventStream::open(&SessionToken(token.to_owned()))
1024 .expect("the session's stream")
1025 .read()
1026 .expect("every event the session wrote");
1027 assert_eq!(read.len(), 1, "{written}");
1028 assert_eq!(
1029 Value::Object(read[0].payload.clone()),
1030 json!({
1031 "name": "check",
1032 "detail": {
1033 "said": "pushed with [redacted]",
1034 "attempts": [
1035 {"output": "[redacted]"},
1036 ["retried", "[redacted],"],
1037 ],
1038 },
1039 }),
1040 "every string of the payload is redacted, however deeply it is nested"
1041 );
1042 }
1043
1044 #[test]
1045 fn a_record_a_writer_died_part_way_through_is_healed_before_the_next_event_is_appended() {
1046 // A writer killed mid-line leaves bytes no newline finished. The next event this
1047 // crate records truncates them first, so it starts a line of its own and takes
1048 // the number after the last whole record.
1049 let _home = inhabit();
1050 let token = "torn-tail";
1051 let path = path_for(token).expect("a stream path");
1052 Stream::open(token)
1053 .expect("the session's stream")
1054 .emit(EventKind::SessionOpened, Map::new());
1055 let whole = std::fs::read_to_string(&path).expect("the stream was written");
1056 let torn = "{\"v\":1,\"ts\":\"2026-09-13T00:00:00.000Z\",\"stream\":\"";
1057 {
1058 use std::io::Write;
1059 std::fs::OpenOptions::new()
1060 .append(true)
1061 .open(&path)
1062 .and_then(|mut file| file.write_all(torn.as_bytes()))
1063 .expect("the stream takes the bytes");
1064 }
1065
1066 Stream::open(token)
1067 .expect("the session's stream, reopened")
1068 .emit(EventKind::SessionClosed, Map::new());
1069
1070 let written = std::fs::read_to_string(&path).expect("the stream");
1071 assert!(
1072 written.starts_with(&whole) && written.ends_with('\n') && !written.contains(torn),
1073 "the torn bytes were not truncated away before the append:\n{written}"
1074 );
1075 let envelopes = envelopes(&written);
1076 assert_eq!(
1077 envelopes
1078 .iter()
1079 .map(|envelope| envelope["seq"].as_u64().expect("a seq"))
1080 .collect::<Vec<u64>>(),
1081 vec![1, 2],
1082 "one gapless series:\n{written}"
1083 );
1084 assert_eq!(
1085 envelopes[1]["kind"], "session-closed",
1086 "the last record is the one the healing writer wrote:\n{written}"
1087 );
1088 }
1089}