import fs from 'fs/promises'; import path from 'path'; import { WorkflowExecutor } from './src/vl/workflow-executor.js'; import { ToolRegistry } from './src/core/tool-registry.js'; import { createReadFileTool } from './src/tools/read-file.js'; import { createWriteFileTool } from './src/tools/write-file.js'; import { createBashTool } from './src/tools/bash.js'; import { createWorkflowRunTool } from './src/tools/workflow-run.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 rootDir = '/tmp/vlcode-lite-subflow-runtime-test'; const simpleChildDir = path.join(rootDir, 'simple-child-space'); const dynamicChildDir = path.join(rootDir, 'sandboxes', 'branch-a'); const toolChildDir = path.join(rootDir, 'tool-child-space'); console.log('\n── Workflow Subflow Runtime Regression ──'); await fs.rm(rootDir, { recursive: true, force: true }); await fs.mkdir(simpleChildDir, { recursive: true }); await fs.mkdir(dynamicChildDir, { recursive: true }); await fs.mkdir(toolChildDir, { recursive: true }); const registry = new ToolRegistry(); const toolConfig = { workDir: rootDir, toolRegistry: registry, _toolRegistry: registry, model: 'claude-opus-4-6', llmProvider: 'anthropic', }; registry.register('ReadFile', createReadFileTool({ workDir: rootDir })); registry.register('WriteFile', createWriteFileTool({ workDir: rootDir })); registry.register('Bash', createBashTool({ workDir: rootDir })); registry.register('WorkflowRun', createWorkflowRunTool(toolConfig)); const simpleChildWorkflow = { version: '3.16', name: 'SimpleChildWorkflow', steps: [ { id: 'Set_010_Result', target: '$result', value: '="child:" + goal', next: 'Write_020_Result' }, { id: 'Write_020_Result', target: '="Artifacts/simple.txt"', value: '=$result', next: 'Stop_End' }, { id: 'Stop_End' }, ], }; await fs.writeFile(path.join(simpleChildDir, 'simple-child.json'), JSON.stringify(simpleChildWorkflow, null, 2), 'utf8'); await test('Subflow_* alias executes WorkflowRun sync and maps child outputs', async () => { const parentWorkflow = { version: '3.16', name: 'ParentSubflowAlias', steps: [ { id: 'Subflow_010_RunChild', workflow_path: 'simple-child.json', work_dir: 'simple-child-space', params: { goal: '="alpha"' }, out: { '$childStatus': '=_result.status', '$childValue': '=_result.variables["$result"]', '$childWorkDir': '=_result.workDir', }, next: 'Stop_End', }, { id: 'Stop_End' }, ], }; const toolMessages = []; const executor = new WorkflowExecutor({ workDir: rootDir, model: 'claude-opus-4-6', toolRegistry: registry, }); await executor.execute(parentWorkflow, {}, { onToolMessage: (info) => toolMessages.push(info), }); assertEqual(executor._ctx.variables.$childStatus, 'completed'); assertEqual(executor._ctx.variables.$childValue, 'child:alpha'); assert(String(executor._ctx.variables.$childWorkDir || '').endsWith('/simple-child-space'), 'child workDir should point at isolated subdir'); assertEqual( await fs.readFile(path.join(simpleChildDir, 'Artifacts', 'simple.txt'), 'utf8'), 'child:alpha' ); assert(toolMessages.some((info) => info.name === 'WorkflowRun' && info.data?.event === 'node_start'), 'missing forwarded child node_start event'); assert(toolMessages.some((info) => info.name === 'WorkflowRun' && info.data?.event === 'done'), 'missing forwarded child done event'); }); await test('tools can create a child workflow and run it inside another subspace', async () => { const dynamicChildWorkflow = { version: '3.16', name: 'DynamicExploreChild', steps: [ { id: 'Set_010_Result', target: '$result', value: '="branch=" + branch + ";seed=" + seed', next: 'Write_020_Result' }, { id: 'Write_020_Result', target: '="Artifacts/explore.txt"', value: '=$result', next: 'Stop_End' }, { id: 'Stop_End' }, ], }; const childJsonText = JSON.stringify(dynamicChildWorkflow); const parentWorkflow = { version: '3.16', name: 'DynamicSubflowExploration', steps: [ { id: 'Set_005_SpawnExplore', target: '$spawnExplore', value: '=true', next: 'Tool_020_WriteChild' }, { id: 'Tool_020_WriteChild', tool: 'WriteFile', if: '=$spawnExplore', input: { file_path: '="sandboxes/branch-a/generated-child.json"', content: childJsonText, }, next: 'Subflow_030_RunExplore', }, { id: 'Subflow_030_RunExplore', if: '=$spawnExplore', workflow_path: '="generated-child.json"', work_dir: '="sandboxes/branch-a"', params: { seed: '="beta"', branch: '="branch-a"', }, out: { '$dynamicStatus': '=_result.status', '$dynamicArtifact': '=_result.variables["$result"]', '$dynamicFiles': '=_result.filesWritten', }, next: 'Tool_040_ReadExplore', }, { id: 'Tool_040_ReadExplore', tool: 'ReadFile', if: '=$spawnExplore', input: { file_path: '="sandboxes/branch-a/Artifacts/explore.txt"', }, out: { '$artifactText': '=_result', }, next: 'Stop_End', }, { id: 'Stop_End' }, ], }; const toolStarts = []; const toolMessages = []; const executor = new WorkflowExecutor({ workDir: rootDir, model: 'claude-opus-4-6', toolRegistry: registry, }); await executor.execute(parentWorkflow, {}, { onToolStart: (info) => toolStarts.push(info), onToolMessage: (info) => toolMessages.push(info), }); const childWorkflowPath = path.join(dynamicChildDir, 'generated-child.json'); const childArtifactPath = path.join(dynamicChildDir, 'Artifacts', 'explore.txt'); assertEqual(executor._ctx.variables.$dynamicStatus, 'completed'); assertEqual(executor._ctx.variables.$dynamicArtifact, 'branch=branch-a;seed=beta'); assert(String(executor._ctx.variables.$artifactText || '').includes('branch=branch-a;seed=beta'), 'parent should read child artifact back'); assert((executor._ctx.variables.$dynamicFiles || []).some((fp) => String(fp).includes('Artifacts/explore.txt')), 'child filesWritten should include explore artifact'); assertEqual(await fs.readFile(childArtifactPath, 'utf8'), 'branch=branch-a;seed=beta'); assert(await fs.readFile(childWorkflowPath, 'utf8'), 'generated child workflow should exist'); let rootArtifactMissing = false; try { await fs.access(path.join(rootDir, 'Artifacts', 'explore.txt')); } catch { rootArtifactMissing = true; } assert(rootArtifactMissing, 'child artifact should remain isolated in child subspace'); assert(toolStarts.some((info) => info.stepId === 'Tool_020_WriteChild' && info.name === 'WriteFile'), 'missing dynamic child WriteFile tool start'); assert(toolStarts.some((info) => info.stepId === 'Subflow_030_RunExplore' && info.name === 'WorkflowRun'), 'missing subflow WorkflowRun tool start'); assert(toolMessages.some((info) => info.stepId === 'Subflow_030_RunExplore' && info.data?.event === 'node_start'), 'missing forwarded child node_start'); assert(toolMessages.some((info) => info.stepId === 'Subflow_030_RunExplore' && info.data?.event === 'done'), 'missing forwarded child done'); }); await test('child workflows scope file and bash tools to their own work_dir', async () => { const toolChildWorkflow = { version: '3.16', name: 'ToolScopedChild', steps: [ { id: 'Tool_010_WriteNote', tool: 'WriteFile', input: { file_path: '="Artifacts/note.txt"', content: '="note:" + seed', }, next: 'Tool_020_ReadNote', }, { id: 'Tool_020_ReadNote', tool: 'ReadFile', input: { file_path: '="Artifacts/note.txt"', }, out: { '$noteText': '=_result', }, next: 'Tool_030_Pwd', }, { id: 'Tool_030_Pwd', tool: 'Bash', input: { command: '="pwd"', }, out: { '$pwdText': '=_result', }, next: 'Stop_End', }, { id: 'Stop_End' }, ], }; await fs.writeFile(path.join(toolChildDir, 'tool-child.json'), JSON.stringify(toolChildWorkflow, null, 2), 'utf8'); const parentWorkflow = { version: '3.16', name: 'ScopedToolChildParent', steps: [ { id: 'Subflow_010_RunToolChild', workflow_path: 'tool-child.json', work_dir: 'tool-child-space', params: { seed: '="gamma"' }, out: { '$childNote': '=_result.variables["$noteText"]', '$childPwd': '=_result.variables["$pwdText"]', }, next: 'Stop_End', }, { id: 'Stop_End' }, ], }; const executor = new WorkflowExecutor({ workDir: rootDir, model: 'claude-opus-4-6', toolRegistry: registry, }); await executor.execute(parentWorkflow, {}, {}); const childNotePath = path.join(toolChildDir, 'Artifacts', 'note.txt'); assert(String(executor._ctx.variables.$childNote || '').includes('note:gamma'), 'child note should be returned to parent'); assert(String(executor._ctx.variables.$childPwd || '').trim() === toolChildDir, 'bash pwd should execute inside child work_dir'); assertEqual(await fs.readFile(childNotePath, 'utf8'), 'note:gamma'); let rootNoteMissing = false; try { await fs.access(path.join(rootDir, 'Artifacts', 'note.txt')); } catch { rootNoteMissing = true; } assert(rootNoteMissing, 'root workspace should not receive child note artifact'); }); console.log(`\n── Results ──\n\n ${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0);