dwarves-gui/src/generation.rs
//! **A pane re-reads when its GENERATION moves — never when a human presses a button.**
//!
//! The owner, 2026-08-22: *"SCAN skulle ju bort och vara en chain i jera som arbetas i
//! background direkt efter populate"*, and again 2026-08-25: **SCAN is not a user
//! action, full stop.** A control labelled `Scan` / `Rescan` / `Refresh` / `Reload`
//! teaches the operator that data is something you *ask for*. That is the lesson the
//! `lens_chain` architecture ("lenses are jobs, results are tables") abolished, and it
//! stays abolished whether the work behind the button is expensive or cheap.
//!
//! nornir already keys the lens chain this way: [`nornir::lens_chain::generation`]
//! hashes the current Iceberg snapshot id of every table a lens reads, so re-submitting
//! on an unchanged warehouse is a no-op all the way down. This module is the same idea
//! for the panes that read something **other** than a lens table — a file on disk, a
//! ledger, a git checkout — so all of them get one mechanism instead of four hand-rolled
//! poll loops (LAW 5).
//!
//! # The two properties that matter
//!
//! * **Cold start reads.** The first probe moves `None → Some(g)`, which counts as a
//! move, so a pane reads itself on open. There is no separate "auto-read on open"
//! flag to keep in agreement with the poll — the flag WAS the second code path.
//! * **An unchanged source does nothing.** Same generation ⇒ [`Watch::moved`] is
//! `false` ⇒ no read, no thread, no RPC. This is what makes removing the buttons
//! safe: the pane is not quietly polling the warehouse to death behind them.
//!
//! # What a generation is
//!
//! A short opaque string that changes **exactly** when the thing the pane reads changes,
//! and is cheap enough to compute every frame-ish. It is never compared for order, only
//! for equality — so a file's `(mtime, len)`, a hash of a directory listing, or the
//! warehouse's own `g-…` id all qualify. [`absent`] is a generation too: a source that
//! is GONE has a stable, distinguishable fingerprint, which is what lets a pane say
//! *gone* instead of *empty*.
/// The generation of a source that is not there at all.
///
/// This is deliberately a real value rather than an `Option`: "the file is missing" is a
/// STATE the pane must be able to render honestly and re-read out of, not an absence
/// that reads the same as "nothing changed". A ledger whose workspace was deregistered
/// and a ledger with no rows are different facts, and this is the seam that keeps them
/// different.
pub const ABSENT: &str = "absent";
/// A cheap fingerprint of one file: `len:mtime_nanos`, or [`ABSENT`].
///
/// `(len, mtime)` rather than a content hash because the probe runs on the frame path;
/// a `.runa.ron` edit moves both, and a re-write that changed neither is a file that did
/// not change in any way this pane can render.
pub fn file_generation(path: &std::path::Path) -> String {
let Ok(md) = std::fs::metadata(path) else {
return ABSENT.to_string();
};
let nanos = md
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{}:{}", md.len(), nanos)
}
/// A fingerprint of several files at once — the concatenation of their individual
/// [`file_generation`]s, in the order given. Used where one pane's answer depends on
/// more than one file and any of them moving is a move.
pub fn paths_generation(paths: &[std::path::PathBuf]) -> String {
paths
.iter()
.map(|p| file_generation(p))
.collect::<Vec<_>>()
.join("|")
}
/// **A fingerprint of every checkout's CURRENT BRANCH under `root`.**
///
/// `<root>/<repo>/.git/HEAD` is the file git rewrites on `checkout`, so this moves
/// exactly when a repo moves onto or off a ramp branch — which is the whole input to the
/// release pane's ramp scan. One `read_dir` plus one `stat` per checkout; cheap enough
/// for a two-second probe, and it needs no repo roster to be passed in.
///
/// A missing or unreadable `root` fingerprints as [`ABSENT`] rather than as an empty
/// listing, so "the constellation root is not there" cannot read as "no repos are
/// ramped" — the same distinction `LedgerRead::Absent` draws for the jera ledger.
pub fn git_heads_generation(root: &std::path::Path) -> String {
let Ok(rd) = std::fs::read_dir(root) else {
return ABSENT.to_string();
};
let mut heads: Vec<String> = rd
.filter_map(|e| e.ok())
.map(|e| e.path().join(".git").join("HEAD"))
.filter(|p| p.exists())
.map(|p| format!("{}={}", p.display(), file_generation(&p)))
.collect();
heads.sort();
if heads.is_empty() {
return ABSENT.to_string();
}
heads.join("|")
}
/// **The generation watch a pane owns.** Holds the last generation it read at, and
/// rate-limits the probe so a per-frame call does not stat the disk 60 times a second.
#[derive(Debug, Clone)]
pub struct Watch {
/// The generation the pane last READ at. `None` = never read (cold).
last: Option<String>,
/// Monotonic seconds of the last probe (egui's `input.time`).
last_probe_s: f64,
/// Minimum seconds between probes.
interval_s: f64,
}
impl Watch {
/// A watch that probes at most every `interval_s` seconds.
pub fn new(interval_s: f64) -> Self {
// `last_probe_s` starts at negative infinity so the FIRST frame probes rather
// than waiting out an interval — a pane must not open blank for 1.5 s.
Self {
last: None,
last_probe_s: f64::NEG_INFINITY,
interval_s,
}
}
/// The generation this watch last read at (`None` = never read).
pub fn last(&self) -> Option<&str> {
self.last.as_deref()
}
/// True when the source was never read, whatever the probe says.
pub fn cold(&self) -> bool {
self.last.is_none()
}
/// **Has the source moved since the last read?** Probes at most once per interval;
/// `probe` is only called when a probe is actually due, so an expensive fingerprint
/// costs nothing on the frames in between.
///
/// Returns `true` on a cold watch (first read) and whenever the generation differs
/// from the one the pane last read at. Records the new generation, so a caller that
/// ignores a `true` will not be told again — call it only where the read follows.
pub fn moved(&mut self, now_s: f64, probe: impl FnOnce() -> String) -> bool {
if now_s - self.last_probe_s < self.interval_s {
return false;
}
self.last_probe_s = now_s;
let g = probe();
if self.last.as_deref() == Some(g.as_str()) {
return false;
}
self.last = Some(g);
true
}
/// **Record a generation somebody else probed.** Pure: no interval, no closure —
/// for a source whose probe is already rate-limited elsewhere (a background poller,
/// an RPC answer landing on a channel). Returns `true` on a cold watch and whenever
/// `g` differs from the generation the pane last read at.
pub fn saw(&mut self, g: &str) -> bool {
if self.last.as_deref() == Some(g) {
return false;
}
self.last = Some(g.to_string());
true
}
/// Forget the last generation, so the next probe counts as a move. For a pane that
/// re-binds to a different workspace / file: the source did not change, the QUESTION
/// did, and that must read as cold rather than as "unchanged".
pub fn forget(&mut self) {
self.last = None;
self.last_probe_s = f64::NEG_INFINITY;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **RED-when-broken: the three properties the removed buttons were doing by hand.**
///
/// Each leg is asserted against a mutation that would reintroduce a button:
/// (1) a cold watch must read WITHOUT a press; (2) an unchanged source must not
/// re-read (or the pane becomes a poll loop hammering the warehouse); (3) a moved
/// source must re-read WITHOUT a press.
#[test]
fn a_watch_reads_cold_ignores_unchanged_and_catches_a_move() {
let mut w = Watch::new(0.0);
assert!(w.cold(), "a fresh watch has never read");
// (1) COLD ⇒ reads, with nobody pressing anything.
assert!(
w.moved(1.0, || "g-aaa".into()),
"a cold watch must read on open"
);
assert_eq!(w.last(), Some("g-aaa"));
assert!(!w.cold());
// (2) UNCHANGED ⇒ silent. This is the property that makes removing the button
// safe rather than merely quiet: no read, no thread, no RPC.
assert!(
!w.moved(2.0, || "g-aaa".into()),
"an unchanged generation must not re-read"
);
assert!(
!w.moved(3.0, || "g-aaa".into()),
"…and must stay silent, not alternate"
);
// (3) MOVED ⇒ reads itself, again with no press.
assert!(
w.moved(4.0, || "g-bbb".into()),
"a moved generation must re-read"
);
assert_eq!(w.last(), Some("g-bbb"));
// Re-binding to a different question reads as COLD, not as unchanged.
w.forget();
assert!(w.cold());
assert!(
w.moved(5.0, || "g-bbb".into()),
"a re-bound pane re-reads the same generation"
);
}
/// **RED-when-broken: the interval really rate-limits.** Without it the probe runs
/// on every frame — 60 stats a second per pane, which is how a "cheap" fingerprint
/// becomes the thing that hangs the UI.
#[test]
fn the_probe_does_not_run_more_often_than_the_interval() {
let calls = std::cell::Cell::new(0usize);
let mut w = Watch::new(1.5);
assert!(
w.moved(100.0, || {
calls.set(calls.get() + 1);
"g-1".into()
}),
"cold reads"
);
assert_eq!(calls.get(), 1);
// Inside the interval the closure must not even be CALLED — asserting on the
// return value alone would pass with a probe that ran and was discarded.
assert!(!w.moved(100.5, || {
calls.set(calls.get() + 1);
"g-2".into()
}));
assert_eq!(calls.get(), 1, "the probe must not run inside the interval");
assert!(
w.moved(101.6, || {
calls.set(calls.get() + 1);
"g-2".into()
}),
"…and runs after it"
);
assert_eq!(calls.get(), 2);
}
/// **RED-when-broken: an absent source is its own generation.** A file that is GONE
/// must be distinguishable from a file that is unchanged, or a pane cannot say
/// "gone" — the `jobsfleet.rs:74` defect, where an absent `jobs.redb` reads as an
/// empty list by design and a deleted workspace is indistinguishable from a quiet one.
#[test]
fn an_absent_file_has_a_stable_generation_that_is_not_a_readings_of_zero() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pipeline.runa.ron");
let gone = file_generation(&path);
assert_eq!(
gone, ABSENT,
"a missing file fingerprints as ABSENT, not as an empty read"
);
assert_eq!(
file_generation(&path),
ABSENT,
"…stably, so it does not thrash"
);
std::fs::write(&path, b"(stages: [])").unwrap();
let there = file_generation(&path);
assert_ne!(
there, ABSENT,
"a file that exists must not fingerprint as absent"
);
// And the move from absent → present is a real move a watch catches.
let mut w = Watch::new(0.0);
let p = path.clone();
std::fs::remove_file(&path).unwrap();
assert!(w.moved(1.0, || file_generation(&p)), "cold");
assert_eq!(w.last(), Some(ABSENT));
std::fs::write(&path, b"(stages: [])").unwrap();
let p2 = path.clone();
assert!(
w.moved(2.0, || file_generation(&p2)),
"a file appearing is a generation move"
);
}
}
/// **The warehouse generation, probed OFF the frame.**
///
/// A thin pane's generation is the workspace's `current_snapshot` — the id nornir mints
/// when a populate commits, and the same fact `nornir::lens_chain::generation` keys the
/// lens chain on. Reading it is a gRPC round trip, so it must never happen on the frame
/// that is trying to draw: this owns a worker and a channel, and [`poll`](Self::poll) is
/// non-blocking.
///
/// One probe in flight at a time, and a probe only when the interval has elapsed — so a
/// pane left open costs one small RPC every `interval_s`, whatever the frame rate.
pub struct WarehouseGeneration {
endpoint: String,
token: String,
workspace: String,
interval_s: f64,
last_probe_s: f64,
inflight: Option<std::sync::mpsc::Receiver<Option<String>>>,
latest: Option<String>,
}
impl WarehouseGeneration {
/// A probe of `workspace`'s snapshot id against `endpoint`, at most every
/// `interval_s` seconds.
pub fn new(
endpoint: impl Into<String>,
token: impl Into<String>,
workspace: impl Into<String>,
interval_s: f64,
) -> Self {
Self {
endpoint: endpoint.into(),
token: token.into(),
workspace: workspace.into(),
interval_s,
last_probe_s: f64::NEG_INFINITY,
inflight: None,
latest: None,
}
}
/// Re-bind to another workspace. The generation of a DIFFERENT question is not
/// comparable to the old one, so the known value is dropped.
pub fn rebind(&mut self, workspace: impl Into<String>) {
self.workspace = workspace.into();
self.latest = None;
self.inflight = None;
self.last_probe_s = f64::NEG_INFINITY;
}
/// **Non-blocking.** Lands an answer if the worker has one, starts a probe if one is
/// due, and returns the newest generation known — `None` until the first answer.
pub fn poll(&mut self, now_s: f64) -> Option<String> {
if let Some(rx) = &self.inflight {
match rx.try_recv() {
Ok(g) => {
self.inflight = None;
// An unreachable server is a generation too: `ABSENT`. It is stable,
// so a pane does not thrash re-reading while the server is down, and
// it MOVES when the server comes back — which is exactly when the
// pane should read again.
self.latest = Some(g.unwrap_or_else(|| ABSENT.to_string()));
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.inflight = None;
self.latest = Some(ABSENT.to_string());
}
}
}
if self.inflight.is_none() && now_s - self.last_probe_s >= self.interval_s {
self.last_probe_s = now_s;
let (tx, rx) = std::sync::mpsc::channel();
let (e, t, w) = (
self.endpoint.clone(),
self.token.clone(),
self.workspace.clone(),
);
if std::thread::Builder::new()
.name("warehouse-generation".into())
.spawn(move || {
let g = nornir_warehouse_embed::get_warehouse(&e, &t, &w)
.ok()
.map(|r| r.current_snapshot)
.filter(|s| !s.trim().is_empty());
let _ = tx.send(g);
})
.is_ok()
{
self.inflight = Some(rx);
}
}
self.latest.clone()
}
}