src/viz/release_tab.rs
//! 🚀 **Release** tab — the release-op DAG (`release_events`) as a viz surface.
//!
//! Two panes over the same data, both driven by the warehouse
//! [`release_events`](crate::warehouse::release_events) table:
//!
//! 1. **Op-log** (B3) — a streaming worklog, one line per
//! `component × op × phase` boundary, prefixed by the component it acts on:
//! `[znippy] test start`, `[znippy] test end ok (1.2s)`, `[holger] gate end ok`.
//! Grouped by `run_id`, **newest run on top**, `seq`-ordered within a run.
//! 2. **Lit-up dep-graph** (B4) — the rectangular dep-graph (reusing
//! [`super::graph`]'s node/edge geometry) where each node is **coloured by
//! its latest `release_events` status for the selected run**: running = amber
//! pulse, ok = green, fail = red, idle = dim. As a run progresses (the table
//! gains rows), the graph lights up.
//!
//! Read path: **local** mode opens the warehouse read-only and calls
//! [`query_release_events`]. **Remote** mode reads the SAME rows over the
//! `Viz.ReleaseEvents` gRPC (the warehouse lives on the server, which holds the
//! redb lock), decoding them into the identical [`ReleaseEventRow`] — so the
//! op-log + lit-up dep-graph render byte-identically whether the viz is embedded
//! or thin. This is the same local-or-remote split the other tabs use.
use std::collections::{BTreeMap};
use std::path::PathBuf;
use std::time::Instant;
use eframe::egui::{
self, Align2, Color32, CornerRadius, FontId, Pos2, Rect, RichText, ScrollArea, Sense, Stroke,
Vec2,
};
use crate::release::doctor::{self, DepPolicy, DoctorReport};
use crate::warehouse::iceberg::IcebergWarehouse;
use crate::warehouse::release_events::{
EventSelector, ReleaseEventRow, query_release_events, status,
};
use nornir_release_core::edition::{self, EditionReport};
use super::facett_theme::{AMBER, GREEN, RED, Theme};
/// Where the release events come from (mirrors the other tabs' local/remote split).
enum Src {
Local(PathBuf),
/// Thin mode — read `release_events` over the `Viz.ReleaseEvents` RPC. The
/// `workspace` selects which served workspace's events (the gRPC header).
Remote {
endpoint: String,
token: String,
workspace: String,
},
}
/// One "run" — the rows of a single `run_id`, newest run on top.
#[derive(Clone)]
struct RunGroup {
run_id: String,
/// `seq`-ordered rows for this run.
rows: Vec<ReleaseEventRow>,
/// Latest `ts_micros` in the run — used to sort runs newest-first.
latest_ts: i64,
}
// The cached **Gate / Doctor** model is the SHARED [`super::gate::GateModel`] —
// the same computation + `gate_json` shape the 🧬 nornir RELEASE DASHBOARD reuses.
use super::gate::GateModel;
pub struct ReleaseTabState {
src: Src,
loaded: bool,
error: Option<String>,
/// Inputs for the **Gate / Doctor** section: the workspace checkout root and
/// the repos to analyze. Only LOCAL mode has a checkout to scan; remote mode
/// leaves this empty (the gate section then shows a "local-only" note).
gate_repos: Vec<(String, PathBuf)>,
/// Forbidden-version policy fed to the doctor's skew analysis (e.g. `arrow 56`).
gate_policy: DepPolicy,
/// Cached gate/doctor model — computed in `load()`/`reload()`, NOT per repaint.
gate: Option<GateModel>,
/// Set when computing the gate model failed (rendered in the section).
gate_error: Option<String>,
/// Whether the opt-in dynamic edition lint pass (`cargo check`, multi-second)
/// has been requested via the button; folded into the next gate recompute.
edition_lint_requested: bool,
/// All runs found, newest first.
runs: Vec<RunGroup>,
/// The selected run_id (the graph + op-log scope). Defaults to newest.
selected_run: Option<String>,
/// Pulse phase for the amber "running" glow (advances every frame).
started_at: Instant,
/// Active palette — every colour below derives from it so a palette switch
/// re-skins the whole pane.
theme: Theme,
/// Two-step confirm latch for the HEAVY release-grade operation (runs the
/// full release gate across the build order server-side — release-grade).
run_armed: bool,
/// Last `Ops.RunRelease` result (rendered + reported in state_json).
run_result: Option<Result<super::remote::OpRunResult, String>>,
/// The **crate publish-DAG** pane (facett-graphview): nodes = crates, edges =
/// depends-on, node colour = registry state, wave badge, cycle ring. Built in
/// `load()` (local mode only — needs a workspace checkout to scan).
depdag: super::release_depdag::ReleaseDepDagPane,
/// The assembled dep-DAG model, cached so the transitive/probe toggles rebuild
/// the pane without re-scanning the workspace.
depdag_model: Option<nornir_release_core::depdag::ReleaseDepDag>,
/// Registry probe results (crate → max published version), filled on demand by
/// the "⬇ probe registry" button. Empty ⇒ states show `unknown` (offline).
depdag_published: BTreeMap<String, Option<String>>,
/// Whether the DEEP (symbol-derived) edge set has been loaded from the
/// warehouse yet (lazy — only on the first THIN→DEEP flip, since it reads the
/// warehouse's `call_edges`). Reset with the model.
depdag_deep_loaded: bool,
}
impl ReleaseTabState {
pub fn local(root: PathBuf) -> Self {
Self::with(Src::Local(root))
}
pub fn remote(endpoint: String, token: String, workspace: String) -> Self {
Self::with(Src::Remote {
endpoint,
token,
workspace,
})
}
/// Re-point the thin-mode RPC at a new workspace (the picker switched). No-op
/// when local; drops the cache so the next draw refetches.
pub fn set_workspace(&mut self, workspace: String) {
if let Src::Remote { workspace: w, .. } = &mut self.src {
*w = workspace;
}
self.reload();
}
/// Wire the **Gate / Doctor** section's inputs: the workspace root + repo names
/// (local mode only). Each repo's checkout is `root/<repo>`; repos without a
/// `Cargo.toml` are skipped. The forbidden-version policy mirrors the CLI's
/// `--forbid crate=version` (e.g. `arrow 56`). Drops any cached model so the
/// next `load()` recomputes.
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.gate_policy = policy;
self.gate = None;
self.gate_error = None;
self.loaded = false;
}
fn with(src: Src) -> Self {
Self {
src,
loaded: false,
error: None,
gate_repos: Vec::new(),
gate_policy: DepPolicy::default(),
gate: None,
gate_error: None,
edition_lint_requested: false,
runs: Vec::new(),
selected_run: None,
started_at: Instant::now(),
theme: Theme::default(),
run_armed: false,
run_result: None,
depdag: super::release_depdag::ReleaseDepDagPane::default(),
depdag_model: None,
depdag_published: BTreeMap::new(),
depdag_deep_loaded: false,
}
}
/// Assemble the crate publish-DAG from the gate repos (local mode only) + build
/// the facett pane. Offline: local versions parsed from manifests, registry
/// states filled only after an explicit probe (`depdag_published`). Cheap enough
/// to run once per load; gated on a non-empty checkout set.
fn compute_depdag(&mut self) {
if self.gate_repos.is_empty() {
return;
}
let graphs: Vec<crate::release::doctor::RepoGraph> = self
.gate_repos
.iter()
.filter_map(|(n, p)| crate::release::doctor::gather_repo_graph(n, p).ok())
.collect();
if graphs.is_empty() {
return;
}
let local = crate::release::doctor::local_crate_versions(&self.gate_repos);
let dag = nornir_release_core::depdag::ReleaseDepDag::assemble(
&graphs,
&local,
&self.depdag_published,
);
self.depdag.build(&dag);
self.depdag_model = Some(dag);
}
/// Probe the registry for every crate's max published version (best-effort,
/// network), then reassemble the dep-DAG so nodes light up will-publish / desync
/// / already-published / unpublished. The default crates.io sparse-index backend.
fn probe_registry(&mut self) {
let Some(model) = &self.depdag_model else {
return;
};
let backend = nornir_release_core::registry::CratesIoBackend;
use nornir_release_core::registry::RegistryBackend as _;
let mut published: BTreeMap<String, Option<String>> = BTreeMap::new();
for n in &model.nodes {
published.insert(n.name.clone(), backend.published_max_version(&n.name));
}
self.depdag_published = published;
self.compute_depdag();
}
/// Load the **DEEP** (symbol-derived) crate→crate edge set from the code
/// room and hand it to the pane: the reader's `Viz.Knowledge` answer carries
/// the local crate → local crate edges (CALLS ∪ USES_TYPE at item level, with
/// a few of the names that justify each) — over gRPC in thin mode, from the
/// workspace's own code room in fat mode. Lazy (on the first THIN→DEEP flip)
/// and once (guarded by `depdag_deep_loaded`). A reader that cannot answer
/// marks the set UNAVAILABLE with its reason — never "no deep edges".
fn compute_deep_edges(&mut self) {
if self.depdag_deep_loaded {
return;
}
self.depdag_deep_loaded = true;
let answer = match &self.src {
Src::Local(root) => super::remote::local_surface(root, "", "Viz.Knowledge", &serde_json::json!({ "workspace": "" })),
Src::Remote { endpoint, token, workspace } => super::remote::fetch_knowledge(endpoint, token, workspace),
};
match answer.map_err(|e| format!("{e:#}")).and_then(|v| deep_edges_from_answer(&v)) {
Ok(edges) => self.depdag.set_deep_edges(edges),
Err(why) => self.depdag.mark_deep_unavailable(why),
}
}
/// The 🚀 Release pane's operation strip: **Release (heavy gate)** — the
/// full release-grade gate across the build order. HEAVY: confirm + a
/// "long-running / release-grade" warning before firing `Ops.RunRelease`,
/// which persists `release_events` so this very pane lights up. Remote-only.
/// It runs each repo's CONFIGURED `[gates]` suite — the same suite
/// `Release.GateAll` (the 🚦 pane) runs, via `server_grpc::run_gate_suite`.
/// There is no CLI twin: the release verbs moved to `dwarves release`.
fn draw_release_op(&mut self, ui: &mut egui::Ui) {
let Src::Remote {
endpoint,
token,
workspace,
} = &self.src
else {
ui.label(
egui::RichText::new("release via `nornir release` CLI (local mode)")
.color(self.theme.text_dim),
);
return;
};
let (endpoint, token, workspace) = (endpoint.clone(), token.clone(), workspace.clone());
if !self.run_armed {
if ui
.button("🚀 Release (heavy gate)")
.on_hover_text("HEAVY / release-grade — runs each repo's CONFIGURED [gates] suite across the build order server-side (Ops.RunRelease), the same suite the 🚦 Release-gates pane runs")
.clicked()
{
self.run_armed = true;
}
} else {
ui.colored_label(AMBER, "⚠ heavy / release-grade — confirm?");
if ui.button("✓ run release gate").clicked() {
self.run_result = Some(
super::remote::run_release(&endpoint, &token, "", &workspace)
.map_err(|e| format!("{e:#}")),
);
self.run_armed = false;
self.reload();
}
if ui.button("✕ cancel").clicked() {
self.run_armed = false;
}
}
match &self.run_result {
Some(Ok(r)) => {
ui.colored_label(if r.ok { GREEN } else { RED }, &r.summary);
}
Some(Err(e)) => {
ui.colored_label(RED, e);
}
None => {}
}
}
/// Swap the active palette so the next draw re-skins every colour.
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// Test-only: inject release-op rows directly (no warehouse on disk), marking
/// the tab loaded so `draw`/`state_json` render the injected DAG. Mirrors
/// the app's `inject_timeline_for_test` for this surface.
#[doc(hidden)]
pub fn inject_for_test(&mut self, rows: Vec<ReleaseEventRow>) {
self.runs = group_runs(rows);
self.selected_run = self.runs.first().map(|r| r.run_id.clone());
self.loaded = true;
self.error = None;
}
/// Test-only: inject a Gate / Doctor model directly (no checkout to scan), so
/// the robot/inject-assert harness can read the `"gate"` block out of
/// `state_json()`. Mirrors `inject_for_test` for the gate surface. Marks the
/// tab loaded and emits the trace, exactly as `compute_gate` would.
#[doc(hidden)]
pub fn inject_gate_for_test(&mut self, doctor: DoctorReport, edition: EditionReport) {
// Mark configured so `draw_gate_section` renders the model (not the
// local-only note); use a sentinel repo entry the doctor would have filled.
if self.gate_repos.is_empty() {
self.gate_repos.push(("nornir".into(), PathBuf::from(".")));
}
self.gate = Some(GateModel::new(doctor, edition));
self.gate_error = None;
self.loaded = true;
self.emit_trace();
}
/// Test hook: assemble + build the crate publish-DAG pane from the configured
/// gate repos (no draw frame), so the inject-and-assert harness can read the
/// `"depdag"` block out of `state_json()`. Returns the pane's node count.
#[doc(hidden)]
pub fn build_depdag_for_test(&mut self) -> usize {
self.compute_depdag();
self.depdag.node_count()
}
/// Test hook: do what the "deep" toggle does WITHOUT a warehouse — inject the
/// DEEP edge set directly, flip the pane into DEEP mode, and rebuild, so a
/// headless test can assert the `"depdag"` block reports `mode == "deep"` over
/// the injected symbol edges. Requires a prior `build_depdag_for_test`.
#[doc(hidden)]
pub fn toggle_deep_for_test(
&mut self,
deep_edges: Vec<nornir_release_core::depdag::CrateDepEdge>,
) {
self.depdag.set_deep_edges(deep_edges);
self.depdag.toggle_mode();
if let Some(m) = self.depdag_model.clone() {
self.depdag.build(&m);
}
}
/// Re-scope (workspace switch / reload): drop the cache so the next draw reloads.
pub fn reload(&mut self) {
self.loaded = false;
self.error = None;
self.runs.clear();
self.selected_run = None;
self.gate = None;
self.gate_error = None;
// Preserve any registry probe across a reload, but drop the built pane so the
// next load() reassembles from a fresh workspace scan.
self.depdag = super::release_depdag::ReleaseDepDagPane::default();
self.depdag_model = None;
self.depdag_deep_loaded = false;
}
/// Compute (and cache) the **Gate / Doctor** model: the doctor advisory + the
/// edition gate. Runs ONCE per load/reload (not per repaint). The edition
/// dynamic lint pass (`cargo check`) only runs when `edition_lint_requested`
/// — otherwise we skip it and use only the cheap `static_findings`, per the
/// perf caveat. Local mode only (remote has no checkout to scan).
fn compute_gate(&mut self) {
if self.gate.is_some() || self.gate_repos.is_empty() {
return;
}
// The dynamic `cargo check` edition lint pass is opt-in — the always-on
// view uses static-only. The SHARED gate model does the same computation
// the 🧬 dashboard reuses.
match GateModel::compute(
&self.gate_repos,
&self.gate_policy,
self.edition_lint_requested,
) {
Ok(m) => {
self.gate = Some(m);
self.gate_error = None;
self.emit_trace();
}
Err(e) => self.gate_error = Some(e),
}
}
/// Emit the gate/doctor model as a machine-readable trace (the twin of what the
/// pane paints), and self-report a `functional_status` row so a pane that can't
/// build its model becomes a RED matrix row. No-op for trace when unconfigured.
fn emit_trace(&self) {
let model = self.gate_json();
super::trace::emit_out("release.gate.build", &model);
super::trace::emit_end(
"release.gate",
&serde_json::json!({
"skew_count": model["skew_count"],
"held_back": model["held_back"],
"cycle_advice": model["cycle_advice"],
"edition_clean": model["edition_clean"],
}),
);
#[cfg(feature = "testmatrix")]
{
// ok ⇔ the pane built a gate model (or had no inputs to build one) AND
// did not error. A broken model build → RED row.
let built = self.gate.is_some() || self.gate_repos.is_empty();
crate::selftest::emit(
"viz/release (gate/doctor)",
"release_gate_model_built",
built && self.gate_error.is_none(),
&format!(
"skew={} held_back={} cycle_advice={} edition_clean={} error={:?}",
model["skew_count"],
model["held_back"].as_array().map(|a| a.len()).unwrap_or(0),
model["cycle_advice"],
model["edition_clean"],
self.gate_error,
),
);
}
}
/// Read every release event from the warehouse and group by run (local only).
fn load(&mut self) {
if self.loaded {
return;
}
self.loaded = true;
// Build the cached Gate / Doctor model (once per load; perf-sensitive code
// paths are gated — dynamic `cargo check` is opt-in).
self.compute_gate();
// Assemble the crate publish-DAG (facett dep-graph) alongside (local only).
self.compute_depdag();
let rows = match &self.src {
Src::Local(root) => {
// Read-only + lock-tolerant: coexist with a live server holding
// the catalog lock (the snapshot-copy fallback) exactly as the
// `nornir release events` CLI read path does.
match IcebergWarehouse::open_read_only(root)
.and_then(|wh| wh.block_on(query_release_events(&wh, &EventSelector::All)))
{
Ok(rows) => rows,
Err(e) => {
self.error = Some(format!("{e:#}"));
return;
}
}
}
Src::Remote {
endpoint,
token,
workspace,
} => {
// Thin mode: read the SAME rows over Viz.ReleaseEvents (the server
// holds the warehouse lock). Decoded into ReleaseEventRow, so the
// grouping/graph below is source-agnostic.
match super::remote::fetch_release_events(endpoint, token, workspace) {
Ok(rows) => rows,
Err(e) => {
self.error = Some(format!("{e:#}"));
return;
}
}
}
};
self.runs = group_runs(rows);
self.selected_run = self.runs.first().map(|r| r.run_id.clone());
}
/// The currently-selected run (or the newest if none chosen yet).
fn current(&self) -> Option<&RunGroup> {
let sel = self.selected_run.as_deref();
sel.and_then(|id| self.runs.iter().find(|r| r.run_id == id))
.or_else(|| self.runs.first())
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
self.load();
if let Some(err) = self.error.clone() {
ui.colored_label(RED, format!("release_events read failed:\n{err}"));
return;
}
if self.runs.is_empty() {
ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(20.0);
ui.heading("🚀 Release — no release runs recorded yet");
ui.label("Every release run records its op DAG here:");
ui.monospace("dwarves release # the orchestrator");
ui.monospace("nornir release events <run-id|repo> # CLI twin");
ui.add_space(12.0);
ui.horizontal(|ui| self.draw_release_op(ui));
});
ui.add_space(16.0);
ui.separator();
// Even with no runs yet, the Gate / Doctor advisory is useful.
self.draw_gate_section(ui);
});
return;
}
// ── run picker (newest first) ────────────────────────────────────────
let runs: Vec<(String, usize, i64)> = self
.runs
.iter()
.map(|r| (r.run_id.clone(), r.rows.len(), r.latest_ts))
.collect();
egui::Panel::top("release_controls").show_inside(ui, |ui| {
ui.horizontal_wrapped(|ui| {
ui.label("run:");
let sel = self
.selected_run
.clone()
.unwrap_or_else(|| runs.first().map(|r| r.0.clone()).unwrap_or_default());
egui::ComboBox::from_id_salt("release_run")
.selected_text(short_run(&sel))
.show_ui(ui, |ui| {
for (id, n, _) in &runs {
ui.selectable_value(
&mut self.selected_run,
Some(id.clone()),
format!("{} · {n} events", short_run(id)),
);
}
});
if ui
.button("↻ reload")
.on_hover_text("re-read release_events")
.clicked()
{
self.reload();
}
ui.separator();
self.draw_release_op(ui);
ui.separator();
legend(ui, &theme);
});
});
// Resolve the selected run AFTER the picker may have changed it.
let Some(run) = self.current().cloned() else {
return;
};
// Latest status per component for THIS run (drives the graph colours).
let comp_status = component_status(&run.rows);
// ── B4: the lit-up dep-graph (top half) ──────────────────────────────
let graph_h = (ui.available_height() * 0.5).max(180.0);
egui::Panel::top("release_graph")
.resizable(true)
.default_size(graph_h)
.show_inside(ui, |ui| {
ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
self.draw_lit_graph(ui, &run, &comp_status);
});
});
// ── B3: the op-log (bottom half) ─────────────────────────────────────
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.horizontal(|ui| {
ui.strong(format!("run {}", short_run(&run.run_id)));
ui.separator();
let overall = run_outcome(&run.rows);
let (col, txt) = match overall {
RunOutcome::Running => (AMBER, "● running"),
RunOutcome::Ok => (GREEN, "✓ ok"),
RunOutcome::Fail => (RED, "✗ failed"),
};
ui.colored_label(col, txt);
ui.separator();
ui.weak(format!("{} events", run.rows.len()));
});
ui.separator();
ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(ui, |ui| {
for r in &run.rows {
op_log_row(ui, &theme, r);
}
ui.add_space(8.0);
ui.separator();
// The Gate / Doctor advisory beneath the op-log (cached model).
self.draw_gate_section(ui);
});
});
// Keep the amber pulse animating while any op is still running.
if comp_status.values().any(|s| *s == status::RUNNING) {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(80));
}
}
/// B4 — the rectangular dep-graph, nodes coloured by latest release status.
fn draw_lit_graph(
&self,
ui: &mut egui::Ui,
run: &RunGroup,
comp_status: &BTreeMap<String, String>,
) {
let theme = self.theme;
// Components (graph nodes) = every component named in this run, ordered
// deps-first by depends_on (a stable toposort; falls back to first-seen).
let order = topo_order(run);
if order.is_empty() {
ui.label("(no components in this run)");
return;
}
const NODE_W: f32 = 150.0;
const NODE_H: f32 = 52.0;
const COL_GAP: f32 = 210.0;
const LEFT_PAD: f32 = 24.0;
const TOP_PAD: f32 = 40.0;
let col_of: BTreeMap<&str, usize> = order
.iter()
.enumerate()
.map(|(i, c)| (c.as_str(), i))
.collect();
let pos_for = |rect: Rect, comp: &str| -> Pos2 {
let col = *col_of.get(comp).unwrap_or(&0);
Pos2::new(
rect.left() + LEFT_PAD + col as f32 * COL_GAP,
rect.top() + TOP_PAD,
)
};
let needed_w = LEFT_PAD + order.len() as f32 * COL_GAP + NODE_W;
let canvas = Vec2::new(
ui.available_width().max(needed_w),
(NODE_H + TOP_PAD * 2.0).max(160.0),
);
let (rect, _resp) = ui.allocate_exact_size(canvas, Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, theme.bg);
// amber pulse 0..1 for the running glow.
let pulse = {
let t = self.started_at.elapsed().as_secs_f32();
0.5 + 0.5 * (t * 3.0).sin()
};
// edges (consumer → producer), drawn under nodes.
for comp in &order {
if let Some(deps) = depends_on_of(run, comp) {
let from = pos_for(rect, comp) + Vec2::new(0.0, NODE_H / 2.0);
for dep in deps {
if !col_of.contains_key(dep.as_str()) {
continue;
}
let to = pos_for(rect, dep) + Vec2::new(NODE_W, NODE_H / 2.0);
painter.line_segment([from, to], Stroke::new(2.0_f32, theme.edge));
}
}
}
// nodes.
for comp in &order {
let node_pos = pos_for(rect, comp);
let node_rect = Rect::from_min_size(node_pos, Vec2::new(NODE_W, NODE_H));
let st = comp_status
.get(comp.as_str())
.map(String::as_str)
.unwrap_or("idle");
let base = lit_color(&theme, st);
// Running nodes pulse between dim and full amber.
let fill = if st == status::RUNNING {
let a = (110.0 + 110.0 * pulse) as u8;
AMBER.linear_multiply(a as f32 / 255.0)
} else {
theme.node_fill
};
painter.rect_filled(node_rect, CornerRadius::same(6), fill);
painter.rect_stroke(
node_rect,
CornerRadius::same(6),
Stroke::new(2.5_f32, base),
egui::StrokeKind::Inside,
);
painter.text(
node_pos + Vec2::new(NODE_W / 2.0, 12.0),
Align2::CENTER_TOP,
comp,
FontId::proportional(14.0),
theme.text,
);
painter.text(
node_pos + Vec2::new(NODE_W / 2.0, 30.0),
Align2::CENTER_TOP,
st,
FontId::monospace(11.0),
base,
);
}
}
/// The structured **Gate / Doctor** block folded into `state_json` — what the
/// robot test (track D) asserts on. Stable keys regardless of whether the model
/// is present (remote / unconfigured → `present: false`, zero counts).
fn gate_json(&self) -> serde_json::Value {
// Delegate to the SHARED gate model's `gate_json` shape (the same the 🧬
// dashboard ships), so the two surfaces can never drift.
match &self.gate {
Some(m) => m.gate_json(self.gate_error.as_deref()),
None => super::gate::gate_json_absent(self.gate_error.as_deref()),
}
}
/// The **Gate / Doctor** section of the Release pane: dirty trees, external-dep
/// skew (with the ⛔ transitive-pin rows), cycle-break advice, and the
/// edition-2024 gate result. Reads only the CACHED model (no recompute per
/// frame); the dynamic edition lint pass is behind a button.
/// The **crate publish-DAG** section: the facett dep-graph pane (pan/zoom, node
/// status colour, wave badge, cycle ring) + its controls (transitive toggle,
/// registry probe). Local mode only — needs a workspace checkout to scan.
fn draw_depdag_section(&mut self, ui: &mut egui::Ui) {
let theme = self.theme;
egui::CollapsingHeader::new("🧬 Crate publish-DAG")
.default_open(false)
.show(ui, |ui| {
if !self.depdag.is_built() {
ui.label(
RichText::new(
"crate publish-DAG is local-only — it maps the crate dependency graph the release doctor topo-sorts (nodes = crates, edges = depends-on)",
)
.color(theme.text_dim),
);
return;
}
ui.horizontal_wrapped(|ui| {
// THIN (cargo graph) ⇄ DEEP (symbol-derived call-graph rollup).
let deep = self.depdag.mode == super::release_depdag::DepDagMode::Deep;
if ui
.selectable_label(deep, "deep")
.on_hover_text("DEEP: crate→crate edges rolled up from the warehouse call-graph (symbol-derived). Off = the THIN cargo-metadata dependency graph.")
.clicked()
{
// Lazily load the DEEP edge set on the first flip into it.
self.compute_deep_edges();
self.depdag.toggle_mode();
if let Some(m) = self.depdag_model.clone() {
self.depdag.build(&m);
}
}
if deep {
ui.weak(format!("{} symbol edges", self.depdag.deep_edge_count()));
}
if ui
.selectable_label(self.depdag.show_transitive, "transitive")
.on_hover_text("Synthesise transitive-closure edges (dashed)")
.clicked()
{
self.depdag.toggle_transitive();
if let Some(m) = self.depdag_model.clone() {
self.depdag.build(&m);
}
}
if ui
.button("⬇ probe registry")
.on_hover_text("Fetch each crate's published version from crates.io and light up will-publish / desync / already-published (network)")
.clicked()
{
self.probe_registry();
}
ui.separator();
depdag_legend(ui, &theme);
});
ui.separator();
// Give the interactive pane a bounded height so it coexists with the
// op-log/gate sections in the scroll column.
let size = egui::vec2(ui.available_width(), 320.0);
ui.allocate_ui(size, |ui| {
self.depdag.ui(ui);
});
});
}
fn draw_gate_section(&mut self, ui: &mut egui::Ui) {
self.draw_depdag_section(ui);
let theme = self.theme;
egui::CollapsingHeader::new("🩺 Gate / Doctor")
.default_open(true)
.show(ui, |ui| {
if self.gate_repos.is_empty() {
ui.label(
RichText::new(
"gate/doctor is local-only — run `nornir release doctor` / `nornir release gate edition <repo>` via the CLI",
)
.color(theme.text_dim),
);
return;
}
if let Some(err) = &self.gate_error {
ui.colored_label(RED, format!("gate/doctor failed: {err}"));
return;
}
let Some(m) = &self.gate else {
ui.label(RichText::new("(computing gate model…)").color(theme.text_dim));
return;
};
// ── dirty trees ──────────────────────────────────────────────
let dirty: Vec<&str> =
m.doctor.dirty.iter().filter(|d| d.dirty).map(|d| d.repo.as_str()).collect();
ui.horizontal_wrapped(|ui| {
ui.strong("Working trees:");
if dirty.is_empty() {
ui.colored_label(GREEN, "✅ all clean");
} else {
ui.colored_label(AMBER, format!("🟡 dirty: {}", dirty.join(", ")));
}
});
// ── external-dep skew (with ⛔ transitive-pin rows) ───────────
ui.add_space(2.0);
ui.strong(format!("External dependency skew ({}):", m.doctor.skew.len()));
if m.doctor.skew.is_empty() {
ui.colored_label(GREEN, " ✅ no divergence");
} else {
for c in &m.doctor.skew {
ui.label(
RichText::new(format!(" {} (target {})", c.crate_name, c.target))
.color(theme.text),
);
for e in &c.entries {
use doctor::SkewStatus;
let (glyph, col) = match e.status {
SkewStatus::Ok => ("✓", GREEN),
SkewStatus::Behind if e.held_by_transitive_pin => ("⛔", RED),
SkewStatus::Behind => ("·", theme.text_dim),
SkewStatus::Forbidden => ("⚠", AMBER),
};
let note = if e.held_by_transitive_pin {
format!(" (held: lock already resolves {}; a transitive dep pins {})", c.target, e.version)
} else {
String::new()
};
ui.colored_label(
col,
format!(" {glyph} {} {}{}", e.repo, e.version, note),
);
}
}
}
// ── cycle-break advice ───────────────────────────────────────
ui.add_space(2.0);
if m.doctor.cycle_advice.is_empty() {
ui.horizontal(|ui| {
ui.strong("Cycle-break advice:");
ui.colored_label(GREEN, "✅ clean DAG (no cycle)");
});
} else {
ui.strong(format!("Cycle-break advice ({}):", m.doctor.cycle_advice.len()));
for a in &m.doctor.cycle_advice {
ui.colored_label(
AMBER,
format!(" ⟲ {} — 💡 {}", a.members.join(" ⇄ "), a.rationale),
);
}
}
// ── edition-2024 gate ────────────────────────────────────────
ui.add_space(2.0);
ui.horizontal_wrapped(|ui| {
ui.strong("Edition 2024 gate:");
if m.edition.is_clean() {
ui.colored_label(GREEN, "✅ clean");
} else {
ui.colored_label(
RED,
format!(
"✗ {} static finding(s){}",
m.edition.static_findings.len(),
if m.edition.lint_pass_ran {
format!(" + {} lint(s)", m.edition.lints.len())
} else {
String::new()
},
),
);
}
});
for f in &m.edition.static_findings {
ui.colored_label(theme.text_dim, format!(" · {} — {}", f.package, f.issue));
}
if m.edition.lint_pass_ran {
for l in &m.edition.lints {
ui.colored_label(AMBER, format!(" ⚠ {l}"));
}
}
// The HEAVY opt-in: run the dynamic `cargo check` 2024-compat lint
// pass (multi-second) — off by default per the perf caveat.
if !self.edition_lint_requested {
if ui
.button("▶ run edition lint pass (cargo check)")
.on_hover_text("HEAVY: shells `cargo check` with --force-warn rust-2024-compatibility; multi-second")
.clicked()
{
self.edition_lint_requested = true;
self.gate = None; // force recompute with the dynamic pass
self.loaded = false;
}
} else {
ui.label(RichText::new("(dynamic lint pass ran)").color(theme.text_dim));
}
});
}
/// Test/introspection hook: the release-op rows + run + per-component status
/// the tab is rendering, as a JSON value folded into the app's `state_json`.
pub fn state_json(&self) -> serde_json::Value {
let current = self.current();
let rows: Vec<serde_json::Value> = current
.map(|run| {
run.rows
.iter()
.map(|r| {
serde_json::json!({
"component": r.component,
"op": r.op,
"phase": r.phase,
"status": r.status,
"line": op_log_line(r),
})
})
.collect()
})
.unwrap_or_default();
let comp_status = current
.map(|run| component_status(&run.rows))
.unwrap_or_default();
serde_json::json!({
"source": match &self.src {
Src::Local(p) => format!("local {}", p.display()),
Src::Remote { endpoint, workspace, .. } => {
format!("remote {endpoint} ws={workspace} (Viz.ReleaseEvents)")
}
},
"error": self.error,
"runs": self.runs.len(),
"selected_run": current.map(|r| r.run_id.clone()),
"rows": rows,
"component_status": comp_status,
"palette": self.theme.name,
// The Gate / Doctor model this pane renders (track C/D): skew, the ⛔
// transitive-pin held-back set, cycle-break advice, and the edition gate.
"gate": self.gate_json(),
// The crate publish-DAG (facett dep-graph): nodes = crates, edges =
// depends-on, waves = parallel publish order, per-crate registry state.
"depdag": self.depdag.state_json(),
// The HEAVY operation button this pane exposes (this feature): the
// release-grade gate → Ops.RunRelease. The matrix walks `ops` to prove
// the button is wired; `run_result` reports the last gate's outcome.
"ops": {
"buttons": [ { "id": "run_release_gate", "rpc": "Ops.RunRelease", "heavy": true, "confirm": true } ],
"armed": self.run_armed,
},
"run_result": match &self.run_result {
None => serde_json::json!({ "ran": false }),
Some(Ok(r)) => serde_json::json!({
"ran": true, "ok": r.ok, "summary": r.summary, "run_id": r.run_id,
"targets": r.targets.iter().map(|(n, s, m)| serde_json::json!({ "name": n, "status": s, "message": m })).collect::<Vec<_>>(),
}),
Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
},
})
}
}
// ─── grouping + status reduction ─────────────────────────────────────────────
/// Group rows into runs (newest first; rows within a run kept in `seq` order —
/// `query_release_events` already sorts by `(run_id, seq)`).
fn group_runs(rows: Vec<ReleaseEventRow>) -> Vec<RunGroup> {
let mut by_run: BTreeMap<String, Vec<ReleaseEventRow>> = BTreeMap::new();
for r in rows {
by_run.entry(r.run_id.clone()).or_default().push(r);
}
let mut runs: Vec<RunGroup> = by_run
.into_iter()
.map(|(run_id, mut rows)| {
rows.sort_by_key(|r| r.seq);
let latest_ts = rows.iter().map(|r| r.ts_micros).max().unwrap_or(0);
RunGroup {
run_id,
rows,
latest_ts,
}
})
.collect();
// Newest run on top.
runs.sort_by(|a, b| b.latest_ts.cmp(&a.latest_ts));
runs
}
/// Latest status per component within a run, walking rows in `seq` order so the
/// final state wins (a `running` start followed by an `ok` end → `ok`).
fn component_status(rows: &[ReleaseEventRow]) -> BTreeMap<String, String> {
let mut out: BTreeMap<String, String> = BTreeMap::new();
for r in rows {
// Skip the `run/*` envelope rows (no real component lane).
if r.op == "run" {
continue;
}
out.insert(r.component.clone(), r.status.clone());
}
out
}
/// Components in deps-first order: a simple Kahn toposort over the run's
/// `depends_on` edges; falls back to first-seen order on a cycle / missing data.
fn topo_order(run: &RunGroup) -> Vec<String> {
use std::collections::{BTreeSet, VecDeque};
// Components that are real ops (not the run envelope).
let mut comps: Vec<String> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
for r in &run.rows {
if r.op == "run" {
continue;
}
if seen.insert(r.component.clone()) {
comps.push(r.component.clone());
}
}
let set: BTreeSet<&str> = comps.iter().map(String::as_str).collect();
let mut indeg: BTreeMap<&str, usize> = comps.iter().map(|c| (c.as_str(), 0)).collect();
let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for c in &comps {
if let Some(deps) = depends_on_of(run, c) {
for d in deps {
if set.contains(d.as_str()) {
// edge c → d means c consumes d; we want d first.
adj.entry(d.as_str()).or_default().push(c.as_str());
*indeg.entry(c.as_str()).or_insert(0) += 1;
}
}
}
}
let mut q: VecDeque<&str> = comps
.iter()
.map(String::as_str)
.filter(|c| indeg[c] == 0)
.collect();
let mut out: Vec<String> = Vec::new();
while let Some(c) = q.pop_front() {
out.push(c.to_string());
if let Some(children) = adj.get(c) {
for &ch in children {
let Some(d) = indeg.get_mut(ch) else { continue };
*d = d.saturating_sub(1);
if *d == 0 {
q.push_back(ch);
}
}
}
}
if out.len() == comps.len() { out } else { comps }
}
/// The `depends_on` list recorded for a component in this run (the first non-null
/// occurrence — every per-repo row carries the same list).
fn depends_on_of<'a>(run: &'a RunGroup, comp: &str) -> Option<&'a Vec<String>> {
run.rows
.iter()
.find(|r| r.component == comp && r.depends_on.is_some())
.and_then(|r| r.depends_on.as_ref())
}
enum RunOutcome {
Running,
Ok,
Fail,
}
/// The run's overall outcome: any `fail` → Fail; else any non-`end` op still open
/// → Running; else Ok.
fn run_outcome(rows: &[ReleaseEventRow]) -> RunOutcome {
if rows.iter().any(|r| r.status == status::FAIL) {
return RunOutcome::Fail;
}
// A run that emitted a `run/end ok` is done.
let ended_ok = rows
.iter()
.any(|r| r.op == "run" && r.phase == "end" && r.status == status::OK);
if ended_ok {
RunOutcome::Ok
} else if rows.iter().any(|r| r.status == status::RUNNING) {
RunOutcome::Running
} else {
RunOutcome::Ok
}
}
// ─── rendering helpers ───────────────────────────────────────────────────────
/// The op-log line for a row: `[component] op phase status (elapsed) — detail`.
fn op_log_line(r: &ReleaseEventRow) -> String {
let elapsed = r
.elapsed_ms
.map(|ms| format!(" ({:.1}s)", ms as f32 / 1000.0))
.unwrap_or_default();
let detail = if r.detail.is_empty() {
String::new()
} else {
format!(" — {}", r.detail)
};
format!(
"[{}] {} {} {}{}{}",
r.component, r.op, r.phase, r.status, elapsed, detail
)
}
/// One coloured op-log row (the component-prefixed worklog line).
fn op_log_row(ui: &mut egui::Ui, theme: &Theme, r: &ReleaseEventRow) {
let (glyph, col) = match r.status.as_str() {
status::OK => ("✓", GREEN),
status::FAIL => ("✗", RED),
status::WARN => ("⚠", AMBER),
status::RUNNING => ("…", AMBER),
_ => ("·", theme.text_dim),
};
ui.horizontal(|ui| {
ui.colored_label(col, glyph);
// `[component]` prefix in a distinct tint so lanes are scannable.
ui.label(
RichText::new(format!("[{}]", r.component))
.monospace()
.size(12.0)
.color(theme.text_dim),
);
ui.colored_label(col, op_log_line_suffix(r));
});
}
/// The part of the op-log line after the `[component]` prefix (so the row can
/// tint the prefix separately).
fn op_log_line_suffix(r: &ReleaseEventRow) -> String {
let elapsed = r
.elapsed_ms
.map(|ms| format!(" ({:.1}s)", ms as f32 / 1000.0))
.unwrap_or_default();
let detail = if r.detail.is_empty() {
String::new()
} else {
format!(" — {}", r.detail)
};
format!("{} {} {}{}{}", r.op, r.phase, r.status, elapsed, detail)
}
/// Node outline / status-text colour for a component's latest status.
fn lit_color(theme: &Theme, st: &str) -> Color32 {
match st {
status::OK => GREEN,
status::FAIL => RED,
status::WARN => AMBER,
status::RUNNING => AMBER,
_ => theme.node_stroke, // idle / dim
}
}
fn short_run(id: &str) -> String {
if id.chars().count() > 12 {
let head: String = id.chars().take(12).collect();
format!("{head}…")
} else {
id.to_string()
}
}
/// Status legend for the graph + op-log.
fn legend(ui: &mut egui::Ui, theme: &Theme) {
for (st, label) in [
(status::RUNNING, "running"),
(status::OK, "ok"),
(status::FAIL, "fail"),
("idle", "idle"),
] {
ui.colored_label(lit_color(theme, st), "●");
ui.weak(label);
ui.add_space(4.0);
}
}
/// Registry-state legend for the crate publish-DAG (matches `release_depdag`'s
/// node fills).
fn depdag_legend(ui: &mut egui::Ui, _theme: &Theme) {
for (col, label) in [
(Color32::from_rgb(90, 200, 140), "will-publish"),
(Color32::from_rgb(120, 130, 140), "published"),
(Color32::from_rgb(230, 90, 90), "desync"),
(Color32::from_rgb(90, 150, 230), "unpublished"),
(Color32::from_rgb(110, 110, 120), "unknown"),
] {
ui.colored_label(col, "●");
ui.weak(label);
ui.add_space(4.0);
}
}
/// The reader's `Viz.Knowledge` answer → the pane's DEEP crate → crate edges.
fn deep_edges_from_answer(v: &serde_json::Value) -> Result<Vec<nornir_release_core::depdag::CrateDepEdge>, String> {
let rows = v["crate_edges"].as_array().ok_or_else(|| "the reader's answer carries no `crate_edges` (an older server)".to_string())?;
Ok(rows
.iter()
.map(|e| nornir_release_core::depdag::CrateDepEdge {
from: e["from"].as_str().unwrap_or("").to_string(),
to: e["to"].as_str().unwrap_or("").to_string(),
via: e["via"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default(),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn row(
run: &str,
seq: i64,
comp: &str,
op: &str,
phase: &str,
st: &str,
deps: Option<Vec<&str>>,
elapsed: Option<i64>,
) -> ReleaseEventRow {
ReleaseEventRow {
run_id: run.into(),
seq,
ts_micros: seq * 10,
component: comp.into(),
repo: comp.into(),
op: op.into(),
phase: phase.into(),
status: st.into(),
detail: String::new(),
depends_on: deps.map(|v| v.into_iter().map(String::from).collect()),
elapsed_ms: elapsed,
}
}
#[test]
fn group_runs_newest_first_and_seq_ordered() {
let rows = vec![
row(
"runA",
1,
"znippy",
"test",
"end",
status::OK,
None,
Some(1200),
),
row(
"runA",
0,
"znippy",
"test",
"start",
status::RUNNING,
None,
None,
),
row(
"runB",
0,
"korp",
"test",
"start",
status::RUNNING,
Some(vec![]),
None,
),
];
// runB has the highest ts (seq 0 * 10 = 0) vs runA (max seq 1 → ts 10),
// so runA is newest. Verify ordering + per-run seq order.
let runs = group_runs(rows);
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].run_id, "runA", "newest (highest ts) run on top");
assert_eq!(
runs[0].rows.iter().map(|r| r.seq).collect::<Vec<_>>(),
vec![0, 1]
);
}
#[test]
fn component_status_takes_latest() {
let rows = vec![
row(
"r",
0,
"znippy",
"test",
"start",
status::RUNNING,
None,
None,
),
row("r", 1, "znippy", "test", "end", status::OK, None, Some(5)),
row(
"r",
2,
"holger",
"gate",
"start",
status::RUNNING,
Some(vec!["znippy"]),
None,
),
row("r", 3, "r", "run", "end", status::OK, None, None), // envelope skipped
];
let st = component_status(&rows);
assert_eq!(st.get("znippy").map(String::as_str), Some(status::OK));
assert_eq!(st.get("holger").map(String::as_str), Some(status::RUNNING));
assert!(
!st.contains_key("r"),
"run-envelope component is not a node"
);
}
#[test]
fn topo_order_is_deps_first() {
let run = RunGroup {
run_id: "r".into(),
latest_ts: 0,
rows: vec![
row(
"r",
0,
"holger",
"gate",
"start",
status::RUNNING,
Some(vec!["znippy"]),
None,
),
row(
"r",
1,
"znippy",
"test",
"start",
status::RUNNING,
Some(vec![]),
None,
),
],
};
// holger depends on znippy → znippy must come first.
let order = topo_order(&run);
assert_eq!(order, vec!["znippy".to_string(), "holger".to_string()]);
}
#[test]
fn op_log_line_is_component_prefixed() {
let r = row(
"r",
0,
"znippy",
"test",
"end",
status::OK,
None,
Some(1200),
);
assert_eq!(op_log_line(&r), "[znippy] test end ok (1.2s)");
}
/// Robot scenario (headless, asserts on `state_json`): a minimal 2-repo
/// workspace (`app` → `base` via a path dep) is scanned into the crate
/// publish-DAG; assert the `"depdag"` block reports the nodes/edges, the
/// publish waves (base before app), and — with a fixture registry probe — the
/// per-crate local-vs-published state.
#[test]
fn release_depdag_scans_a_workspace_and_reports_waves() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("base")).unwrap();
fs::write(
root.join("base/Cargo.toml"),
"[package]\nname = \"base\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
fs::create_dir_all(root.join("app")).unwrap();
fs::write(
root.join("app/Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"1.0.0\"\nedition = \"2021\"\n\n[dependencies]\nbase = { path = \"../base\", version = \"0.1.0\" }\n",
)
.unwrap();
let mut tab = ReleaseTabState::local(root.to_path_buf());
tab.set_gate_inputs(root, &["base".into(), "app".into()], DepPolicy::default());
let n = tab.build_depdag_for_test();
assert_eq!(n, 2, "app + base");
let j = tab.state_json();
let dd = &j["depdag"];
assert_eq!(dd["mode"], "thin");
assert_eq!(dd["layout"]["node_count"], 2);
assert_eq!(dd["layout"]["direct_edges"], 1, "app → base");
// waves: base publishes first (depends on nothing), then app.
assert_eq!(dd["waves"][0][0], "base");
assert_eq!(dd["publishable"], true);
// Registry annotation: inject a fixture probe (offline) and reassemble.
tab.depdag_published = [
("app".to_string(), Some("0.9.0".to_string())), // local ahead → will publish
("base".to_string(), None), // unpublished → first publish
]
.into_iter()
.collect();
tab.build_depdag_for_test();
let j = tab.state_json();
let nodes = j["depdag"]["dag"]["nodes"].as_array().unwrap();
let app = nodes.iter().find(|x| x["name"] == "app").unwrap();
assert_eq!(app["state"], "will_publish");
let base = nodes.iter().find(|x| x["name"] == "base").unwrap();
assert_eq!(base["state"], "unpublished");
// THIN→DEEP toggle: inject a symbol-derived edge set (no warehouse) and flip.
// The `mode` flips to "deep" and the laid-out graph now shows the symbol edge.
tab.toggle_deep_for_test(vec![nornir_release_core::depdag::CrateDepEdge {
from: "app".into(),
to: "base".into(),
via: vec!["open".into()],
}]);
let j = tab.state_json();
let dd = &j["depdag"];
assert_eq!(dd["mode"], "deep");
assert_eq!(dd["deep_edge_count"], 1);
assert_eq!(
dd["layout"]["node_count"], 2,
"same crate nodes in DEEP mode"
);
assert_eq!(
dd["layout"]["direct_edges"], 1,
"the one symbol-derived edge"
);
}
#[test]
fn run_outcome_fail_wins() {
let rows = vec![
row("r", 0, "znippy", "test", "end", status::OK, None, Some(5)),
row("r", 1, "holger", "test", "end", status::FAIL, None, Some(3)),
];
assert!(matches!(run_outcome(&rows), RunOutcome::Fail));
}
/// The DEEP edges are the code room's crate → crate edges; a workspace
/// nothing was consumed into leaves them UNAVAILABLE with the reason.
#[cfg(feature = "code-room")]
#[test]
fn deep_edges_come_from_the_code_room() {
use crate::code_room::reader::fixture;
let mut st = ReleaseTabState::local(fixture::shop().builds.clone());
st.compute_deep_edges();
let js = st.depdag.state_json();
assert_eq!((js["deep_state"].as_str(), js["deep_edge_count"].as_u64()), (Some("available"), Some(1)), "{js}");
let answer = crate::viz::remote::local_surface(&fixture::shop().builds, "ws", "Viz.Knowledge", &serde_json::Value::Null).unwrap();
let edges = deep_edges_from_answer(&answer).unwrap();
assert_eq!(edges.iter().map(|e| (e.from.as_str(), e.to.as_str(), e.via.clone())).collect::<Vec<_>>(), vec![("web", "shop", vec!["checkout".to_string()])]);
let mut st = ReleaseTabState::local(fixture::unconsumed().builds.clone());
st.compute_deep_edges();
let js = st.depdag.state_json();
assert_eq!(js["deep_state"], "unavailable");
assert!(js["deep_note"].as_str().unwrap().contains("[not_consumed]"), "{js}");
assert_eq!(js["deep_edge_count"], 0);
}
}