integrations/dwarves-flow.patch
diff --git a/crates/dwarves/Cargo.toml b/crates/dwarves/Cargo.toml
index 770da71..5ad3384 100644
--- a/crates/dwarves/Cargo.toml
+++ b/crates/dwarves/Cargo.toml
@@ -329,6 +329,8 @@ docs-fat = ["docs", "embed-nornir"]
# Off by default: the CLI/published forge links no facett/egui; only the demo UI
# (which already renders egui) turns it on. Mirrors knut's `facett-adapter`.
facett-adapter = ["dep:facett-pipeline", "dep:facett-releaseview", "dep:facett-core"]
+# Falsett graph rendering is optional and independent of the pipeline-view adapter.
+facett-flow = ["dep:facett-flow"]
# ── the GALOIS feature↔dependency axis (`feature_axis_source`) ──
# Supply `facett_buildthing::galois_pane::GaloisPane` with its facts: the derived
# feature→dependency ACTIVATION graph (`nornir-feature-graph`), which answers "which
@@ -782,6 +784,7 @@ holger-server-lib = { package = "holger-server-lib", version = "0.6.10", path =
# The engine-agnostic pipeline model + `PipelineAdapter` trait. dwarves' adapter
# (brokkr build DAG → `PipelineModel`) is the ONLY facett touch-point; the model
# types never leak into dwarves' public API. Path to the sibling facett checkout.
+facett-flow = { version = "0.2", path = "../../../facett/facett-flow", optional = true, default-features = false, features = ["layered", "status", "labels", "compact-state"] }
facett-pipeline = { version = "0.2", path = "../../../facett/facett-pipeline", optional = true }
# The engine-agnostic RELEASE-CONVERGENCE viewer (`ReleaseView` — the 3-plane
# local·sparring·crates.io convergence render). dwarves' `convergence_adapter` lowers the
diff --git a/crates/dwarves/src/brokkr/mod.rs b/crates/dwarves/src/brokkr/mod.rs
index 7ad06c9..111b8d5 100644
--- a/crates/dwarves/src/brokkr/mod.rs
+++ b/crates/dwarves/src/brokkr/mod.rs
@@ -28,6 +28,9 @@ pub mod elm;
/// one). Feature-gated: the default/published crate links no facett.
#[cfg(feature = "facett-adapter")]
pub mod facett_adapter;
+/// Build DAG and live run statuses for the optional Falsett renderer.
+#[cfg(feature = "facett-flow")]
+pub mod flow_adapter;
pub mod engine;
pub mod facets;
pub mod history;
diff --git a/crates/dwarves/src/brokkr/flow_adapter.rs b/crates/dwarves/src/brokkr/flow_adapter.rs
new file mode 100644
index 0000000..5494e2a
--- /dev/null
+++ b/crates/dwarves/src/brokkr/flow_adapter.rs
@@ -0,0 +1,316 @@
+//! Brokkr run DAGs rendered by Falsett. Only the optional `facett-flow` feature
+//! links the renderer; the execution engine and its data remain in dwarves.
+use std::collections::{BTreeMap, BTreeSet};
+
+use facett_flow::{
+ attr::{Status, StatusTable},
+ Edge, Graph, LayeredOpts, Node,
+};
+
+use super::{dag::Dag, engine::RunReport, history::RunStatus};
+
+/// Geometry and live statuses have the same node/edge ordering. Hosts retain
+/// their `FlowScene` and can apply subsequent statuses without replacing geometry.
+#[derive(Clone, Debug, PartialEq)]
+pub struct FlowParts {
+ pub graph: Graph,
+ pub status: StatusTable,
+}
+
+/// Render a validated Brokkr DAG, optionally annotated by its run report.
+/// Dependencies point from prerequisite to consumer. Stage names define groups;
+/// a missing report/row, running step or skipped step has no terminal colour.
+/// Duplicate ledger rows use the last matching row, like `facett_adapter`.
+/// Rows from a different run are ignored. Neither the graph nor its layout
+/// depends on the report, so live status changes preserve scene geometry.
+///
+/// The DAG must fit Falsett's node/edge budget. Layout spacing contracts for very
+/// deep/wide DAGs to stay inside its coordinate budget.
+pub fn run_graph(dag: &Dag, report: Option<&RunReport>) -> FlowParts {
+ let known: BTreeSet<_> = dag.order.iter().map(|n| n.step.as_str()).collect();
+ let consumers: BTreeSet<_> = dag
+ .order
+ .iter()
+ .flat_map(|n| n.needs.iter().map(String::as_str))
+ .collect();
+ let by_step: BTreeMap<_, _> = report
+ .into_iter()
+ .flat_map(|r| r.steps.iter().filter(move |s| s.run_id == r.run.run_id))
+ .map(|r| (r.step.as_str(), r.status))
+ .collect();
+ let mapped_status = |name: &str| match by_step.get(name) {
+ Some(RunStatus::Passed) => Some(Status::Ok),
+ Some(RunStatus::Failed) => Some(Status::Error),
+ Some(RunStatus::Skipped | RunStatus::Running) | None => None,
+ };
+ let mut graph = Graph {
+ nodes: dag
+ .order
+ .iter()
+ .map(|n| Node {
+ id: n.step.clone(),
+ label: n.step.clone(),
+ group: if n.stage.trim().is_empty() {
+ "steps".into()
+ } else {
+ n.stage.clone()
+ },
+ position: [0.0, 0.0],
+ kind: if n.needs.is_empty() {
+ 0
+ } else if !consumers.contains(n.step.as_str()) {
+ 2
+ } else {
+ 1
+ },
+ })
+ .collect(),
+ edges: dag
+ .order
+ .iter()
+ .flat_map(|n| {
+ n.needs
+ .iter()
+ .filter(|need| known.contains(need.as_str()))
+ .map(|need| Edge {
+ from: need.clone(),
+ to: n.step.clone(),
+ })
+ })
+ .collect(),
+ };
+ let max_pitch = 9_000_000.0 / dag.order.len().saturating_sub(1).max(1) as f32;
+ graph
+ .layered(LayeredOpts {
+ pitch: [240.0_f32.min(max_pitch), 120.0_f32.min(max_pitch)],
+ ..Default::default()
+ })
+ .expect("validated Brokkr DAG within Falsett budget");
+ let status = StatusTable {
+ nodes: dag.order.iter().map(|n| mapped_status(&n.step)).collect(),
+ edges: graph.edges.iter().map(|e| mapped_status(&e.to)).collect(),
+ };
+ FlowParts { graph, status }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::brokkr::{
+ history::{BuildRow, PipelineRunRow},
+ model::{Pipeline, PipelineFile, RunKind, Stage, Step},
+ };
+
+ fn fixture() -> Dag {
+ // Same multi-stage dependency diamond as the Brokkr DAG/engine fixture.
+ let file: PipelineFile = toml::from_str(
+ r#"
+[pipeline]
+name = "nornir-ci"
+[[pipeline.stage]]
+name = "build"
+[[pipeline.stage.step]]
+name = "compile"
+run = { variant = "server" }
+[[pipeline.stage]]
+name = "test"
+[[pipeline.stage.step]]
+name = "unit"
+needs = ["compile"]
+run = { container = { name = "rust:1.85", cmd = ["cargo", "test"] } }
+[[pipeline.stage.step]]
+name = "lint"
+needs = ["compile"]
+run = { variant = "lint" }
+[[pipeline.stage]]
+name = "deploy"
+[[pipeline.stage.step]]
+name = "ship"
+needs = ["unit", "lint"]
+run = { vm = "debian" }
+"#,
+ )
+ .unwrap();
+ Dag::build(&file.pipeline).unwrap()
+ }
+
+ fn report() -> RunReport {
+ RunReport {
+ run: PipelineRunRow {
+ run_id: "run-1".into(),
+ pipeline: "nornir-ci".into(),
+ project: "nornir".into(),
+ trigger: "manual".into(),
+ sha: None,
+ status: RunStatus::Failed,
+ steps: 4,
+ passed: 1,
+ duration_ms: 900,
+ job_ref: None,
+ finished_at: "2026-10-04T10:00:00Z".into(),
+ },
+ steps: [
+ ("compile", "build", RunStatus::Passed),
+ ("unit", "test", RunStatus::Failed),
+ ("lint", "test", RunStatus::Running),
+ ("ship", "deploy", RunStatus::Skipped),
+ ]
+ .into_iter()
+ .map(|(step, stage, status)| BuildRow {
+ run_id: "run-1".into(),
+ step: step.into(),
+ stage: stage.into(),
+ backend: "variant".into(),
+ matrix: String::new(),
+ status,
+ summary: String::new(),
+ artifact_id: None,
+ job_ref: None,
+ duration_ms: 100,
+ finished_at: "2026-10-04T10:00:00Z".into(),
+ })
+ .collect(),
+ }
+ }
+
+ #[test]
+ fn dag_fixture_keeps_stages_and_prerequisite_direction() {
+ let parts = run_graph(&fixture(), None);
+ assert_eq!((parts.graph.nodes.len(), parts.graph.edges.len()), (4, 4));
+ parts.graph.validate().unwrap();
+ let nodes: BTreeMap<_, _> = parts
+ .graph
+ .nodes
+ .iter()
+ .map(|n| (n.id.as_str(), n))
+ .collect();
+ assert_eq!(nodes["compile"].group, "build");
+ assert_eq!(nodes["ship"].group, "deploy");
+ for e in &parts.graph.edges {
+ assert!(nodes[e.from.as_str()].position[0] < nodes[e.to.as_str()].position[0]);
+ }
+ assert!(parts
+ .status
+ .nodes
+ .iter()
+ .chain(&parts.status.edges)
+ .all(Option::is_none));
+ }
+
+ #[test]
+ fn report_fixture_changes_attributes_without_relayout() {
+ let dag = fixture();
+ let plain = run_graph(&dag, None);
+ let mut report = report();
+ let actual = run_graph(&dag, Some(&report));
+ assert_eq!(actual.graph, plain.graph);
+ let statuses: BTreeMap<_, _> = actual
+ .graph
+ .nodes
+ .iter()
+ .zip(&actual.status.nodes)
+ .map(|(n, s)| (n.id.as_str(), *s))
+ .collect();
+ assert_eq!(statuses["compile"], Some(Status::Ok));
+ assert_eq!(statuses["unit"], Some(Status::Error));
+ assert_eq!(statuses["lint"], None);
+ assert_eq!(statuses["ship"], None);
+ for (edge, status) in actual.graph.edges.iter().zip(&actual.status.edges) {
+ assert_eq!(*status, statuses[edge.to.as_str()]);
+ }
+ // Reports cannot accidentally colour a node using a different run's row.
+ report.steps[0].run_id = "old-run".into();
+ let actual = run_graph(&dag, Some(&report));
+ let compile = actual
+ .graph
+ .nodes
+ .iter()
+ .position(|n| n.id == "compile")
+ .unwrap();
+ assert_eq!(actual.status.nodes[compile], None);
+ }
+
+ #[test]
+ fn empty_and_single_step_dags_are_valid() {
+ let empty = run_graph(&Dag { order: Vec::new() }, None);
+ empty.graph.validate().unwrap();
+ let dag = Dag::build(&Pipeline {
+ name: "one".into(),
+ stages: vec![Stage {
+ name: String::new(),
+ steps: vec![Step {
+ name: "compile".into(),
+ needs: Vec::new(),
+ run: RunKind::Variant("server".into()),
+ artifacts: Vec::new(),
+ report: None,
+ when: None,
+ }],
+ }],
+ ..Default::default()
+ })
+ .unwrap();
+ let parts = run_graph(&dag, None);
+ parts.graph.validate().unwrap();
+ assert_eq!(parts.graph.nodes[0].group, "steps");
+ assert_eq!(parts.status.nodes, [None]);
+ }
+ #[test]
+ fn actual_engine_report_maps_failure_and_downstream_skip() {
+ use crate::brokkr::{
+ engine::{run_pipeline, RunRequest},
+ runner::FakeRunner,
+ };
+ let pipeline = Pipeline {
+ name: "ci".into(),
+ stages: vec![Stage {
+ name: "build".into(),
+ steps: ["compile", "unit"]
+ .into_iter()
+ .enumerate()
+ .map(|(i, name)| Step {
+ name: name.into(),
+ needs: if i == 0 {
+ Vec::new()
+ } else {
+ vec!["compile".into()]
+ },
+ run: RunKind::Variant("server".into()),
+ artifacts: Vec::new(),
+ report: None,
+ when: None,
+ })
+ .collect(),
+ }],
+ ..Default::default()
+ };
+ let dag = Dag::build(&pipeline).unwrap();
+ let report = run_pipeline(
+ &pipeline,
+ &dag,
+ &FakeRunner::new().fail_step("compile"),
+ &RunRequest::default(),
+ "run-actual",
+ )
+ .unwrap();
+ let parts = run_graph(&dag, Some(&report));
+ let statuses: BTreeMap<_, _> = parts
+ .graph
+ .nodes
+ .iter()
+ .zip(&parts.status.nodes)
+ .map(|(n, s)| (n.id.as_str(), *s))
+ .collect();
+ assert_eq!(statuses["compile"], Some(Status::Error));
+ assert_eq!(statuses["unit"], None);
+ assert_eq!(
+ report
+ .steps
+ .iter()
+ .find(|r| r.step == "unit")
+ .unwrap()
+ .status,
+ RunStatus::Skipped
+ );
+ }
+}