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("