dwarves-gui/src/pipeline.rs
//! The **Pipeline DAG** tab — the forge's pipelines rendered on the REUSED
//! `facett-pipeline` viewer (the same animated, themed DAG viewer knut's Spark
//! pipelines and every other engine render on).
//!
//! It hosts TWO sources on that ONE viewer — "show another engine's pipeline = a new
//! ADAPTER, not a new viewer":
//!
//! * **runa** (the default) — the CI/CD pipeline dwarves AUTHORS as data. A real
//! committed `*.runa.ron` is loaded through [`dwarves_runa::Pipeline::from_ron_str`] (which
//! also runs runa's validator — duplicate-stage / unknown-`needs` / DAG-cycle) and
//! mapped onto facett's engine-agnostic `PipelineModel` by
//! [`facett_pipeline::RunaPipelineAdapter`]. Stages → nodes, `needs` → DAG edges, the
//! stage's typed [`dwarves_runa::Step`]s → the node sublabel. Its per-stage status is LIVE:
//! read back from the run history dwarves historises for jera-dispatched runs
//! ([`dwarves::store::read_runs`]/[`read_steps`](dwarves::store::read_steps)), else
//! from the jera ledger's own `CI_BUILD` record for that pipeline, else honestly Idle.
//! * **runs** — the selected pipeline's REAL runs, read from the forge
//! ([`crate::pipeline_runs::RunsLeg`]): newest first, each run's stages and steps, a
//! step's log, a `BuildWorkspace` step's units. (This leg was `NOT WIRED` until
//! 2026-10-16; before that it drew a pipeline typed into this file, labelled a live run.)
//!
//! Both sources are `PipelineView`s built by `PipelineView::from_adapter` — no viewer is
//! re-implemented here, and no runa twin of the brokkr view exists.
//!
//! Native-only (alongside the Funnel / Claude / Jobs panes): the wasm web demo keeps the
//! single Forge panel.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use dwarves::brokkr::history::RunStatus;
use facett_core::Facet;
use facett_pipeline::{PipelineView, RunaPipelineAdapter, StageStatus};
// ── the runa leg: a real `*.runa.ron`, lit from the live run state ────────────────
/// Where the per-stage status came from — the honest provenance the gate asserts on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StatusSource {
/// Per-stage rows from the historised run (`brokkr/steps.jsonl`) of the newest run
/// of THIS pipeline — the richest signal.
RunHistory,
/// No per-stage history, but the jera ledger holds a `CI_BUILD` record for this
/// pipeline: every stage takes that run-level status.
JeraLedger,
/// Nothing has run this pipeline here — every stage is honestly Idle.
Static,
}
impl StatusSource {
/// The stable lower-case tag used in `state_json`.
pub fn key(self) -> &'static str {
match self {
StatusSource::RunHistory => "run-history",
StatusSource::JeraLedger => "jera-ledger",
StatusSource::Static => "static",
}
}
}
/// The repo root the `*.runa.ron` files are discovered under. Ladder:
/// `$DWARVES_REPO_ROOT` → the current directory when it holds a `*.runa.ron` → the
/// dwarves checkout this binary was built from (`dwarves-gui/..`). Never panics.
pub fn runa_repo_root() -> PathBuf {
if let Some(r) = std::env::var("DWARVES_REPO_ROOT")
.ok()
.filter(|r| !r.trim().is_empty())
{
return PathBuf::from(r);
}
if let Ok(cwd) = std::env::current_dir() {
if !discover_runa_files(&cwd).is_empty() {
return cwd;
}
}
// `dwarves-gui/` → the dwarves checkout root (where `dwarves.runa.ron` lives).
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."))
}
/// Every committed `*.runa.ron` under `root` — the repo root itself plus the
/// conventional `pipelines/` directory — sorted by path so the list is stable.
pub fn discover_runa_files(root: &Path) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
for dir in [root.to_path_buf(), root.join("pipelines")] {
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for e in rd.filter_map(|e| e.ok()) {
let p = e.path();
if p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".runa.ron"))
.unwrap_or(false)
{
out.push(p);
}
}
}
out.sort();
out
}
/// The dwarves warehouse the run history is read from — the SAME ladder the forge's
/// own `store::warehouse_from` walks (`$DWARVES_WAREHOUSE` → `$HOME/.dwarves/warehouse`),
/// never a re-derived path.
fn warehouse_root() -> PathBuf {
dwarves::store::warehouse_from(
std::env::var("DWARVES_WAREHOUSE").ok().as_deref(),
std::env::var("HOME").ok().as_deref(),
/* system_scope */ false,
)
}
/// Fold one runa stage's historised step rows into a single [`StageStatus`]: any failed
/// step fails the stage, any still-running step keeps it running, all green is Done, and
/// a stage with no rows has not started (Idle). Pure — the tested core of the live light.
pub fn fold_stage_status(rows: &[RunStatus]) -> StageStatus {
if rows.is_empty() {
return StageStatus::Idle;
}
if rows.contains(&RunStatus::Failed) {
return StageStatus::Failed;
}
if rows.contains(&RunStatus::Running) {
return StageStatus::Running;
}
if rows.iter().all(|s| *s == RunStatus::Passed) {
return StageStatus::Done;
}
// Anything left is a skipped/never-started mix — honestly not done.
StageStatus::Idle
}
/// Map a jera ledger status string onto the stage light (the run-level fallback).
/// EXHAUSTIVE on purpose: it used to match on the ledger's status STRING with a
/// `_ => None` catch-all, so a status jera renamed would have read as "no verdict" —
/// silently, forever. Over the shared [`facett_jobview::JobStatus`] the compiler owns
/// that decision instead (E0004 when a variant is added).
fn ledger_status(status: facett_jobview::JobStatus) -> StageStatus {
use facett_jobview::JobStatus;
match status {
JobStatus::Running => StageStatus::Running,
JobStatus::Done => StageStatus::Done,
JobStatus::Failed => StageStatus::Failed,
JobStatus::Queued => StageStatus::Idle,
}
}
/// The live per-stage status for `pipeline_name`, plus where it came from and the run it
/// reflects. Reads the warehouse run history first (per-stage truth), then the jera
/// ledger (run-level truth), then gives up honestly. Never panics; a missing warehouse /
/// ledger is the [`StatusSource::Static`] case.
fn live_stage_status(
pipeline_name: &str,
warehouse: &Path,
jobs: Option<&dyn crate::jera::JeraJobSource>,
) -> (HashMap<String, StageStatus>, StatusSource, String) {
// (1) The historised run: newest run of THIS pipeline wins.
if let Ok(runs) = dwarves::store::read_runs(warehouse) {
if let Some(run) = runs.iter().rev().find(|r| r.pipeline == pipeline_name) {
if let Ok(steps) = dwarves::store::read_steps(warehouse, &run.run_id) {
if !steps.is_empty() {
let mut per_stage: HashMap<String, Vec<RunStatus>> = HashMap::new();
for row in &steps {
per_stage
.entry(row.stage.clone())
.or_default()
.push(row.status);
}
let map = per_stage
.into_iter()
.map(|(stage, rows)| (stage, fold_stage_status(&rows)))
.collect();
return (map, StatusSource::RunHistory, run.run_id.clone());
}
}
}
}
// (2) The jera ledger's own `CI_BUILD` record for this pipeline (the run-level light
// the runa executor opens for every `dwarves run`).
//
// **OVER THE SAME SEAM THE JERA TAB USES.** This leg used to call
// `dwarves_embed::read_ledger_jobs` on a path derived from the UI's own `$HOME`
// (`$DWARVES_WAREHOUSE`, else `~/.dwarves/...`). `9e82007` took that road away from
// the Jera tab and this pane kept it, so the shell shipped two panes reading "the
// ledger" by two different roads — and on any real deployment the guessed one is the
// wrong file: the served workspace's ledger lives under the SERVER's nornir
// warehouse, which the installed forge (user `dwarves`, mode `-rw-r--r--`) opens for
// its process lifetime and the UI's user cannot open for WRITE at all, which is what
// `JobStore::open` needs even to read. Now both panes come through
// [`crate::jera::JeraJobSource`], so the file road is taken in exactly one place and
// only when an operator names it.
if let Some(src) = jobs {
// `Rows` is the only answer that carries a verdict. `Empty` / `Absent` /
// `Failed` all mean "no ledger verdict for this pipeline" HERE — the honest
// rendering of the difference belongs to the Jera tab, which owns it.
// `""` = every kind: this pane matches on the ledger's `target` (the pipeline
// name) across whatever ran, so scoping the read to one class would hide the very
// verdict it is looking for. The Jera tab is where a class window belongs
// (`jera::JeraTab::kind_ui`).
if let crate::jera::JobsRead::Rows(entries) =
src.read(dwarves_embed::DEFAULT_LEDGER_LIMIT, "")
{
if let Some(rec) = entries.iter().find(|r| r.target == pipeline_name) {
// A run-level verdict applies to every stage the run covered — recorded
// under the sentinel key the caller expands over the stage names.
let map = HashMap::from([(UNIFORM_KEY.to_string(), ledger_status(rec.status))]);
return (map, StatusSource::JeraLedger, rec.id.clone());
}
}
}
(HashMap::new(), StatusSource::Static, String::new())
}
/// The sentinel status key meaning "this verdict covers every stage" (the ledger leg).
const UNIFORM_KEY: &str = "*";
/// Seconds between `.runa.ron` + ledger probes for the pipeline pane.
const RUNA_PROBE_S: f64 = 1.0;
/// The **runa** leg of the Pipeline tab: one committed `*.runa.ron` on the reused viewer.
pub struct RunaLeg {
/// The repo root the files were discovered under.
root: PathBuf,
/// Every discovered `*.runa.ron`, in a stable order.
files: Vec<PathBuf>,
/// Which one is shown.
selected: usize,
/// The GENERATION watch over the SELECTED file plus the jera ledger — the two
/// inputs `reload()` actually reads. It re-reads when either moves, never on a
/// press (`crate::generation`).
watch: crate::generation::Watch,
/// The parsed pipeline's name (`""` when the file failed to load).
name: String,
/// A load/validation error, surfaced honestly instead of a blank pane.
error: Option<String>,
/// Stage name → live status (the map handed to `RunaPipelineAdapter::with_status`).
status: HashMap<String, StageStatus>,
/// Where that status came from.
status_source: StatusSource,
/// The run the status reflects (`""` when static).
run_id: String,
/// The stage names, in declaration order (the `state_json` oracle).
stages: Vec<String>,
/// The stages the viewer was last built over (same list = same pipeline: a
/// reload keeps the camera).
stages_before: Vec<String>,
/// The reused viewer.
view: PipelineView,
/// **Where the run-level light comes from** — the SAME seam the 🧬 Jera tab reads
/// (`crate::jera::job_source`), injected by the shell. `None` means this leg simply
/// does not run: a pane with no source says `Static` rather than guessing a
/// `jobs.redb` path out of the UI's own `$HOME`, which is what it used to do.
jobs: Option<std::sync::Arc<dyn crate::jera::JeraJobSource>>,
}
impl RunaLeg {
/// Discover the repo's `*.runa.ron` files and render the first (dwarves' own
/// `dwarves.runa.ron` sorts first). Never panics — a repo with no pipeline file, or a
/// file that fails runa's validator, renders an honest empty DAG plus the reason.
pub fn discover() -> Self {
let root = runa_repo_root();
Self::at(&root, 0)
}
/// Load the `index`-th `*.runa.ron` found under `root` (clamped).
pub fn at(root: &Path, index: usize) -> Self {
let files = discover_runa_files(root);
let mut me = Self {
root: root.to_path_buf(),
files,
selected: 0,
name: String::new(),
error: None,
status: HashMap::new(),
status_source: StatusSource::Static,
run_id: String::new(),
stages: Vec::new(),
stages_before: Vec::new(),
view: PipelineView::new("runa — no pipeline file", Vec::new(), Vec::new()),
watch: crate::generation::Watch::new(RUNA_PROBE_S),
jobs: None,
};
me.select(index);
me
}
/// Attach the shell's job source and re-read, so the run-level light lands on the
/// first frame rather than one probe later.
pub fn with_jobs(mut self, jobs: std::sync::Arc<dyn crate::jera::JeraJobSource>) -> Self {
self.jobs = Some(jobs);
self.reload();
self
}
/// **The generation drive.** Called once a frame: re-reads the selected pipeline
/// and its live status when the FILE or the jera ledger has actually moved. This is
/// what replaced the `⟳ Reload` button — same read, triggered by the data rather
/// than by the operator.
pub fn tick(&mut self, now_s: f64) {
let Some(file) = self.files.get(self.selected).cloned() else {
return;
};
// The pipeline FILE is still a cheap `stat`. The ledger no longer is: over the
// wire there is no probe cheaper than the read itself, which is exactly what
// `JeraJobSource::generation` returns `None` to say. So the source contributes
// its fingerprint when it HAS one (the named file road) and nothing when it does
// not — and the `RUNA_PROBE_S` cadence carries the wire leg, as it carries every
// other remote pane in this shell.
let jobs_gen = self.jobs.as_ref().and_then(|j| j.generation());
if self.watch.moved(now_s, || {
let mut g = crate::generation::paths_generation(std::slice::from_ref(&file));
if let Some(j) = &jobs_gen {
g.push_str(j);
}
g
}) {
self.reload();
}
}
/// Show the `index`-th discovered file (clamped) and re-read its live status.
pub fn select(&mut self, index: usize) {
if self.files.is_empty() {
self.error = Some("no *.runa.ron in this repo".to_string());
return;
}
self.selected = index.min(self.files.len() - 1);
// A different file is a different QUESTION, not an unchanged source — so the
// watch reads as cold rather than as "nothing moved".
self.watch.forget();
self.reload();
}
/// **Show `repo`'s `file`** (`pipelines/release.runa.ron`) — a link from another
/// tab (🧭 Explorer) lands here. A discovered file is selected; a file of a SIBLING
/// checkout (`<root>/../<repo>/<file>`) is added and selected; one this UI cannot
/// read says so by name and the shown pipeline stays.
pub fn open(&mut self, repo: &str, file: &str) {
let here = self.root.file_name().and_then(|n| n.to_str()) == Some(repo);
let want = if here {
self.root.join(file)
} else {
self.root
.parent()
.map(|p| p.join(repo).join(file))
.unwrap_or_default()
};
if let Some(i) = self.files.iter().position(|f| *f == want) {
self.select(i);
return;
}
if want.is_file() {
self.files.push(want);
self.select(self.files.len() - 1);
return;
}
self.error = Some(format!(
"{repo}/{file} is not under this UI's checkouts ({} — set $DWARVES_REPO_ROOT)",
want.display()
));
}
/// Re-read the selected file AND its live run status, rebuilding the reused viewer.
pub fn reload(&mut self) {
let Some(path) = self.files.get(self.selected).cloned() else {
return;
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
self.error = Some(format!("{}: {e}", path.display()));
return;
}
};
// `from_ron_str` parses AND validates (duplicate stage / unknown `needs` / cycle),
// so an unsound pipeline can never reach the viewer pretending to be a DAG.
let pipeline = match dwarves_runa::Pipeline::from_ron_str(&text) {
Ok(p) => p,
Err(e) => {
self.error = Some(format!("{}: {e}", path.display()));
self.name.clear();
self.stages.clear();
self.view = PipelineView::new(
format!("runa — {} (invalid)", path.display()),
Vec::new(),
Vec::new(),
);
return;
}
};
self.error = None;
self.name = pipeline.name.clone();
self.stages = pipeline.stages.iter().map(|s| s.name.clone()).collect();
// The LIVE light: per-stage from the run history, else run-level from the jera
// ledger (expanded over every stage), else all Idle.
let (mut status, source, run_id) =
live_stage_status(&self.name, &warehouse_root(), self.jobs.as_deref());
if let Some(uniform) = status.remove(UNIFORM_KEY) {
for s in &self.stages {
status.insert(s.clone(), uniform);
}
}
self.status = status;
self.status_source = source;
self.run_id = run_id;
// The REUSED viewer, via the REUSED runa adapter. No twin. A reload of the SAME
// pipeline (new run status) keeps the viewer's camera — only a different
// pipeline is framed afresh.
let keep = !self.stages.is_empty() && self.stages_before == self.stages;
let (nav, fitted) = (*self.view.nav(), self.view.is_fitted());
self.view = PipelineView::from_adapter(
&RunaPipelineAdapter::new(&pipeline).with_status(self.status.clone()),
);
if keep && fitted {
self.view.set_camera(nav, fitted);
}
self.stages_before = self.stages.clone();
}
/// The file currently rendered (`""` when none was found).
pub fn file(&self) -> String {
self.files
.get(self.selected)
.and_then(|p| p.file_name().and_then(|n| n.to_str()))
.unwrap_or_default()
.to_string()
}
}
// `jera_ledger_root()` used to live here, and its doc comment said it was "the same
// `~/.dwarves/jobs` location the Jera tab polls". That stopped being true at `9e82007`
// and nothing caught it, which is the whole hazard: a comment describing a road the code
// no longer takes is how the next reader loses an afternoon. The road is
// `crate::jera::job_source` now, and `$DWARVES_JOBS_DIR` is the one env var that names a
// file (`$DWARVES_JERA_LEDGER` selected a path only this pane ever read).
// ── the two-source pane ──────────────────────────────────────────────────────────
/// Which pipeline the tab is showing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PipelineSource {
/// The authored `*.runa.ron` CI/CD pipeline (the default).
Runa,
/// The selected pipeline's real runs, read from the forge.
Runs,
}
impl PipelineSource {
/// The stable lower-case tag used in `state_json` / the JSON seam.
pub fn key(self) -> &'static str {
match self {
PipelineSource::Runa => "runa",
PipelineSource::Runs => "runs",
}
}
}
/// The runs leg's viewer: its runs are drawn as tables (no stage DAG of its own).
pub fn forge_view() -> PipelineView {
PipelineView::new("runs", Vec::new(), Vec::new())
}
/// The **Pipeline** tab: the runa authoring DAG and the brokkr build DAG on ONE reused
/// viewer, switchable in the header (and over the JSON seam).
pub struct PipelinePane {
source: PipelineSource,
runa: RunaLeg,
forge: PipelineView,
runs: crate::pipeline_runs::RunsLeg,
}
impl PipelinePane {
/// The shipped pane: the repo's own `*.runa.ron` first, the live brokkr run behind it.
pub fn new() -> Self {
Self {
source: PipelineSource::Runa,
runa: RunaLeg::discover(),
forge: forge_view(),
runs: crate::pipeline_runs::RunsLeg::new("", ""),
}
}
/// The pane rooted at a specific repo (tests / a host that already resolved a root).
pub fn at(root: &Path) -> Self {
Self {
source: PipelineSource::Runa,
runa: RunaLeg::at(root, 0),
forge: forge_view(),
runs: crate::pipeline_runs::RunsLeg::new("", ""),
}
}
/// The shipped pane WITH the shell's job source — what `dwarves-gui` mounts. Split
/// from [`new`](Self::new) so a test can still build the pane with no wire at all.
pub fn with_jobs(jobs: std::sync::Arc<dyn crate::jera::JeraJobSource>) -> Self {
Self {
source: PipelineSource::Runa,
runa: RunaLeg::discover().with_jobs(jobs),
forge: forge_view(),
runs: crate::pipeline_runs::RunsLeg::new("", ""),
}
}
/// Read the selected pipeline's runs from the forge at `endpoint` (builder style).
pub fn with_forge(mut self, endpoint: impl Into<String>, token: impl Into<String>) -> Self {
self.runs = crate::pipeline_runs::RunsLeg::new(endpoint, token);
self.runs.set_pipeline(&self.runa.name);
self
}
/// Which source is showing.
pub fn source(&self) -> PipelineSource {
self.source
}
/// Switch source (the header toggle + the `{"pipeline_source":…}` seam).
pub fn set_source(&mut self, source: PipelineSource) {
self.source = source;
}
/// The active leg's reused viewer (read-only — camera, where the DAG is on screen).
pub fn viewer(&self) -> &PipelineView {
self.view()
}
/// The active leg's reused viewer.
fn view(&self) -> &PipelineView {
match self.source {
PipelineSource::Runa => &self.runa.view,
PipelineSource::Runs => &self.forge,
}
}
fn view_mut(&mut self) -> &mut PipelineView {
match self.source {
PipelineSource::Runa => &mut self.runa.view,
PipelineSource::Runs => &mut self.forge,
}
}
}
impl Default for PipelinePane {
fn default() -> Self {
Self::new()
}
}
impl Facet for PipelinePane {
fn title(&self) -> &str {
"Pipeline"
}
fn ui(&mut self, ui: &mut egui::Ui) {
// Both halves, in order — the shape this pane keeps when mounted ALONE (the unit
// tests below box it bare). In the shell it is a `SplitPanel`: the stage DAG and
// the source/stage detail become two scene panes with a draggable divider.
self.source_ui(ui);
ui.separator();
self.dag_ui(ui);
}
fn state_json(&self) -> serde_json::Value {
self.folded_state_json()
}
fn update_json(&mut self, msg_json: &str) {
self.apply_msg(msg_json);
}
}
impl PipelinePane {
/// **The SOURCE + stage detail — the inspector half.** Which source is lit (runa
/// `*.runa.ron` vs a live brokkr forge run), which file, how many stages, where the
/// per-stage light came from, and the reload.
///
/// This was a `ui.horizontal` header ABOVE the DAG in one flow: a runa file with a
/// long name, a parse error, or a wide stage summary WRAPS that row, and every
/// wrapped line came straight out of the graph's height with no divider to take it
/// back. It is its own window now. Returns the pane's own observable state.
fn source_ui(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
// GENERATION, not a press. `source_ui` is the one place drawn on every frame in
// BOTH mountings (bare `Facet::ui` and the split shell's `ui_inspector`), so the
// drive lives here rather than being duplicated per mounting (LAW 5).
self.runa.tick(ui.input(|i| i.time));
self.runs.set_pipeline(&self.runa.name);
// The honest one-line header: which source, which file, and where the per-stage
// light came from (PLAIN words + PLAIN numbers — a nordisk law).
let mut want: Option<PipelineSource> = None;
// Nothing in the UI sets this any more; kept so the switch below reads
// unchanged. The generation drive owns re-reading.
let reload = false;
ui.horizontal(|ui| {
if ui
.selectable_label(self.source == PipelineSource::Runa, "runa (*.runa.ron)")
.on_hover_text("the authored CI/CD pipeline, as data")
.clicked()
{
want = Some(PipelineSource::Runa);
}
if ui
.selectable_label(self.source == PipelineSource::Runs, "runs (forge)")
.on_hover_text("this pipeline's real runs, read from the forge: steps, logs, units")
.clicked()
{
want = Some(PipelineSource::Runs);
}
ui.separator();
match self.source {
PipelineSource::Runa => {
if let Some(err) = &self.runa.error {
ui.label(format!("⚠ {err}"));
} else {
ui.label(format!(
"{} · {} stages · status: {}",
self.runa.file(),
self.runa.stages.len(),
self.runa.status_source.key(),
));
}
// NO ⟳ Reload button (owner 2026-08-25: SCAN is not a user
// action). The file and its live run status are re-read when the
// `.runa.ron` or the jera ledger MOVES — see the generation drive
// in `ui`.
}
PipelineSource::Runs => {
ui.label(self.runs.line());
}
}
});
if let Some(s) = want {
self.source = s;
}
if reload {
self.runa.reload();
}
serde_json::json!({
"source": self.source.key(),
"pipeline_file": self.runa.file(),
"stage_count": self.runa.stages.len(),
"status_source": self.runa.status_source.key(),
})
}
/// **The stage DAG — the master half.** The reused `facett-pipeline` viewer, alone.
fn dag_ui(&mut self, ui: &mut egui::Ui) {
match self.source {
PipelineSource::Runa => self.view_mut().ui(ui),
PipelineSource::Runs => self.runs.ui(ui),
}
}
fn folded_state_json(&self) -> serde_json::Value {
// Fold the ACTIVE reused viewer's own state (nodes · edges · statuses) and ADD
// the pane's provenance oracle: which source, which file, which pipeline, where
// the per-stage light came from, and which run it reflects. Everything the pane
// SHOWS is assertable from here (L4).
let mut v = self.view().state_json();
let (idle, running, done, failed) = self.view().counts();
if let Some(obj) = v.as_object_mut() {
obj.insert("kind".into(), serde_json::json!("pipeline"));
obj.insert("source".into(), serde_json::json!(self.source.key()));
// The reused viewer already folds `statuses` — this is the same tuple read
// through the view's own `counts()`, kept so a driver can assert the totals
// without walking the node list.
obj.insert(
"stage_status_counts".into(),
serde_json::json!({
"idle": idle, "running": running, "done": done, "failed": failed,
}),
);
match self.source {
PipelineSource::Runa => {
obj.insert("pipeline_file".into(), serde_json::json!(self.runa.file()));
obj.insert("pipeline_name".into(), serde_json::json!(self.runa.name));
obj.insert("stages".into(), serde_json::json!(self.runa.stages));
obj.insert(
"stage_count".into(),
serde_json::json!(self.runa.stages.len()),
);
obj.insert(
"status_source".into(),
serde_json::json!(self.runa.status_source.key()),
);
obj.insert("run_id".into(), serde_json::json!(self.runa.run_id));
obj.insert(
"available_pipelines".into(),
serde_json::json!(self
.runa
.files
.iter()
.filter_map(|p| p.file_name().and_then(|n| n.to_str()))
.collect::<Vec<_>>()),
);
obj.insert(
"error".into(),
serde_json::json!(self.runa.error.clone().unwrap_or_default()),
);
}
PipelineSource::Runs => {
let runs = self.runs.state_json();
obj.insert("pipeline_name".into(), serde_json::json!(self.runa.name));
obj.insert("status_source".into(), serde_json::json!("forge"));
obj.insert("run_id".into(), runs["run_id"].clone());
obj.insert("note".into(), runs["line"].clone());
obj.insert("runs".into(), runs);
}
}
}
v
}
fn apply_msg(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
return;
};
if let Some(s) = v.get("pipeline_source").and_then(|s| s.as_str()) {
match s {
"runa" => self.source = PipelineSource::Runa,
"runs" | "forge" => {
self.source = PipelineSource::Runs;
self.runs.set_pipeline(&self.runa.name);
self.runs.ask();
}
_ => {}
}
return;
}
// the runs leg: {"runs_wait":true} · {"runs_select":"<run>"} · {"runs_step":n} · {"runs_full_log":n}
if v.get("runs_wait").and_then(|r| r.as_bool()) == Some(true) {
self.runs.set_pipeline(&self.runa.name);
self.runs.wait();
return;
}
if let Some(id) = v.get("runs_select").and_then(|r| r.as_str()) {
self.runs.select_run(id);
self.runs.wait();
return;
}
if let Some(n) = v.get("runs_step").and_then(|r| r.as_u64()) {
self.runs.select_step(n as u32, false);
self.runs.wait();
return;
}
if let Some(n) = v.get("runs_full_log").and_then(|r| r.as_u64()) {
self.runs.select_step(n as u32, true);
self.runs.wait();
return;
}
if let Some(i) = v.get("select_pipeline").and_then(|i| i.as_u64()) {
self.runa.select(i as usize);
return;
}
// `{"open_pipeline":{"repo":…,"file":…}}` — a link (🧭 Explorer) carries the file
if let Some(o) = v.get("open_pipeline") {
self.source = PipelineSource::Runa;
self.runa.open(
o["repo"].as_str().unwrap_or(""),
o["file"].as_str().unwrap_or(""),
);
return;
}
if v.get("reload").and_then(|r| r.as_bool()) == Some(true) {
self.runa.reload();
}
}
}
/// **The Pipeline body DECOMPOSES**: the stage DAG is the master, the source + stage
/// detail the inspector — two scene panes with a draggable divider, each with its own
/// chrome bar.
impl crate::tabbed::SplitPanel for PipelinePane {
fn ui_main(&mut self, ui: &mut egui::Ui) {
self.dag_ui(ui);
}
fn ui_inspector(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
self.source_ui(ui)
}
}
/// The tab's panel — the shipped construction path the shell mounts.
pub fn pipeline_view() -> PipelinePane {
PipelinePane::new()
}
/// The shipped mount: the pane over the shell's ONE job source.
pub fn pipeline_view_with_jobs(
jobs: std::sync::Arc<dyn crate::jera::JeraJobSource>,
) -> PipelinePane {
PipelinePane::with_jobs(jobs)
}
#[cfg(test)]
mod tests {
use super::*;
/// **A link opens a pipeline by repo + file**: one of the root's own files, a
/// sibling checkout's file (added), and a file that is not there named by its path.
#[test]
fn a_link_opens_its_pipeline_file() {
let dir = tempfile::tempdir().unwrap();
let own = dir.path().join("dwarves");
std::fs::create_dir_all(own.join("pipelines")).unwrap();
std::fs::write(
own.join("dwarves.runa.ron"),
r#"(name: "dwarves-forge", stages: [(name: "a", steps: [CargoTest])])"#,
)
.unwrap();
std::fs::write(
own.join("pipelines/release.runa.ron"),
r#"(name: "release", stages: [(name: "r", steps: [CargoTest])])"#,
)
.unwrap();
std::fs::create_dir_all(dir.path().join("tunnr")).unwrap();
std::fs::write(
dir.path().join("tunnr/tunnr.runa.ron"),
r#"(name: "tunnr-ci", stages: [(name: "t", steps: [CargoTest])])"#,
)
.unwrap();
let mut leg = RunaLeg::at(&own, 0);
assert_eq!(leg.name, "dwarves-forge");
leg.open("dwarves", "pipelines/release.runa.ron");
assert_eq!(
(leg.file().as_str(), leg.name.as_str()),
("release.runa.ron", "release")
);
leg.open("tunnr", "tunnr.runa.ron");
assert_eq!(
(leg.file().as_str(), leg.name.as_str(), leg.error.clone()),
("tunnr.runa.ron", "tunnr-ci", None)
);
leg.open("knut", "knut.runa.ron");
assert!(
leg.error
.as_deref()
.unwrap()
.contains("knut/knut.runa.ron is not under this UI's checkouts"),
"{:?}",
leg.error
);
assert_eq!(leg.name, "tunnr-ci", "the shown pipeline stays");
}
/// **The Pipeline tab's DAG opens centred** — rendered: dwarves' OWN committed
/// `dwarves.runa.ron` beside a 320 px rail; the DAG's box is centred (±2 px) in the
/// part of its canvas the viewer sees and wholly inside it; a reload of the same
/// pipeline (new run status) keeps the viewer's camera.
#[test]
fn the_pipeline_dag_opens_centred_and_a_reload_keeps_the_camera() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().to_path_buf();
let pane = std::rc::Rc::new(std::cell::RefCell::new(PipelinePane::at(&root)));
let p2 = pane.clone();
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1400.0, 900.0))
.build_ui(move |ui| {
egui::Panel::left("rail").exact_size(320.0).resizable(false).show_inside(ui, |ui| {
ui.label("rail");
});
egui::CentralPanel::default().show_inside(ui, |ui| Facet::ui(&mut *p2.borrow_mut(), ui));
});
for _ in 0..4 {
h.step();
}
let (seen, b) = {
let p = pane.borrow();
(p.viewer().seen_rect().expect("painted"), p.viewer().screen_box().expect("stages"))
};
assert!((b.center() - seen.center()).length() <= 2.0, "centred: box {b:?} in {seen:?}");
assert!(seen.expand(0.5).contains_rect(b), "fully visible: box {b:?} in {seen:?}");
// the viewer pans; a reload of the same pipeline keeps where they are
pane.borrow_mut().runa.view.zoom_to_cursor(1.5, seen.center());
let nav = *pane.borrow().viewer().nav();
pane.borrow_mut().runa.reload();
h.step();
assert_eq!(*pane.borrow().viewer().nav(), nav, "a reload keeps the camera");
}
fn cell(name: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status("dwarves-gui", name, ok, detail);
let _ = (name, ok, detail);
}
/// RED-when-broken: dwarves' OWN committed `dwarves.runa.ron` loads through runa's
/// parser+validator and renders on the REUSED `facett-pipeline` viewer via
/// `RunaPipelineAdapter` — one node per stage, one edge per `needs`, and the typed
/// steps as the node sublabel. This is the dogfood proof: the forge's UI shows the
/// forge's own authored pipeline, not a hand-typed fixture.
#[test]
fn dwarves_own_runa_pipeline_renders_on_the_reused_viewer() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let pane = PipelinePane::at(&root);
let s = Facet::state_json(&pane);
assert_eq!(
s["source"], "runa",
"the Pipeline tab opens on the runa view"
);
assert_eq!(
s["error"], "",
"dwarves.runa.ron parses AND passes runa's validator"
);
assert_eq!(
s["pipeline_file"], "dwarves.runa.ron",
"the repo's own pipeline sorts first"
);
assert_eq!(s["pipeline_name"], "dwarves-forge");
// lint → build → test → ui → doctor.
let stages: Vec<&str> = s["stages"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(
stages,
vec!["lint", "build", "test", "ui", "doctor"],
"one node per authored stage"
);
assert_eq!(
s["node_count"], 5,
"the reused viewer holds a node per stage"
);
// needs edges: build→lint, test→build, ui→build, doctor→test, doctor→ui = 5.
assert_eq!(s["edge_count"], 5, "one edge per `needs` entry");
// The typed runa steps become the node sublabel (the `kind` the adapter builds).
let lint = s["nodes"]
.as_array()
.unwrap()
.iter()
.find(|n| n["name"] == "lint")
.unwrap();
// `Lint(tool: Fmt)` + `Lint(tool: Clippy)` since cb05921 (the fmt gate was an
// `Xtask(cmd: "fmt-check")` verb the xtask never had).
assert_eq!(
lint["detail"], "Lint, Lint",
"the stage's typed steps are the sublabel"
);
let ok =
s["source"] == "runa" && s["pipeline_name"] == "dwarves-forge" && s["node_count"] == 5;
cell(
"pipeline_runa_view_renders_the_committed_ron",
ok,
"the Pipeline tab renders dwarves' OWN committed dwarves.runa.ron (5 stages, 5 `needs` edges, typed steps as sublabels) through runa's validator + the REUSED facett-pipeline viewer via RunaPipelineAdapter — no viewer twin, no fixture",
);
}
/// RED-when-broken: with no run of this pipeline recorded, every stage is HONESTLY
/// Idle and the pane SAYS so (`status_source: "static"`). A pane that greened stages
/// it never ran would red here.
#[test]
fn an_unrun_pipeline_is_honestly_idle() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
// Point the status readers at empty roots so the test never depends on this box's
// warehouse/ledger contents.
let _guard = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let empty =
std::env::temp_dir().join(format!("dwarves-gui-no-warehouse-{}", std::process::id()));
let prev_wh = std::env::var("DWARVES_WAREHOUSE").ok();
unsafe {
std::env::set_var("DWARVES_WAREHOUSE", &empty);
}
// No `DWARVES_JERA_LEDGER` any more: the ledger leg is a SEAM, not a path, and
// `PipelinePane::at` injects none — so this pane genuinely has one source of
// light (the warehouse history) and it is empty.
let pane = PipelinePane::at(&root);
let s = Facet::state_json(&pane);
let idle_ok = s["status_source"] == "static"
&& s["run_id"] == ""
&& s["stage_status_counts"]["idle"] == 5
&& s["stage_status_counts"]["done"] == 0
&& s["stage_status_counts"]["running"] == 0
&& s["stage_status_counts"]["failed"] == 0;
assert_eq!(
s["status_source"], "static",
"no run recorded ⇒ the honest static light"
);
assert_eq!(
s["stage_status_counts"]["idle"], 5,
"every stage is Idle, none invented green"
);
unsafe {
match prev_wh {
Some(v) => std::env::set_var("DWARVES_WAREHOUSE", v),
None => std::env::remove_var("DWARVES_WAREHOUSE"),
}
}
cell(
"pipeline_runa_unrun_is_honestly_idle",
idle_ok,
"a *.runa.ron with no recorded run lights every stage Idle and reports status_source:\"static\" — the pane never invents a green stage",
);
}
/// ★ **THE RUN-LEVEL LIGHT COMES OFF THE SEAM, NOT OFF A PATH.**
///
/// `9e82007` moved the 🧬 Jera tab from a `jobs.redb` the UI derived out of its own
/// `$HOME` onto `Viz.Jobs`, and this pane kept the old road — so the shell shipped
/// two panes reading "the ledger" by two different roads, and on a real deployment
/// the guessed one is the wrong file (the served workspace's ledger lives under the
/// SERVER's nornir warehouse, owned by user `dwarves` and `-rw-r--r--`, and
/// `JobStore::open` needs WRITE access even to read).
///
/// The proof is a source that could not possibly be a file: an in-memory fake. It
/// lights the DAG, so the pane is reading the SEAM. The warehouse is pointed at an
/// empty dir so leg (1) cannot be what lit it, and the run id the pane reports has to
/// be the fake's job id — a value no local read could synthesise.
#[test]
fn the_run_level_light_comes_from_the_injected_source_not_a_guessed_path() {
use crate::jera::{JeraJobSource, JobsRead};
use facett_jobview::{JobEntry, JobStatus};
struct FakeJobs;
impl JeraJobSource for FakeJobs {
fn read(&self, _limit: usize, _kind: &str) -> JobsRead {
// The crate's OWN constructor, not a struct literal — `JobEntry` grows
// fields and its `new` is what absorbs that.
let mut e = JobEntry::new("wire-job-42", "ci_build", JobStatus::Failed);
// The ledger's `target` is the PIPELINE name — what the pane matches on.
e.target = "dwarves-forge".to_string();
e.started_micros = 1;
e.elapsed_ms = Some(1);
JobsRead::Rows(vec![e])
}
fn describe(&self) -> String {
"in-memory fake".to_string()
}
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let _guard = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let empty =
std::env::temp_dir().join(format!("dwarves-gui-seam-wh-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&empty);
let prev_wh = std::env::var("DWARVES_WAREHOUSE").ok();
unsafe { std::env::set_var("DWARVES_WAREHOUSE", &empty) };
let pane = PipelinePane {
source: PipelineSource::Runa,
runa: RunaLeg::at(&root, 0).with_jobs(std::sync::Arc::new(FakeJobs)),
forge: forge_view(),
runs: crate::pipeline_runs::RunsLeg::new("", ""),
};
let s = Facet::state_json(&pane);
unsafe {
match prev_wh {
Some(v) => std::env::set_var("DWARVES_WAREHOUSE", v),
None => std::env::remove_var("DWARVES_WAREHOUSE"),
}
}
// APPLIED OUTPUT: the verdict travelled, it is attributed to the LEDGER leg, and
// it carries the id only the injected source knows.
assert_eq!(
s["status_source"], "jera-ledger",
"the run-level light must be attributed to the ledger leg, got {s}"
);
assert_eq!(
s["run_id"], "wire-job-42",
"the pane must report the id the SOURCE gave it — a value no path read could \
have produced"
);
assert_eq!(
s["stage_status_counts"]["failed"], 5,
"a run-level FAILED verdict covers every stage of the run, got {s}"
);
let ok = s["status_source"] == "jera-ledger" && s["run_id"] == "wire-job-42";
cell(
"pipeline_ledger_leg_reads_the_shared_seam",
ok,
"the Pipeline tab's run-level light comes through crate::jera::JeraJobSource — the SAME seam the Jera tab reads over Viz.Jobs — instead of dwarves_embed::read_ledger_jobs on a $HOME-derived jobs.redb path; proven with an in-memory source that cannot be a file",
);
}
/// RED-when-broken (the LIVE light): historised run rows for the pipeline light the
/// DAG per stage — a failed stage reads Failed, a running one Running, a green one
/// Done, and an unrun one stays Idle. Driven through the SAME warehouse reader the
/// pane uses, with a seeded history, so the assertion is on the RESULT (the per-stage
/// statuses), never a literal `true`.
#[test]
fn historised_run_rows_light_the_runa_dag_per_stage() {
use dwarves::brokkr::history::{BuildRow, PipelineRunRow};
let _guard = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let wh = std::env::temp_dir().join(format!("dwarves-gui-runa-wh-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&wh);
let run_id = "runa-run-7";
let row = |stage: &str, step: &str, status| BuildRow {
run_id: run_id.to_string(),
step: step.to_string(),
stage: stage.to_string(),
backend: "process".into(),
matrix: String::new(),
status,
summary: String::new(),
artifact_id: None,
job_ref: None,
duration_ms: 0,
finished_at: String::new(),
};
let run = PipelineRunRow {
run_id: run_id.to_string(),
pipeline: "dwarves-forge".to_string(),
project: "dwarves".to_string(),
trigger: "manual".to_string(),
sha: None,
status: RunStatus::Failed,
steps: 4,
passed: 2,
duration_ms: 0,
job_ref: None,
finished_at: String::new(),
};
let steps = vec![
row("lint", "Xtask", RunStatus::Passed),
row("lint", "Lint", RunStatus::Passed),
row("build", "Build", RunStatus::Running),
row("test", "Nextest", RunStatus::Failed),
// `ui` + `doctor` never ran — they must stay Idle.
];
dwarves::store::append_history(&wh, &run, &steps).expect("seed the run history");
let prev_wh = std::env::var("DWARVES_WAREHOUSE").ok();
let prev_led = std::env::var("DWARVES_JERA_LEDGER").ok();
unsafe {
std::env::set_var("DWARVES_WAREHOUSE", &wh);
std::env::set_var("DWARVES_JERA_LEDGER", &wh);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let pane = PipelinePane::at(&root);
let s = Facet::state_json(&pane);
unsafe {
match prev_wh {
Some(v) => std::env::set_var("DWARVES_WAREHOUSE", v),
None => std::env::remove_var("DWARVES_WAREHOUSE"),
}
match prev_led {
Some(v) => std::env::set_var("DWARVES_JERA_LEDGER", v),
None => std::env::remove_var("DWARVES_JERA_LEDGER"),
}
}
let status_of = |name: &str| -> String {
s["nodes"]
.as_array()
.unwrap()
.iter()
.find(|n| n["name"] == name)
.map(|n| n["status"].as_str().unwrap_or_default().to_string())
.unwrap_or_default()
};
let lit_ok = s["status_source"] == "run-history"
&& s["run_id"] == run_id
&& status_of("lint") == "done"
&& status_of("build") == "running"
&& status_of("test") == "failed"
&& status_of("ui") == "idle"
&& status_of("doctor") == "idle";
assert_eq!(
s["status_source"], "run-history",
"the live light comes from the historised run"
);
assert_eq!(s["run_id"], run_id, "and names the run it reflects");
assert_eq!(
status_of("lint"),
"done",
"two green steps ⇒ the stage is Done"
);
assert_eq!(
status_of("build"),
"running",
"a running step keeps the stage Running"
);
assert_eq!(status_of("test"), "failed", "a failed step fails the stage");
assert_eq!(
status_of("ui"),
"idle",
"a stage with no rows never started"
);
assert_eq!(
s["stage_status_counts"],
serde_json::json!({"idle": 2, "running": 1, "done": 1, "failed": 1})
);
let _ = std::fs::remove_dir_all(&wh);
cell(
"pipeline_runa_live_status_from_the_run_ledger",
lit_ok,
"a historised jera-dispatched run lights the runa DAG PER STAGE on the reused viewer (done/running/failed/idle folded from the step rows), and the pane names the run it reflects — asserted on the resulting per-node statuses, not a literal true",
);
}
/// The stage fold is the tested core of the live light: pure, total, and never
/// optimistic (a single failure fails the stage, a mixed/skipped set is not Done).
#[test]
fn stage_fold_is_never_optimistic() {
assert_eq!(fold_stage_status(&[]), StageStatus::Idle);
assert_eq!(
fold_stage_status(&[RunStatus::Passed, RunStatus::Passed]),
StageStatus::Done
);
assert_eq!(
fold_stage_status(&[RunStatus::Passed, RunStatus::Failed]),
StageStatus::Failed
);
assert_eq!(
fold_stage_status(&[RunStatus::Running, RunStatus::Failed]),
StageStatus::Failed,
"a failure outranks an in-flight step"
);
assert_eq!(
fold_stage_status(&[RunStatus::Passed, RunStatus::Running]),
StageStatus::Running
);
assert_eq!(
fold_stage_status(&[RunStatus::Passed, RunStatus::Skipped]),
StageStatus::Idle,
"a skipped step means the stage did NOT complete"
);
}
/// The RUNS leg draws no stage of its own and invents no run: a pane with no forge
/// says so (`no_forge`), with the selected pipeline named; switching back restores
/// the runa DAG.
#[test]
fn the_runs_leg_without_a_forge_says_so_and_draws_no_run() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let mut pane = PipelinePane::at(&root);
Facet::update_json(&mut pane, r#"{"pipeline_source":"runs"}"#);
let s = Facet::state_json(&pane);
let runs_ok = s["source"] == "runs"
&& s["node_count"] == 0
&& s["status_source"] == "forge"
&& s["runs"]["state"] == "no_forge"
&& s["run_id"].is_null()
&& s["pipeline_name"] == "dwarves-forge"
&& s["stage_status_counts"]["done"] == 0;
assert!(runs_ok, "no forge: said, no run drawn: {s}");
assert!(s["note"].as_str().unwrap_or("").contains("no forge"), "{s}");
// the old name of the leg still reaches it
Facet::update_json(&mut pane, r#"{"pipeline_source":"forge"}"#);
assert_eq!(Facet::state_json(&pane)["source"], "runs");
Facet::update_json(&mut pane, r#"{"pipeline_source":"runa"}"#);
let back = Facet::state_json(&pane);
let back_ok = back["source"] == "runa" && back["pipeline_name"] == "dwarves-forge";
assert!(back_ok, "{back}");
cell(
"pipeline_source_toggle_keeps_both_dags",
runs_ok && back_ok,
"the Pipeline tab's {\"pipeline_source\":…} seam switches runa ⇄ runs: runa renders its authored DAG; the runs leg reads the forge and, with none, says no_forge and draws no run",
);
}
/// The repo's other committed pipelines are reachable from the pane (the selector),
/// so `pipelines/release.runa.ron` + `holger.runa.ron` are not invisible.
#[test]
fn every_committed_runa_pipeline_is_selectable() {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let mut pane = PipelinePane::at(&root);
let s = Facet::state_json(&pane);
let avail: Vec<String> = s["available_pipelines"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(avail.contains(&"dwarves.runa.ron".to_string()));
assert!(avail.contains(&"holger.runa.ron".to_string()));
assert!(
avail.contains(&"release.runa.ron".to_string()),
"pipelines/ is discovered too: {avail:?}"
);
// Select the holger products pipeline over the seam and assert THAT graph renders.
let idx = avail.iter().position(|f| f == "holger.runa.ron").unwrap();
Facet::update_json(
&mut pane,
&serde_json::json!({ "select_pipeline": idx }).to_string(),
);
let h = Facet::state_json(&pane);
assert_eq!(h["pipeline_name"], "holger-products");
assert_eq!(h["pipeline_file"], "holger.runa.ron");
assert_eq!(h["stage_count"], 4, "build · test · package · release");
}
}