From 32015b2226f7ed4998d44cf8fc21d2841b245592 Mon Sep 17 00:00:00 2001
From: Bryan Helmkamp <19+brynary@users.noreply.github.com>
Date: Wed, 20 May 2026 09:31:08 -0400
Subject: [PATCH] fix(graph): support dotted Fabro graph attributes (#324)
## Summary
Graph rendering now accepts documented Fabro dotted DOT attributes end
to end while keeping raw Graphviz calls behind `fabro-graphviz`. The new
`RenderableDot` boundary applies Fabro render styling and normalization
before raw SVG rendering, and both the CLI subprocess and server path
now route through that typed boundary instead of calling `graphviz_sys`
directly outside the graphviz crate.
The branch also adds a small curated DOT compatibility corpus covering
ACP agent attributes, human default choices, and subworkflow manager
attributes. Those fixtures are exercised by both render and validation
tests, and the run overview now shows graph render errors directly
instead of falling through to the empty graph state.
## Verification
- `cargo nextest run -p fabro-graphviz`
- `cargo nextest run -p fabro-validate`
- `cargo nextest run -p fabro-cli render_graph`
- `cargo nextest run -p fabro-server
render_graph_from_manifest_accepts_fabro_dotted_attributes`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy -p fabro-graphviz -p fabro-cli -p
fabro-server -p fabro-validate --all-targets -- -D warnings`
- `rg -n "graphviz_sys" lib/crates/fabro-cli lib/crates/fabro-server`
- `cd apps/fabro-web && bun test`
- `cd apps/fabro-web && bun run typecheck`
---
[](https://github.com/EveryInc/compound-engineering-plugin)
Generated with GPT-5 via [Codex](https://openai.com/codex)
---------
Co-authored-by: Jess Martin <27258+jessmartin@users.noreply.github.com>
---
Cargo.lock | 1 -
.../app/routes/run-overview.test.tsx | 88 +++++
apps/fabro-web/app/routes/run-overview.tsx | 15 +-
lib/crates/fabro-cli/Cargo.toml | 1 -
.../fabro-cli/src/commands/render_graph.rs | 5 +-
.../fabro-cli/tests/it/cmd/render_graph.rs | 41 ++
lib/crates/fabro-graphviz/src/render.rs | 354 +++++++++++++++++-
.../fabro-server/src/server/handler/graph.rs | 9 +-
lib/crates/fabro-server/src/server/tests.rs | 50 +++
lib/crates/fabro-validate/src/lib.rs | 48 +++
test/dot-compatibility/acp-agent-chain.fabro | 23 ++
.../human-default-choice.fabro | 17 +
.../subworkflow-manager.fabro | 25 ++
13 files changed, 663 insertions(+), 14 deletions(-)
create mode 100644 apps/fabro-web/app/routes/run-overview.test.tsx
create mode 100644 test/dot-compatibility/acp-agent-chain.fabro
create mode 100644 test/dot-compatibility/human-default-choice.fabro
create mode 100644 test/dot-compatibility/subworkflow-manager.fabro
diff --git a/Cargo.lock b/Cargo.lock
index 4b0fbcbdc..63d11f266 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1761,7 +1761,6 @@ dependencies = [
"fs2",
"futures",
"git2",
- "graphviz-sys",
"hkdf",
"httpmock",
"indicatif",
diff --git a/apps/fabro-web/app/routes/run-overview.test.tsx b/apps/fabro-web/app/routes/run-overview.test.tsx
new file mode 100644
index 000000000..41caeaa8b
--- /dev/null
+++ b/apps/fabro-web/app/routes/run-overview.test.tsx
@@ -0,0 +1,88 @@
+import { afterEach, describe, expect, mock, test } from "bun:test";
+import TestRenderer, { act } from "react-test-renderer";
+import { MemoryRouter, Route, Routes } from "react-router";
+
+import { ApiError } from "../lib/api-client";
+
+let currentGraphData: string | null | undefined;
+let currentGraphError: Error | undefined;
+let currentGraphLoading = false;
+
+const graphMutateMock = mock(() => Promise.resolve(currentGraphData));
+
+mock.module("../lib/queries", () => ({
+ useRun: () => ({ data: undefined }),
+ useRunStages: () => ({ data: undefined }),
+ useRunGraph: () => ({
+ data: currentGraphData,
+ error: currentGraphError,
+ isLoading: currentGraphLoading,
+ mutate: graphMutateMock,
+ }),
+}));
+
+mock.module("../components/run-summary-panel", () => ({
+ RunSummaryPanel: () =>
summary
,
+}));
+
+mock.module("../components/stage-sidebar", () => ({
+ StageSidebar: () => ,
+}));
+
+const { default: RunOverview } = await import("./run-overview");
+
+const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
+
+function textFromNode(
+ node: ReturnType,
+): string {
+ if (!node) return "";
+ if (typeof node === "string") return node;
+ if (Array.isArray(node)) return node.map(textFromNode).join(" ");
+ return (node.children ?? []).map(textFromNode).join(" ");
+}
+
+function render(): TestRenderer.ReactTestRenderer {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ let renderer!: TestRenderer.ReactTestRenderer;
+ act(() => {
+ renderer = TestRenderer.create(
+
+
+ } />
+
+ ,
+ );
+ });
+ mountedRenderers.push(renderer);
+ return renderer;
+}
+
+afterEach(() => {
+ for (const renderer of mountedRenderers.splice(0)) {
+ act(() => renderer.unmount());
+ }
+ currentGraphData = undefined;
+ currentGraphError = undefined;
+ currentGraphLoading = false;
+ graphMutateMock.mockClear();
+ delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
+});
+
+describe("RunOverview", () => {
+ test("shows graph render errors instead of the empty graph state", () => {
+ currentGraphError = new ApiError({
+ status: 400,
+ message: "failed to parse DOT source",
+ requestId: "req_123",
+ body: null,
+ });
+
+ const renderer = render();
+ const text = textFromNode(renderer.toJSON());
+
+ expect(text).toContain("Couldn't render workflow graph");
+ expect(text).toContain("failed to parse DOT source");
+ expect(text).not.toContain("No workflow graph");
+ });
+});
diff --git a/apps/fabro-web/app/routes/run-overview.tsx b/apps/fabro-web/app/routes/run-overview.tsx
index 71f1de877..6a62cf50c 100644
--- a/apps/fabro-web/app/routes/run-overview.tsx
+++ b/apps/fabro-web/app/routes/run-overview.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphTheme } from "../lib/graph-theme";
+import { ApiError } from "../lib/api-client";
import { useRun, useRunGraph, useRunStages } from "../lib/queries";
import { RunSummaryPanel } from "../components/run-summary-panel";
import { StageSidebar } from "../components/stage-sidebar";
@@ -9,7 +10,7 @@ import {
GRAPH_ZOOM_STEPS,
GraphToolbar,
} from "../components/graph-toolbar";
-import { EmptyState } from "../components/state";
+import { EmptyState, ErrorState } from "../components/state";
import {
ACTIVE_STAGE_STATES,
SUCCEEDED_STAGE_STATES,
@@ -32,6 +33,12 @@ export default function RunOverview() {
[stagesQuery.data],
);
const graphSvg = graphQuery.data;
+ const graphErrorDescription =
+ graphQuery.error instanceof ApiError
+ ? graphQuery.error.message
+ : graphQuery.error
+ ? "The graph render request failed."
+ : undefined;
const apiStatus = runQuery.data?.lifecycle.status;
const terminalOutcome: "succeeded" | "failed" | "dead" | null =
apiStatus?.kind === "succeeded" ||
@@ -223,6 +230,12 @@ export default function RunOverview() {
/>
+ ) : graphQuery.error ? (
+ void graphQuery.mutate()}
+ />
) : (
i32 {
@@ -18,7 +20,8 @@ pub(crate) fn execute() -> i32 {
return 1;
}
- match graphviz_sys::render_dot_to_svg(&dot_source) {
+ let dot = render::RenderableDot::from_fabro_source(&dot_source);
+ match render::render_raw_svg(&dot) {
Ok(svg) => {
if std::io::stdout().write_all(&svg).is_err() {
return 1;
diff --git a/lib/crates/fabro-cli/tests/it/cmd/render_graph.rs b/lib/crates/fabro-cli/tests/it/cmd/render_graph.rs
index 22714fdb4..27644fe33 100644
--- a/lib/crates/fabro-cli/tests/it/cmd/render_graph.rs
+++ b/lib/crates/fabro-cli/tests/it/cmd/render_graph.rs
@@ -77,6 +77,47 @@ fn render_graph_outputs_svg() {
);
}
+#[test]
+fn render_graph_accepts_fabro_dotted_attributes() {
+ let context = test_context!();
+ let mut cmd = render_graph_command(&context);
+ cmd.args(["__render-graph"])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+
+ let mut child = cmd.spawn().expect("render-graph subprocess should spawn");
+ child
+ .stdin
+ .as_mut()
+ .expect("stdin should be piped")
+ .write_all(
+ br#"digraph X {
+ start [shape=Mdiamond]
+ exit [shape=Msquare]
+ a [label="A", acp.command="codex"]
+ start -> a -> exit
+ }"#,
+ )
+ .expect("stdin write should succeed");
+
+ let output = child
+ .wait_with_output()
+ .expect("render-graph subprocess should exit");
+
+ assert!(
+ output.status.success(),
+ "stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let stdout = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8");
+ assert!(
+ stdout.contains("