Version: 0.4.0 | Spec: v3.16 core + VLCode extensions | Updated: 2026-03-14
┌─────────────────────────────────────────────────────┐
│ VL-Workflow-Engine (pure JS library, zero deps) │
│ - Parse / validate / execute workflow JSON │
│ - Event stream for all runtime state │
│ - Checkpoint / resume / re-run from any step │
│ - Adapter pattern for LLM, API, file I/O │
└──────────────────────┬──────────────────────────────┘
│ require('vl-workflow-engine')
┌──────────────────────▼──────────────────────────────┐
│ VLClaw Broker (HTTP server, port 9160) │
│ - Implements real adapters (Claude SDK, file I/O) │
│ - Converts engine events → SSE stream │
│ - Persists checkpoints for crash recovery │
│ - Exposes REST API for VLCode │
└──────────────────────┬──────────────────────────────┘
│ SSE / REST
┌──────────────────────▼──────────────────────────────┐
│ VL-Code IDE (Web IDE, port 3200) │
│ - Chat window, Detail Log, Status Bar, Flow Tab │
│ - Subscribes to SSE for real-time updates │
│ - Shows inputs/outputs per step │
│ - Supports re-run from any step (with overrides) │
└─────────────────────────────────────────────────────┘
const { Engine } = require('vl-workflow-engine');
const workflow = { /* workflow JSON per spec v3.16 */ };
const engine = new Engine(workflow, {
onEvent: (event) => console.log(event), // global event listener
onCheckpoint: (cp) => saveToStorage(cp) // auto-save after each step
});
// Validate
const errors = engine.validate();
if (errors.length) throw new Error(errors.join('\n'));
// Execute
const ctx = await engine.execute(
{ projectName: 'MyApp' }, // initial params
{ // adapters
llm: myClaudeAdapter,
file: myFileAdapter,
doc: myDocAdapter,
}
);
console.log(ctx.snapshot()); // query current state
console.log(ctx.checkpoint()); // serializable checkpoint for persistence
All events are emitted via ctx.onEvent(fn). Each event has this shape:
interface RunEvent {
type: string; // RunEventType value
stepID: string|null; // which step emitted it (null for workflow-level)
payload: object; // type-specific data
seq: number; // auto-incrementing sequence number
ts: string; // ISO 8601 timestamp
runID: string; // workflow instance ID
}
| Type | When | Payload |
|---|---|---|
workflow_start |
Workflow begins | { name, version, params, resumedFrom? } |
workflow_done |
Workflow completes | { duration_ms, stop_id? } |
workflow_failed |
Workflow throws | { error, failed_step_id, duration_ms } |
workflow_cancelled |
Abort called | { duration_ms } |
step_start |
Step begins | { type, meta, resolvedInputs } |
step_done |
Step completes | { outputs, selected? } |
step_error |
Step throws | { error } |
step_skipped |
step.if is false |
{ condition } |
step_print |
step.print evaluated |
{ value } |
llm_token |
Response token streamed | { delta } |
llm_thinking |
Thinking/reasoning token | { delta } |
llm_tool_use |
LLM requests tool call | { tool_use_id, name, input } |
llm_tool_result |
Tool result fed back | { tool_use_id, content, is_error } |
tool_start |
VLCode Tool_* step begins |
{ name, input, inputSummary } |
tool_message |
Tool emits runtime progress/info | { name, level, message, data } |
tool_done |
VLCode Tool_* step completes |
{ name, output, outputSummary, toolResult } |
tool_error |
VLCode Tool_* step fails |
{ name, error, allowError, output, toolResult } |
llm_error |
LLM call failed | { error, type, code, retryable, latency_ms } |
llm_done |
LLM call completed | { latency_ms, finish_reason, model, usage, thinking_tokens, has_tool_use } |
file_start |
File write begins | { path } |
file_done |
File write done | { path, size_bytes } |
pause_start |
Waiting for human | { nodeId, waitToken, reason, expireAt } |
pause_resumed |
Human approved | { nodeId, requestId, resumedAt } |
pause_timeout |
Pause timed out | { nodeId, expiredAt, timeoutAction } |
pause_rejected |
Invalid resume token | { reason } |
var_changed |
Pipeline variable changed | { name, oldValue, newValue } |
Bold = enhanced in v0.3.0 (new payload fields)
Every step_start event now includes the step's resolved input parameters:
{
type: 'step_start',
stepID: 'LLM_AnalyzeRequest',
payload: {
type: 'LLM',
meta: { title: 'Analyze user request' },
resolvedInputs: { // ← NEW
userRequest: "添加一个登录页面",
model: "claude-opus-4-6",
currentMeta: {
_truncated: true, // auto-truncated (> 10KB)
type: 'object',
length: 52340,
preview: '{"projectName":"MyApp","pages":[{"name":"Home"...'
}
}
}
}
resolvedInputs is the result of evaluating all expressions in step.innull if the step has no in block{ _truncated, type, length, preview }resolvedInputs is null (step execution is unaffected)Every step_done event now includes the step's output results:
{
type: 'step_done',
stepID: 'LLM_AnalyzeRequest',
payload: {
outputs: { // ← NEW
'$plan': { action: 'add-page', pageName: 'Login', ... },
'$updatedMeta': {
_truncated: true,
type: 'object',
length: 52340,
preview: '{"projectName":"MyApp"...'
}
}
}
}
// Branch step also includes `selected`:
{
type: 'step_done',
stepID: 'Branch_Route',
payload: {
selected: 'LLM_AddPage', // which branch was taken
outputs: null // Branch typically has no out mapping
}
}
outputs is collected by reading back step.out targets from the execution context$variable targets: the current variable value (after output mapping)/file/path targets: { _file: true, path: '/path/to/file' } (no file content)null if the step has no out blockAny value > 10KB in resolvedInputs or outputs is replaced with:
interface TruncatedValue {
_truncated: true;
type: string; // 'object' | 'string'
length: number; // original JSON length in chars
preview: string; // first 200 chars + '...'
}
Consumers should check value?._truncated === true to detect truncated values.
For an LLM step, events are emitted in this order:
step_start { resolvedInputs } ← inputs visible
→ llm_thinking(xN) ← thinking/reasoning tokens
→ llm_token(xN) ← response tokens
→ llm_tool_use(0..N) ← tool call requests
→ llm_tool_result(0..N) ← tool execution results
→ [loop: llm_token → llm_tool_use → ...] ← agentic multi-turn
→ llm_done ← final summary with usage
→ var_changed(0..N) ← pipeline variable mutations
→ file_done(0..N) ← file writes from output mapping
→ step_print(0..1) ← optional print
→ step_done { outputs } ← outputs visible
On error: step_start → llm_error → step_error (no step_done).
Tool_* is a VLCode extension layered on top of the engine's custom handler mechanism.
When VLCode registers a Tool custom handler, a tool step emits:
step_start
→ tool_start
→ tool_message(0..N)
→ tool_done | tool_error
→ var_changed(0..N)
→ step_done | step_error
Runtime mapping variables during a Tool_* step:
_result: normalized primary tool payload_toolResult: full raw tool envelope returned by the underlying VLCode toolNormalization rule used by VLCode:
{ result: ... }, _result is auto-unwrapped to that inner payloaddiff, undoId, or the raw nested envelope, use _toolResultIf allowError or continueOnError is set on the step:
tool_error still fires_result becomes { ok: false, error, tool }step_doneTool implementations may accept a second runtime argument:
execute: async (input, runtime = {}) => {
runtime.info?.('Reading file', { file_path: input.file_path });
runtime.progress?.('Halfway done', { bytes: 4096 });
return { result: '...' };
}
VLCode exposes these helpers on runtime:
emit(type, payload)text(message, meta)info(message, data)warn(message, data)error(message, data)progress(message, data)All of them are surfaced to workflow observers as tool_message events and carry runID plus clientRunToken through the HTTP/SSE layer.
// Method 1: via Engine constructor (receives all events)
const engine = new Engine(workflow, {
onEvent: (event) => { /* ... */ }
});
// Method 2: via ExecutionContext (after execute starts)
const ctx = await engine.execute(params, adapters);
ctx.onEvent((event) => { /* ... */ });
The engine supports checkpointing (saving execution state) and resuming (re-executing from any step). This enables three key scenarios:
interface WorkflowCheckpoint {
_type: 'vl_workflow_checkpoint';
_version: 2;
workflowID: string; // "wf_1710000000000"
currentStepID: string; // last step that was running
status: string; // "running" | "completed" | "failed" | ...
params: Record<string, any>; // read-only input params
paramTypes: Record<string, string>;
variables: Record<string, any>; // current pipeline variables ($xxx)
varTypes: Record<string, string>;
localVars: Record<string, any>; // _result, _meta, etc.
artifacts: Record<string, string>; // files written so far
completedSteps: string[]; // ordered list of completed step IDs
// Phase 2: parallel & loop state
completedBranches: Record<string, string[]>; // { parentStepID: [completed child IDs] }
loopProgress: Record<string, number>; // { loopStepID: completedIterationCount }
eventSeq: number; // last event sequence number
version: string; // workflow spec version
createdAt: string; // ISO 8601 timestamp
}
Backward compatible: v1 checkpoints (without
completedBranches/loopProgress) still work — they are treated as having no parallel/loop state, so everything re-runs.
// Manual: call ctx.checkpoint() anytime
const cp = ctx.checkpoint();
fs.writeFileSync('checkpoint.json', JSON.stringify(cp));
// Automatic: onCheckpoint callback fires after every step completion
const engine = new Engine(workflow, {
onCheckpoint: (cp) => {
// Called after each step_done with the latest state
redis.set(`checkpoint:${cp.workflowID}`, JSON.stringify(cp));
// or: fs.writeFileSync(`/tmp/${cp.workflowID}.json`, JSON.stringify(cp));
}
});
// Server crashed while running LLM_GenerateCode.
// On restart, load the last checkpoint and resume from the crashed step:
const cp = JSON.parse(redis.get('checkpoint:wf_171000000'));
// cp.currentStepID is the last step that was running when the crash happened
// cp.completedSteps shows what already completed
const engine = new Engine(workflow);
const ctx = await engine.executeFrom(cp, adapters);
// → Starts from cp.currentStepID, all variables restored
// → Does NOT re-run completed steps
// User ran the workflow. LLM_GenerateCode produced bad output.
// User wants to fix $plan and re-run from LLM_GenerateCode:
// Option A: Use the last checkpoint, override variables
const ctx = await engine.executeFrom(
lastCheckpoint,
adapters,
{ '$plan': userFixedPlan } // ← variable overrides
);
// Option B: Build a minimal resume object manually
const ctx = await engine.executeFrom(
{
currentStepID: 'LLM_GenerateCode',
params: { projectName: 'MyApp' },
variables: {
'$plan': userFixedPlan,
'$meta': existingMeta,
// ... all variables that the step needs
}
},
adapters
);
class Engine {
async executeFrom(
checkpoint: WorkflowCheckpoint | {
currentStepID: string; // required: which step to start from
params?: Record<string, any>;
variables?: Record<string, any>;
localVars?: Record<string, any>;
// ... any checkpoint fields
},
adapters?: AdapterMap,
overrides?: Record<string, any> // applied AFTER checkpoint restore
): Promise<ExecutionContext>;
}
Behavior:
ExecutionContext from the checkpoint dataoverrides on top (can override $variables, _localVars, or params)checkpoint.currentStepID and follows the next chain to completionworkflow_start with { resumedFrom: stepID } in the payloadonEvent and onCheckpoint callbacks work normallyImportant notes:
currentStepID are NOT re-executed execute()
│
▼
Set_Init ──step_done──→ onCheckpoint({completedSteps:['Set_Init']})
│
▼
LLM_Analyze ──step_done──→ onCheckpoint({completedSteps:['Set_Init','LLM_Analyze']})
│
▼
LLM_Generate ──💥 CRASH
│
▼ (on restart)
executeFrom(lastCheckpoint)
│
▼
LLM_Generate ──step_done──→ onCheckpoint({completedSteps:[...,'LLM_Generate']})
│
▼
Write_Output ──step_done──→ ...
│
▼
workflow_done
Noop_Fork
├──→ Set_A ✅ ──→ onCheckpoint({completedBranches: {'Noop_Fork': ['Set_A']}})
├──→ Set_B ✅ ──→ onCheckpoint({completedBranches: {'Noop_Fork': ['Set_A','Set_B']}})
└──→ Set_C 💥 CRASH
│
▼ (on restart)
executeFrom({currentStepID: 'Noop_Fork', completedBranches: {'Noop_Fork': ['Set_A','Set_B']}})
│
▼
Noop_Fork
├── Set_A → SKIPPED (already in completedBranches)
├── Set_B → SKIPPED (already in completedBranches)
└── Set_C → RUNS ✅
│
▼
next step...
Key behaviors:
completedBranches_executeChildren filters out branches whose entry ID is in completedBranches[parentStepID]_executeChildren returns immediately and proceeds to step.next Loop_Process (source: 100 items, serial mode)
│
├── iteration 0 ✅ → onCheckpoint({loopProgress: {'Loop_Process': 1}})
├── iteration 1 ✅ → onCheckpoint({loopProgress: {'Loop_Process': 2}})
├── ...
├── iteration 59 ✅ → onCheckpoint({loopProgress: {'Loop_Process': 60}})
├── iteration 60 💥 CRASH
│
▼ (on restart)
executeFrom({currentStepID: 'Loop_Process', loopProgress: {'Loop_Process': 60}})
│
▼
Loop_Process
├── iterations 0-59 → SKIPPED
├── iteration 60 → RUNS (item=source[60], _index=60)
├── iteration 61 → RUNS
├── ...
└── iteration 99 → RUNS ✅
│
▼
next step...
Key behaviors:
_index is the true array index (e.g., 60), not a relative offset_item is source[_index] — correct even after resumeloopProgress[stepID] >= source.length, the loop body doesn't execute at all| Event | Checkpoint emitted? | What's tracked |
|---|---|---|
| Step completes (step_done) | Yes | completedSteps updated |
| Parallel branch completes | Yes | completedBranches[parent] updated |
| Serial loop iteration completes | Yes | loopProgress[loopID] incremented |
| Parallel loop fully completes | Yes | loopProgress[loopID] = source.length |
| Step error / workflow fail | No | Last good checkpoint is the recovery point |
The engine calls ctx.llmAdapter.call(params, onToken, callbacks).
interface LLMAdapter {
call(
params: LLMParams,
onToken?: (delta: string) => void, // legacy: response tokens only
callbacks?: { // extended: full observability
onToken: (delta: string) => void; // response token
onThinking: (delta: string) => void; // thinking/reasoning token
onToolUse: (toolUse: ToolUseBlock) => void;
onToolResult: (toolResult: ToolResultBlock) => void;
}
): Promise<LLMResult>;
}
interface LLMParams {
model?: string; // e.g. "claude-opus-4-6" (MUST default to opus)
system?: string; // system prompt (docs auto-injected here)
messages: Message[]; // conversation history
stream?: boolean; // enable streaming (default: false)
tools?: Tool[]; // tool definitions for agentic loops
docs?: number[]; // DocCenter IDs — auto-fetched and injected
output_config?: {
type: 'json_object' | 'json_schema';
schema?: object;
schemaRef?: string; // resolved from registry
};
max_tokens?: number;
temperature?: number;
thinking?: { type: 'enabled', budget_tokens: number }; // enable extended thinking
}
interface LLMResult {
content: string | object; // response content
model: string; // actual model used
id?: string; // response ID
finish_reason?: string; // 'stop' | 'end_turn' | 'tool_use' | ...
stop_reason?: string; // alias
usage: {
input_tokens: number;
output_tokens: number;
thinking_tokens?: number; // extended thinking tokens consumed
};
tool_calls?: ToolUseBlock[]; // if model requested tool use
tool_use?: ToolUseBlock[]; // alias (Anthropic format)
thinking?: string; // full thinking text (non-streaming)
}
interface ToolUseBlock {
id: string; // "toolu_xxx"
name: string; // tool name
input: object; // tool arguments
}
interface ToolResultBlock {
tool_use_id: string; // matches ToolUseBlock.id
content: string; // tool output
is_error?: boolean; // was it an error
}
// Example: Claude adapter with full observability
const Anthropic = require('@anthropic-ai/sdk');
const client = new Anthropic();
const claudeAdapter = {
async call(params, onToken, callbacks) {
const model = params.model || 'claude-opus-4-6';
if (params.stream) {
const stream = await client.messages.stream({
model,
system: params.system,
messages: params.messages,
max_tokens: params.max_tokens || 16384,
tools: params.tools,
thinking: params.thinking,
});
let fullContent = '';
let fullThinking = '';
stream.on('contentBlockDelta', (delta) => {
if (delta.delta.type === 'thinking_delta') {
fullThinking += delta.delta.thinking;
callbacks?.onThinking?.(delta.delta.thinking);
} else if (delta.delta.type === 'text_delta') {
fullContent += delta.delta.text;
callbacks?.onToken?.(delta.delta.text);
}
});
stream.on('contentBlockStop', (block) => {
if (block.content_block.type === 'tool_use') {
callbacks?.onToolUse?.({
id: block.content_block.id,
name: block.content_block.name,
input: block.content_block.input,
});
}
});
const response = await stream.finalMessage();
const toolBlocks = response.content.filter(b => b.type === 'tool_use');
if (toolBlocks.length > 0) {
for (const tb of toolBlocks) {
const result = await executeToolLocally(tb.name, tb.input);
callbacks?.onToolResult?.({
tool_use_id: tb.id,
content: JSON.stringify(result),
is_error: false,
});
}
}
return {
content: fullContent,
model: response.model,
id: response.id,
finish_reason: response.stop_reason,
usage: response.usage,
tool_use: toolBlocks,
thinking: fullThinking || undefined,
};
} else {
const response = await client.messages.create({
model,
system: params.system,
messages: params.messages,
max_tokens: params.max_tokens || 16384,
tools: params.tools,
thinking: params.thinking,
});
const textBlock = response.content.find(b => b.type === 'text');
const thinkBlock = response.content.find(b => b.type === 'thinking');
return {
content: textBlock?.text || '',
model: response.model,
id: response.id,
finish_reason: response.stop_reason,
usage: response.usage,
thinking: thinkBlock?.thinking || undefined,
};
}
}
};
Tool_* steps let a workflow call any tool already registered in the local VLCode ToolRegistry.
Minimal shape:
{
"id": "Tool_010_ReadSpec",
"tool": "ReadFile",
"input": {
"file_path": "=\"docs/spec.md\""
},
"out": {
"$specText": "=_result",
"$rawReadFileEnvelope": "=_toolResult"
},
"next": "Stop_End"
}
Supported fields in VLCode:
tool or toolName: explicit tool nameinput or in: tool argumentstimeout: forwarded into the tool input if the tool input does not already include oneallowError or continueOnError: keep the workflow running after tool failureout: normal workflow output mappingName fallback:
tool is omitted, VLCode derives it from the step id, for example Tool_010_ReadFile → ReadFileScope notes:
executeFrom(), and the WorkflowRun tool pathtool_message events through the optional runtime argumenttool_start/tool_message/tool_done/tool_error by runID or clientRunTokenQuery the current workflow execution state at any time via ctx.snapshot().
interface WorkflowSnapshot {
runID: string;
status: string; // "running" | "completed" | "failed" | "stopped" | "paused"
currentStepID: string | null;
elapsedMs: number;
params: Record<string, any>;
variables: Record<string, any>;
localVars: Record<string, any>;
artifacts: string[];
paused: { nodeID: string; token: string } | null;
eventSeq: number;
version: string;
}
app.get('/workflow/:runID/status', (req, res) => {
const ctx = activeWorkflows.get(req.params.runID);
if (!ctx) return res.status(404).json({ error: 'not found' });
res.json(ctx.snapshot());
});
event: <RunEventType>
data: <JSON payload>
id: <seq>
event: workflow_start
data: {"name":"MetaDirect","version":"3.15","params":{"projectName":"MyApp"}}
id: 1
event: step_start
data: {"stepID":"LLM_010","type":"LLM","meta":{"title":"Analyze request"},"resolvedInputs":{"userRequest":"添加登录页","model":"claude-opus-4-6"}}
id: 2
event: llm_thinking
data: {"stepID":"LLM_010","delta":"Let me analyze the requirements..."}
id: 3
event: llm_token
data: {"stepID":"LLM_010","delta":"```json\n{\"action\":\"add-page\""}
id: 4
event: llm_done
data: {"stepID":"LLM_010","latency_ms":12340,"model":"claude-opus-4-6","usage":{"input_tokens":1200,"output_tokens":3400,"total_tokens":4600},"thinking_tokens":800}
id: 5
event: tool_start
data: {"stepID":"Tool_020_ReadFile","name":"ReadFile","input":{"file_path":"docs/spec.md"}}
id: 5a
event: tool_done
data: {"stepID":"Tool_020_ReadFile","name":"ReadFile","output":"# Spec ..."}
id: 5b
event: var_changed
data: {"stepID":"LLM_010","name":"$plan","oldValue":null,"newValue":{"action":"add-page","pageName":"Login"}}
id: 6
event: step_done
data: {"stepID":"LLM_010","outputs":{"$plan":{"action":"add-page","pageName":"Login"}}}
id: 7
event: step_start
data: {"stepID":"Write_020","type":"Write","meta":{"title":"Write page file"},"resolvedInputs":{"target":"/Apps/Login.vx","value":"..."}}
id: 8
event: file_done
data: {"stepID":"Write_020","path":"Apps/Login.vx","size_bytes":4096}
id: 9
event: step_done
data: {"stepID":"Write_020","outputs":{"/Apps/Login.vx":{"_file":true,"path":"/Apps/Login.vx"}}}
id: 10
event: workflow_done
data: {"duration_ms":45000}
id: 11
app.get('/workflow/:runID/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const ctx = activeWorkflows.get(req.params.runID);
if (!ctx) { res.end(); return; }
const listener = (event) => {
res.write(`event: ${event.type}\n`);
res.write(`data: ${JSON.stringify({ stepID: event.stepID, ...event.payload })}\n`);
res.write(`id: ${event.seq}\n\n`);
};
ctx.onEvent(listener);
req.on('close', () => { /* remove listener */ });
});
| Method | Path | Description |
|---|---|---|
POST |
/workflow/run |
Start a workflow, returns { runID } |
GET |
/workflow/:runID/status |
Get WorkflowSnapshot |
GET |
/workflow/:runID/events |
SSE event stream |
POST |
/workflow/:runID/resume |
Resume a paused workflow { token, payload } |
POST |
/workflow/:runID/abort |
Abort a running workflow |
GET |
/workflow/:runID/variables |
Get current pipeline variables |
GET |
/workflow/:runID/artifacts |
List written files |
GET |
/workflow/:runID/checkpoint |
Get latest checkpoint (NEW) |
POST |
/workflow/rerun |
Re-run from step { checkpoint, stepID, overrides } (NEW) |
Returns the latest WorkflowCheckpoint JSON. Broker should persist checkpoints using onCheckpoint.
// Broker implementation
const checkpointStore = new Map(); // or Redis/file
const engine = new Engine(workflow, {
onCheckpoint: (cp) => {
checkpointStore.set(cp.workflowID, cp);
}
});
app.get('/workflow/:runID/checkpoint', (req, res) => {
const cp = checkpointStore.get(req.params.runID);
if (!cp) return res.status(404).json({ error: 'no checkpoint' });
res.json(cp);
});
Re-run a workflow from a specific step, optionally with variable overrides.
// Request body
{
"workflowName": "MetaDirect", // which workflow to load
"checkpoint": { ... }, // from GET /checkpoint or stored
"stepID": "LLM_GenerateCode", // override currentStepID (optional)
"overrides": { // variable overrides (optional)
"$plan": { "action": "add-page", "pageName": "Login" }
}
}
// Broker implementation
app.post('/workflow/rerun', async (req, res) => {
const { workflowName, checkpoint, stepID, overrides } = req.body;
const workflow = loadWorkflow(workflowName);
const engine = new Engine(workflow, { onEvent, onCheckpoint });
if (stepID) checkpoint.currentStepID = stepID;
const ctx = await engine.executeFrom(checkpoint, adapters, overrides);
res.json({ runID: ctx.workflowID, status: ctx.status });
});
| Engine Event | VLCode Panel | Display |
|---|---|---|
workflow_start |
Status Bar | "Running: {name}" (or "Resuming from: {resumedFrom}") |
step_start |
Status Bar + Detail Log | Step title + collapsible inputs panel |
step_done |
Detail Log | Checkmark + collapsible outputs panel |
step_error |
Main Chat + Detail Log | Error message |
llm_thinking |
Detail Log (collapsible) | Streaming thinking text |
llm_token |
Detail Log | Streaming response text |
llm_tool_use |
Detail Log (collapsible) | Tool name + input |
llm_tool_result |
Detail Log (collapsible) | Tool output |
tool_start |
Detail Log (collapsible) | Local tool name + input |
tool_done |
Detail Log (collapsible) | Local tool output |
tool_error |
Detail Log | Error, optionally marked continued |
llm_done |
Detail Log | Model, tokens, latency |
llm_error |
Main Chat + Detail Log | Error + retry info |
var_changed |
Detail Log | Variable diff |
file_done |
Detail Log | File path + size |
pause_start |
Main Chat | Approval button |
workflow_done |
Main Chat + Status Bar | Summary |
workflow_failed |
Main Chat | Error + "Re-run from step" button |
Each step in the Detail Log should be a card with expandable sections:
┌─────────────────────────────────────────────────┐
│ ✅ LLM_AnalyzeRequest — Analyze user request │ ← title from step.meta
│ 12.3s │ ← duration
├─────────────────────────────────────────────────┤
│ ▶ Inputs │ ← collapsed by default
│ userRequest: "添加一个登录页面" │
│ model: "claude-opus-4-6" │
│ currentMeta: (52KB, truncated) [展开] │
├─────────────────────────────────────────────────┤
│ ▶ Thinking │ ← collapsed, from llm_thinking
│ Let me analyze the requirements... │
├─────────────────────────────────────────────────┤
│ ▼ Response │ ← expanded, from llm_token
│ { "action": "add-page", "pageName": "Login" } │
├─────────────────────────────────────────────────┤
│ ▶ Tool Calls (2) │ ← collapsed, from llm_tool_use
│ read_file("spec.md") → "# Spec content..." │
│ read_file("theme.json") → { ... } │
├─────────────────────────────────────────────────┤
│ ▶ Outputs │ ← collapsed by default
│ $plan: { action: "add-page", ... } │
│ $updatedMeta: (52KB, truncated) [展开] │
├─────────────────────────────────────────────────┤
│ ▶ Token Usage │ ← collapsed
│ Input: 1,200 Output: 3,400 Thinking: 800 │
│ Model: claude-opus-4-6 Latency: 12.3s │
├─────────────────────────────────────────────────┤
│ [🔄 从此步骤重跑] │ ← re-run button
└─────────────────────────────────────────────────┘
function renderValue(value) {
if (value?._truncated) {
return `(${formatBytes(value.length)}, truncated) ` +
`<button onclick="showPreview('${value.preview}')">展开预览</button>`;
}
if (value?._file) {
return `📄 ${value.path}`;
}
return JSON.stringify(value, null, 2);
}
function formatBytes(chars) {
if (chars < 1024) return `${chars}B`;
return `${(chars / 1024).toFixed(1)}KB`;
}
用户在 Detail Log 中点击某个已完成步骤的 [🔄 从此步骤重跑] 按钮
│
▼
弹出「重跑配置」对话框:
┌──────────────────────────────────────────┐
│ 从 LLM_GenerateCode 重新执行 │
│ │
│ 当前变量值: │
│ $plan: { action: "add-page", ... } │
│ [✏️ 编辑] │
│ $meta: (52KB) [查看] │
│ [✏️ 编辑] │
│ $userRequest: "添加登录页" │
│ [✏️ 编辑] │
│ │
│ ℹ️ 此步骤之前的所有步骤不会重新执行 │
│ ℹ️ 变量值来自该步骤执行前的快照 │
│ │
│ [取消] [🚀 开始重跑] │
└──────────────────────────────────────────┘
│
▼
前端调用 POST /workflow/rerun {
checkpoint: lastCheckpoint,
stepID: 'LLM_GenerateCode',
overrides: { '$plan': userEditedPlan } // 用户编辑过的变量
}
│
▼
新的 SSE stream 开始, Detail Log 追加新的执行记录
const evtSource = new EventSource(`http://localhost:9160/workflow/${runID}/events`);
evtSource.addEventListener('step_start', (e) => {
const data = JSON.parse(e.data);
statusBar.update(`Step: ${data.stepID} (${data.type})`);
detailLog.addStepCard(data.stepID, data.type, data.meta);
// NEW: show inputs
if (data.resolvedInputs) {
detailLog.setStepInputs(data.stepID, data.resolvedInputs);
}
});
evtSource.addEventListener('step_done', (e) => {
const data = JSON.parse(e.data);
detailLog.markStepDone(data.stepID);
// NEW: show outputs
if (data.outputs) {
detailLog.setStepOutputs(data.stepID, data.outputs);
}
});
evtSource.addEventListener('llm_thinking', (e) => {
const data = JSON.parse(e.data);
detailLog.appendThinking(data.stepID, data.delta);
});
evtSource.addEventListener('llm_token', (e) => {
const data = JSON.parse(e.data);
detailLog.appendResponse(data.stepID, data.delta);
});
evtSource.addEventListener('llm_tool_use', (e) => {
const data = JSON.parse(e.data);
detailLog.addToolCall(data.stepID, data.name, data.input);
});
evtSource.addEventListener('llm_tool_result', (e) => {
const data = JSON.parse(e.data);
detailLog.addToolResult(data.stepID, data.tool_use_id, data.content, data.is_error);
});
evtSource.addEventListener('llm_done', (e) => {
const data = JSON.parse(e.data);
detailLog.setLLMStats(data.stepID, {
model: data.model, latencyMs: data.latency_ms,
usage: data.usage, thinkingTokens: data.thinking_tokens
});
});
evtSource.addEventListener('var_changed', (e) => {
const data = JSON.parse(e.data);
detailLog.addVarChange(data.name, data.oldValue, data.newValue);
});
evtSource.addEventListener('workflow_done', (e) => {
const data = JSON.parse(e.data);
mainChat.addMessage(`Workflow completed in ${(data.duration_ms/1000).toFixed(1)}s`);
statusBar.update('Done');
});
evtSource.addEventListener('workflow_failed', (e) => {
const data = JSON.parse(e.data);
mainChat.addMessage(`Workflow failed at ${data.failed_step_id}: ${data.error}`);
mainChat.addRerunButton(data.failed_step_id); // ← NEW: offer re-run
});
// Re-run action
async function rerunFromStep(stepID, overrides = {}) {
const cpRes = await fetch(`/workflow/${runID}/checkpoint`);
const checkpoint = await cpRes.json();
const res = await fetch('/workflow/rerun', {
method: 'POST',
body: JSON.stringify({ workflowName, checkpoint, stepID, overrides })
});
const { runID: newRunID } = await res.json();
// Subscribe to new SSE stream
subscribeToWorkflow(newRunID);
}
const {
// Core
Engine,
// Expression
ExpressionEvaluator, toBool, toFloat,
// Registry
Registry, parseServiceSignature, parseVariableDeclaration, parseParamDeclaration,
// Parallel
ParallelExecutor, ParallelError,
// Types & Constants
WorkflowType, StepType, getStepType, ExecutionStatus, WriteMode, LoopMode,
ParallelErrorStrategy, RunEventType, SUPPORTED_VERSIONS, isV310OrLater,
LLMError, buildErrorMap, ExecutionContext, ChildExecutionContext,
// Executor helpers
applyOutputMapping, emitEvent
} = require('vl-workflow-engine');
completedBranches tracks which branches completed per parent step; on resume, only pending branches executeloopProgress tracks iteration count per loop step; serial loops emit checkpoint per iteration for precise recoverycompletedBranches and loopProgress (backward compatible with v1)completedBranches, the entire chain re-runssource.slice(startIndex) ensures only unprocessed items execute_completedSteps, _completedBranches, _loopProgressTool_*, tool_start/tool_done/tool_error, _result, and _toolResultctx.checkpoint() produces serializable state snapshotengine.executeFrom(checkpoint, adapters, overrides) resumes from any stepstep_start enriched: resolvedInputs field shows all evaluated input parametersstep_done enriched: outputs field shows all output mapping results{ _truncated, type, length, preview }ctx._completedSteps and checkpoint.completedStepsworkflow_start extended: resumedFrom field when using executeFrom()step_start/step_done payload enrichment (resolvedInputs / outputs)chunk → delta (aligned with Spec §13.3)llm_thinking, llm_tool_use, llm_tool_result, llm_error, var_changedcallbacks parameter with onThinking, onToolUse, onToolResultctx.snapshot() returns full execution statellm_done enhanced: includes thinking_tokens, has_tool_use$ variables emit var_changed on mutation(params, onToken) still work