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/jera.rs

//! **The Jera tab** — dwarves' ONE jobs/zones central panel, mounted from the shared
//! [`facett_zoneui::JeraCentral`] widget (the jera ledger + a per-job **zone column**).
//!
//! ## What this unifies (the duality it kills)
//! dwarves-gui used to ship TWO job surfaces: a static **Jobs** fixture pane
//! ([`facett_jobview::JobList`] over a fixture) and a live **Jobs (live)** cockpit
//! ([`dwarves_embed::JobsFleetPanel`]). That split confused (which is the real one?),
//! exactly like the Forge/Forge-(live) duality that was already unified. This tab
//! replaces BOTH with the single shared [`JeraCentral`]: the SAME jera ledger, now with
//! the framework's zone column. **There is no fixture fallback** — the fixture is
//! reachable only through the explicit [`JeraTab::from_fixture`], because a fallback is
//! what once turned a removed workspace into three fabricated jobs (`1aa8f0f`).
//!
//! ## It reads over the WIRE (`Viz.Jobs`), not off a path
//!
//! The shipped pane is [`JeraTab::wire`]: it asks the dwarves forge (`:7900`), which
//! proxies to its embedded nornir, for the served workspace's ledger. The rule is written
//! one line above the answer in `edda/crates/nornir-wire/proto/nornir.proto:981-983` —
//! *the server holds the redb lock, SO CLIENTS GO THROUGH HERE*. The FILE road survives
//! as [`JeraTab::live`] for a host that owns the ledger it names; [`WireJobs`] carries
//! the three measured reasons the shipped pane does not take it.
//!
//! ## Reuse-first (L5)
//! No twin: the jobs tree/sort/fold/filter live once in `facett-jobview`; the zone
//! column lives once in `facett-zoneui::JeraCentral`; the `JobRecord → JobEntry` mapping
//! lives once in `dwarves-embed` and BOTH roads go through it (`Viz.Jobs` serializes
//! `nornir::jobs::JobRecord`, which is a re-export of `jera::jobs::JobRecord` — the same
//! type, not a structural twin). [`JeraTab`] is a thin host that polls, tags each job with
//! a [`ZonedJob`], feeds the shared central panel, and maps the JSON control seam onto the
//! reused [`JobList`]'s typed `Msg` (the shared widget takes the no-op `update_json`, so
//! the host supplies the JSON→`Msg` seam — the same pattern `JobsView`/`ClaudeTab` use).
//!
//! [`JobList`]: facett_jobview::JobList

use std::path::PathBuf;
use std::time::Duration;

use facett_core::Facet;
#[cfg(any(test, feature = "fixtures"))]
use facett_jobview::JobStatus;
use facett_jobview::{JobEntry, Msg, SortKey};
use facett_zoneui::{JeraCentral, Zone, ZonedJob};

/// Default seconds between live auto-poll re-reads of the jera ledger (mirrors the
/// `dwarves-embed` cockpit interval — one source for the cadence).
pub const POLL_INTERVAL_S: f64 = dwarves_embed::DEFAULT_JOBS_POLL_INTERVAL_S;

/// **Where the rendered jobs came from — and, when there are none, WHY there are none.**
///
/// This used to be `Ledger | Fixture`, and `live_zoned_jobs` returned `None` for a
/// ledger that was absent, empty OR unreadable alike — all three fell through to the
/// demo fixture. So a workspace that had been REMOVED (which takes its `jobs.redb` with
/// it: `nornir::registry::remove` unlinks `<root>/<name>/builds/`) rendered a green
/// `build`, a running `bench` and a failed `release` that **do not exist**. Not merely
/// "empty read as idle" — fabricated jobs read as real ones.
///
/// The owner, 2026-08-25: *nuke the workspace and the jobs are nuked with it*. A ledger
/// that is gone is therefore the NORMAL end state of a removed workspace, and it is the
/// one state this pane must be loudest about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum JeraSource {
    /// **Nothing has been read yet** — the pane's cold state, before the first answer
    /// lands. It exists because the field used to be initialised to [`Idle`], and "the
    /// first read has not come back" is not "the forge is idle": a pane that opened on a
    /// wire source and was asked for its state one frame later claimed a quiet forge it
    /// had never heard from. The same collapse `1aa8f0f` found between *absent* and
    /// *empty*, one step earlier in the sequence.
    ///
    /// [`Idle`]: JeraSource::Idle
    Unread,
    /// Rows read from the live ledger.
    Ledger,
    /// The ledger is there and readable and holds no rows — a real, idle forge.
    Idle,
    /// There is no ledger file. The workspace is gone, or was never populated.
    /// **Never rendered as "0 jobs" and never as the fixture.**
    Absent,
    /// The ledger could not be read at all (a broken read is not an absence).
    ///
    /// This is the state of a pane that has **never** had a good reading. A read that
    /// fails while a previous reading is on screen does NOT land here — it keeps the
    /// previous source and records the reason in [`JeraTab::error`], because blanking a
    /// good list because one refresh failed is the failure this pane exists to prevent.
    Unreadable,
    /// The explicit multi-zone demo, reachable ONLY through
    /// [`JeraTab::from_fixture`] — never a fallback for a live pane.
    Fixture,
}

impl JeraSource {
    fn key(self) -> &'static str {
        match self {
            JeraSource::Unread => "unread",
            JeraSource::Ledger => "ledger",
            JeraSource::Idle => "idle",
            JeraSource::Absent => "absent",
            JeraSource::Unreadable => "unreadable",
            JeraSource::Fixture => "fixture",
        }
    }

    /// The sentence the header shows. Each state says a DIFFERENT thing, because each
    /// state IS a different thing — that is the whole point of the enum. `whence` is the
    /// source's own [`JeraJobSource::describe`], so the sentence names the WIRE or the
    /// FILE the reading came off rather than assuming one of them.
    fn headline(self, whence: &str) -> String {
        match self {
            JeraSource::Unread => format!("{whence} · not read yet"),
            JeraSource::Ledger => whence.to_string(),
            JeraSource::Idle => format!("{whence} · no jobs yet (the forge is idle)"),
            JeraSource::Absent => format!(
                "⚠ NO LEDGER — {whence} — the workspace is GONE (or was never populated). \
                 This is not an idle forge and these are not zero jobs.",
            ),
            JeraSource::Unreadable => format!("⚠ could not READ jobs from {whence}"),
            JeraSource::Fixture => "fixture (explicit demo)".to_string(),
        }
    }
}

