integrations/nornir-flow.patch
diff --git a/Cargo.toml b/Cargo.toml
index b6f67b3..90b4b1d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -465,7 +465,7 @@ docs-export = ["nornir-docgen/export", "dep:nornir-docbook"]
# dep-graph edges as woven threads between lanes. Pure Rust
# (wgpu/glow back-end); no JS, no C deps beyond the platform's own
# windowing libs that eframe pulls in.
-viz = ["wire-client", "dep:eframe", "dep:egui", "dep:image", "dep:egui-snarl", "dep:facett-jobview", "dep:facett-docview", "dep:facett-graphview", "dep:facett-graph3d", "dep:facett-plandag", "dep:facett-warehousedeck", "dep:facett-helix", "dep:facett-core", "dep:facett-brokkr", "dep:facett-app", "dep:facett-graphpan", "dep:facett-matrix"]
+viz = ["wire-client", "dep:eframe", "dep:egui", "dep:image", "dep:egui-snarl", "dep:facett-jobview", "dep:facett-docview", "dep:facett-graphview", "dep:facett-graph3d", "dep:facett-plandag", "dep:facett-warehousedeck", "dep:facett-helix", "dep:facett-core", "dep:facett-flow", "dep:facett-brokkr", "dep:facett-app", "dep:facett-graphpan", "dep:facett-matrix"]
# Semantic-search embedders. The `vector::Embedder` trait is the interface;
# two interchangeable backends run the SELECTED model's ONNX export (the model
# is chosen from `src/vector/embed_registry.rs` via $NORNIR_EMBED_MODEL —
@@ -1089,6 +1089,7 @@ facett-helix = { version = "0.2", path = "../facett/facett-helix", optional = tr
# snapshot-asserted surfaces". Pulled directly (not just transitively) so the
# `autonom::facett_probe` drive code can name `facett_core::harness`; same
# egui 0.34 / facett-core 0.1 stack as every embed above.
+facett-flow = { path = "../facett/facett-flow", optional = true, default-features = false, features = ["layered", "status", "labels", "hit-test", "compact-state", "wgpu"] }
facett-core = { version = "0.2", path = "../facett/facett-core", optional = true }
# ⚒ Brokkr tab pipeline BUILDER: the reusable, nornir-agnostic facett component
# `facett_brokkr::PipelineBuilder` lets a user AUTHOR a `brokkr.toml` in the
diff --git a/src/bin/urdr-threads.rs b/src/bin/urdr-threads.rs
index 4b5d28c..8ec5f63 100644
--- a/src/bin/urdr-threads.rs
+++ b/src/bin/urdr-threads.rs
@@ -419,6 +419,9 @@ fn main() -> Result<()> {
"Urðr Threads",
native_options,
Box::new(move |cc| {
+ if let Some(render_state) = &cc.wgpu_render_state {
+ facett_flow::gpu::install_renderer(render_state);
+ }
// Force dark mode: the timeline/graphs are painted on a dark canvas,
// but egui otherwise follows the system theme (light on some displays)
// → dark/black UI text in the side panel + labels. Pin it dark.
diff --git a/src/viz/app.rs b/src/viz/app.rs
index 6fdc256..0e183dc 100644
--- a/src/viz/app.rs
+++ b/src/viz/app.rs
@@ -2814,7 +2814,10 @@ impl eframe::App for UrdrThreadsApp {
// `update(ctx, frame)` is deprecated). The given `ui` is a bare root with no
// CentralPanel, so the existing ctx-panel layout in `draw_ui` works unchanged
// when driven off `ui.ctx()`.
- fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+ fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
+ if let Some(render_state) = frame.wgpu_render_state() {
+ facett_flow::gpu::evict_idle(render_state, ui.ctx().cumulative_frame_nr());
+ }
self.draw_ui(ui);
}
}
diff --git a/src/viz/build_tab.rs b/src/viz/build_tab.rs
index 91b2212..9c8de24 100644
--- a/src/viz/build_tab.rs
+++ b/src/viz/build_tab.rs
@@ -12,7 +12,7 @@
//!
//! The observable state lives entirely in [`BuildThing`](crate::build::elm::BuildThing)
//! (the facett Elm pure core: Model / Msg / update / Effect). `draw` is a pure
-//! projection + input collector: it emits [`BuildThingMsg`]s and lets
+//! projection + input collector: it emits [`BuildTabMsg`]s and lets
//! [`BuildThing::update`] mutate the model and hand back [`BuildThingEffect`]s. The
//! only side work — actually running the build — is done by a [`BuildRunner`] seam
//! OFF the paint thread; its result is fed back as a `BuildSucceeded` / `BuildFailed`
@@ -28,10 +28,25 @@ use std::sync::{Arc, Mutex};
use eframe::egui::{self};
+use super::build_flow::{BuildResult, build_plan_graph, declared_plans};
use crate::build::elm::{BuildThing, BuildThingEffect, BuildThingMsg};
use crate::build::surface::BuildSurface;
use crate::build::variant::BuildVariantSet;
use crate::jobs::JobSink;
+use facett_core::{Elm, Facet};
+use nornir_build_thing::plugin::BuildPlan;
+
+/// Inputs for the build cards and the cached, independently navigable plan graph.
+#[derive(Clone, Debug)]
+pub enum BuildTabMsg {
+ Build(BuildThingMsg),
+ Flow(facett_flow::Msg),
+}
+impl From<BuildThingMsg> for BuildTabMsg {
+ fn from(value: BuildThingMsg) -> Self {
+ Self::Build(value)
+ }
+}
use super::facett_theme::{GREEN, RED, Theme, ThemeExt};
use super::test_boot::{BootRunner, BootStatus, BootTarget, TestBootState};
@@ -132,6 +147,8 @@ struct InFlight {
pub struct BuildTabState {
/// The Elm pure core (all observable state).
thing: BuildThing,
+ plans: Vec<BuildPlan>,
+ flow: facett_flow::FlowGraph,
/// The reflected surface (choosable rows: binary × features × profile × target).
surface: BuildSurface,
/// The repo the builds run in.
@@ -235,8 +252,17 @@ impl BuildTabState {
.unwrap_or_else(|| "<repo>".to_string());
let surface = BuildSurface::from_set(repo.clone(), &set);
let thing = BuildThing::from_surface(&surface);
+ let plans = declared_plans(&repo, &set);
+ let parts = build_plan_graph(&plans, &Default::default());
+ let mut flow = facett_flow::FlowGraph::new("build-plan", parts.graph)
+ .expect("valid declared build graph");
+ flow.set_labels(true);
+ flow.set_compact_state(true);
+ flow.update(facett_flow::Msg::Running(false));
Self {
thing,
+ plans,
+ flow,
surface,
repo_root,
runner,
@@ -254,6 +280,61 @@ impl BuildTabState {
}
}
+ /// Supply the exact plans materialized by a plugin (including custom steps).
+ /// Existing card/runner state is retained; only the graph topology is replaced.
+ pub fn set_build_plans(&mut self, plans: Vec<BuildPlan>) {
+ let parts = build_plan_graph(&plans, &Default::default());
+ self.plans = plans;
+ self.flow
+ .update(facett_flow::Msg::Replace(Arc::new(parts.graph)));
+ self.sync_flow_status();
+ }
+
+ fn sync_flow_status(&mut self) {
+ use crate::build::elm::CardStatus;
+ use facett_flow::attr::Status;
+ let results = self
+ .thing
+ .state()
+ .cards
+ .iter()
+ .map(|card| {
+ let variant = match &card.status {
+ CardStatus::Built { .. } => Some(Status::Ok),
+ CardStatus::Failed { .. } => Some(Status::Error),
+ CardStatus::Idle | CardStatus::Building => None,
+ };
+ (
+ card.name.clone(),
+ BuildResult {
+ variant,
+ ..Default::default()
+ },
+ )
+ })
+ .collect();
+ // Outcomes change independently of positions and camera. Do not replace
+ // the scene on a build transition or guess which unreported step failed.
+ let status = super::build_flow::build_plan_status(&self.plans, &results);
+ self.flow.update(facett_flow::Msg::Status(Arc::new(status)));
+ }
+
+ fn update_build(&mut self, msg: BuildThingMsg) -> Vec<BuildThingEffect> {
+ let selected = if let BuildThingMsg::Select(variant) = &msg {
+ self.plans.iter().position(|plan| &plan.variant == variant)
+ } else {
+ None
+ };
+ let effects = self.thing.update(msg);
+ if let Some(index) = selected {
+ self.flow.update(facett_flow::Msg::Select(Some(format!(
+ "plan{index}:variant"
+ ))));
+ }
+ self.sync_flow_status();
+ effects
+ }
+
/// Point the test-boot jobs at a durable job ledger `sink` (the app wires the
/// server's `jobs.redb` sink so boot jobs show in the Jobs panel too).
pub fn set_boot_sink(&mut self, sink: JobSink, workspace: impl Into<String>) {
@@ -328,6 +409,7 @@ impl BuildTabState {
"building": !self.inflight.is_empty(),
// The Elm model (cards, statuses, log) — the full pure-core state.
"model": self.thing.state_json(),
+ "flow": Facet::state_json(&self.flow),
// The reflected choosable rows (binary × features × profile × target).
"rows": self.surface.state_json()["variants"].clone(),
"provenance": serde_json::Value::Object(provenance),
@@ -400,7 +482,7 @@ impl BuildTabState {
/// Send one message through the Elm core and run any effects it hands back
/// (spawning the build off the paint thread). The ONE mutation path.
fn dispatch(&mut self, msg: BuildThingMsg) {
- for effect in self.thing.update(msg) {
+ for effect in self.update_build(msg) {
match effect {
BuildThingEffect::RunBuild(variant) => self.spawn_build(variant),
}
@@ -479,7 +561,7 @@ impl BuildTabState {
/// tab uses (no worker thread). The test seam — asserts the full BUILD wiring
/// (select → `nornir build --attest --seal` → provenance card) deterministically.
pub fn run_build_blocking(&mut self, variant: &str) {
- let effects = self.thing.update(BuildThingMsg::Build(variant.to_string()));
+ let effects = self.update_build(BuildThingMsg::Build(variant.to_string()));
for effect in effects {
let BuildThingEffect::RunBuild(v) = effect;
match self.runner.run(&self.repo_root, &v) {
@@ -519,7 +601,10 @@ impl BuildTabState {
// The pure Elm view paints + emits the frame's Msgs; dispatch runs them (and
// their effects — the real build spawn) — the ONE live mutation path.
for msg in <Self as facett_core::Elm>::view(self, ui) {
- self.dispatch(msg);
+ for effect in <Self as Elm>::update(self, msg) {
+ let BuildThingEffect::RunBuild(variant) = effect;
+ self.spawn_build(variant);
+ }
}
// Drain the boot/kill presses the pure view recorded and act on them (the
// live-only side work — a headless deck render never drains these, so it
@@ -543,7 +628,7 @@ impl BuildTabState {
/// interactions produced — it never mutates the model or does I/O. Kept as the
/// [`Elm::view`](facett_core::Elm::view) impl below; [`Self::draw`] (live) and the
/// facett `Facet::ui` bridge both drive it.
- fn view_pure(&self, ui: &mut egui::Ui) -> Vec<BuildThingMsg> {
+ fn view_pure(&self, ui: &mut egui::Ui) -> Vec<BuildTabMsg> {
ui.heading(format!("🔨 Build — {}", self.surface.repo));
ui.label(
"Choose a build target (binary × features × profile × target), then press \
@@ -552,6 +637,14 @@ impl BuildTabState {
ui.add_space(6.0);
// Collect messages during the render, apply after (keep the borrow clean).
+ let mut flow_messages = Vec::new();
+ egui::CollapsingHeader::new("Build plan graph")
+ .default_open(true)
+ .show(ui, |ui| {
+ ui.allocate_ui(egui::vec2(ui.available_width(), 220.0), |ui| {
+ flow_messages.extend(self.flow.view(ui).into_iter().map(BuildTabMsg::Flow));
+ });
+ });
let mut pending: Vec<BuildThingMsg> = Vec::new();
let selected = self.thing.state().selected.clone();
@@ -759,7 +852,8 @@ impl BuildTabState {
}
});
- pending
+ flow_messages.extend(pending.into_iter().map(BuildTabMsg::Build));
+ flow_messages
}
/// Paint the per-card status chip (idle / building / built digest / failed).
@@ -803,14 +897,14 @@ impl BuildTabState {
///
/// - `Model` = [`BuildThingModel`](crate::build::elm::BuildThingModel) (the whole
/// pure-core observable state);
-/// - `Msg` = [`BuildThingMsg`] / `Effect` = [`BuildThingEffect`];
+/// - `Msg` = [`BuildTabMsg`] (cards or graph) / `Effect` = [`BuildThingEffect`];
/// - `update` is the pure mutation path (delegates to the core `BuildThing::update`)
/// and returns the effects as DATA — the live [`BuildTabState::draw`] runs them
/// (`spawn_build`), while the macro-generated `Facet::ui` applies them without
/// side work so a headless render never spawns a real build.
impl facett_core::Elm for BuildTabState {
type Model = crate::build::elm::BuildThingModel;
- type Msg = BuildThingMsg;
+ type Msg = BuildTabMsg;
type Effect = BuildThingEffect;
fn title(&self) -> &str {
@@ -821,11 +915,28 @@ impl facett_core::Elm for BuildTabState {
self.thing.state()
}
- fn update(&mut self, msg: BuildThingMsg) -> Vec<BuildThingEffect> {
- self.thing.update(msg)
+ fn update(&mut self, msg: BuildTabMsg) -> Vec<BuildThingEffect> {
+ match msg {
+ BuildTabMsg::Build(msg) => self.update_build(msg),
+ BuildTabMsg::Flow(msg) => {
+ if let facett_flow::Msg::Select(Some(id)) = &msg
+ && let Some(index) = id
+ .split(':')
+ .next()
+ .and_then(|p| p.strip_prefix("plan"))
+ .and_then(|p| p.parse::<usize>().ok())
+ && let Some(plan) = self.plans.get(index)
+ {
+ self.thing
+ .update(BuildThingMsg::Select(plan.variant.clone()));
+ }
+ self.flow.update(msg);
+ Vec::new()
+ }
+ }
}
- fn view(&self, ui: &mut egui::Ui) -> Vec<BuildThingMsg> {
+ fn view(&self, ui: &mut egui::Ui) -> Vec<BuildTabMsg> {
self.view_pure(ui)
}
}
@@ -908,6 +1019,50 @@ profile = "release"
BuildTabState::from_toml(PathBuf::from("/tmp/holger"), DEMO_FIXTURE, runner)
}
+ #[test]
+ fn build_outcome_changes_only_attributes_and_preserves_navigation() {
+ let mut tab = tab_with(Arc::new(MockRunner::default()));
+ let graph = Arc::clone(&tab.flow.state().graph);
+ let camera = facett_flow::Camera {
+ centre: [123.0, 456.0],
+ zoom: 1.7,
+ };
+ Elm::update(
+ &mut tab,
+ BuildTabMsg::Flow(facett_flow::Msg::Camera(camera.clone())),
+ );
+ tab.run_build_blocking("mini");
+ assert!(Arc::ptr_eq(&graph, &tab.flow.state().graph));
+ assert_eq!(tab.flow.state().camera, camera);
+ let status = tab.flow.state().status.as_ref().unwrap();
+ let root = graph
+ .nodes
+ .iter()
+ .position(|n| n.id == "plan1:variant")
+ .unwrap();
+ assert_eq!(status.nodes[root], Some(facett_flow::attr::Status::Ok));
+ assert_eq!(
+ status.nodes[root + 1],
+ None,
+ "runner reports no individual step outcome"
+ );
+ assert!(tab.state_json()["flow"].is_object());
+ }
+
+ #[test]
+ fn graph_and_cards_share_selection_without_starting_a_build() {
+ let runner = Arc::new(MockRunner::default());
+ let mut tab = tab_with(runner.clone());
+ Elm::update(
+ &mut tab,
+ BuildTabMsg::Flow(facett_flow::Msg::Select(Some("plan2:step0".into()))),
+ );
+ assert_eq!(tab.thing.state().selected.as_deref(), Some("full"));
+ Elm::update(&mut tab, BuildThingMsg::Select("mini".into()).into());
+ assert_eq!(tab.flow.state().selected.as_deref(), Some("plan1:variant"));
+ assert!(runner.calls.lock().unwrap().is_empty());
+ }
+
/// ★ RED-WHEN-BROKEN: the live Build tab reflects **THIS repo's** declared
/// atoms, not a fixture. Before 2026-08-01 it rendered a frozen copy of
/// holger's `embedded`/`mini`/`full` in every repo, so the assertion that
@@ -1194,7 +1349,7 @@ profile = "release"
// core Model (BuildThingModel) — `update` is the only way it changed.
let snap = harness::snapshot_json(
&mut tab,
- vec![crate::build::elm::BuildThingMsg::Select("full".to_string())],
+ vec![crate::build::elm::BuildThingMsg::Select("full".to_string()).into()],
);
assert!(snap.is_object(), "snapshot is the serializable Model");
assert_eq!(
@@ -1230,7 +1385,9 @@ profile = "release"
// deltas; nothing is uploaded headless, so they are cleared (2026-10-03).
ctx.run_ui(egui::RawInput::default(), |ui| {
egui::CentralPanel::default().show(ui, |ui| tab.draw(ui));
- }).textures_delta.clear();
+ })
+ .textures_delta
+ .clear();
let j = tab.state_json();
assert!(!j.as_object().unwrap().is_empty());
assert_eq!(j["variant_count"], 3);
diff --git a/src/viz/depgraph_layout.rs b/src/viz/depgraph_layout.rs
index 0050dcb..0e3bee1 100644
--- a/src/viz/depgraph_layout.rs
+++ b/src/viz/depgraph_layout.rs
@@ -1,8 +1,84 @@
-//! Re-export of the pure dependency-graph layout.
-//!
-//! The layout core moved to the shared `nornir-depgraph` edda leaf (so nornir AND
-//! dwarves render the same suite dependency view). It stays reachable as
-//! `viz::depgraph_layout` so the 🔗 Dep Graph tab's renderer (`super::graph`) and
-//! the introspection call sites are unchanged.
+//! Nornir's CrossRepoEdge facade over Facett's shared-core dependency layout.
+//! The old implementation was extracted to edda. Keep this public viz surface
+//! while using the same iterative core ranks as Falsett and the other graph panes.
+pub use facett_graphview::{EdgeClass, LaidEdge, LaidNode};
+use nornir_depgraph::CrossRepoEdge;
+use std::collections::BTreeSet;
+use std::ops::{Deref, DerefMut};
-pub use nornir_depgraph::layout::*;
+#[derive(Debug, Clone)]
+pub struct DepGraphLayout(facett_graphview::DepGraphLayout);
+impl Deref for DepGraphLayout {
+ type Target = facett_graphview::DepGraphLayout;
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+impl DerefMut for DepGraphLayout {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+}
+fn edges(input: &[CrossRepoEdge]) -> Vec<facett_graphview::DepEdge> {
+ input
+ .iter()
+ .map(|e| facett_graphview::DepEdge {
+ from: e.from.clone(),
+ to: e.to.clone(),
+ via: e.via.iter().cloned().collect(),
+ })
+ .collect()
+}
+impl DepGraphLayout {
+ pub fn build(
+ repos: &[String],
+ input: &[CrossRepoEdge],
+ deep: bool,
+ collapsed: &BTreeSet<String>,
+ ) -> Self {
+ Self(facett_graphview::DepGraphLayout::build(
+ repos,
+ &edges(input),
+ deep,
+ collapsed,
+ ))
+ }
+ pub fn transitive_closure(repo: &str, input: &[CrossRepoEdge]) -> BTreeSet<String> {
+ facett_graphview::DepGraphLayout::transitive_closure(repo, &edges(input))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn legacy_dag_layout_json_and_collapse_are_preserved() {
+ let repos = ["app", "core", "util", "other"].map(String::from);
+ let input = [("app", "core"), ("core", "util"), ("app", "other")]
+ .map(|(a, b)| CrossRepoEdge::normal(a, b, BTreeSet::from(["dependency".into()])));
+ for deep in [false, true] {
+ for collapsed in [BTreeSet::new(), BTreeSet::from(["core".into()])] {
+ let old = nornir_depgraph::layout::DepGraphLayout::build(
+ &repos, &input, deep, &collapsed,
+ );
+ let shared = DepGraphLayout::build(&repos, &input, deep, &collapsed);
+ assert_eq!(shared.state_json(), old.state_json());
+ assert_eq!(shared.visible(), old.visible());
+ }
+ }
+ assert_eq!(
+ DepGraphLayout::transitive_closure("app", &input),
+ nornir_depgraph::layout::DepGraphLayout::transitive_closure("app", &input)
+ );
+ }
+ #[test]
+ fn cycle_guard_stays_bounded_to_the_real_nodes() {
+ let repos = ["a", "b"].map(String::from);
+ let input = [
+ CrossRepoEdge::normal("a", "b", BTreeSet::new()),
+ CrossRepoEdge::normal("b", "a", BTreeSet::new()),
+ ];
+ let shared = DepGraphLayout::build(&repos, &input, false, &BTreeSet::new());
+ assert!(shared.nodes.iter().all(|n| n.col < 2));
+ }
+}
diff --git a/src/viz/mod.rs b/src/viz/mod.rs
index e9bd539..a1e36ed 100644
--- a/src/viz/mod.rs
+++ b/src/viz/mod.rs
@@ -134,6 +134,8 @@ mod security_tab; // 🛰 the Warehouse (Constellation) Navigator MODEL — rend
// (recency/bugs/release-wave/blast/bottleneck/deepest-chain), consumed by facett's
// Code Vault view via `NavigatorModel::state_json`. Extends the release_depdag seed.
#[cfg(feature = "viz")]
+pub mod build_flow; // BuildPlan → cached Falsett graph adapter
+#[cfg(feature = "viz")]
pub mod build_tab; // 🔨 build runner (now a sub-pane of ⚒ Dwarves — N5)
// egui/facett-bound (eframe + facett_brokkr/facett_theme/facett_ui) — only the viz app
// (dwarves_tab) uses it, so gate on `viz`; a headless `server`-only build omits it.
diff --git a/src/viz/build_flow.rs b/src/viz/build_flow.rs
new file mode 100644
index 0000000..95eb12f
--- /dev/null
+++ b/src/viz/build_flow.rs
@@ -0,0 +1,251 @@
+//! Build plans as Falsett graphs. Commands stay data; drawing never executes a plan.
+use std::collections::BTreeMap;
+
+use facett_flow::{
+ Edge, Graph, Node,
+ attr::{Status, StatusTable},
+};
+use nornir_build_thing::plugin::{BuildPlan, BuildThing, Manifest};
+use nornir_build_thing::variant::BuildVariantSet;
+
+/// Only outcomes actually reported by the forge. A variant-level failure does
+/// not identify the failing step; leave unreported steps/artifacts uncoloured.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct BuildResult {
+ pub variant: Option<Status>,
+ pub steps: Vec<Option<Status>>,
+ pub artifacts: BTreeMap<String, Status>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct FlowParts {
+ pub graph: Graph,
+ pub status: StatusTable,
+}
+
+/// Variant → ordered command steps → expected artifacts. Index-qualified IDs
+/// distinguish repeated step labels and artifacts in different variant plans.
+/// `results` are keyed by variant; missing entries carry no terminal status.
+pub fn build_plan_graph(plans: &[BuildPlan], results: &BTreeMap<String, BuildResult>) -> FlowParts {
+ let mut graph = Graph {
+ nodes: Vec::new(),
+ edges: Vec::new(),
+ };
+ for (p, plan) in plans.iter().enumerate() {
+ let group = if plan.variant.trim().is_empty() {
+ format!("plan-{p}")
+ } else {
+ plan.variant.clone()
+ };
+ let root = format!("plan{p}:variant");
+ graph.nodes.push(Node {
+ id: root.clone(),
+ label: plan.variant.clone(),
+ position: [0.0; 2],
+ group: group.clone(),
+ kind: 0,
+ });
+ let mut previous = root;
+ for (s, step) in plan.steps.iter().enumerate() {
+ let id = format!("plan{p}:step{s}");
+ graph.nodes.push(Node {
+ id: id.clone(),
+ label: step.name.clone(),
+ position: [0.0; 2],
+ group: group.clone(),
+ kind: 1,
+ });
+ graph.edges.push(Edge {
+ from: previous,
+ to: id.clone(),
+ });
+ previous = id;
+ }
+ for (a, artifact) in plan.expected_artifacts.iter().enumerate() {
+ let id = format!("plan{p}:artifact{a}");
+ graph.nodes.push(Node {
+ id: id.clone(),
+ label: artifact.clone(),
+ position: [0.0; 2],
+ group: group.clone(),
+ kind: 2,
+ });
+ graph.edges.push(Edge {
+ from: previous.clone(),
+ to: id,
+ });
+ }
+ }
+ let pitch = 9_000_000.0 / graph.nodes.len().saturating_sub(1).max(1) as f32;
+ graph
+ .layered(facett_flow::LayeredOpts {
+ pitch: [240.0_f32.min(pitch), 120.0_f32.min(pitch)],
+ ..Default::default()
+ })
+ .expect("build plans within Falsett graph budget");
+ FlowParts {
+ graph,
+ status: build_plan_status(plans, results),
+ }
+}
+
+/// Attribute-only projection for live outcomes; no topology rebuild or layout.
+pub fn build_plan_status(
+ plans: &[BuildPlan],
+ results: &BTreeMap<String, BuildResult>,
+) -> StatusTable {
+ let mut status = StatusTable {
+ nodes: Vec::new(),
+ edges: Vec::new(),
+ };
+ for plan in plans {
+ let result = results.get(&plan.variant);
+ status.nodes.push(result.and_then(|r| r.variant));
+ for i in 0..plan.steps.len() {
+ let state = result.and_then(|r| r.steps.get(i).copied().flatten());
+ status.nodes.push(state);
+ status.edges.push(state);
+ }
+ for artifact in &plan.expected_artifacts {
+ let state = result.and_then(|r| r.artifacts.get(artifact).copied());
+ status.nodes.push(state);
+ status.edges.push(state);
+ }
+ }
+ status
+}
+
+/// The Build tab still supports descriptor-only hosts. Reuse the build thing's
+/// default materializer instead of duplicating argv/artifact/cfg conventions.
+/// A host with plugin-specific plans calls `BuildTabState::set_build_plans`.
+pub fn declared_plans(repo: &str, set: &BuildVariantSet) -> Vec<BuildPlan> {
+ struct Declared<'a> {
+ repo: &'a str,
+ set: &'a BuildVariantSet,
+ }
+ impl BuildThing for Declared<'_> {
+ fn manifest(&self) -> Manifest {
+ Manifest {
+ abi: nornir_build_thing::ABI_VERSION,
+ repo: self.repo.into(),
+ source_crate: "declared-build-surface".into(),
+ summary: "Declared cargo build plans".into(),
+ }
+ }
+ fn variants(&self) -> BuildVariantSet {
+ self.set.clone()
+ }
+ }
+ let declared = Declared { repo, set };
+ set.variants
+ .iter()
+ .filter_map(|v| declared.plan(&v.name))
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use nornir_build_thing::plugin::PlanStep;
+ fn plan() -> BuildPlan {
+ BuildPlan {
+ variant: "server".into(),
+ steps: ["codegen", "compile"]
+ .into_iter()
+ .map(|name| PlanStep {
+ name: name.into(),
+ program: "cargo".into(),
+ args: vec!["build".into()],
+ env: Vec::new(),
+ })
+ .collect(),
+ env: Vec::new(),
+ expected_artifacts: vec!["target/release/server".into()],
+ rationale: None,
+ }
+ }
+ #[test]
+ fn plan_chain_and_artifact_fanout_have_valid_unique_ids_and_direction() {
+ let mut other = plan();
+ other.variant = "client".into();
+ other
+ .expected_artifacts
+ .push("target/release/client".into());
+ let parts = build_plan_graph(&[plan(), other], &BTreeMap::new());
+ parts.graph.validate().unwrap();
+ assert_eq!((parts.graph.nodes.len(), parts.graph.edges.len()), (9, 7));
+ let positions: BTreeMap<_, _> = parts
+ .graph
+ .nodes
+ .iter()
+ .map(|n| (&n.id, n.position))
+ .collect();
+ for edge in &parts.graph.edges {
+ assert!(positions[&edge.from][0] < positions[&edge.to][0]);
+ }
+ assert!(parts.status.nodes.iter().all(Option::is_none));
+ }
+ #[test]
+ fn results_change_attributes_without_geometry_or_fabricated_step_outcomes() {
+ let plans = [plan()];
+ let plain = build_plan_graph(&plans, &BTreeMap::new());
+ let mut results = BTreeMap::from([(
+ "server".into(),
+ BuildResult {
+ variant: Some(Status::Error),
+ ..Default::default()
+ },
+ )]);
+ let failed = build_plan_graph(&plans, &results);
+ assert_eq!(plain.graph, failed.graph);
+ assert_eq!(failed.status.nodes, [Some(Status::Error), None, None, None]);
+ let result = results.get_mut("server").unwrap();
+ result.steps = vec![Some(Status::Ok), Some(Status::Error)];
+ let explicit = build_plan_graph(&plans, &results);
+ assert_eq!(
+ explicit.status.nodes[1..3],
+ [Some(Status::Ok), Some(Status::Error)]
+ );
+ assert_eq!(explicit.status.nodes[3], None);
+ }
+ #[test]
+ fn descriptor_plans_use_the_existing_materializer() {
+ let set = BuildVariantSet::from_toml_str(
+ r#"[build]
+[[build.variant]]
+name = "server"
+bin = "server"
+profile = "release"
+"#,
+ )
+ .unwrap();
+ let plans = declared_plans("fixture", &set);
+ assert_eq!(plans.len(), 1);
+ assert_eq!(plans[0].steps[0].args, set.variants[0].cargo_args());
+ assert_eq!(
+ plans[0].expected_artifacts,
+ vec![
+ set.variants[0]
+ .artifact_rel_path()
+ .unwrap()
+ .to_string_lossy()
+ .into_owned()
+ ]
+ );
+ let parts = build_plan_graph(&plans, &BTreeMap::new());
+ parts.graph.validate().unwrap();
+ assert_eq!(parts.graph.nodes.len(), 3);
+ }
+ #[test]
+ fn empty_plans_and_artifacts_without_steps_remain_valid() {
+ build_plan_graph(&[], &BTreeMap::new())
+ .graph
+ .validate()
+ .unwrap();
+ let mut p = plan();
+ p.steps.clear();
+ let parts = build_plan_graph(&[p], &BTreeMap::new());
+ parts.graph.validate().unwrap();
+ assert_eq!((parts.graph.nodes.len(), parts.graph.edges.len()), (2, 1));
+ }
+}