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