feat(api): expose workflow graph source as raw DOT

Add GET /api/v1/runs/{id}/graph/source returning text/vnd.graphviz so
the run graph can be inspected as the original Graphviz DOT in addition
to the rendered SVG. Refactor get_graph to share DOT loading with the
new handler. The web run-graph view gains a Graph | Source toggle that
lazy-loads and displays the DOT with a copy button.
This commit is contained in:
Bryan Helmkamp 2026-04-27 16:24:17 -07:00
parent cdc43e05fc
commit fd1087fe2d
No known key found for this signature in database
7 changed files with 311 additions and 49 deletions

View file

@ -91,6 +91,13 @@ export function useRunGraph(id: string | undefined, direction?: "LR" | "TB") {
);
}
export function useRunGraphSource(id: string | undefined, enabled: boolean) {
return useSWR<string | null>(
id && enabled ? queryKeys.runs.graphSource(id) : null,
apiNullableTextFetcher,
);
}
export function useRunLogs(id: string | undefined, refreshInterval?: number) {
return useSWR<string | null>(
id ? queryKeys.runs.logs(id) : null,

View file

@ -32,6 +32,7 @@ export const queryKeys = {
stages: (id: string) => `/api/v1/runs/${pathSegment(id)}/stages`,
graph: (id: string, direction?: "LR" | "TB") =>
withQuery(`/api/v1/runs/${pathSegment(id)}/graph`, { direction }),
graphSource: (id: string) => `/api/v1/runs/${pathSegment(id)}/graph/source`,
settings: (id: string) => `/api/v1/runs/${pathSegment(id)}/settings`,
logs: (id: string) => `/api/v1/runs/${pathSegment(id)}/logs`,
billing: (id: string) => `/api/v1/runs/${pathSegment(id)}/billing`,

View file

@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router";
import { graphTheme } from "../lib/graph-theme";
import { useRunGraph, useRunStages } from "../lib/queries";
import { useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries";
import { CopyButton } from "../components/ui";
import { LoadingState } from "../components/state";
import { StageSidebar } from "../components/stage-sidebar";
import {
GRAPH_DEFAULT_ZOOM_INDEX,
@ -69,11 +71,15 @@ function stripGraphTitle(svg: SVGSVGElement) {
title.remove();
}
type View = "graph" | "source";
export default function RunGraph() {
const { id } = useParams();
const [direction, setDirection] = useState<Direction>("LR");
const [view, setView] = useState<View>("graph");
const stagesQuery = useRunStages(id);
const graphQuery = useRunGraph(id, direction);
const sourceQuery = useRunGraphSource(id, view === "source");
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),
[stagesQuery.data],
@ -184,35 +190,94 @@ export default function RunGraph() {
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} activeLink="graph" />
<div className="min-w-0 flex-1">
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
<GraphToolbar
direction={direction}
setDirection={setDirection}
fitToWindow={fitToWindow}
zoomIndex={zoomIndex}
setZoomIndex={setZoomIndex}
/>
<div className="min-w-0 flex-1 space-y-3">
<ViewToggle view={view} setView={setView} />
{view === "graph" ? (
<div className="graph-svg relative rounded-md border border-line bg-panel-alt">
<GraphToolbar
direction={direction}
setDirection={setDirection}
fitToWindow={fitToWindow}
zoomIndex={zoomIndex}
setZoomIndex={setZoomIndex}
/>
<div
ref={containerRef}
className="overflow-hidden p-6"
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
ref={innerRef}
className="flex items-center justify-center"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
ref={containerRef}
className="overflow-hidden p-6"
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<p className="text-sm text-fg-muted">Loading diagram...</p>
<div
ref={innerRef}
className="flex items-center justify-center"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
>
<p className="text-sm text-fg-muted">Loading diagram...</p>
</div>
</div>
</div>
</div>
) : (
<SourcePanel source={sourceQuery.data} loading={sourceQuery.data === undefined && !sourceQuery.error} />
)}
</div>
</div>
);
}
function ViewToggle({ view, setView }: { view: View; setView: (v: View) => void }) {
const btn =
"rounded px-3 py-1.5 text-xs font-medium transition-colors";
return (
<div role="group" aria-label="Graph view" className="inline-flex rounded-md border border-line bg-panel/80 p-0.5">
<button
type="button"
onClick={() => setView("graph")}
aria-pressed={view === "graph"}
className={`${btn} ${view === "graph" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
>
Graph
</button>
<button
type="button"
onClick={() => setView("source")}
aria-pressed={view === "source"}
className={`${btn} ${view === "source" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
>
Source
</button>
</div>
);
}
function SourcePanel({ source, loading }: { source: string | null | undefined; loading: boolean }) {
if (loading) {
return (
<div className="rounded-md border border-line bg-panel-alt p-4">
<LoadingState label="Loading graph source…" />
</div>
);
}
if (!source) {
return (
<div className="rounded-md border border-line bg-panel-alt p-4">
<p className="text-sm text-fg-muted">No graph source available for this run.</p>
</div>
);
}
return (
<div className="rounded-md border border-line bg-panel-alt">
<div className="flex items-center justify-between gap-3 border-b border-line px-3 py-2">
<span className="font-mono text-xs text-fg-muted">workflow.fabro</span>
<CopyButton value={source} label="Copy graph source" />
</div>
<pre className="max-h-[70vh] overflow-auto whitespace-pre p-4 font-mono text-xs leading-5 text-fg-2">
{source}
</pre>
</div>
);
}

View file

@ -1049,6 +1049,28 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/graph/source:
get:
operationId: retrieveRunGraphSource
tags: [Runs]
summary: Retrieve Graphviz DOT source
description: Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow's `.fabro` file).
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"200":
description: Graphviz DOT source
content:
text/vnd.graphviz:
schema:
type: string
"404":
description: Run not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/checkpoint:
get:
operationId: retrieveRunCheckpoint

View file

@ -414,15 +414,27 @@ pub(crate) async fn unpause_stub(
(StatusCode::OK, Json(serde_json::json!({"id": id, "status": "running", "created_at": "2026-03-06T14:30:00Z"}))).into_response()
}
const DEMO_GRAPH_DOT: &str = "digraph demo {\n graph [goal=\"Demo\"]\n rankdir=LR\n start [shape=Mdiamond, label=\"Start\"]\n detect [label=\"Detect\\nDrift\"]\n exit [shape=Msquare, label=\"Exit\"]\n propose [label=\"Propose\\nChanges\"]\n review [label=\"Review\\nChanges\"]\n apply [label=\"Apply\\nChanges\"]\n start -> detect\n detect -> exit [label=\"No drift\"]\n detect -> propose [label=\"Drift found\"]\n propose -> review\n review -> propose [label=\"Revise\"]\n review -> apply [label=\"Accept\"]\n apply -> exit\n}";
pub(crate) async fn get_run_graph(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
// Use graphviz to render the demo DOT source
let dot_source = "digraph demo {\n graph [goal=\"Demo\"]\n rankdir=LR\n start [shape=Mdiamond, label=\"Start\"]\n detect [label=\"Detect\\nDrift\"]\n exit [shape=Msquare, label=\"Exit\"]\n propose [label=\"Propose\\nChanges\"]\n review [label=\"Review\\nChanges\"]\n apply [label=\"Apply\\nChanges\"]\n start -> detect\n detect -> exit [label=\"No drift\"]\n detect -> propose [label=\"Drift found\"]\n propose -> review\n review -> propose [label=\"Revise\"]\n review -> apply [label=\"Accept\"]\n apply -> exit\n}";
crate::server::render_graph_bytes(DEMO_GRAPH_DOT).await
}
crate::server::render_graph_bytes(dot_source).await
pub(crate) async fn get_run_graph_source(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
(
StatusCode::OK,
[("content-type", "text/vnd.graphviz")],
DEMO_GRAPH_DOT,
)
.into_response()
}
pub(crate) async fn list_secrets(

View file

@ -1138,6 +1138,7 @@ fn demo_routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/pause", post(demo::pause_stub))
.route("/runs/{id}/unpause", post(demo::unpause_stub))
.route("/runs/{id}/graph", get(demo::get_run_graph))
.route("/runs/{id}/graph/source", get(demo::get_run_graph_source))
.route("/runs/{id}/stages", get(demo::get_run_stages))
.route("/runs/{id}/artifacts", get(demo::list_run_artifacts_stub))
.route("/runs/{id}/files", get(demo::list_run_files_stub))
@ -1238,6 +1239,7 @@ fn real_routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/timeline", get(run_timeline))
.route("/runs/{id}/unarchive", post(unarchive_run))
.route("/runs/{id}/graph", get(get_graph))
.route("/runs/{id}/graph/source", get(get_graph_source))
.route("/runs/{id}/stages", get(list_run_stages))
.route("/runs/{id}/artifacts", get(list_run_artifacts))
.route("/runs/{id}/files", get(list_run_files))
@ -7931,6 +7933,33 @@ struct GraphParams {
direction: Option<String>,
}
async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result<String, Response> {
let live_dot_source = {
let runs = state.runs.lock().expect("runs lock poisoned");
runs.get(id).map(|managed_run| managed_run.dot_source.clone())
};
let dot_source = if let Some(dot) = live_dot_source.filter(|d| !d.is_empty()) {
Some(dot)
} else {
match state.store.open_run_reader(id).await {
Ok(run_store) => match run_store.state().await {
Ok(run_state) => run_state.graph_source,
Err(err) => {
return Err(
ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(),
);
}
},
Err(_) => return Err(ApiError::not_found("Run not found.").into_response()),
}
};
dot_source.ok_or_else(|| {
ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response()
})
}
async fn get_graph(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
@ -7942,28 +7971,9 @@ async fn get_graph(
Err(response) => return response,
};
let live_dot_source = {
let runs = state.runs.lock().expect("runs lock poisoned");
runs.get(&id)
.map(|managed_run| managed_run.dot_source.clone())
};
let dot_source = if let Some(dot) = live_dot_source.filter(|d| !d.is_empty()) {
Some(dot)
} else {
match state.store.open_run_reader(&id).await {
Ok(run_store) => match run_store.state().await {
Ok(run_state) => run_state.graph_source,
Err(err) => {
return ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response();
}
},
Err(_) => return ApiError::not_found("Run not found.").into_response(),
}
};
let Some(dot) = dot_source else {
return ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response();
let dot = match load_run_dot_source(&state, &id).await {
Ok(dot) => dot,
Err(response) => return response,
};
let dot = match params.direction.as_deref() {
@ -7977,6 +7987,22 @@ async fn get_graph(
render_graph_bytes(&dot).await
}
async fn get_graph_source(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
match load_run_dot_source(&state, &id).await {
Ok(dot) => (StatusCode::OK, [("content-type", "text/vnd.graphviz")], dot).into_response(),
Err(response) => response,
}
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,
@ -11784,6 +11810,7 @@ slug = "fabro"
(Method::POST, format!("/runs/{run_id}/archive")),
(Method::POST, format!("/runs/{run_id}/unarchive")),
(Method::GET, format!("/runs/{run_id}/graph")),
(Method::GET, format!("/runs/{run_id}/graph/source")),
(Method::GET, format!("/runs/{run_id}/stages")),
(Method::GET, format!("/runs/{run_id}/artifacts")),
(Method::GET, format!("/runs/{run_id}/files")),
@ -12120,6 +12147,60 @@ slug = "fabro"
);
}
#[tokio::test]
async fn get_graph_source_returns_dot() {
let state = create_app_state();
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"version": 1,
"cwd": "/tmp",
"target": {
"identifier": "workflow.fabro",
"path": "workflow.fabro",
},
"workflows": {
"workflow.fabro": {
"source": MINIMAL_DOT,
"files": {},
},
},
}))
.unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/graph/source")))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
let response = checked_response!(response, StatusCode::OK).await;
let content_type = response
.headers()
.get("content-type")
.expect("content-type header should be present")
.to_str()
.unwrap();
assert_eq!(content_type, "text/vnd.graphviz");
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let dot = String::from_utf8(bytes.to_vec()).unwrap();
assert_eq!(dot, MINIMAL_DOT);
}
#[tokio::test]
async fn render_graph_from_manifest_returns_svg() {
let app = test_app_with();

View file

@ -786,6 +786,46 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow\'s `.fabro` file).
* @summary Retrieve Graphviz DOT source
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveRunGraphSource: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('retrieveRunGraphSource', 'id', id)
const localVarPath = `/api/v1/runs/{id}/graph/source`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'text/vnd.graphviz,application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
@ -1230,6 +1270,19 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.retrieveRunGraph']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow\'s `.fabro` file).
* @summary Retrieve Graphviz DOT source
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async retrieveRunGraphSource(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<string>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRunGraphSource(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.retrieveRunGraphSource']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
@ -1483,6 +1536,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
retrieveRunGraph(id: string, options?: RawAxiosRequestConfig): AxiosPromise<string> {
return localVarFp.retrieveRunGraph(id, options).then((request) => request(axios, basePath));
},
/**
* Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow\'s `.fabro` file).
* @summary Retrieve Graphviz DOT source
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveRunGraphSource(id: string, options?: RawAxiosRequestConfig): AxiosPromise<string> {
return localVarFp.retrieveRunGraphSource(id, options).then((request) => request(axios, basePath));
},
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
@ -1736,6 +1799,17 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).retrieveRunGraph(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns the raw Graphviz DOT source for the workflow graph (the contents of the workflow\'s `.fabro` file).
* @summary Retrieve Graphviz DOT source
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public retrieveRunGraphSource(id: string, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).retrieveRunGraphSource(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run