import fs from 'fs/promises'; import path from 'path'; import { WorkflowExecutor } from './src/vl/workflow-executor.js'; import { prepareCheckpointForRerun } from './src/server/routes/workflow.js'; import { ToolRegistry } from './src/core/tool-registry.js'; import { createReadFileTool } from './src/tools/read-file.js'; let passed = 0; let failed = 0; function test(name, fn) { return Promise.resolve() .then(fn) .then(() => { console.log(` ✓ ${name}`); passed++; }) .catch((err) => { console.log(` ✗ ${name}: ${err.message}`); failed++; }); } function assertEqual(a, b, msg) { if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } const tmpDir = '/tmp/vlcode-lite-workflow-executor-test'; const outputPath = path.join(tmpDir, 'rerun-output.txt'); const complexDir = path.join(tmpDir, 'complex-rerun'); const complexSummaryPath = path.join(complexDir, 'Artifacts/summary.txt'); const complexAuditPath = path.join(complexDir, 'Artifacts/audit.txt'); const registryReadPath = path.join(tmpDir, 'tool-read.txt'); const workflow = { version: '3.16', name: 'ExecutorRerunSmoke', steps: [ { id: 'Set_Default', target: '$msg', value: '=\"first\"', next: 'Write_Output' }, { id: 'Write_Output', target: '/rerun-output.txt', value: '=$msg', mode: 'overwrite', next: 'Stop_End' }, { id: 'Stop_End' }, ], }; const actualRegistryToolWorkflow = { version: '3.16', name: 'ExecutorActualRegistryTool', steps: [ { id: 'Tool_010_ReadFile', input: { file_path: '="tool-read.txt"' }, out: { '$toolRead': '=_result', '$toolReadRaw': '=_toolResult.result', }, next: 'Stop_End', }, { id: 'Stop_End' }, ], }; const complexWorkflow = { version: '3.16', name: 'ExecutorComplexRerun', steps: [ { id: 'Set_010_Numbers', target: '$nums', value: '=[2,4,6,8]', next: 'Set_020_Sum' }, { id: 'Set_020_Sum', target: '$sum', value: '=0', next: 'Set_030_Audit' }, { id: 'Set_030_Audit', target: '$audit', value: '=[]', next: 'Fork_040_Prepare' }, { id: 'Fork_040_Prepare', children: ['Set_041_BranchA', 'Set_042_BranchB'], next: 'Loop_050_Sum' }, { id: 'Set_041_BranchA', target: '$branchA', value: '=\"branch-A-ready\"', next: 'Write_043_BranchA' }, { id: 'Write_043_BranchA', target: '=\"Artifacts/branch-a.txt\"', value: '=$branchA' }, { id: 'Set_042_BranchB', target: '$branchB', value: '=\"branch-B-ready\"', next: 'Write_044_BranchB' }, { id: 'Write_044_BranchB', target: '=\"Artifacts/branch-b.txt\"', value: '=$branchB' }, { id: 'Loop_050_Sum', source: '$nums', mode: 'serial', children: ['Set_051_Add', 'Set_052_AuditEntry'], next: 'Pause_060_Review' }, { id: 'Set_051_Add', target: '$sum', value: '=$sum + _item' }, { id: 'Set_052_AuditEntry', target: '$audit[_index]', value: '=\"i=\" + _index + \";n=\" + _item + \";sum=\" + $sum' }, { id: 'Pause_060_Review', reason: 'Review', next: 'Fork_070_Finalize' }, { id: 'Fork_070_Finalize', children: ['Write_071_Summary', 'Write_072_Audit'], next: 'Stop_080_End' }, { id: 'Write_071_Summary', target: '=\"Artifacts/summary.txt\"', value: '=\"sum=\" + $sum + \";count=\" + $nums.length' }, { id: 'Write_072_Audit', target: '=\"Artifacts/audit.txt\"', value: '=$audit' }, { id: 'Stop_080_End' }, ], }; const toolWorkflow = { version: '3.16', name: 'ExecutorToolStep', steps: [ { id: 'Tool_010_EchoTool', tool: 'EchoTool', input: { text: '="hello"', nested: { source: '="workflow"' } }, out: { '$echoed': '=_result.echoed', '$toolSource': '=_result.meta.source', }, next: 'Stop_End', }, { id: 'Stop_End' }, ], }; const toolAllowErrorWorkflow = { version: '3.16', name: 'ExecutorToolAllowError', steps: [ { id: 'Tool_010_FailTool', tool: 'FailTool', allowError: true, input: { reason: '="expected"' }, out: { '$toolOk': '=_result.ok', '$toolError': '=_result.error', }, next: 'Stop_End', }, { id: 'Stop_End' }, ], }; const toolMessageWorkflow = { version: '3.16', name: 'ExecutorToolMessage', steps: [ { id: 'Tool_010_MessageTool', tool: 'MessageTool', input: { query: '="inspect"' }, out: { '$toolOk': '=_result.ok', }, next: 'Stop_End', }, { id: 'Stop_End' }, ], }; console.log('\n── Workflow Executor Regression ──'); await fs.mkdir(tmpDir, { recursive: true }); await fs.rm(outputPath, { force: true }); await fs.rm(complexDir, { recursive: true, force: true }); await fs.mkdir(complexDir, { recursive: true }); await fs.writeFile(registryReadPath, 'registry-tool-ok', 'utf8'); await test('executeFrom handles checkpoint.artifacts object for rerun overrides', async () => { let checkpoint = null; const first = new WorkflowExecutor({ workDir: tmpDir, model: 'claude-opus-4-6' }); await first.execute(workflow, {}, { onCheckpoint: (cp) => { checkpoint = cp; }, }); assert(checkpoint, 'checkpoint missing after initial execution'); assertEqual(await fs.readFile(outputPath, 'utf8'), 'first'); assert(checkpoint.artifacts && !Array.isArray(checkpoint.artifacts), 'checkpoint.artifacts should be an object'); const rerun = new WorkflowExecutor({ workDir: tmpDir, model: 'claude-opus-4-6' }); await rerun.executeFrom( workflow, { ...checkpoint, currentStepID: 'Write_Output' }, { '$msg': 'edited' }, {} ); assertEqual(await fs.readFile(outputPath, 'utf8'), 'edited'); }); await test('prepareCheckpointForRerun clears fork/loop resume state for complex reruns', async () => { let checkpoint = null; const first = new WorkflowExecutor({ workDir: complexDir, model: 'claude-opus-4-6' }); let resumed = false; await first.execute(complexWorkflow, {}, { onCheckpoint: (cp) => { checkpoint = cp; }, onPause(info) { if (resumed) return; resumed = true; setTimeout(() => first.resume(info.nodeId), 10); }, }); assert(checkpoint, 'complex checkpoint missing after initial execution'); assertEqual(await fs.readFile(complexSummaryPath, 'utf8'), 'sum=20;count=4'); const forkCheckpoint = prepareCheckpointForRerun(complexWorkflow, checkpoint, 'Fork_070_Finalize'); const forkRerun = new WorkflowExecutor({ workDir: complexDir, model: 'claude-opus-4-6' }); await forkRerun.executeFrom(complexWorkflow, forkCheckpoint, { '$sum': 99 }, {}); assertEqual(await fs.readFile(complexSummaryPath, 'utf8'), 'sum=99;count=4'); const loopCheckpoint = prepareCheckpointForRerun(complexWorkflow, checkpoint, 'Loop_050_Sum'); const loopRerun = new WorkflowExecutor({ workDir: complexDir, model: 'claude-opus-4-6' }); let rerunPaused = false; await loopRerun.executeFrom(complexWorkflow, loopCheckpoint, { '$nums': [3, 3, 3], '$sum': 0, '$audit': [], }, { onPause(info) { if (rerunPaused) return; rerunPaused = true; setTimeout(() => loopRerun.resume(info.nodeId), 10); }, }); assertEqual(await fs.readFile(complexSummaryPath, 'utf8'), 'sum=9;count=3'); assertEqual( await fs.readFile(complexAuditPath, 'utf8'), '["i=0;n=3;sum=3","i=1;n=3;sum=6","i=2;n=3;sum=9"]' ); }); await test('Tool_* executes local tool registry and maps _result outputs', async () => { const calls = []; const toolRegistry = { async execute(name, input) { calls.push({ name, input }); return { result: { echoed: String(input.text).toUpperCase(), meta: { source: input.nested?.source || '' } } }; }, }; const executor = new WorkflowExecutor({ workDir: tmpDir, model: 'claude-opus-4-6', toolRegistry }); const events = []; await executor.execute(toolWorkflow, {}, { onToolStart: (info) => events.push({ type: 'start', ...info }), onToolDone: (info) => events.push({ type: 'done', ...info }), }); assertEqual(calls.length, 1, 'tool should be invoked once'); assertEqual(calls[0].name, 'EchoTool'); assertEqual(calls[0].input.text, 'hello'); assertEqual(calls[0].input.nested.source, 'workflow'); assertEqual(executor._ctx.variables.$echoed, 'HELLO'); assertEqual(executor._ctx.variables.$toolSource, 'workflow'); assert(events.some((event) => event.type === 'start' && event.name === 'EchoTool'), 'missing tool start event'); assert(events.some((event) => event.type === 'done' && event.name === 'EchoTool'), 'missing tool done event'); }); await test('Tool_* allowError captures tool failure into _result without aborting workflow', async () => { const toolRegistry = { async execute() { return { error: 'tool-failed' }; }, }; const executor = new WorkflowExecutor({ workDir: tmpDir, model: 'claude-opus-4-6', toolRegistry }); const errors = []; await executor.execute(toolAllowErrorWorkflow, {}, { onToolError: (info) => errors.push(info), }); assertEqual(executor._ctx.variables.$toolOk, false); assertEqual(executor._ctx.variables.$toolError, 'tool-failed'); assert(errors.some((event) => event.name === 'FailTool' && event.allowError === true), 'missing allowError tool event'); }); await test('Tool_* executes an actual registered VLCode tool via ToolRegistry', async () => { const toolRegistry = new ToolRegistry(); toolRegistry.register('ReadFile', createReadFileTool({ workDir: tmpDir })); const executor = new WorkflowExecutor({ workDir: tmpDir, model: 'claude-opus-4-6', toolRegistry }); await executor.execute(actualRegistryToolWorkflow, {}, {}); assert(String(executor._ctx.variables.$toolRead || '').includes('registry-tool-ok'), 'ReadFile result missing expected content'); assert(String(executor._ctx.variables.$toolReadRaw || '').includes('registry-tool-ok'), 'raw tool result missing expected content'); }); await test('Tool_* forwards tool runtime messages through onToolMessage', async () => { const toolRegistry = new ToolRegistry(); toolRegistry.register('MessageTool', { description: 'Emit runtime messages for workflow testing', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }, async execute(input, runtime) { runtime.info?.('starting inspection', { query: input.query }); runtime.progress?.('halfway', { percent: 50 }); return { result: { ok: true } }; }, }); const executor = new WorkflowExecutor({ workDir: tmpDir, model: 'claude-opus-4-6', toolRegistry }); const messages = []; await executor.execute(toolMessageWorkflow, {}, { onToolMessage: (info) => messages.push(info), }); assertEqual(executor._ctx.variables.$toolOk, true); assert(messages.some((info) => info.name === 'MessageTool' && info.message === 'starting inspection'), 'missing info tool message'); assert(messages.some((info) => info.name === 'MessageTool' && info.message === 'halfway'), 'missing progress tool message'); }); await test('DocCenter path 1 is pinned to bundled local VL syntax', async () => { const originalFetch = globalThis.fetch; const executor = new WorkflowExecutor({ workDir: tmpDir, model: 'claude-opus-4-6', cookie: 'test-cookie' }); const docs = { '1': 'remote syntax placeholder', '100': 'remote meta prompt placeholder' }; globalThis.fetch = async (url, options = {}) => { if (String(url).includes('SERVICE_DocCenter_GetDocList')) { return { async json() { return { data: [ { path: '1', _id: 1 }, { path: '100', _id: 100 }, ], }; }, }; } const body = JSON.parse(options.body || '{}'); return { async json() { return { data: { currentContent: body.docId === 1 ? 'Current version: `// VL_VERSION:3.6`' : 'remote meta prompt', }, }; }, }; }; try { await executor._resolveDocCenterDocs(docs, { onText() {} }); } finally { globalThis.fetch = originalFetch; } assert(docs['1'].includes('Current version: `// VL_VERSION:3.5`'), 'bundled local syntax should override remote path 1'); assertEqual(docs['100'], 'remote meta prompt'); }); console.log(`\n── Results ──\n\n ${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0);