/// The clearly-marked fallback: a zoned fixture that SPREADS jobs across all three
/// zones so the framework's zone column is exercised even offline — a green `build`
/// run (+4 dispatched steps) in **demo**, a running `bench` in **test**, and a failed
/// `release` in **prod**. Live ledger jobs (which the jera `JobRecord` does not yet
/// carry a zone on) land in [`Zone::Demo`]; this fixture is the multi-zone showcase.
#[cfg(any(test, feature = "fixtures"))]
fn fixture_zoned_jobs() -> Vec<ZonedJob> {
    let step = |id: &str,
                kind: &str,
                status: JobStatus,
                started: i64,
                elapsed: Option<i64>,
                detail: &str,
                node: &str|
     -> JobEntry {
        JobEntry {
            target: "dwarves@a1b2c3d4".into(),
            started_micros: started,
            elapsed_ms: elapsed,
            detail: detail.into(),
            meta: vec![("node".into(), node.into())],
            parent_id: Some("#1042".into()),
            ..JobEntry::new(id, kind, status)
        }
    };
    vec![
        // The green `build` run in the DEMO zone — the foldable parent + its 4 steps.
        ZonedJob::new(
            JobEntry {
                target: "dwarves@a1b2c3d4".into(),
                started_micros: 1_000_000,
                elapsed_ms: Some(134_000),
                detail: "build · 4/4 steps green".into(),
                meta: vec![("trigger".into(), "push".into())],
                parent_id: None,
                ..JobEntry::new("#1042", "build", JobStatus::Done)
            },
            Zone::Demo,
        ),
        ZonedJob::new(
            step(
                "#1042.1",
                "cargo-build",
                JobStatus::Done,
                1_000_100,
                Some(78_000),
                "ran",
                "loki",
            ),
            Zone::Demo,
        ),
        ZonedJob::new(
            step(
                "#1042.2",
                "unit",
                JobStatus::Done,
                1_000_200,
                Some(22_000),
                "cache-hit",
                "odin",
            ),
            Zone::Demo,
        ),
        ZonedJob::new(
            step(
                "#1042.3",
                "lint",
                JobStatus::Done,
                1_000_300,
                Some(9_000),
                "unchanged",
                "odin",
            ),
            Zone::Demo,
        ),
        ZonedJob::new(
            step(
                "#1042.4",
                "release",
                JobStatus::Done,
                1_000_400,
                Some(25_000),
                "ran",
                "loki",
            ),
            Zone::Demo,
        ),
        // A running `bench` in the TEST zone (top-level, newest → sorts first).
        ZonedJob::new(
            JobEntry {
                target: "dwarves".into(),
                started_micros: 3_000_000,
                elapsed_ms: None,
                detail: "bench · 1/3 steps".into(),
                meta: vec![("node".into(), "nuc".into())],
                parent_id: None,
                ..JobEntry::new("#1040", "bench", JobStatus::Running)
            },
            Zone::Test,
        ),
        // A failed `release` in the PROD zone.
        ZonedJob::new(
            JobEntry {
                target: "dwarves@deadbeef".into(),
                started_micros: 2_000_000,
                elapsed_ms: Some(31_000),
                detail: "release · publish step failed".into(),
                meta: vec![("node".into(), "loki".into())],
                parent_id: None,
                ..JobEntry::new("#1039", "release", JobStatus::Failed)
            },
            Zone::Prod,
        ),
    ]
}

// ── the READ SEAM: where the jobs come from ──────────────────────────────────
//
// Two roads, and the shipped one is the WIRE. The rule is written one line above the
// verb in `edda/crates/nornir-wire/proto/nornir.proto:981-983`:
//
//   > the server holds the redb lock, SO CLIENTS GO THROUGH HERE.
//
// The file road ([`LedgerJobs`]) is kept for a host that genuinely owns the ledger it is
// pointing at, and for the tests that create one. It is not the shipped tab's road: see
// [`WireJobs`] for the three measured reasons.

/// **What a jobs read found.** Four outcomes that must never render alike — the
/// three-way `LedgerRead::{Absent,Empty,Rows}` that `1aa8f0f` bought, plus the fourth
/// thing a broken read is.
///
/// The rule this type exists to hold: *"no answer" and "no jobs" are never the same
/// value.* `read_ledger_jobs` used to return `Ok(Vec::new())` for both an absent and an
/// empty ledger, `jera.rs:118` fell through to `fixture_zoned_jobs()`, and a **deleted
/// workspace painted invented jobs** — a green build, a running bench, a failed release
/// that did not exist. Not "empty read as idle" but fabrication read as reality.
#[derive(Debug)]
pub enum JobsRead {
    /// Jobs, newest-first, already mapped onto the shared widget's entries.
    Rows(Vec<JobEntry>),
    /// The source answered and the ledger holds **no rows** — a real, idle forge.
    Empty,
    /// There is no ledger at all. The workspace is gone, or was never populated.
    /// A **file-road** answer: over the wire the client is not entitled to this claim
    /// (see [`WireJobs`]), and an unserved workspace arrives as [`Failed`] instead.
    ///
    /// [`Failed`]: JobsRead::Failed
    Absent,
    /// A **NAMED** failure — the read did not happen. Never a blank, never zero jobs.
    Failed(String),
}

/// Where the Jera tab's jobs come from. Injected so a test drives the pane over a
/// deterministic source with no server and no ledger file, exactly as the Coverage and
/// Data-view panes' data seams are injected.
pub trait JeraJobSource: Send + Sync {
    /// Read at most `limit` jobs of `kind`, newest-first. `kind = ""` means every kind.
    /// Runs OFF the egui thread.
    ///
    /// # Why the kind is an ARGUMENT and not a filter over the answer (`jerakind-1`)
    ///
    /// Because the crowding happens in the SERVER'S truncate, before anything a client
    /// could filter. MEASURED on the live dwarves forge 2026-08-27, `Viz.Jobs` at the
    /// server's own default window of 200 rows against the same workspace's ledger:
    ///
    /// | kind | rows in the MIXED window | rows a kind-scoped read returns |
    /// |---|---|---|
    /// | `snapshot_member` | 57 | 200+ |
    /// | `scip_member` | 47 | 200+ |
    /// | `knowledge_member` | 38 | 200+ |
    /// | `lens` | 26 | **200+** |
    /// | `feed` | 6 | **84** |
    /// | `workspace_populate` | **0** | **1** |
    ///
    /// The three per-member kinds are 142 of the 200 — 71 % of the window — because a
    /// populate emits one job per workspace member per phase. `feed` shows 7.1 % of its
    /// rows and `workspace_populate` shows NONE of its one row: a job class can be pushed
    /// entirely out of view by a noisier one, and no amount of client-side filtering brings
    /// it back, because it never arrived.
    ///
    /// `Viz.Jobs` has taken a `kind` since it was written and the server SELECTs on it
    /// (`JobSelector::Kind`); the pane passed `""` and dropped the capability on the floor.
    fn read(&self, limit: usize, kind: &str) -> JobsRead;
    /// What the header says about WHERE the jobs came from.
    fn describe(&self) -> String;
    /// A cheap fingerprint that moves exactly when the source moves, or `None` when the
    /// source has no probe cheaper than the read itself (every remote source: an RPC to
    /// ask "did it change?" costs what the answer costs).
    fn generation(&self) -> Option<String> {
        None
    }
}

