dwarves-gui/src/release.rs
//! **The Release tab** — the release of the **SELECTED workspace**, in two halves:
//!
//! 1. **The three-graph CONVERGENCE** (task #6 + #28) — the SAME constellation DAG in
//! THREE states (LOCAL manifest · SPARRING Gate-B rehearsal · CRATES_IO published)
//! that a release drives to CONVERGE, on the REUSED `facett-releaseview` 3-plane
//! viewer (via `dwarves_embed::release_panel_at` — no viewer twin, L5).
//! 2. **The SHOOTING-RAMP band** — the state the convergence graph alone cannot show:
//! which repos are sitting on a ramp branch right now, what the saved bump plan is,
//! what **Gate A** (static) and **Gate B** (the `/sparring` rehearsal) said, and — the
//! loud one — how many crates a fail-stopped `--execute` already got LIVE and
//! immutable on crates.io. All of it read through the shared, always-compiled,
//! strictly READ-ONLY [`dwarves::release_state`] leaf.
//!
//! ## This pane cannot release anything
//! `release_state` is a `.git/HEAD` parse plus three file reads — no process is spawned,
//! no repo is written, and the lean `dwarves` dep this crate carries links none of the
//! `release` feature's publish machinery. There is deliberately **no button here that
//! can publish**: a UI that can accidentally shoot at crates.io is a defect.
//!
//! ## Where the convergence comes from (the ladder)
//! A `workspace-release` run writes its convergence output to the shared
//! `dwarves-workspace-release-convergence.json`; a per-workspace run writes the
//! `…-convergence-<ws>.json` twin. The pane reads **the workspace's own file first, the
//! shared run file second, and only then the nordisk fixture** — so a real run is never
//! invisible just because it wrote the shared path. `source` in `state_json` tells the
//! three apart (`workspace-run` · `release-run` · `fixture`), which is what the gate
//! asserts on.
use std::path::PathBuf;
use dwarves::release_state::ReleaseRunState;
use facett_core::{Facet, Panel};
/// Which section of the ramp band is expanded (the pane's one real interaction).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Band {
/// The gate verdicts (Gate A static · Gate B `/sparring` rehearsal).
Gates,
/// Which repos are on a ramp branch, and which kind.
Ramp,
/// The saved bump plan: deps-first order + crate→ship-version graph.
Plan,
}
impl Band {
/// The stable lower-case tag used in `state_json` / the JSON seam.
pub fn key(self) -> &'static str {
match self {
Band::Gates => "gates",
Band::Ramp => "ramp",
Band::Plan => "plan",
}
}
fn parse(s: &str) -> Option<Band> {
Some(match s {
"gates" => Band::Gates,
"ramp" => Band::Ramp,
"plan" => Band::Plan,
_ => return None,
})
}
}
/// Where the rendered convergence came from.
///
/// ## The nordisk fixture is gone (audit 2026-09-10)
/// There used to be a third, drawing kind here — `Fixture` — and with no release run
/// anywhere it is what the tab rendered: `dwarves_embed::release_fixture()`, a
/// four-crate chain (skade green · dwarves and holger RED "needs-release" · nornir
/// AMBER "bump-drift") whose version numbers were invented. A pane whose whole job is
/// to answer *which crates need publishing* was answering it before any release had
/// run. `release_fixture` is `#[cfg(test)]` now, and this variant no longer draws
/// anything: it is the NAMED absence, and it says which file it wanted.
// Each variant names a kind of run (or its absence); the shared `Run` is the meaning.
#[allow(clippy::enum_variant_names)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ConvSource {
/// The SELECTED workspace's own convergence file (a run for this workspace).
WorkspaceRun,
/// The shared file a `workspace-release` run writes (a real run, unnamed workspace).
ReleaseRun,
/// **No run anywhere.** Not a fallback graph — an empty viewer under a line naming
/// the convergence file that was not written. Zero nodes, and no verdict.
NoRun,
}
impl ConvSource {
fn key(self) -> &'static str {
match self {
ConvSource::WorkspaceRun => "workspace-run",
ConvSource::ReleaseRun => "release-run",
ConvSource::NoRun => "empty",
}
}
}
/// **Lower a [`ReleaseConvergence`] onto the shared DAG shape** the graph engine paints.
///
/// The edge direction is the model's own (`from` depends on `to`), so the DAG reads
/// left-to-right from dependents to dependencies — the order a release publishes in,
/// reversed. Node colour is the crate's [`ConvergeStatus`]; the badge is the local
/// version, and a crate that is not converged carries an emphasis ring, because "which
/// crates are still off" is the whole question this graph is for.
///
/// [`ReleaseConvergence`]: facett_releaseview::model::ReleaseConvergence
/// [`ConvergeStatus`]: facett_releaseview::model::ConvergeStatus
pub fn convergence_to_dag(
conv: &facett_releaseview::model::ReleaseConvergence,
) -> crate::dag_graph::DagSpec {
use crate::dag_graph::{DagNode, DagSpec};
use facett_graphview::Color;
use facett_releaseview::model::ConvergeStatus;
let fill = |s: ConvergeStatus| match s {
ConvergeStatus::Converged => Color::rgb(0x3c, 0x7a, 0x4e),
ConvergeStatus::NeedsRelease => Color::rgb(0x8f, 0x33, 0x33),
ConvergeStatus::LockDesync => Color::rgb(0x8f, 0x4a, 0x2a),
ConvergeStatus::BumpDrift => Color::rgb(0x8a, 0x6d, 0x1f),
ConvergeStatus::Publishing => Color::rgb(0x2f, 0x5c, 0x8a),
};
let nodes: Vec<DagNode> = conv
.nodes
.iter()
.map(|n| DagNode {
id: n.krate.clone(),
// The three versions ARE the state — putting only the name in the box would
// make a converged and a diverged node look the same.
label: format!(
"{} L{} · S{} · C{}",
n.krate, n.local_ver, n.sparring_ver, n.cratesio_ver
),
fill: fill(n.status),
ring: (!n.status.is_converged()).then(|| Color::rgb(0xe0, 0xa8, 0x00)),
badge: (!n.status.is_converged()).then(|| n.status.label().to_string()),
})
.collect();
let edges: Vec<(String, String)> = conv
.edges
.iter()
.filter_map(|e| {
let from = conv.nodes.get(e.from)?.krate.clone();
let to = conv.nodes.get(e.to)?.krate.clone();
Some((from, to))
})
.collect();
DagSpec {
nodes,
edges,
consumes: Vec::new(),
}
}
/// The constellation root the ramp scan walks — every sibling checkout of this repo.
/// Ladder: `$DWARVES_CONSTELLATION_ROOT` → the parent of the repo root the Pipeline tab
/// resolved (`…/git/dwarves` → `…/git`, where the polyrepo constellation lives).
pub fn constellation_root() -> PathBuf {
if let Some(r) = std::env::var("DWARVES_CONSTELLATION_ROOT")
.ok()
.filter(|r| !r.trim().is_empty())
{
return PathBuf::from(r);
}
let repo = crate::pipeline::runa_repo_root();
repo.parent().map(|p| p.to_path_buf()).unwrap_or(repo)
}
/// The Release tab: the reused release-convergence pane bound to the selected workspace,
/// under a shooting-ramp / gate band read live off disk. A [`Facet`] the tabbed host
/// mounts like any other pane.
pub struct ReleaseTab {
/// The reused `facett-releaseview` pane for the current workspace (empty when no
/// release run has written a convergence file — never a fixture).
inner: Box<dyn Panel>,
/// The active workspace the pane is bound to (`""` = none selected).
workspace: String,
/// Where `inner`'s convergence came from.
conv_source: ConvSource,
/// The convergence file this binding resolved to — the one the pane loaded, or,
/// under [`ConvSource::NoRun`], the one it wanted and did not find. Named on
/// screen and in `state_json` so an absence points at something an operator can
/// go look at, instead of being drawn over.
conv_path: PathBuf,
/// The read-only release-run state (ramps · plan · gates · what is already live).
run: ReleaseRunState,
/// The constellation root the ramp scan reads.
root: PathBuf,
/// Which band section is expanded.
band: Band,
/// The SAME convergence `inner` is showing, as a graph on the shared
/// `facett-graphview` engine. Two renders of ONE model — the graph is built from
/// [`dwarves_embed::load_convergence_at`] on the same ladder the pane loads, so the
/// two halves cannot disagree about which release they are drawing.
graph: crate::dag_graph::DagPane,
/// Which render of the convergence is on screen.
conv_view: ConvView,
/// The GENERATION watch over the constellation's checkouts — see
/// `crate::generation::git_heads_generation`. The ramp scan is re-run when a HEAD
/// moves, never when a button is pressed.
watch: crate::generation::Watch,
}
/// Seconds between checkout-HEAD probes for the ramp scan.
const RAMP_PROBE_S: f64 = 2.0;
/// Which render of the release convergence the tab is showing.
///
/// Two renders, one model. They answer different questions and neither replaces the
/// other: the 3-plane view is about *how far from converged each crate is*; the graph
/// is about *what depends on what*, which the plane render cannot show because it
/// spends its geometry on the three version states instead of on the DAG.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConvView {
/// The reused `facett-releaseview` pane (3-plane / tri-badge).
Planes,
/// The dependency DAG on the shared `facett-graphview` engine.
Graph,
}
impl ConvView {
fn key(self) -> &'static str {
match self {
ConvView::Planes => "planes",
ConvView::Graph => "graph",
}
}
fn parse(s: &str) -> Option<Self> {
Some(match s {
"planes" | "releaseview" => ConvView::Planes,
"graph" | "graphview" => ConvView::Graph,
_ => return None,
})
}
}
impl ReleaseTab {
/// Bind to `workspace` (may be `""` = none), load its convergence pane and scan the
/// constellation's release state now.
pub fn new(workspace: impl Into<String>) -> Self {
Self::rooted(workspace, constellation_root())
}
/// Bind with an explicit constellation root (tests / a host that already resolved it).
pub fn rooted(workspace: impl Into<String>, root: impl Into<PathBuf>) -> Self {
let root = root.into();
let mut me = Self {
inner: dwarves_embed::release_panel(None),
workspace: String::new(),
conv_source: ConvSource::NoRun,
conv_path: dwarves_embed::default_convergence_path(),
run: ReleaseRunState::default(),
root,
band: Band::Gates,
graph: crate::dag_graph::DagPane::new("Release DAG"),
conv_view: ConvView::Planes,
watch: crate::generation::Watch::new(RAMP_PROBE_S),
};
me.bind(&workspace.into());
me
}
/// The tab with nothing selected — the honest EMPTY tab (tests / the shell before
/// a warehouse is picked). It was called `fixture()` and it drew one; it draws the
/// named absence over the convergence path now.
#[allow(dead_code)]
pub fn unbound() -> Self {
Self::new("")
}
/// **Re-bind to `workspace`** — resolve its convergence file, reload the reused pane
/// from it, and re-read the release-run state. Never panics.
pub fn bind(&mut self, workspace: &str) {
self.workspace = workspace.to_string();
let named = workspace.trim();
// The ladder: this workspace's own run file → the shared run file → NOTHING.
// The third rung used to be the nordisk fixture; it is now the named absence,
// and the path below is what gets named.
let ws_path: PathBuf = dwarves_embed::workspace_convergence_path(workspace);
let shared: PathBuf = dwarves_embed::default_convergence_path();
let (path, source) = if !named.is_empty() && ws_path.is_file() {
(ws_path, ConvSource::WorkspaceRun)
} else if !named.is_empty() && shared.is_file() {
// A real `workspace-release` run wrote the shared path — reporting "no run"
// instead would hide an actual release. (An unnamed selection keeps the old
// behaviour: the shared path IS its path, resolved above.)
(shared, ConvSource::ReleaseRun)
} else {
(ws_path, ConvSource::NoRun)
};
self.conv_source = source;
self.conv_path = path.clone();
// `release_panel_at` loads the file, or — when it is absent — hands back the
// pane carrying `facett_core::empty::EmptyState` over that path (never a
// fixture). The ONE reused construction path; no viewer re-implemented here.
self.inner = dwarves_embed::release_panel_at(&path);
// The graph is the SAME model on the SAME ladder — one load path, two renders.
let conv = dwarves_embed::load_convergence_at(&path);
self.graph.set_spec(&convergence_to_dag(&conv));
self.rescan();
}
/// Re-read the release-run state: the gate verdicts a run reported, layered over a
/// LIVE scan of the constellation's ramp branches / changelist / checkpoint.
pub fn rescan(&mut self) {
self.run = ReleaseRunState::load_or_scan(&self.workspace, &self.root);
}
/// The workspace the release pane is bound to (`""` = none → fixture).
pub fn workspace(&self) -> &str {
&self.workspace
}
/// The read-only release-run state the band renders.
pub fn run_state(&self) -> &ReleaseRunState {
&self.run
}
/// The one-line status the band header shows for the current run.
fn headline(&self) -> String {
let live = self.run.live_crate_count();
if live > 0 {
// PLAIN NUMBERS (a nordisk law) — and the loudest fact wins the header.
return format!("⛔ {live} crate(s) LIVE on crates.io from a fail-stopped run");
}
if self.run.is_mid_ramp() {
let names: Vec<&str> = self.run.on_ramp().iter().map(|r| r.repo.as_str()).collect();
return format!("● mid-ramp: {} repo(s) — {}", names.len(), names.join(", "));
}
"○ no ramp open — every repo is on its base branch".to_string()
}
}
impl Facet for ReleaseTab {
fn title(&self) -> &str {
"Release"
}
fn ui(&mut self, ui: &mut egui::Ui) {
// GENERATION, not a press: re-read the ramp branches + gate report when a
// checkout's HEAD or the persisted release-state file has actually moved. An
// unchanged constellation costs one `read_dir` + one `stat` per checkout every
// `RAMP_PROBE_S` and no scan at all.
let now_s = ui.input(|i| i.time);
let root = self.root.clone();
if self
.watch
.moved(now_s, || crate::generation::git_heads_generation(&root))
{
self.rescan();
}
// 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 three-graph
// convergence viewer and the shooting-ramp band become two scene panes with a
// draggable divider between them.
self.ramp_ui(ui);
ui.separator();
self.convergence_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 ReleaseTab {
/// **The shooting-RAMP band — the inspector half.** Which workspace's release this
/// is, where the graph came from, the headline, and the Gates / Ramp / Bump-plan
/// detail behind the band selector.
///
/// This is why the pane decomposes: the band's detail GROWS — a constellation with
/// many members lists a ramp line per repo, a bump plan a line per crate — and it sat
/// ABOVE the convergence viewer in one vertical flow, so every line it grew came
/// straight out of the graph's height, with no divider to take it back.
/// Returns the pane's own observable state.
fn ramp_ui(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
// An honest header: which workspace's release this is, and WHICH run produced
// the graph below (PLAIN words — a nordisk law). With no run there is no graph,
// and the header is where that is said, by name: the convergence file that was
// never written. It used to read "○ fixture (no release run yet)" above four
// invented crates — a caption cannot un-draw a red "needs-release" badge.
ui.horizontal(|ui| {
if self.workspace.is_empty() {
ui.label("Release: no workspace selected");
} else {
ui.label(format!("Release: {}", self.workspace));
}
ui.separator();
match self.conv_source {
ConvSource::WorkspaceRun => {
ui.label("● workspace release run");
}
ConvSource::ReleaseRun => {
ui.label("● release run (shared output)");
}
ConvSource::NoRun => {
ui.label(format!(
"○ no release run — nothing to converge. Wanted {}, which no run \
has written. Run one: `dwarves release --features facett-adapter`",
self.conv_path.display()
));
}
}
});
// ── the SHOOTING-RAMP band ────────────────────────────────────────────────
ui.separator();
ui.label(self.headline());
let mut want: Option<Band> = None;
// Kept so the band switch below reads unchanged; nothing in the UI sets it any
// more (the generation drive above owns re-reading).
let rescan = false;
ui.horizontal(|ui| {
for (b, label) in [
(Band::Gates, "Gates"),
(Band::Ramp, "Ramp"),
(Band::Plan, "Bump plan"),
] {
if ui.selectable_label(self.band == b, label).clicked() {
want = Some(b);
}
}
// NO ⟳ Rescan button (owner 2026-08-25: SCAN is not a user action). The
// ramp state is re-read when the CHECKOUTS move — `<root>/<repo>/.git/HEAD`
// is the file git rewrites on `checkout`, which is exactly the input to this
// scan. See the generation drive at the top of `ui`.
});
match self.band {
Band::Gates => {
ui.label(format!(
"Gate A (static): {} — {}",
self.run.gates.gate_a.label(),
self.run.gates.gate_a_detail
));
ui.label(format!(
"Gate B (/sparring rehearsal): {} — {}",
self.run.gates.gate_b.label(),
self.run.gates.gate_b_detail
));
if let Some(p) = &self.run.partial {
ui.label(format!(
"already live ({}): {}",
p.published.len(),
p.published.join(", ")
));
if let Some(f) = &p.failed_at {
ui.label(format!("fail-stopped at: {f}"));
}
}
}
Band::Ramp => {
for r in &self.run.ramps {
let mark = if r.kind.on_ramp() { "●" } else { "○" };
ui.label(format!(
"{mark} {}: {} ({})",
r.repo,
r.branch,
r.kind.label()
));
}
if self.run.ramps.is_empty() {
ui.label("no member checkouts found under the constellation root");
}
}
Band::Plan => match &self.run.plan {
Some(p) => {
ui.label(format!(
"{} · bump {} · branch {}",
p.date, p.bump, p.branch
));
ui.label(format!(
"deps-first order ({}): {}",
p.order.len(),
p.order.join(" → ")
));
for (k, v) in &p.ship_versions {
ui.label(format!(" {k} → {v}"));
}
}
None => {
ui.label("no saved changelist — no dated release day is open");
}
},
}
if let Some(b) = want {
self.band = b;
}
if rescan {
self.rescan();
}
serde_json::json!({
"band": self.band.key(),
"headline": self.headline(),
"source": self.conv_source.key(),
"release_run": self.run.state_json(),
})
}
/// **The three-graph CONVERGENCE — the master half.** The reused
/// `facett-releaseview` viewer (LOCAL · SPARRING · CRATES_IO), and nothing else.
fn convergence_ui(&mut self, ui: &mut egui::Ui) {
// Pick the render — two views of ONE model. The toggle is the pane's second
// real interaction (after the band selector).
ui.horizontal(|ui| {
ui.label("Convergence:");
if ui
.selectable_label(self.conv_view == ConvView::Planes, "3-plane")
.clicked()
{
self.conv_view = ConvView::Planes;
}
if ui
.selectable_label(self.conv_view == ConvView::Graph, "Dependency DAG")
.clicked()
{
self.conv_view = ConvView::Graph;
}
if self.conv_view == ConvView::Graph {
ui.separator();
// The measurement, on screen: nodes drawn and the last frame's cost. No
// node cap, and the number that proves it is visible, not buried.
let r = self.graph.report();
let us = r["last_frame_us"].as_u64().unwrap_or(0);
ui.weak(format!(
"{} node(s) · {} edge(s) · {:.1} ms/frame · no node cap",
self.graph.node_count(),
self.graph.edge_count(),
us as f64 / 1000.0,
));
}
});
ui.separator();
match self.conv_view {
ConvView::Planes => self.inner.ui(ui),
ConvView::Graph => self.graph.ui(ui),
}
}
fn folded_state_json(&self) -> serde_json::Value {
// Fold the reused ReleaseView pane's own state (planes · nodes · statuses ·
// converged) and ADD (a) the binding oracle the gate reads and (b) the WHOLE
// release-run state, so a robot can assert which repos are ramped / what the
// gates said / how many crates are live — the RESULT, not a literal `true`.
let mut v = self.inner.state_json();
if let Some(obj) = v.as_object_mut() {
obj.insert(
"selected_workspace".into(),
serde_json::json!(self.workspace),
);
obj.insert("release_target".into(), serde_json::json!(self.workspace));
obj.insert("source".into(), serde_json::json!(self.conv_source.key()));
// The convergence file this binding resolved to — loaded when `source` is a
// run, and, when `source` is `"empty"`, the file that was NOT written. A
// drive asserting the absence can then assert WHAT is absent, which is the
// only thing that distinguishes an honest empty pane from a broken one.
obj.insert(
"convergence_path".into(),
serde_json::json!(self.conv_path.display().to_string()),
);
obj.insert("band".into(), serde_json::json!(self.band.key()));
// Which render of the convergence is showing, and what the GRAPH render
// actually drew — node/edge counts, the measured frame cost, and the
// explicit `node_cap: null` (there is none; nornir's CallGraph caps at 160).
obj.insert("conv_view".into(), serde_json::json!(self.conv_view.key()));
obj.insert("graph".into(), self.graph.report());
obj.insert("headline".into(), serde_json::json!(self.headline()));
obj.insert(
"constellation_root".into(),
serde_json::json!(self.root.display().to_string()),
);
// The read-only shooting-ramp state, verbatim from the shared leaf.
obj.insert("release_run".into(), self.run.state_json());
// The safety oracle: this pane exposes NO publish affordance, by construction.
obj.insert("can_publish".into(), serde_json::json!(false));
}
v
}
fn apply_msg(&mut self, msg_json: &str) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
// The selector's broadcast (the SAME `{"set_workspace":…}` every tab gets):
// re-bind the release pane to the newly-selected workspace.
if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
self.bind(ws);
return;
}
if let Some(b) = v
.get("release_band")
.and_then(|t| t.as_str())
.and_then(Band::parse)
{
self.band = b;
return;
}
if let Some(cv) = v
.get("conv_view")
.and_then(|t| t.as_str())
.and_then(ConvView::parse)
{
self.conv_view = cv;
return;
}
if v.get("rescan").and_then(|r| r.as_bool()) == Some(true) {
self.rescan();
return;
}
}
// Anything else forwards to the reused pane's own seam (view toggles, etc.).
self.inner.update_json(msg_json);
}
}
/// **The Release body DECOMPOSES**: the convergence viewer is the master, the
/// shooting-ramp band the detail — two scene panes with a draggable divider, each with
/// its own chrome bar.
impl crate::tabbed::SplitPanel for ReleaseTab {
fn ui_main(&mut self, ui: &mut egui::Ui) {
self.convergence_ui(ui);
}
fn ui_inspector(&mut self, ui: &mut egui::Ui) -> serde_json::Value {
self.ramp_ui(ui)
}
}
#[cfg(test)]
mod tests {
use super::*;
use dwarves::release_state::{GateState, GateVerdict, PartialRelease};
use facett_releaseview::model::{CrateConvergence, ReleaseConvergence};
use std::path::Path;
/// Emit this tab's coverage-board cell (reuse edda's testmatrix — never a local
/// harness).
fn cell(name: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status("dwarves-gui", name, ok, detail);
let _ = (name, ok, detail);
}
/// Point `std::env::temp_dir()` (and therefore EVERY convergence / release-state path)
/// at a private scratch directory, so a test can never see — or be seen by — a real
/// release run's artifacts on this box. Returns the scratch root; the caller restores
/// `TMPDIR` when done. Must be held under [`crate::ENV_LOCK`].
fn scratch_tmp(tag: &str) -> (PathBuf, Option<String>) {
let prev = std::env::var("TMPDIR").ok();
let dir =
std::env::temp_dir().join(format!("dwarves-gui-release-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
unsafe { std::env::set_var("TMPDIR", &dir) };
(dir, prev)
}
fn restore_tmp(prev: Option<String>) {
unsafe {
match prev {
Some(v) => std::env::set_var("TMPDIR", v),
None => std::env::remove_var("TMPDIR"),
}
}
}
/// A fake constellation member checkout on `branch`.
fn fake_repo(root: &Path, name: &str, branch: &str) {
let git = root.join(name).join(".git");
std::fs::create_dir_all(&git).unwrap();
std::fs::write(git.join("HEAD"), format!("ref: refs/heads/{branch}\n")).unwrap();
}
/// Write a tiny, DISTINCT convergence JSON so a test can prove the pane loaded THAT
/// file rather than the 4-node nordisk fixture.
fn seed_convergence(path: &Path, title: &str) {
let conv = ReleaseConvergence::new(title).with_node(CrateConvergence::new(
"only-crate",
"1.0.0",
"1.0.0",
"1.0.0",
));
std::fs::write(path, serde_json::to_vec(&conv).unwrap()).unwrap();
}
/// RED-when-broken (the AUDIT gate, 2026-09-10): with NO selection — or a workspace
/// that never ran a release — the Release tab draws NOTHING and says so BY NAME.
///
/// This test is the inverse of the one it replaces. That one asserted
/// `source == "fixture"` and `node_count == 4`: the nordisk fixture, four real crate
/// names carrying invented versions, two of them badged RED "needs-release", under a
/// caption reading "○ fixture". The caption was honest and the pixels were not — the
/// pane's entire job is to answer *which crates need publishing*, and it was
/// answering before any release had run.
///
/// So the assertions here are: zero nodes, `source:"empty"`, the convergence file
/// NAMED (both in the tab's own `convergence_path` and in the reused pane's
/// `convergence_empty` block), and — still — no invented gate verdict.
#[test]
fn no_selection_draws_no_convergence_and_names_the_missing_file() {
let _g = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let (dir, prev) = scratch_tmp("empty");
// `scratch_tmp` repoints TMPDIR, so both convergence paths resolve under `dir`
// and neither exists — the "no run anywhere" state, hermetically.
let t = ReleaseTab::rooted("", &dir);
let s = Facet::state_json(&t);
let named = s["convergence_path"].as_str().unwrap_or("").to_string();
let empty_block = &s["convergence_empty"];
let ok = s["source"] == "empty"
&& s["selected_workspace"] == ""
&& s["component"] == "ReleaseView"
&& s["node_count"] == 0
&& !named.is_empty()
&& empty_block["empty"] == true
&& !empty_block["feed"].as_str().unwrap_or("").is_empty()
&& s["release_run"]["gate_a"] == "not-run"
&& s["release_run"]["live_crate_count"] == 0
&& s["can_publish"] == false;
assert_eq!(
s["source"], "empty",
"no run anywhere ⇒ the NAMED absence, not a fixture"
);
assert_eq!(s["node_count"], 0, "no run ⇒ no crates drawn: {s}");
assert!(
!named.is_empty(),
"the absence names the convergence file it wanted: {s}"
);
assert_eq!(
empty_block["empty"], true,
"the reused pane carries its EmptyState: {s}"
);
assert_eq!(
s["release_run"]["gate_a"], "not-run",
"no run ⇒ no invented gate verdict"
);
assert_eq!(
s["can_publish"], false,
"the Release pane exposes NO publish affordance"
);
// And the deleted fixture's crate/version pairs must not be reachable from the
// state at all — the pin that keeps them from creeping back in as a "demo".
let dump = s.to_string();
for gone in ["needs-release", "bump-drift", "0.6.9", "nordisk release-3"] {
assert!(
!dump.contains(gone),
"the nordisk fixture leaked back in (`{gone}`): {dump}"
);
}
restore_tmp(prev);
let _ = std::fs::remove_dir_all(&dir);
cell(
"release_no_run_names_the_absence",
ok,
"with no release run anywhere the Release tab draws ZERO nodes and NAMES what is missing — source:\"empty\", the convergence file it wanted in convergence_path, and the reused facett-releaseview pane's own EmptyState block — instead of the deleted nordisk 4-crate FIXTURE whose invented versions badged two real crates RED \"needs-release\" before any release had run (audit 2026-09-10); gate_a stays \"not-run\" and can_publish:false",
);
}
/// RED-when-broken (the BINDING gate): selecting a workspace that HAS a release run
/// makes the tab reflect THAT workspace's convergence file, and a run that wrote only
/// the SHARED output path is still shown (the gap that made a real release invisible
/// whenever a workspace was selected). The third rung of the ladder is no longer the
/// fixture: with no run anywhere the tab reports `source:"empty"` and zero nodes.
#[test]
fn the_selected_workspace_and_the_shared_run_output_both_reach_the_viewer() {
let _g = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let (dir, prev) = scratch_tmp("bind");
let ws = "dwarves-shell-test-ws";
seed_convergence(
&dwarves_embed::workspace_convergence_path(ws),
"dwarves-shell-test convergence",
);
let mut t = ReleaseTab::rooted("", &dir);
// Bind via the SAME `{"set_workspace":…}` broadcast the selector sends.
Facet::update_json(
&mut t,
&serde_json::json!({ "set_workspace": ws }).to_string(),
);
let s = Facet::state_json(&t);
let bound_ok = s["selected_workspace"] == ws
&& s["release_target"] == ws
&& s["source"] == "workspace-run"
&& s["title"] == "dwarves-shell-test convergence"
&& s["node_count"] == 1
&& s["converged"] == true;
assert_eq!(
s["source"], "workspace-run",
"a run for THIS workspace ⇒ its own file"
);
assert_eq!(s["node_count"], 1, "the seeded convergence, and only it");
// A workspace with NO file of its own, while a real run wrote the SHARED output:
// the run must still be visible (this used to silently show the fixture).
let _ = std::fs::remove_file(dwarves_embed::workspace_convergence_path(ws));
seed_convergence(
&dwarves_embed::default_convergence_path(),
"shared run convergence",
);
Facet::update_json(
&mut t,
&serde_json::json!({ "set_workspace": "no-run-ws" }).to_string(),
);
let s = Facet::state_json(&t);
let shared_ok = s["source"] == "release-run"
&& s["title"] == "shared run convergence"
&& s["node_count"] == 1;
assert_eq!(
s["source"], "release-run",
"a real run's shared output is NOT hidden"
);
assert_eq!(s["title"], "shared run convergence");
// With no run anywhere, the ladder's last rung: the NAMED absence. It used to be
// the nordisk fixture, told apart only by `source` — four invented crates drawn
// in exactly the place a real convergence goes (deleted 2026-09-10).
let _ = std::fs::remove_file(dwarves_embed::default_convergence_path());
Facet::update_json(
&mut t,
&serde_json::json!({ "set_workspace": "still-no-run-ws" }).to_string(),
);
let s = Facet::state_json(&t);
let empty_ok = s["source"] == "empty"
&& s["node_count"] == 0
&& s["convergence_empty"]["empty"] == true
&& s["convergence_path"]
.as_str()
.is_some_and(|p| !p.is_empty());
assert_eq!(
s["source"], "empty",
"no run anywhere ⇒ the named absence: {s}"
);
assert_eq!(
s["node_count"], 0,
"and nothing drawn in a real convergence's place"
);
restore_tmp(prev);
let _ = std::fs::remove_dir_all(&dir);
cell(
"release_binds_to_the_selected_workspace_convergence",
bound_ok && shared_ok && empty_ok,
"the Release tab re-binds over the {\"set_workspace\":…} broadcast and walks the convergence LADDER — the workspace's own run file, then the SHARED workspace-release output (previously invisible whenever a workspace was selected), then NOTHING (source:\"empty\", zero nodes, the wanted file named) where the nordisk fixture used to be — with `source` naming which of the three reached the reused 3-plane viewer",
);
}
/// RED-when-broken (the MID-RAMP view — the whole point of the band): a constellation
/// with one repo on a DATED release branch and one on an EPHEMERAL ramp reports
/// exactly those two as on-ramp, names the bump plan from the saved changelist, and
/// drives the band sections over the SAME JSON seam a robot uses. Asserted on the
/// resulting state, never a literal `true`.
#[test]
fn a_release_mid_ramp_is_visible_with_its_bump_plan() {
let _g = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let (dir, prev) = scratch_tmp("midramp");
let cst = dir.join("constellation");
fake_repo(&cst, "nornir", "release/2026-07-18");
fake_repo(&cst, "holger", "release/ramp/run-42");
fake_repo(&cst, "skade", "main");
// The saved changelist the dated release day wrote (the bump plan).
let nornir_docs = cst.join("nornir").join(".nornir");
std::fs::create_dir_all(&nornir_docs).unwrap();
std::fs::write(
nornir_docs.join("release-2026-07-18-changelist.md"),
"- date: 2026-07-18\n- root: workspace_nordisk\n- bump: patch\n- branch: release/2026-07-18\n\n\
## Deps-first publish order (2 step(s))\n1. skade\n2. holger\n\n\
## Ship-version graph (2 crate(s))\n- holger@0.6.9\n- skade@0.5.2\n",
)
.unwrap();
let t = ReleaseTab::rooted("nordisk", &cst);
let s = Facet::state_json(&t);
let r = &s["release_run"];
let ramp_ok = r["mid_ramp"] == true
&& r["member_count"] == 3
&& r["on_ramp"] == serde_json::json!(["holger", "nornir"])
&& r["bump"] == "patch"
&& r["release_branch"] == "release/2026-07-18"
&& r["planned_crate_count"] == 2
&& r["publish_order"] == serde_json::json!(["skade", "holger"])
&& r["ship_versions"]["skade"] == "0.5.2";
assert_eq!(
r["mid_ramp"], true,
"a release IS open on this constellation"
);
assert_eq!(
r["on_ramp"],
serde_json::json!(["holger", "nornir"]),
"exactly the ramped repos"
);
assert_eq!(
r["ramps"][2]["kind"], "base",
"skade is on main, not on a ramp"
);
assert_eq!(r["bump"], "patch", "the saved changelist names the bump");
assert_eq!(
r["publish_order"],
serde_json::json!(["skade", "holger"]),
"deps-first order"
);
assert!(
s["headline"]
.as_str()
.unwrap()
.contains("mid-ramp: 2 repo(s)"),
"the header states the ramp plainly: {}",
s["headline"]
);
// Drive the band over the seam (the pane's real interaction) and assert it moved.
let mut t = t;
assert_eq!(
Facet::state_json(&t)["band"],
"gates",
"the band opens on the gates"
);
Facet::update_json(&mut t, r#"{"release_band":"plan"}"#);
let band_ok = Facet::state_json(&t)["band"] == "plan";
assert_eq!(Facet::state_json(&t)["band"], "plan");
Facet::update_json(&mut t, r#"{"release_band":"ramp"}"#);
let band_ok = band_ok && Facet::state_json(&t)["band"] == "ramp";
assert_eq!(Facet::state_json(&t)["band"], "ramp");
restore_tmp(prev);
let _ = std::fs::remove_dir_all(&dir);
cell(
"release_mid_ramp_is_visible",
ramp_ok && band_ok,
"the Release tab's shooting-ramp band shows a release MID-FLIGHT: exactly the repos on a ramp branch (dated release/YYYY-MM-DD + ephemeral release/ramp/<run-id>, the untouched repo excluded), the saved changelist's bump level + deps-first order + crate→ship-version graph, and a headline naming the ramp — with the Gates/Ramp/Bump-plan sections driven over the {\"release_band\":…} seam",
);
}
/// RED-when-broken (the GATE view + the SAFETY view): a run's reported Gate A / Gate B
/// verdicts reach the pane, and a fail-stopped `--execute` that already put crates on
/// crates.io is surfaced LOUDLY with the exact live count — the state this box is
/// actually in. A pane that under-reports live crates is the dangerous failure.
#[test]
fn gate_verdicts_and_an_already_live_partial_release_are_surfaced() {
let _g = crate::ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let (dir, prev) = scratch_tmp("gates");
let cst = dir.join("constellation");
fake_repo(&cst, "dwarves", "release/ramp/run-9");
let ws = "gate-test-ws";
let reported = ReleaseRunState {
workspace: ws.to_string(),
run_id: "ws-deadbeef".into(),
execute: true,
gates: GateState {
gate_a: GateVerdict::Passed,
gate_a_detail: "static gates green".into(),
gate_b: GateVerdict::Failed,
gate_b_detail: "the /sparring rehearsal did not fully publish the cascade".into(),
},
ramps: Vec::new(),
plan: None,
partial: Some(PartialRelease {
published: vec![
"skade@0.5.2".into(),
"holger@0.6.9".into(),
"nornir-git@0.2.1".into(),
],
failed_at: Some("dwarves/publish".into()),
kept_ramps: vec![("dwarves".into(), "release/ramp/run-9".into())],
}),
};
reported
.write(&dwarves::release_state::workspace_release_state_path(ws))
.unwrap();
let t = ReleaseTab::rooted(ws, &cst);
let s = Facet::state_json(&t);
let r = &s["release_run"];
let gates_ok = r["gate_a"] == "passed"
&& r["gate_b"] == "failed"
&& r["gate_b_detail"] == "the /sparring rehearsal did not fully publish the cascade"
&& r["run_id"] == "ws-deadbeef"
&& r["execute"] == true;
let live_ok = r["live_crate_count"] == 3
&& r["failed_at"] == "dwarves/publish"
&& r["live_crates"]
== serde_json::json!(["skade@0.5.2", "holger@0.6.9", "nornir-git@0.2.1"]);
assert_eq!(r["gate_a"], "passed", "Gate A's verdict reaches the pane");
assert_eq!(r["gate_b"], "failed", "Gate B's verdict reaches the pane");
assert_eq!(
r["live_crate_count"], 3,
"EVERY already-live crate is counted"
);
assert!(
s["headline"].as_str().unwrap().contains("3 crate(s) LIVE"),
"a partial release is the LOUDEST thing in the header: {}",
s["headline"]
);
// The live ramp scan still wins over the (empty) ramp set in the reported file.
assert_eq!(
r["member_count"], 1,
"the ramps come from the live disk scan"
);
assert_eq!(r["mid_ramp"], true);
assert_eq!(
s["can_publish"], false,
"still no publish affordance, mid-incident"
);
let _ = std::fs::remove_file(dwarves::release_state::workspace_release_state_path(ws));
restore_tmp(prev);
let _ = std::fs::remove_dir_all(&dir);
cell(
"release_gates_and_partial_release_surfaced",
gates_ok && live_ok,
"the Release tab surfaces the run's Gate A (static) and Gate B (/sparring rehearsal) verdicts with their detail lines, and renders a fail-stopped --execute's ALREADY-LIVE crates.io crates with the exact count + the fail point as the loudest line in the header — read-only, can_publish:false",
);
}
/// **The Release DAG opens centred and a click never moves it** — rendered: the
/// tab's own `ui` (the band, the toolbar, then the graph) beside a 320 px rail, the
/// graph's box centred (±2 px) and wholly inside the part of its canvas the viewer
/// sees; a real click on a node (📌 Stay) leaves it where it was. RED before: the
/// pane's first spec drew at zoom 1 with world (0, 0) at the canvas centre — the
/// chain hung off to the right.
#[test]
fn the_release_dag_opens_centred_and_a_click_never_moves_it() {
use facett_releaseview::model::{CrateConvergence, DepEdge, ReleaseConvergence};
let conv = ReleaseConvergence::new("release-dag-camera")
.with_node(CrateConvergence::new("skade", "0.5.1", "0.5.1", "0.5.1"))
.with_node(CrateConvergence::new("dwarves", "0.1.4", "0.1.4", "0.1.3"))
.with_node(CrateConvergence::new("holger", "0.6.9", "0.6.9", "0.6.8"))
.with_node(CrateConvergence::new("nornir", "0.5.3", "0.5.4", "0.5.4"))
.with_edge(DepEdge::new(1, 0))
.with_edge(DepEdge::new(2, 1))
.with_edge(DepEdge::new(3, 2));
let dir = tempfile::tempdir().unwrap();
let mut tab = ReleaseTab::rooted("", dir.path());
tab.graph.set_spec(&convergence_to_dag(&conv));
tab.conv_view = ConvView::Graph;
let cell = std::rc::Rc::new(std::cell::RefCell::new(tab));
let c2 = cell.clone();
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1400.0, 900.0))
.with_step_dt(1.0 / 60.0)
.build_ui(move |ui| {
egui::Panel::left("rail").exact_size(320.0).show_inside(ui, |ui| {
ui.label("rail");
});
facett_core::Facet::ui(&mut *c2.borrow_mut(), ui);
});
for _ in 0..4 {
h.step();
}
let (seen, b) = {
let t = cell.borrow();
(t.graph.last_visible().expect("painted"), t.graph.screen_box().expect("nodes"))
};
assert!((b.center() - seen.center()).length() <= 2.0, "the DAG is centred: box {b:?} in {seen:?}");
assert!(seen.expand(0.5).contains_rect(b), "the whole DAG is visible: box {b:?} in {seen:?}");
// 📌 Stay: a real click on `holger` selects it and moves nothing
let at = |t: &ReleaseTab| {
let p = t.graph.positions().into_iter().find(|(i, _)| i == "holger").unwrap().1;
t.graph.world_to_screen(p).unwrap()
};
let before = at(&cell.borrow());
let cam = cell.borrow().graph.camera();
h.input_mut().events.push(egui::Event::PointerMoved(before));
h.step();
for pressed in [true, false] {
h.input_mut().events.push(egui::Event::PointerButton { pos: before, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
for _ in 0..30 {
h.step();
}
assert_eq!(cell.borrow().graph.selected(), Some("holger"), "the click landed on holger");
assert_eq!(cell.borrow().graph.camera(), cam, "the camera did not move");
assert_eq!(at(&cell.borrow()), before);
}
/// **The release convergence is ALSO a graph, on the shared engine — and the two
/// renders are ONE model.** RED-when-broken: the graph's node count must equal the
/// convergence's, the dependency edges must survive the lowering, the render must
/// be measured (a real frame cost), and there must be no node cap.
#[test]
fn the_release_convergence_renders_as_a_measured_graph() {
// A 4-crate chain built HERE, in the test. It used to be
// `dwarves_embed::release_fixture()` — deleted on 2026-09-10 because the
// shipped pane drew it whenever no release run was on disk.
use facett_releaseview::model::{CrateConvergence, DepEdge, ReleaseConvergence};
let conv = ReleaseConvergence::new("release-dag-test")
.with_node(CrateConvergence::new("skade", "0.5.1", "0.5.1", "0.5.1"))
.with_node(CrateConvergence::new("dwarves", "0.1.4", "0.1.4", "0.1.3"))
.with_node(CrateConvergence::new("holger", "0.6.9", "0.6.9", "0.6.8"))
.with_node(CrateConvergence::new("nornir", "0.5.3", "0.5.4", "0.5.4"))
.with_edge(DepEdge::new(1, 0))
.with_edge(DepEdge::new(2, 1))
.with_edge(DepEdge::new(3, 2));
let spec = convergence_to_dag(&conv);
assert_eq!(
spec.nodes.len(),
conv.nodes.len(),
"every crate becomes a node"
);
assert_eq!(
spec.edges.len(),
conv.edges.len(),
"every dependency becomes an edge"
);
let mut pane = crate::dag_graph::DagPane::new("Release DAG");
pane.set_spec(&spec);
assert_eq!(pane.node_count(), 4);
assert_eq!(pane.edge_count(), 3);
assert!(
pane.report()["node_cap"].is_null(),
"no node cap (nornir's CallGraph caps at 160)"
);
// MEASURE it: render a real headless frame and read the cost the pane recorded.
let cell_pane = std::cell::RefCell::new(pane);
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1000.0, 700.0))
.build_ui(|ui| {
cell_pane.borrow_mut().ui(ui);
});
h.step();
h.step();
let measured = cell_pane.borrow().last_frame_us();
assert!(measured.is_some(), "the frame is timed, not asserted-fast");
let us = measured.unwrap();
cell(
"release_convergence_renders_as_a_measured_graph",
measured.is_some(),
&format!(
"the release convergence lowers 1:1 onto the shared facett-graphview engine (4 nodes / 3 dependency edges, same model as the 3-plane pane), renders a real headless frame measured at {} us, and reports node_cap:null — there is no MAX_NODES ceiling",
us
),
);
}
}