| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- 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);
|