/// **The ONE chooser of a job source, for every pane that shows jobs.**
///
/// It used to live inline in the shell next to the Jera tab, which is why the Pipeline
/// tab never got it: that pane kept deriving a `jobs.redb` path out of the UI's own
/// `$HOME` long after `9e82007` took that road away from the Jera tab. Two panes reading
/// the same ledger by two different roads is the shape that lets one of them be right and
/// the other quietly wrong, so the decision is made HERE, once.
///
/// `$DWARVES_JOBS_DIR` names the file road, and only that: an operator naming a path is a
/// statement about a ledger they own, which is a different thing from the UI guessing one
/// out of its own home directory and hoping. Unset ⇒ the wire, always.
pub fn job_source(
    endpoint: impl Into<String>,
    token: impl Into<String>,
    workspace: impl Into<String>,
    ledger_root: impl Into<PathBuf>,
) -> std::sync::Arc<dyn JeraJobSource> {
    let named = std::env::var("DWARVES_JOBS_DIR").is_ok_and(|d| !d.trim().is_empty());
    if named {
        std::sync::Arc::new(LedgerJobs {
            root: ledger_root.into(),
        })
    } else {
        std::sync::Arc::new(WireJobs {
            endpoint: endpoint.into(),
            token: token.into(),
            workspace: workspace.into(),
        })
    }
}

/// **The SHIPPED source: `Viz.Jobs` over gRPC.**
///
/// The tab asks the server that HOLDS the ledger for its contents, instead of opening a
/// `jobs.redb` at a path it had to guess. Three measured reasons the file road is wrong
/// here, only one of which is the lock:
///
/// 1. **Ownership, not locking.** `jera::jobs::JobStore::open_read_only` genuinely IS
///    lock-tolerant — measured on t14s 2026-08-25: against a live exclusive writer it
///    snapshots the file aside and returns its rows. What it is not is *permission*-
///    tolerant: it routes through `redb::Database::create`, so a `-rw-r--r--` ledger
///    owned by another user fails `Permission denied (os error 13)` before jera's
///    `is_lock_error` is consulted, and the snapshot fallback never runs. The installed
///    forge runs as user `dwarves`; a UI a human started does not.
/// 2. **The snapshot is a whole-file copy, every read.** At this tab's 2 s cadence that
///    is a recurring byte copy of the ledger to render a list.
/// 3. **The path the UI would guess is the wrong file.**
///    `dwarves_embed::default_ledger_root()` resolves `$HOME/.dwarves/warehouse` — the
///    UI's own home, and dwarves' forge ledger, which the server opens only for the
///    duration of a run. The ledger `Viz.Jobs` serves is the SERVED WORKSPACE's
///    (`<nornir warehouse root>/jobs.redb`, `server_grpc.rs:6013`), held open for the
///    process lifetime. Two different files; the UI cannot pick between them from a path.
///
/// ## Absent vs empty, over the wire
///
/// The client does not get to invent [`JobsRead::Absent`]: a server that answers has
/// opened the served workspace's ledger, so `[]` means the ledger IS empty. "There is no
/// such workspace" arrives as a typed `NotServed`, and a forge whose proxy does not carry
/// `Viz` yet arrives as `Unsupported` — both become [`JobsRead::Failed`], which is a
/// named failure on the surface and never a count of zero.
pub struct WireJobs {
    /// The DWARVES host (`:7900`). The suite-host-embedded-auth LAW: the UI dials only
    /// the forge, which proxies to its embedded nornir. There is no second endpoint and
    /// no second token here — see `crates/dwarves/src/warehouse_proxy.rs`.
    pub endpoint: String,
    /// The dwarves host bearer (`$DWARVES_SERVER_TOKEN`). Empty ⇒ open.
    pub token: String,
    /// The selected workspace, stamped as the `nornir-workspace` header.
    pub workspace: String,
}

impl JeraJobSource for WireJobs {
    fn read(&self, limit: usize, kind: &str) -> JobsRead {
        let rows = match nornir_warehouse_embed::viz::jobs(
            &self.endpoint,
            &self.token,
            &self.workspace,
            // **THE KIND GOES ON THE WIRE.** This was a literal `""` — every kind — so the
            // server truncated its newest-200 across every class and the quiet ones never
            // arrived. See [`JeraJobSource::read`] for the census.
            kind,
            limit as u32,
        ) {
            Ok(rows) => rows,
            // Every arm of `WarehouseReadError` already Displays as a sentence naming
            // WHICH failure it was (unreachable / auth_failed / not_served / transport
            // cannot carry it), so the pane names the outage rather than paraphrasing it.
            Err(e) => return JobsRead::Failed(format!("{e}")),
        };
        if rows.is_empty() {
            return JobsRead::Empty;
        }
        match dwarves_embed::entries_from_records_json(&rows) {
            Ok(entries) => JobsRead::Rows(entries),
            // A ledger that half-decodes must not render as a shorter ledger.
            Err(e) => JobsRead::Failed(format!(
                "Viz.Jobs answered, but the rows did not decode: {e:#}"
            )),
        }
    }

    fn describe(&self) -> String {
        format!(
            "Viz.Jobs @ {} · workspace {}",
            self.endpoint, self.workspace
        )
    }
}

/// **The FILE source** — the jera ledger at a path this host owns.
///
/// Reuses `dwarves-embed`'s reader + mapper (one ledger reader in the repo, L5). Kept
/// because it answers a genuinely different question from [`WireJobs`] (*this ledger on
/// this disk* vs *the served workspace's*), and because a host that owns its ledger
/// should not need a server to read it. Not the shipped tab's road.
pub struct LedgerJobs {
    pub root: PathBuf,
}

/// **The FILE road SELECTs now** (`jerakind-1`, closed).
///
/// This used to widen the ask to `limit * 20` and `retain` on the way out, because
/// `dwarves_embed::read_ledger_jobs` took a limit and no kind — the kind arrived after the
/// truncate, and no amount of client-side filtering can recover a row the read never
/// returned. Widening only moved the odds (a class holding 0.5 % of the ledger needs
/// roughly a 200x window to be sure of one row), and the doc here said as much: the real
/// fix was a `kind` on the reader itself.
///
/// It has one. `read_ledger_jobs(root, limit, kind)` scans once, applies
/// [`jera::jobs::JobSelector::matches`] and truncates what SURVIVED the select, so the
/// file road now scopes its read the same way [`WireJobs`] does — with the same
/// `Rows(vec![])`-is-not-`Empty` distinction, decided in the reader instead of re-derived
/// here. ONE reader, both roads (LAW 5), which is why there is no `retain` left below.
impl JeraJobSource for LedgerJobs {
    fn read(&self, limit: usize, kind: &str) -> JobsRead {
        match dwarves_embed::read_ledger_jobs(&self.root, limit, kind) {
            Ok(dwarves_embed::LedgerRead::Absent) => JobsRead::Absent,
            Ok(dwarves_embed::LedgerRead::Empty) => JobsRead::Empty,
            Ok(dwarves_embed::LedgerRead::Rows(recs)) => {
                JobsRead::Rows(dwarves_embed::entries_from_records(&recs))
            }
            Err(e) => JobsRead::Failed(format!(
                "the ledger at {} could not be READ: {e:#}",
                self.root.display()
            )),
        }
    }

    fn describe(&self) -> String {
        format!("jera ledger @ {}", self.root.display())
    }

    fn generation(&self) -> Option<String> {
        Some(crate::generation::file_generation(
            &self.root.join("jobs.redb"),
        ))
    }
}

/// Tag entries with [`Zone::Demo`] — the jera `JobRecord` carries no zone yet, and
/// inventing one per job would be exactly the fabrication this pane exists to refuse.
fn zoned(entries: Vec<JobEntry>) -> Vec<ZonedJob> {
    entries
        .into_iter()
        .map(|e| ZonedJob::new(e, Zone::Demo))
        .collect()
}

