mirror of
https://github.com/openclaw/lobster.git
synced 2026-08-14 00:48:09 +00:00
feat: add workflow graph visualization command
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user