//! `on watch --run ""`: keeps main working at the speed changes land. //! //! Lands don't wait for checks (nobody waits), so something has to notice //! when main breaks. This follows main from any machine (a CI box, a dev //! machine, a cloud computer) and runs the check on each new version it //! sees. When the check fails, it finds the first version that fails it by //! binary search over the numbered versions since the last one that passed, //! takes that version back out (a new version, as `on undo `), and //! its change is open again for its owner, with what failed in the take-back's //! description. A check that fails then passes on the same version is tried //! again before anything is blamed. use std::path::PathBuf; use std::time::Duration; use serde_json::json; use crate::app::App; use crate::db::Project; use crate::out::{Fail, Out, Report}; use crate::ws::{self, Index}; use crate::{ids, paths}; /// Where the watcher keeps the last version that passed, per project and check. fn state_path(project: &Project, check: &str) -> PathBuf { use sha2::{Digest, Sha256}; let h: String = Sha256::digest(format!("{}\n{check}", project.id).as_bytes())[..8].iter().map(|b| format!("{b:02x}")).collect(); paths::home().join("watch").join(format!("{h}.json")) } struct Tester<'a> { app: &'a App, project: &'a Project, check: &'a str, root: PathBuf, index: Index, tries: u32, } /// How one version did: passed, failed (with the end of its output), or can't tell (exit 125). enum Outcome { Pass, Fail(i32, String), Skip, } impl Tester<'_> { fn run(&mut self, n: u64) -> Result { let v = self.app.version(self.project, n)?.ok_or_else(|| Fail::new("not_found", format!("main has no version {n}")))?; self.app.host(self.project).fetch_tree(&self.app.store, self.project, &v.tree, None)?; let skip = crate::seal::Seals::load(&self.app.db, &self.project.id)?.unreadable(); self.index = ws::materialize(&self.root, &self.app.store, &self.index, &self.app.files(&v.tree)?, &[], &skip, None)?; let mut last = Outcome::Pass; for _ in 0..self.tries.max(1) { last = self.once()?; if !matches!(last, Outcome::Fail(..)) { return Ok(last); } } Ok(last) } fn once(&self) -> Result { let mut shell = if cfg!(windows) { std::process::Command::new("cmd") } else { std::process::Command::new("sh") }; shell.args(if cfg!(windows) { ["/C", self.check] } else { ["-c", self.check] }); let out = shell .current_dir(&self.root) .stdin(std::process::Stdio::null()) .output() .map_err(|e| Fail::new("bad_usage", format!("couldn't run {:?}: {e}", self.check)).state("nothing changed"))?; Ok(match out.status.code() { Some(0) => Outcome::Pass, Some(125) => Outcome::Skip, code => { let mut text = String::from_utf8_lossy(&out.stdout).into_owned(); text.push_str(&String::from_utf8_lossy(&out.stderr)); let tail: Vec<&str> = text.lines().rev().take(30).collect(); Outcome::Fail(code.unwrap_or(-1), tail.into_iter().rev().collect::>().join("\n")) } }) } } /// `on watch --run "" [--from ] [--once] [--every ] [--tries ]`. pub fn run(app: &App, check: String, from: Option, once: bool, every: u64, tries: u32) -> Out { let s = app.session()?; let project = s.project.clone(); let state = state_path(&project, &check); let mut green: Option = from.or_else(|| { let text = std::fs::read_to_string(&state).ok()?; serde_json::from_str::(&text).ok()?["green"].as_u64() }); let root = paths::home().join("tmp").join(format!("watch-{}", ids::new_workspace_id())); std::fs::create_dir_all(&root)?; let mut tester = Tester { app, project: &project, check: &check, root: root.clone(), index: Index::default(), tries }; let mut r = Report::new(); let mut events = Vec::new(); let result = (|| -> Result<(), Fail> { loop { let Some(main) = app.refresh(&project)? else { if once { return Ok(()); } std::thread::sleep(Duration::from_secs(every)); continue; }; if green.is_some_and(|g| g >= main.n) { if once { return Ok(()); } std::thread::sleep(Duration::from_secs(every)); continue; } match tester.run(main.n)? { Outcome::Pass | Outcome::Skip => { green = Some(main.n); let _ = std::fs::create_dir_all(state.parent().unwrap()); let _ = std::fs::write(&state, json!({ "green": main.n }).to_string()); say(app, once, &mut r, &mut events, "passed", format!("main {}", main.n), json!({ "passed": main.n })); continue; } Outcome::Fail(code, tail) => { let Some(good) = green else { return Err(Fail::new("checks_failed", format!("main {} fails {check:?} (exit {code}), and no version is known to pass it", main.n)) .detail(tail) .state("main didn't change") .next(format!("on watch --run {check:?} --from "))); }; // The first failing version since the last that passed. let (mut lo, mut hi) = (good, main.n); let mut why = tail; let mut fail_code = code; while hi - lo > 1 { let mid = lo + (hi - lo) / 2; match tester.run(mid)? { Outcome::Pass | Outcome::Skip => lo = mid, Outcome::Fail(c, t) => { hi = mid; why = t; fail_code = c; } } } let reason = format!("{check} failed on it (exit {fail_code}), and passed on main {lo} before it (on watch).\n\n{why}"); let (v, back) = crate::cmd::take_back_why(app, &s, hi, Some(&reason)).map_err(|f| { f.next(format!("main {hi} broke {check:?}: take it back by hand, then on watch --from {}", main.n + 1)) })?; // The change that made it is open again (the host does it for a hosted project). if !project.hosted() && let Ok(mut c) = app.change(&v.change) && c.state == "landed" { c.state = "open".into(); c.version = None; app.db.put_change(&c)?; } say( app, once, &mut r, &mut events, "took back", format!("main {hi} (\"{}\", {}): {check} failed on it; main {} takes it out, and its change {} is open again", v.title, v.author, back.n, app.short(&project, &v.change)), json!({ "failed": hi, "change": v.change, "taken_back_as": back.n }), ); // What's after it may pass now; it's checked from the last good one. green = Some(lo); } } } })(); let _ = std::fs::remove_dir_all(&root); result?; r.set("events", events); if let Some(g) = green { r.set("green", g); } Ok(r) } /// One thing that happened: in the answer with --once; as it happens otherwise (it runs for good). fn say(app: &App, once: bool, r: &mut Report, events: &mut Vec, label: &str, text: String, event: serde_json::Value) { if once { r.line(label, text); } else if app.json { println!("{event}"); } else { println!("{label:<10} {text}"); } events.push(event); }