/// The **Jera** central-panel tab: the shared [`JeraCentral`] fed live from the jera
/// ledger (or the zoned fixture), with the per-job zone column. A [`Facet`].
pub struct JeraTab {
    inner: JeraCentral,
    /// Where the jobs come from. `Arc` so the off-thread read worker clones it — the
    /// SAME shape the Coverage pane's `source` and the Dupes pane's `reader` have.
    source_seam: std::sync::Arc<dyn JeraJobSource>,
    limit: usize,
    /// **The KIND the read is scoped to** — `""` means every kind (`jerakind-1`).
    ///
    /// This is the SERVER-side window, not a lens over what arrived: it rides
    /// [`JeraJobSource::read`] into `Viz.Jobs`, whose `JobSelector::Kind` arm selects before
    /// the truncate. The shared jobs widget also has a kind filter (`Msg::SetKindFilter`)
    /// and the two are deliberately different things — that one narrows the rows on screen,
    /// this one changes which rows are fetched. Only this one can bring back a class the
    /// mixed window never carried (`workspace_populate`: 0 rows in the newest 200, 1 row
    /// in the ledger).
    kind: String,
    /// Every job kind this pane has SEEN, cumulative over the session — the picker's option
    /// list.
    ///
    /// Cumulative and not per-read, because the moment a kind is selected the read returns
    /// only that kind, and a picker rebuilt from the current rows would then offer exactly
    /// one option and no way back. It is also why the picker carries a typed entry beside
    /// it: a class that has never appeared in a mixed window cannot be in this set, and
    /// `workspace_populate` is precisely such a class.
    kinds_seen: std::collections::BTreeSet<String>,
    /// The text in the typed-kind box, which is not the same as [`Self::kind`]: a
    /// half-typed word must not re-scope the read on every keystroke.
    kind_draft: String,
    /// The state of the last **successful** read. A failed refresh does not land here;
    /// it lands in [`error`](Self::error) and leaves this — and the rows on screen —
    /// alone.
    source: JeraSource,
    /// The last failure, in the source's own words (`nornir-server unreachable: …`,
    /// `nornir warehouse transport cannot carry it: …`). `None` ⇒ the last read
    /// succeeded. Cleared by the next success, never by a blank.
    error: Option<String>,
    job_count: usize,
    auto_poll: bool,
    last_poll_s: f64,
    /// A read in flight (worker receiver + when it started). The frame polls it; the
    /// robot seam blocks on it. The read runs OFF the egui thread because it is a gRPC
    /// round trip, and the window must keep painting the PREVIOUS reading while it is
    /// in flight.
    pending: Option<(std::sync::mpsc::Receiver<JobsRead>, std::time::Instant)>,
    /// The source's GENERATION watch. A pane re-reads when its source MOVES — never
    /// because somebody pressed a button, and never on a frame where the source is
    /// byte-for-byte what it was at the last read (LAW 5 / `crate::generation`).
    ///
    /// A source with no cheap probe ([`JeraJobSource::generation`] = `None`) is read on
    /// the interval instead: for a remote ledger, asking *"did it change?"* is an RPC
    /// that costs exactly what the answer costs, so a probe would be a second round trip
    /// buying nothing. The cadence is unchanged either way.
    watch: crate::generation::Watch,
}

impl JeraTab {
    /// **The SHIPPED pane: `Viz.Jobs` over gRPC to the dwarves host.**
    ///
    /// `endpoint` is the FORGE (`:7900`), never nornir's `:7878` — the
    /// suite-host-embedded-auth LAW, guarded by `tests/robot_host_only.rs`. The forge
    /// proxies the call to its embedded nornir, stamping its own host bearer on the
    /// loopback hop, so there is one endpoint and one token in front of the UI.
    ///
    /// The first read STARTS at construction and is **not** waited on: the shell must
    /// not block on a network round trip to open, and a dead forge would otherwise hold
    /// the window for the connect timeout. Until it lands the pane says `pending`, which
    /// is a third thing from "no jobs" and from "the read failed".
    pub fn wire(
        endpoint: impl Into<String>,
        token: impl Into<String>,
        workspace: impl Into<String>,
    ) -> Self {
        Self::with_source_lazy(std::sync::Arc::new(WireJobs {
            endpoint: endpoint.into(),
            token: token.into(),
            workspace: workspace.into(),
        }))
    }

    /// Live over the jera ledger FILE at `ledger_root` — the road for a host that owns
    /// the ledger it is pointing at. Not the shipped tab's road ([`wire`](Self::wire)
    /// is); kept because "this ledger on this disk" is a different question from "the
    /// served workspace's ledger".
    pub fn live(ledger_root: impl Into<PathBuf>) -> Self {
        Self::with_source(std::sync::Arc::new(LedgerJobs {
            root: ledger_root.into(),
        }))
    }

    /// The pane over an injected source (the robot-test seam), with the first read done
    /// EAGERLY — the caller gets a pane whose `state_json` is already an answer. Right
    /// for a local source (a file `stat` + read); wrong for one that dials a socket, and
    /// [`with_source_lazy`](Self::with_source_lazy) is that one.
    pub fn with_source(source: std::sync::Arc<dyn JeraJobSource>) -> Self {
        let mut me = Self::with_source_lazy(source);
        me.wait();
        me
    }

    /// The pane over an injected source with the first read STARTED but not waited on.
    /// The frame lands it; a scripted caller lands it with [`wait`](Self::wait) or
    /// `{"refresh":true}`.
    pub fn with_source_lazy(source: std::sync::Arc<dyn JeraJobSource>) -> Self {
        let mut me = Self {
            inner: JeraCentral::new("dwarves — jera (jobs · zones)"),
            source_seam: source,
            limit: dwarves_embed::DEFAULT_LEDGER_LIMIT,
            kind: String::new(),
            kinds_seen: std::collections::BTreeSet::new(),
            kind_draft: String::new(),
            source: JeraSource::Unread,
            error: None,
            job_count: 0,
            auto_poll: true,
            last_poll_s: 0.0,
            pending: None,
            watch: crate::generation::Watch::new(POLL_INTERVAL_S),
        };
        me.start();
        me
    }

    /// The multi-zone fixture panel (offline; forces the fixture source) — used by the
    /// robot tests and any host without a ledger. (`allow(dead_code)`: the shipped bin
    /// uses [`wire`](Self::wire); this test-facing constructor is unreferenced there.)
    #[cfg(any(test, feature = "fixtures"))]
    #[allow(dead_code)]
    pub fn from_fixture() -> Self {
        let mut me = Self {
            inner: JeraCentral::new("dwarves — jera (jobs · zones)"),
            source_seam: std::sync::Arc::new(LedgerJobs {
                root: PathBuf::from("/nonexistent/dwarves-gui/jera-fixture"),
            }),
            limit: dwarves_embed::DEFAULT_LEDGER_LIMIT,
            kind: String::new(),
            kinds_seen: std::collections::BTreeSet::new(),
            kind_draft: String::new(),
            source: JeraSource::Fixture,
            error: None,
            job_count: 0,
            auto_poll: false,
            last_poll_s: 0.0,
            pending: None,
            watch: crate::generation::Watch::new(POLL_INTERVAL_S),
        };
        let jobs = fixture_zoned_jobs();
        me.job_count = jobs.len();
        me.inner.set_jobs(jobs);
        me
    }

