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(¤t)
.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");
}
}