dwarves-gui/src/warehouse_select.rs
//! **WarehouseSelector** — dwarves' warehouse/workspace selector, a faithful mirror
//! of nornir's viz workspace picker (`nornir/src/viz/app.rs`).
//!
//! The model (nornir parity): MANY warehouses may be registered, but exactly ONE is
//! **selected/active** at a time (`""` = none — a legitimate resting state, not an
//! error). Every warehouse-bound function binds to the selection: the host routes the
//! chosen name to each tab's `set_workspace` (the funnel re-fetches THAT warehouse),
//! and release/warehouse-writes target it ([`release_target`](WarehouseSelector::release_target)).
//! This is the "active warehouse" that sits beside the "active zone".
//!
//! nornir's shape reproduced:
//! * `warehouses: Vec<String>` — the registered options offered in the picker.
//! * `selected: String` — the ONE active name (`""` = none).
//! * `state_json.workspace_picker = { options, selected, count }` → here surfaced as
//! the top-level `selected_warehouse` + a `warehouse_select` slice.
//! * the egui `ComboBox` with a `⚠ none — pick one` label when nothing is selected.
//!
//! It is NOT a `Facet`/tab — it is a header widget the [`TabbedView`](crate::tabbed)
//! owns and renders above the tab bar; its `ui` returns the newly-chosen name so the
//! host can broadcast the re-bind.
use nornir_warehouse_embed::{AddWarehouse, PopulateProgress};
/// How often the picker asks the server whether a populate is happening.
///
/// The poll is TWO cheap RPCs (`Workspaces.Get` + the compact `PopulateStatus`
/// roll-up), never the unpaginated `Viz.CloneEvents`. It runs whenever a warehouse is
/// selected and an endpoint is configured — NOT only after a button here started
/// something — because nornir's own poll loop, another client and `nornir workspace
/// populate` all move the same warehouse this shell is bound to.
const PROGRESS_POLL: std::time::Duration = std::time::Duration::from_millis(1500);
/// Every Nth progress poll also counts crates (a `dep_graph_edges` scan). The repo
/// denominator is exact and free; the crate count is the REFINING estimate and costs a
/// table scan, so it arrives later and improves. `det är ok` that it moves.
const CRATE_REFINE_EVERY: u32 = 4;
/// Row cap on the crate-count scan. Bounded so a poll can never pull an unbounded
/// table across the wire.
const CRATE_SCAN_LIMIT: u32 = 200_000;
/// **The variable this client reads its host bearer from.** Named once, by the crate
/// that owns the resolution chain, so the code that reads it and every message that
/// names it cannot drift apart.
pub const TOKEN_ENV: &str = dwarves_embed::TOKEN_ENV;
/// **The credential this pane's messages describe** — the dwarves client chain
/// (`$DWARVES_SERVER_TOKEN`, then `~/.dwarves/token`), walked by
/// [`dwarves_embed::ClientCredential`].
///
/// The pane holds only a bearer STRING (`AddWarehouse::token`, a
/// nornir-warehouse-embed field), and a message that has to say *no credential
/// configured* versus *token rejected* needs to know which rung produced it. So the
/// chain is re-walked and matched against the string: if they agree — which is the
/// normal case, because `build_native_app` resolves the pane's bearer from this very
/// chain — the rung is a FACT from the walk. If they disagree, the host handed this
/// pane a bearer directly and the message says that instead of guessing a rung.
fn credential_for(token: &str) -> dwarves_embed::ClientCredential {
let chain = dwarves_embed::ClientCredential::resolve();
if chain.token.trim() == token.trim() {
return chain;
}
dwarves_embed::ClientCredential {
token: token.to_string(),
whence: if token.trim().is_empty() {
dwarves_embed::TokenSource::Nothing
} else {
dwarves_embed::TokenSource::Flag
},
}
}
/// The SHORT spellings that look right and are not. Naming them is the whole value of
/// the message: see [`credential_refusal`].
pub const TOKEN_ENV_LOOKALIKES: [&str; 2] = ["DWARVES_TOKEN", "NORNIR_TOKEN"];
/// The lookalike names, spelled as a message fragment.
fn misnamed(env: &dyn Fn(&str) -> Option<String>) -> Vec<&'static str> {
TOKEN_ENV_LOOKALIKES
.iter()
.copied()
.filter(|k| env(k).is_some_and(|v| !v.trim().is_empty()))
.collect()
}
/// **Refuse BEFORE the RPC — but only when the misconfiguration is unambiguous.**
///
/// MEASURED live 2026-08-22: the Add-warehouse form answered
/// `Workspaces.Register RPC: 'The request does not have valid authentication
/// credentials'`. The token was not wrong — there was none. The unit had been launched
/// with `DWARVES_TOKEN` / `NORNIR_TOKEN`, while this client reads [`TOKEN_ENV`]
/// (`DWARVES_SERVER_TOKEN`) and `unwrap_or_default()`s it, so it sent an EMPTY bearer
/// and the server answered with its generic refusal.
///
/// ## Why this does NOT simply refuse every empty token
///
/// Because an empty bearer is legitimate here: `lib.rs` documents "Empty ⇒ open (the
/// host's dev default)", and the pane's own gRPC tests drive a mock that wants no
/// credential. A blanket refusal would turn a working dev shell into a dead one, which
/// is a worse defect than the one being fixed.
///
/// So the split is by EVIDENCE. A short lookalike name carrying a value while the real
/// one is empty has no innocent reading — somebody set a token, meaning it for this
/// client, and spelled it wrong. That is refused here, by name, before a request
/// leaves the process, because sending an empty bearer in that state can only produce
/// the useless generic answer. Both-unset is ambiguous (it is exactly what an open
/// server looks like), so it flies, and [`explain_auth_failure`] names it afterwards
/// if the server does refuse.
///
/// ## The FILE rung, which this message used to deny
///
/// It said *"There is no `~/.nornir/token` fallback — this shell holds no nornir
/// address, so it holds no nornir credential"*, and the reasoning was sound about
/// NORNIR and wrong about the conclusion. The client's endpoint is a DWARVES endpoint,
/// so the credential resolves from the DWARVES chain — `$DWARVES_SERVER_TOKEN`, then
/// `~/.dwarves/token`, the same 0600 file `dwarves-server` reads when it resolves the
/// secret it will demand, and the same ladder the `dwarves` CLI has always used
/// (`nornir_service::resolve_token`). The owner, 2026-08-27: *"the token chain is wrong
/// EVERY time server and client are started."*
///
/// `~/.nornir/token` is still NOT a rung, and on oden the two files are byte-identical
/// (`cmp` says so), which is exactly why reading the wrong one would go unnoticed here
/// and break on the first box where they differ. What changed is that the message now
/// names the rung the operator can actually USE.
///
/// `env` is injected so the ladder is testable without mutating process environment —
/// `std::env::set_var` is unsound across test threads and would make the guard flaky.
pub fn credential_refusal(token: &str, env: &dyn Fn(&str) -> Option<String>) -> Option<String> {
if !token.trim().is_empty() {
return None;
}
let wrong = misnamed(env);
if wrong.is_empty() {
return None;
}
let file = dwarves_embed::token_file()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<no $HOME, so no token file>".into());
Some(format!(
"error: NO CREDENTIAL SENT — nothing was requested. This client walks \
${TOKEN_ENV}, then {file}, and neither held a secret — but ${} IS set: that is \
the wrong name. Relaunch with ${TOKEN_ENV}, or write the host's token into \
{file} (0600), which is a supported rung and the same file dwarves-server \
reads. This is NOT the server refusing a token; no request was made.",
wrong.join(" and $")
))
}
/// **Say which KIND of auth failure this was**, once the server has answered.
///
/// The server's refusal — `The request does not have valid authentication
/// credentials` — reads identically for four different situations: no token, a wrong
/// token, a revoked token, and a token sent to a server that wanted a different one.
/// The client knows something the server does not, namely what it sent, and it must
/// say it. That is the whole silent-shape disease: a state the UI CAN distinguish,
/// reported as a state it cannot.
///
/// Returns `None` for anything that is not an auth refusal — a clone failure, a
/// missing member, a dead endpoint all keep their own words untouched.
pub fn explain_auth_failure(
status: &str,
token: &str,
env: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
// The classification — is this an auth refusal at all, and is it *no credential*
// or *credential rejected* — belongs to the crate that owns the chain, so the two
// sentences and the chain they describe cannot drift apart (LAW 5). This function
// adds the ONE thing that is local to this pane: the misspelled-variable hint,
// which came out of a measured live incident and has no home in the resolver.
explain_auth_failure_with(status, &credential_for(token), env)
}
/// [`explain_auth_failure`] with the credential handed in — the PURE half.
///
/// Split out so the guard below asserts the four sentences without reading this box's
/// `$HOME` or its environment. [`credential_for`] walks the real chain, which is right
/// for the pane and wrong for a test: on oden `~/.dwarves/token` exists and holds a
/// secret, so a test that went through the wrapper would be measuring this machine's
/// configuration instead of the classification.
pub fn explain_auth_failure_with(
status: &str,
cred: &dwarves_embed::ClientCredential,
env: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
let base = cred.explain_auth_failure(status)?;
if cred.is_configured() {
return Some(base);
}
let wrong = misnamed(env);
if wrong.is_empty() {
return Some(base);
}
Some(format!(
"{base}\n\nAND: ${} IS set — that is the wrong name for this client. Relaunch \
with ${TOKEN_ENV}.",
wrong.join(" and $")
))
}
/// A warehouse RPC in flight on the background worker.
///
/// `Workspaces.Register` clones the SSH members and builds the warehouse inside
/// nornir, and `Workspaces.Fetch` re-clones and rebuilds it — **minutes**, not
/// milliseconds. Run on the egui thread they freeze the whole shell mid-click, so
/// they run on ONE named worker (`dwarves-warehouse`, blocking I/O — not a CPU
/// fan-out) and their outcome lands here on a later frame.
#[derive(Debug)]
struct WarehouseJob {
/// What the worker is doing, shown live under the picker.
what: String,
/// Which verb is running — its landing decides what the picker does next.
verb: WarehouseVerb,
/// For [`WarehouseVerb::Remove`], the name being cleared (it must leave the picker
/// when the job lands, and the form's own record cannot say which name it was).
target: String,
/// The worker's own copy of the form, sent back with `status`/`registered`/
/// `last_fetch` filled in by the very same [`AddWarehouse`] methods the
/// synchronous path used — the status strings are not twinned here.
rx: std::sync::mpsc::Receiver<AddWarehouse>,
}
/// The two WHOLE actions the picker offers, mirroring nornir's own workspace
/// vocabulary. Each one either happens completely or reports that it did not.
///
/// `Resync` was the third, and it is GONE with its button: a re-sync is a populate, and
/// populate is what nornir's poll sweep does by itself when a member's git moves.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WarehouseVerb {
/// **Add** — register AND populate, as one act. There is no follow-up press that
/// finishes an add; if the populate did not happen, the add FAILED.
Add,
/// **Remove** — clear the registry row AND the on-disk warehouse together. Never
/// touches ssh keys or tokens.
Remove,
/// **Repopulate** — THE ONE REFRESH (`Workspaces.Repopulate { purge: true }`):
/// "make the code room of the SELECTED warehouse current, now" — drop the
/// orphaned v1 tables still in it and start a consume for every member behind
/// its offset.
///
/// Since Mímir v2 there is no derived cache to discard (a lens is computed on
/// request), so what this adds over nornir's own sweep is the WHEN and the
/// report: which state is served, which members are behind, what the consumer
/// is on, or nornir's named refusal when nothing can consume them.
///
/// It leaves the picker exactly as it found it: unlike Add and Remove, a Refresh
/// changes no registry row, so nothing folds in or drops out when it lands.
Repopulate,
}
/// The warehouse picker: a registry of registered warehouses + the ONE selected.
///
/// **Three verbs, all WHOLE actions** — nornir's own workspace vocabulary, mirrored:
///
/// * `➕ Add…` — registers **and** populates, as one act. There is no separate populate
/// step, and no "added but not yet populated" state to report green: the success
/// condition is a warehouse snapshot, so a failed member clone fails the add and the
/// warehouse does not fold into the picker.
/// * `🧹 Repopulate` — **THE ONE REFRESH** (`Workspaces.Repopulate { purge: true }`):
/// makes the selected warehouse's code graph current — drops the orphaned v1
/// tables and starts a consume for every member behind its offset — through the
/// server's open handle. One button, in the header, for the whole warehouse.
/// * `🗑 Remove…` — clears the registry entry **and** the on-disk warehouse together
/// (`Workspaces.Remove { purge: true }`). Two-step, because it destroys data. It never
/// touches ssh keys or tokens.
///
/// There is deliberately **no re-sync control**. A re-sync is `Workspaces.Fetch`, i.e. a
/// populate, and nornir's own poll sweep already fetches every monitored workspace and
/// republishes when a member's SHA moved — the same trigger, without an operator having
/// to know to press anything. SCAN is not a user action (owner, 2026-08-25).
///
/// **Why it is not called "Refresh".** It was, for about an hour, and
/// `no_button_literal_in_the_crate_is_scan_shaped` failed on it — correctly. That guard
/// bans the words `Scan` / `Rescan` / `Refresh` / `Reload` / `Re-sync` / `⟳` / `🔄` on
/// any pressable in this crate, and the ban is about the WORD, not the wiring: an
/// operator reading `🔄 Refresh` on a warehouse picker cannot tell it from the re-sync
/// button that was deleted on 2026-08-25, so the vocabulary alone re-teaches the thing
/// the deletion was meant to unteach — that a warehouse is something you poke by hand.
/// The verb is nornir's own (`Workspaces.Repopulate`), so the label is nornir's own
/// word, and the glyph is the purge rather than a cycle-arrow.
///
/// **`🧹 Repopulate` is not that button coming back, and the distinction is the point.**
/// Re-sync asked for a populate, which the sweep already does. Refresh asks for the
/// one thing auto-population *cannot* do by itself: throw away the derived answers so
/// the folds recompute instead of short-circuiting on their own cache
/// (`lens_runs` at the current generation). It re-clones nothing and re-scans nothing.
/// And it stays ONE button here rather than N per pane — the Mímir/warehouse panes are
/// READERS (see `mimir_lens.rs`), and thirteen lenses do not want thirteen buttons.
///
/// **When an action returns nothing to explain itself, the pane says THAT and points at
/// the RPC that can.** A populate that yields neither a snapshot nor a member error is
/// unexplainable *from this action's reply*; the per-member outcome lives in
/// `clone_events`, read by `Viz.CloneEvents` behind `nornir workspace events <name>`.
/// The pane names it rather than inventing a cause or drawing the silence as success.
///
/// ⚠ 2026-08-27: this doc used to say that RPC "answers `Unimplemented` server-side".
/// MEASURED false — nornir-server answers it with real rows; the dwarves forge's `Viz`
/// PROXY did not carry it, which made it unreachable for precisely this pane when the
/// UI runs thin against the forge. Now forwarded
/// (`dwarves::warehouse_proxy`, `clone_events`).
///
/// ⚠ 2026-09-03: **and this doc used to end "the pane still only NAMES it".** That was
/// true when the proxy fix landed and stopped being true when the fetch did — the pane
/// reads the rows on a named worker thread (`dwarves-clone-events`), paints them in
/// `warehouse_clone_events_grid`, publishes `clone_events_reads` /
/// `clone_events_fetching` in `state_json` so a headless run can see the READ and not
/// just a flag, and has two tests over it
/// (`the_pane_fetches_clone_events_and_paints_the_per_member_outcome`,
/// `a_refused_clone_events_read_is_named_not_drawn_as_an_empty_ledger`).
/// The stale sentence cost a real hour: a sweep on 2026-09-03 read it, grepped
/// `crates/**` — where `dwarves-gui/` is NOT, it is a top-level directory — found zero
/// hits, and filed the row as open. A doc that describes a finished thing as pending
/// is the same defect as a green that was never red, pointing the other way.
#[derive(Debug, Default)]
pub struct WarehouseSelector {
/// Every registered warehouse name (the picker options), stable order.
warehouses: Vec<String>,
/// The active selection (`""` = none selected — the onboarding resting state).
selected: String,
/// The "Add warehouse (SSH)" form + the endpoint/token Populate also uses (present
/// iff an add-source endpoint is configured). Add drives nornir's
/// `Workspaces.Register` (eager) and Populate its `Workspaces.Fetch` over gRPC —
/// the warehouse-WRITE happens in nornir.
add: Option<AddWarehouse>,
/// Whether the Add form is currently expanded in the header.
adding: bool,
/// The warehouse a `🗑 Remove…` click is asking to confirm (`None` = not asking).
/// Remove destroys data, so it is a two-step act: the first click ARMS it and says
/// exactly what will be cleared, the second one does it.
confirm_remove: Option<String>,
/// The ONE register/populate in flight (`None` = idle). At most one at a time: a
/// second would race the first's warehouse build.
job: Option<WarehouseJob>,
/// The live populate progress of the SELECTED warehouse, refreshed on a poll that
/// runs whether or not anything here started the populate. `None` before the first
/// poll answers.
progress: Option<PopulateProgress>,
/// The progress poll in flight (at most one), and when the last one started.
progress_rx: Option<std::sync::mpsc::Receiver<Option<PopulateProgress>>>,
last_progress_poll: Option<std::time::Instant>,
/// Poll counter — every [`CRATE_REFINE_EVERY`]th poll also refines the crate count.
progress_ticks: u32,
/// The last [`step_board::OrderFault`] message, if two producers raced on one run.
/// Held rather than logged: it names a bug in the producers, and the person who can
/// act on it is the one looking at the pane.
step_fault: Option<String>,
/// Was the picker's ComboBox popup open on the PREVIOUS frame? The edge detector
/// [`Self::ui`] uses to fire exactly ONE registry refresh per open — not one per
/// frame the popup happens to be on screen.
picker_open: bool,
/// The registry refresh in flight (at most one), carrying the served names.
registry_rx: Option<std::sync::mpsc::Receiver<Vec<String>>>,
/// How many registry refreshes have LANDED. Published in [`Self::state_json`]
/// because a number that moves is the only way a headless drive can see that
/// opening the picker actually went to the server — "the list looks the same" is
/// indistinguishable from "nothing was asked".
registry_refreshes: u32,
/// **The per-member populate outcomes (`clone_events`) for the SELECTED
/// warehouse** — `Ok(rows)` newest-first, or `Err(why)` when the read itself
/// failed. `None` = never asked.
///
/// A failed READ is kept as a failure and painted as one. Collapsing it to an
/// empty list would draw "the server would not answer" identically to "every
/// member populated cleanly", which is the exact silence this pane exists to
/// break.
clone_events: Option<Result<Vec<serde_json::Value>, String>>,
/// The warehouse `clone_events` belongs to, so switching the selection
/// invalidates them instead of showing one warehouse's failures under another's
/// name.
clone_events_ws: String,
/// The `Viz.CloneEvents` read in flight (at most one).
clone_events_rx: Option<std::sync::mpsc::Receiver<Result<Vec<serde_json::Value>, String>>>,
/// How many `Viz.CloneEvents` reads have LANDED — published in
/// [`Self::state_json`] so a headless drive can tell "the rows were fetched"
/// from "the pane merely names the RPC", which is the whole defect here.
clone_events_reads: u32,
}
/// One string field of a `clone_events` row, or `""`. The rows arrive as the
/// server's JSON verbatim (the `nornir-warehouse-embed` leg deliberately carries no
/// `nornir-wire` dependency), so the pane reads the wire contract's field names
/// rather than restating `CloneEventRow` as a second struct that would have to be
/// kept in agreement with it (LAW 5).
fn row_str(row: &serde_json::Value, field: &str) -> String {
row.get(field)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
}
impl WarehouseSelector {
/// Build a selector over `warehouses`, selecting `selected` iff it is a registered
/// name (else nothing is selected — the `""` resting state). An unknown initial
/// selection is never silently invented.
pub fn new(warehouses: Vec<String>, selected: impl Into<String>) -> Self {
let mut s = Self {
warehouses,
selected: String::new(),
add: None,
adding: false,
confirm_remove: None,
job: None,
progress: None,
progress_rx: None,
last_progress_poll: None,
progress_ticks: 0,
step_fault: None,
picker_open: false,
registry_rx: None,
registry_refreshes: 0,
clone_events: None,
clone_events_ws: String::new(),
clone_events_rx: None,
clone_events_reads: 0,
};
let want = selected.into();
if !want.is_empty() {
s.select(&want);
}
s
}
/// **Enable the "Add warehouse (SSH)" affordance** by pointing it at a
/// `nornir-server` endpoint (+ bearer token). Without this the selector is a pure
/// picker; with it the header grows an `➕ Add…` control that registers + populates
/// a warehouse from an SSH git remote via nornir's `Workspaces` RPCs.
pub fn with_add_source(
mut self,
endpoint: impl Into<String>,
token: impl Into<String>,
) -> Self {
self.add = Some(AddWarehouse::new(endpoint, token));
self
}
/// Mutable access to the Add form (for the host / robot test to drive it).
pub fn add_form_mut(&mut self) -> Option<&mut AddWarehouse> {
self.add.as_mut()
}
/// After a successful Add/Populate, fold the newly-registered warehouse into the
/// picker and select it (so every tab re-binds to the freshly-populated warehouse).
/// Returns the name iff it was newly registered+selected this call.
///
/// **A warehouse that was not POPULATED never folds in.** A register whose member
/// clone failed produces a record with no snapshot; showing it in the picker as a
/// selectable warehouse is drawing a failure as a success, which is exactly what
/// `✓ populated now (eager register)` over an empty `git/` did.
pub fn commit_added(&mut self) -> Option<String> {
let form = self.add.as_ref()?;
let name = form.registered.as_ref()?.name.clone();
let populated = form
.last_add
.as_ref()
.map(|a| a.populated)
.unwrap_or_else(|| form.registered.as_ref().is_some_and(|r| r.is_populated()));
if name.is_empty() || !populated {
return None;
}
let newly = self.register(name.clone());
// Select it whether or not it was newly added (re-populate keeps it selected).
self.select(&name);
if newly || self.selected == name {
Some(name)
} else {
None
}
}
/// The active warehouse name (`""` = none selected).
pub fn selected(&self) -> &str {
&self.selected
}
/// True iff a warehouse is currently selected.
pub fn has_selection(&self) -> bool {
!self.selected.is_empty()
}
/// The registered warehouse names (the picker options).
pub fn warehouses(&self) -> &[String] {
&self.warehouses
}
/// **The warehouse release/warehouse-writes bind to** — the SELECTED one. dwarves'
/// release ops and warehouse submits target this (never a hard-coded / env-only
/// default), so a release always writes to the active warehouse. `""` when none is
/// selected (a release must refuse rather than write to an unnamed warehouse).
pub fn release_target(&self) -> &str {
&self.selected
}
/// **Register a warehouse** (populate the registry). Idempotent: a duplicate name
/// is ignored. Returns `true` iff it was newly added.
pub fn register(&mut self, name: impl Into<String>) -> bool {
let name = name.into();
if name.is_empty() || self.warehouses.iter().any(|w| w == &name) {
return false;
}
self.warehouses.push(name);
true
}
/// **Select the active warehouse** by name. An empty string deselects (back to the
/// `""` none state). A non-empty name is honoured only if it is registered (an
/// unknown name is a no-op, never an invented selection). Returns `true` iff the
/// selection changed.
pub fn select(&mut self, name: impl AsRef<str>) -> bool {
let name = name.as_ref();
if name.is_empty() {
let changed = !self.selected.is_empty();
self.selected.clear();
return changed;
}
if self.warehouses.iter().any(|w| w == name) && self.selected != name {
self.selected = name.to_string();
return true;
}
false
}
/// **Re-read the registry from the server** — `Workspaces.List` through the dwarves
/// host, on the SAME thin `nornir-warehouse-embed` client the Add/Populate form uses.
///
/// Called by [`Self::ui`] on the frame the picker OPENS (see the `picker_open` edge
/// detector there), which is the honest moment: the picker IS the affordance that
/// shows the registry, so it is the one place a list frozen at process start is
/// indefensible. Until this existed, `list_warehouses` had exactly ONE call site in
/// the tree — `lib.rs::registered_warehouses`, run once inside `build_native_app` —
/// so a warehouse registered afterwards by the nornir CLI, a second dwarves window
/// or another operator was invisible until the app was restarted.
///
/// Off the UI thread (a gRPC round trip on it would stall the frame the click
/// opened), at most one in flight, and it never blocks the popup: the names land on
/// a later frame, exactly as the populate-progress poll does.
pub fn refresh_registry(&mut self, ctx: &egui::Context) {
if self.registry_rx.is_some() {
return; // one refresh at a time
}
let Some(form) = self.add.as_ref() else {
return;
};
let (endpoint, token) = (form.endpoint.clone(), form.token.clone());
let (tx, rx) = std::sync::mpsc::channel();
let ctx2 = ctx.clone();
// ONE named worker doing blocking I/O off the UI thread — not a CPU fan-out.
let spawned = std::thread::Builder::new()
.name("dwarves-warehouse-registry".to_string())
.spawn(move || {
let names = nornir_warehouse_embed::list_warehouses(&endpoint, &token)
.map(|served| {
served
.into_iter()
.map(|w| w.name)
.filter(|n| !n.is_empty())
.collect()
})
.unwrap_or_default();
let _ = tx.send(names);
ctx2.request_repaint();
});
if spawned.is_ok() {
self.registry_rx = Some(rx);
}
}
/// Land a finished registry refresh, if any.
///
/// **It UNIONS; it never replaces.** A name the shell was seeded with — from
/// `$DWARVES_WAREHOUSES`, or from a server that answered at startup and has since
/// gone quiet — survives a refresh, and so does the current selection. Replacing
/// the list would mean one failed or empty `Workspaces.List` silently takes the
/// user's warehouse away mid-session, which is a worse lie than a slightly long
/// list: `list_warehouses` returns `Ok(vec![])` for "unreachable" as readily as for
/// "genuinely none". Removal stays what it already is — the explicit 🗑 Remove verb.
fn land_registry(&mut self) {
let Some(rx) = self.registry_rx.as_ref() else {
return;
};
match rx.try_recv() {
Ok(names) => {
for n in names {
self.register(n);
}
self.registry_refreshes = self.registry_refreshes.wrapping_add(1);
self.registry_rx = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.registry_rx = None,
}
}
/// True iff a register/populate is running on the worker right now.
pub fn is_busy(&self) -> bool {
self.job.is_some()
}
/// True iff `🔄 Populate` is offered *and enabled* this frame: an endpoint is
/// configured, a warehouse is selected, and no other warehouse RPC is in flight.
/// This is a function of the SELECTION — nothing about it changes when the add form
/// opens or closes, which is exactly why the control survives a successful Add.
pub fn can_populate(&self) -> bool {
self.add.is_some() && self.has_selection() && self.job.is_none()
}
/// Start a warehouse RPC on the named worker. The worker mutates its own clone of
/// the form via the SAME [`AddWarehouse`] methods the UI would have called inline,
/// then ships it back — so status/outcome formatting lives in exactly one place.
fn spawn_job(
&mut self,
ctx: &egui::Context,
what: impl Into<String>,
verb: WarehouseVerb,
target: impl Into<String>,
op: impl FnOnce(&mut AddWarehouse) + Send + 'static,
) {
if self.job.is_some() {
return; // one warehouse build at a time
}
let Some(form) = self.add.as_ref() else {
return;
};
// ── THE CREDENTIAL PREFLIGHT ────────────────────────────────────────────
//
// Every warehouse write — Add, Refresh, Remove — is spawned from here, so
// this is the ONE place that can refuse before a request leaves the process.
// See [`credential_refusal`] for the incident it exists because of.
if let Some(why) = credential_refusal(&form.token, &|k| std::env::var(k).ok()) {
if let Some(f) = self.add.as_mut() {
f.status = why;
}
return;
}
let mut work = form.clone();
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
let spawned = std::thread::Builder::new()
.name("dwarves-warehouse".to_string())
.spawn(move || {
op(&mut work);
let _ = tx.send(work);
// Wake the shell so the outcome lands on the very next frame.
ctx.request_repaint();
});
match spawned {
Ok(_) => {
self.job = Some(WarehouseJob {
what: what.into(),
verb,
target: target.into(),
rx,
})
}
Err(e) => {
if let Some(f) = self.add.as_mut() {
f.status = format!("error: could not start the warehouse worker: {e}");
}
}
}
}
/// Land a finished job, if any. Returns `Some((verb, target))` on the frame the
/// outcome arrives. The user's `name`/`ssh_url` are NOT overwritten — they may have
/// kept typing while the RPC ran.
fn poll_job(&mut self) -> Option<(WarehouseVerb, String)> {
let job = self.job.as_ref()?;
let verb = job.verb;
let target = job.target.clone();
match job.rx.try_recv() {
Ok(done) => {
if let Some(f) = self.add.as_mut() {
// Every warehouse outcome lands here, so this is the one place that
// can tell "the server refused a token I sent" from "the server
// refused because I sent none". See `explain_auth_failure`.
f.status =
explain_auth_failure(&done.status, &f.token, &|k| std::env::var(k).ok())
.unwrap_or(done.status);
f.registered = done.registered;
f.last_fetch = done.last_fetch;
f.last_add = done.last_add;
f.last_removed = done.last_removed;
// The worker mutates a CLONE of the form, so every outcome the pane
// renders has to be carried back across here explicitly. `🧹 Repopulate`
// is the whole reason that matters: its ENTIRE visible result is this
// field — the queued jera job ids, or nornir's named refusal — because
// a Repopulate touches no registry row and so changes nothing else on
// screen. Omit this line and the button reports `null` forever while
// looking like it worked.
f.last_repopulate = done.last_repopulate;
}
self.job = None;
Some((verb, target))
}
Err(std::sync::mpsc::TryRecvError::Empty) => None,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// The worker died without reporting — say so, never hang on a spinner.
if let Some(f) = self.add.as_mut() {
f.status = "error: the warehouse worker died before reporting".to_string();
}
self.job = None;
Some((verb, target))
}
}
}
/// **Did the last `Add` genuinely produce a warehouse?** `false` when no add has
/// been run, and — crucially — `false` when the register succeeded over a
/// warehouse with no snapshot. A registered-but-empty warehouse is not an added
/// warehouse, so it never folds into the picker as if it were.
fn last_add_succeeded(&self) -> bool {
self.add
.as_ref()
.and_then(|a| a.last_add.as_ref())
.map(|o| o.populated)
.unwrap_or(false)
}
/// True iff `🗑 Remove…` is offered and enabled this frame.
pub fn can_remove(&self) -> bool {
self.add.is_some() && self.has_selection() && self.job.is_none()
}
/// True iff `🧹 Repopulate` is offered and enabled this frame — the same three
/// conditions Remove needs (an endpoint, a selection, no job in flight), because
/// it is the same kind of thing: a WRITE to the selected warehouse that must not
/// race another one.
pub fn can_repopulate(&self) -> bool {
self.add.is_some() && self.has_selection() && self.job.is_none()
}
/// The live populate progress of the selected warehouse, if a poll has answered.
pub fn progress(&self) -> Option<&PopulateProgress> {
self.progress.as_ref()
}
/// **Drive the populate-progress poll.** Called every frame; it starts at most one
/// RPC per [`PROGRESS_POLL`] and lands the result on a later frame.
///
/// It runs whenever a warehouse is selected and an endpoint is configured — NOT
/// only while a job of ours is in flight. A populate started by nornir's poll loop,
/// by another client, or from the command line moves the same warehouse this shell
/// is bound to, and the person looking at the screen is entitled to see it.
fn drive_progress(&mut self, ctx: &egui::Context) {
// Land a finished poll first.
if let Some(rx) = &self.progress_rx {
match rx.try_recv() {
Ok(p) => {
if let Some(p) = p {
// **Publish the transition.** The poll is the producer: it is the
// only thing here that sees a phase change, and it sees one
// whether or not a button in this process started the populate.
//
// The OrderFault is NOT swallowed. It means a second producer
// announced a phase over this one — the poll worker owns the
// clone while a Fetch from the button, from nornir's poll loop or
// from the CLI owns the build — and a person watching a bar go red
// is entitled to the sentence that says which two steps raced.
// The board has already marked the step Failed by the time this
// returns, so the bar is red either way; this only carries the
// words to the status line.
if let Err(fault) = crate::populate_steps::observe(
&p.workspace,
&p,
"a populate is under way on the server",
) {
self.step_fault = Some(fault.message());
}
self.progress = Some(p);
}
self.progress_rx = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.progress_rx = None,
}
}
let (Some(form), false) = (self.add.as_ref(), self.selected.is_empty()) else {
self.progress = None;
return;
};
if self.progress_rx.is_some() {
return; // one poll at a time
}
let due = self
.last_progress_poll
.is_none_or(|t| t.elapsed() >= PROGRESS_POLL);
if !due {
// Keep repainting while a populate is on screen so the bar actually moves.
if self.progress.as_ref().is_some_and(|p| p.running) {
ctx.request_repaint_after(PROGRESS_POLL);
}
return;
}
self.last_progress_poll = Some(std::time::Instant::now());
self.progress_ticks = self.progress_ticks.wrapping_add(1);
// The crate count is the REFINING estimate: it costs a table scan, so it is
// taken on every Nth tick rather than every one.
let limit = if self.progress_ticks % CRATE_REFINE_EVERY == 1 {
CRATE_SCAN_LIMIT
} else {
0
};
let (endpoint, token, ws) = (
form.endpoint.clone(),
form.token.clone(),
self.selected.clone(),
);
let (tx, rx) = std::sync::mpsc::channel();
let ctx2 = ctx.clone();
// ONE named worker doing blocking I/O off the UI thread — not a CPU fan-out.
let spawned = std::thread::Builder::new()
.name("dwarves-populate-progress".to_string())
.spawn(move || {
let p =
nornir_warehouse_embed::poll_populate_progress(&endpoint, &token, &ws, limit)
.ok();
let _ = tx.send(p);
ctx2.request_repaint();
});
if spawned.is_ok() {
self.progress_rx = Some(rx);
}
}
/// **The populate progress bar.** Drawn whenever a populate is happening OR one has
/// just finished with something to say — never only when a button here started it.
///
/// Indeterminate (a spinner) while there is no denominator; a proportion the moment
/// one exists. The counters are drawn beside it, and a counter that has not been
/// measured shows `…` while one this seam cannot measure shows `n/a` with the
/// reason on hover — neither is ever drawn as `0`.
fn progress_ui(&self, ui: &mut egui::Ui) {
// ── THE STEP BAR ────────────────────────────────────────────────────────
// Drawn from the BOARD, not from `self.progress`: the board is the producer's
// published claim and it survives between polls, so the bar is on screen on the
// frame after the run starts rather than on whichever frame an RPC happens to
// land. It is also what a robot posture seeds, which is how this gets a
// screenshot without a live populate to photograph.
// One segment per phase, drawn by the shared `facett_jobview` widget from the
// producer's own StepRun — so the sentence under it is the string
// `populate_steps` built, not a second one composed here that could disagree
// with it.
//
// This replaces a single `repos_done/repos_total` bar. That bar was not wrong
// about the clone; it was wrong about the other two thirds of the work, which it
// drew as 100%.
if let Some(run) =
crate::populate_steps::board().get(&crate::populate_steps::job_id(&self.selected))
{
let meter = run.to_meter();
facett_jobview::step_meter_ui(ui, &run.id, &meter);
}
// A producer race is a bug in dwarves, not a status of the populate — so it is
// said in words, in facett's warm red, next to the red segment. Before the early
// return below, because a fault is exactly the case where the poll may have
// stopped landing.
if let Some(fault) = &self.step_fault {
ui.colored_label(facett_core::look::RED, fault);
}
let Some(p) = &self.progress else { return };
// Nothing running and nothing populated ⇒ nothing more to say.
if !p.running && p.repos_total == 0 {
return;
}
ui.horizontal(|ui| {
// The CLONE phase's own fraction still gets a number beside the bar, because
// "12/19" is the thing a person actually wants off a clone. It is the same
// measured pair the step segment fills from — one source, two renderings of
// it is exactly what this pane used to get wrong.
if let Some(f) = p.fraction() {
ui.label(format!("clone {:.0}%", f * 100.0));
}
ui.label(p.counters_line())
.on_hover_text(nornir_warehouse_embed::BYTES_UNAVAILABLE_WHY);
});
if p.running {
ui.weak(format!(
"{} member(s) fetching · this bar follows the SERVER, so it shows a populate \
started anywhere — nornir's poll loop, another client, or the CLI",
p.repos_fetching
));
}
if p.repos_failed > 0 {
ui.colored_label(
egui::Color32::from_rgb(0xd9, 0x53, 0x4f),
format!(
"{} member(s) failed{}{}",
p.repos_failed,
if p.failing_member.is_empty() {
String::new()
} else {
format!(" — first: {}", p.failing_member)
},
if p.last_error.is_empty() {
String::new()
} else {
format!(": {}", p.last_error)
},
),
);
}
}
/// Drop a warehouse from the picker (after a landed Remove) and deselect if it was
/// the selection. Returns `true` iff it had been registered here.
fn forget(&mut self, name: &str) -> bool {
let Some(i) = self.warehouses.iter().position(|w| w == name) else {
return false;
};
self.warehouses.remove(i);
if self.selected == name {
self.selected.clear();
}
true
}
/// Render the picker (a `ComboBox`, nornir-style). Returns `Some(new_selection)`
/// **iff the selection changed this frame** (so the host re-binds every tab to it);
/// `None` when unchanged.
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<String> {
let ctx = ui.ctx().clone();
// ── land any finished background RPC BEFORE drawing ─────────────────────
// A landed REGISTER folds the new warehouse into the picker and selects it, so
// this frame already draws it as an option AND as the Populate target. (Doing
// this after the draw is what made the old form flicker: the state changed
// behind widgets that had already been laid out.)
// …including a finished REGISTRY refresh, so a warehouse somebody else
// registered is already an option on the frame that draws the open popup.
self.land_registry();
let mut committed: Option<String> = None;
let mut removed: Option<String> = None;
match self.poll_job() {
// An ADD folds in ONLY if it actually produced a warehouse. A register
// whose member clone failed must not appear in the picker as a warehouse —
// that is drawing a failure as a success, and it is the exact shape that
// printed `✓ populated now (eager register)` over an empty `git/`.
Some((WarehouseVerb::Add, _)) => {
if self.last_add_succeeded() {
if let Some(name) = self.commit_added() {
// The add is WHOLE — registered and populated — so there is no
// next step and the form can close.
self.adding = false;
committed = Some(name);
}
} else {
// Keep the form open with the failure on screen: the user has
// something to fix (a deploy key, a URL), not a button to press.
self.adding = true;
}
}
Some((WarehouseVerb::Remove, name)) => {
// Only drop it from the picker if the RPC said Ok — a failed Remove
// leaves the warehouse where it is.
let ok = self
.add
.as_ref()
.and_then(|a| a.last_removed.clone())
.is_some_and(|n| n == name);
if ok && self.forget(&name) {
removed = Some(name);
}
}
// A landed REFRESH changes nothing here, and that is the correct
// behaviour rather than a gap: `Workspaces.Repopulate` touches no
// registry row, so no warehouse folds in and none drops out. Its whole
// outcome — the queued job ids, or nornir's named refusal — is the
// status line `poll_job` already wrote from the worker's form.
Some((WarehouseVerb::Repopulate, _)) => {}
_ => {}
}
if self.job.is_some() {
ctx.request_repaint();
}
let before = self.selected.clone();
let mut chosen = self.selected.clone();
let label = if chosen.is_empty() {
"⚠ none — pick one".to_string()
} else {
chosen.clone()
};
ui.label("Warehouse:");
let combo = egui::ComboBox::from_id_salt("dwarves_warehouse_picker")
.selected_text(label)
.show_ui(ui, |ui| {
// A leading "none" option so the user can deselect.
ui.selectable_value(&mut chosen, String::new(), "⚠ none");
for w in &self.warehouses {
ui.selectable_value(&mut chosen, w.clone(), w);
}
});
// ── REFRESH ON OPEN ─────────────────────────────────────────────────────
// `show_ui` yields `Some` only while the popup is up, so this is a real
// closed→open EDGE, not a per-frame poll: opening the picker asks the server
// what is registered, once. It is the only interaction whose whole purpose is
// to show the registry, so it is where the list stops being a photograph taken
// at process start. The answer lands on a later frame (`land_registry`), which
// is why the popup never waits on a socket.
let popup_open = combo.inner.is_some();
if popup_open && !self.picker_open {
self.refresh_registry(&ctx);
}
self.picker_open = popup_open;
if self.add.is_some() {
// ── NO Re-sync button (owner 2026-08-25: SCAN is not a user action) ──
//
// Re-sync was `Workspaces.Fetch { force: false }` — a populate. populate is
// precisely the thing the whole architecture says happens BY ITSELF: nornir's
// own poll sweep (`spawn_server_poll_loop` → `republish_served`) fetches each
// monitored workspace's git members and republishes on change, which is what
// moves the generation and submits the feed + lens chains. A button here only
// let an operator ask for work the server already does on the same trigger,
// and it taught that a warehouse is something you refresh by hand.
//
// What remains reachable, deliberately: `➕ Add…` still registers AND
// populates as ONE whole action (a new warehouse has no poll history to ride
// on), and `🗑 Remove…` still destroys. Both are INTENTS, not data requests.
// ── Add warehouse (SSH): the WHOLE action ──────────────────────────
let toggle = if self.adding {
"✖ cancel"
} else {
"➕ Add…"
};
if ui.button(toggle).clicked() {
self.adding = !self.adding;
}
// ── 🧹 Repopulate: THE ONE REFRESH ────────────────────────────────────
//
// This is the button that the deleted per-pane Scan buttons were NOT.
// Scan asked, N times over, for work nornir already schedules by itself
// — a landed snapshot submits one jera chain job per lens, deduped by key
// and superseded by generation. What no button could ask for was the one
// thing auto-population cannot do on its own: *throw the derived answers
// away and compute them again*. `Workspaces.Repopulate { purge: true }`
// is that verb, and this is its ONE door. It does not come back as a Scan
// button per pane, and it never will: a pane is a READER.
//
// It shows what happened by NAME — the jera job ids the chain queued, so
// the operator can watch them in the Jobs panel; or nornir's own refusal
// sentence, which names the in-flight job id when the populate gate is
// held or when the chain asked for is already the chain running.
let can_repopulate = self.can_repopulate();
if ui
.add_enabled(can_repopulate, egui::Button::new("🧹 Repopulate"))
.on_hover_text(
"Repopulate the SELECTED warehouse: make its code graph current — drop the \
orphaned v1 tables still in it (the code room's lake is never touched) and \
start a consume for every member behind its offset. Shows which members, \
or nornir's named refusal when nothing can consume them.",
)
.on_disabled_hover_text("pick a warehouse first")
.clicked()
{
let name = self.selected.clone();
let what = format!("repopulating `{name}` (Workspaces.Repopulate --purge)…");
let target = name.clone();
self.spawn_job(
&ctx,
what,
WarehouseVerb::Repopulate,
&target,
move |form| {
// PURGE is the point of the button: a Repopulate that only
// re-enqueued would be answered by the chain's own short-circuit
// ("already computed at this generation") and change nothing an
// operator can see. The refusal/status the RPC returns is written
// into `form.status` by `repopulate_named` either way.
let _ = form.repopulate_named(&name, true);
},
);
}
// ── Remove: the OTHER whole action ─────────────────────────────────
// Two-step, because it destroys data: the first click arms it and states
// exactly what goes and what does not.
let can_remove = self.can_remove();
if ui
.add_enabled(can_remove, egui::Button::new("🗑 Remove…"))
.on_hover_text("clear the SELECTED warehouse: its registry entry AND its on-disk warehouse (warehouse + catalog.redb + jobs.redb). ssh keys and tokens are never touched.")
.on_disabled_hover_text("pick a warehouse first")
.clicked()
{
self.confirm_remove = Some(self.selected.clone());
}
if self.job.is_some() || !self.has_selection() {
self.confirm_remove = None;
}
if self.adding {
self.add_form_ui(ui, &ctx);
}
if let Some(victim) = self.confirm_remove.clone() {
self.remove_confirm_ui(ui, &ctx, &victim);
}
// ── the populate progress bar ───────────────────────────────────────
// Driven every frame and polled on its own cadence, so it is on screen for
// ANY populate — not only one this process started.
self.drive_progress(&ctx);
self.progress_ui(ui);
// ── the status line belongs to the PICKER, not the form ─────────────
// It outlives the form's close, so the add's verdict is still on screen
// afterwards.
// Set by the unexplainable-add branch below: an add that cannot say why
// it failed AUTO-asks the RPC that can, instead of leaving the operator to
// go and run a CLI verb the pane just told them about.
let mut want_events = false;
if let Some(job) = &self.job {
ui.horizontal(|ui| {
ui.spinner();
ui.label(&job.what);
});
} else if let Some(f) = &self.add {
if !f.status.is_empty() {
// A failed action is drawn as a failure, in the failure colour.
if f.status.starts_with("error:") {
ui.colored_label(egui::Color32::from_rgb(0xd9, 0x53, 0x4f), &f.status);
} else {
ui.label(&f.status);
}
}
// WHEN THIS ACTION'S REPLY CANNOT SAY WHY, SAY THAT — and point at the
// RPC that can, instead of leaving an unexplained failure. Until
// 2026-08-27 this text asserted the RPC "answers Unimplemented", which
// was measured FALSE: nornir served it and the forge's Viz proxy did not
// forward it. The proxy now does; the pane does not yet fetch the rows.
if f.last_add.as_ref().is_some_and(|a| a.cause_unknown) {
ui.colored_label(
egui::Color32::from_rgb(0xe0, 0xa8, 0x00),
format!(
"no cause in this reply: the populate returned neither a snapshot nor \
an error. The per-member outcome is in `clone_events` — `{}` \
(the RPC behind `nornir workspace events <name>`) answers it, and \
the rows are below.",
nornir_warehouse_embed::MISSING_DIAGNOSTIC_RPC,
),
);
want_events = true;
}
}
// ── the per-member populate outcomes ────────────────────────────────
// Naming the RPC is not answering the question. This FETCHES it.
self.clone_events_ui(ui, &ctx, want_events);
}
if let Some(name) = removed {
// The removed warehouse was (possibly) the selection; the host must re-bind
// every tab to whatever the selection is NOW, which may be none.
let _ = name;
return Some(self.selected.clone());
}
if chosen != before {
// Route through `select` so an unknown name can never be forced in.
if chosen.is_empty() {
self.select("");
} else {
self.select(&chosen);
}
Some(self.selected.clone())
} else {
// A freshly registered warehouse becomes the new selection.
committed
}
}
/// **The per-member populate outcomes — fetched, not merely named.**
///
/// `clone_events` is the ONE answer to "why did member X fail to populate":
/// per member, `ok`/`error`, the error chain, and when. Until now this pane
/// only printed the RPC's NAME, which is an answer nobody on the `:7900` road
/// could act on — dwarves-ui on t14s can reach the forge and not nornir, so
/// "go and ask `Viz.CloneEvents`" was a dead end for exactly the operator who
/// needed it. The forge's `Viz` proxy now forwards the verb
/// (`dwarves::warehouse_proxy`) and [`nornir_warehouse_embed::viz::clone_events`]
/// speaks it, so the rows come here.
///
/// `auto` = the caller has a reason to want them now (an add that could not
/// explain itself). Otherwise there is an explicit control, because reading a
/// populate ledger on every frame is not free.
///
/// **A failed READ is painted as a failure.** An unreachable server, an
/// `Unauthenticated`, a non-array body — each is named on screen. Drawing any of
/// them as "no rows" would be the same silence in a new place.
fn clone_events_ui(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, auto: bool) {
// Land a finished read first.
if let Some(rx) = &self.clone_events_rx {
match rx.try_recv() {
Ok(res) => {
self.clone_events = Some(res);
self.clone_events_reads = self.clone_events_reads.wrapping_add(1);
self.clone_events_rx = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.clone_events_rx = None,
}
}
// A selection change invalidates rows that belong to another warehouse.
if self.clone_events_ws != self.selected {
self.clone_events = None;
self.clone_events_ws = self.selected.clone();
}
let (Some(form), false) = (self.add.as_ref(), self.selected.is_empty()) else {
return;
};
let (endpoint, token, ws) = (
form.endpoint.clone(),
form.token.clone(),
self.selected.clone(),
);
let mut go = auto && self.clone_events.is_none();
ui.horizontal(|ui| {
let busy = self.clone_events_rx.is_some();
if ui
.add_enabled(!busy, egui::Button::new("why did a member fail?"))
.on_hover_text(format!(
"read `{}` — the per-member populate outcome (ok / error + the error \
chain + when) for `{ws}`",
nornir_warehouse_embed::MISSING_DIAGNOSTIC_RPC,
))
.clicked()
{
go = true;
}
if busy {
ui.spinner();
}
});
if go && self.clone_events_rx.is_none() {
let (tx, rx) = std::sync::mpsc::channel();
let ctx2 = ctx.clone();
// ONE named worker doing blocking gRPC off the UI thread.
let spawned = std::thread::Builder::new()
.name("dwarves-clone-events".to_string())
.spawn(move || {
let res = nornir_warehouse_embed::viz::clone_events(&endpoint, &token, &ws)
.map_err(|e| e.to_string());
let _ = tx.send(res);
ctx2.request_repaint();
});
match spawned {
Ok(_) => self.clone_events_rx = Some(rx),
// A thread that could not be spawned is a NAMED failure, not silence.
Err(e) => {
self.clone_events =
Some(Err(format!("could not start the clone_events read: {e}")));
self.clone_events_reads = self.clone_events_reads.wrapping_add(1);
}
}
}
match &self.clone_events {
None => {}
Some(Err(why)) => {
ui.colored_label(
egui::Color32::from_rgb(0xd9, 0x53, 0x4f),
format!(
"{} failed: {why}",
nornir_warehouse_embed::MISSING_DIAGNOSTIC_RPC
),
);
}
Some(Ok(rows)) if rows.is_empty() => {
ui.label(format!(
"{} answered with no rows for `{}` — nothing has been cloned or fetched \
under this warehouse yet.",
nornir_warehouse_embed::MISSING_DIAGNOSTIC_RPC,
self.clone_events_ws,
));
}
Some(Ok(rows)) => {
ui.label(format!(
"{} · {} row(s) for `{}` (newest first)",
nornir_warehouse_embed::MISSING_DIAGNOSTIC_RPC,
rows.len(),
self.clone_events_ws,
));
egui::ScrollArea::vertical()
.id_salt("warehouse_clone_events")
.max_height(180.0)
.show(ui, |ui| {
egui::Grid::new("warehouse_clone_events_grid")
.num_columns(4)
.striped(true)
.spacing([12.0, 2.0])
.show(ui, |ui| {
for r in rows.iter().take(200) {
let member = row_str(r, "member");
let status = row_str(r, "status");
let detail = row_str(r, "detail");
let ms =
r.get("elapsed_ms").and_then(|v| v.as_i64()).unwrap_or(0);
// The verdict keeps its colour: a red row must
// read as red, not as one more grey line.
if status == "error" {
ui.colored_label(
egui::Color32::from_rgb(0xd9, 0x53, 0x4f),
format!("✗ {member}"),
);
} else {
ui.label(format!("✓ {member}"));
}
ui.label(&status);
ui.label(format!("{ms} ms"));
// The error chain is the payload — never elided to
// a status word.
ui.label(detail);
ui.end_row();
}
});
});
}
}
}
/// The inline Add-warehouse form (name + SSH remote → Add).
///
/// **Add is a WHOLE action**: it registers *and* populates. There is no second
/// button that finishes it, and no state in which the warehouse is "added but not
/// yet populated" — if the populate did not happen, the add failed and says so.
fn add_form_ui(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) {
let busy = self.job.is_some();
let Some(form) = self.add.as_mut() else {
return;
};
let mut go = false;
ui.horizontal(|ui| {
// Add leftmost, then the ssh url (the field you actually type), then the
// name — pre-filled from the url (`workspace_nordisk` → `nordisk`) via the
// form's `set_ssh_url`, so it usually needs no typing at all.
let can = form.can_add() && !busy;
go = ui
.add_enabled(can, egui::Button::new("Add"))
.on_hover_text("register AND populate this SSH remote as a warehouse, as one action — nornir clones the members and builds the warehouse. If a member clone fails, the add fails.")
.clicked();
ui.label("ssh:");
let mut ssh = form.ssh_url.clone();
if ui
.add(
egui::TextEdit::singleline(&mut ssh)
.hint_text("git@host:owner/repo.git")
.desired_width(320.0),
)
.changed()
{
form.set_ssh_url(&ssh);
}
ui.label("name:");
let mut name = form.name.clone();
if ui.add(egui::TextEdit::singleline(&mut name).desired_width(110.0)).changed() {
form.set_name(&name);
}
});
ui.weak("Add registers and populates in one action — there is no separate populate step.");
if go {
let name = form.name.trim().to_string();
let what = format!("adding `{name}` — registering + populating …");
self.spawn_job(ctx, what, WarehouseVerb::Add, &name, move |f| {
// Best-effort: on failure the status line shows the error; no panic.
// `add()` returns Err when there is no snapshot at the end, and it has
// already written the honest verdict into `status`.
let _ = f.add();
});
}
}
/// The Remove confirmation — armed by `🗑 Remove…`, and explicit about the blast
/// radius in BOTH directions: what it clears, and what it provably does not.
fn remove_confirm_ui(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, victim: &str) {
let busy = self.job.is_some();
let mut go = false;
let mut cancel = false;
ui.group(|ui| {
ui.colored_label(
egui::Color32::from_rgb(0xd9, 0x53, 0x4f),
format!("Remove `{victim}` — this clears data."),
);
ui.label(
"Goes: the registry entry AND the workspace's on-disk warehouse \
(warehouse + catalog.redb + jobs.redb).",
);
ui.label(
"Stays: your ssh keys and your tokens. This is a workspace-data RPC on \
the server; it has no field that names a credential, and the dwarves \
key and vault live on this machine, which it never touches.",
);
ui.horizontal(|ui| {
go = ui
.add_enabled(
!busy,
egui::Button::new(format!("🗑 Remove `{victim}` for real")),
)
.clicked();
cancel = ui.button("✖ keep it").clicked();
});
});
if cancel {
self.confirm_remove = None;
}
if go {
let name = victim.to_string();
let target = name.clone();
self.confirm_remove = None;
self.spawn_job(
ctx,
format!("removing `{name}` — clearing its warehouse …"),
WarehouseVerb::Remove,
&target,
move |f| {
let _ = f.remove_named(&name);
},
);
}
}
/// The picker AS DATA (the robot oracle): mirrors nornir's `workspace_picker`
/// slice, plus the `release_target` the release/write binding reads.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"kind": "warehouse-select",
"selected_warehouse": self.selected,
"has_selection": self.has_selection(),
"warehouses": self.warehouses,
"count": self.warehouses.len(),
"release_target": self.release_target(),
"adding": self.adding,
"can_add_warehouses": self.add.is_some(),
// Populate binds to the SELECTION, not to "an Add just happened" — so this
// stays true across frames for as long as a warehouse is picked.
"can_populate": self.can_populate(),
"populate_target": if self.add.is_some() { self.selected.clone() } else { String::new() },
// Remove is the OTHER whole action; `confirming_remove` names the warehouse
// a click has ARMED but not yet performed (it destroys data, so it is two
// steps), and is `null` when nothing is armed.
"can_remove": self.can_remove(),
"confirming_remove": self.confirm_remove,
// THE ONE REFRESH, made observable headlessly. `last_repopulate` is the
// whole outcome — the job ids the chain queued and the tables the purge
// dropped, or nornir's NAMED refusal. A drive that could only see
// "a button exists" could not tell a Refresh that queued thirteen jobs
// from one that was refused, which is the only thing worth asserting.
"can_repopulate": self.can_repopulate(),
"last_repopulate": self
.add
.as_ref()
.and_then(|a| a.last_repopulate.as_ref())
.map(|r| serde_json::json!({
"workspace": r.workspace,
"purged": r.purged,
"dropped": r.dropped,
"generation": r.generation,
"queued": r.queued,
"in_flight": r.in_flight,
"refusal": r.refusal,
"is_refusal": r.is_refusal(),
"line": r.describe(),
})),
"busy": self.job.is_some(),
"busy_what": self.job.as_ref().map(|j| j.what.clone()).unwrap_or_default(),
// Opening the picker re-reads the registry (`Workspaces.List`). This counter
// is what makes that observable headlessly: "the list looks the same" and
// "nothing was asked" are otherwise the same picture.
"registry_refreshes": self.registry_refreshes,
"registry_refreshing": self.registry_rx.is_some(),
// The `clone_events` READ, made observable headlessly. `reads` moving is
// the only way a drive can tell "the pane fetched the rows" from "the pane
// named the RPC" — which were the same picture until 2026-08-27, and the
// reason a green test could sit on top of a pane that answered nothing.
"clone_events_reads": self.clone_events_reads,
"clone_events_fetching": self.clone_events_rx.is_some(),
"clone_events": match &self.clone_events {
None => serde_json::Value::Null,
Some(Err(why)) => serde_json::json!({ "error": why }),
Some(Ok(rows)) => serde_json::json!({
"workspace": self.clone_events_ws,
"rows": rows.len(),
"failed": rows.iter().filter(|r| row_str(r, "status") == "error").count(),
"members": rows.iter().map(|r| row_str(r, "member")).collect::<Vec<_>>(),
}),
},
"add": self.add.as_ref().map(|a| a.state_json()),
// The live populate progress of the SELECTED warehouse — present for a
// populate started ANYWHERE, not only by this process. `null` before the
// first poll answers. `fraction` is null while the total is unknown, which
// is what makes the bar indeterminate rather than parked at 0%.
"progress": self.progress.as_ref().map(|p| serde_json::json!({
"workspace": p.workspace,
"running": p.running,
"repos_total": p.repos_total,
"repos_done": p.repos_done,
"repos_fetching": p.repos_fetching,
"repos_failed": p.repos_failed,
"fraction": p.fraction(),
"crates": p.crates,
"bytes": p.bytes,
"counters": p.counters_line(),
"verdict": p.verdict,
"last_synced": p.last_synced,
"failing_member": p.failing_member,
"last_error": p.last_error,
})),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Emit this pane's coverage-board cell (reuse edda's testmatrix — never a local
/// harness). The warehouse SELECTOR is the shell's header affordance every
/// warehouse-bound tab re-binds off, so it owes the board a row.
fn cell(name: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status("dwarves-gui", name, ok, detail);
let _ = (name, ok, detail);
}
/// The exact sentence the live server sent the owner on 2026-08-22.
const SERVER_REFUSAL: &str = "error: Workspaces.Register RPC: status: Unauthenticated, \
message: \"The request does not have valid authentication credentials\"";
/// A stand-in environment. Injected rather than `set_var`, which is unsound across
/// test threads and would make this guard flaky — a flaky guard is a guard nobody
/// believes, which is the same as not having one.
fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |k: &str| {
pairs
.iter()
.find(|(n, _)| *n == k)
.map(|(_, v)| v.to_string())
}
}
/// **The four auth situations must not read alike.**
///
/// MEASURED live 2026-08-22: the Add-warehouse form reported the server's generic
/// `The request does not have valid authentication credentials` when the real
/// cause was that the unit had been launched with `DWARVES_TOKEN` instead of
/// `DWARVES_SERVER_TOKEN`, so the client sent NO credential at all. The server's
/// sentence is identical for a missing token, a wrong token and a revoked one, so
/// the operator hunts a bad secret while the truth is that none was ever supplied.
///
/// Each case asserts on the WORDS the operator would read — the short name by
/// name, the correct name by name, the count of characters actually sent. A test
/// that only asserted `status.contains("error")` would pass on every one of these
/// and on the broken shape too.
#[test]
fn a_missing_token_and_a_rejected_token_do_not_report_alike() {
// ── 1. NO token, and a lookalike short name IS set: the live incident. ──
let e = env_of(&[("DWARVES_TOKEN", "115580c8")]);
let pre =
credential_refusal("", &e).expect("the misspelling must be refused BEFORE the RPC");
assert!(
pre.contains("DWARVES_TOKEN IS set"),
"it must name the wrong variable: {pre}"
);
assert!(
pre.contains("DWARVES_SERVER_TOKEN"),
"…and the right one: {pre}"
);
assert!(
pre.contains("no request was made"),
"…and say nothing was sent: {pre}"
);
// The classification goes through the PURE half, with the credential handed in:
// `credential_for` walks the real chain, and on oden `~/.dwarves/token` exists
// and holds a secret, so the wrapper would measure this box's configuration
// instead of the four sentences.
let none = dwarves_embed::ClientCredential {
token: String::new(),
whence: dwarves_embed::TokenSource::Nothing,
};
let after = explain_auth_failure_with(SERVER_REFUSAL, &none, &e)
.expect("an auth refusal is classified");
assert!(after.contains("NO CREDENTIAL"), "{after}");
assert!(
after.contains("EMPTY bearer"),
"the cause is the empty bearer, not a bad token: {after}"
);
assert!(after.contains("DWARVES_TOKEN IS set"), "{after}");
assert!(
after.contains("server said:"),
"the server's own words are kept, not replaced: {after}"
);
// ── 2. NO token and NOTHING set: ambiguous, so it must NOT block. ───────
// An empty bearer is legitimate — `lib.rs` documents "Empty ⇒ open (the host's
// dev default)" and this pane's own gRPC tests drive a mock that wants none.
// Refusing here would kill a working dev shell to fix a typo.
let bare = env_of(&[]);
assert_eq!(
credential_refusal("", &bare),
None,
"a blanket refusal of every empty token would break the open-server default"
);
let after = explain_auth_failure_with(SERVER_REFUSAL, &none, &bare)
.expect("still classified once refused");
assert!(after.contains("NO CREDENTIAL"), "{after}");
// ★ THE FIX: the message names the WHOLE chain, and the file rung is offered as
// something the operator can USE. It used to say "No ~/.nornir/token fallback
// exists here", which was the wrong rule stated as a fact — a dwarves endpoint
// resolves a dwarves credential, and `~/.dwarves/token` is a rung.
assert!(after.contains("$DWARVES_SERVER_TOKEN"), "{after}");
assert!(
after.contains(".dwarves/token"),
"the FILE rung must be named: {after}"
);
assert!(
after.contains("supported rung") && after.contains("dwarves-server reads"),
"…and named as usable, not denied: {after}"
);
assert!(
!after.contains(".nornir/token"),
"and nornir's credential is still not this client's: {after}"
);
// ── 3. A token WAS sent and the server refused it: a different verdict. ─
let from_file = dwarves_embed::ClientCredential {
token: "deadbeefdeadbeef".to_string(),
whence: dwarves_embed::TokenSource::File("/home/x/.dwarves/token".into()),
};
let rejected = explain_auth_failure_with(SERVER_REFUSAL, &from_file, &bare)
.expect("a rejected token is still an auth failure");
assert!(rejected.contains("TOKEN REJECTED"), "{rejected}");
assert!(
rejected.contains("16 chars"),
"it must say what it sent: {rejected}"
);
// …and WHICH rung produced the rejected one: a stale token file and a stale
// environment are different mistakes, and the old message said "$VAR" for both.
assert!(
rejected.contains("/home/x/.dwarves/token"),
"the rung that produced the rejected credential must be named: {rejected}"
);
let from_env = dwarves_embed::ClientCredential {
token: "deadbeefdeadbeef".to_string(),
whence: dwarves_embed::TokenSource::Env(TOKEN_ENV.to_string()),
};
let rejected_env = explain_auth_failure_with(SERVER_REFUSAL, &from_env, &bare).unwrap();
assert!(
rejected_env.contains(&format!("${TOKEN_ENV}")),
"{rejected_env}"
);
assert_ne!(
rejected, rejected_env,
"the same rejected secret from two different rungs must not read alike"
);
assert!(
!rejected.contains("NO CREDENTIAL"),
"a rejected token must NOT be reported as a missing one — that is the whole \
distinction this guard exists for: {rejected}"
);
assert_eq!(
credential_refusal("deadbeefdeadbeef", &e),
None,
"a client that HAS a token must never be blocked before the RPC"
);
// ── 4. A NON-auth failure keeps its own words, untouched. ───────────────
let clone_failed =
"error: member clone failed: git@forge.example:nordisk/nope.git — repository not found";
assert_eq!(
explain_auth_failure_with(clone_failed, &none, &e),
None,
"a clone failure must not be relabelled an auth problem — over-matching here \
would hide every other cause behind a token story"
);
cell(
"warehouse_auth_named",
true,
"a missing host token is named BY VARIABLE before/at the RPC (and the wrong \
short spelling is named too) instead of surfacing the server's generic \
'does not have valid authentication credentials', which reads identically \
for a missing, wrong and revoked token; a token that WAS sent and refused \
reports the rejection with the length sent; a non-auth failure is untouched",
);
}
#[test]
fn register_select_and_release_target_bind_to_one_selection() {
let mut s = WarehouseSelector::new(vec!["nordisk".into()], "");
// Nothing selected initially: the onboarding resting state, no invented default.
assert!(!s.has_selection());
assert_eq!(s.selected(), "");
assert_eq!(
s.release_target(),
"",
"no selection ⇒ release has no target"
);
// Register more warehouses (populate the registry).
assert!(s.register("skade"));
assert!(s.register("knut"));
assert!(!s.register("skade"), "duplicate register is idempotent");
assert_eq!(s.warehouses().len(), 3);
// Select one → release_target follows the SELECTED warehouse (the bind).
assert!(s.select("skade"));
assert_eq!(s.selected(), "skade");
assert_eq!(
s.release_target(),
"skade",
"release binds to the selected warehouse"
);
// Selecting an UNKNOWN name is a no-op (never invents a selection).
assert!(!s.select("does-not-exist"));
assert_eq!(s.selected(), "skade");
// Re-select a different one → both selection AND release_target move together.
assert!(s.select("nordisk"));
assert_eq!(s.release_target(), "nordisk");
// Deselect → back to none.
assert!(s.select(""));
assert!(!s.has_selection());
let st = s.state_json();
let ok = st["selected_warehouse"] == "" && st["count"] == 3 && st["release_target"] == "";
assert_eq!(st["selected_warehouse"], "");
assert_eq!(st["count"], 3);
assert_eq!(st["release_target"], "");
cell(
"warehouse_selector_registry_and_one_selection",
ok,
"the warehouse selector holds MANY registered warehouses but exactly ONE selection (nornir parity): register is idempotent, selecting an unknown name is a no-op, the release target follows the selection, and deselecting returns the honest empty onboarding state",
);
}
/// **MODEL level only** — the fold-in arithmetic, with the register HAND-INJECTED.
/// SSH validation gates `can_add`, and a `registered` record folds into the picker
/// and becomes the selection. RED-when-broken: an HTTPS remote must never be
/// addable (L6), and a registered warehouse must land in the picker + be selected
/// (so tabs re-bind to it).
///
/// It renders NOTHING and clicks NOTHING, so it can say nothing about whether the
/// buttons exist or are hittable — that is
/// [`robot_add_is_whole_and_resync_stays_hittable`]'s job, and it is
/// what this test's prose used to claim while never touching a widget.
#[test]
fn add_warehouse_validates_ssh_and_folds_into_the_picker() {
use nornir_warehouse_embed::RegisteredWorkspace;
let mut s = WarehouseSelector::new(vec!["skade".into()], "skade")
.with_add_source("http://127.0.0.1:0", "");
assert_eq!(s.state_json()["can_add_warehouses"], true);
// Drive the form: an HTTPS remote is rejected, an SSH one is accepted.
let form = s.add_form_mut().expect("add source configured");
form.update_json(
r#"{"name":"nordisk","ssh_url":"https://forge.example/nordisk/dwarves.git"}"#,
);
assert_eq!(form.state_json()["ssh_valid"], false);
assert!(!form.can_add(), "HTTPS ⇒ Add disabled (L6)");
form.update_json(r#"{"ssh_url":"git@forge.example:nordisk/dwarves.git"}"#);
assert!(form.can_add(), "valid SSH + name ⇒ Add enabled");
// Nothing registered yet ⇒ commit is a no-op.
assert!(s.commit_added().is_none());
// Simulate a successful eager register (the server populated it).
s.add_form_mut().unwrap().registered = Some(RegisteredWorkspace {
name: "nordisk".into(),
mode: "monitored".into(),
current_snapshot: "snap-1".into(),
members: vec!["dwarves".into()],
});
let committed = s
.commit_added()
.expect("newly registered warehouse folds in");
assert_eq!(committed, "nordisk");
assert!(
s.warehouses().contains(&"nordisk".to_string()),
"the added warehouse joins the picker"
);
assert_eq!(s.selected(), "nordisk", "and becomes the active selection");
assert_eq!(
s.release_target(),
"nordisk",
"release now targets the freshly-populated warehouse"
);
assert_eq!(s.state_json()["add"]["populated"], true);
cell(
"warehouse_selector_add_ssh_validates_and_folds_in",
s.warehouses().contains(&"nordisk".to_string())
&& s.selected() == "nordisk"
&& s.state_json()["add"]["populated"] == true,
"MODEL ONLY (no render, no click): SSH validation gates can_add (an HTTPS remote is rejected, L6) and a hand-injected registered record folds into the picker, becomes the active selection and re-targets release — button reachability is asserted separately by the kittest scenario",
);
}
/// **RED-when-broken: an IDLE picker never writes to a warehouse by itself.**
///
/// `Populate` drives `Workspaces.Fetch{force}` — nornir re-clones the members and
/// rebuilds the warehouse. That is a WRITE against whichever server the shell was
/// pointed at, so it must happen if and ONLY if a human clicks it. A self-firing
/// button here would re-sync a live production warehouse the instant the UI opened.
///
/// This renders the real widget for many frames with a warehouse already selected
/// and **no input whatsoever**, then asserts the mock server was never asked to do
/// anything: no `Fetch`, no `Register`, no job ever started.
#[test]
fn an_idle_picker_never_fires_a_warehouse_write_by_itself() {
let srv = nornir_warehouse_embed::testkit::spawn_mock_workspaces();
let sel = std::cell::RefCell::new(
WarehouseSelector::new(vec!["nordisk".into()], "nordisk")
.with_add_source(&srv.endpoint, ""),
);
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(900.0, 420.0))
.build_ui(|ui| {
sel.borrow_mut().ui(ui);
});
// Many frames, zero events. Populate is enabled the whole time (a warehouse IS
// selected) — being enabled must not make it fire.
let mut ever_busy = false;
for _ in 0..120 {
h.step();
ever_busy |= sel.borrow().is_busy();
}
let s = sel.borrow();
assert_eq!(
s.state_json()["can_populate"],
true,
"Re-sync is offered (a warehouse is selected)"
);
assert_eq!(s.state_json()["can_remove"], true, "Remove is offered too");
assert!(!ever_busy, "…but NO job ever started without a click");
assert!(
srv.log.fetched().is_empty(),
"no Workspaces.Fetch was sent: {:?}",
srv.log.fetched()
);
assert!(
srv.log.registered().is_empty(),
"no Workspaces.Register was sent: {:?}",
srv.log.registered()
);
// REMOVE is the destructive one — an idle picker must not arm it, let alone fire
// it. `confirming_remove` stays null and nothing reaches the wire.
assert!(
srv.log.removed().is_empty(),
"no Workspaces.Remove was sent: {:?}",
srv.log.removed()
);
assert!(
s.state_json()["confirming_remove"].is_null(),
"Remove is not even ARMED without a click"
);
assert_eq!(
s.state_json()["add"]["status"],
"",
"no status ⇒ no RPC was even attempted"
);
let ok = !ever_busy
&& srv.log.fetched().is_empty()
&& srv.log.registered().is_empty()
&& srv.log.removed().is_empty();
cell(
"warehouse_selector_idle_never_writes",
ok,
"an idle warehouse picker with a selection renders 120 real egui frames with no input and sends NOTHING to nornir — no Workspaces.Fetch, no Register, no Remove, no background job, and the destructive Remove is not even armed: these are warehouse WRITES and may only ever fire from a human click",
);
}
/// **ROBOT SCENARIO (kittest render + real clicks + a real gRPC seam): `Add` is a
/// WHOLE action, and `🔄 Re-sync` is a separate verb that stays hittable.**
///
/// Two things are asserted here and they are different claims:
///
/// 1. **Add registers AND populates.** The mock's `Register` deliberately returns
/// an EMPTY snapshot — the "recorded, not populated" state — so if `Add` were
/// only a register, the warehouse would end unpopulated and the user would have
/// to press something else to finish. It does not: one click drives a
/// `Workspaces.Register` *and* the `Workspaces.Fetch` that mints the snapshot,
/// and the picker reports `populated`.
/// 2. **Re-sync is still there afterwards, and still hittable.** It is not a step
/// of Add (Add is finished); it is the operation on an EXISTING warehouse — one
/// added in an earlier session, or one whose remotes have moved. It used to live
/// inside the add form gated on `registered.is_some()`, painted for exactly one
/// frame under a pointer still on `Add`, so no user could click it.
///
/// kittest routes a click through `accesskit::Action::Click`, which egui honours
/// **only for enabled widgets**, so a missing, disabled or one-frame-flickering
/// button produces no RPC and reds this.
#[test]
fn robot_add_is_whole_and_no_resync_control_remains() {
use egui_kittest::kittest::Queryable;
use egui_kittest::Harness;
// A REAL nornir `Workspaces` server (mock): Register records the workspace with
// NO snapshot, Fetch is what mints one — so "did the add populate?" is
// answerable on the wire rather than by trusting the register's return value.
let srv = nornir_warehouse_embed::testkit::spawn_mock_workspaces();
let sel = std::cell::RefCell::new(
WarehouseSelector::new(vec![], "").with_add_source(&srv.endpoint, ""),
);
let mut h = Harness::builder()
.with_size(egui::vec2(900.0, 620.0))
.build_ui(|ui| {
sel.borrow_mut().ui(ui);
});
// Pump frames until `done` or we give up — never `run()`, which panics when a
// frame keeps requesting repaints (the busy spinner does exactly that).
let pump = |h: &mut Harness<'_>, done: &dyn Fn() -> bool| {
for _ in 0..600 {
h.step();
if done() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
// ── 1. open the add form by clicking the REAL toggle ────────────────────
h.step();
h.get_by_label("➕ Add…").click();
h.step();
assert!(
sel.borrow().state_json()["adding"] == true,
"the real ➕ Add… toggle opened the form"
);
// Type the name + SSH remote over the form's documented control seam (text
// entry is not what is under test here; button reachability is).
sel.borrow_mut()
.add_form_mut()
.expect("add source configured")
.update_json(r#"{"name":"nordisk","ssh_url":"git@forge.example:nordisk/dwarves.git"}"#);
h.step();
// ── 2. ONE click on the REAL Add button drives the WHOLE action ─────────
h.get_by_label("Add").click();
h.step();
let landed = pump(&mut h, &|| {
!srv.log.fetched().is_empty() && !sel.borrow().is_busy()
});
assert!(
landed,
"the real Add button drove register + populate over gRPC to completion"
);
assert_eq!(
srv.log.registered(),
vec!["nordisk".to_string()],
"Workspaces.Register hit the wire"
);
assert_eq!(
srv.log.fetched(),
vec!["nordisk".to_string()],
"…and so did the populate, from the SAME click — Add is not half an action"
);
// The add folded in: the warehouse is a picker option AND the selection, it is
// POPULATED, and the form is closed because nothing remains to be done.
let add_is_whole = {
let s = sel.borrow();
let st = s.state_json();
assert_eq!(
st["selected_warehouse"], "nordisk",
"the added warehouse became the selection"
);
assert_eq!(
st["release_target"], "nordisk",
"…and release re-targets it"
);
assert_eq!(
st["adding"], false,
"the add form closed — the add is finished"
);
assert_eq!(
st["add"]["populated"], true,
"ADDED means REGISTERED AND POPULATED"
);
assert_eq!(st["add"]["last_add"]["populated"], true);
let status = st["add"]["status"].as_str().unwrap_or_default().to_string();
assert!(
status.starts_with("ok: added `nordisk`"),
"the verdict is the whole action's: {status}"
);
assert!(
status.contains("populated"),
"…and it names the populate: {status}"
);
st["add"]["populated"] == true
};
// ── 3. There is NO re-sync control, and Add's populate is untouched ─────
//
// `🔄 Re-sync` was removed on 2026-08-25 (the owner: SCAN is not a user action).
// A re-sync is `Workspaces.Fetch`, i.e. a populate, and nornir's own poll sweep
// already fetches every monitored workspace and republishes when a member's SHA
// moved — the same trigger, without an operator having to know to press
// anything. What this leg proves is that removing it did NOT quietly take Add's
// populate with it: exactly ONE fetch reached the wire, and it came from Add.
h.step();
h.step();
assert!(
h.query_by_label("🔄 Re-sync").is_none(),
"there is no Re-sync control — populate is not a button any more"
);
assert!(
h.query_by_label("🔄 Populate").is_none(),
"…and there is no Populate button either — Add already populated"
);
assert!(
h.query_all(
egui_kittest::kittest::By::new()
.role(accesskit::Role::Button)
.label_contains("sync")
)
.next()
.is_none(),
"no pressable control offers a re-sync under any spelling"
);
// The WIRE is the proof, not the widget count: one fetch, from the Add.
assert_eq!(
srv.log.fetched(),
vec!["nordisk".to_string()],
"exactly ONE Workspaces.Fetch reached the wire — Add's. Removing the button \
must not have removed the populate that makes Add whole."
);
let s = sel.borrow();
let st = s.state_json();
let status = st["add"]["status"].as_str().unwrap_or_default().to_string();
assert!(
status.starts_with("ok: added `nordisk`"),
"the ADD outcome is reported on the picker, where it outlives the form: {status}"
);
assert_eq!(
st["add"]["last_add"]["populated"], true,
"the add really populated"
);
let ok = add_is_whole && srv.log.fetched().len() == 1;
cell(
"warehouse_selector_add_is_whole_and_no_resync_control",
ok,
"a kittest frame clicks the REAL ➕ Add… and Add buttons against a real Workspaces gRPC mock whose Register returns NO snapshot: that ONE click drives both Workspaces.Register and the Workspaces.Fetch that populates, and the picker reports populated=true — so adding a warehouse needs no follow-up press. The 🔄 Re-sync button is GONE (SCAN is not a user action; nornir's poll sweep owns re-populating), and the wire proves its removal did not take Add's populate with it: exactly one Fetch, from the Add",
);
}
/// **RED-when-broken: a failed member clone FAILS the add.**
///
/// The bug, verbatim from the session log: `workspace add` printed
/// `✓ populated now (eager register)` over an empty `git/`. The descriptor fetch
/// succeeded and the member clone failed, and the command reported green.
///
/// Here the mock's `Fetch` reports a member clone failure and NO snapshot. The add
/// must therefore: report `error:`, name the member failure, NOT report populated,
/// and — the part that matters most — **not fold the warehouse into the picker**,
/// because a warehouse that was not built is not a warehouse you can select.
#[test]
fn a_failed_member_clone_fails_the_add() {
use nornir_warehouse_embed::testkit::{spawn_mock_workspaces_with, FetchBehaviour};
let srv = spawn_mock_workspaces_with(FetchBehaviour::MemberCloneFails);
let mut s = WarehouseSelector::new(vec![], "").with_add_source(&srv.endpoint, "");
let form = s.add_form_mut().expect("add source configured");
form.update_json(
r#"{"name":"njord","ssh_url":"git@github.com:vetra-automation/Njord.git"}"#,
);
// Drive the SAME method the button drives (synchronously — the worker is not
// what is under test).
let outcome = form.add();
assert!(
outcome.is_err(),
"a failed member clone must fail the action, not report green"
);
let st = s.state_json();
let status = st["add"]["status"].as_str().unwrap_or_default().to_string();
assert!(
status.starts_with("error:"),
"the status is an ERROR, not a ✓: {status}"
);
assert!(
status.contains("NOT populated"),
"…and says the warehouse was not built: {status}"
);
assert!(
status.contains("Permission denied"),
"…quoting the member's real failure: {status}"
);
assert_eq!(st["add"]["populated"], false, "registered ≠ populated");
assert_eq!(
st["add"]["cause_unknown"], false,
"the cause WAS available — the fetch reported it"
);
// The register did happen on the wire (that leg succeeded) …
assert_eq!(srv.log.registered(), vec!["njord".to_string()]);
// … but the picker must not show it as an added warehouse.
assert!(
s.commit_added().is_none(),
"a warehouse that was not populated must never fold into the picker"
);
assert!(
!s.warehouses().contains(&"njord".to_string()),
"…so it is not an option"
);
assert!(
!s.has_selection(),
"…and it certainly did not become the active selection"
);
cell(
"warehouse_add_fails_when_a_member_clone_fails",
outcome.is_err() && st["add"]["populated"] == false,
"against a Workspaces mock whose Fetch reports a member clone failure and no snapshot, the whole-action Add returns Err and its status reads `error: ... registered but NOT populated - member: clone failed: Permission denied (publickey)` instead of the `✓ populated now (eager register)` the CLI printed over an empty git/ — the success condition is a warehouse snapshot, not an Ok from Register",
);
}
/// **RED-when-broken: when this action's reply cannot say WHY, the pane says THAT —
/// and names the RPC that can.**
///
/// `nornir workspace events <name>` / `Viz.CloneEvents` is the way to see why a
/// member did not populate. There is a real failure shape — populate returns neither
/// a snapshot nor an error — where the ADD REPLY alone cannot explain itself. The
/// pane must not invent a cause and must not draw the silence as success; it names
/// the RPC that holds the per-member outcome.
///
/// ⚠ 2026-08-27: this test used to also assert the word `Unimplemented`, because the
/// pane claimed the RPC was unimplemented server-side. MEASURED false (nornir served
/// it; the forge's `Viz` proxy did not forward it — now it does), so the assertion
/// went with the claim. The load-bearing half is unchanged and still red-when-broken:
/// the pane must NAME the RPC, in the model AND on screen.
#[test]
fn an_unexplainable_add_names_the_rpc_that_would_have_explained_it() {
use egui_kittest::kittest::Queryable;
use nornir_warehouse_embed::testkit::{spawn_mock_workspaces_with, FetchBehaviour};
let srv = spawn_mock_workspaces_with(FetchBehaviour::SilentlyDoesNothing);
let sel = std::cell::RefCell::new(
WarehouseSelector::new(vec![], "").with_add_source(&srv.endpoint, ""),
);
{
let mut s = sel.borrow_mut();
let form = s.add_form_mut().expect("add source configured");
form.update_json(r#"{"name":"njord","ssh_url":"git@github.com:nordisk/njord.git"}"#);
assert!(form.add().is_err(), "no snapshot ⇒ the add failed");
}
let st = sel.borrow().state_json();
assert_eq!(st["add"]["populated"], false);
assert_eq!(
st["add"]["cause_unknown"], true,
"no error and no snapshot ⇒ the cause is unknown"
);
let status = st["add"]["status"].as_str().unwrap_or_default().to_string();
assert!(
status.contains("Viz.CloneEvents"),
"the status names the diagnostic RPC: {status}"
);
// …and it must NOT claim the RPC is unimplemented: that was measured false on
// 2026-08-27, and a stale attribution painted on screen is the defect this line
// now guards against rather than the one it used to assert.
assert!(
!status.contains("Unimplemented"),
"the status must not blame the server for a gap that was in the forge proxy: {status}"
);
// And it is PAINTED, not merely in the model.
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1100.0, 700.0))
.build_ui(|ui| {
sel.borrow_mut().ui(ui);
});
h.step();
h.step();
let named_on_screen = h
.query_all_by_label_contains("Viz.CloneEvents")
.next()
.is_some();
assert!(
named_on_screen,
"the pane names the missing diagnostic RPC on screen"
);
cell(
"warehouse_add_names_the_missing_diagnostic_rpc",
named_on_screen,
"when a populate returns neither a snapshot nor an error, the add REPLY cannot say why - the per-member outcome lives in clone_events, read by Viz.CloneEvents behind `nornir workspace events <name>`. The pane paints that, naming the RPC, instead of showing an unexplained failure or drawing the silence as a success. It also no longer claims the RPC is Unimplemented: measured 2026-08-27, nornir served it and the dwarves Viz proxy did not forward it, so the gap was ours and the on-screen sentence blamed the wrong product",
);
}
/// **RED-when-broken: the pane FETCHES `Viz.CloneEvents` and paints the rows.**
///
/// Naming an RPC is not answering the question it answers. Until 2026-08-27 this
/// pane printed `Viz.CloneEvents` and stopped there, which is a dead end for the
/// operator who needs it most: dwarves-ui on t14s reaches the forge (`:7900`) and
/// not nornir, so "go ask that RPC" was advice they could not take.
///
/// Drives the REAL pane against a mock `Viz` server, presses the real control, and
/// asserts on the APPLIED output — the member names and the error chain painted in
/// the grid — not that a flag flipped. `clone_events_reads` moving is what
/// separates "the rows were fetched" from "the RPC was named", the two states that
/// used to render identically.
#[test]
fn the_pane_fetches_clone_events_and_paints_the_per_member_outcome() {
use egui_kittest::kittest::Queryable;
use nornir_warehouse_embed::testkit::{spawn_mock_viz, MockViz};
// Two members: one that populated, one that did not — with the error chain
// that is the whole reason to read this table.
let rows = r#"[
{"ts_micros":1756312986000000,"workspace":"nordisk","member":"skade","remote":"ssh://git@forge.example/nordisk/skade.git","op":"clone-fetch","status":"error","detail":"gix::init /home/dwarves/.nornir/workspaces/nordisk/git/skade: File exists (os error 17)","elapsed_ms":13},
{"ts_micros":1756312983000000,"workspace":"nordisk","member":"znippy","remote":"ssh://git@forge.example/nordisk/znippy.git","op":"clone-fetch","status":"ok","detail":"4dce050a","elapsed_ms":3021}
]"#;
let mock = MockViz {
clone_events: rows.to_string(),
..Default::default()
};
let seen_ws = mock.seen_workspaces.clone();
let srv = spawn_mock_viz(mock);
let sel = std::cell::RefCell::new(
WarehouseSelector::new(vec!["nordisk".to_string()], "nordisk")
.with_add_source(&srv.endpoint, ""),
);
assert_eq!(
sel.borrow().state_json()["clone_events"],
serde_json::Value::Null,
"nothing is fetched before it is asked for",
);
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1200.0, 800.0))
.build_ui(|ui| {
sel.borrow_mut().ui(ui);
});
h.step();
// Press the REAL control, by its real label.
h.get_by_label_contains("why did a member fail?").click();
h.step();
// The read is on a worker thread; drive frames until it lands (or give up).
let t0 = std::time::Instant::now();
while sel.borrow().state_json()["clone_events_reads"]
.as_u64()
.unwrap_or(0)
== 0
{
assert!(
t0.elapsed() < std::time::Duration::from_secs(10),
"the read never landed"
);
std::thread::sleep(std::time::Duration::from_millis(20));
h.step();
}
h.step();
let st = sel.borrow().state_json();
assert_eq!(st["clone_events"]["rows"], 2, "both rows must arrive: {st}");
assert_eq!(
st["clone_events"]["failed"], 1,
"the failed member must be countable: {st}"
);
assert_eq!(st["clone_events"]["workspace"], "nordisk");
assert_eq!(
st["clone_events"]["members"],
serde_json::json!(["skade", "znippy"]),
"the rows are the SERVER's, in the server's order",
);
// ── the APPLIED output: it is on the screen, not only in the model ──
let painted_member = h.query_all_by_label_contains("skade").next().is_some();
let painted_error = h
.query_all_by_label_contains("File exists (os error 17)")
.next()
.is_some();
assert!(painted_member, "the failing member is not on screen");
assert!(
painted_error,
"the ERROR CHAIN is not on screen — a status word alone answers nothing"
);
// The client stamped the selected workspace rather than leaning on a default.
let seen = seen_ws.lock().unwrap().clone();
assert!(
seen.iter().any(|w| w == "nordisk"),
"the read must carry the selected workspace in the header: {seen:?}"
);
cell(
"warehouse_pane_fetches_clone_events",
painted_member && painted_error,
"the pane no longer merely NAMES Viz.CloneEvents - it calls it and paints the per-member populate outcome, error chain included. That is the only answer to `why did member X fail to populate`, and before this it was unreachable for every client that can see :7900 but not nornir (dwarves-ui on t14s)",
);
}
/// **RED-when-broken: a failed READ is painted as a failure, never as "no rows".**
///
/// An unauthenticated call, an unreachable server and an empty ledger must be three
/// distinguishable pictures. Collapsing the first two into the third is the same
/// silence in a new place — and it is the failure mode this pane was built to break.
#[test]
fn a_refused_clone_events_read_is_named_not_drawn_as_an_empty_ledger() {
use egui_kittest::kittest::Queryable;
use nornir_warehouse_embed::testkit::{spawn_mock_viz, Code, MockViz};
let srv = spawn_mock_viz(MockViz {
refuse: Some((Code::Unauthenticated, "missing bearer token".into())),
..Default::default()
});
let sel = std::cell::RefCell::new(
WarehouseSelector::new(vec!["nordisk".to_string()], "nordisk")
.with_add_source(&srv.endpoint, ""),
);
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1200.0, 800.0))
.build_ui(|ui| {
sel.borrow_mut().ui(ui);
});
h.step();
h.get_by_label_contains("why did a member fail?").click();
h.step();
let t0 = std::time::Instant::now();
while sel.borrow().state_json()["clone_events_reads"]
.as_u64()
.unwrap_or(0)
== 0
{
assert!(
t0.elapsed() < std::time::Duration::from_secs(10),
"the read never landed"
);
std::thread::sleep(std::time::Duration::from_millis(20));
h.step();
}
h.step();
let st = sel.borrow().state_json();
assert!(
st["clone_events"]["error"].is_string(),
"a refused read must stay a failure in the model: {st}"
);
assert!(
st["clone_events"]["rows"].is_null(),
"a refused read must NOT present a row count: {st}"
);
let named = h.query_all_by_label_contains("failed:").next().is_some();
assert!(named, "the failure is not painted");
}
/// **RED-when-broken: Remove is a WHOLE action — it clears the warehouse — and it
/// never touches the identity.**
///
/// Drives the real `🗑 Remove…` → confirm → `Workspaces.Remove` path and asserts on
/// the wire that `purge = true` (the on-disk warehouse goes with the registry row,
/// not just the row). The identity half is a structural claim, asserted as such:
/// the whole request message this UI can send carries a name and a purge flag and
/// nothing else, so there is no field through which a key or token could be
/// destroyed.
#[test]
fn remove_clears_the_warehouse_and_can_never_touch_the_identity() {
use egui_kittest::kittest::Queryable;
let srv = nornir_warehouse_embed::testkit::spawn_mock_workspaces();
// Seed the registry server-side so Remove has something to remove.
nornir_warehouse_embed::add_warehouse_by_ssh(
&srv.endpoint,
"",
"nordisk",
"git@forge.example:nordisk/dwarves.git",
)
.expect("mock register");
let sel = std::cell::RefCell::new(
WarehouseSelector::new(vec!["nordisk".into()], "nordisk")
.with_add_source(&srv.endpoint, ""),
);
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1100.0, 700.0))
.build_ui(|ui| {
sel.borrow_mut().ui(ui);
});
h.step();
// ── one click ARMS it (it destroys data — it is not a one-click act) ────
h.get_by_label("🗑 Remove…").click();
h.step();
assert_eq!(
sel.borrow().state_json()["confirming_remove"],
"nordisk",
"the first click arms the confirmation, it does not remove"
);
assert!(
srv.log.removed().is_empty(),
"…and nothing has reached the wire yet"
);
// The confirmation states the blast radius in both directions.
let says_what_stays = h
.query_all_by_label_contains("ssh keys and your tokens")
.next()
.is_some();
assert!(
says_what_stays,
"the confirmation says what is NOT destroyed, not only what is"
);
// ── the second click does it ───────────────────────────────────────────
h.get_by_label("🗑 Remove `nordisk` for real").click();
h.step();
let mut done = false;
for _ in 0..600 {
h.step();
if !srv.log.removed().is_empty() && !sel.borrow().is_busy() {
done = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(
done,
"the confirmed Remove drove a real Workspaces.Remove to completion"
);
assert_eq!(
srv.log.removed(),
vec![("nordisk".to_string(), true)],
"Remove is sent with purge=true — the on-disk warehouse goes with the registry row"
);
let s = sel.borrow();
let st = s.state_json();
assert!(
!s.warehouses().contains(&"nordisk".to_string()),
"…and it leaves the picker"
);
assert_eq!(
st["selected_warehouse"], "",
"the selection it held is released, not left dangling"
);
let status = st["add"]["status"].as_str().unwrap_or_default().to_string();
assert!(status.starts_with("ok: removed `nordisk`"), "{status}");
assert!(status.contains("ssh keys and tokens untouched"), "{status}");
// THE IDENTITY CLAIM, structurally: the entire message this UI can send is
// {name, purge}. There is no credential-shaped field on it to abuse.
let req = nornir_warehouse_embed::pb::WorkspaceName {
name: "nordisk".into(),
purge: true,
};
let _exhaustive = match req {
nornir_warehouse_embed::pb::WorkspaceName { name: _, purge: _ } => (),
};
cell(
"warehouse_remove_is_whole_and_keeps_the_identity",
srv.log.removed() == vec![("nordisk".to_string(), true)],
"a kittest frame clicks the REAL 🗑 Remove... (which only ARMS a confirmation - nothing reaches the wire), reads the confirmation stating what is destroyed AND what is kept, then clicks the confirm and proves a Workspaces.Remove{purge:true} reached the server: the registry row and the on-disk warehouse (warehouse + catalog.redb + jobs.redb) go together, the warehouse leaves the picker and releases the selection. The identity half is structural - the whole request message is {name, purge}, exhaustively matched here, so no credential can ride it",
);
}
/// **RED-when-broken: THE ONE REFRESH reaches the wire with `purge = true`, and a
/// NAMED refusal is drawn as a refusal — never as a success with a zero in it.**
///
/// The per-pane Scan buttons are gone and are not coming back; what replaced them
/// is one button, here, driving `Workspaces.Repopulate`. So the assertions are the
/// two things that make it that button rather than a re-sync in a new hat:
///
/// 1. it sends `purge = true` — a Refresh that only re-enqueued would be answered
/// by the chain's own "already computed at this generation" short-circuit and
/// change nothing an operator can see;
/// 2. when the server refuses BY NAME (the dedup path: the chain asked for is the
/// chain already running), the pane shows nornir's sentence WITH the in-flight
/// job id in it, and does not report the run as ok.
///
/// The RAGNARÖK inverse is in the same test: with the mock answering normally the
/// same click is a success carrying job ids — so a green here cannot be "the pane
/// always says refused".
#[test]
fn the_one_refresh_purges_on_the_wire_and_draws_a_named_refusal_as_a_refusal() {
use egui_kittest::kittest::Queryable;
let srv = nornir_warehouse_embed::testkit::spawn_mock_workspaces();
nornir_warehouse_embed::add_warehouse_by_ssh(
&srv.endpoint,
"",
"nordisk",
"git@forge.example:nordisk/dwarves.git",
)
.expect("mock register");
let sel = std::cell::RefCell::new(
WarehouseSelector::new(vec!["nordisk".into()], "nordisk")
.with_add_source(&srv.endpoint, ""),
);
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1100.0, 700.0))
.build_ui(|ui| {
sel.borrow_mut().ui(ui);
});
h.step();
assert_eq!(
sel.borrow().state_json()["can_repopulate"],
true,
"a selected warehouse and an endpoint is all Refresh needs"
);
assert!(
srv.log.repopulated().is_empty(),
"…and nothing has reached the wire before a click: {:?}",
srv.log.repopulated()
);
// ── ONE click, and it is one click: a Refresh destroys no source data, so
// unlike Remove it needs no confirmation step. ──
h.get_by_label("🧹 Repopulate").click();
let mut done = false;
for _ in 0..600 {
h.step();
if !srv.log.repopulated().is_empty() && !sel.borrow().is_busy() {
done = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(
done,
"the Refresh click drove a real Workspaces.Repopulate to completion"
);
assert_eq!(
srv.log.repopulated(),
vec![("nordisk".to_string(), true)],
"Refresh is sent with purge=true — the orphaned v1 tables are dropped"
);
let st = sel.borrow().state_json();
assert_eq!(
st["last_repopulate"]["is_refusal"], false,
"a queued run is not a refusal"
);
let queued = st["last_repopulate"]["queued"]
.as_array()
.expect("the job ids are reported")
.len();
assert!(queued > 0, "the run must name the jobs it queued: {st}");
let status = st["add"]["status"].as_str().unwrap_or_default().to_string();
assert!(status.starts_with("ok: `nordisk` serves "), "{status}");
assert!(
status.contains("2 member(s) behind, consume started")
&& status.contains("edda behind"),
"the status names WHICH members a consume was started for, not just that \
something happened: {status}"
);
assert!(
status.contains("3 orphaned v1 table(s) dropped"),
"a purge says what kind of table it dropped: {status}"
);
// ── RED: the SAME click against a server that cannot consume. ──
srv.log.refuse_repopulate_as_unconsumable();
let before = srv.log.repopulated().len();
h.get_by_label("🧹 Repopulate").click();
let mut refused = false;
for _ in 0..600 {
h.step();
if srv.log.repopulated().len() > before && !sel.borrow().is_busy() {
refused = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(refused, "the second Refresh reached the server too");
let st = sel.borrow().state_json();
assert_eq!(
st["last_repopulate"]["is_refusal"], true,
"nothing queued + nornir's named reason IS a refusal: {st}"
);
assert!(
st["last_repopulate"]["queued"]
.as_array()
.is_some_and(|a| a.is_empty()),
"a refusal queued nothing, and must not claim otherwise: {st}"
);
let status = st["add"]["status"].as_str().unwrap_or_default().to_string();
assert!(
!status.starts_with("ok:"),
"a refusal is not an ok: {status}"
);
assert!(
status.contains("[driver_not_available]") && status.contains("cargo-mimir-dump"),
"the pane must show nornir's own refusal — the STATE and what is missing on the \
box — verbatim: {status}"
);
cell(
"warehouse_one_refresh_repopulate",
srv.log.repopulated() == vec![("nordisk".into(), true), ("nordisk".into(), true)]
&& status.contains("[driver_not_available]"),
"a kittest frame clicks the REAL 🧹 Repopulate - the ONE door onto Workspaces.Repopulate that replaced the deleted per-pane Scan buttons - and proves it reaches the wire with purge=true (drop the orphaned v1 tables), reports which members are behind and had a consume started, and when the server refuses BY NAME because nothing can consume them, paints nornir's own sentence WITH its state instead of an ok with a zero in it",
);
}
/// **RED-when-broken: the progress bar paints for a populate NOBODY HERE STARTED,
/// and its estimate refines instead of jumping.**
///
/// The picker is built over an already-registered warehouse and **nothing in this
/// test clicks Add, Re-sync or anything else**. The server's own per-member
/// `sync_state` is moved between frames — exactly what nornir's monitor writes
/// while it clones — and the bar has to follow it. That is the difference between
/// "a progress bar for our button" and "a progress bar for population".
#[test]
fn a_populate_started_elsewhere_still_draws_a_bar() {
use egui_kittest::kittest::Queryable;
let srv = nornir_warehouse_embed::testkit::spawn_mock_workspaces();
nornir_warehouse_embed::register_warehouse_by_ssh(
&srv.endpoint,
"",
"nordisk",
"git@forge.example:nordisk/dwarves.git",
)
.expect("mock register");
// A populate is under way on the SERVER. This process did not start it.
srv.log.set_member_states(&[
("dwarves", "fetching"),
("nornir", ""),
("holger", ""),
("skade", ""),
]);
let sel = std::cell::RefCell::new(
WarehouseSelector::new(vec!["nordisk".into()], "nordisk")
.with_add_source(&srv.endpoint, ""),
);
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1100.0, 700.0))
.build_ui(|ui| {
sel.borrow_mut().ui(ui);
});
// Pump until the first poll lands (it runs on the named worker).
let pump = |h: &mut egui_kittest::Harness<'_>, done: &dyn Fn() -> bool| {
for _ in 0..600 {
h.step();
if done() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
let landed = pump(&mut h, &|| sel.borrow().progress().is_some());
assert!(
landed,
"the progress poll runs without any button being pressed"
);
{
let s = sel.borrow();
let st = s.state_json();
assert_eq!(
st["progress"]["running"], true,
"a populate is running on the server"
);
assert_eq!(
st["busy"], false,
"…and no job of OURS is in flight — nothing here started it"
);
assert_eq!(
st["progress"]["repos_total"], 4,
"the denominator is known on the first poll"
);
assert_eq!(st["progress"]["repos_done"], 0);
assert_eq!(st["progress"]["fraction"], 0.0);
// The refining counter has not been measured yet: `…`, never `0`.
assert_eq!(st["progress"]["crates"]["state"], "pending");
// Bytes are not measurable over this seam and say so rather than read 0.
assert_eq!(st["progress"]["bytes"]["state"], "unavailable");
assert!(
st["progress"]["counters"]
.as_str()
.unwrap_or_default()
.contains("n/a bytes"),
"{}",
st["progress"]["counters"]
);
}
// …and the bar is really painted, with its counters.
h.step();
assert!(
h.query_all_by_label_contains("0/4 repo(s)")
.next()
.is_some(),
"the counters are on screen beside the bar"
);
assert!(
h.query_all_by_label_contains("shows a populate started anywhere")
.next()
.is_some(),
"…and the pane says the bar follows the server, not our button"
);
// ── the server advances; the bar must follow ───────────────────────────
srv.log.set_member_states(&[
("dwarves", "ok"),
("nornir", "ok"),
("holger", "fetching"),
("skade", ""),
]);
let moved = pump(&mut h, &|| {
sel.borrow().progress().is_some_and(|p| p.repos_done == 2)
});
assert!(
moved,
"the bar follows the server's own progress with no input here"
);
let advanced = sel.borrow().state_json()["progress"]["fraction"] == 0.5;
assert!(
advanced,
"the proportion moved: {}",
sel.borrow().state_json()["progress"]
);
cell(
"warehouse_progress_bar_follows_any_populate",
advanced,
"with a warehouse selected and NO button ever clicked, the picker polls the server and paints a populate progress bar: it reads the per-member sync_state nornir's monitor writes, so it shows a populate started by nornir's poll loop, another client or the CLI. The repo denominator is known on the first poll (a proportion, not a bar parked at 0%), the bar follows the server as members land, the crate count reports `pending` until it is measured, and bytes report `unavailable` with the reason instead of a fabricated 0",
);
}
/// RED-when-broken: the release target MUST equal the selection. If `release_target`
/// ever decoupled from `selected` (e.g. returned a fixed default), this fails.
#[test]
fn release_target_equals_selection_always() {
let mut s = WarehouseSelector::new(vec!["a".into(), "b".into()], "a");
let start_ok = s.release_target() == s.selected();
assert_eq!(s.release_target(), s.selected());
s.select("b");
assert_eq!(s.release_target(), "b");
assert_eq!(s.release_target(), s.selected());
cell(
"warehouse_selector_release_target_equals_selection",
start_ok && s.release_target() == "b" && s.release_target() == s.selected(),
"the release target is never decoupled from the selection: it equals the selected warehouse before and after every re-selection (a fixed default would red this)",
);
}
/// **OPENING THE PICKER REFRESHES THE REGISTRY** — the design doc's Deferred item
/// (`.nornir/add-warehouse-ssh-populate-design.md`: *"Live `list_warehouses`
/// refresh on open. The client fn exists; wiring the header to pre-fill the picker
/// from `Workspaces.List` on startup is a follow-up."*).
///
/// Before this cell, `list_warehouses` had exactly ONE call site in the whole tree
/// — `lib.rs::registered_warehouses`, called once inside `build_native_app`. The
/// picker was therefore a photograph of the registry taken at process start. A
/// warehouse registered afterwards by ANYTHING else — the nornir CLI, a second
/// dwarves window, another operator — was invisible until the user restarted the
/// app, and the affordance that looks like it would show you the current list (the
/// picker itself) was the one thing that could not.
///
/// RED-when-broken, and it does not call the refresh seam directly: it CLICKS the
/// real ComboBox button through kittest (AccessKit `Action::Click`, which egui
/// honours only for enabled widgets), so what is proven is the WIRING in `ui()` and
/// not merely that a method exists. Remove the open-edge call and `registry_refreshes`
/// never moves and `late-ws` never appears.
#[test]
fn robot_opening_the_picker_refreshes_the_registry_from_the_server() {
use egui_kittest::kittest::Queryable;
use egui_kittest::Harness;
// A REAL nornir `Workspaces` server (mock) — the same one the Add/Populate cell
// drives, reached over the same thin client (L5, never a second mock).
let srv = nornir_warehouse_embed::testkit::spawn_mock_workspaces();
// The shell as `build_native_app` left it: the picker holds the ONE name the
// startup query knew about.
let sel = std::cell::RefCell::new(
WarehouseSelector::new(vec!["seed-ws".to_string()], "seed-ws")
.with_add_source(&srv.endpoint, ""),
);
// …and then SOMEBODY ELSE registers a warehouse. This is the whole scenario:
// the registry moved and this process did not do it.
nornir_warehouse_embed::register_warehouse_by_ssh(
&srv.endpoint,
"",
"late-ws",
"git@forge.example:nordisk/late-ws.git",
)
.expect("another client registered a warehouse against the same server");
let mut h = Harness::builder()
.with_size(egui::vec2(900.0, 620.0))
.build_ui(|ui| {
sel.borrow_mut().ui(ui);
});
// Pump frames until `done` — never `run()`, which panics when a frame keeps
// requesting repaints.
let pump = |h: &mut Harness<'_>, done: &dyn Fn() -> bool| {
for _ in 0..600 {
h.step();
if done() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
false
};
h.step();
// The premise: nothing has refreshed, so the picker is still the startup photo.
let before = sel.borrow().state_json();
assert_eq!(
before["count"], 1,
"the picker starts on its one seeded name"
);
// Tolerant of a MISSING key on purpose: the red this test exists to catch is
// "opening the picker asked the server nothing", not "the oracle field is
// absent". Absent reads as zero here so the failure lands on the assertion
// below, which is the one with something to say.
assert_eq!(
before["registry_refreshes"].as_u64().unwrap_or(0),
0,
"and nothing has refreshed it yet"
);
// ── OPEN THE PICKER, for real. ──
// BY ROLE, not by label: egui gives the combo button `Role::ComboBox` and hangs
// its selected text on a child `Label`/`TextRun`, so `get_by_label("seed-ws")`
// finds nothing (MEASURED — kittest dumped the tree). The role is also the
// stabler handle: it does not move when the selection changes.
h.get_by_role(egui::accesskit::Role::ComboBox).click();
h.step();
let refreshed = pump(&mut h, &|| {
sel.borrow().state_json()["registry_refreshes"]
.as_u64()
.unwrap_or(0)
>= 1
});
assert!(
refreshed,
"opening the picker must fire a Workspaces.List refresh — the picker is the \
affordance that shows the registry, so it is the one place a stale list is \
indefensible"
);
let st = sel.borrow().state_json();
let names: Vec<String> = st["warehouses"]
.as_array()
.expect("warehouses is an array")
.iter()
.map(|w| w.as_str().unwrap_or_default().to_string())
.collect();
let sees_late = names.iter().any(|n| n == "late-ws");
assert!(
sees_late,
"the warehouse registered by the other client is now offered: {names:?}"
);
// A refresh UNIONS — it never empties the picker. A name the shell was seeded
// with (`$DWARVES_WAREHOUSES`, or a server that answered once and has since gone
// quiet) must survive a refresh, or a transient RPC failure would silently take
// the user's selection away.
let keeps_seed = names.iter().any(|n| n == "seed-ws");
assert!(keeps_seed, "a refresh adds, it does not replace: {names:?}");
assert_eq!(
st["selected_warehouse"], "seed-ws",
"…and the selection is untouched"
);
let ok = sees_late && keeps_seed;
cell(
"warehouse_picker_refreshes_the_registry_on_open",
ok,
"a kittest frame CLICKS the real warehouse ComboBox against a live Workspaces \
gRPC mock: opening the picker fires Workspaces.List through the host and folds \
in a warehouse registered by ANOTHER client after this shell started, without \
dropping the startup-seeded names or moving the selection. Closes the design \
doc's Deferred `live list_warehouses refresh on open` — before this the RPC's \
only call site was the once-at-startup seed in build_native_app, so the picker \
was a photograph of the registry taken at process start",
);
}
}