    /// **Start a read OFF the egui thread.** The worker owns a clone of the source; the
    /// frame keeps painting the previous reading and [`poll`](Self::poll) lands the
    /// result. A read started while one is in flight is not a second round trip.
    pub fn start(&mut self) {
        if self.source == JeraSource::Fixture || self.pending.is_some() {
            // The explicit demo pane keeps its demo; only a LIVE pane reads.
            return;
        }
        let source = std::sync::Arc::clone(&self.source_seam);
        let limit = self.limit;
        let kind = self.kind.clone();
        let (tx, rx) = std::sync::mpsc::channel();
        if std::thread::Builder::new()
            .name("jera-jobs-read".into())
            .spawn(move || {
                let _ = tx.send(source.read(limit, &kind));
            })
            .is_err()
        {
            // A worker that could not even be SPAWNED is a named failure, not a blank.
            self.land(JobsRead::Failed(
                "could not spawn the jera jobs read worker".into(),
            ));
            return;
        }
        self.pending = Some((rx, std::time::Instant::now()));
    }

    /// Non-blocking: land the in-flight read if the worker finished. Called once/frame.
    pub fn poll(&mut self) {
        let Some((rx, _)) = &self.pending else { return };
        match rx.try_recv() {
            Ok(read) => {
                self.pending = None;
                self.land(read);
            }
            Err(std::sync::mpsc::TryRecvError::Empty) => {}
            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
                self.pending = None;
                // A DEAD WORKER IS A NAMED FAILURE. It keeps the previous reading and
                // records the reason: blanking a good job list because a thread died
                // would be the very failure this pane exists to prevent.
                self.land(JobsRead::Failed(
                    "the jera jobs read worker died before it answered".into(),
                ));
            }
        }
    }

    /// Blocking: wait for the in-flight read. The ROBOT seam (`update_json`) and the
    /// cold-start read use this so a scripted read is complete when the call returns;
    /// the window never blocks on it.
    pub fn wait(&mut self) {
        let Some((rx, _)) = self.pending.take() else {
            return;
        };
        let read = rx.recv().unwrap_or(JobsRead::Failed(
            "the jera jobs read worker died before it answered".into(),
        ));
        self.land(read);
    }

    /// Whether a read is in flight — the oracle that lets a caller tell "still reading"
    /// from "read nothing".
    pub fn is_pending(&self) -> bool {
        self.pending.is_some()
    }

    /// (Re-)read the source, synchronously. One body: [`start`](Self::start) then
    /// [`wait`](Self::wait), so the blocking road and the frame road cannot drift.
    ///
    /// **The fixture is not a fallback.** A live pane renders what the source actually
    /// says — rows, idle, gone, or a named failure — because the fallback is what turned
    /// a removed workspace into three fabricated jobs.
    pub fn refresh(&mut self) {
        self.start();
        self.wait();
    }

    /// Fold a landed read into the pane.
    ///
    /// The two properties every pane in this repo holds, and this one now holds too:
    /// **a failed read keeps the previous reading on screen**, and **a failure is
    /// NAMED**. The only failure that blanks is one with nothing to blank — a pane that
    /// has never had a good reading, which lands in [`JeraSource::Unreadable`] and says
    /// so in words.
    fn land(&mut self, read: JobsRead) {
        match read {
            JobsRead::Rows(entries) => {
                self.error = None;
                self.source = JeraSource::Ledger;
                // The picker's option list grows from what has actually been read — never
                // from a roster kept beside `jera::jobs::kind`, which would be a second copy
                // free to drift from the constants the forge emits.
                for e in &entries {
                    if !e.kind.is_empty() {
                        self.kinds_seen.insert(e.kind.clone());
                    }
                }
                let jobs = zoned(entries);
                self.job_count = jobs.len();
                self.inner.set_jobs(jobs);
            }
            JobsRead::Empty => {
                self.error = None;
                self.source = JeraSource::Idle;
                self.job_count = 0;
                self.inner.set_jobs(Vec::new());
            }
            JobsRead::Absent => {
                self.error = None;
                self.source = JeraSource::Absent;
                self.job_count = 0;
                self.inner.set_jobs(Vec::new());
            }
            JobsRead::Failed(why) => {
                self.error = Some(why);
                // Nothing on screen yet ⇒ the pane's whole state IS the failure, and it
                // must say so rather than draw an empty list. Something on screen ⇒ the
                // rows and the source stay exactly as they were, and the failure rides
                // beside them as a STALE marker.
                if self.job_count == 0 && self.source != JeraSource::Ledger {
                    self.source = JeraSource::Unreadable;
                }
            }
        }
    }

    /// What the header says about WHERE the jobs came from (the source's own words).
    fn whence(&self) -> String {
        self.source_seam.describe()
    }

    /// True when auto-poll is on AND at least one interval has elapsed. Pure.
    pub fn poll_due(&self, now_s: f64) -> bool {
        self.auto_poll && (now_s - self.last_poll_s) >= POLL_INTERVAL_S
    }
}

impl JeraTab {
    /// **The jobs/zones TREE — the master half.** The shared `facett_zoneui::JeraCentral`
    /// (zone summary column + the reused jobs tree), plus the LIVE auto-poll that feeds
    /// it. The poll lives on the master because the master is drawn FIRST in the split,
    /// so a refresh lands in the tree on the same frame it fires.
    fn jobs_ui(&mut self, ui: &mut egui::Ui) {
        let now_s = ui.input(|i| i.time);
        // Land an in-flight read first, if the worker finished — the previous reading is
        // what has been on screen while it travelled.
        self.poll();
        // GENERATION, not a click and not a blind interval: probe the source's cheap
        // fingerprint and re-read only when it MOVED. An unchanged ledger FILE costs one
        // `stat` per interval and nothing else — which is what makes deleting the
        // `⟳ Refresh` button an improvement rather than a capability loss.
        //
        // A source with no cheaper probe than its own answer (the WIRE) reads on the
        // interval instead. That is not a regression to blind polling: for a remote
        // ledger a generation probe IS a round trip, so a probe would double the traffic
        // to save nothing. Either way there is one read in flight at a time and no
        // button.
        if self.auto_poll {
            let due = match self.source_seam.generation() {
                Some(_) => {
                    let seam = std::sync::Arc::clone(&self.source_seam);
                    self.watch
                        .moved(now_s, || seam.generation().unwrap_or_default())
                }
                None => now_s - self.last_poll_s >= POLL_INTERVAL_S,
            };
            if due {
                self.start();
                self.last_poll_s = now_s;
            }
            ui.ctx()
                .request_repaint_after(Duration::from_secs_f32(POLL_INTERVAL_S as f32));
        }
        self.inner.ui(ui);
    }

