Skip to main content

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(&registry, &identity),
788        policy::load(&registry),
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(&registry, &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(&registry, &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}