test-workflow-subflow-runtime.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import fs from 'fs/promises';
  2. import path from 'path';
  3. import { WorkflowExecutor } from './src/vl/workflow-executor.js';
  4. import { ToolRegistry } from './src/core/tool-registry.js';
  5. import { createReadFileTool } from './src/tools/read-file.js';
  6. import { createWriteFileTool } from './src/tools/write-file.js';
  7. import { createBashTool } from './src/tools/bash.js';
  8. import { createWorkflowRunTool } from './src/tools/workflow-run.js';
  9. let passed = 0;
  10. let failed = 0;
  11. function test(name, fn) {
  12. return Promise.resolve()
  13. .then(fn)
  14. .then(() => {
  15. console.log(` ✓ ${name}`);
  16. passed++;
  17. })
  18. .catch((err) => {
  19. console.log(` ✗ ${name}: ${err.message}`);
  20. failed++;
  21. });
  22. }
  23. function assertEqual(a, b, msg) {
  24. if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
  25. }
  26. function assert(cond, msg) {
  27. if (!cond) throw new Error(msg || 'Assertion failed');
  28. }
  29. const rootDir = '/tmp/vlcode-lite-subflow-runtime-test';
  30. const simpleChildDir = path.join(rootDir, 'simple-child-space');
  31. const dynamicChildDir = path.join(rootDir, 'sandboxes', 'branch-a');
  32. const toolChildDir = path.join(rootDir, 'tool-child-space');
  33. console.log('\n── Workflow Subflow Runtime Regression ──');
  34. await fs.rm(rootDir, { recursive: true, force: true });
  35. await fs.mkdir(simpleChildDir, { recursive: true });
  36. await fs.mkdir(dynamicChildDir, { recursive: true });
  37. await fs.mkdir(toolChildDir, { recursive: true });
  38. const registry = new ToolRegistry();
  39. const toolConfig = {
  40. workDir: rootDir,
  41. toolRegistry: registry,
  42. _toolRegistry: registry,
  43. model: 'claude-opus-4-6',
  44. llmProvider: 'anthropic',
  45. };
  46. registry.register('ReadFile', createReadFileTool({ workDir: rootDir }));
  47. registry.register('WriteFile', createWriteFileTool({ workDir: rootDir }));
  48. registry.register('Bash', createBashTool({ workDir: rootDir }));
  49. registry.register('WorkflowRun', createWorkflowRunTool(toolConfig));
  50. const simpleChildWorkflow = {
  51. version: '3.16',
  52. name: 'SimpleChildWorkflow',
  53. steps: [
  54. { id: 'Set_010_Result', target: '$result', value: '="child:" + goal', next: 'Write_020_Result' },
  55. { id: 'Write_020_Result', target: '="Artifacts/simple.txt"', value: '=$result', next: 'Stop_End' },
  56. { id: 'Stop_End' },
  57. ],
  58. };
  59. await fs.writeFile(path.join(simpleChildDir, 'simple-child.json'), JSON.stringify(simpleChildWorkflow, null, 2), 'utf8');
  60. await test('Subflow_* alias executes WorkflowRun sync and maps child outputs', async () => {
  61. const parentWorkflow = {
  62. version: '3.16',
  63. name: 'ParentSubflowAlias',
  64. steps: [
  65. {
  66. id: 'Subflow_010_RunChild',
  67. workflow_path: 'simple-child.json',
  68. work_dir: 'simple-child-space',
  69. params: { goal: '="alpha"' },
  70. out: {
  71. '$childStatus': '=_result.status',
  72. '$childValue': '=_result.variables["$result"]',
  73. '$childWorkDir': '=_result.workDir',
  74. },
  75. next: 'Stop_End',
  76. },
  77. { id: 'Stop_End' },
  78. ],
  79. };
  80. const toolMessages = [];
  81. const executor = new WorkflowExecutor({
  82. workDir: rootDir,
  83. model: 'claude-opus-4-6',
  84. toolRegistry: registry,
  85. });
  86. await executor.execute(parentWorkflow, {}, {
  87. onToolMessage: (info) => toolMessages.push(info),
  88. });
  89. assertEqual(executor._ctx.variables.$childStatus, 'completed');
  90. assertEqual(executor._ctx.variables.$childValue, 'child:alpha');
  91. assert(String(executor._ctx.variables.$childWorkDir || '').endsWith('/simple-child-space'), 'child workDir should point at isolated subdir');
  92. assertEqual(
  93. await fs.readFile(path.join(simpleChildDir, 'Artifacts', 'simple.txt'), 'utf8'),
  94. 'child:alpha'
  95. );
  96. assert(toolMessages.some((info) => info.name === 'WorkflowRun' && info.data?.event === 'node_start'), 'missing forwarded child node_start event');
  97. assert(toolMessages.some((info) => info.name === 'WorkflowRun' && info.data?.event === 'done'), 'missing forwarded child done event');
  98. });
  99. await test('tools can create a child workflow and run it inside another subspace', async () => {
  100. const dynamicChildWorkflow = {
  101. version: '3.16',
  102. name: 'DynamicExploreChild',
  103. steps: [
  104. { id: 'Set_010_Result', target: '$result', value: '="branch=" + branch + ";seed=" + seed', next: 'Write_020_Result' },
  105. { id: 'Write_020_Result', target: '="Artifacts/explore.txt"', value: '=$result', next: 'Stop_End' },
  106. { id: 'Stop_End' },
  107. ],
  108. };
  109. const childJsonText = JSON.stringify(dynamicChildWorkflow);
  110. const parentWorkflow = {
  111. version: '3.16',
  112. name: 'DynamicSubflowExploration',
  113. steps: [
  114. { id: 'Set_005_SpawnExplore', target: '$spawnExplore', value: '=true', next: 'Tool_020_WriteChild' },
  115. {
  116. id: 'Tool_020_WriteChild',
  117. tool: 'WriteFile',
  118. if: '=$spawnExplore',
  119. input: {
  120. file_path: '="sandboxes/branch-a/generated-child.json"',
  121. content: childJsonText,
  122. },
  123. next: 'Subflow_030_RunExplore',
  124. },
  125. {
  126. id: 'Subflow_030_RunExplore',
  127. if: '=$spawnExplore',
  128. workflow_path: '="generated-child.json"',
  129. work_dir: '="sandboxes/branch-a"',
  130. params: {
  131. seed: '="beta"',
  132. branch: '="branch-a"',
  133. },
  134. out: {
  135. '$dynamicStatus': '=_result.status',
  136. '$dynamicArtifact': '=_result.variables["$result"]',
  137. '$dynamicFiles': '=_result.filesWritten',
  138. },
  139. next: 'Tool_040_ReadExplore',
  140. },
  141. {
  142. id: 'Tool_040_ReadExplore',
  143. tool: 'ReadFile',
  144. if: '=$spawnExplore',
  145. input: {
  146. file_path: '="sandboxes/branch-a/Artifacts/explore.txt"',
  147. },
  148. out: {
  149. '$artifactText': '=_result',
  150. },
  151. next: 'Stop_End',
  152. },
  153. { id: 'Stop_End' },
  154. ],
  155. };
  156. const toolStarts = [];
  157. const toolMessages = [];
  158. const executor = new WorkflowExecutor({
  159. workDir: rootDir,
  160. model: 'claude-opus-4-6',
  161. toolRegistry: registry,
  162. });
  163. await executor.execute(parentWorkflow, {}, {
  164. onToolStart: (info) => toolStarts.push(info),
  165. onToolMessage: (info) => toolMessages.push(info),
  166. });
  167. const childWorkflowPath = path.join(dynamicChildDir, 'generated-child.json');
  168. const childArtifactPath = path.join(dynamicChildDir, 'Artifacts', 'explore.txt');
  169. assertEqual(executor._ctx.variables.$dynamicStatus, 'completed');
  170. assertEqual(executor._ctx.variables.$dynamicArtifact, 'branch=branch-a;seed=beta');
  171. assert(String(executor._ctx.variables.$artifactText || '').includes('branch=branch-a;seed=beta'), 'parent should read child artifact back');
  172. assert((executor._ctx.variables.$dynamicFiles || []).some((fp) => String(fp).includes('Artifacts/explore.txt')), 'child filesWritten should include explore artifact');
  173. assertEqual(await fs.readFile(childArtifactPath, 'utf8'), 'branch=branch-a;seed=beta');
  174. assert(await fs.readFile(childWorkflowPath, 'utf8'), 'generated child workflow should exist');
  175. let rootArtifactMissing = false;
  176. try {
  177. await fs.access(path.join(rootDir, 'Artifacts', 'explore.txt'));
  178. } catch {
  179. rootArtifactMissing = true;
  180. }
  181. assert(rootArtifactMissing, 'child artifact should remain isolated in child subspace');
  182. assert(toolStarts.some((info) => info.stepId === 'Tool_020_WriteChild' && info.name === 'WriteFile'), 'missing dynamic child WriteFile tool start');
  183. assert(toolStarts.some((info) => info.stepId === 'Subflow_030_RunExplore' && info.name === 'WorkflowRun'), 'missing subflow WorkflowRun tool start');
  184. assert(toolMessages.some((info) => info.stepId === 'Subflow_030_RunExplore' && info.data?.event === 'node_start'), 'missing forwarded child node_start');
  185. assert(toolMessages.some((info) => info.stepId === 'Subflow_030_RunExplore' && info.data?.event === 'done'), 'missing forwarded child done');
  186. });
  187. await test('child workflows scope file and bash tools to their own work_dir', async () => {
  188. const toolChildWorkflow = {
  189. version: '3.16',
  190. name: 'ToolScopedChild',
  191. steps: [
  192. {
  193. id: 'Tool_010_WriteNote',
  194. tool: 'WriteFile',
  195. input: {
  196. file_path: '="Artifacts/note.txt"',
  197. content: '="note:" + seed',
  198. },
  199. next: 'Tool_020_ReadNote',
  200. },
  201. {
  202. id: 'Tool_020_ReadNote',
  203. tool: 'ReadFile',
  204. input: {
  205. file_path: '="Artifacts/note.txt"',
  206. },
  207. out: {
  208. '$noteText': '=_result',
  209. },
  210. next: 'Tool_030_Pwd',
  211. },
  212. {
  213. id: 'Tool_030_Pwd',
  214. tool: 'Bash',
  215. input: {
  216. command: '="pwd"',
  217. },
  218. out: {
  219. '$pwdText': '=_result',
  220. },
  221. next: 'Stop_End',
  222. },
  223. { id: 'Stop_End' },
  224. ],
  225. };
  226. await fs.writeFile(path.join(toolChildDir, 'tool-child.json'), JSON.stringify(toolChildWorkflow, null, 2), 'utf8');
  227. const parentWorkflow = {
  228. version: '3.16',
  229. name: 'ScopedToolChildParent',
  230. steps: [
  231. {
  232. id: 'Subflow_010_RunToolChild',
  233. workflow_path: 'tool-child.json',
  234. work_dir: 'tool-child-space',
  235. params: { seed: '="gamma"' },
  236. out: {
  237. '$childNote': '=_result.variables["$noteText"]',
  238. '$childPwd': '=_result.variables["$pwdText"]',
  239. },
  240. next: 'Stop_End',
  241. },
  242. { id: 'Stop_End' },
  243. ],
  244. };
  245. const executor = new WorkflowExecutor({
  246. workDir: rootDir,
  247. model: 'claude-opus-4-6',
  248. toolRegistry: registry,
  249. });
  250. await executor.execute(parentWorkflow, {}, {});
  251. const childNotePath = path.join(toolChildDir, 'Artifacts', 'note.txt');
  252. assert(String(executor._ctx.variables.$childNote || '').includes('note:gamma'), 'child note should be returned to parent');
  253. assert(String(executor._ctx.variables.$childPwd || '').trim() === toolChildDir, 'bash pwd should execute inside child work_dir');
  254. assertEqual(await fs.readFile(childNotePath, 'utf8'), 'note:gamma');
  255. let rootNoteMissing = false;
  256. try {
  257. await fs.access(path.join(rootDir, 'Artifacts', 'note.txt'));
  258. } catch {
  259. rootNoteMissing = true;
  260. }
  261. assert(rootNoteMissing, 'root workspace should not receive child note artifact');
  262. });
  263. console.log(`\n── Results ──\n\n ${passed} passed, ${failed} failed\n`);
  264. process.exit(failed > 0 ? 1 : 0);