Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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
+        );
+    }
+}