    /// **THE JOB-CLASS WINDOW — which kinds the READ asks for** (`jerakind-1`).
    ///
    /// # The defect
    ///
    /// The pane asked `Viz.Jobs` for the newest rows of EVERY kind and the server truncated
    /// that mix. MEASURED on the live dwarves forge 2026-08-27, the server's own default
    /// 200-row window against the same workspace's ledger:
    ///
    /// ```text
    ///   snapshot_member  57      scip_member 47      knowledge_member 38   → 142 of 200
    ///   lens             26      of 200+ in the ledger
    ///   feed              6      of 84                    (7.1 %)
    ///   workspace_populate 0     of 1                     (invisible)
    /// ```
    ///
    /// A populate emits one job per workspace member per phase, so three per-member kinds
    /// take 71 % of the window and a class that ran once cannot be seen at all. No
    /// client-side filter recovers it — the rows never arrived.
    ///
    /// # Why a picker AND a typed box
    ///
    /// The picker's options are the kinds this pane has actually seen
    /// ([`Self::kinds_seen`]), because a hard-coded roster beside `jera::jobs::kind`'s 53
    /// constants would be a second copy free to drift from the ones the forge emits. But a
    /// kind that has never survived a mixed window is by definition NOT in that set —
    /// `workspace_populate` is exactly such a class — so a typed entry sits beside the
    /// picker. It commits on Enter or on ⏎, not per keystroke, because a half-typed word
    /// must not re-scope the read.
    ///
    /// The shared jobs widget has its own kind filter and this is deliberately not it: that
    /// one narrows the rows on screen, this one changes which rows are fetched. Both are
    /// useful and they compose; only this one can bring back a class the window never
    /// carried.
    fn kind_ui(&mut self, ui: &mut egui::Ui) {
        let all = "alla klasser";
        let current = if self.kind.is_empty() {
            all.to_string()
        } else {
            self.kind.clone()
        };
        let mut pick: Option<String> = None;
        egui::ComboBox::from_id_salt("jera_server_kind")
            .selected_text(&current)
            .show_ui(ui, |ui| {
                if ui.selectable_label(self.kind.is_empty(), all).clicked() {
                    pick = Some(String::new());
                }
                // The SELECTED kind is offered even when it is not in `kinds_seen` — a
                // typed class scopes the read to itself, so its own rows are the only ones
                // that come back and nothing would re-add it to the set.
                let mut opts: Vec<String> = self.kinds_seen.iter().cloned().collect();
                if !self.kind.is_empty() && !opts.contains(&self.kind) {
                    opts.push(self.kind.clone());
                    opts.sort();
                }
                for k in opts {
                    if ui.selectable_label(self.kind == k, &k).clicked() {
                        pick = Some(k);
                    }
                }
            });
        let box_r = ui.add(
            egui::TextEdit::singleline(&mut self.kind_draft)
                .hint_text("klass, t.ex. workspace_populate")
                .desired_width(200.0),
        );
        let entered = box_r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
        let clicked = ui
            .button("⏎")
            .on_hover_text("läs om, avgränsat till den klassen")
            .clicked();
        if (entered || clicked) && !self.kind_draft.trim().is_empty() {
            pick = Some(self.kind_draft.trim().to_string());
        }
        if let Some(k) = pick {
            // A NEW WINDOW IS A NEW READ. Setting the field without re-reading would leave
            // the previous mix on screen under a label naming one class — the exact
            // "the pane says one thing and shows another" shape this whole pass is about.
            let changed = self.kind != k;
            self.kind = k;
            if changed {
                self.start();
            }
        }
        // What the window IS, in words, beside the count that came out of it. A `0 jobs`
        // under a kind window means "none of this class", which is a different fact from
        // "the forge is idle" — and the count alone cannot say which.
        if !self.kind.is_empty() {
            ui.label(
                egui::RichText::new(format!(
                    "läsningen är avgränsad till {} — servern väljer klassen FÖRE sin                      avhuggning, så inga rader trängs ut av en pratigare klass",
                    self.kind
                ))
                .small()
                .italics(),
            );
        }
    }

    /// **The live-FEED control strip — the inspector half.** Refresh · live/paused ·
    /// the job class window · the job count and where the jobs came from.
    ///
    /// This sat as a `ui.horizontal` row ABOVE the tree in one vertical flow, so every
    /// row it grew came straight out of the tree's height and there was no divider to
    /// win it back. It is its own window now; stacked vertically, because a control
    /// strip in a 24%-wide column is a list, not a row.
    fn feed_ui(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
        let now_s = ui.input(|i| i.time);
        // NO ⟳ Refresh button. The ledger is re-read when `jobs.redb` moves
        // (`jobs_ui`, via `crate::generation::Watch`); a button here would only let the
        // operator ask for a read that has already happened, and would teach that the
        // jobs list is something you request. SCAN is not a user action.
        let (glyph, hint) = if self.auto_poll {
            ("● live", "auto-polling — click to pause")
        } else {
            ("⏸ paused", "auto-poll paused — click to resume live")
        };
        if ui.button(glyph).on_hover_text(hint).clicked() {
            self.auto_poll = !self.auto_poll;
            self.last_poll_s = now_s;
        }
        ui.separator();
        self.kind_ui(ui);
        ui.separator();
        let whence = self.whence();
        let src = self.source.headline(&whence);
        // A COUNT is only honest once the state is stated. `0 jobs` beside "the
        // workspace is gone" means something entirely different from `0 jobs` beside
        // "the forge is idle", and the old header could not tell them apart.
        match self.source {
            JeraSource::Absent | JeraSource::Unreadable => {
                ui.label(
                    egui::RichText::new(&src)
                        .color(egui::Color32::from_rgb(0xc6, 0x28, 0x28))
                        .strong(),
                );
            }
            _ => {
                // PLAIN NUMBERS (a nordisk law).
                ui.label(format!("{} jobs · {}", self.job_count, src));
            }
        }
        // **A read IN FLIGHT is named on the surface.** "still reading" is not "read
        // nothing", and the list below (if any) is the PREVIOUS reading — it stays.
        if self.is_pending() {
            ui.label(
                egui::RichText::new(
                    "⟳ reading jobs… (the list below, if any, is the previous reading)",
                )
                .italics(),
            );
        }
        // **A FAILED refresh is named too, beside the reading it did not replace.** The
        // rows above are stale, and saying nothing about that is how a dead server looks
        // exactly like a quiet one.
        if let Some(why) = &self.error {
            let stale = if self.job_count > 0 {
                format!(
                    "⚠ the last read FAILED — {why} · the {} jobs above are the PREVIOUS reading",
                    self.job_count
                )
            } else {
                format!("⚠ the last read FAILED — {why}")
            };
            ui.label(
                egui::RichText::new(stale)
                    .color(egui::Color32::from_rgb(0xc6, 0x28, 0x28))
                    .strong(),
            );
        }
        serde_json::json!({
            "jobs": self.job_count,
            "source": self.source.key(),
            "source_headline": src,
            // The robot oracle names the state directly, so a test asserts "gone"
            // rather than pattern-matching a sentence.
            "ledger_absent": self.source == JeraSource::Absent,
            "auto_poll": self.auto_poll,
            "whence": whence,
            // The three oracles a pane with an off-thread read owes a robot: is a read in
            // flight, did the last one fail, and is what is on screen stale because of it.
            "pending": self.is_pending(),
            "error": self.error.clone(),
            "stale": self.error.is_some() && self.job_count > 0,
        })
    }
}

