Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

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()
    }
}