use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::event::Envelope;
use crate::graph;
use crate::journal;
use crate::ledger::{self, LaunchRecord, RunPaths, Skipped};
use crate::projection::{self, RunState};
use crate::telemetry::{self, RunTelemetry};
pub const SUMMARY_SCHEMA_VERSION: u32 = 7;
fn this_version<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<u32, D::Error> {
let found = u32::deserialize(reader)?;
if found != SUMMARY_SCHEMA_VERSION {
return Err(serde::de::Error::custom(format!(
"summary schema_version {found}, and this build reads {SUMMARY_SCHEMA_VERSION}"
)));
}
Ok(found)
}
pub(crate) fn version_this_build_moved_past(text: &str) -> Option<u32> {
#[derive(Deserialize)]
struct Declared {
schema_version: u32,
}
serde_json::from_str::<Declared>(text)
.ok()
.map(|declared| declared.schema_version)
.filter(|declared| *declared < SUMMARY_SCHEMA_VERSION)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NodeLanding {
pub landing: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub drafted: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunSummary {
#[serde(deserialize_with = "this_version")]
pub schema_version: u32,
pub run_id: String,
pub last_write_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_event_kind: Option<String>,
pub event_count: u64,
pub node_counts: BTreeMap<String, u64>,
pub stop_recorded: bool,
pub graph_complete: bool,
pub decisions_pending: u64,
pub surfaces_queued: u64,
pub surfaces_read: u64,
pub awaiting_human_action: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub project: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub launcher: String,
pub session: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<NonZeroU32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub let_go_by: Option<crate::projection::DriverClaim>,
pub timing: RunTelemetry,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parked: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub judge_rejected: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub landings: BTreeMap<String, NodeLanding>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oneharness_sessions: Option<PathBuf>,
pub journal_len: u64,
pub journal_mtime_ms: u64,
}
fn plan_name(paths: &RunPaths) -> Option<String> {
crate::views::plan_of(paths).ok().and_then(|plan| plan.name)
}
fn journal_stamp(paths: &RunPaths) -> (u64, u64) {
let Ok(about) = std::fs::metadata(paths.journal()) else {
return (0, 0);
};
let modified = about
.modified()
.ok()
.and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |since| {
u64::try_from(since.as_millis()).unwrap_or(u64::MAX)
});
(about.len(), modified)
}
type Stamp = (u64, u64);
struct Store<'a> {
state: &'a RunState,
name: Option<String>,
event_count: u64,
last_event_kind: Option<String>,
timing: &'a RunTelemetry,
judged: &'a BTreeSet<String>,
}
impl RunSummary {
pub fn of(paths: &RunPaths) -> crate::Result<Self> {
if !paths.exists() {
return Err(crate::Error::NoSuchRun {
run: paths.run.clone(),
root: paths.dir.parent().unwrap_or(Path::new(".")).to_path_buf(),
});
}
let stamp = journal_stamp(paths);
if let Some(stored) = ledger::read_json_opt::<Self>(&paths.summary()) {
if (stored.journal_len, stored.journal_mtime_ms) == stamp && stored.run_id == paths.run
{
return Ok(stored.with_the_report_the_run_left(paths));
}
}
let folded = Self::folded(paths, stamp)?;
let _ = ledger::write_json(&paths.summary(), &folded);
Ok(folded)
}
fn with_the_report_the_run_left(mut self, paths: &RunPaths) -> Self {
let unlanded = graph::Landing::Unlanded.as_str();
if !self.landings.values().any(|node| node.landing == unlanded) {
return self;
}
let Some(result) = ledger::read_json_opt::<crate::engine::RunResult>(&paths.result())
else {
return self;
};
for node in result.nodes {
if node.landing != Some(graph::Landing::Landed) {
continue;
}
if let Some(held) = self.landings.get_mut(&node.id) {
if held.landing == unlanded {
held.landing = graph::Landing::Landed.as_str().to_string();
}
}
}
self
}
fn folded(paths: &RunPaths, stamp: Stamp) -> crate::Result<Self> {
let view = crate::views::RunView::open(paths)?;
let judged: BTreeSet<String> = view
.events
.iter()
.filter_map(crate::report::a_judge_failed)
.map(str::to_string)
.collect();
Ok(Self::derive(
&paths.run,
&view.launch,
&Store {
state: &view.state,
name: plan_name(paths),
event_count: view.events.len() as u64,
last_event_kind: view.events.last().map(|event| event.kind.0.clone()),
timing: &telemetry::of_run(paths, &view.events),
judged: &judged,
},
stamp,
))
}
fn derive(
run: &str,
launch: &LaunchRecord,
store: &Store<'_>,
(journal_len, journal_mtime_ms): Stamp,
) -> Self {
let Store {
state,
name,
event_count,
last_event_kind,
timing,
judged,
} = store;
let (event_count, last_event_kind, timing) =
(*event_count, last_event_kind.clone(), *timing);
let statuses = state.statuses();
let mut node_counts: BTreeMap<String, u64> = BTreeMap::new();
for status in statuses.values() {
*node_counts.entry(status.as_str().to_string()).or_insert(0) += 1;
}
let with_status = |wanted: graph::NodeStatus| -> Vec<String> {
statuses
.iter()
.filter(|(_, status)| **status == wanted)
.map(|(id, _)| id.clone())
.collect()
};
Self {
schema_version: SUMMARY_SCHEMA_VERSION,
run_id: run.to_string(),
last_write_at: state.last_write_at,
last_event_kind,
event_count,
node_counts,
stop_recorded: state.stop_recorded(),
graph_complete: !statuses.is_empty() && graph::is_terminal(&statuses),
decisions_pending: state.decisions_pending.len() as u64,
surfaces_queued: state.surfaces_queued,
surfaces_read: state.surfaces_read,
awaiting_human_action: state.awaiting_human_action(),
project: launch.project.clone(),
name: name.clone(),
launcher: launch.launcher.clone(),
session: launch.session.clone(),
started_at: launch.launched_at().map(str::to_string),
pid: launch.driver_pid(),
host: launch.recorded_host().map(str::to_string),
started: launch.driver_stamp().map(str::to_string),
let_go_by: state.let_go_by.clone(),
timing: timing.clone(),
oneharness_sessions: launch.oneharness_sessions.clone(),
parked: with_status(graph::NodeStatus::Parked),
judge_rejected: with_status(graph::NodeStatus::Failed)
.into_iter()
.filter(|id| {
matches!(
state.outcomes.get(id).map(String::as_str),
Some(crate::engine::TASK_FAILED | crate::engine::TASK_FAILED_CHANGE_OPEN)
)
})
.filter(|id| judged.contains(id))
.collect(),
landings: state
.landings
.iter()
.map(|(node, landing)| {
(
node.clone(),
NodeLanding {
landing: landing.as_str().to_string(),
branch: state.branches.get(node).cloned(),
repo: state.graph.get(node).and_then(|node| node.repo.clone()),
drafted: statuses.get(node) == Some(&graph::NodeStatus::CompleteDraft),
},
)
})
.collect(),
journal_len,
journal_mtime_ms,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Listing {
pub root: PathBuf,
pub summaries: Vec<RunSummary>,
pub skipped: Vec<Skipped>,
}
impl Listing {
pub fn of(root: &Path) -> Self {
let index = ledger::all_runs(root);
let mut summaries = Vec::new();
let mut skipped = index.skipped;
for paths in index.runs {
match RunSummary::of(&paths) {
Ok(summary) => summaries.push(summary),
Err(error) => skipped.push(Skipped {
path: paths.dir,
reason: error.to_string(),
}),
}
}
summaries.sort_by(|a, b| {
b.last_write_at
.cmp(&a.last_write_at)
.then_with(|| a.run_id.cmp(&b.run_id))
});
skipped.sort_by(|a, b| a.path.cmp(&b.path));
Self {
root: root.to_path_buf(),
summaries,
skipped,
}
}
}
#[derive(Debug, Default, Clone)]
struct Folded {
state: RunState,
aggregate: telemetry::Aggregate,
events: u64,
last_event_kind: Option<String>,
judged: BTreeSet<String>,
}
impl Folded {
fn new() -> Self {
Self {
state: RunState {
strict: true,
..RunState::default()
},
..Self::default()
}
}
fn take(&mut self, paths: &RunPaths, event: &Envelope) {
projection::fold_one(&mut self.state, event);
self.aggregate.fold(paths, event);
self.events += 1;
self.last_event_kind = Some(event.kind.0.clone());
if let Some(node) = crate::report::a_judge_failed(event) {
self.judged.insert(node.to_string());
}
}
}
#[derive(Debug)]
pub(crate) struct Maintainer {
paths: RunPaths,
name: Option<String>,
settled: Folded,
open: Vec<Envelope>,
open_ts: String,
newest_ts: String,
accounted: u64,
settled_seq: BTreeMap<String, u64>,
}
impl Maintainer {
pub(crate) fn of(paths: &RunPaths) -> Self {
let read = journal::finished_records_after(&paths.journal(), 0);
let accounted: u64 = read.iter().map(|(_, bytes)| *bytes).sum();
let mut events: Vec<Envelope> = read.into_iter().filter_map(|(record, _)| record).collect();
journal::merge_order(&mut events);
let open_ts = events
.last()
.map(|event| event.ts.clone())
.unwrap_or_default();
let opened = events
.iter()
.rposition(|event| event.ts != open_ts)
.map_or(0, |before| before + 1);
let mut settled = Folded::new();
let mut settled_seq = BTreeMap::new();
for event in &events[..opened] {
settled.take(paths, event);
seq_reached(&mut settled_seq, event);
}
Self {
paths: paths.clone(),
name: plan_name(paths),
settled,
open: events[opened..].to_vec(),
newest_ts: events
.iter()
.map(|event| &event.ts)
.max()
.cloned()
.unwrap_or_default(),
open_ts,
accounted,
settled_seq,
}
}
fn current(&self) -> Folded {
let mut folded = self.settled.clone();
let mut open: Vec<&Envelope> = self.open.iter().collect();
open.sort_by(|a, b| a.stream.cmp(&b.stream).then(a.seq.cmp(&b.seq)));
for event in open {
folded.take(&self.paths, event);
}
folded
}
pub(crate) fn appended(&mut self, event: &Envelope, bytes: u64) {
let len = journal_stamp(&self.paths).0;
if len == self.accounted + bytes {
self.fold(event, bytes);
} else if len > self.accounted + bytes {
self.catch_up();
} else {
*self = Self::of(&self.paths);
}
self.write();
}
fn fold(&mut self, event: &Envelope, bytes: u64) -> Rebuilt {
let settled_past_it = self
.settled_seq
.get(&event.stream)
.is_some_and(|reached| event.seq <= *reached);
let closing_over_it = event.ts > self.open_ts
&& self
.open
.iter()
.any(|held| held.stream == event.stream && held.seq > event.seq);
if settled_past_it || closing_over_it || event.ts < self.newest_ts {
*self = Self::of(&self.paths);
return Rebuilt::Yes;
}
if event.ts > self.open_ts {
self.settled = self.current();
for settled in &self.open {
seq_reached(&mut self.settled_seq, settled);
}
self.open_ts = event.ts.clone();
self.open.clear();
}
self.open.push(event.clone());
if event.ts > self.newest_ts {
self.newest_ts = event.ts.clone();
}
self.accounted += bytes;
Rebuilt::No
}
fn catch_up(&mut self) {
for (record, bytes) in
journal::finished_records_after(&self.paths.journal(), self.accounted)
{
match record {
Some(event) if self.fold(&event, bytes) == Rebuilt::Yes => return,
Some(_) => {}
None => self.accounted += bytes,
}
}
}
fn write(&mut self) {
let mut folded = self.current();
folded.state.cross_dag = crate::crossdag::resolve_quietly(
&self
.paths
.dir
.parent()
.map_or_else(ledger::runs_root, Path::to_path_buf),
&folded.state.graph,
);
if folded
.state
.landings
.values()
.any(|landing| *landing == graph::Landing::Unlanded)
{
crate::views::landings_the_run_re_read(&mut folded.state, &self.paths);
}
let summary = RunSummary::derive(
&self.paths.run,
&self.launch(),
&Store {
state: &folded.state,
name: self.name.clone(),
event_count: folded.events,
last_event_kind: folded.last_event_kind.clone(),
timing: &folded.aggregate.finish(&self.paths.run, &folded.state),
judged: &folded.judged,
},
(self.accounted, journal_stamp(&self.paths).1),
);
let _ = ledger::write_json(&self.paths.summary(), &summary);
}
fn launch(&self) -> LaunchRecord {
ledger::read_json_opt::<LaunchRecord>(&self.paths.launch()).unwrap_or(LaunchRecord {
run_id: self.paths.run.clone(),
project: String::new(),
dir: PathBuf::new(),
graph: String::new(),
graph_run: String::new(),
observer_runs: Vec::new(),
observer_ending: String::new(),
node_graph: String::new(),
pr_author_graph: String::new(),
node_validator: String::new(),
envelope_reviewer: String::new(),
launcher: crate::sys::UNKNOWN_LAUNCHER.to_string(),
session: String::new(),
pid: 0,
host: String::new(),
started: String::new(),
started_at: String::new(),
heartbeat_interval: 0,
writeback_item_budget: 0,
success_hook: String::new(),
failure_hook: String::new(),
hook_timeout: 0,
dispatch_env_hook: String::new(),
dispatch_env_hook_timeout: 0,
dag_sets: Vec::new(),
node_sets: Vec::new(),
adoptions: 0,
filters: crate::filter::Filters::default(),
bus_config: Default::default(),
maintenance_config: None,
oneharness_sessions: None,
envelope_reviewer_bar: Default::default(),
})
}
}
pub(crate) fn seal(paths: &RunPaths) {
Maintainer::of(paths).write();
}
fn seq_reached(reached: &mut BTreeMap<String, u64>, event: &Envelope) {
let held = reached.entry(event.stream.clone()).or_insert(event.seq);
*held = (*held).max(event.seq);
}
#[derive(Debug, PartialEq, Eq)]
enum Rebuilt {
No,
Yes,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{EventKind, Labels, Source, ENVELOPE_VERSION};
use crate::journal::{Journal, PipelineKind};
use crate::plan::{Node, Plan, PLAN_SCHEMA_VERSION};
use crate::sys;
use serde_json::json;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("onepipeline-summary-{name}-{}", sys::pid()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a scratch root");
dir
}
fn plan(nodes: &[&str]) -> Plan {
Plan {
schema_version: PLAN_SCHEMA_VERSION,
goal: Some(crate::plan::Goal {
text: "list a host without folding it".into(),
}),
name: Some("demo".into()),
concurrency: 4,
tasks: nodes
.iter()
.map(|id| Node {
id: (*id).to_string(),
persona: Some("engineer".into()),
task: Some("## What\ndo it".into()),
..Node::default()
})
.collect(),
}
}
fn a_run(root: &Path, run: &str) -> RunPaths {
let paths = RunPaths::under(root, run);
paths.create().expect("the run directory");
let mut record = LaunchRecord {
run_id: run.to_string(),
project: "plans:demo".into(),
dir: PathBuf::from("/tmp/launch"),
graph: String::new(),
graph_run: String::new(),
observer_runs: Vec::new(),
observer_ending: String::new(),
node_graph: String::new(),
pr_author_graph: String::new(),
node_validator: String::new(),
envelope_reviewer: String::new(),
launcher: "e2e".into(),
session: "a-session".into(),
pid: 0,
host: String::new(),
started: String::new(),
started_at: sys::now_rfc3339(),
heartbeat_interval: 1_800,
writeback_item_budget: 0,
success_hook: String::new(),
failure_hook: String::new(),
hook_timeout: 0,
dispatch_env_hook: String::new(),
dispatch_env_hook_timeout: 0,
dag_sets: Vec::new(),
node_sets: Vec::new(),
adoptions: 0,
filters: crate::filter::Filters::default(),
bus_config: Default::default(),
maintenance_config: None,
oneharness_sessions: None,
envelope_reviewer_bar: Default::default(),
};
record.driven_by_this_process();
ledger::write_json(&paths.launch(), &record).expect("a launch record");
paths
}
fn emit(journal: &mut Journal, kind: PipelineKind, node: Option<&str>, run: &str) {
journal
.emit(
kind,
crate::journal::labels(run, node),
crate::journal::payload(&[("status", json!("done"))]),
)
.expect("appended");
}
fn recorded(root: &Path, run: &str, records: usize) -> RunPaths {
let paths = a_run(root, run);
let mut journal = Journal::open(&paths);
journal
.emit(
PipelineKind::RunStarted,
crate::journal::labels(run, None),
crate::journal::payload(&[("plan", json!(plan(&["build", "ship"])))]),
)
.expect("appended");
for nth in 0..records {
emit(
&mut journal,
PipelineKind::NodeReady,
Some(if nth % 2 == 0 { "build" } else { "ship" }),
run,
);
}
paths
}
fn cost_of(paths: &RunPaths) -> (RunSummary, u64) {
let before = ledger::bytes_read();
let summary = RunSummary::of(paths).expect("the run reads");
(summary, ledger::bytes_read() - before)
}
#[test]
fn a_summary_read_is_bounded_and_the_fold_it_replaces_is_not() {
let root = scratch("bounded");
let small = recorded(&root, "small", 10);
let large = recorded(&root, "large", 10_000);
assert!(
std::fs::metadata(large.journal()).expect("a store").len()
> 100 * std::fs::metadata(small.journal()).expect("a store").len(),
"the two stores are not orders of magnitude apart"
);
let (small_row, small_cost) = cost_of(&small);
let (large_row, large_cost) = cost_of(&large);
assert_eq!(small_row.event_count, 11);
assert_eq!(large_row.event_count, 10_001);
assert!(
large_cost < 2 * small_cost,
"reading the larger run's summary cost {large_cost} bytes against \
{small_cost} for a store a thousandth the size"
);
let folded = |paths: &RunPaths| {
std::fs::remove_file(paths.summary()).expect("the document");
cost_of(paths).1
};
let small_fold = folded(&small);
let large_fold = folded(&large);
assert!(
large_fold > 100 * small_fold,
"the fold this replaces cost {large_fold} against {small_fold}, so the \
measurement above is not measuring what a listing reads"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_run_with_no_summary_folds_once_and_caches_what_it_folded() {
let root = scratch("fallback");
let paths = recorded(&root, "demo", 40);
let maintained = RunSummary::of(&paths).expect("the run reads");
std::fs::remove_file(paths.summary()).expect("the document");
let (folded, fold_cost) = cost_of(&paths);
assert_eq!(
folded, maintained,
"the row a fold produces differs from the row the writer maintained"
);
assert!(
paths.summary().is_file(),
"the fold was not cached, so every later reader folds again"
);
let (again, cached_cost) = cost_of(&paths);
assert_eq!(again, folded);
assert!(
cached_cost < fold_cost,
"the cached read cost {cached_cost} against a fold's {fold_cost}"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_stale_summary_is_refolded_rather_than_served() {
let root = scratch("stale");
let paths = recorded(&root, "demo", 6);
let served = RunSummary::of(&paths).expect("the run reads");
assert_eq!(served.event_count, 7);
let mut appended = event(PipelineKind::NodeReady, "demo", "other-stream", 0);
appended.ts = sys::now_rfc3339();
ledger::append_line(
&paths.journal(),
&serde_json::to_string(&appended).expect("a record"),
)
.expect("appended");
let refolded = RunSummary::of(&paths).expect("the run reads");
assert_eq!(
refolded.event_count, 8,
"a summary that predates a record in the store was served anyway"
);
let store = std::fs::read(paths.journal()).expect("the store");
let len = store.len();
std::thread::sleep(std::time::Duration::from_millis(1_100));
std::fs::write(paths.journal(), &store[..len - 1]).expect("a store rewritten in place");
std::fs::write(paths.journal(), [&store[..len - 1], b"\n"].concat())
.expect("a store rewritten to its own length");
assert_eq!(
std::fs::metadata(paths.journal()).expect("the store").len() as usize,
len,
"the rewrite changed the length, so this is not the case under test"
);
let served = RunSummary::of(&paths).expect("the run reads");
assert_eq!(
(served.journal_len, served.journal_mtime_ms),
journal_stamp(&paths),
"a summary written against a store that has since been rewritten was served"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_run_still_recording_stays_current_and_stays_bounded() {
let root = scratch("growing");
let paths = a_run(&root, "demo");
let mut journal = Journal::open(&paths);
journal
.emit(
PipelineKind::RunStarted,
crate::journal::labels("demo", None),
crate::journal::payload(&[("plan", json!(plan(&["build"])))]),
)
.expect("appended");
let mut costs = Vec::new();
let mut written = 1;
for round in 1..=4 {
for _ in 0..(round * 500) {
emit(&mut journal, PipelineKind::NodeReady, Some("build"), "demo");
written += 1;
}
let (row, cost) = cost_of(&paths);
assert_eq!(
row.event_count, written,
"the served summary is behind the store it describes"
);
assert_eq!(
row.last_event_kind.as_deref(),
Some(PipelineKind::NodeReady.as_str())
);
costs.push(cost);
}
let (first, last) = (costs[0], costs[costs.len() - 1]);
assert!(
last < 2 * first,
"serving the summary grew with the journal: {costs:?}"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_record_stamped_behind_the_newest_instant_is_reread_rather_than_folded_onto_the_end() {
let root = scratch("out-of-order");
let paths = recorded(&root, "demo", 4);
let mut behind = event(PipelineKind::NodeReady, "demo", "a-sibling", 7);
behind.source = Source::Agentgraph;
behind.kind = EventKind("turn-completed".into());
behind.ts = "2020-01-01T00:00:00.000Z".into();
behind
.payload
.insert("usage".into(), json!({"input_tokens": 11}));
let mut journal = Journal::open(&paths);
journal.relay(&behind).expect("relayed");
let served = RunSummary::of(&paths).expect("the run reads");
std::fs::remove_file(paths.summary()).expect("the document");
let folded = RunSummary::of(&paths).expect("the run reads");
assert_eq!(
served, folded,
"a record stamped behind the newest instant left the two accounts apart"
);
assert!(
folded.timing.wall_ms > 0,
"a record stamped years before the store left no wall clock"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_producer_publishing_out_of_its_own_seq_order_leaves_one_account_not_two() {
let root = scratch("out-of-seq");
let closed_by_another = |also: bool| -> Vec<(&'static str, u64, &'static str, bool)> {
let mut relayed = vec![
("a-sibling", 2, "turn-activity", false),
("a-sibling", 3, "turn-message", false),
("a-sibling", 10, "member-started", true),
("a-sibling", 4, "turn-completed", false),
];
if also {
relayed.push(("b-sibling", 0, "turn-activity", false));
}
relayed.push(("a-sibling", 5, crate::report::MEMBER_SETTLED, false));
relayed
};
for (run, relayed) in [
("still-open", closed_by_another(false)),
("already-closed", closed_by_another(true)),
] {
let paths = a_run(&root, run);
let mut engine = Journal::open(&paths);
engine
.emit(
PipelineKind::RunStarted,
crate::journal::labels(run, None),
crate::journal::payload(&[("plan", json!(plan(&["build"])))]),
)
.expect("appended");
emit(
&mut engine,
PipelineKind::NodeDispatched,
Some("build"),
run,
);
let mut relay = Journal::open(&paths);
let base = sys::now_millis();
let instant = sys::rfc3339_from_millis(base);
let later = sys::rfc3339_from_millis(base + 5);
for (stream, seq, kind, judge) in relayed {
let mut record = event(PipelineKind::NodeReady, run, stream, seq);
record.source = Source::Agentgraph;
record.kind = EventKind(kind.into());
record.ts = if seq == 5 || stream == "b-sibling" {
later.clone()
} else {
instant.clone()
};
if judge {
record.payload.insert("role".into(), json!("judge"));
}
relay.relay(&record).expect("relayed");
}
std::thread::sleep(std::time::Duration::from_millis(30));
emit(&mut engine, PipelineKind::NodeSettled, Some("build"), run);
let served = RunSummary::of(&paths).expect("the run reads");
std::fs::remove_file(paths.summary()).expect("the document");
assert_eq!(
served,
RunSummary::of(&paths).expect("the run folds"),
"on '{run}' a producer's out-of-order `seq` left the maintained row \
and the folded row apart"
);
}
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn two_writers_on_one_journal_keep_the_summary_current_without_rereading_the_store() {
let root = scratch("two-writers");
let paths = recorded(&root, "demo", 400);
let store = std::fs::metadata(paths.journal()).expect("a store").len();
let mut engine = Journal::open(&paths);
let mut relay = Journal::open(&paths);
let before = ledger::bytes_read();
const ROUNDS: usize = 40;
for nth in 0..ROUNDS {
emit(&mut engine, PipelineKind::NodeReady, Some("build"), "demo");
let mut relayed = event(PipelineKind::NodeReady, "demo", "a-sibling", nth as u64);
relayed.source = Source::Agentgraph;
relayed.kind = EventKind("turn-completed".into());
relay.relay(&relayed).expect("relayed");
}
let maintaining = ledger::bytes_read() - before;
let rereading = store * (ROUNDS as u64) * 2;
assert!(
maintaining * 4 < rereading,
"keeping the document current across {} interleaved appends read {maintaining} \
bytes, against {rereading} for reading a store of {store} again each time: \
the writer is re-reading what it already holds",
ROUNDS * 2
);
let served = RunSummary::of(&paths).expect("the run reads");
assert_eq!(served.event_count as usize, 401 + ROUNDS * 2);
std::fs::remove_file(paths.summary()).expect("the document");
assert_eq!(
served,
RunSummary::of(&paths).expect("the run folds"),
"two writers left the maintained row and the folded row apart"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn a_listing_reports_the_roots_it_could_not_read() {
let root = scratch("listing");
recorded(&root, "readable", 3);
recorded(&root, "also-readable", 3);
std::fs::create_dir_all(root.join("half-written")).expect("a run root with no launch");
let listing = Listing::of(&root);
let named: Vec<&str> = listing
.summaries
.iter()
.map(|row| row.run_id.as_str())
.collect();
assert_eq!(named.len(), 2, "{named:?}");
assert!(named.contains(&"readable"));
let survey = crate::views::Survey::of(&root);
assert_eq!(
listing
.skipped
.iter()
.map(|root| (root.path.clone(), root.reason.clone()))
.collect::<Vec<_>>(),
survey
.skipped
.iter()
.map(|root| (root.path.clone(), root.reason.clone()))
.collect::<Vec<_>>(),
"the bounded listing and the survey report different refusals"
);
std::fs::remove_dir_all(&root).ok();
}
const GOLDEN: &str = include_str!("../tests/golden/run-summary-v7.json");
const GOLDEN_EARLIER: [(u32, &str); 6] = [
(1, include_str!("../tests/golden/run-summary-v1.json")),
(2, include_str!("../tests/golden/run-summary-v2.json")),
(3, include_str!("../tests/golden/run-summary-v3.json")),
(4, include_str!("../tests/golden/run-summary-v4.json")),
(5, include_str!("../tests/golden/run-summary-v5.json")),
(6, include_str!("../tests/golden/run-summary-v6.json")),
];
fn golden() -> RunSummary {
RunSummary {
schema_version: SUMMARY_SCHEMA_VERSION,
run_id: "golden".into(),
last_write_at: Some(1_786_000_000_000),
last_event_kind: Some("node-settled".into()),
event_count: 42,
node_counts: BTreeMap::from([("done".to_string(), 2), ("failed".to_string(), 1)]),
stop_recorded: false,
graph_complete: true,
decisions_pending: 0,
surfaces_queued: 2,
surfaces_read: 1,
awaiting_human_action: false,
project: "plans:golden".into(),
name: Some("Golden".into()),
launcher: "claude-code".into(),
session: String::new(),
started_at: None,
pid: None,
host: None,
started: None,
let_go_by: Some(
crate::projection::DriverClaim::of_stream("golden-host-4242")
.expect("the golden stream names a driver"),
),
timing: serde_json::from_str(include_str!("../tests/golden/telemetry-v2.json"))
.expect("the telemetry golden reads back into the types"),
oneharness_sessions: Some(PathBuf::from("/runs/golden/oneharness-sessions.jsonl")),
parked: Vec::new(),
judge_rejected: vec!["publish".into()],
landings: BTreeMap::from([
(
"build".to_string(),
NodeLanding {
landing: "landed".into(),
branch: None,
repo: None,
drafted: false,
},
),
(
"publish".to_string(),
NodeLanding {
landing: "unlanded".into(),
branch: Some("onepipeline/golden".into()),
repo: Some("nickderobertis/onepipeline".into()),
drafted: false,
},
),
]),
journal_len: 8_192,
journal_mtime_ms: 1_786_000_000_100,
}
}
#[test]
fn a_schema_7_document_is_the_shape_the_golden_pins() {
let rendered = serde_json::to_string_pretty(&golden()).expect("it serialises");
assert_eq!(
rendered.trim(),
GOLDEN.trim(),
"the summary document changed shape. If that was deliberate, bump \
SUMMARY_SCHEMA_VERSION and update tests/golden/run-summary-v7.json together"
);
}
#[test]
fn the_documents_earlier_builds_wrote_are_refused_rather_than_read() {
for (version, earlier) in GOLDEN_EARLIER {
let refused = serde_json::from_str::<RunSummary>(earlier).expect_err("it is refused");
assert!(
refused
.to_string()
.contains(&format!("schema_version {version}")),
"the refusal does not name the version it met: {refused}"
);
}
}
#[test]
fn a_schema_7_document_round_trips_and_a_version_this_build_does_not_read_is_refused() {
let read: RunSummary =
serde_json::from_str(GOLDEN).expect("the golden reads back into the types");
assert_eq!(read, golden());
assert_eq!(read.started_at, None);
assert_eq!(read.pid, None);
assert_eq!(read.host, None);
assert_eq!(read.started, None);
assert!(read.session.is_empty());
assert!(read.parked.is_empty());
assert_eq!(read.landings["build"].branch, None);
assert_eq!(
read.landings["publish"].branch.as_deref(),
Some("onepipeline/golden")
);
let mut later: serde_json::Value = serde_json::from_str(GOLDEN).expect("it parses");
later["schema_version"] = json!(SUMMARY_SCHEMA_VERSION + 1);
let refused = serde_json::from_value::<RunSummary>(later).expect_err("it is refused");
assert!(
refused.to_string().contains("schema_version"),
"the refusal does not name the version: {refused}"
);
}
#[test]
fn a_summary_from_a_schema_this_build_does_not_read_folds_rather_than_vanishes() {
let root = scratch("later-schema");
let paths = recorded(&root, "demo", 5);
let mut document: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(paths.summary()).expect("the document"))
.expect("a summary");
document["schema_version"] = json!(SUMMARY_SCHEMA_VERSION + 1);
std::fs::write(paths.summary(), document.to_string()).expect("a later build's summary");
let served = RunSummary::of(&paths).expect("the run reads");
assert_eq!(served.schema_version, SUMMARY_SCHEMA_VERSION);
assert_eq!(served.event_count, 6);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn the_seal_leaves_a_document_current_for_the_journal_as_it_stands() {
let root = scratch("sealed");
let paths = recorded(&root, "demo", 2);
let mut journal = Journal::open(&paths);
emit(
&mut journal,
PipelineKind::NodeSettled,
Some("build"),
"demo",
);
emit(
&mut journal,
PipelineKind::NodeSettled,
Some("ship"),
"demo",
);
let stray = event(PipelineKind::PlannerSurfaced, "demo", "another-writer", 0);
let line = serde_json::to_string(&stray).expect("a record");
ledger::append_line_healed(&paths.journal(), &line).expect("appended");
let behind: RunSummary =
serde_json::from_str(&std::fs::read_to_string(paths.summary()).expect("the document"))
.expect("a summary");
assert_ne!(
(behind.journal_len, behind.journal_mtime_ms),
journal_stamp(&paths),
"the staged document is not behind its journal, so the seal has nothing to prove"
);
seal(&paths);
let sealed: RunSummary =
serde_json::from_str(&std::fs::read_to_string(paths.summary()).expect("the document"))
.expect("a summary");
assert_eq!(
(sealed.journal_len, sealed.journal_mtime_ms),
journal_stamp(&paths)
);
assert!(sealed.graph_complete, "{sealed:?}");
assert_eq!(sealed.event_count, 6);
let (served, bytes) = cost_of(&paths);
assert_eq!(served, sealed);
assert!(
bytes < sealed.journal_len,
"the sealed document was folded rather than served: {bytes} bytes read"
);
std::fs::remove_dir_all(&root).ok();
}
fn event(kind: PipelineKind, run: &str, stream: &str, seq: u64) -> Envelope {
Envelope {
v: ENVELOPE_VERSION,
ts: sys::now_rfc3339(),
stream: stream.to_string(),
seq,
source: Source::Pipeline,
kind: EventKind(kind.as_str().into()),
dimensions: Default::default(),
labels: Labels {
run_id: Some(run.to_string()),
node: Some("build".into()),
..Labels::default()
},
payload: crate::journal::payload(&[("status", json!("done"))]),
artifacts: Vec::new(),
}
}
}