src/viz/build_tab.rs
//! 🔨 **Build** tab — the build-thing UI (Tier-2 #5/#26), drawn in the viz.
//!
//! Reflects a [`BuildSurface`](crate::build::surface::BuildSurface) into CHOOSABLE
//! rows — one per declared variant, each showing its `(binary × features × profile
//! × target)` axes, its up-front validity, and a **BUILD** button. Pressing BUILD
//! for the selected variant runs the existing `nornir build --attest --seal` path
//! (validate → materialize → `cargo build` → build-provenance attestation →
//! warehouse row → znippy seal) and surfaces the resulting provenance summary
//! (digest, artifact count, sealed?) back onto the card.
//!
//! # Elm-pure, side-work-as-data
//!
//! The observable state lives entirely in [`BuildThing`](crate::build::elm::BuildThing)
//! (the facett Elm pure core: Model / Msg / update / Effect). `draw` is a pure
//! projection + input collector: it emits [`BuildThingMsg`]s and lets
//! [`BuildThing::update`] mutate the model and hand back [`BuildThingEffect`]s. The
//! only side work — actually running the build — is done by a [`BuildRunner`] seam
//! OFF the paint thread; its result is fed back as a `BuildSucceeded` / `BuildFailed`
//! message. Tests inject a mock runner, so the wiring is verified without compiling
//! the world.
//!
//! Demo content: **holger's** declared build-space (embedded · mini · full), the
//! first consumer of build-thing.
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use eframe::egui::{self};
use crate::build::elm::{BuildThing, BuildThingEffect, BuildThingMsg};
use crate::build::surface::BuildSurface;
use crate::build::variant::BuildVariantSet;
use crate::jobs::JobSink;
use super::facett_theme::{GREEN, RED, Theme, ThemeExt};
use super::test_boot::{BootRunner, BootStatus, BootTarget, TestBootState};
/// The provenance summary a completed build surfaces (the warehouse row's key
/// facts), fed back into the [`BuildThing`] card and shown in the tab.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProvenanceSummary {
/// Lowercase-hex attestation digest.
pub digest: String,
/// How many artifacts the build produced.
pub artifacts: usize,
/// Whether the attestation was znippy-sealed.
pub sealed: bool,
/// A one-line description of the warehouse row the build recorded.
pub warehouse_row: String,
}
/// Widest the build grid's free-length `features` cell may grow before it wraps.
const FEATURES_COL_MAX_W: f32 = 260.0;
/// The exact `nornir` CLI argv the BUILD button runs for `variant` — the existing
/// `nornir build --variant <v> --attest --seal` path. The single source of truth
/// for the wiring (asserted by tests, used by [`NornirCliRunner`]).
pub fn attest_seal_argv(variant: &str) -> Vec<String> {
vec![
"build".to_string(),
"--variant".to_string(),
variant.to_string(),
"--attest".to_string(),
"--seal".to_string(),
]
}
/// The side-work seam: run a build for a variant and return its provenance summary.
/// The real impl shells out to `nornir build --attest --seal`; tests inject a mock
/// so the BUILD wiring is exercised without compiling anything.
pub trait BuildRunner: Send + Sync {
/// Run `variant`'s build in `repo_root`; `Ok` carries the provenance summary,
/// `Err` a human reason.
fn run(&self, repo_root: &Path, variant: &str) -> Result<ProvenanceSummary, String>;
}
/// The production runner: spawns `nornir build --variant <v> --attest --seal --out
/// <tmp>` in the repo root, then reads the written [`Attestation`] JSON. The seal
/// only materializes when the `build-provenance` feature is compiled into the
/// invoked `nornir`; otherwise the attestation is captured but unsealed (honest).
pub struct NornirCliRunner;
impl BuildRunner for NornirCliRunner {
fn run(&self, repo_root: &Path, variant: &str) -> Result<ProvenanceSummary, String> {
use std::process::Command;
let bin = std::env::var("NORNIR_BIN").unwrap_or_else(|_| "nornir".to_string());
let out = std::env::temp_dir().join(format!("nornir-build-{variant}.attestation.json"));
let mut args = attest_seal_argv(variant);
args.push("--out".to_string());
args.push(out.to_string_lossy().into_owned());
let status = Command::new(&bin)
.current_dir(repo_root)
.args(&args)
.status()
.map_err(|e| format!("spawn `{bin} {}`: {e}", args.join(" ")))?;
if !status.success() {
return Err(format!(
"`{bin} build --variant {variant} --attest --seal` exited {status}"
));
}
let text = std::fs::read_to_string(&out)
.map_err(|e| format!("read attestation {}: {e}", out.display()))?;
let att = crate::build::provenance::Attestation::from_json(&text)
.map_err(|e| format!("parse attestation: {e:#}"))?;
Ok(ProvenanceSummary {
digest: att.digest_hex(),
artifacts: att.record.artifacts.len(),
sealed: att.is_sealed(),
warehouse_row: format!(
"build_provenance: repo={} variant={} outcome={:?} artifacts={}",
att.record.repo,
att.record.variant.name,
att.record.outcome,
att.record.artifacts.len(),
),
})
}
}
/// One in-flight build: the variant + the slot a worker thread posts its result to.
struct InFlight {
variant: String,
slot: Arc<Mutex<Option<Result<ProvenanceSummary, String>>>>,
}
/// 🔨 The Build tab: the [`BuildThing`] Elm core + the surface it reflects, a
/// pluggable [`BuildRunner`], and the in-flight build slots.
pub struct BuildTabState {
/// The Elm pure core (all observable state).
thing: BuildThing,
/// The reflected surface (choosable rows: binary × features × profile × target).
surface: BuildSurface,
/// The repo the builds run in.
repo_root: PathBuf,
/// The build side-work seam (real = [`NornirCliRunner`]).
runner: Arc<dyn BuildRunner>,
/// Builds started but not yet reported back.
inflight: Vec<InFlight>,
/// The provenance summaries of completed builds, by variant (for the warehouse
/// row line under a Built card).
provenance: std::collections::BTreeMap<String, ProvenanceSummary>,
/// 🚀 The **test-boot** jobs manager: pressing a target's test-boot button
/// spins the built target up as a nornir job with a bidirectional kill
/// lifecycle (see [`super::test_boot`]).
test_boot: TestBootState,
/// Boot-press intents collected by the pure `view` (variant name → boot), drained
/// and spawned by the live [`Self::draw`]. Interior-mutable so the pure `&self`
/// view can record them without mutating the model (mirrors the build `pending`).
pending_boots: RefCell<Vec<String>>,
/// Kill-press intents (job ids) collected by the pure view, drained by `draw`.
pending_kills: RefCell<Vec<String>>,
/// The declared container image builds (kept so a test-boot builds an exact
/// [`BootTarget::Container`] from the real descriptor, not a lossy projection).
container_builds: Vec<nornir_build_thing::ContainerBuild>,
/// The declared VM builds (kept for exact [`BootTarget::Vm`] test-boots).
vm_builds: Vec<nornir_build_thing::VmBuild>,
/// Container/VM test-boot intents (target name) collected by the pure view.
pending_target_boots: RefCell<Vec<String>>,
/// The active facett palette.
palette: Theme,
}
impl BuildTabState {
/// The Build tab over **`repo_root`'s OWN declared build space**, wired to the
/// real [`NornirCliRunner`].
///
/// # What this used to do, and why it was wrong
///
/// Until 2026-08-01 this seeded the tab from `HOLGER_DEMO` — a hardcoded
/// `[build]` TOML literal in this file declaring holger's `embedded` / `mini` /
/// `full`. It was byte-copied from holger's descriptor when holger was
/// build-thing's first consumer, and then it froze. The consequence: **nornir's
/// own Build tab never showed nornir's own atoms.** It showed a stale copy of
/// another repo's, so pressing BUILD shelled `nornir build --variant full` in
/// nornir's root, where no variant called `full` exists.
///
/// It was also the second declaration of the build space — a build-variant list
/// written in Rust next to the TOML that is supposed to be the only one. That is
/// the shape `nornir_build_thing::suite_cli` exists to remove.
///
/// # The fallback is EMPTY and says so
///
/// A repo with no `.nornir/build-variants.toml` (or an unparseable one) yields an
/// empty surface carrying the reason, never a fixture. An empty Build tab is a
/// true statement about a repo that declares no build space; a fixture is a false
/// one about every repo, and it is indistinguishable from a working tab.
pub fn new(repo_root: PathBuf) -> Self {
let (set, note) = match BuildVariantSet::discover(&repo_root) {
Ok(Some(set)) => (set, None),
Ok(None) => (
BuildVariantSet::default(),
Some(format!(
"no build space declared — {} does not exist",
BuildVariantSet::conventional_path(&repo_root).display()
)),
),
Err(e) => (
BuildVariantSet::default(),
Some(format!(
"{} could not be read: {e:#}",
BuildVariantSet::conventional_path(&repo_root).display()
)),
),
};
if let Some(why) = ¬e {
eprintln!("nornir viz 🔨 Build: {why}");
}
Self::from_set(repo_root, set, Arc::new(NornirCliRunner))
}
/// Build the tab from a `[build]` TOML descriptor + an injected runner — the
/// TEST seam, and the only caller that may supply a descriptor as a string.
/// Falls back to an empty surface if the descriptor doesn't parse.
pub fn from_toml(repo_root: PathBuf, toml: &str, runner: Arc<dyn BuildRunner>) -> Self {
Self::from_set(
repo_root,
BuildVariantSet::from_toml_str(toml).unwrap_or_default(),
runner,
)
}
/// The ONE constructor both the live path and the test seam route through.
pub fn from_set(
repo_root: PathBuf,
set: BuildVariantSet,
runner: Arc<dyn BuildRunner>,
) -> Self {
let repo = repo_root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "<repo>".to_string());
let surface = BuildSurface::from_set(repo.clone(), &set);
let thing = BuildThing::from_surface(&surface);
Self {
thing,
surface,
repo_root,
runner,
inflight: Vec::new(),
provenance: std::collections::BTreeMap::new(),
// No warehouse configured by default → the boot jobs are observable in
// the tab's state_json but not persisted; the app swaps in a real sink.
test_boot: TestBootState::new(JobSink::noop(), repo),
pending_boots: RefCell::new(Vec::new()),
pending_kills: RefCell::new(Vec::new()),
container_builds: set.containers.clone(),
vm_builds: set.vms.clone(),
pending_target_boots: RefCell::new(Vec::new()),
palette: Theme::default(),
}
}
/// Point the test-boot jobs at a durable job ledger `sink` (the app wires the
/// server's `jobs.redb` sink so boot jobs show in the Jobs panel too).
pub fn set_boot_sink(&mut self, sink: JobSink, workspace: impl Into<String>) {
self.test_boot = TestBootState::with_runner(self.test_boot_runner(), sink, workspace);
}
/// Test-only: swap the boot side-work runner so a headless robot can press
/// test-boot without spawning a real child process / container / KVM. A
/// [`super::test_boot::FakeBootRunner`] drives the whole boot lifecycle
/// deterministically through the same job path the live tab uses.
#[doc(hidden)]
pub fn set_boot_runner_for_test(&mut self, runner: Arc<dyn BootRunner>) {
self.test_boot =
TestBootState::with_runner(runner, JobSink::noop(), self.surface.repo.clone());
}
/// The current boot runner (kept so `set_boot_sink` preserves an injected one).
fn test_boot_runner(&self) -> Arc<dyn BootRunner> {
self.test_boot.runner_arc()
}
/// Adopt the app-wide palette (C8) so the pane paints in the active theme.
pub fn set_palette(&mut self, palette: Theme) {
self.palette = palette;
}
/// Test-only: swap the build side-work runner so a headless robot can drive
/// the BUILD button without spawning a real `nornir build --attest --seal`
/// subprocess (which would compile the world). A mock runner returns a canned
/// [`ProvenanceSummary`] instantly, so the full select → BUILD → provenance
/// wiring is exercised deterministically through the same async spawn/poll
/// path the live tab uses (`tests/viz_build_robot.rs`).
#[doc(hidden)]
pub fn set_runner_for_test(&mut self, runner: Arc<dyn BuildRunner>) {
self.runner = runner;
}
/// The observable state a headless test / the operator reads: the Elm model,
/// the surface summary, and each completed build's warehouse row. The inherent
/// method the app's `state_json()` reads; the facett `Facet::state_json` (from
/// [`impl_facet_via_elm!`], form `custom_state_json`) delegates to the same body
/// so the deck/matrix and the app agree on the observable surface.
pub fn state_json(&self) -> serde_json::Value {
self.observable_json()
}
/// The rich observable JSON, factored out so BOTH the inherent [`Self::state_json`]
/// and the facett `Facet::state_json` bridge return it (one truth, no method-name
/// ambiguity between the inherent and trait `state_json`).
fn observable_json(&self) -> serde_json::Value {
let provenance: serde_json::Map<String, serde_json::Value> = self
.provenance
.iter()
.map(|(k, v)| {
(
k.clone(),
serde_json::json!({
"digest": v.digest,
"artifacts": v.artifacts,
"sealed": v.sealed,
"warehouse_row": v.warehouse_row,
}),
)
})
.collect();
serde_json::json!({
"surface": "build",
"repo": self.surface.repo,
"variant_count": self.surface.variants.len(),
"valid_count": self.surface.variants.iter().filter(|v| v.valid).count(),
"selected": self.thing.state().selected,
"building": !self.inflight.is_empty(),
// The Elm model (cards, statuses, log) — the full pure-core state.
"model": self.thing.state_json(),
// The reflected choosable rows (binary × features × profile × target).
"rows": self.surface.state_json()["variants"].clone(),
"provenance": serde_json::Value::Object(provenance),
// 🚀 the live test-boot jobs (Exe/Container/Vm) + their status/log.
"test_boot": self.test_boot.state_json(),
})
}
/// Best-effort path to a built cargo variant's executable — the exe a
/// [`super::test_boot::BootMode::Exe`] boot spawns. Derived from the variant's
/// `bin`/`package` under `<repo>/target/<profile>/`. Best-effort because the
/// real path depends on `CARGO_TARGET_DIR`/profile-dir; the injected fake runner
/// makes tests independent of it, and a wrong path surfaces as a Failed boot job
/// (honest), never a panic.
fn exe_path_for(&self, variant: &str) -> PathBuf {
let v = self.surface.variants.iter().find(|v| v.name == variant);
let bin = v
.and_then(|v| v.bin.clone().or_else(|| v.package.clone()))
.unwrap_or_else(|| variant.to_string());
let profile_dir = match v.map(|v| v.profile.as_str()) {
Some("release") => "release",
_ => "debug",
};
self.repo_root.join("target").join(profile_dir).join(bin)
}
/// Build the [`BootTarget`] for a variant's test-boot. A cargo variant boots in
/// **Exe** mode (spawn the built binary); a declared VM/container target would
/// select its own mode via [`BootTarget::mode`].
fn boot_target_for(&self, variant: &str) -> BootTarget {
BootTarget::Exe {
name: variant.to_string(),
exe: self.exe_path_for(variant),
args: Vec::new(),
}
}
/// Press **test-boot** for a variant: create a boot job spinning the built exe
/// up (the ONE boot-spawn path; live `draw` and the test helper both call it).
pub fn press_test_boot(&mut self, variant: &str) -> String {
let target = self.boot_target_for(variant);
self.test_boot.boot(target)
}
/// Press **test-boot** for a declared container or VM target (by name): boot it
/// in its own mode (Container via podman, Vm via tunnr). Returns the job id, or
/// `None` if no such target is declared.
pub fn press_test_boot_target(&mut self, name: &str) -> Option<String> {
if let Some(c) = self.container_builds.iter().find(|c| c.name == name) {
return Some(self.test_boot.boot(BootTarget::Container(c.clone())));
}
if let Some(v) = self.vm_builds.iter().find(|v| v.name == name) {
return Some(self.test_boot.boot(BootTarget::Vm(v.clone())));
}
None
}
/// Drive the boot-job lifecycle one tick (drain logs, poll each boot both
/// directions). Called by `draw`; also the test seam.
pub fn poll_boots(&mut self) {
self.test_boot.poll();
}
/// Kill a running boot job by id (job cancel → kill the boot). The test seam +
/// the live 🗙 button both route here.
pub fn kill_boot(&mut self, job_id: &str) -> bool {
self.test_boot.kill(job_id)
}
/// Send one message through the Elm core and run any effects it hands back
/// (spawning the build off the paint thread). The ONE mutation path.
fn dispatch(&mut self, msg: BuildThingMsg) {
for effect in self.thing.update(msg) {
match effect {
BuildThingEffect::RunBuild(variant) => self.spawn_build(variant),
}
}
}
/// Spawn a worker thread that runs the build via the [`BuildRunner`] so the
/// paint thread never blocks; the result is polled in [`Self::poll`].
fn spawn_build(&mut self, variant: String) {
let slot: Arc<Mutex<Option<Result<ProvenanceSummary, String>>>> =
Arc::new(Mutex::new(None));
let runner = Arc::clone(&self.runner);
let repo_root = self.repo_root.clone();
let worker_slot = Arc::clone(&slot);
let worker_variant = variant.clone();
std::thread::spawn(move || {
let result = runner.run(&repo_root, &worker_variant);
*worker_slot.lock().unwrap() = Some(result);
});
self.inflight.push(InFlight { variant, slot });
}
/// Drain any finished builds, feeding their outcome back through the Elm core.
fn poll(&mut self) {
let mut done: Vec<(String, Result<ProvenanceSummary, String>)> = Vec::new();
self.inflight.retain(|f| {
let ready = f.slot.lock().unwrap().take();
match ready {
Some(result) => {
done.push((f.variant.clone(), result));
false
}
None => true,
}
});
for (variant, result) in done {
match result {
Ok(summary) => {
// EMIT-DOCTRINE: a finished build is a REAL functional signal —
// record it as a matrix row (green). A no-`testmatrix` build strips
// the call + the detail string entirely (the emit is an inline no-op).
nornir_testmatrix::functional_status(
"viz-build",
&format!("build_{variant}"),
true,
&format!(
"built '{variant}': {} artifact(s), digest {}…, sealed={}",
summary.artifacts,
summary.digest.chars().take(16).collect::<String>(),
summary.sealed,
),
);
self.dispatch(BuildThingMsg::BuildSucceeded {
variant: variant.clone(),
digest: summary.digest.clone(),
artifacts: summary.artifacts,
sealed: summary.sealed,
});
self.provenance.insert(variant, summary);
}
Err(reason) => {
// A failed build is a RED functional row (an honest broken surface).
nornir_testmatrix::functional_status(
"viz-build",
&format!("build_{variant}"),
false,
&format!("build '{variant}' FAILED: {reason}"),
);
self.dispatch(BuildThingMsg::BuildFailed { variant, reason });
}
}
}
}
/// Run a build synchronously through the SAME runner + feedback path the live
/// tab uses (no worker thread). The test seam — asserts the full BUILD wiring
/// (select → `nornir build --attest --seal` → provenance card) deterministically.
pub fn run_build_blocking(&mut self, variant: &str) {
let effects = self.thing.update(BuildThingMsg::Build(variant.to_string()));
for effect in effects {
let BuildThingEffect::RunBuild(v) = effect;
match self.runner.run(&self.repo_root, &v) {
Ok(summary) => {
self.dispatch(BuildThingMsg::BuildSucceeded {
variant: v.clone(),
digest: summary.digest.clone(),
artifacts: summary.artifacts,
sealed: summary.sealed,
});
self.provenance.insert(v, summary);
}
Err(reason) => self.dispatch(BuildThingMsg::BuildFailed { variant: v, reason }),
}
}
}
/// Paint the tab: the header, one choosable row per variant, and the activity
/// log. Pure projection — every mutation goes through [`Self::dispatch`].
///
/// This is the LIVE path (used by the app's tab dispatch): it first drains any
/// finished builds ([`Self::poll`]) and requests a repaint while one is running,
/// then runs the pure [`Elm::view`](facett_core::Elm::view) to collect the frame's
/// `Msg`s and dispatches them — running their [`BuildThingEffect`]s (the real
/// `spawn_build`). The facett [`Facet::ui`](facett_core::Facet::ui) bridge (from
/// [`impl_facet_via_elm!`]) drives the SAME `view` but applies `update` without
/// running effects, so a headless deck/harness render never spawns a real build.
pub fn draw(&mut self, ui: &mut egui::Ui) {
// Feed back any completed builds first, then poll the live boot jobs both
// directions (drain logs, detect a boot that died on its own → job
// terminates), and request a repaint while a build OR a boot is running.
self.poll();
self.poll_boots();
if !self.inflight.is_empty() || self.test_boot.any_running() {
ui.ctx().request_repaint();
}
// The pure Elm view paints + emits the frame's Msgs; dispatch runs them (and
// their effects — the real build spawn) — the ONE live mutation path.
for msg in <Self as facett_core::Elm>::view(self, ui) {
self.dispatch(msg);
}
// Drain the boot/kill presses the pure view recorded and act on them (the
// live-only side work — a headless deck render never drains these, so it
// never spawns a real boot).
let boots: Vec<String> = self.pending_boots.borrow_mut().drain(..).collect();
for variant in boots {
self.press_test_boot(&variant);
}
let target_boots: Vec<String> = self.pending_target_boots.borrow_mut().drain(..).collect();
for name in target_boots {
self.press_test_boot_target(&name);
}
let kills: Vec<String> = self.pending_kills.borrow_mut().drain(..).collect();
for job_id in kills {
self.kill_boot(&job_id);
}
}
/// The pure FC-9 view of the Build tab: paints the header, one choosable row per
/// variant, and the activity log, and RETURNS the [`BuildThingMsg`]s the user's
/// interactions produced — it never mutates the model or does I/O. Kept as the
/// [`Elm::view`](facett_core::Elm::view) impl below; [`Self::draw`] (live) and the
/// facett `Facet::ui` bridge both drive it.
fn view_pure(&self, ui: &mut egui::Ui) -> Vec<BuildThingMsg> {
ui.heading(format!("🔨 Build — {}", self.surface.repo));
ui.label(
"Choose a build target (binary × features × profile × target), then press \
BUILD to run `nornir build --attest --seal` and record its provenance.",
);
ui.add_space(6.0);
// Collect messages during the render, apply after (keep the borrow clean).
let mut pending: Vec<BuildThingMsg> = Vec::new();
let selected = self.thing.state().selected.clone();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
egui::Grid::new("build_variants")
.num_columns(6)
.striped(true)
.spacing([14.0, 8.0])
.show(ui, |ui| {
ui.strong("target");
ui.strong("binary");
ui.strong("features");
ui.strong("profile");
ui.strong("triple");
ui.strong("status / build");
ui.end_row();
for v in &self.surface.variants {
let is_sel = selected.as_deref() == Some(v.name.as_str());
if ui.selectable_label(is_sel, v.name.as_str()).clicked() {
pending.push(BuildThingMsg::Select(v.name.clone()));
}
ui.monospace(
v.bin
.clone()
.or_else(|| v.package.clone())
.unwrap_or_default(),
);
// The features cell is the one free-length column. Unbounded it
// pushed the action column (BUILD / test-boot) off a 1400-px
// surface once egui 0.36 measured text a little wider
// (2026-10-02, viz_build_robot): cap it and wrap at the
// commas, so the actions stay on screen however many
// features a variant declares.
ui.scope(|ui| {
ui.set_max_width(FEATURES_COL_MAX_W);
ui.add(
egui::Label::new(
egui::RichText::new(if v.features.is_empty() {
"—".to_string()
} else {
v.features.join(", ")
})
.monospace(),
)
.wrap(),
);
});
ui.monospace(v.profile.clone());
ui.monospace(v.target.clone().unwrap_or_else(|| "host".to_string()));
ui.horizontal(|ui| {
// Status chip from the Elm card.
let card =
self.thing.state().cards.iter().find(|c| c.name == v.name);
self.draw_status(ui, card);
// The BUILD button — disabled (with the reason) when invalid.
let building = self.inflight.iter().any(|f| f.variant == v.name);
let enabled = v.valid && !building;
// The button text carries the variant name so each
// row's BUILD action is a UNIQUELY-addressable atom (no
// "BUILD" label collision) — a headless robot can drive
// exactly one target's build via `click_label("BUILD full")`.
let btn = egui::Button::new(
egui::RichText::new(format!("BUILD {}", v.name)).strong(),
);
if ui.add_enabled(enabled, btn).clicked() {
pending.push(BuildThingMsg::Build(v.name.clone()));
}
// 🚀 TEST-BOOT — spin the built target up as a nornir job.
// Enabled once the variant is built (a boot needs an
// artifact). Uniquely labelled per variant so a robot can
// drive exactly one (`click_label("test-boot full")`).
let is_built = matches!(
self.thing
.state()
.cards
.iter()
.find(|c| c.name == v.name)
.map(|c| &c.status),
Some(crate::build::elm::CardStatus::Built { .. })
);
let boot_btn = egui::Button::new(egui::RichText::new(format!(
"🚀 test-boot {}",
v.name
)));
if ui.add_enabled(is_built, boot_btn).clicked() {
self.pending_boots.borrow_mut().push(v.name.clone());
}
if !v.valid {
if let Some(reason) = &v.invalid_reason {
ui.colored_label(RED, format!("⚠ {reason}"));
}
}
});
ui.end_row();
}
});
// The materialized command line for the selected target + its warehouse row.
if let Some(sel) = &selected {
if let Some(v) = self.surface.variants.iter().find(|v| &v.name == sel) {
ui.add_space(8.0);
ui.label(egui::RichText::new("selected command").weak());
ui.code(v.command_line.as_str());
}
if let Some(p) = self.provenance.get(sel) {
ui.label(
egui::RichText::new(&p.warehouse_row)
.color(GREEN)
.monospace(),
);
}
}
// 🚀 Container / VM boot targets — each declared image/VM gets a
// test-boot button that spins it up in its own mode (Container via
// podman, Vm via tunnr). Uniquely labelled per target for robot drives.
if !self.surface.containers.is_empty() || !self.surface.vms.is_empty() {
ui.add_space(8.0);
ui.separator();
ui.label(egui::RichText::new("bootable targets (container / vm)").weak());
for c in &self.surface.containers {
ui.horizontal(|ui| {
ui.monospace(format!("📦 {} [{}]", c.name, c.format));
let btn = egui::Button::new(egui::RichText::new(format!(
"🚀 test-boot {}",
c.name
)));
if ui.add_enabled(c.valid, btn).clicked() {
self.pending_target_boots.borrow_mut().push(c.name.clone());
}
if let Some(r) = &c.invalid_reason {
ui.colored_label(RED, format!("⚠ {r}"));
}
});
}
for v in &self.surface.vms {
ui.horizontal(|ui| {
ui.monospace(format!(
"🖥 {} [{}] {}MiB/{}c",
v.name, v.format, v.mem_mb, v.cores
));
let btn = egui::Button::new(egui::RichText::new(format!(
"🚀 test-boot {}",
v.name
)));
if ui.add_enabled(v.valid, btn).clicked() {
self.pending_target_boots.borrow_mut().push(v.name.clone());
}
if let Some(r) = &v.invalid_reason {
ui.colored_label(RED, format!("⚠ {r}"));
}
});
}
}
// Activity log (newest last).
let log = &self.thing.state().log;
if !log.is_empty() {
ui.add_space(8.0);
ui.separator();
ui.label(egui::RichText::new("activity").weak());
for line in log.iter().rev().take(8) {
ui.monospace(line.as_str());
}
}
// 🚀 Running / recent test-boot jobs — one row per boot, with its live
// status, a 🗙 kill button (job cancel → kill the boot), and a log tail.
let boot_jobs = self.test_boot.jobs();
if !boot_jobs.is_empty() {
ui.add_space(8.0);
ui.separator();
ui.label(egui::RichText::new("🚀 test-boot jobs").strong());
for job in boot_jobs {
ui.horizontal(|ui| {
let color = match &job.status {
BootStatus::BootedOk => self.palette.status_color("ok"),
BootStatus::Running | BootStatus::Booting => {
self.palette.status_color("running")
}
BootStatus::Failed(_) => self.palette.status_color("error"),
BootStatus::Killed => RED,
};
ui.colored_label(color, format!("● {}", job.status.as_str()));
ui.monospace(format!("{} [{}]", job.target, job.mode.as_str()));
if !job.status.is_terminal() {
let kill = egui::Button::new(
egui::RichText::new(format!("🗙 kill {}", job.target))
.color(RED),
);
if ui.add(kill).clicked() {
self.pending_kills.borrow_mut().push(job.job_id.clone());
}
}
});
// The boot log tail (child stdout/stderr / serial console).
for line in job.log.iter().rev().take(4).rev() {
ui.monospace(format!(" {line}"));
}
}
}
});
pending
}
/// Paint the per-card status chip (idle / building / built digest / failed).
fn draw_status(&self, ui: &mut egui::Ui, card: Option<&crate::build::elm::VariantCard>) {
use crate::build::elm::CardStatus;
match card.map(|c| &c.status) {
Some(CardStatus::Idle) | None => {
ui.weak("idle");
}
Some(CardStatus::Building) => {
ui.colored_label(self.palette.status_color("running"), "building…");
}
Some(CardStatus::Built {
digest,
artifacts,
sealed,
}) => {
let seal = if *sealed { " 🔒" } else { "" };
ui.colored_label(
self.palette.status_color("ok"),
format!("built · {artifacts} art · {digest}…{seal}"),
);
}
Some(CardStatus::Failed { reason }) => {
ui.colored_label(
self.palette.status_color("error"),
format!("failed: {reason}"),
);
}
}
}
}
/// **The facett `Elm` contract for the Build tab.** nornir's dep-light
/// `nornir-build-thing` crate holds the pure `Model`/`Msg`/`update`/`Effect` core
/// ([`BuildThing`]) but cannot pull egui, so the egui `view` — and thus the
/// `facett_core::Elm` impl + the `Facet` bridge — live HERE on the viz side (exactly
/// as the crate's own docs prescribe). With this, `Tab::Build` renders through the
/// same facett `Facet` contract as nornir's ~35 other embedded facett components and
/// can be driven headless by `facett_core::harness` / asserted via `state_json`.
///
/// - `Model` = [`BuildThingModel`](crate::build::elm::BuildThingModel) (the whole
/// pure-core observable state);
/// - `Msg` = [`BuildThingMsg`] / `Effect` = [`BuildThingEffect`];
/// - `update` is the pure mutation path (delegates to the core `BuildThing::update`)
/// and returns the effects as DATA — the live [`BuildTabState::draw`] runs them
/// (`spawn_build`), while the macro-generated `Facet::ui` applies them without
/// side work so a headless render never spawns a real build.
impl facett_core::Elm for BuildTabState {
type Model = crate::build::elm::BuildThingModel;
type Msg = BuildThingMsg;
type Effect = BuildThingEffect;
fn title(&self) -> &str {
self.thing.title()
}
fn state(&self) -> &Self::Model {
self.thing.state()
}
fn update(&mut self, msg: BuildThingMsg) -> Vec<BuildThingEffect> {
self.thing.update(msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<BuildThingMsg> {
self.view_pure(ui)
}
}
// Writes `impl Facet for BuildTabState` (title + the FC-9 view/update `ui` loop),
// with the RICH `state_json` (surface + provenance + rows) rather than the plain
// model-only serde default — the same observable JSON the app's `state_json()` reads.
facett_core::impl_facet_via_elm!(BuildTabState, custom_state_json, {
fn state_json(&self) -> facett_core::elm::__macro_deps::serde_json::Value {
self.observable_json()
}
});
#[cfg(test)]
mod tests {
use super::*;
/// A mock runner: records every (repo_root, variant) call and returns a canned
/// provenance summary — so the BUILD wiring is tested WITHOUT compiling the world.
#[derive(Default)]
struct MockRunner {
calls: Mutex<Vec<(PathBuf, String)>>,
fail: bool,
}
impl BuildRunner for MockRunner {
fn run(&self, repo_root: &Path, variant: &str) -> Result<ProvenanceSummary, String> {
self.calls
.lock()
.unwrap()
.push((repo_root.to_path_buf(), variant.to_string()));
if self.fail {
return Err("cargo exited 101".to_string());
}
Ok(ProvenanceSummary {
digest: "abcdef0123456789 feedface".replace(' ', ""),
artifacts: 1,
sealed: true,
warehouse_row: format!(
"build_provenance: variant={variant} outcome=Success artifacts=1"
),
})
}
}
/// A THREE-VARIANT descriptor for the tab's own unit tests, kept deliberately
/// generic (`embedded` / `mini` / `full`) so the tests exercise the reflection
/// and the BUILD wiring without pinning them to any repo's real atom names.
///
/// ⚠ TEST-ONLY. This is what used to be `HOLGER_DEMO` at module scope, where it
/// seeded the LIVE `BuildTabState::new` and made nornir's Build tab render
/// holger's frozen build space instead of nornir's own. The live path now
/// discovers the repo's real declaration; a build space written in Rust exists
/// nowhere outside this `#[cfg(test)]` module.
const DEMO_FIXTURE: &str = r#"
[build]
offers = ["sign", "testmatrix", "served", "gui"]
[[build.variant]]
name = "embedded"
package = "holger-server-lib"
features = ["sign"]
no_default_features = true
profile = "release"
[[build.variant]]
name = "mini"
package = "holger-server-cli"
bin = "holger-server"
no_default_features = true
profile = "release"
[[build.variant]]
name = "full"
package = "holger-server-cli"
bin = "holger-server"
profile = "release"
"#;
fn tab_with(runner: Arc<dyn BuildRunner>) -> BuildTabState {
BuildTabState::from_toml(PathBuf::from("/tmp/holger"), DEMO_FIXTURE, runner)
}
/// ★ RED-WHEN-BROKEN: the live Build tab reflects **THIS repo's** declared
/// atoms, not a fixture. Before 2026-08-01 it rendered a frozen copy of
/// holger's `embedded`/`mini`/`full` in every repo, so the assertion that
/// would have caught it is precisely "the names on the cards are the names in
/// the file". Reads the expected set from the descriptor rather than
/// hardcoding it, so it cannot rot into a second declaration of its own.
#[test]
fn the_live_build_tab_shows_this_repos_own_atoms() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf();
let declared = BuildVariantSet::discover(&root)
.expect("read .nornir/build-variants.toml")
.expect("nornir declares a build space");
let want: Vec<&str> = declared.variants.iter().map(|v| v.name.as_str()).collect();
assert!(
want.len() >= 2,
"the declaration must carry more than one atom for this guard to mean anything"
);
let tab = BuildTabState::new(root);
let got: Vec<&str> = tab
.surface
.variants
.iter()
.map(|v| v.name.as_str())
.collect();
assert_eq!(
got, want,
"the Build tab is not showing nornir's declared atoms (it showed holger's \
frozen `embedded`/`mini`/`full` fixture until 2026-08-01)"
);
for stale in ["embedded", "mini", "full"] {
assert!(
!got.contains(&stale),
"the tab still carries the holger demo fixture variant '{stale}'"
);
}
}
/// The EMPTY fallback is empty and labelled — never a fixture. A repo with no
/// declaration must produce zero cards, because "here are three variants you do
/// not have" is worse than "you declare none".
#[test]
fn a_repo_with_no_declaration_gets_an_empty_tab_not_a_fixture() {
let dir = tempfile::tempdir().expect("tempdir");
let tab = BuildTabState::new(dir.path().to_path_buf());
assert!(
tab.surface.variants.is_empty(),
"a repo declaring no build space got {} card(s): {:?}",
tab.surface.variants.len(),
tab.surface
.variants
.iter()
.map(|v| &v.name)
.collect::<Vec<_>>()
);
}
/// 🚀 **Press test-boot → a nornir job that tracks the boot** — the core of
/// the ask, asserted through the tab's `state_json` (the surface a robot reads),
/// with the boot side-work FAKED so no real process spawns. Covers all three
/// lifecycle facts: a press creates a running boot job; the job tracks the boot
/// status; and both kill directions (job-kill → boot killed; boot-death → job
/// terminates) land in `state_json`.
#[test]
fn press_test_boot_creates_a_job_that_tracks_boot_status() {
use crate::viz::test_boot::BootStatus;
use crate::viz::test_boot::{FakeBootRunner, FakeProc};
// Build `full` first (test-boot needs a built artifact), then inject a fake
// boot runner whose proc we keep to drive the lifecycle.
let mut tab = tab_with(Arc::new(MockRunner::default()));
tab.run_build_blocking("full");
let proc = FakeProc::running();
tab.set_boot_runner_for_test(Arc::new(FakeBootRunner::yielding(proc.clone())));
// Press test-boot → one boot job appears, kind exe, tracked in state_json.
let job_id = tab.press_test_boot("full");
let j = tab.state_json();
assert_eq!(j["test_boot"]["boot_jobs"].as_array().unwrap().len(), 1);
assert_eq!(j["test_boot"]["boot_jobs"][0]["mode"], "exe");
assert_eq!(j["test_boot"]["boot_jobs"][0]["target"], "full");
assert_eq!(j["test_boot"]["boot_jobs"][0]["status"], "booting");
// Poll → the job tracks the boot reaching Running.
tab.poll_boots();
assert_eq!(
tab.state_json()["test_boot"]["boot_jobs"][0]["status"],
"running"
);
assert_eq!(tab.state_json()["test_boot"]["running"], 1);
// Kill the JOB → the boot is killed (bidirectional), job goes terminal.
assert!(tab.kill_boot(&job_id));
assert!(
*proc.killed.lock().unwrap(),
"the job-kill reached the boot"
);
let j = tab.state_json();
assert_eq!(j["test_boot"]["boot_jobs"][0]["status"], "killed");
assert_eq!(j["test_boot"]["running"], 0);
assert_eq!(j["test_boot"]["boot_jobs"][0]["terminal"], true);
let _ = BootStatus::Booting; // (enum in scope for readers)
}
/// The other lifecycle direction: the boot DIES on its own → the job terminates
/// without any operator kill.
#[test]
fn boot_death_terminates_the_job() {
use crate::viz::test_boot::{BootStatus, FakeBootRunner, FakeProc};
let mut tab = tab_with(Arc::new(MockRunner::default()));
tab.run_build_blocking("mini");
let proc = FakeProc::running();
tab.set_boot_runner_for_test(Arc::new(FakeBootRunner::yielding(proc.clone())));
tab.press_test_boot("mini");
tab.poll_boots();
assert_eq!(
tab.state_json()["test_boot"]["boot_jobs"][0]["status"],
"running"
);
// The boot exits cleanly on its own → next poll flips the job to booted_ok.
*proc.status.lock().unwrap() = BootStatus::BootedOk;
tab.poll_boots();
assert_eq!(
tab.state_json()["test_boot"]["boot_jobs"][0]["status"],
"booted_ok"
);
assert_eq!(tab.state_json()["test_boot"]["running"], 0);
}
#[test]
fn holger_demo_reflects_into_three_choosable_rows() {
let tab = tab_with(Arc::new(MockRunner::default()));
assert_eq!(
tab.surface.variant_names(),
vec!["embedded", "mini", "full"]
);
assert!(tab.surface.variants.iter().all(|v| v.valid));
let j = tab.state_json();
assert_eq!(j["surface"], "build");
assert_eq!(j["variant_count"], 3);
assert_eq!(j["valid_count"], 3);
// The rows carry the choosable axes (binary × features × profile × target).
assert_eq!(j["rows"][0]["name"], "embedded");
assert_eq!(j["rows"][0]["features"][0], "sign");
}
#[test]
fn build_button_wires_to_nornir_build_attest_seal() {
// The single source of truth for the BUILD action's argv.
assert_eq!(
attest_seal_argv("full"),
vec!["build", "--variant", "full", "--attest", "--seal"],
);
let runner = Arc::new(MockRunner::default());
let mut tab = tab_with(runner.clone());
tab.run_build_blocking("full");
// The runner was invoked exactly once, for `full`, in the repo root.
let calls = runner.calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].1, "full");
assert_eq!(calls[0].0, PathBuf::from("/tmp/holger"));
// The card ends Built and surfaces the provenance summary + warehouse row.
let full = tab
.thing
.state()
.cards
.iter()
.find(|c| c.name == "full")
.unwrap();
assert!(
matches!(
&full.status,
crate::build::elm::CardStatus::Built {
artifacts: 1,
sealed: true,
..
}
),
"got {:?}",
full.status,
);
let j = tab.state_json();
assert_eq!(j["provenance"]["full"]["artifacts"], 1);
assert_eq!(j["provenance"]["full"]["sealed"], true);
assert!(
j["provenance"]["full"]["warehouse_row"]
.as_str()
.unwrap()
.contains("variant=full")
);
}
/// A descriptor whose `bad` variant enables a feature holger doesn't offer, so
/// the surface reflects it as invalid (with a reason) — the disabled-BUILD row.
const INVALID_DEMO: &str = r#"
[build]
offers = ["sign"]
[[build.variant]]
name = "good"
package = "holger-server-lib"
features = ["sign"]
profile = "release"
[[build.variant]]
name = "bad"
package = "holger-server-lib"
features = ["ghost"]
profile = "release"
"#;
#[test]
fn invalid_variant_never_runs_the_build() {
let runner = Arc::new(MockRunner::default());
let mut tab =
BuildTabState::from_toml(PathBuf::from("/tmp/holger"), INVALID_DEMO, runner.clone());
// The `bad` variant reflected as invalid with a reason (feature not offered).
let bad = tab
.surface
.variants
.iter()
.find(|v| v.name == "bad")
.unwrap();
assert!(!bad.valid);
assert!(
bad.invalid_reason
.as_deref()
.unwrap()
.contains("not offered")
);
// Pressing BUILD on it is refused by the Elm guard → the runner never runs.
tab.run_build_blocking("bad");
assert!(
runner.calls.lock().unwrap().is_empty(),
"invalid variant must not run"
);
let bad_card = tab
.thing
.state()
.cards
.iter()
.find(|c| c.name == "bad")
.unwrap();
assert_eq!(bad_card.status, crate::build::elm::CardStatus::Idle);
}
#[test]
fn failed_build_marks_the_card_failed() {
let runner = Arc::new(MockRunner {
fail: true,
..Default::default()
});
let mut tab = tab_with(runner);
tab.run_build_blocking("mini");
let mini = tab
.thing
.state()
.cards
.iter()
.find(|c| c.name == "mini")
.unwrap();
assert!(matches!(
&mini.status,
crate::build::elm::CardStatus::Failed { .. }
));
}
/// **The facett `Elm` + `Facet` contract** — drive the Build tab through the
/// SAME headless harness the ~six other embedded facett components use
/// (`autonom::facett_probe`): apply a `Msg` through `Elm::update` (the only
/// mutation path), snapshot the `Model` (FC-3), read the RICH `Facet::state_json`,
/// and prove `Facet::ui` paints — no window, no GPU. This is what makes
/// `Tab::Build` "driven/asserted like the others".
#[test]
fn drives_through_the_facett_elm_harness() {
use facett_core::{Facet, harness};
let mut tab = tab_with(Arc::new(MockRunner::default()));
// Drive Select("full") through the Elm contract; the snapshot is the pure
// core Model (BuildThingModel) — `update` is the only way it changed.
let snap = harness::snapshot_json(
&mut tab,
vec![crate::build::elm::BuildThingMsg::Select("full".to_string())],
);
assert!(snap.is_object(), "snapshot is the serializable Model");
assert_eq!(
snap["selected"],
serde_json::json!("full"),
"Select folded into the model"
);
// The Facet's rich state_json (surface + rows + model) — the same observable
// JSON the app reads (custom_state_json bridge, not the plain model default).
let facet_state = Facet::state_json(&tab);
assert_eq!(facet_state["surface"], serde_json::json!("build"));
assert_eq!(facet_state["variant_count"], serde_json::json!(3));
assert_eq!(facet_state["model"]["selected"], serde_json::json!("full"));
// The Facet paints headless (proves the egui `view` renders through the bridge).
let report = harness::headless_render(&mut tab);
assert!(
report.drew(),
"the Build Facet drew tessellated geometry: {report:?}"
);
}
/// Headless render smoke: instantiate the model + draw once, assert no panic and
/// a non-empty state_json. (The FULL interactive drive — real AccessKit clicks on
/// the target rows + the per-row BUILD button, asserted against `state_json` — is
/// the robot-UI test `tests/viz_build_robot.rs`.)
#[test]
fn headless_render_does_not_panic_and_emits_state() {
let mut tab = tab_with(Arc::new(MockRunner::default()));
let ctx = egui::Context::default();
// egui 0.36 debug-asserts on a FullOutput dropped with unapplied texture
// deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
ctx.run_ui(egui::RawInput::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| tab.draw(ui));
}).textures_delta.clear();
let j = tab.state_json();
assert!(!j.as_object().unwrap().is_empty());
assert_eq!(j["variant_count"], 3);
}
}