src/viz/nornir_dashboard.rs
//! 🧬 **nornir RELEASE DASHBOARD** — go/no-go release state in ONE glance.
//!
//! A **Release button** + a **gate-overlaid dependency graph** beside it. The
//! button reflects the gate verdict (green/go, red+disabled/no-go) and the graph
//! shows WHY: each repo chip is badged from the shared [`gate`](super::gate) model
//! (dirty 🟡 · behind · ⛔ held-by-transitive-pin · edition-fail), and every
//! dependency cycle is drawn with its suggested **cut edge** dashed + the
//! rationale. Clicking a node scopes the release to that repo and lights its blast
//! radius (the downstream subtree) via the SHARED [`graph_render::draw_graph`] —
//! the SAME renderer the 🏛 Architecture board uses (no duplicated canvas).
//!
//! The gate model is the shared [`gate::GateModel`] (the 🚀 Release pane's exact
//! computation + `gate_json` shape) so the dashboard never recomputes or reshapes
//! the verdict. Node→history reuses the app's already-loaded [`Timeline`].
use std::path::PathBuf;
use eframe::egui::{self, RichText, ScrollArea, Vec2};
use super::facett_theme::{AMBER, GREEN, RED, Theme, ThemeExt};
use super::gate::GateModel;
use super::graph_render::{
self, Decorations, GraphEdge, GraphModel, GraphNode, GraphView, NodeDecoration,
};
use super::model::Timeline;
use super::release_wizard::{GateStatus, WizardModel, WizardStep};
use crate::release::doctor::DepPolicy;
/// Column / row pitch for the repo-graph layout (world units, before zoom).
const COL_W: f32 = 230.0;
const ROW_H: f32 = 70.0;
/// 🧬 The release-dashboard state. Owns the gate inputs (local-mode checkout +
/// repos + policy), the cached shared gate model, the canvas view (pan/zoom +
/// selection), the release-scope (clicked node), and the last Release-button run.
pub struct NornirDashboard {
/// Gate inputs: workspace checkout root + repos (local mode only — remote has
/// no checkout to scan, so the dashboard shows the gate as unavailable there).
gate_repos: Vec<(String, PathBuf)>,
policy: DepPolicy,
/// `true` in local mode (a checkout exists to scan); `false` remote.
local: bool,
/// The cached SHARED gate model — computed once per load/reload, NOT per frame.
gate: Option<GateModel>,
gate_error: Option<String>,
/// `true` once a gate compute has been attempted (so we don't recompute every
/// frame). Reset by `reload`.
loaded: bool,
/// The release scope: the repo a node-click pinned the Release button to
/// (`None` = whole workspace / advisory `release doctor`).
scoped_repo: Option<String>,
/// The canvas pan/zoom + selected node id (drives the blast-radius highlight).
view: GraphView,
/// The last Release-button result line (advisory run summary).
last_run: Option<String>,
/// 🧙 The guided RELEASE WIZARD the Release button opens — a gated walk over
/// the existing release tiers (Advise → Fix → Rehearse → Promote). Orchestration
/// only; it reads the SHARED gate model + calls the existing engines.
wizard: WizardModel,
/// Whether the wizard panel is open (the Release button toggles it on).
wizard_open: bool,
theme: Theme,
}
impl Default for NornirDashboard {
fn default() -> Self {
Self {
gate_repos: Vec::new(),
policy: DepPolicy::default(),
local: false,
gate: None,
gate_error: None,
loaded: false,
scoped_repo: None,
view: GraphView::default(),
last_run: None,
wizard: WizardModel::new(),
wizard_open: false,
theme: Theme::default(),
}
}
}
impl NornirDashboard {
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// Wire the gate inputs (local mode only). Repo checkouts are `root/<repo>`;
/// repos without a `Cargo.toml` are skipped. Drops the cache so the next draw
/// recomputes. Mirrors `ReleaseTabState::set_gate_inputs`.
pub fn set_gate_inputs(&mut self, root: &std::path::Path, repos: &[String], policy: DepPolicy) {
self.gate_repos = repos
.iter()
.map(|name| (name.clone(), root.join(name)))
.filter(|(_, p)| p.join("Cargo.toml").exists())
.collect();
self.policy = policy;
self.local = true;
self.gate = None;
self.gate_error = None;
self.loaded = false;
}
/// Remote mode: no checkout to scan — the gate is unavailable (the graph still
/// renders from an injected model in tests / the gate section shows a note).
pub fn set_remote(&mut self) {
self.local = false;
self.reload();
}
/// Drop the cached gate so the next draw recomputes (workspace switch / reload).
pub fn reload(&mut self) {
self.loaded = false;
self.gate = None;
self.gate_error = None;
self.scoped_repo = None;
self.view.clear_selection();
self.wizard.reset();
self.wizard_open = false;
}
/// Compute (and cache) the shared gate model once per load — NOT per repaint.
/// Static-only edition pass (the perf caveat); local mode only.
fn ensure_gate(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
if self.gate_repos.is_empty() {
return;
}
match GateModel::compute(&self.gate_repos, &self.policy, false) {
Ok(m) => {
self.gate = Some(m);
self.gate_error = None;
}
Err(e) => self.gate_error = Some(e),
}
}
/// Test-only: inject a gate model directly (no checkout to scan), so the robot
/// / inject-assert harness reads `state_json` back. Mirrors the Release pane's
/// `inject_gate_for_test`.
#[doc(hidden)]
pub fn inject_gate_for_test(
&mut self,
doctor: crate::release::doctor::DoctorReport,
edition: nornir_release_core::edition::EditionReport,
) {
if self.gate_repos.is_empty() {
self.gate_repos.push(("nornir".into(), PathBuf::from(".")));
}
self.local = true;
self.gate = Some(GateModel::new(doctor, edition));
self.gate_error = None;
self.loaded = true;
}
/// Drive a node click from a test (parity with a pointer click on the graph):
/// scope the release to `repo` + light its blast radius. Returns whether the
/// repo is a node in the current graph.
#[doc(hidden)]
pub fn select_repo_for_test(&mut self, repo: &str) -> bool {
let in_graph = self
.gate
.as_ref()
.map(|m| self.repo_nodes(m).iter().any(|r| r == repo))
.unwrap_or(false);
if in_graph {
self.scoped_repo = Some(repo.to_string());
self.view.selected = Some(repo.to_string());
}
in_graph
}
/// Test/robot-drive seam: open the wizard (what the Release button does), so
/// the inject-assert harness reads `state_json()["release_wizard"]` back without
/// a pointer click. Parity with clicking 🚀 Release.
#[doc(hidden)]
pub fn open_wizard_for_test(&mut self) {
self.wizard_open = true;
}
/// Test/robot-drive seam: arm/disarm the override for a wizard step (the
/// explicit "advance anyway" past a red gate).
#[doc(hidden)]
pub fn wizard_set_override_for_test(&mut self, step: &str, armed: bool) -> bool {
match WizardStep::from_name(step) {
Some(s) => {
self.wizard.set_override(s, armed);
true
}
None => false,
}
}
/// Test/robot-drive seam: advance the wizard one step IF its gate allows it
/// (green or overridden). Returns the new current step name.
#[doc(hidden)]
pub fn wizard_advance_for_test(&mut self) -> String {
let step = self.wizard.advance(self.gate.as_ref());
step.name().to_string()
}
/// Test/robot-drive seam: drive the Fix step's "apply fixes" action (the
/// `apply_skew_bumps` + re-advise path) without a pointer click. Returns the
/// recorded fix lines so a test can assert what was bumped. With injected
/// repos that have no on-disk checkout, this exercises the orchestration and
/// the record/re-advise path (the bump itself is unit-tested in `cargo.rs`).
#[doc(hidden)]
pub fn wizard_apply_fixes_for_test(&mut self) -> Vec<String> {
self.apply_fixes();
self.wizard.fix_progress().lines
}
/// Test/robot-drive seam: seed the Rehearse tier as already passed/failed (no
/// real stage run), so the gating + promote path is exercisable in tests.
#[doc(hidden)]
pub fn wizard_inject_rehearse_for_test(&mut self, ok: bool, lines: Vec<String>) {
self.wizard.inject_rehearse_for_test(ok, lines);
}
/// Test/robot-drive seam: type the promote confirm phrase.
#[doc(hidden)]
pub fn wizard_set_promote_confirm_for_test(&mut self, input: &str) {
self.wizard.promote_confirm_input = input.to_string();
}
/// The set of repo node names in the graph (topo order ∪ edge endpoints ∪
/// skew/dirty repos), deterministic.
fn repo_nodes(&self, m: &GateModel) -> Vec<String> {
use std::collections::BTreeSet;
let mut set: BTreeSet<String> = BTreeSet::new();
for r in &m.doctor.topo.order {
set.insert(r.clone());
}
for r in &m.doctor.topo.cycle {
set.insert(r.clone());
}
for e in &m.doctor.repo_edges {
set.insert(e.from.clone());
set.insert(e.to.clone());
}
for d in &m.doctor.dirty {
set.insert(d.repo.clone());
}
for c in &m.doctor.skew {
for e in &c.entries {
set.insert(e.repo.clone());
}
}
// Keep topo order first (deps-first), then any extras alphabetically.
let mut ordered: Vec<String> = m.doctor.topo.order.clone();
for r in set {
if !ordered.contains(&r) {
ordered.push(r);
}
}
ordered
}
/// Build the SHARED graph model (repo nodes laid out by topo column) + the gate
/// DECORATIONS overlay (per-node badge/ring + each cycle's dashed cut edge). The
/// decoration layer is domain-agnostic data the shared renderer paints — facett
/// needn't know what a "release gate" is.
fn build_graph(&self, m: &GateModel) -> (GraphModel, Decorations) {
let theme = self.theme;
let repos = self.repo_nodes(m);
// Column = topo rank (deps-first → left); unknown repos pushed right.
let col_of = |r: &str| -> usize { repos.iter().position(|x| x == r).unwrap_or(0) };
let total_w = COL_W * (repos.len().saturating_sub(1)) as f32;
// Per-repo gate facts for the badges/rings.
let dirty: std::collections::BTreeSet<&str> = m
.doctor
.dirty
.iter()
.filter(|d| d.dirty)
.map(|d| d.repo.as_str())
.collect();
let held: std::collections::BTreeSet<String> = m.held_back().into_iter().collect();
let behind: std::collections::BTreeSet<&str> = m
.doctor
.skew
.iter()
.flat_map(|c| c.entries.iter())
.filter(|e| e.status != crate::release::doctor::SkewStatus::Ok)
.map(|e| e.repo.as_str())
.collect();
// The edition gate is scoped to the FIRST repo (mirrors the gate compute).
let edition_fail_repo = (!m.edition.is_clean())
.then(|| m.doctor.topo.order.first().cloned())
.flatten();
let mut nodes = Vec::new();
let mut deco_nodes = std::collections::HashMap::new();
for (i, repo) in repos.iter().enumerate() {
let x = -total_w / 2.0 + col_of(repo) as f32 * COL_W;
// Stagger rows so same-column repos don't overlap.
let y = -((repos.len() as f32) * ROW_H) / 2.0 + i as f32 * ROW_H * 0.5;
nodes.push(GraphNode {
id: repo.clone(),
label: repo.clone(),
fill: theme.node_fill,
stroke: theme.node_stroke,
pos: egui::Pos2::new(x, y),
});
// The badge string + the ring colour for this repo's WORST gate fact.
let mut badges = String::new();
let mut ring = None;
if held.contains(repo) {
badges.push('⛔');
ring = Some(RED);
}
if edition_fail_repo.as_deref() == Some(repo.as_str()) {
badges.push('✗');
ring = Some(RED);
}
if behind.contains(repo.as_str()) && ring.is_none() {
badges.push('▼');
ring = Some(AMBER);
}
if dirty.contains(repo.as_str()) {
badges.push('🟡');
if ring.is_none() {
ring = Some(AMBER);
}
}
if ring.is_none() {
ring = Some(GREEN); // clean repo → green go ring.
}
if !badges.is_empty() || ring.is_some() {
deco_nodes.insert(
repo.clone(),
NodeDecoration {
ring,
badge: (!badges.is_empty()).then_some(badges),
badge_color: ring,
},
);
}
}
// Base edges (repo → dependency), neutral palette.
let mut edges = Vec::new();
for e in &m.doctor.repo_edges {
edges.push(GraphEdge {
from: e.from.clone(),
to: e.to.clone(),
color: theme.edge,
dashed: false,
label: None,
});
}
// Decoration EDGES: each dependency cycle's suggested cut, dashed + the
// rationale, painted on top in amber.
let mut deco_edges = Vec::new();
for a in &m.doctor.cycle_advice {
deco_edges.push(GraphEdge {
from: a.cut_from.clone(),
to: a.cut_to.clone(),
color: AMBER,
dashed: true,
label: Some(format!("✂ cut · {}", a.rationale)),
});
}
(
GraphModel { nodes, edges },
Decorations {
nodes: deco_nodes,
edges: deco_edges,
},
)
}
/// Render the dashboard. `tl` is the app's loaded timeline (for node→history);
/// `Err` / empty is fine — the history panel just shows "no releases recorded".
pub fn draw(&mut self, ui: &mut egui::Ui, tl: Option<&Timeline>) {
self.ensure_gate();
let theme = self.theme;
ui.heading("🧬 nornir — RELEASE DASHBOARD");
ui.label(
RichText::new(
"go/no-go at a glance: the Release button + the gate-overlaid dependency graph",
)
.color(theme.text_dim),
);
ui.separator();
// ── The Release button (reflects the gate verdict) ────────────────────
let gate_ok = self.gate.as_ref().map(|m| m.gate_ok()).unwrap_or(true);
let scope = self.scoped_repo.clone();
ui.horizontal_wrapped(|ui| {
let label = match &scope {
Some(r) => format!("🚀 Release — {r}"),
None => "🚀 Release (doctor — advisory)".to_string(),
};
// The Release button OPENS the guided WIZARD (NOT a one-shot run) — it
// walks the existing tiers as gates. Always enabled (the wizard's first
// gate is Advise, which itself reflects the verdict); a no-go is shown
// on the button + graph and blocks the wizard from advancing.
let btn = egui::Button::new(RichText::new(&label).color(if gate_ok {
theme.text
} else {
RED
}));
let resp = ui.add(btn);
if resp.clicked() {
self.wizard_open = true;
self.last_run = Some(match &scope {
Some(r) => format!("opened the release wizard scoped to {r}"),
None => "opened the release wizard (workspace)".to_string(),
});
}
if gate_ok {
ui.colored_label(GREEN, "✅ go");
} else {
ui.colored_label(RED, "⛔ no-go — see the graph");
}
if self.wizard_open && ui.button("✖ close wizard").clicked() {
self.wizard_open = false;
}
if scope.is_some() && ui.button("✖ clear scope").clicked() {
self.scoped_repo = None;
self.view.clear_selection();
}
if ui.button("⊙ fit").clicked() {
self.view.fit();
}
});
if let Some(run) = &self.last_run {
ui.colored_label(theme.text_dim, format!("· {run}"));
}
// ── The guided RELEASE WIZARD (opened by the Release button) ───────────
// The gate-overlaid graph below shows state at each step (the #15 overlay).
if self.wizard_open {
ui.separator();
self.draw_wizard(ui);
}
// ── The gate-overlaid dependency graph beside it ──────────────────────
// Gather what we need before the borrow of `self` for the canvas split.
let model_deco = self.gate.as_ref().map(|m| self.build_graph(m));
let clicked_repo;
match model_deco {
Some((model, deco)) if !model.nodes.is_empty() => {
// Legend.
ui.horizontal_wrapped(|ui| {
ui.label("legend:");
ui.colored_label(RED, "⛔ held / ✗ edition");
ui.colored_label(AMBER, "▼ behind · 🟡 dirty · ✂ suggested cut");
ui.colored_label(GREEN, "✅ clean");
ui.label("· click a repo → scope the release + light its blast radius");
});
let resp = graph_render::draw_graph(
ui,
&model,
&deco,
&mut self.view,
theme.bg,
theme.text,
theme.selection(),
theme.text_dim,
);
clicked_repo = resp.clicked_node;
}
_ => {
clicked_repo = None;
ui.add_space(12.0);
if !self.local {
ui.weak(
"the gate-overlaid graph is local-only — launch the viz over a workspace \
checkout so `release doctor` can scan it.",
);
} else if let Some(err) = &self.gate_error {
ui.colored_label(RED, format!("gate/doctor failed: {err}"));
} else {
ui.weak("(computing the gate model…)");
}
}
}
// A node click scopes the release to that repo (the blast radius is lit by
// the renderer's downstream highlight via `view.selected`, set in-place).
if let Some(repo) = clicked_repo {
self.scoped_repo = Some(repo);
}
// ── Node → history ("checkout button history") ────────────────────────
if let Some(repo) = self.scoped_repo.clone() {
ui.separator();
self.draw_history(ui, &repo, tl);
}
}
/// 🧙 Render the guided RELEASE WIZARD — the stepper + the active step's gate +
/// blockers + advance control. Orchestration UI only: it reads the SHARED gate
/// model and drives the pure [`WizardModel`] state machine; the slow/external
/// tiers (Rehearse/Promote) run on a background thread behind explicit buttons.
fn draw_wizard(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
let gate = self.gate.as_ref();
// Deferred intents (collected under the immutable `gate` borrow, acted on
// after it ends so the &mut-self spawns don't conflict with the borrow).
let mut want_rehearse = false;
let mut want_promote = false;
let mut want_apply_fixes = false;
ui.horizontal_wrapped(|ui| {
ui.heading("🧙 Release wizard");
ui.label(
RichText::new("each step is a gate that must be green to advance")
.color(theme.text_dim),
);
if ui.button("↺ restart").clicked() {
self.wizard.reset();
}
});
// ── The stepper: every tier badged by its gate status, current marked. ──
ui.horizontal_wrapped(|ui| {
for step in WizardStep::ORDER {
let st = self.wizard.gate_status(step, gate);
let (glyph, col) = match st {
GateStatus::Green => ("✅", GREEN),
GateStatus::Red => ("⛔", RED),
GateStatus::Pending => ("·", theme.text_dim),
};
let mut txt = RichText::new(format!("{glyph} {}", step.label())).color(col);
if step == self.wizard.current {
txt = txt.strong().underline();
}
ui.label(txt);
if step.next().is_some() {
ui.label(RichText::new("→").color(theme.text_dim));
}
}
});
ui.separator();
// ── The active step's body ──────────────────────────────────────────
let current = self.wizard.current;
match current {
WizardStep::Advise => {
ui.label("Advise — `release doctor`: dirty trees, skew, ⛔ transitive-pins, cycle cuts, the edition gate. Problems light up on the graph below.");
match gate {
Some(m) if m.gate_ok() => {
ui.colored_label(GREEN, "✅ advisory clean — nothing blocks a release");
}
Some(_) => {
ui.colored_label(
RED,
"⛔ blocking problems — see Fix (and the graph overlay)",
);
}
None => {
ui.colored_label(
theme.text_dim,
"· no gate model (remote / no checkout to scan)",
);
}
}
}
WizardStep::Fix => {
ui.label("Fix — apply the suggested moves, then re-run Advise until clear:");
let moves = WizardModel::fix_moves(gate);
if moves.is_empty() {
ui.colored_label(GREEN, "✅ nothing to fix");
} else {
for m in &moves {
ui.label(format!(" 💡 {m}"));
}
// The APPLY action (task #31): turn the skew advice into real
// version-bump edits via `apply_skew_bumps`, then re-run Advise
// so the gates re-evaluate. Local mode only (needs a checkout).
if self.local && !self.gate_repos.is_empty() {
if ui
.button("🔧 apply fixes (bump skew → target, re-advise)")
.on_hover_text(
"Applies the coordinated version bumps for every skewed crate, \
then recomputes the gate (release doctor) against the edits.",
)
.clicked()
{
want_apply_fixes = true;
}
} else {
ui.colored_label(
theme.text_dim,
"· apply needs a local checkout (remote: bump via the CLI)",
);
}
}
// The record of the last apply (what got bumped / any error).
let fp = self.wizard.fix_progress();
for line in &fp.lines {
ui.monospace(format!(" {line}"));
}
if let Some(e) = &fp.error {
ui.colored_label(RED, format!(" ⚠ {e}"));
} else if fp.done && fp.ok && !fp.lines.is_empty() {
ui.colored_label(GREEN, " ✅ fixes applied — Advise re-evaluated");
}
}
WizardStep::Rehearse => {
ui.label("Rehearse — `release stage --execute` into the embedded-holger /sparring registry (publish order + per-dependent build-verify).");
let p = self.wizard.rehearse_progress();
if p.running {
ui.colored_label(AMBER, "⏳ rehearsing into /sparring…");
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(500));
} else if ui.button("▶ run rehearsal (stage --execute)").clicked() {
want_rehearse = true;
}
for line in &p.lines {
ui.monospace(format!(" {line}"));
}
if let Some(e) = &p.error {
ui.colored_label(RED, format!(" ⚠ {e}"));
}
}
WizardStep::Promote => {
ui.colored_label(
RED,
"Promote — `release promote` to crates.io in topo order. IRREVERSIBLE.",
);
let phrase = self.wizard.promote_confirm_phrase.clone();
ui.horizontal_wrapped(|ui| {
ui.label(format!("type `{phrase}` to confirm:"));
ui.text_edit_singleline(&mut self.wizard.promote_confirm_input);
});
let p = self.wizard.promote_progress();
if p.running {
ui.colored_label(AMBER, "⏳ promoting to crates.io…");
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(500));
} else {
let can = self.wizard.can_run_promote(gate);
let btn =
egui::Button::new(RichText::new("🚀 PROMOTE to crates.io").color(if can {
RED
} else {
theme.text_dim
}));
if ui.add_enabled(can, btn).clicked() {
want_promote = true;
}
if !can {
ui.colored_label(
theme.text_dim,
"· rehearsal must be green + the confirm typed",
);
}
}
for line in &p.lines {
ui.monospace(format!(" {line}"));
}
if let Some(e) = &p.error {
ui.colored_label(RED, format!(" ⚠ {e}"));
}
}
}
// ── The gate + advance / override / back controls ─────────────────────
ui.separator();
let blockers = self.wizard.blockers(current, gate);
let can_advance = self.wizard.can_advance(current, gate);
ui.horizontal_wrapped(|ui| {
if self.wizard.current.index() > 0 && ui.button("← back").clicked() {
self.wizard.back();
}
if current.next().is_some() {
let next_label = current.next().map(|n| n.label()).unwrap_or("");
let btn = egui::Button::new(RichText::new(format!("advance → {next_label}")));
if ui.add_enabled(can_advance, btn).clicked() {
self.wizard.advance(gate);
}
if !can_advance {
// The explicit override — advance past a red/pending gate anyway.
let mut armed = self.wizard.override_armed(current);
if ui
.checkbox(&mut armed, "⚠ override (advance anyway)")
.changed()
{
self.wizard.set_override(current, armed);
}
}
}
});
if !blockers.is_empty() {
for b in &blockers {
ui.colored_label(RED, format!(" ⛔ {b}"));
}
}
// The `gate` immutable borrow ends here — now act on the deferred intents
// (the slow/external tiers run on a background thread, not this paint thread).
if want_apply_fixes {
self.apply_fixes();
}
if want_rehearse {
self.spawn_rehearsal();
}
if want_promote {
self.spawn_promote();
}
}
/// Fix step: report the doctor's skew advice as the exact version-bump EDITS it
/// implies — and hand the ACT to the forge.
///
/// This used to call `dwarves::release_doctor::cargo::apply_skew_bumps` in-process
/// and rewrite every affected `Cargo.toml` from nornir's paint loop. Rewriting a
/// manifest is a constructive operation, and under PRE-PROMPT §L5a those belong to
/// the forge; the warehouse owner hosts the PANE, not the edit. Doing it here also
/// meant nornir had to path-dep `dwarves`, which made `nornir -> dwarves -> nornir`
/// a cargo cycle and is why the forge could never compile nornir in.
///
/// So the computation stays (it is a pure read over the already-loaded
/// [`CrateSkew`](nornir_release_core::model::CrateSkew) — same numbers, same repos,
/// same targets) and the write leaves. The operator gets the concrete plan plus the
/// one verb that performs it. Nothing is hidden and nothing is silently skipped:
/// `state_json["fix_applied"].ok` is FALSE, because nothing was applied.
fn apply_fixes(&mut self) {
let Some(skew) = self.gate.as_ref().map(|m| m.doctor.skew.clone()) else {
self.wizard.record_fix_applied(
false,
Vec::new(),
Some("no gate model loaded (remote / no checkout)".into()),
);
return;
};
// The PLAN, read straight off the skew PODs: which crate, to what version, in
// which repos. `bump_repos()` is the same hint set the engine iterates.
let known: std::collections::BTreeSet<&str> =
self.gate_repos.iter().map(|(n, _)| n.as_str()).collect();
let mut lines: Vec<String> = Vec::new();
for c in &skew {
for repo in c.bump_repos() {
if !known.contains(repo) {
continue;
}
lines.push(format!(
"would bump {} → {} in {repo}",
c.crate_name, c.target
));
}
}
if lines.is_empty() {
self.wizard.record_fix_applied(
false,
vec!["no skew bumps pending (nothing behind on disk)".into()],
None,
);
return;
}
let ws = self
.workspace_root()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<workspace>".into());
lines.push(String::new());
lines.push(format!("run: dwarves release doctor --fix {ws}"));
self.wizard.record_fix_applied(
false,
lines,
Some(
"the manifest edits are the forge's to make (L5a) — nornir shows the plan, \
`dwarves release doctor --fix` writes it"
.into(),
),
);
}
/// The workspace root the stage rehearsal writes its `.nornir/stage` data dir
/// under — the parent of the repo checkouts (`root/<repo>` ← `gate_repos`).
fn workspace_root(&self) -> Option<PathBuf> {
self.gate_repos
.first()
.and_then(|(_, p)| p.parent().map(|p| p.to_path_buf()))
}
/// Spawn the Rehearse tier on a BACKGROUND THREAD (never the paint thread):
/// build the stage plan from the gathered repo graphs + `release::stage::execute`
/// into a fresh embedded-holger /sparring registry, streaming the outcome into
/// the wizard's job handle. No-op when there's no local checkout to stage.
fn spawn_rehearsal(&mut self) {
let handle = self.wizard.rehearse_handle();
{
let mut g = handle.lock().unwrap();
*g = super::release_wizard::JobProgress {
running: true,
..Default::default()
};
}
let repos = self.gate_repos.clone();
let Some(ws_root) = self.workspace_root() else {
let mut g = handle.lock().unwrap();
g.running = false;
g.done = true;
g.ok = false;
g.error = Some("no local checkout to rehearse (remote / no workspace root)".into());
return;
};
std::thread::spawn(move || {
let result = (|| -> anyhow::Result<crate::release::stage::StageOutcome> {
let graphs = repos
.iter()
.map(|(name, path)| crate::release::doctor::gather_repo_graph(name, path))
.collect::<anyhow::Result<Vec<_>>>()?;
let http = "127.0.0.1:18464";
let grpc = "127.0.0.1:18465";
let registry = format!("sparse+http://{http}/sparring/index/");
let plan = crate::release::stage::plan_stage(&graphs, ®istry, "release/wizard");
if !plan.is_executable() {
anyhow::bail!(
"plan not executable (dependency cycle: {})",
plan.cycle.join(", ")
);
}
let data_dir = ws_root.join(".nornir/stage");
let repo_paths: std::collections::BTreeMap<String, PathBuf> =
repos.iter().cloned().collect();
let holger_bin = PathBuf::from("holger-server");
// The viz wizard rehearses over gate_repos with no repo-config in scope,
// so no private set is available here — pass empty (skip nothing), same
// as before the #3 private-skip param was added to the CLI path.
let private = std::collections::BTreeSet::new();
crate::release::stage::execute(
&plan,
&repo_paths,
&private,
&holger_bin,
&data_dir,
grpc,
http,
)
})();
let mut g = handle.lock().unwrap();
g.running = false;
g.done = true;
match result {
Ok(out) => {
g.ok = out.errors.is_empty();
// Shared renderer (`StageOutcome::report_lines`) — this used
// to be hand-rolled here and dropped `already_staged`, so a
// rehearsal re-run with no version bump showed an empty log.
g.lines.extend(out.report_lines());
if !g.ok {
g.error = Some(format!("{} step(s) failed", out.errors.len()));
}
}
Err(e) => {
g.ok = false;
g.error = Some(format!("{e:#}"));
}
}
});
}
/// Promote tier: run the patch-fork PROMOTE GATE and report its verdict — and hand
/// the crates.io publish itself to the forge.
///
/// This used to `dwarves::release_doctor::publish::publish_all` from a background
/// thread inside the viz: nornir uploading crates to crates.io in-process. Publishing
/// is the most constructive operation there is, and under PRE-PROMPT §L5a it belongs
/// to the forge; the warehouse owner hosts the PANE, not the upload. It was also one
/// of the call sites forcing nornir's `dwarves` path-dep, which made
/// `nornir -> dwarves -> nornir` a cargo cycle and blocked the fat forge entirely.
///
/// What survives is the whole JUDGEMENT, unchanged and still on a background thread:
/// gather the repo + its workspace siblings, classify `[patch.crates-io]` forks
/// foreign-vs-own, and name every crate that transitively rides a foreign fork and
/// therefore may not ship. That is a filesystem read
/// ([`compute_promote_block`](nornir_release_core::model::compute_promote_block)) and
/// it is exactly what an operator needs before promoting. Then it names the verb.
///
/// `ok` is TRUE when the gate passes — the gate really did run and really did pass;
/// the job body says plainly that nothing was published.
fn spawn_promote(&mut self) {
let handle = self.wizard.promote_handle();
{
let mut g = handle.lock().unwrap();
*g = super::release_wizard::JobProgress {
running: true,
..Default::default()
};
}
// Promote the FIRST repo's workspace (the scope) — the topo order within it
// is derived by the publish engine. Scoped repo takes precedence.
let ws_root = self.workspace_root();
let scope = self.scoped_repo.clone();
let target = scope
.as_ref()
.and_then(|r| self.gate_repos.iter().find(|(n, _)| n == r))
.or_else(|| self.gate_repos.first())
.map(|(_, p)| p.clone());
let Some(repo_root) = target else {
let mut g = handle.lock().unwrap();
g.running = false;
g.done = true;
g.ok = false;
g.error = Some("no local checkout to promote".into());
return;
};
std::thread::spawn(move || {
// CRATE-precise PROMOTE GATE (task #32): gather the repo + its workspace
// siblings, classify `[patch.crates-io]` forks foreign-vs-own, and hold every
// crate that transitively needs a FOREIGN fork. Publishing strips the patch,
// so a held crate would ship against a stock dep it cannot build with — the
// arrow-58 `[patch] iceberg = { path = "../iceberg-arrow58" }` trap. Own-crate
// dev overrides stay publishable. Filesystem-only; no network, no mutation.
let mut repos: Vec<(String, std::path::PathBuf)> =
vec![("repo".to_string(), repo_root.clone())];
if let Some(ws) = &ws_root {
if let Ok(entries) = std::fs::read_dir(ws) {
for e in entries.flatten() {
let p = e.path();
if p.is_dir() && p != repo_root && p.join("Cargo.toml").exists() {
let n = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
repos.push((n, p));
}
}
}
}
let block = crate::release::doctor::compute_promote_block(
repos.iter().map(|(n, p)| (n.clone(), p.as_path())),
);
let direct_for_report: Vec<crate::release::doctor::PatchForkBlock> = block
.forks
.iter()
.filter(|b| b.is_foreign_fork)
.cloned()
.collect();
let mut g = handle.lock().unwrap();
g.running = false;
g.done = true;
for b in &direct_for_report {
g.lines.push(format!(
"⛔ promote-blocked (must be held from crates.io): {} rides {} → {} fork",
b.crate_name, b.patched_dep, b.source
));
}
for held in &block.blocked {
g.lines.push(format!(" held transitively: {held}"));
}
if direct_for_report.is_empty() && block.blocked.is_empty() {
g.lines
.push("promote gate: clean — no crate rides a foreign fork".into());
}
// The gate ran and rendered its verdict; the UPLOAD is the forge's.
g.ok = direct_for_report.is_empty() && block.blocked.is_empty();
g.lines.push(String::new());
g.lines
.push("nothing was published — the crates.io upload is the forge's (L5a)".into());
g.lines.push(format!(
"run: dwarves workspace-release {} --execute",
repo_root.display()
));
});
}
/// The clicked node's git/release history — the per-repo lane from the app's
/// loaded [`Timeline`] (sha · gate · published versions · when). REUSES the
/// timeline the viz already has rather than re-querying release_lineage.
fn draw_history(&self, ui: &mut egui::Ui, repo: &str, tl: Option<&Timeline>) {
let theme = self.theme;
ui.collapsing(
RichText::new(format!("🕘 {repo} — release history")).strong().color(theme.accent),
|ui| {
let lane = tl.and_then(|t| t.lanes.iter().find(|l| l.repo == repo));
match lane {
Some(lane) if !lane.nodes.is_empty() => {
ScrollArea::vertical().max_height(220.0).auto_shrink([false, false]).show(ui, |ui| {
// Newest first.
for n in lane.nodes.iter().rev() {
let (glyph, col) = match n.gate_status.as_str() {
"pass" | "passed" | "ok" => ("✓", GREEN),
"fail" | "failed" => ("✗", RED),
_ => ("·", theme.text_dim),
};
let sha = &n.sha[..n.sha.len().min(12)];
let when = n.timestamp.format("%Y-%m-%d %H:%M").to_string();
let pubs = if n.published_versions.is_empty() {
String::new()
} else {
let v: Vec<String> = n
.published_versions
.iter()
.map(|(c, ver)| format!("{c} {ver}"))
.collect();
format!(" 📦 {}", v.join(", "))
};
ui.horizontal_wrapped(|ui| {
ui.colored_label(col, glyph);
ui.monospace(sha);
ui.colored_label(theme.text_dim, when);
if n.dirty {
ui.colored_label(AMBER, "🟡 dirty");
}
if !pubs.is_empty() {
ui.label(pubs);
}
});
}
});
}
_ => {
ui.weak("no releases recorded for this repo yet — its history fills as `dwarves release` records to the warehouse.");
}
}
},
);
}
/// The dashboard's slice of the app `state_json` (LAW #6) — present flag,
/// button-enabled (the gate verdict), the scoped repo, the SHARED gate block,
/// and the graph's node badges + the suggested cut edges. So the robot test
/// asserts the gate overlay + button state WITHOUT pixels.
pub fn state_json(&self) -> serde_json::Value {
let gate = match &self.gate {
Some(m) => m.gate_json(self.gate_error.as_deref()),
None => super::gate::gate_json_absent(self.gate_error.as_deref()),
};
let gate_ok = self.gate.as_ref().map(|m| m.gate_ok()).unwrap_or(true);
// The graph's node badges + the cut edges, as the overlay the user sees.
let (node_badges, cut_edges) = match &self.gate {
Some(m) => {
let (_model, deco) = self.build_graph(m);
let badges: Vec<serde_json::Value> = self
.repo_nodes(m)
.iter()
.filter_map(|r| {
deco.nodes.get(r).map(|d| {
serde_json::json!({
"repo": r,
"badge": d.badge,
"ring": d.ring.map(|c| format!("#{:02X}{:02X}{:02X}", c.r(), c.g(), c.b())),
})
})
})
.collect();
let cuts: Vec<serde_json::Value> = deco
.edges
.iter()
.map(|e| serde_json::json!({ "from": e.from, "to": e.to, "label": e.label }))
.collect();
(badges, cuts)
}
None => (Vec::new(), Vec::new()),
};
// The blast radius lit by the current selection (downstream subtree).
let blast: Vec<String> = match (&self.gate, &self.view.selected) {
(Some(m), Some(sel)) => {
let (model, _deco) = self.build_graph(m);
graph_render::downstream_of(&model, sel)
.into_iter()
.collect()
}
_ => Vec::new(),
};
serde_json::json!({
"present": self.gate.is_some(),
"local": self.local,
// The Release button: enabled ⇔ the gate is OK (or remote/no-checkout,
// where the heavy run lives in the CLI). RED + disabled on a no-go.
"button_enabled": gate_ok || !self.local,
"gate_ok": gate_ok,
"scoped_repo": self.scoped_repo,
"selected": self.view.selected,
// The blast radius the selection lit (downstream repos to re-validate).
"blast_radius": blast,
// The SHARED gate block (the same shape the 🚀 Release pane ships).
"gate": gate,
// The graph overlay the user sees, as DATA: per-repo badges + rings.
"node_badges": node_badges,
// Each dependency cycle's suggested cut edge + rationale (dashed line).
"cut_edges": cut_edges,
"last_run": self.last_run,
// 🧙 The guided RELEASE WIZARD (task #16): whether it's open + its step
// state machine (current step, per-step gate status / can_advance /
// blockers / override-armed, the promote typed-confirm, the suggested
// fix moves, and the async tier job progress). So the robot test reads
// the step + gating back as data — no pixels.
"release_wizard": {
"open": self.wizard_open,
"machine": self.wizard.state_json(self.gate.as_ref()),
},
"palette": self.theme.name,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release::doctor::{
CrateSkew, CycleAdvice, DoctorReport, RepoCrateStatus, RepoDirty, RepoEdge, SkewStatus,
TopoReport,
};
use nornir_release_core::edition::{EditionFinding, EditionReport};
fn gate() -> (DoctorReport, EditionReport) {
let doctor = DoctorReport {
dirty: vec![RepoDirty {
repo: "nornir".into(),
dirty: true,
error: None,
}],
skew: vec![CrateSkew {
crate_name: "arrow".into(),
target: "58.3.0".into(),
diverged: true,
entries: vec![
RepoCrateStatus {
repo: "znippy".into(),
version: "58.3.0".into(),
status: SkewStatus::Ok,
held_by_transitive_pin: false,
},
RepoCrateStatus {
repo: "nornir".into(),
version: "57".into(),
status: SkewStatus::Behind,
held_by_transitive_pin: true,
},
],
}],
topo: TopoReport {
order: vec!["znippy".into(), "nornir".into()],
cycle: vec![],
},
blast: Default::default(),
cycle_advice: vec![CycleAdvice {
members: vec!["nornir".into(), "znippy".into()],
cut_from: "nornir".into(),
cut_to: "znippy".into(),
via: vec!["znippy-common".into()],
rationale: "extract znippy-common into a leaf crate".into(),
}],
repo_edges: vec![RepoEdge {
from: "nornir".into(),
to: "znippy".into(),
via: vec!["znippy-common".into()],
}],
patch_forks: vec![],
promote_blocked: vec![],
..Default::default()
};
let edition = EditionReport {
static_findings: vec![EditionFinding {
package: "znippy".into(),
issue: "edition 2021 — want 2024".into(),
}],
lints: vec![],
lint_pass_ran: false,
msrv_advisories: vec![],
msrv_verify_errors: vec![],
docs_errors: vec![],
};
(doctor, edition)
}
#[test]
fn dashboard_state_reflects_gate_overlay_and_button() {
let mut d = NornirDashboard::default();
let (doctor, edition) = gate();
d.inject_gate_for_test(doctor, edition);
let js = d.state_json();
assert_eq!(js["present"], true);
// edition NOT clean + nornir held back ⇒ no-go ⇒ button disabled (local).
assert_eq!(js["gate_ok"], false);
assert_eq!(
js["button_enabled"], false,
"a failing gate disables the Release button"
);
assert_eq!(js["gate"]["skew_count"], 1);
assert_eq!(js["gate"]["held_back"][0], "nornir");
// The graph overlay: nornir badged ⛔ (held); znippy badged ✗ (edition,
// scoped to topo-first = znippy).
let badges = js["node_badges"].as_array().unwrap();
let nornir = badges.iter().find(|b| b["repo"] == "nornir").unwrap();
assert!(
nornir["badge"].as_str().unwrap().contains('⛔'),
"nornir held badge"
);
let znippy = badges.iter().find(|b| b["repo"] == "znippy").unwrap();
assert!(
znippy["badge"].as_str().unwrap().contains('✗'),
"edition fail badge"
);
// The cycle's suggested cut is a dashed overlay edge with its rationale.
let cuts = js["cut_edges"].as_array().unwrap();
assert_eq!(cuts.len(), 1);
assert_eq!(cuts[0]["from"], "nornir");
assert_eq!(cuts[0]["to"], "znippy");
assert!(cuts[0]["label"].as_str().unwrap().contains("cut"));
}
#[test]
fn clicking_a_node_scopes_release_and_lights_blast_radius() {
let mut d = NornirDashboard::default();
let (doctor, edition) = gate();
d.inject_gate_for_test(doctor, edition);
// Click nornir → scope to nornir, light its downstream (nornir → znippy).
assert!(d.select_repo_for_test("nornir"));
let js = d.state_json();
assert_eq!(js["scoped_repo"], "nornir");
assert_eq!(js["selected"], "nornir");
let blast: Vec<String> = js["blast_radius"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(blast.contains(&"nornir".to_string()));
assert!(
blast.contains(&"znippy".to_string()),
"downstream blast lit: {blast:?}"
);
}
#[test]
fn fix_step_reports_the_bump_plan_and_hands_the_edit_to_the_forge() {
// A real two-repo checkout with an arrow skew: `ahead` at 58.3.0, `behind`
// at 57. The doctor surfaces the skew (skew_count = 1), and the Fix step
// reports the exact edit that would close it — WITHOUT making it.
//
// This test used to assert the opposite: that Fix wrote `arrow = "58.3.0"`
// into `behind/Cargo.toml` via `dwarves::release_doctor::cargo::apply_skew_bumps`,
// from nornir's paint loop. Rewriting a manifest is a constructive operation
// and belongs to the forge (PRE-PROMPT §L5a) — the warehouse owner hosts the
// pane, not the edit. It also meant nornir path-dep'd `dwarves`, which made
// `nornir -> dwarves -> nornir` a cargo cycle and is why the fat forge could
// never compile nornir in.
//
// So the contract inverted, and the test with it. RED if the plan stops
// naming the concrete crate/target/repo (the operator would be told "run
// dwarves" with no idea what it would do), if the hand-off verb disappears
// (a dead end instead of a route), or — most importantly — if the write
// comes back (`ok` must stay FALSE, and the file must be byte-identical).
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (name, ver) in [("ahead", "58.3.0"), ("behind", "57")] {
let dir = root.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\narrow = \"{ver}\"\n"
),
)
.unwrap();
}
let mut d = NornirDashboard::default();
d.set_gate_inputs(
root,
&["ahead".into(), "behind".into()],
DepPolicy::default(),
);
d.ensure_gate();
// The doctor surfaces the arrow skew across the two repos.
let js = d.state_json();
assert_eq!(
js["gate"]["skew_count"], 1,
"arrow skew detected: {}",
js["gate"]
);
let before = std::fs::read_to_string(root.join("behind/Cargo.toml")).unwrap();
// Drive the Fix step.
let lines = d.wizard_apply_fixes_for_test();
// (1) the PLAN is concrete: which crate, to what version, in which repo.
assert!(
lines
.iter()
.any(|l| l.contains("arrow") && l.contains("58.3.0") && l.contains("behind")),
"the plan must name arrow → 58.3.0 in behind: {lines:?}"
);
// (2) the HAND-OFF names the verb that performs it — a route, not a dead end.
assert!(
lines
.iter()
.any(|l| l.contains("dwarves release doctor --fix")),
"the plan must route the operator to the forge: {lines:?}"
);
// (3) NOTHING WAS WRITTEN. This is the assertion that keeps the constructive
// operation on the forge's side of the line; if it ever goes green-by-writing
// again, the crate edge is on its way back.
let after = std::fs::read_to_string(root.join("behind/Cargo.toml")).unwrap();
assert_eq!(
after, before,
"Fix must not edit a manifest — that is the forge's"
);
let js = d.state_json();
assert_eq!(
js["gate"]["skew_count"], 1,
"the skew is still open — nothing was fixed"
);
let machine = &js["release_wizard"]["machine"];
assert_eq!(machine["fix_applied"]["done"], true, "the step ran");
assert_eq!(
machine["fix_applied"]["ok"], false,
"…and honestly reports that it applied nothing (RAGNARÖK: a no-op is never green)",
);
}
#[test]
fn clean_workspace_is_go_and_button_enabled() {
let mut d = NornirDashboard::default();
let doctor = DoctorReport {
dirty: vec![RepoDirty {
repo: "a".into(),
dirty: false,
error: None,
}],
skew: vec![],
topo: TopoReport {
order: vec!["a".into()],
cycle: vec![],
},
blast: Default::default(),
cycle_advice: vec![],
repo_edges: vec![],
patch_forks: vec![],
promote_blocked: vec![],
..Default::default()
};
let edition = EditionReport {
static_findings: vec![],
lints: vec![],
lint_pass_ran: false,
msrv_advisories: vec![],
msrv_verify_errors: vec![],
docs_errors: vec![],
};
d.inject_gate_for_test(doctor, edition);
let js = d.state_json();
assert_eq!(js["gate_ok"], true);
assert_eq!(js["button_enabled"], true);
}
}