use std::path::Path;
use crate::cli::UnwatchedArgs;
use crate::error::{Error, Result, EXIT_RUNS_UNWATCHED, EXIT_SUCCESS};
use crate::ledger::{self, LaunchRecord, RunPaths};
use crate::summary::RunSummary;
use crate::sys;
use crate::views;
use crate::watchers::Watchers;
const ARM_A_WATCH: &str = "watch it with: onepipeline watch";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unwatched {
pub reported: Vec<UnwatchedRun>,
pub unresolved: Vec<String>,
}
impl Unwatched {
pub const fn exit_code(&self) -> i32 {
if self.reported.is_empty() {
EXIT_SUCCESS
} else {
EXIT_RUNS_UNWATCHED
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnwatchedRun {
pub run: String,
pub standing: &'static str,
pub why_not_watched: String,
}
impl UnwatchedRun {
pub(crate) fn line(&self) -> String {
format!(
"{:<24} {:<12} {} — {ARM_A_WATCH} {}\n",
self.run, self.standing, self.why_not_watched, self.run
)
}
}
pub(crate) fn unwatched(root: &Path, session: &str) -> Result<Unwatched> {
let mut reported: Vec<UnwatchedRun> = Vec::new();
let owned = discover_owned_runs(root, session)?;
let mut unresolved: Vec<String> = owned.unresolved;
for paths in owned.runs {
match decide(&paths) {
Decided::Settled => {}
Decided::Undecidable(reason) => {
unresolved.push(format!("{}: {reason}\n", paths.run));
}
Decided::NotProvenSettled(summary) => {
let watchers = Watchers::of(&paths);
if watchers.any_live() {
continue;
}
for refused in &watchers.refused {
unresolved.push(format!(
"{}: a watcher record cannot be read, so it is not a live watch: {} — {}\n",
paths.run,
refused.path.display(),
refused.reason
));
}
reported.push(UnwatchedRun {
run: paths.run.clone(),
standing: views::summary_standing_word(root, &summary),
why_not_watched: watchers.why_not_watched(),
});
}
}
}
reported.sort_by(|a, b| a.run.cmp(&b.run));
unresolved.sort();
Ok(Unwatched {
reported,
unresolved,
})
}
pub(crate) fn session(args: &UnwatchedArgs) -> Result<String> {
args.session
.clone()
.or_else(|| std::env::var(sys::LAUNCHER_SESSION_ENV).ok())
.filter(|named| !named.trim().is_empty())
.ok_or_else(|| {
Error::Invalid(format!(
"no session to ask about: name one with `--session`, or export {} in the \
environment this runs in",
sys::LAUNCHER_SESSION_ENV
))
})
}
fn discover_owned_runs(root: &Path, session: &str) -> Result<Discovered> {
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Discovered::default())
}
Err(error) => {
return Err(Error::Ledger {
path: root.to_path_buf(),
source: error,
})
}
};
let mut owned = Discovered::default();
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(error) => {
owned.unresolved.push(format!(
"{}: an entry under the runs root cannot be read, so this look at it is \
incomplete: {error}\n",
root.display()
));
continue;
}
};
let Ok(name) = entry.file_name().into_string() else {
continue;
};
if !ledger::is_valid_run_id(&name) {
continue;
}
let paths = RunPaths::under(root, &name);
let Some(launch) = ledger::read_json_opt::<LaunchRecord>(&paths.launch()) else {
continue;
};
if launch.owned_by(session) {
owned.runs.push(paths);
}
}
Ok(owned)
}
#[derive(Default)]
struct Discovered {
runs: Vec<RunPaths>,
unresolved: Vec<String>,
}
enum Decided {
Settled,
NotProvenSettled(Box<RunSummary>),
Undecidable(String),
}
fn decide(paths: &RunPaths) -> Decided {
let text = match std::fs::read_to_string(paths.summary()) {
Ok(text) => text,
Err(error) => {
return Decided::Undecidable(format!(
"its settlement cannot be decided: its summary document could not be read: \
{error}"
))
}
};
let summary = match serde_json::from_str::<RunSummary>(&text) {
Ok(summary) => summary,
Err(refusal) => {
if crate::summary::version_this_build_moved_past(&text).is_some() {
return match RunSummary::of(paths) {
Ok(refreshed) => decided_from(paths, refreshed),
Err(error) => Decided::Undecidable(format!(
"its settlement cannot be decided: its summary document is at a \
schema this build has moved past, and refreshing it failed: {error}"
)),
};
}
return Decided::Undecidable(format!(
"its settlement cannot be decided: its summary document could not be read: \
{refusal}"
));
}
};
decided_from(paths, summary)
}
fn decided_from(paths: &RunPaths, summary: RunSummary) -> Decided {
if summary.run_id != paths.run {
return Decided::Undecidable(format!(
"its settlement cannot be decided: its summary document is run '{}'",
summary.run_id
));
}
if summary.stop_recorded || summary.graph_complete {
if stamped(paths, &summary) {
return Decided::Settled;
}
return Decided::Undecidable(
"its settlement cannot be decided: its document records that it settled but is \
behind its journal"
.to_string(),
);
}
Decided::NotProvenSettled(Box::new(summary))
}
fn stamped(paths: &RunPaths, summary: &RunSummary) -> bool {
let about = match std::fs::metadata(paths.journal()) {
Ok(about) => about,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return (summary.journal_len, summary.journal_mtime_ms) == (0, 0)
}
Err(_) => return false,
};
let Some(modified) = about
.modified()
.ok()
.and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|since| u64::try_from(since.as_millis()).ok())
else {
return false;
};
(summary.journal_len, summary.journal_mtime_ms) == (about.len(), modified)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::error::EXIT_REFUSED;
use crate::watchers::{WatchStanding, WatcherRecord, WATCHER_SCHEMA_VERSION};
use std::collections::BTreeSet;
pub(crate) 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## 68.")
.expect("this verb is recorded under entry 68")
.1
.to_string();
entry
.split_once("\n## ")
.map_or(entry.clone(), |(head, _)| head.to_string())
}
pub(crate) fn block() -> serde_json::Value {
let entry = divergence_entry();
let block = entry
.split_once("```json")
.expect("entry 68 carries the json block these tests drive")
.1
.split_once("```")
.expect("the block is fenced")
.0;
serde_json::from_str(block).expect("entry 68's block is JSON")
}
fn every_standing() -> [WatchStanding; 7] {
let all = [
WatchStanding::Live,
WatchStanding::AnotherRun,
WatchStanding::AnotherHost,
WatchStanding::ProcessGone,
WatchStanding::AwaitingItsParent,
WatchStanding::NotThatProcess,
WatchStanding::Unproven,
];
for standing in all {
match standing {
WatchStanding::Live
| WatchStanding::AnotherRun
| WatchStanding::AnotherHost
| WatchStanding::ProcessGone
| WatchStanding::AwaitingItsParent
| WatchStanding::NotThatProcess
| WatchStanding::Unproven => {}
}
}
all
}
#[test]
fn the_divergence_entry_names_the_record_this_build_writes() {
let block = block();
assert_eq!(
block["schema_version"].as_u64(),
Some(u64::from(WATCHER_SCHEMA_VERSION)),
"entry 68 states a schema version this build does not write"
);
let written = WatcherRecord {
schema_version: WATCHER_SCHEMA_VERSION,
run_id: "gated".into(),
pid: std::num::NonZeroU32::new(4_242).expect("a pid"),
host: "a-host".into(),
started: "linux-proc-stat:1".into(),
began_at: "2026-01-01T00:00:00.000Z".into(),
};
let fields: BTreeSet<String> = serde_json::to_value(&written)
.expect("a record is an object")
.as_object()
.expect("a record is an object")
.keys()
.cloned()
.collect();
let named: BTreeSet<String> =
serde_json::from_value(block["fields"].clone()).expect("entry 68 names the fields");
assert_eq!(
named, fields,
"entry 68's inventory is not the record this build writes"
);
let read: WatcherRecord =
serde_json::from_str(&serde_json::to_string(&written).expect("a record serializes"))
.expect("a record this build wrote reads back");
assert_eq!(read, written);
}
#[test]
fn a_record_at_another_schema_version_is_refused() {
let mut document = serde_json::json!({
"schema_version": WATCHER_SCHEMA_VERSION + 1,
"run_id": "gated",
"pid": 4_242,
"host": "a-host",
"started": "linux-proc-stat:1",
"began_at": "2026-01-01T00:00:00.000Z",
});
let refusal = serde_json::from_value::<WatcherRecord>(document.clone())
.expect_err("a version this build does not write is refused");
assert!(
refusal.to_string().contains("schema_version"),
"the refusal does not say what it refused: {refusal}"
);
document["schema_version"] = serde_json::json!(WATCHER_SCHEMA_VERSION);
document["watching_since_tick"] = serde_json::json!(1);
serde_json::from_value::<WatcherRecord>(document).expect_err("an unknown key is refused");
}
#[test]
fn the_divergence_entry_names_every_standing_this_build_reads() {
let named: Vec<String> =
serde_json::from_value(block()["standings"].clone()).expect("entry 68 names them");
let read: Vec<String> = every_standing()
.iter()
.map(|standing| standing.as_str().to_string())
.collect();
assert_eq!(
named, read,
"entry 68 names a different set of standings than this build reads"
);
let live = every_standing()
.into_iter()
.filter(|standing| standing.is_live())
.count();
assert_eq!(live, 1, "exactly one standing reads as watching");
}
#[test]
fn the_divergence_entry_proposes_exactly_the_options_this_build_offers() {
use clap::CommandFactory;
let named: BTreeSet<String> =
serde_json::from_value(block()["options"].clone()).expect("entry 68 names them");
let offered: BTreeSet<String> = crate::cli::Cli::command()
.get_subcommands()
.find(|sub| sub.get_name() == "unwatched")
.expect("the binary offers `unwatched`")
.get_arguments()
.filter_map(|arg| arg.get_long().map(str::to_string))
.collect();
assert_eq!(
named, offered,
"entry 68 proposes a different set of options than this build offers"
);
assert!(
divergence_entry().contains("onepipeline unwatched [--session <ID>]"),
"entry 68 does not spell the command it proposes"
);
}
#[test]
fn the_divergence_entry_names_the_statuses_this_verb_returns() {
let block = block();
assert_eq!(
block["exit_reported"].as_i64(),
Some(i64::from(EXIT_RUNS_UNWATCHED))
);
assert_eq!(
block["exit_none_reported"].as_i64(),
Some(i64::from(EXIT_SUCCESS))
);
assert_eq!(
block["exit_refused"].as_i64(),
Some(i64::from(EXIT_REFUSED))
);
assert_ne!(
EXIT_RUNS_UNWATCHED, EXIT_SUCCESS,
"the answer that a run is unwatched cannot be told from the answer that none is"
);
assert_ne!(
EXIT_RUNS_UNWATCHED, EXIT_REFUSED,
"the answer that a run is unwatched cannot be told from a refusal"
);
}
}