use std::io::Write;
use std::time::{Duration, Instant};
use crate::cli::{WatchArgs, WatchUntil, WATCH_CURSOR_VERSION};
use crate::error::{
Error, Result, EXIT_NOTHING_DRIVING, EXIT_SUCCESS, EXIT_SURFACE_WAITING, EXIT_WATCH_ELAPSED,
};
use crate::event::{Envelope, PipelineKind, Source};
use crate::filter::EventFilter;
use crate::graph::{self, GraphState};
use crate::journal;
use crate::ledger::RunPaths;
use crate::views::{self, RunView, Unread};
const POLL: Duration = Duration::from_secs(1);
const MEANINGFUL: [PipelineKind; 8] = [
PipelineKind::EditCommitted,
PipelineKind::EditRejected,
PipelineKind::NodeSettled,
PipelineKind::PlannerSurfaceQueued,
PipelineKind::DecisionPending,
PipelineKind::DecisionCleared,
PipelineKind::CompletionRequested,
PipelineKind::RunStopped,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Ending {
Settled,
SurfaceWaiting,
NothingDriving,
Elapsed,
}
impl Ending {
const fn as_str(self) -> &'static str {
match self {
Self::Settled => "settled",
Self::SurfaceWaiting => "surface-waiting",
Self::NothingDriving => "nothing-driving",
Self::Elapsed => "elapsed",
}
}
const fn exit_code(self) -> i32 {
match self {
Self::Settled => EXIT_SUCCESS,
Self::SurfaceWaiting => EXIT_SURFACE_WAITING,
Self::NothingDriving => EXIT_NOTHING_DRIVING,
Self::Elapsed => EXIT_WATCH_ELAPSED,
}
}
}
impl serde::Serialize for Ending {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut record = serializer.serialize_map(Some(2))?;
record.serialize_entry("condition", self.as_str())?;
record.serialize_entry("exit", &self.exit_code())?;
record.end()
}
}
pub(crate) fn watch(args: &WatchArgs, paths: &RunPaths, filter: &EventFilter) -> Result<i32> {
let mut cursor = match args.cursor.as_deref() {
Some(token) => resolve_cursor(paths, token)?,
None => Cursor::start(&paths.run),
};
let deadline = Instant::now()
.checked_add(Duration::from_secs(args.timeout))
.ok_or_else(|| {
Error::Invalid(format!(
"a wait of {} seconds is further ahead than this host's clock can name; \
give `--timeout` a value it can reach",
args.timeout
))
})?;
let tick = Duration::from_secs(args.tick_interval);
let mut quiet_since = Instant::now();
let mut out = Emitter::new();
loop {
let view = RunView::open(paths)?;
let (mut fresh, at) = journal::finished_after(&paths.journal(), cursor.at);
cursor.at = at;
journal::merge_order(&mut fresh);
for event in fresh
.iter()
.filter(|event| meaningful(event) && filter.matches(event))
{
out.event(&view, event)?;
quiet_since = Instant::now();
}
if let Some(ending) = concluded(&view, paths, args.until) {
return out.returned(&view, ending, &cursor);
}
if Instant::now() >= deadline {
return out.returned(&view, Ending::Elapsed, &cursor);
}
if !tick.is_zero() && quiet_since.elapsed() >= tick {
out.heartbeat(&view)?;
quiet_since = Instant::now();
}
std::thread::sleep(POLL);
}
}
fn meaningful(event: &Envelope) -> bool {
event.source == Source::Pipeline
&& PipelineKind::from_wire(&event.kind).is_some_and(|kind| MEANINGFUL.contains(&kind))
}
fn concluded(view: &RunView, paths: &RunPaths, until: WatchUntil) -> Option<Ending> {
let statuses = view.state.statuses();
if !statuses.is_empty() && graph::state_of(&statuses) == GraphState::Complete {
return Some(Ending::Settled);
}
if view.liveness().is_undriven() {
return Some(Ending::NothingDriving);
}
if until == WatchUntil::Surface && views::blocking_surface(paths) {
return Some(Ending::SurfaceWaiting);
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Cursor {
run: String,
at: u64,
}
impl Cursor {
fn start(run: &str) -> Self {
Self {
run: run.to_string(),
at: 0,
}
}
}
impl std::fmt::Display for Cursor {
fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(out, "{WATCH_CURSOR_VERSION}:{}:{}", self.run, self.at)
}
}
impl serde::Serialize for Cursor {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
fn resolve_cursor(paths: &RunPaths, token: &str) -> Result<Cursor> {
let Cursor { run, at } = parse_cursor(token)?;
if run != paths.run {
return Err(Error::Invalid(format!(
"cursor '{token}' was printed by a watch of run '{run}', and this is a watch of run '{}'; a cursor is only readable by the run it was printed for",
paths.run
)));
}
let journal = paths.journal();
let held = match std::fs::metadata(&journal) {
Ok(file) => file.len(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
Err(error) => {
return Err(Error::Invalid(format!(
"cursor '{token}' resumes at byte {at} of run '{}', whose journal could not \
be read ({error}); a cursor is checked against the journal it names, and \
one that cannot be read is refused rather than resumed from",
paths.run
)))
}
};
if at > held {
return Err(Error::Invalid(format!(
"cursor '{token}' resumes at byte {at} of run '{}', whose store holds {held}; \
a cursor is only readable by the run the watch that printed it was watching",
paths.run
)));
}
if at > 0 && !ends_a_record(&journal, at) {
return Err(Error::Invalid(format!(
"cursor '{token}' resumes at byte {at} of run '{}', which is inside a record \
rather than after one; a cursor is what an earlier `onepipeline watch` \
printed, and never a byte count of its own",
paths.run
)));
}
Ok(Cursor { run, at })
}
fn ends_a_record(journal: &std::path::Path, at: u64) -> bool {
use std::io::{Read, Seek, SeekFrom};
let Ok(mut file) = std::fs::File::open(journal) else {
return false;
};
if file.seek(SeekFrom::Start(at - 1)).is_err() {
return false;
}
let mut last = [0u8; 1];
file.read_exact(&mut last).is_ok() && last[0] == b'\n'
}
fn parse_cursor(token: &str) -> Result<Cursor> {
let refusal = || {
Error::Invalid(format!(
"'{token}' is not a cursor this build reads; a cursor is what an earlier \
`onepipeline watch` printed, spelled `{WATCH_CURSOR_VERSION}:<run>:<byte>`"
))
};
let (version, rest) = token.split_once(':').ok_or_else(refusal)?;
if version != WATCH_CURSOR_VERSION {
return Err(refusal());
}
let (run, at) = rest.rsplit_once(':').ok_or_else(refusal)?;
if run.is_empty() {
return Err(refusal());
}
Ok(Cursor {
run: run.to_string(),
at: at.parse().map_err(|_| refusal())?,
})
}
#[derive(Debug, serde::Serialize)]
#[serde(tag = "watch", rename_all = "kebab-case")]
enum Record<'a> {
Event { event: &'a Envelope },
Heartbeat {
run_id: &'a str,
unread: UnreadRecord<'a>,
},
Return {
run_id: &'a str,
#[serde(flatten)]
ending: Ending,
cursor: &'a Cursor,
unread: UnreadRecord<'a>,
},
}
#[derive(Debug, serde::Serialize)]
struct UnreadRecord<'a> {
count: usize,
oldest_seconds: Option<u64>,
kinds: Vec<UnreadKind<'a>>,
}
#[derive(Debug, serde::Serialize)]
struct UnreadKind<'a> {
kind: &'a str,
count: usize,
}
impl<'a> UnreadRecord<'a> {
fn of(unread: &'a Unread) -> Self {
Self {
count: unread.count,
oldest_seconds: unread.oldest_seconds,
kinds: unread
.kinds
.iter()
.map(|(kind, count)| UnreadKind {
kind,
count: *count,
})
.collect(),
}
}
}
struct Emitter {
machine: std::io::Stdout,
human: std::io::Stderr,
}
impl Emitter {
fn new() -> Self {
Self {
machine: std::io::stdout(),
human: std::io::stderr(),
}
}
fn event(&mut self, view: &RunView, event: &Envelope) -> Result<()> {
self.say(&views::event_line(view, event), &Record::Event { event })
}
fn heartbeat(&mut self, view: &RunView) -> Result<()> {
let unread = view.unread();
self.say(
&format!(
"-- watching {} {} {}",
view.paths.run,
views::liveness_word(view),
unread_phrase(&unread)
),
&Record::Heartbeat {
run_id: &view.paths.run,
unread: UnreadRecord::of(&unread),
},
)
}
fn returned(&mut self, view: &RunView, ending: Ending, cursor: &Cursor) -> Result<i32> {
let unread = view.unread();
self.say(
&format!(
"-- watch {} {} {} cursor {cursor}",
view.paths.run,
ending.as_str(),
unread_phrase(&unread)
),
&Record::Return {
run_id: &view.paths.run,
ending,
cursor,
unread: UnreadRecord::of(&unread),
},
)?;
Ok(ending.exit_code())
}
fn say(&mut self, human: &str, machine: &Record<'_>) -> Result<()> {
let broken = |what: &str, error: std::io::Error| {
Error::Invalid(format!("the watch could not write to {what}: {error}"))
};
let machine = serde_json::to_string(machine)
.map_err(|e| Error::Invalid(format!("the watch could not render a record: {e}")))?;
writeln!(self.human, "{human}").map_err(|e| broken("standard error", e))?;
self.human
.flush()
.map_err(|e| broken("standard error", e))?;
writeln!(self.machine, "{machine}").map_err(|e| broken("standard output", e))?;
self.machine
.flush()
.map_err(|e| broken("standard output", e))?;
Ok(())
}
}
fn unread_phrase(unread: &Unread) -> String {
match unread.count {
0 => "0 unread planner surfaces".to_string(),
count => format!("{count} unread planner surface(s): {}", unread.phrase()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn divergence_entry() -> String {
let record = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("docs")
.join("contract-divergences.md"),
)
.expect("the divergence record ships");
let entry = record
.split_once("\n## 58.")
.expect("this verb is recorded under entry 58")
.1
.to_string();
entry
.split_once("\n## ")
.map_or(entry.clone(), |(head, _)| head.to_string())
}
#[test]
fn each_terminal_condition_returns_a_status_of_its_own() {
let endings = [
Ending::Settled,
Ending::SurfaceWaiting,
Ending::NothingDriving,
Ending::Elapsed,
];
let codes: std::collections::BTreeSet<i32> =
endings.iter().map(|end| end.exit_code()).collect();
assert_eq!(codes.len(), endings.len(), "two endings share a status");
assert_eq!(Ending::Settled.exit_code(), EXIT_SUCCESS);
assert_eq!(Ending::NothingDriving.exit_code(), EXIT_NOTHING_DRIVING);
assert_eq!(Ending::SurfaceWaiting.exit_code(), EXIT_SURFACE_WAITING);
assert_eq!(Ending::Elapsed.exit_code(), EXIT_WATCH_ELAPSED);
for ending in endings {
let rendered = serde_json::to_value(ending).expect("an ending serializes");
assert_eq!(rendered["condition"], serde_json::json!(ending.as_str()));
assert_eq!(rendered["exit"], serde_json::json!(ending.exit_code()));
}
}
#[test]
fn a_cursor_round_trips_and_anything_else_is_refused() {
let cursor = Cursor {
run: "demo".to_string(),
at: 4096,
};
assert_eq!(parse_cursor(&cursor.to_string()).expect("reads"), cursor);
assert_eq!(
serde_json::to_value(&cursor).expect("a cursor serializes"),
serde_json::json!("1:demo:4096")
);
let colonised = Cursor {
run: "demo:2".to_string(),
at: 8,
};
assert_eq!(
parse_cursor(&colonised.to_string()).expect("reads"),
colonised
);
for token in [
"",
"4096",
"1:4096",
"2:demo:4096",
"1:demo:",
"1:demo:x",
"1:demo:-1",
"1::4096",
] {
let refused = parse_cursor(token).expect_err("refused");
assert!(
refused
.to_string()
.contains("is not a cursor this build reads"),
"{token:?}: {refused}"
);
}
}
#[test]
fn the_divergence_entry_names_exactly_the_kinds_this_build_calls_meaningful() {
let entry = divergence_entry();
for kind in crate::event::PIPELINE_KINDS {
let named = entry.contains(&format!("`{}`", kind.as_str()));
assert_eq!(
named,
MEANINGFUL.contains(kind),
"the entry and this build disagree about whether `{}` is a kind a watch \
emits",
kind.as_str()
);
}
for stated in [
format!("(default {})", crate::cli::DEFAULT_WATCH_TIMEOUT_SECONDS),
format!("(default {})", crate::cli::DEFAULT_WATCH_TICK_SECONDS),
format!("`{WATCH_CURSOR_VERSION}:<run>:<byte>`"),
format!("`{}`", Ending::Settled.exit_code()),
format!("`{}`", Ending::NothingDriving.exit_code()),
format!("`{}`", Ending::SurfaceWaiting.exit_code()),
format!("`{}`", Ending::Elapsed.exit_code()),
] {
assert!(
entry.contains(&stated),
"the entry no longer states {stated}, which this build does"
);
}
}
#[test]
fn the_divergence_entry_proposes_exactly_the_flags_this_build_offers() {
use clap::CommandFactory;
let entry = divergence_entry();
let schema = entry
.split_once("add `onepipeline watch")
.expect("the entry proposes the command")
.1
.split_once('`')
.expect("the proposed command is one fenced span")
.0;
let proposed: std::collections::BTreeSet<String> = schema
.split_whitespace()
.filter_map(|word| {
word.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-')
.strip_prefix("--")
.map(str::to_string)
})
.collect();
let offered: std::collections::BTreeSet<String> = crate::cli::Cli::command()
.get_subcommands()
.find(|sub| sub.get_name() == "watch")
.expect("the binary offers `watch`")
.get_arguments()
.filter_map(|arg| arg.get_long().map(str::to_string))
.collect();
assert_eq!(
proposed, offered,
"the entry proposes a different set of flags than this build offers"
);
assert!(
!offered.contains("heartbeat-interval"),
"`watch` took `start`'s pacemaker flag"
);
}
#[test]
fn the_divergence_entry_names_the_records_the_machine_form_actually_writes() {
let entry = divergence_entry();
let unread = Unread::default();
let written = [
Record::Heartbeat {
run_id: "demo",
unread: UnreadRecord::of(&unread),
},
Record::Return {
run_id: "demo",
ending: Ending::Settled,
cursor: &Cursor::start("demo"),
unread: UnreadRecord::of(&unread),
},
];
for shape in &written {
let rendered = serde_json::to_value(shape).expect("the record serializes");
let tag = rendered["watch"].as_str().expect("every record is tagged");
assert!(
entry.contains(&format!("\"watch\":\"{tag}\"")),
"the entry describes no `{tag}` record, which this build writes"
);
let shown = entry
.split_once(&format!("{{\"watch\":\"{tag}\""))
.unwrap_or_else(|| panic!("the entry shows the `{tag}` record"))
.1
.split_once('}')
.unwrap_or_else(|| panic!("the entry's `{tag}` record is closed"))
.0;
let written: std::collections::BTreeSet<&str> = rendered
.as_object()
.expect("a record is an object")
.keys()
.map(String::as_str)
.filter(|key| *key != "watch")
.collect();
for key in &written {
assert!(
shown.contains(&format!("\"{key}\":")),
"the entry's `{tag}` record does not carry `{key}`, which this build writes"
);
}
for shown_key in shown.split('"').skip(1).step_by(2) {
assert!(
shown_key == "watch" || written.contains(shown_key),
"the entry's `{tag}` record carries `{shown_key}`, which this build does \
not write"
);
}
}
assert!(
entry.contains("\"watch\":\"event\"") && entry.contains("\"event\":"),
"the entry describes no `event` record, which this build writes"
);
}
fn readme_watch_passage() -> String {
let readme = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("README.md"),
)
.expect("the README ships");
readme
.split_once("`onepipeline watch RUN` is the bounded wait")
.expect("the README documents this verb")
.1
.split_once("\n## ")
.expect("that passage ends where the README's next heading begins")
.0
.to_string()
}
#[test]
fn the_readme_passage_names_every_meaningful_kind_and_every_record_this_verb_writes() {
let passage = readme_watch_passage();
for kind in crate::event::PIPELINE_KINDS {
let named = passage.contains(&format!("`{}`", kind.as_str()));
assert_eq!(
named,
MEANINGFUL.contains(kind),
"the README's watch passage names `{}` ({named}), and this build calls it \
meaningful ({})",
kind.as_str(),
MEANINGFUL.contains(kind)
);
}
let unread = Unread::default();
for shape in [
Record::Heartbeat {
run_id: "demo",
unread: UnreadRecord::of(&unread),
},
Record::Return {
run_id: "demo",
ending: Ending::Settled,
cursor: &Cursor::start("demo"),
unread: UnreadRecord::of(&unread),
},
] {
let rendered = serde_json::to_value(&shape).expect("the record serializes");
let tag = rendered["watch"].as_str().expect("every record is tagged");
assert!(
passage.contains(&format!("`{tag}`")),
"the README's watch passage describes no `{tag}` record, which this build writes"
);
for key in rendered
.as_object()
.expect("a record is an object")
.keys()
.filter(|key| *key != "watch")
{
assert!(
passage.contains(&format!("`{key}`")),
"the README's watch passage does not name `{key}`, which the `{tag}` \
record carries"
);
}
}
assert!(
passage.contains("`event`"),
"the README's watch passage describes no `event` record, which this build writes"
);
}
#[test]
fn a_wait_longer_than_the_clock_can_name_is_refused_rather_than_panicking() {
assert!(Instant::now()
.checked_add(Duration::from_secs(u64::MAX))
.is_none());
}
#[test]
fn an_empty_queue_says_so_rather_than_saying_nothing() {
let quiet = unread_phrase(&Unread::default());
assert!(quiet.contains('0'), "{quiet}");
let empty = Unread::default();
let rendered =
serde_json::to_value(UnreadRecord::of(&empty)).expect("the record serializes");
assert_eq!(rendered["count"], serde_json::json!(0));
assert_eq!(rendered["oldest_seconds"], serde_json::Value::Null);
}
}