/// **The Jera body DECOMPOSES**: the jobs/zones tree is the master, the live-feed control
/// strip the detail — two scene panes with a draggable divider, each with its own chrome.
impl crate::tabbed::SplitPanel for JeraTab {
    fn ui_main(&mut self, ui: &mut egui::Ui) {
        self.jobs_ui(ui);
    }

    fn ui_inspector(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
        self.feed_ui(ui)
    }
}

impl Facet for JeraTab {
    fn title(&self) -> &str {
        "Jera"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        // Both halves, in order — the shape this pane keeps when mounted ALONE (the unit
        // tests below box it bare). In the shell it is a `SplitPanel`: the jobs tree and
        // the live-feed control strip become two scene panes with a draggable divider.
        self.feed_ui(ui);
        ui.separator();
        self.jobs_ui(ui);
    }

    fn state_json(&self) -> serde_json::Value {
        // Start from the shared JeraCentral's rich state (the reused jobs tree + the
        // zone column: `zones`, `zone_summary`, `zones_present`) and ADD dwarves' host
        // fields + the NO-bare-demo-button guard (the design's oracle).
        let mut v = Facet::state_json(&self.inner);
        if let Some(obj) = v.as_object_mut() {
            obj.insert("kind".into(), serde_json::json!("jera"));
            obj.insert("source".into(), serde_json::json!(self.source.key()));
            obj.insert("job_count".into(), serde_json::json!(self.job_count));
            // WHY there are no jobs, not just how many. `absent` means the workspace is
            // gone (its `builds/` tree, and `jobs.redb` with it); `idle` means a real,
            // live, empty ledger. A count alone cannot tell those apart, and the pane
            // used to render the DEMO FIXTURE for both.
            obj.insert(
                "ledger_absent".into(),
                serde_json::json!(self.source == JeraSource::Absent),
            );
            obj.insert(
                "source_headline".into(),
                serde_json::json!(self.source.headline(&self.whence())),
            );
            obj.insert("auto_poll".into(), serde_json::json!(self.auto_poll));
            // **THE READ'S OWN WINDOW** (`jerakind-1`). `server_kind` is the class the READ
            // asked for (`""` = every kind), not a lens over what arrived — the distinction
            // the shared widget's own `kind_filter` cannot express. `kinds_seen` is what the
            // picker can offer, and a robot needs it to tell "this class has no rows" from
            // "this pane has never seen this class name".
            obj.insert("server_kind".into(), serde_json::json!(self.kind.clone()));
            obj.insert(
                "kinds_seen".into(),
                serde_json::json!(self.kinds_seen.iter().cloned().collect::<Vec<String>>()),
            );
            // WHERE the jobs came from, in the source's own words — the WIRE
            // (`Viz.Jobs @ …`) or a FILE (`jera ledger @ …`). `tests/robot_host_only.rs`
            // folds this in with every other pane's state and proves no `:7878` address
            // appears anywhere in it: the UI reaches nornir THROUGH the forge.
            obj.insert("whence".into(), serde_json::json!(self.whence()));
            // The three oracles a pane with an off-thread read owes a robot. `pending`
            // exists so "still reading" is never mistaken for "read nothing"; `error`
            // and `stale` exist so a failed refresh is a NAMED failure sitting beside
            // the previous reading, not a blank.
            obj.insert("pending".into(), serde_json::json!(self.is_pending()));
            obj.insert("error".into(), serde_json::json!(self.error.clone()));
            obj.insert(
                "stale".into(),
                serde_json::json!(self.error.is_some() && self.job_count > 0),
            );
            // The framework guard: this central panel is the ONE jobs surface — there is
            // no bare "demo"/"run demo" button anywhere in it (RED if one reappears).
            obj.insert("has_demo_button".into(), serde_json::json!(false));
        }
        v
    }

    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
            return;
        };
        // The tab's own controls.
        if let Some(on) = v.get("auto_poll").and_then(|r| r.as_bool()) {
            self.auto_poll = on;
            return;
        }
        if v.get("refresh").and_then(|r| r.as_bool()) == Some(true)
            || v.get("poll").and_then(|r| r.as_bool()) == Some(true)
        {
            self.refresh();
            return;
        }
        // The reused jobs-tree controls → the JobList's typed Msg (the shared widget
        // takes the no-op update_json default, so the host supplies the JSON→Msg seam).
        let list = self.inner.list_mut();
        if let Some(s) = v.get("sort").and_then(|s| s.as_str()) {
            if let Some(k) = parse_sort(s) {
                list.update(Msg::SetSort(k));
            }
        } else if let Some(id) = v.get("fold").and_then(|s| s.as_str()) {
            list.update(Msg::ToggleFold(id.to_string()));
        } else if let Some(sel) = v.get("select") {
            match sel {
                serde_json::Value::String(id) => {
                    list.update(Msg::Select(id.clone()));
                }
                serde_json::Value::Null => {
                    list.update(Msg::ClearSelection);
                }
                _ => {}
            }
        } else if let Some(sk) = v.get("server_kind") {
            // The SERVER-side window, driven headless. Distinct from `kind_filter` below,
            // which is the shared widget's lens over rows that already arrived: only this
            // one changes what is fetched, and only this one can reach a class the mixed
            // window never carried. `null` ⇒ every kind.
            let want = match sk {
                serde_json::Value::String(k) => k.clone(),
                serde_json::Value::Null => String::new(),
                _ => self.kind.clone(),
            };
            if want != self.kind {
                self.kind = want;
                self.refresh();
            }
        } else if let Some(kf) = v.get("kind_filter") {
            match kf {
                serde_json::Value::String(k) => {
                    list.update(Msg::SetKindFilter(Some(k.clone())));
                }
                serde_json::Value::Null => {
                    list.update(Msg::SetKindFilter(None));
                }
                _ => {}
            }
        }
    }
}

