use std::collections::BTreeSet;
use std::path::PathBuf;
use onemessagebus::{EmitterError, Reading, Redactor};
use serde_json::{Map, Value};
use crate::error::{self, Result};
use crate::event::{
phase_of, ArtifactRef, Dimensions, Envelope, EventFilter, EventKind, Known, Labels, Line,
Phase, Source, SOURCE_WORD,
};
use crate::git::ObjectId;
use crate::landed::Landed;
use crate::rules::MergePolicy;
use crate::session::SessionToken;
use crate::vocabulary::{BusReader, Emitter};
use crate::{home, ids, policy, release, status, store, workspace};
pub const ENVELOPE_VERSION: u32 = 1;
#[derive(Debug)]
pub struct Stream {
path: PathBuf,
id: String,
labels: Labels,
emitter: Emitter,
}
impl Stream {
pub fn open(token: &str) -> Result<Self> {
let path = path_for(token)?;
home::ensure_dir(path.parent().expect("a stream lives in a directory"))?;
let mut labels = Labels::default();
labels
.extra
.insert("session".to_owned(), Value::String(token.to_owned()));
let emitter = emitter(token, &path, &labels);
Ok(Self {
path,
id: token.to_owned(),
labels,
emitter,
})
}
pub fn releases(identity: &str) -> Result<Self> {
let mut stream = Self::open(&releases_token(identity))?;
stream.labels.extra.remove("session");
stream.label("identity", identity);
Ok(stream)
}
pub fn label(&mut self, key: &str, value: &str) {
self.labels
.extra
.insert(key.to_owned(), Value::String(value.to_owned()));
self.emitter = emitter(&self.id, &self.path, &self.labels);
}
pub fn emit(&mut self, kind: EventKind, payload: Map<String, Value>) {
self.emit_with(kind, payload, Vec::new());
}
pub fn emit_push(
&mut self,
phase: Phase,
payload: Map<String, Value>,
artifacts: Vec<ArtifactRef>,
) {
self.append_stamped(EventKind::Push, phase, payload, artifacts);
}
pub fn emit_with(
&mut self,
kind: EventKind,
payload: Map<String, Value>,
artifacts: Vec<ArtifactRef>,
) {
self.append_stamped(kind, phase_of(kind), payload, artifacts);
}
fn append_stamped(
&mut self,
kind: EventKind,
phase: Phase,
payload: Map<String, Value>,
artifacts: Vec<ArtifactRef>,
) {
let Err(unrecorded) =
self.emitter
.try_emit_stamped(kind, Dimensions::at(phase), payload, artifacts)
else {
return;
};
let path = self.path.display();
match &unrecorded.error {
EmitterError::Lock { source, .. } => {
eprintln!("onevcs: warning: cannot order a {kind:?} event in {path}: {source}");
}
EmitterError::Write { source, .. } => {
eprintln!("onevcs: warning: cannot record a {kind:?} event in {path}: {source}");
}
EmitterError::Serialize { source, .. } => {
eprintln!("onevcs: warning: cannot record a {kind:?} event in {path}: {source}");
}
}
}
}
fn emitter(id: &str, path: &std::path::Path, labels: &Labels) -> Emitter {
Emitter::shared(id, Source::from(SOURCE_WORD), path)
.with_version(ENVELOPE_VERSION)
.with_labels(labels.clone())
}
#[derive(Debug)]
pub struct Reader {
path: PathBuf,
position: u64,
read: usize,
}
pub(crate) struct Record {
pub number: usize,
pub text: String,
pub read: std::result::Result<Envelope, String>,
}
impl Reader {
pub fn open(token: &str) -> Result<Self> {
let path = path_for(token)?;
if !path.is_file() {
return Err(error::invalid(format!("no event stream for {token:?}")));
}
Ok(Self {
path,
position: 0,
read: 0,
})
}
pub(crate) fn records(&mut self) -> Result<Vec<Record>> {
let reading =
BusReader::open_at(&self.path, self.position).map_err(error::at("read", &self.path))?;
let bytes = std::fs::read(&self.path).map_err(error::at("read", &self.path))?;
let mut records = Vec::new();
for read in reading {
let (end, envelope) = match read {
Reading::Record(record) => (record.position, Ok(record.envelope)),
Reading::Refused(refused) => (refused.position, Err(refused.reason)),
Reading::Torn(_) => break,
};
let text = line_between(&bytes, self.position, end);
self.position = end;
self.read += 1;
records.push(Record {
number: self.read,
text,
read: envelope,
});
}
Ok(records)
}
}
fn line_between(bytes: &[u8], start: u64, end: u64) -> String {
let (start, end) = (to_index(start), to_index(end).saturating_sub(1));
String::from_utf8_lossy(bytes.get(start..end).unwrap_or_default()).into_owned()
}
fn to_index(position: u64) -> usize {
usize::try_from(position).unwrap_or(usize::MAX)
}
#[derive(Debug, Clone, PartialEq)]
pub struct EventLine {
pub text: String,
pub envelope: Option<Envelope>,
}
#[derive(Debug)]
pub struct EventLines {
session: SessionToken,
reader: Reader,
filter: Option<EventFilter>,
}
impl EventLines {
pub fn open(session: &SessionToken, filter: Option<EventFilter>) -> Result<Self> {
Ok(Self {
session: session.clone(),
reader: Reader::open(&session.0)?,
filter,
})
}
pub fn session(&self) -> &SessionToken {
&self.session
}
pub fn read(&mut self) -> Result<Vec<EventLine>> {
let mut lines = Vec::new();
for record in self.reader.records()? {
let text = record.text.clone();
let Some(filter) = &self.filter else {
let envelope = match attributed_record(record, &self.session.0) {
Ok(Line::Known(known)) => Some(known.envelope),
Ok(Line::Unknown(_)) | Err(_) => None,
};
lines.push(EventLine { text, envelope });
continue;
};
let Line::Known(known) = attributed_record(record, &self.session.0)? else {
continue;
};
if !filter.matches(&known.envelope) {
continue;
}
lines.push(EventLine {
text,
envelope: Some(known.envelope),
});
}
Ok(lines)
}
}
#[derive(Debug)]
pub struct EventStream {
session: SessionToken,
reader: Reader,
filter: EventFilter,
phases: BTreeSet<Phase>,
releases: Option<Correlated>,
}
#[derive(Debug)]
struct Correlated {
identity: String,
token: String,
landing: Option<ObjectId>,
handed: BTreeSet<u64>,
}
impl EventStream {
pub fn open(session: &SessionToken) -> Result<Self> {
Self::open_filtered(session, EventFilter::default())
}
pub fn open_filtered(session: &SessionToken, filter: EventFilter) -> Result<Self> {
let reader = Reader::open(&session.0)?;
let (phases, identity) = supported(session);
for named in filter
.include
.iter()
.chain(&filter.exclude)
.filter_map(|matcher| matcher.fields.phase)
{
if !phases.contains(&named) {
return Err(error::invalid(format!(
"the event filter names the {named} phase, which the session {session} does \
not have: it has {had}. A filter that named it would be answered with \
nothing, and nothing is what a filter for the wrong phase and a session \
that did nothing look alike as",
session = session.0,
had = listed(&phases),
)));
}
}
let releases = match (phases.contains(&Phase::Release), identity) {
(true, Some(identity)) => Some(Correlated {
token: releases_token(&identity),
identity,
landing: None,
handed: BTreeSet::new(),
}),
_ => None,
};
Ok(Self {
session: session.clone(),
reader,
filter,
phases,
releases,
})
}
#[must_use]
pub fn session(&self) -> &SessionToken {
&self.session
}
pub fn read(&mut self) -> Result<Vec<Envelope>> {
let mut events = Vec::new();
for record in self.reader.records()? {
let Line::Known(known) = attributed_record(record, &self.session.0)? else {
continue;
};
let envelope = known.envelope;
if !self.filter.matches(&envelope) {
continue;
}
if !envelope
.dimensions
.phase
.is_some_and(|phase| self.phases.contains(&phase))
{
continue;
}
events.push(envelope);
}
if let Some(correlated) = &mut self.releases {
events.extend(correlated.fresh(&self.session, &self.filter)?);
}
Ok(events)
}
}
impl Correlated {
fn fresh(&mut self, session: &SessionToken, filter: &EventFilter) -> Result<Vec<Envelope>> {
let path = path_for(&self.token)?;
let reading = match BusReader::open(&path) {
Ok(reading) => reading,
Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(failure) => {
return Err(error::invalid(format!(
"the release record for {identity} cannot be read: {failure}",
identity = self.identity,
)))
}
};
let mut candidates = Vec::new();
for (index, read) in reading.enumerate() {
let known = match read {
Reading::Record(record) => self.attributed(Ok(record.envelope), index + 1)?,
Reading::Refused(refused) => self.attributed(Err(refused.reason), index + 1)?,
Reading::Torn(_) => break,
};
if !matches!(
known.kind,
EventKind::ReleaseObserved | EventKind::ReleaseAcknowledged
) || self.handed.contains(&known.envelope.seq)
{
continue;
}
candidates.push((index + 1, known.envelope));
}
if candidates.is_empty() {
return Ok(Vec::new());
}
let landing = match &self.landing {
Some(landing) => landing.clone(),
None => match landed_at(session) {
None => return Ok(Vec::new()),
Some(landing) => {
self.landing = Some(landing.clone());
landing
}
},
};
let mut fresh = Vec::new();
for (line_number, envelope) in candidates {
let named = envelope
.payload
.get("landing_commit")
.and_then(Value::as_str)
.and_then(ObjectId::parse)
.ok_or_else(|| {
self.refusal(
line_number,
"records a release that names no landing commit, so nothing can be said \
about which work it released",
)
})?;
if named != landing {
continue;
}
self.handed.insert(envelope.seq);
if filter.matches(&envelope) {
fresh.push(envelope);
}
}
Ok(fresh)
}
fn attributed(
&self,
read: std::result::Result<Envelope, String>,
line_number: usize,
) -> Result<Known> {
let envelope = read.map_err(|failure| {
self.refusal(line_number, &format!("is not an event envelope: {failure}"))
})?;
if envelope.stream != self.token {
return Err(self.refusal(line_number, "carries an event of another stream"));
}
match Line::of(envelope) {
Line::Known(known) => Ok(*known),
Line::Unknown(header) => Err(self.refusal(
line_number,
&format!(
"is not an event envelope: unknown variant {:?} for an event kind",
header.kind.as_str()
),
)),
}
}
fn refusal(&self, line_number: usize, what: &str) -> crate::Error {
error::invalid(format!(
"line {line_number} of the release record for {identity} {what}",
identity = self.identity,
))
}
}
fn supported(session: &SessionToken) -> (BTreeSet<Phase>, Option<String>) {
let every = || (BTreeSet::from(Phase::every()), None);
let (Ok(record), Ok(registry)) = (workspace::load(&session.0), store::load()) else {
return every();
};
let identity = record.identity.clone();
let (Ok(resolution), Ok((rules, source))) = (
store::resolve(®istry, &identity),
policy::load(®istry),
) else {
return every();
};
let mut phases = BTreeSet::from([Phase::Development, Phase::Integrate]);
let resolved = policy::resolve(
&rules,
&source,
&store::normalize(&resolution.identity.origin),
&resolution.publication,
);
if resolved.policy.publication != MergePolicy::LocalDirect {
phases.insert(Phase::Review);
}
let releases_nothing = release::for_repository(®istry, &identity).is_ok_and(|located| {
located.releases.targets.is_empty() && located.releases.declaration.unreadable().is_none()
});
if !releases_nothing {
phases.insert(Phase::Release);
}
(phases, Some(identity))
}
fn landed_at(session: &SessionToken) -> Option<ObjectId> {
let registry = store::load().ok()?;
match status::landing_of(®istry, &session.0, None).ok()?.landed {
Landed::Yes { evidence } | Landed::InPart { evidence, .. } => {
ObjectId::parse(evidence.commit())
}
Landed::No | Landed::Unknown => None,
}
}
fn listed(phases: &BTreeSet<Phase>) -> String {
phases
.iter()
.map(|phase| phase.as_str())
.collect::<Vec<&str>>()
.join(", ")
}
pub fn attributed(line: &str, session: &str, line_number: usize) -> Result<Line> {
attribute(
Line::read(line).map_err(|failure| failure.to_string()),
session,
line_number,
)
}
pub(crate) fn attributed_record(record: Record, session: &str) -> Result<Line> {
attribute(record.read.map(Line::of), session, record.number)
}
fn attribute(
read: std::result::Result<Line, String>,
session: &str,
line_number: usize,
) -> Result<Line> {
let read = read.map_err(|e| {
error::invalid(format!(
"line {line_number} of the stream for {session:?} is not an event envelope: {e}"
))
})?;
if read.stream() != session {
return Err(error::invalid(format!(
"line {line_number} of the stream for {session:?} carries an event of stream {:?}",
read.stream()
)));
}
Ok(read)
}
fn releases_token(identity: &str) -> String {
format!("releases-{}", ids::short_digest(identity))
}
pub fn path_for(token: &str) -> Result<PathBuf> {
if !ids::is_safe_name(token) {
return Err(error::invalid(format!("{token:?} is not a session token")));
}
Ok(home::streams_dir()?.join(format!("{token}.ndjson")))
}
pub fn redact(text: &str) -> String {
Redactor::from_env().redact(text)
}
pub fn store_artifact(kind: &str, contents: &str) -> Result<ArtifactRef> {
let id = ids::artifact_id();
let directory = home::artifacts_dir()?;
home::ensure_dir(&directory)?;
let clean = redact(contents);
let path = directory.join(&id);
std::fs::write(&path, &clean).map_err(error::at("store the artifact at", &path))?;
Ok(ArtifactRef {
id,
kind: kind.to_owned(),
bytes: clean.len() as u64,
})
}
pub fn read_artifact(id: &str) -> Result<String> {
if !ids::is_safe_name(id) {
return Err(error::invalid(format!("{id:?} is not an artifact id")));
}
let path = home::artifacts_dir()?.join(id);
std::fs::read_to_string(&path)
.map_err(|_| error::invalid(format!("no artifact {id:?} is stored")))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn inhabit() -> tempfile::TempDir {
let home = tempfile::tempdir().expect("a temporary state root");
std::env::set_var(home::HOME_ENV, home.path());
home
}
fn envelopes(written: &str) -> Vec<Value> {
written
.lines()
.map(|line| serde_json::from_str(line).expect("every line is an envelope"))
.collect()
}
#[test]
fn a_credential_nested_in_an_object_and_a_list_never_reaches_a_sessions_stream() {
let _home = inhabit();
let token = "nested-credentials";
let credentials = [
"ghp_0123456789abcdef",
"github_pat_0123456789abcdef",
"AKIA0123456789ABCDEF",
];
let payload = json!({
"name": "check",
"detail": {
"said": format!("pushed with {}", credentials[0]),
"attempts": [
{"output": credentials[1]},
["retried", format!("{},", credentials[2])],
],
},
});
Stream::open(token)
.expect("the session's stream")
.emit_with(
EventKind::ChangeCheck,
payload.as_object().expect("an object").clone(),
Vec::new(),
);
let written = std::fs::read_to_string(path_for(token).expect("a stream path"))
.expect("the stream was written");
for credential in credentials {
assert!(
!written.contains(credential),
"{credential:?} reached the stream file:\n{written}"
);
}
let read = EventStream::open(&SessionToken(token.to_owned()))
.expect("the session's stream")
.read()
.expect("every event the session wrote");
assert_eq!(read.len(), 1, "{written}");
assert_eq!(
Value::Object(read[0].payload.clone()),
json!({
"name": "check",
"detail": {
"said": "pushed with [redacted]",
"attempts": [
{"output": "[redacted]"},
["retried", "[redacted],"],
],
},
}),
"every string of the payload is redacted, however deeply it is nested"
);
}
#[test]
fn a_record_a_writer_died_part_way_through_is_healed_before_the_next_event_is_appended() {
let _home = inhabit();
let token = "torn-tail";
let path = path_for(token).expect("a stream path");
Stream::open(token)
.expect("the session's stream")
.emit(EventKind::SessionOpened, Map::new());
let whole = std::fs::read_to_string(&path).expect("the stream was written");
let torn = "{\"v\":1,\"ts\":\"2026-09-13T00:00:00.000Z\",\"stream\":\"";
{
use std::io::Write;
std::fs::OpenOptions::new()
.append(true)
.open(&path)
.and_then(|mut file| file.write_all(torn.as_bytes()))
.expect("the stream takes the bytes");
}
Stream::open(token)
.expect("the session's stream, reopened")
.emit(EventKind::SessionClosed, Map::new());
let written = std::fs::read_to_string(&path).expect("the stream");
assert!(
written.starts_with(&whole) && written.ends_with('\n') && !written.contains(torn),
"the torn bytes were not truncated away before the append:\n{written}"
);
let envelopes = envelopes(&written);
assert_eq!(
envelopes
.iter()
.map(|envelope| envelope["seq"].as_u64().expect("a seq"))
.collect::<Vec<u64>>(),
vec![1, 2],
"one gapless series:\n{written}"
);
assert_eq!(
envelopes[1]["kind"], "session-closed",
"the last record is the one the healing writer wrote:\n{written}"
);
}
}