feat: add workflow graph visualization command

This commit is contained in:
Vignesh Natarajan
2026-04-11 15:54:17 -07:00
parent 60c976571a
commit 7d6a22a0c3
5 changed files with 534 additions and 2 deletions
+1
View File
@@ -4,6 +4,7 @@ All notable changes to Lobster will be documented in this file.
## Unreleased
- Add `lobster graph` workflow visualization with `mermaid` (default), `dot`, and `ascii` outputs, including step-type nodes, `stdin` data-flow edges, conditional dependency labels (`when`/`condition`), approval-gate diamond shapes, and `--args-json` label resolution support. Thanks to [@vignesh07](https://github.com/vignesh07) (Issue [#53](https://github.com/openclaw/lobster/issues/53)).
- Add workflow composition via `workflow:` + `workflow_args`, including recursive sub-workflow execution, cycle detection, and dry-run visibility for workflow steps. Sub-workflow approval/input halts are rejected with resume-state cleanup. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#73](https://github.com/openclaw/lobster/pull/73)).
- Add per-step `on_error` workflow policies (`stop|continue|skip_rest`) for partial-failure recovery, with structured step error fields (`error`, `errorMessage`) for condition-based branching. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#72](https://github.com/openclaw/lobster/pull/72)).
- Add per-step workflow `timeout_ms` handling, including timeout-triggered aborts, `SIGKILL` for timed shell steps, and dry-run annotations. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#74](https://github.com/openclaw/lobster/pull/74)).
+25
View File
@@ -209,6 +209,31 @@ Notes:
- If you need a human checkpoint before an LLM call, use a dedicated `approval:` step in the workflow file rather than `approve` inside the nested pipeline.
- `cwd`, `env`, `stdin`, `when`, and `condition` work for both shell and pipeline steps.
## Visualizing workflows
Use `lobster graph` to inspect workflow structure before execution.
```bash
lobster graph --file path/to/workflow.lobster
lobster graph --file path/to/workflow.lobster --format mermaid
lobster graph --file path/to/workflow.lobster --format dot
lobster graph --file path/to/workflow.lobster --format ascii
lobster graph --file path/to/workflow.lobster --args-json '{"location":"Seattle"}'
```
What gets visualized:
- each workflow step as a node (`run`, `pipeline`, `approval`, etc.)
- data-flow edges from `stdin: $step.stdout` / `$step.json` references
- conditional dependencies from `when:` / `condition:` expressions
- approval gates as diamond-shaped nodes in `mermaid` and `dot` output
Format notes:
- `mermaid` (default): emits `flowchart TD` text for GitHub/Markdown rendering
- `dot`: emits Graphviz DOT syntax
- `ascii`: emits a terminal-friendly node/edge list
## Calling LLMs from workflows
Use `llm.invoke` from a native `pipeline:` step for model-backed work:
+150 -2
View File
@@ -3,7 +3,9 @@ import { createDefaultRegistry } from './commands/registry.js';
import { runPipeline } from './runtime.js';
import { decodeResumeToken, parseResumeArgs, resolveApprovalId } from './resume.js';
import { cleanupApprovalIndexByStateKey, deleteApprovalId } from './state/store.js';
import { WorkflowResumeArgumentError, runWorkflowFile } from './workflows/file.js';
import { WorkflowResumeArgumentError, loadWorkflowFile, resolveWorkflowArgs, runWorkflowFile } from './workflows/file.js';
import { renderWorkflowGraph } from './workflows/graph.js';
import type { WorkflowGraphFormat } from './workflows/graph.js';
import { deleteStateJson } from './state/store.js';
import {
finalizePipelineToolRun,
@@ -45,6 +47,11 @@ export async function runCli(argv) {
return;
}
if (argv[0] === 'graph') {
await handleGraph({ argv: argv.slice(1) });
return;
}
if (argv[0] === 'run') {
await handleRun({ argv: argv.slice(1), registry });
return;
@@ -59,6 +66,67 @@ export async function runCli(argv) {
await handleRun({ argv, registry });
}
async function handleGraph({ argv }) {
const parsed = parseGraphArgs(argv);
if (parsed.help) {
process.stdout.write(graphHelpText());
return;
}
if (!parsed.filePath) {
process.stderr.write('graph requires a workflow file path (use --file <path>)\n');
process.exitCode = 2;
return;
}
if (!isWorkflowGraphFormat(parsed.format)) {
process.stderr.write('graph --format must be one of: mermaid, dot, ascii\n');
process.exitCode = 2;
return;
}
let argsJson: Record<string, unknown> = {};
if (parsed.argsJson) {
try {
const value = JSON.parse(parsed.argsJson);
if (!value || typeof value !== 'object' || Array.isArray(value)) {
process.stderr.write('graph --args-json must be a JSON object\n');
process.exitCode = 2;
return;
}
argsJson = value as Record<string, unknown>;
} catch {
process.stderr.write('graph --args-json must be valid JSON\n');
process.exitCode = 2;
return;
}
}
let filePath: string;
try {
filePath = await resolveWorkflowFile(parsed.filePath);
} catch (err) {
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
return;
}
try {
const workflow = await loadWorkflowFile(filePath);
const args = resolveWorkflowArgs(workflow.args, argsJson);
const graph = renderWorkflowGraph({ workflow, format: parsed.format, args });
process.stdout.write(graph);
process.stdout.write('\n');
} catch (err) {
process.stderr.write(`Error: ${err?.message ?? String(err)}\n`);
process.exitCode = 1;
}
}
function isWorkflowGraphFormat(value: string): value is WorkflowGraphFormat {
return value === 'mermaid' || value === 'dot' || value === 'ascii';
}
async function handleRun({ argv, registry }) {
const parsed = parseRunArgs(argv);
const { mode, argsJson } = parsed;
@@ -289,6 +357,72 @@ function parseRunArgs(argv) {
return { mode, rest, filePath, argsJson, dryRun };
}
function parseGraphArgs(argv: string[]) {
const rest: string[] = [];
let filePath: string | null = null;
let format = 'mermaid';
let argsJson: string | null = null;
let help = false;
for (let i = 0; i < argv.length; i++) {
const tok = argv[i];
if (tok === '-h' || tok === '--help') {
help = true;
continue;
}
if (tok === '--file') {
const value = argv[i + 1];
if (value) {
filePath = value;
i++;
}
continue;
}
if (tok.startsWith('--file=')) {
filePath = tok.slice('--file='.length);
continue;
}
if (tok === '--format') {
const value = argv[i + 1];
if (value) {
format = value;
i++;
}
continue;
}
if (tok.startsWith('--format=')) {
format = tok.slice('--format='.length) || 'mermaid';
continue;
}
if (tok === '--args-json') {
const value = argv[i + 1];
if (value) {
argsJson = value;
i++;
}
continue;
}
if (tok.startsWith('--args-json=')) {
argsJson = tok.slice('--args-json='.length);
continue;
}
rest.push(tok);
}
if (!filePath && rest.length > 0) {
filePath = rest[0];
}
return { filePath, format, argsJson, help };
}
async function resolveRunTarget(parsed: {
rest: string[];
filePath: string | null;
@@ -600,6 +734,9 @@ function helpText() {
` lobster run --file path/to/workflow.lobster --args-json '{...}'\n` +
` lobster run --dry-run --file path/to/workflow.lobster\n` +
` lobster run --dry-run '<pipeline>'\n` +
` lobster graph --file path/to/workflow.lobster --format mermaid\n` +
` lobster graph --file path/to/workflow.lobster --format dot\n` +
` lobster graph --file path/to/workflow.lobster --format ascii\n` +
` lobster resume --token <token> --approve yes|no\n` +
` lobster resume --token <token> --response-json '{...}'\n` +
` lobster resume --token <token> --cancel\n` +
@@ -615,5 +752,16 @@ function helpText() {
` lobster 'exec --json "echo [1,2,3]" | json'\n` +
` lobster run --mode tool 'exec --json "echo [1]" | approve --prompt "ok?"'\n\n` +
`Commands:\n` +
` exec, head, json, pick, table, where, approve, ask, openclaw.invoke, llm.invoke, llm_task.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run\n`;
` exec, head, json, pick, table, where, approve, ask, openclaw.invoke, llm.invoke, llm_task.invoke, state.get, state.set, diff.last, commands.list, workflows.list, workflows.run, graph\n`;
}
function graphHelpText() {
return `lobster graph — render workflow step graphs\n\n` +
`Usage:\n` +
` lobster graph --file path/to/workflow.lobster [--format mermaid|dot|ascii] [--args-json '{...}']\n` +
` lobster graph path/to/workflow.lobster [--format mermaid|dot|ascii]\n\n` +
`Flags:\n` +
` --file Workflow file path (.lobster, .yaml, .yml, .json)\n` +
` --format Output format: mermaid (default), dot, ascii\n` +
` --args-json JSON object used to resolve workflow args for labels\n`;
}
+250
View File
@@ -0,0 +1,250 @@
import type { WorkflowFile, WorkflowStep } from './file.js';
export type WorkflowGraphFormat = 'mermaid' | 'dot' | 'ascii';
type GraphNode = {
id: string;
type: string;
label: string;
shape: 'box' | 'diamond';
};
type GraphEdge = {
from: string;
to: string;
label?: string;
};
type RenderGraphParams = {
workflow: WorkflowFile;
format: WorkflowGraphFormat;
args?: Record<string, unknown>;
};
function resolveArgsTemplate(input: string, args: Record<string, unknown>) {
return input.replace(/\$\{([A-Za-z0-9_-]+)\}/g, (match, key) => {
if (key in args) return String(args[key]);
return match;
});
}
function isApprovalStep(step: WorkflowStep) {
if (step.approval === true) return true;
if (typeof step.approval === 'string' && step.approval.trim().length > 0) return true;
if (step.approval && typeof step.approval === 'object' && !Array.isArray(step.approval)) return true;
return false;
}
function isInputStep(step: WorkflowStep) {
return Boolean(step.input && typeof step.input === 'object' && !Array.isArray(step.input));
}
function stepType(step: WorkflowStep) {
if (step.parallel) return 'parallel';
if (typeof step.for_each === 'string') return 'for_each';
if (typeof step.workflow === 'string' && step.workflow.trim()) return 'workflow';
if (typeof step.pipeline === 'string' && step.pipeline.trim()) return 'pipeline';
if (typeof step.run === 'string' || typeof step.command === 'string') return 'run';
if (isApprovalStep(step)) return 'approval';
if (isInputStep(step)) return 'input';
return 'step';
}
function stepDetails(step: WorkflowStep, args: Record<string, unknown>) {
if (step.parallel) {
return `parallel (${step.parallel.wait ?? 'all'})`;
}
if (typeof step.for_each === 'string') {
return `for_each: ${resolveArgsTemplate(step.for_each, args)}`;
}
if (typeof step.workflow === 'string' && step.workflow.trim()) {
return `workflow: ${resolveArgsTemplate(step.workflow, args)}`;
}
if (typeof step.pipeline === 'string' && step.pipeline.trim()) {
return `pipeline: ${resolveArgsTemplate(step.pipeline, args)}`;
}
const shell = typeof step.run === 'string' ? step.run : step.command;
if (typeof shell === 'string' && shell.trim()) {
return `run: ${resolveArgsTemplate(shell, args)}`;
}
if (isApprovalStep(step)) return 'approval gate';
if (isInputStep(step)) return 'input request';
return '';
}
function extractStepRefsFromString(value: string): string[] {
const refs = new Set<string>();
const rx = /\$([A-Za-z0-9_-]+)\.[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*/g;
for (const m of value.matchAll(rx)) {
if (m[1]) refs.add(m[1]);
}
return [...refs];
}
function extractStepRefs(value: unknown): string[] {
if (typeof value === 'string') return extractStepRefsFromString(value);
if (Array.isArray(value)) {
const refs = new Set<string>();
for (const item of value) {
for (const ref of extractStepRefs(item)) refs.add(ref);
}
return [...refs];
}
if (value && typeof value === 'object') {
const refs = new Set<string>();
for (const v of Object.values(value as Record<string, unknown>)) {
for (const ref of extractStepRefs(v)) refs.add(ref);
}
return [...refs];
}
return [];
}
function truncate(value: string, max = 80) {
if (value.length <= max) return value;
return `${value.slice(0, max - 1)}`;
}
function collectGraph(workflow: WorkflowFile, args: Record<string, unknown>) {
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const knownStepIds = new Set(workflow.steps.map((s) => s.id));
let prevStepId: string | null = null;
const seenEdgeKeys = new Set<string>();
const addEdge = (edge: GraphEdge) => {
const key = `${edge.from}|${edge.to}|${edge.label ?? ''}`;
if (seenEdgeKeys.has(key)) return;
seenEdgeKeys.add(key);
edges.push(edge);
};
for (const step of workflow.steps) {
const type = stepType(step);
const details = stepDetails(step, args);
const label = details ? `${step.id}\\n${truncate(details)}` : step.id;
nodes.push({
id: step.id,
type,
label,
shape: isApprovalStep(step) ? 'diamond' : 'box',
});
if (prevStepId) {
addEdge({ from: prevStepId, to: step.id, label: 'next' });
}
prevStepId = step.id;
for (const ref of extractStepRefs(step.stdin)) {
if (knownStepIds.has(ref)) addEdge({ from: ref, to: step.id, label: 'stdin' });
}
if (typeof step.for_each === 'string') {
for (const ref of extractStepRefs(step.for_each)) {
if (knownStepIds.has(ref)) addEdge({ from: ref, to: step.id, label: 'for_each' });
}
}
const condition = step.when ?? step.condition;
if (typeof condition === 'string' && condition.trim()) {
const labelValue = truncate(`when: ${condition.trim()}`, 70);
for (const ref of extractStepRefs(condition)) {
if (knownStepIds.has(ref)) addEdge({ from: ref, to: step.id, label: labelValue });
}
}
}
return { nodes, edges };
}
function sanitizeMermaidId(id: string) {
return id.replace(/[^A-Za-z0-9_]/g, '_');
}
function escapeMermaidLabel(value: string) {
return value.replace(/"/g, '\\"');
}
function renderMermaid(nodes: GraphNode[], edges: GraphEdge[]) {
const idMap = new Map<string, string>();
const used = new Set<string>();
for (const node of nodes) {
let key = sanitizeMermaidId(node.id) || 'step';
if (/^\d/.test(key)) key = `s_${key}`;
let i = 2;
while (used.has(key)) {
key = `${sanitizeMermaidId(node.id)}_${i}`;
i += 1;
}
used.add(key);
idMap.set(node.id, key);
}
const lines = ['flowchart TD'];
for (const node of nodes) {
const key = idMap.get(node.id)!;
const label = escapeMermaidLabel(node.label);
if (node.shape === 'diamond') {
lines.push(` ${key}{"${label}"}`);
} else {
lines.push(` ${key}["${label}"]`);
}
}
if (nodes.length) lines.push('');
for (const edge of edges) {
const from = idMap.get(edge.from);
const to = idMap.get(edge.to);
if (!from || !to) continue;
if (edge.label) {
lines.push(` ${from} -->|${escapeMermaidLabel(edge.label)}| ${to}`);
} else {
lines.push(` ${from} --> ${to}`);
}
}
return lines.join('\n');
}
function escapeDot(value: string) {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function renderDot(nodes: GraphNode[], edges: GraphEdge[]) {
const lines = ['digraph workflow {', ' rankdir=TB;'];
for (const node of nodes) {
const shape = node.shape === 'diamond' ? 'diamond' : 'box';
lines.push(` "${escapeDot(node.id)}" [shape=${shape},label="${escapeDot(node.label)}"];`);
}
if (nodes.length) lines.push('');
for (const edge of edges) {
if (edge.label) {
lines.push(
` "${escapeDot(edge.from)}" -> "${escapeDot(edge.to)}" [label="${escapeDot(edge.label)}"];`,
);
} else {
lines.push(` "${escapeDot(edge.from)}" -> "${escapeDot(edge.to)}";`);
}
}
lines.push('}');
return lines.join('\n');
}
function renderAscii(nodes: GraphNode[], edges: GraphEdge[]) {
const lines = ['Workflow Graph', '', 'Nodes:'];
for (const node of nodes) {
lines.push(`- ${node.id} [${node.type}] ${node.label.includes('\\n') ? `(${node.label.split('\\n')[1]})` : ''}`.trim());
}
lines.push('', 'Edges:');
for (const edge of edges) {
lines.push(`- ${edge.from} -> ${edge.to}${edge.label ? ` (${edge.label})` : ''}`);
}
if (edges.length === 0) lines.push('- (none)');
return lines.join('\n');
}
export function renderWorkflowGraph({ workflow, format, args = {} }: RenderGraphParams) {
const { nodes, edges } = collectGraph(workflow, args);
if (format === 'dot') return renderDot(nodes, edges);
if (format === 'ascii') return renderAscii(nodes, edges);
return renderMermaid(nodes, edges);
}
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fsp } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { renderWorkflowGraph } from '../src/workflows/graph.js';
function runCli(args: string[], env?: Record<string, string | undefined>) {
const bin = path.join(process.cwd(), 'bin', 'lobster.js');
return spawnSync(process.execPath, [bin, ...args], {
encoding: 'utf8',
env: { ...process.env, ...(env ?? {}) },
});
}
test('workflow graph renderer outputs mermaid nodes and labeled edges', () => {
const workflow = {
args: { city: { default: 'Phoenix' } },
steps: [
{ id: 'fetch', run: 'weather --json ${city}' },
{ id: 'confirm', approval: 'Proceed?', stdin: '$fetch.json' },
{
id: 'advice',
pipeline: 'llm.invoke --prompt "Summarize this weather"',
stdin: '$fetch.stdout',
when: '$confirm.approved && $fetch.json.temp > 70',
},
],
};
const output = renderWorkflowGraph({ workflow, format: 'mermaid', args: { city: 'Seattle' } });
assert.match(output, /^flowchart TD/m);
assert.match(output, /fetch\["fetch\\nrun: weather --json Seattle"\]/);
assert.match(output, /confirm\{"confirm\\napproval gate"\}/);
assert.match(output, /advice\["advice\\npipeline: llm\.invoke --prompt \\"Summarize this weather\\""\]/);
assert.match(output, /fetch -->\|stdin\| confirm/);
assert.match(output, /fetch -->\|stdin\| advice/);
assert.match(output, /confirm -->\|when: \$confirm\.approved && \$fetch\.json\.temp > 70\| advice/);
});
test('workflow graph renderer outputs dot with approval shape', () => {
const workflow = {
steps: [
{ id: 'fetch', run: 'echo hello' },
{ id: 'confirm', approval: 'Proceed?', stdin: '$fetch.stdout' },
],
};
const output = renderWorkflowGraph({ workflow, format: 'dot' });
assert.match(output, /^digraph workflow \{/m);
assert.match(output, /"confirm" \[shape=diamond,label="confirm\\\\napproval gate"\];/);
assert.match(output, /"fetch" -> "confirm" \[label="stdin"\];/);
});
test('cli graph defaults to mermaid and resolves --args-json values', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-graph-cli-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
const workflow = [
'name: weather-check',
'args:',
' city:',
' default: Phoenix',
'steps:',
' - id: fetch',
' run: weather --json ${city}',
' - id: confirm',
' approval: Proceed?',
' stdin: $fetch.json',
].join('\n');
await fsp.writeFile(filePath, workflow, 'utf8');
const result = runCli(['graph', '--file', filePath, '--args-json', '{"city":"Seattle"}']);
assert.equal(result.status, 0, `stderr=${result.stderr}`);
assert.match(result.stdout, /^flowchart TD/m);
assert.match(result.stdout, /run: weather --json Seattle/);
assert.match(result.stdout, /confirm\{"confirm\\napproval gate"\}/);
});
test('cli graph supports --format dot', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-graph-dot-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
const workflow = [
'steps:',
' - id: fetch',
' run: echo hello',
' - id: gate',
' approval: Proceed?',
' stdin: $fetch.stdout',
].join('\n');
await fsp.writeFile(filePath, workflow, 'utf8');
const result = runCli(['graph', '--file', filePath, '--format', 'dot']);
assert.equal(result.status, 0, `stderr=${result.stderr}`);
assert.match(result.stdout, /^digraph workflow \{/m);
assert.match(result.stdout, /"gate" \[shape=diamond/);
});
test('cli graph rejects unsupported formats', async () => {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-graph-bad-format-'));
const filePath = path.join(tmpDir, 'workflow.lobster');
await fsp.writeFile(filePath, 'steps:\n - id: s\n run: echo ok\n', 'utf8');
const result = runCli(['graph', '--file', filePath, '--format', 'svg']);
assert.equal(result.status, 2);
assert.match(result.stderr, /graph --format must be one of: mermaid, dot, ascii/);
});