/// Parse a sort-column tag from the JSON seam (`None` = unknown).
fn parse_sort(s: &str) -> Option<SortKey> {
    Some(match s {
        "started" => SortKey::Started,
        "kind" => SortKey::Kind,
        "target" => SortKey::Target,
        "status" => SortKey::Status,
        "elapsed" => SortKey::Elapsed,
        _ => return None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// **RED-when-broken: the ONE Jera central panel carries the jera ledger AND the
    /// zone column** — the unification of the old Jobs / Jobs (live) duality into the
    /// shared `facett_zoneui::JeraCentral`. Asserts the reused jobs tree is present (the
    /// jera ledger) AND the framework zone column resolves each job's zone + a per-zone
    /// summary spanning all three zones. If the zone column regressed (JeraCentral fed a
    /// plain JobList, no zones), this is red.
    #[test]
    fn jera_tab_is_one_central_panel_with_the_zone_column() {
        let t = JeraTab::from_fixture();
        let s = t.state_json();
        assert_eq!(s["kind"], "jera");
        assert_eq!(s["source"], "fixture");
        // The reused jera ledger (jobs tree) is present — the old "Jobs" leg.
        assert_eq!(s["count"], 7, "3 runs + 4 dispatched steps");
        assert_eq!(s["parents"], 1, "the build run is the one foldable parent");
        assert_eq!(s["running"], 1, "the bench run is in flight");
        assert_eq!(s["failed"], 1, "the release run failed");
        // The framework ZONE COLUMN — the new unified bit (JeraCentral over the ledger).
        assert_eq!(s["zones"]["#1042"], "demo", "the build run's zone");
        assert_eq!(s["zones"]["#1040"], "test", "the bench run's zone");
        assert_eq!(s["zones"]["#1039"], "prod", "the release run's zone");
        let present = s["zones_present"].as_array().unwrap();
        assert!(
            present.iter().any(|z| z == "demo")
                && present.iter().any(|z| z == "test")
                && present.iter().any(|z| z == "prod"),
            "the zone column spans all three zones: {present:?}"
        );
        assert_eq!(
            s["zone_summary"]["demo"], 5,
            "5 jobs in demo (build + 4 steps)"
        );
        // The framework guard: no bare demo button in the unified panel.
        assert_eq!(s["has_demo_button"], false);
    }

    /// ROBOT SCENARIO — the unified **Jera** central panel driven purely over the remote
    /// seam (`state_json` out / `update_json` in), the same channel a `nornir-robotui`
    /// scenario + a thin gRPC client use — NO pixels, self-driving in code. Fold the
    /// build parent, sort, select, filter by zone-spanning kind — all through the reused
    /// JobList seam JeraCentral delegates to. Emits a `nornir-testmatrix` cell (reuse edda).
    #[test]
    fn robot_jera_central_panel_drives_over_the_seam() {
        let mut t = JeraTab::from_fixture();

        // Fold the build parent → its four children drop out of the display tree.
        let before = t.state_json()["jobs"].as_array().unwrap().len();
        t.update_json(r##"{"fold":"#1042"}"##);
        let folded = t.state_json()["jobs"].as_array().unwrap().len();
        let fold_ok = before == 7 && folded == 3;

        // Unfold, select a dispatched step, sort by elapsed.
        t.update_json(r##"{"fold":"#1042"}"##);
        t.update_json(r##"{"select":"#1042.1"}"##);
        t.update_json(r#"{"sort":"elapsed"}"#);
        let s = t.state_json();
        let select_ok = s["selected"] == "#1042.1";
        let sort_ok = s["sort"] == "Elapsed";

        // Pause live auto-poll over the seam (the ● live / ⏸ toggle).
        t.update_json(r#"{"auto_poll":false}"#);
        let poll_ok = t.state_json()["auto_poll"] == serde_json::json!(false);

        // The zone column survives the drive (still spans all three zones).
        let zones_ok = t.state_json()["zones_present"].as_array().unwrap().len() == 3;

        let drivable = fold_ok && select_ok && sort_ok && poll_ok && zones_ok;
        assert!(drivable, "the unified Jera central panel drives fold/select/sort + zone column over the remote seam: {s}");

        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "dwarves-gui",
            "jera_central_panel_drivable",
            drivable,
            "the unified Jera central panel (facett_zoneui::JeraCentral — jera ledger + zone column) driven headless over state_json/update_json; the Jobs/Jobs(live) duality is now ONE panel",
        );
    }
}

#[cfg(all(test, not(target_arch = "wasm32")))]
mod workspace_lifecycle_tests {
    use super::*;
    use egui_kittest::kittest::Queryable;
    use facett_core::Facet;

    /// Render a `JeraTab` headless and return whether the given text is on screen.
    fn painted(tab: &mut JeraTab, needle: &str) -> bool {
        let cell = std::cell::RefCell::new(tab);
        let mut h = egui_kittest::Harness::builder()
            .with_size(egui::vec2(1400.0, 900.0))
            .build_ui(|ui| Facet::ui(*cell.borrow_mut(), ui));
        h.step();
        h.step();
        h.query_by_label_contains(needle).is_some()
    }

    /// **RED-when-broken: a ledger whose workspace is GONE renders as gone — never as
    /// "no jobs", and above all never as three jobs that do not exist.**
    ///
    /// The old pane fell through to `fixture_zoned_jobs()` whenever the read came back
    /// empty, and an absent ledger came back empty. So removing a workspace — which
    /// unlinks `<root>/<name>/builds/` and takes `jobs.redb` with it — made the Jera tab
    /// paint a green `build`, a running `bench` and a failed `release` that were pure
    /// fabrication. The owner, 2026-08-25: *nuke the workspace and the jobs are nuked with
    /// it* — and the pane must SAY so.
    ///
    /// Asserted against a real ledger file that really is created and really is unlinked,
    /// and against the RENDERED surface, not a flag.
    #[test]
    fn a_pane_over_a_removed_workspace_says_gone_and_never_invents_jobs() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_path_buf();

        // ── (1) the workspace's ledger EXISTS and carries a real row ──────────────
        let store = jera::jobs::JobStore::open(&root).expect("open ledger");
        store
            .start(
                "lens",
                "change_risk @nordisk",
                "nordisk",
                serde_json::json!({}),
            )
            .finish(serde_json::json!({ "rows": 1 }), "");
        drop(store);

        let mut tab = JeraTab::live(root.clone());
        let st = tab.state_json();
        assert_eq!(
            st["source"], "ledger",
            "a populated ledger reads as the ledger"
        );
        assert_eq!(st["job_count"], 1, "the ONE real row is the one rendered");
        assert_eq!(st["ledger_absent"], serde_json::json!(false));

        // ── (2) NUKE the workspace: its builds/ tree goes, ledger and all ─────────
        std::fs::remove_file(root.join("jobs.redb")).unwrap();
        assert!(
            !root.join("jobs.redb").exists(),
            "the ledger really is gone"
        );

        let mut tab = JeraTab::live(root.clone());
        let st = tab.state_json();
        assert_eq!(
            st["source"], "absent",
            "a removed workspace's ledger must read as ABSENT, not as an idle ledger"
        );
        assert_eq!(st["ledger_absent"], serde_json::json!(true));
        // THE regression this exists for: the fixture must not stand in for the truth.
        assert_eq!(
            st["job_count"], 0,
            "a removed workspace has NO jobs — the demo fixture must never be rendered \
             as though it were the ledger. `job_count` was 5 when it was."
        );

        // …and the surface SAYS it, in words, where an operator will read them.
        assert!(
            painted(&mut tab, "NO LEDGER"),
            "the pane must state that the ledger is gone; a blank list is the same \
             picture as a quiet forge: {}",
            tab.state_json()["source_headline"]
        );
        assert!(
            !painted(&mut tab, "cargo-build"),
            "no fixture job may be painted for a workspace that is gone"
        );

        // ── (3) an IDLE forge is a THIRD thing, and says something different ──────
        let store = jera::jobs::JobStore::open(&root).expect("re-open ledger");
        drop(store);
        let mut tab = JeraTab::live(root.clone());
        assert_eq!(
            tab.state_json()["source"],
            "idle",
            "a ledger that exists with no rows is an idle forge, not a deleted workspace"
        );
        assert!(
            !painted(&mut tab, "NO LEDGER"),
            "…and must NOT claim the workspace is gone"
        );
        assert!(painted(&mut tab, "idle"), "…it says it is idle");
    }
}