@xdelivered/emberflow 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (414) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/bin/commands.test.ts +185 -0
  4. package/bin/commands.ts +306 -0
  5. package/bin/emberflow.mjs +38 -0
  6. package/bin/init.test.ts +165 -0
  7. package/bin/init.ts +297 -0
  8. package/bin/runtime.mjs +105 -0
  9. package/bin/runtime.test.ts +167 -0
  10. package/bin/skillTargets.test.ts +108 -0
  11. package/bin/skillTargets.ts +36 -0
  12. package/bin/skillTemplates.test.ts +37 -0
  13. package/bin/tsconfig.json +23 -0
  14. package/dist/bin/commands.d.ts +24 -0
  15. package/dist/bin/commands.js +252 -0
  16. package/dist/bin/init.d.ts +17 -0
  17. package/dist/bin/init.js +241 -0
  18. package/dist/bin/runtime.d.mts +39 -0
  19. package/dist/bin/runtime.mjs +96 -0
  20. package/dist/bin/skillTargets.d.ts +13 -0
  21. package/dist/bin/skillTargets.js +22 -0
  22. package/dist/server/agents/claudeAdapter.d.ts +8 -0
  23. package/dist/server/agents/claudeAdapter.js +190 -0
  24. package/dist/server/agents/claudeParse.d.ts +2 -0
  25. package/dist/server/agents/claudeParse.js +162 -0
  26. package/dist/server/agents/codexAdapter.d.ts +13 -0
  27. package/dist/server/agents/codexAdapter.js +174 -0
  28. package/dist/server/agents/codexParse.d.ts +2 -0
  29. package/dist/server/agents/codexParse.js +74 -0
  30. package/dist/server/agents/detect.d.ts +23 -0
  31. package/dist/server/agents/detect.js +35 -0
  32. package/dist/server/agents/gitScope.d.ts +24 -0
  33. package/dist/server/agents/gitScope.js +95 -0
  34. package/dist/server/agents/modelRejectionHint.d.ts +8 -0
  35. package/dist/server/agents/modelRejectionHint.js +12 -0
  36. package/dist/server/agents/prompt.d.ts +79 -0
  37. package/dist/server/agents/prompt.js +364 -0
  38. package/dist/server/agents/runManager.d.ts +90 -0
  39. package/dist/server/agents/runManager.js +259 -0
  40. package/dist/server/agents/types.d.ts +15 -0
  41. package/dist/server/agents/types.js +1 -0
  42. package/dist/server/apiStore.d.ts +60 -0
  43. package/dist/server/apiStore.js +249 -0
  44. package/dist/server/auth/enforce.d.ts +1 -0
  45. package/dist/server/auth/enforce.js +6 -0
  46. package/dist/server/auth/types.d.ts +1 -0
  47. package/dist/server/auth/types.js +1 -0
  48. package/dist/server/auth/verifiers.d.ts +1 -0
  49. package/dist/server/auth/verifiers.js +4 -0
  50. package/dist/server/authAttach.d.ts +19 -0
  51. package/dist/server/authAttach.js +79 -0
  52. package/dist/server/cli.d.ts +7 -0
  53. package/dist/server/cli.js +804 -0
  54. package/dist/server/client.d.ts +130 -0
  55. package/dist/server/client.js +247 -0
  56. package/dist/server/environments.d.ts +103 -0
  57. package/dist/server/environments.js +486 -0
  58. package/dist/server/flowStore.d.ts +37 -0
  59. package/dist/server/flowStore.js +117 -0
  60. package/dist/server/httpOperations.d.ts +32 -0
  61. package/dist/server/httpOperations.js +62 -0
  62. package/dist/server/index.d.ts +1 -0
  63. package/dist/server/index.js +1022 -0
  64. package/dist/server/infrastructure.d.ts +51 -0
  65. package/dist/server/infrastructure.js +116 -0
  66. package/dist/server/login.d.ts +21 -0
  67. package/dist/server/login.js +44 -0
  68. package/dist/server/mcp.d.ts +1 -0
  69. package/dist/server/mcp.js +182 -0
  70. package/dist/server/migrateFlows.d.ts +8 -0
  71. package/dist/server/migrateFlows.js +25 -0
  72. package/dist/server/mockHandler.d.ts +15 -0
  73. package/dist/server/mockHandler.js +35 -0
  74. package/dist/server/nodesPayload.d.ts +14 -0
  75. package/dist/server/nodesPayload.js +14 -0
  76. package/dist/server/normalizeFlow.d.ts +21 -0
  77. package/dist/server/normalizeFlow.js +48 -0
  78. package/dist/server/openBrowser.d.ts +8 -0
  79. package/dist/server/openBrowser.js +16 -0
  80. package/dist/server/operationResult.d.ts +12 -0
  81. package/dist/server/operationResult.js +26 -0
  82. package/dist/server/pathGuard.d.ts +1 -0
  83. package/dist/server/pathGuard.js +16 -0
  84. package/dist/server/pathSafety.d.ts +20 -0
  85. package/dist/server/pathSafety.js +35 -0
  86. package/dist/server/projectConfig.d.ts +47 -0
  87. package/dist/server/projectConfig.js +61 -0
  88. package/dist/server/projectMode.d.ts +26 -0
  89. package/dist/server/projectMode.js +44 -0
  90. package/dist/server/redact.d.ts +6 -0
  91. package/dist/server/redact.js +37 -0
  92. package/dist/server/runRegistry.d.ts +84 -0
  93. package/dist/server/runRegistry.js +200 -0
  94. package/dist/server/runtime/RuntimeAdapter.d.ts +22 -0
  95. package/dist/server/runtime/RuntimeAdapter.js +1 -0
  96. package/dist/server/runtime/expressAdapter.d.ts +19 -0
  97. package/dist/server/runtime/expressAdapter.js +56 -0
  98. package/dist/server/scenarioTest.d.ts +14 -0
  99. package/dist/server/scenarioTest.js +66 -0
  100. package/dist/server/subflowRunner.d.ts +32 -0
  101. package/dist/server/subflowRunner.js +72 -0
  102. package/dist/server/testRunner.d.ts +70 -0
  103. package/dist/server/testRunner.js +151 -0
  104. package/dist/src/config.d.ts +3 -0
  105. package/dist/src/config.js +2 -0
  106. package/dist/src/engine/authVerify.d.ts +33 -0
  107. package/dist/src/engine/authVerify.js +63 -0
  108. package/dist/src/engine/diagnostics.d.ts +62 -0
  109. package/dist/src/engine/diagnostics.js +148 -0
  110. package/dist/src/engine/executor.d.ts +151 -0
  111. package/dist/src/engine/executor.js +684 -0
  112. package/dist/src/engine/httpError.d.ts +7 -0
  113. package/dist/src/engine/httpError.js +14 -0
  114. package/dist/src/engine/index.d.ts +11 -0
  115. package/dist/src/engine/index.js +11 -0
  116. package/dist/src/engine/output.d.ts +11 -0
  117. package/dist/src/engine/output.js +20 -0
  118. package/dist/src/engine/publish.d.ts +30 -0
  119. package/dist/src/engine/publish.js +95 -0
  120. package/dist/src/engine/registry.d.ts +34 -0
  121. package/dist/src/engine/registry.js +67 -0
  122. package/dist/src/engine/scenarios.d.ts +12 -0
  123. package/dist/src/engine/scenarios.js +19 -0
  124. package/dist/src/engine/schemaCheck.d.ts +4 -0
  125. package/dist/src/engine/schemaCheck.js +34 -0
  126. package/dist/src/engine/trace.d.ts +12 -0
  127. package/dist/src/engine/trace.js +12 -0
  128. package/dist/src/engine/types.d.ts +308 -0
  129. package/dist/src/engine/types.js +1 -0
  130. package/dist/src/engine/validation.d.ts +26 -0
  131. package/dist/src/engine/validation.js +318 -0
  132. package/dist/src/flows/anomaly-flows.d.ts +23 -0
  133. package/dist/src/flows/anomaly-flows.js +151 -0
  134. package/dist/src/flows/city-sweep-flow.d.ts +10 -0
  135. package/dist/src/flows/city-sweep-flow.js +127 -0
  136. package/dist/src/flows/login-flow.d.ts +13 -0
  137. package/dist/src/flows/login-flow.js +145 -0
  138. package/dist/src/flows/pradar-flows.d.ts +5 -0
  139. package/dist/src/flows/pradar-flows.js +375 -0
  140. package/dist/src/flows/weather-flow.d.ts +12 -0
  141. package/dist/src/flows/weather-flow.js +150 -0
  142. package/dist/src/lib/pathParams.d.ts +3 -0
  143. package/dist/src/lib/pathParams.js +6 -0
  144. package/dist/src/nodes/anomaly.d.ts +2 -0
  145. package/dist/src/nodes/anomaly.js +556 -0
  146. package/dist/src/nodes/flow-control.d.ts +7 -0
  147. package/dist/src/nodes/flow-control.js +270 -0
  148. package/dist/src/nodes/index.d.ts +3 -0
  149. package/dist/src/nodes/index.js +20 -0
  150. package/dist/src/nodes/login.d.ts +7 -0
  151. package/dist/src/nodes/login.js +158 -0
  152. package/dist/src/nodes/loops.d.ts +11 -0
  153. package/dist/src/nodes/loops.js +60 -0
  154. package/dist/src/nodes/pradar/index.d.ts +7 -0
  155. package/dist/src/nodes/pradar/index.js +9 -0
  156. package/dist/src/nodes/pradar/logic.d.ts +189 -0
  157. package/dist/src/nodes/pradar/logic.js +370 -0
  158. package/dist/src/nodes/pradar/nodes.d.ts +42 -0
  159. package/dist/src/nodes/pradar/nodes.js +801 -0
  160. package/dist/src/nodes/requireAuth.d.ts +20 -0
  161. package/dist/src/nodes/requireAuth.js +48 -0
  162. package/dist/src/nodes/response.d.ts +8 -0
  163. package/dist/src/nodes/response.js +33 -0
  164. package/dist/src/nodes/weather.d.ts +33 -0
  165. package/dist/src/nodes/weather.js +260 -0
  166. package/package.json +92 -0
  167. package/server/agentRoute.test.ts +157 -0
  168. package/server/agents/__fixtures__/fake-claude-writer.mjs +23 -0
  169. package/server/agents/__fixtures__/fake-claude.mjs +55 -0
  170. package/server/agents/__fixtures__/fake-codex-writer.mjs +27 -0
  171. package/server/agents/__fixtures__/fake-codex.mjs +52 -0
  172. package/server/agents/__fixtures__/fake-version-empty.mjs +3 -0
  173. package/server/agents/__fixtures__/fake-version-fail.mjs +4 -0
  174. package/server/agents/__fixtures__/fake-version-garbage.mjs +4 -0
  175. package/server/agents/__fixtures__/fake-version-good.mjs +4 -0
  176. package/server/agents/__fixtures__/fake-version-hang.mjs +3 -0
  177. package/server/agents/claudeAdapter.test.ts +127 -0
  178. package/server/agents/claudeAdapter.ts +212 -0
  179. package/server/agents/claudeParse.ts +193 -0
  180. package/server/agents/codexAdapter.test.ts +125 -0
  181. package/server/agents/codexAdapter.ts +198 -0
  182. package/server/agents/codexParse.test.ts +58 -0
  183. package/server/agents/codexParse.ts +126 -0
  184. package/server/agents/detect.test.ts +65 -0
  185. package/server/agents/detect.ts +43 -0
  186. package/server/agents/gitScope.test.ts +129 -0
  187. package/server/agents/gitScope.ts +110 -0
  188. package/server/agents/modelRejectionHint.test.ts +30 -0
  189. package/server/agents/modelRejectionHint.ts +14 -0
  190. package/server/agents/prompt.test.ts +613 -0
  191. package/server/agents/prompt.ts +527 -0
  192. package/server/agents/runManager.test.ts +251 -0
  193. package/server/agents/runManager.ts +286 -0
  194. package/server/agents/types.ts +17 -0
  195. package/server/apiMount.test.ts +591 -0
  196. package/server/apiStore.test.ts +324 -0
  197. package/server/apiStore.ts +241 -0
  198. package/server/auth/enforce.test.ts +61 -0
  199. package/server/auth/enforce.ts +6 -0
  200. package/server/auth/types.ts +5 -0
  201. package/server/auth/verifiers.test.ts +37 -0
  202. package/server/auth/verifiers.ts +9 -0
  203. package/server/authAttach.test.ts +175 -0
  204. package/server/authAttach.ts +99 -0
  205. package/server/cli.ts +847 -0
  206. package/server/cliAuth.test.ts +229 -0
  207. package/server/cliCreate.test.ts +131 -0
  208. package/server/cliDoctor.test.ts +326 -0
  209. package/server/cliTest.test.ts +735 -0
  210. package/server/client.ts +314 -0
  211. package/server/demoProject.test.ts +29 -0
  212. package/server/diagnosticsRoute.test.ts +169 -0
  213. package/server/environments.test.ts +620 -0
  214. package/server/environments.ts +603 -0
  215. package/server/environmentsRoute.test.ts +385 -0
  216. package/server/errorWorkflow.test.ts +205 -0
  217. package/server/flowStore.test.ts +84 -0
  218. package/server/flowStore.ts +125 -0
  219. package/server/httpOperations.test.ts +377 -0
  220. package/server/httpOperations.ts +96 -0
  221. package/server/index.ts +1111 -0
  222. package/server/infrastructure.test.ts +154 -0
  223. package/server/infrastructure.ts +161 -0
  224. package/server/infrastructureRoute.test.ts +105 -0
  225. package/server/login.test.ts +153 -0
  226. package/server/login.ts +67 -0
  227. package/server/mcp.ts +278 -0
  228. package/server/migrateFlows.test.ts +55 -0
  229. package/server/migrateFlows.ts +23 -0
  230. package/server/mockHandler.test.ts +138 -0
  231. package/server/mockHandler.ts +52 -0
  232. package/server/mockRun.test.ts +367 -0
  233. package/server/mockServe.test.ts +467 -0
  234. package/server/nodesPayload.test.ts +29 -0
  235. package/server/nodesPayload.ts +26 -0
  236. package/server/normalizeFlow.test.ts +80 -0
  237. package/server/normalizeFlow.ts +59 -0
  238. package/server/openBrowser.test.ts +20 -0
  239. package/server/openBrowser.ts +22 -0
  240. package/server/operationResult.test.ts +67 -0
  241. package/server/operationResult.ts +27 -0
  242. package/server/pathGuard.test.ts +19 -0
  243. package/server/pathGuard.ts +12 -0
  244. package/server/pathSafety.test.ts +38 -0
  245. package/server/pathSafety.ts +33 -0
  246. package/server/projectConfig.test.ts +67 -0
  247. package/server/projectConfig.ts +101 -0
  248. package/server/projectMode.test.ts +106 -0
  249. package/server/projectMode.ts +59 -0
  250. package/server/redact.test.ts +88 -0
  251. package/server/redact.ts +35 -0
  252. package/server/redactEgress.test.ts +281 -0
  253. package/server/runRegistry.ts +270 -0
  254. package/server/runsAuth.test.ts +119 -0
  255. package/server/runtime/RuntimeAdapter.ts +24 -0
  256. package/server/runtime/expressAdapter.test.ts +38 -0
  257. package/server/runtime/expressAdapter.ts +70 -0
  258. package/server/scenarioTest.test.ts +148 -0
  259. package/server/scenarioTest.ts +88 -0
  260. package/server/setupStatus.test.ts +127 -0
  261. package/server/subflowRunner.ts +104 -0
  262. package/server/testRunner.ts +216 -0
  263. package/server/tsconfig.json +20 -0
  264. package/server/workflowTestRoute.test.ts +297 -0
  265. package/src/App.tsx +233 -0
  266. package/src/components/AgentConsole.tsx +461 -0
  267. package/src/components/BranchRulesEditor.tsx +156 -0
  268. package/src/components/CreateModal.tsx +275 -0
  269. package/src/components/Dock.tsx +487 -0
  270. package/src/components/EnvironmentDialog.tsx +569 -0
  271. package/src/components/EnvironmentPicker.tsx +357 -0
  272. package/src/components/EnvironmentsDialog.tsx +144 -0
  273. package/src/components/ExecutionPager.tsx +67 -0
  274. package/src/components/InfraPanel.test.tsx +103 -0
  275. package/src/components/InfraPanel.tsx +171 -0
  276. package/src/components/Inspector.tsx +1182 -0
  277. package/src/components/Json.tsx +50 -0
  278. package/src/components/JsonModal.tsx +52 -0
  279. package/src/components/LogMessage.tsx +61 -0
  280. package/src/components/NodeRunModal.tsx +213 -0
  281. package/src/components/RunLogPanel.tsx +397 -0
  282. package/src/components/RunbookView.tsx +1085 -0
  283. package/src/components/Scenarios.tsx +476 -0
  284. package/src/components/SettingsDialog.tsx +229 -0
  285. package/src/components/Sidebar.tsx +436 -0
  286. package/src/components/StatusBar.tsx +302 -0
  287. package/src/components/Toolbar.tsx +663 -0
  288. package/src/components/WelcomeDialog.test.tsx +107 -0
  289. package/src/components/WelcomeDialog.tsx +412 -0
  290. package/src/components/agentMarkdown.test.tsx +91 -0
  291. package/src/components/agentMarkdown.tsx +158 -0
  292. package/src/components/ui/badge.tsx +31 -0
  293. package/src/components/ui/button.tsx +46 -0
  294. package/src/components/ui/combobox.tsx +114 -0
  295. package/src/components/ui/command.tsx +82 -0
  296. package/src/components/ui/dialog.tsx +73 -0
  297. package/src/components/ui/input.tsx +20 -0
  298. package/src/components/ui/popover.tsx +32 -0
  299. package/src/components/ui/resizable.tsx +44 -0
  300. package/src/components/ui/tabs.tsx +36 -0
  301. package/src/config.ts +4 -0
  302. package/src/engine/authVerify.ts +81 -0
  303. package/src/engine/diagnostics.test.ts +437 -0
  304. package/src/engine/diagnostics.ts +211 -0
  305. package/src/engine/executor.test.ts +1339 -0
  306. package/src/engine/executor.ts +840 -0
  307. package/src/engine/httpError.test.ts +12 -0
  308. package/src/engine/httpError.ts +16 -0
  309. package/src/engine/index.ts +11 -0
  310. package/src/engine/output.ts +21 -0
  311. package/src/engine/publish.test.ts +176 -0
  312. package/src/engine/publish.ts +127 -0
  313. package/src/engine/registry.test.ts +64 -0
  314. package/src/engine/registry.ts +80 -0
  315. package/src/engine/scenarios.test.ts +55 -0
  316. package/src/engine/scenarios.ts +29 -0
  317. package/src/engine/schemaCheck.test.ts +21 -0
  318. package/src/engine/schemaCheck.ts +35 -0
  319. package/src/engine/trace.test.ts +19 -0
  320. package/src/engine/trace.ts +23 -0
  321. package/src/engine/types.test.ts +22 -0
  322. package/src/engine/types.ts +316 -0
  323. package/src/engine/validation.test.ts +314 -0
  324. package/src/engine/validation.ts +354 -0
  325. package/src/flows/anomaly-flows.test.ts +81 -0
  326. package/src/flows/anomaly-flows.ts +202 -0
  327. package/src/flows/city-sweep-flow.ts +131 -0
  328. package/src/flows/flows.test.ts +30 -0
  329. package/src/flows/login-flow.ts +150 -0
  330. package/src/flows/pradar-flows.test.ts +204 -0
  331. package/src/flows/pradar-flows.ts +419 -0
  332. package/src/flows/weather-flow.ts +154 -0
  333. package/src/globals.css +147 -0
  334. package/src/lib/pathParams.test.ts +24 -0
  335. package/src/lib/pathParams.ts +6 -0
  336. package/src/lib/registerLens.test.ts +79 -0
  337. package/src/lib/registerLens.ts +59 -0
  338. package/src/lib/runbookModel.test.ts +61 -0
  339. package/src/lib/runbookModel.ts +290 -0
  340. package/src/lib/runbookProjection.test.ts +417 -0
  341. package/src/lib/runbookProjection.ts +254 -0
  342. package/src/lib/useTailAnchor.ts +53 -0
  343. package/src/lib/utils.ts +7 -0
  344. package/src/main.tsx +13 -0
  345. package/src/nodes/anomaly.test.ts +98 -0
  346. package/src/nodes/anomaly.ts +637 -0
  347. package/src/nodes/effects-scan.test.ts +44 -0
  348. package/src/nodes/flow-control.test.ts +280 -0
  349. package/src/nodes/flow-control.ts +305 -0
  350. package/src/nodes/index.ts +22 -0
  351. package/src/nodes/login.test.ts +81 -0
  352. package/src/nodes/login.ts +186 -0
  353. package/src/nodes/loops.ts +69 -0
  354. package/src/nodes/pradar/index.ts +11 -0
  355. package/src/nodes/pradar/logic.test.ts +288 -0
  356. package/src/nodes/pradar/logic.ts +582 -0
  357. package/src/nodes/pradar/nodes.test.ts +146 -0
  358. package/src/nodes/pradar/nodes.ts +955 -0
  359. package/src/nodes/requireAuth.test.ts +75 -0
  360. package/src/nodes/requireAuth.ts +55 -0
  361. package/src/nodes/response.test.ts +33 -0
  362. package/src/nodes/response.ts +39 -0
  363. package/src/nodes/traceDetail.test.ts +39 -0
  364. package/src/nodes/weather.test.ts +65 -0
  365. package/src/nodes/weather.ts +306 -0
  366. package/src/store/agentClient.test.ts +103 -0
  367. package/src/store/agentClient.ts +131 -0
  368. package/src/store/apiTree.test.ts +17 -0
  369. package/src/store/apiTree.ts +73 -0
  370. package/src/store/builderStore.test.ts +1234 -0
  371. package/src/store/builderStore.ts +1879 -0
  372. package/src/store/infraClient.ts +63 -0
  373. package/src/store/nodeMeta.test.ts +24 -0
  374. package/src/store/nodeMeta.ts +21 -0
  375. package/src/store/persistence.test.ts +15 -0
  376. package/src/store/persistence.ts +54 -0
  377. package/src/store/serverRunner.ts +381 -0
  378. package/src/store/setupClient.ts +57 -0
  379. package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  380. package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  381. package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  382. package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  383. package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  384. package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  385. package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  386. package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  387. package/studio-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  388. package/studio-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  389. package/studio-dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  390. package/studio-dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  391. package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  392. package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  393. package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  394. package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  395. package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  396. package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  397. package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  398. package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  399. package/studio-dist/assets/index-DNJwW-hM.css +1 -0
  400. package/studio-dist/assets/index-O26dKRjW.js +65 -0
  401. package/studio-dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  402. package/studio-dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  403. package/studio-dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  404. package/studio-dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  405. package/studio-dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  406. package/studio-dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  407. package/studio-dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  408. package/studio-dist/favicon.svg +1 -0
  409. package/studio-dist/icons.svg +24 -0
  410. package/studio-dist/index.html +14 -0
  411. package/templates/skills/emberflow-basics/SKILL.md +316 -0
  412. package/templates/skills/emberflow-model-process/SKILL.md +209 -0
  413. package/templates/skills/emberflow-new-workflow/SKILL.md +211 -0
  414. package/templates/skills/emberflow-review-workflow/SKILL.md +148 -0
@@ -0,0 +1,1339 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { NodeRegistry } from './registry';
3
+ import { InMemoryTraceSink } from './trace';
4
+ import { explainUndefinedRead, getByPath, startRun } from './executor';
5
+ import { registerLoopNodes } from '../nodes/loops';
6
+ import { registerFlowControlNodes } from '../nodes/flow-control';
7
+ import type { LogLine, NodeRunState, WorkflowDefinition, WorkflowNode } from './types';
8
+
9
+ function makeRegistry(): NodeRegistry {
10
+ const r = new NodeRegistry();
11
+ r.register(
12
+ { type: 'emit', label: 'Emit', configSchema: { fields: [{ name: 'value', type: 'string' }] } },
13
+ async (ctx) => ({ value: ctx.config.value, nested: { deep: 'gold' } }),
14
+ );
15
+ r.register(
16
+ { type: 'echo', label: 'Echo', inputSchema: { fields: [{ name: 'value', type: 'string', required: true }] } },
17
+ async (ctx) => {
18
+ ctx.log('info', `echoing ${String(ctx.input.value)}`);
19
+ return { echoed: ctx.input.value };
20
+ },
21
+ );
22
+ r.register({ type: 'boom', label: 'Boom' }, async () => {
23
+ throw new Error('kaboom');
24
+ });
25
+ return r;
26
+ }
27
+
28
+ const node = (id: string, type: string, extra: Partial<WorkflowNode> = {}): WorkflowNode => ({
29
+ id, type, label: id, position: { x: 0, y: 0 }, config: {}, ...extra,
30
+ });
31
+
32
+ const flow = (nodes: WorkflowNode[], edges: WorkflowDefinition['edges']): WorkflowDefinition => ({
33
+ id: 'f', name: 'f', version: 1, nodes, edges,
34
+ createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
35
+ });
36
+
37
+ const deterministic = () => {
38
+ let i = 0;
39
+ return { now: () => '2026-01-01T00:00:00Z', newId: () => `id-${++i}` };
40
+ };
41
+
42
+ const linearFlow = () =>
43
+ flow(
44
+ [
45
+ node('a', 'emit', { config: { value: 'hi' } }),
46
+ node('b', 'echo', { inputMap: { value: { sourceNodeId: 'a', sourceField: 'value' } } }),
47
+ ],
48
+ [{ id: 'e1', source: 'a', target: 'b' }],
49
+ );
50
+
51
+ describe('getByPath', () => {
52
+ it('resolves dot paths and $', () => {
53
+ expect(getByPath({ a: { b: 1 } }, 'a.b')).toBe(1);
54
+ expect(getByPath({ a: 1 }, '$')).toEqual({ a: 1 });
55
+ expect(getByPath({ a: 1 }, 'zz.yy')).toBeUndefined();
56
+ });
57
+ });
58
+
59
+ describe('executor', () => {
60
+ it('runs a linear flow and maps outputs to inputs', async () => {
61
+ const handle = startRun({ flow: linearFlow(), registry: makeRegistry(), ...deterministic() });
62
+ const run = await handle.runToEnd();
63
+ expect(run.status).toBe('succeeded');
64
+ expect(run.nodeStates.b.output).toEqual({ echoed: 'hi' });
65
+ expect(run.nodeStates.b.input).toEqual({ value: 'hi' });
66
+ });
67
+
68
+ it('steps one node at a time', async () => {
69
+ const handle = startRun({ flow: linearFlow(), registry: makeRegistry(), ...deterministic() });
70
+ expect(handle.run.nodeStates.a.status).toBe('queued');
71
+ expect(await handle.step()).toBe(true);
72
+ expect(handle.run.nodeStates.a.status).toBe('succeeded');
73
+ expect(handle.run.nodeStates.b.status).toBe('queued');
74
+ expect(await handle.step()).toBe(false);
75
+ expect(handle.run.status).toBe('succeeded');
76
+ });
77
+
78
+ it('supports $ whole-output mapping and dot paths', async () => {
79
+ const f = flow(
80
+ [
81
+ node('a', 'emit', { config: { value: 'hi' } }),
82
+ node('b', 'echo', { inputMap: { value: { sourceNodeId: 'a', sourceField: 'nested.deep' } } }),
83
+ ],
84
+ [{ id: 'e1', source: 'a', target: 'b' }],
85
+ );
86
+ const run = await startRun({ flow: f, registry: makeRegistry(), ...deterministic() }).runToEnd();
87
+ expect(run.nodeStates.b.output).toEqual({ echoed: 'gold' });
88
+ });
89
+
90
+ it('falls back to config for unmapped schema inputs', async () => {
91
+ const f = flow([node('a', 'echo', { config: { value: 'from-config' } })], []);
92
+ const run = await startRun({ flow: f, registry: makeRegistry(), ...deterministic() }).runToEnd();
93
+ expect(run.nodeStates.a.output).toEqual({ echoed: 'from-config' });
94
+ });
95
+
96
+ it('fails the run and skips downstream on node error', async () => {
97
+ const f = flow(
98
+ [node('a', 'boom'), node('b', 'emit')],
99
+ [{ id: 'e1', source: 'a', target: 'b' }],
100
+ );
101
+ const run = await startRun({ flow: f, registry: makeRegistry(), ...deterministic() }).runToEnd();
102
+ expect(run.status).toBe('failed');
103
+ expect(run.nodeStates.a.status).toBe('failed');
104
+ expect(run.nodeStates.a.error).toBe('kaboom');
105
+ expect(run.nodeStates.b.status).toBe('skipped');
106
+ });
107
+
108
+ it('cancel skips remaining nodes', async () => {
109
+ const handle = startRun({ flow: linearFlow(), registry: makeRegistry(), ...deterministic() });
110
+ await handle.step();
111
+ handle.cancel();
112
+ expect(handle.run.status).toBe('cancelled');
113
+ expect(handle.run.nodeStates.b.status).toBe('skipped');
114
+ expect(await handle.step()).toBe(false);
115
+ });
116
+
117
+ it('records a trace sample per executed node, including failures', async () => {
118
+ const trace = new InMemoryTraceSink();
119
+ const f = flow(
120
+ [node('a', 'emit', { config: { value: 'x' } }), node('b', 'boom')],
121
+ [{ id: 'e1', source: 'a', target: 'b' }],
122
+ );
123
+ await startRun({ flow: f, registry: makeRegistry(), trace, ...deterministic() }).runToEnd();
124
+ expect(trace.all()).toHaveLength(2);
125
+ expect(trace.samplesFor('b')[0].status).toBe('failed');
126
+ });
127
+
128
+ it('emits logs and state-change events', async () => {
129
+ const logs: LogLine[] = [];
130
+ const changes: Array<[string, NodeRunState['status']]> = [];
131
+ await startRun({
132
+ flow: linearFlow(),
133
+ registry: makeRegistry(),
134
+ events: {
135
+ onLog: (l) => logs.push(l),
136
+ onNodeStateChange: (id, s) => changes.push([id, s.status]),
137
+ },
138
+ ...deterministic(),
139
+ }).runToEnd();
140
+ expect(logs.some((l) => l.nodeId === 'b' && l.message === 'echoing hi')).toBe(true);
141
+ expect(changes).toContainEqual(['b', 'running']);
142
+ expect(changes).toContainEqual(['b', 'succeeded']);
143
+ });
144
+
145
+ it('does not emit events during construction', () => {
146
+ const changes: string[] = [];
147
+ startRun({
148
+ flow: linearFlow(),
149
+ registry: makeRegistry(),
150
+ events: { onNodeStateChange: (id) => changes.push(id) },
151
+ ...deterministic(),
152
+ });
153
+ expect(changes).toEqual([]);
154
+ });
155
+
156
+ it('refuses to start an invalid flow', () => {
157
+ const f = flow([node('a', 'ghost')], []);
158
+ expect(() => startRun({ flow: f, registry: makeRegistry(), ...deterministic() })).toThrow(/Invalid flow/);
159
+ });
160
+
161
+ it('routes: only the taken branch executes, other branches are skipped', async () => {
162
+ const r = makeRegistry();
163
+ r.register({ type: 'router', label: 'Router' }, async (ctx) => ({
164
+ ...ctx.input,
165
+ $branch: 'left',
166
+ }));
167
+ r.register({ type: 'probe', label: 'Probe' }, async (ctx) => ({ hit: ctx.config.tag }));
168
+ const f = flow(
169
+ [
170
+ node('r', 'router'),
171
+ node('l', 'probe', { config: { tag: 'L' } }),
172
+ node('x', 'probe', { config: { tag: 'X' } }),
173
+ node('afterL', 'probe', { config: { tag: 'AL' } }),
174
+ node('afterX', 'probe', { config: { tag: 'AX' } }),
175
+ ],
176
+ [
177
+ { id: 'e1', source: 'r', target: 'l', sourceHandle: 'left' },
178
+ { id: 'e2', source: 'r', target: 'x', sourceHandle: 'right' },
179
+ { id: 'e3', source: 'l', target: 'afterL' },
180
+ { id: 'e4', source: 'x', target: 'afterX' },
181
+ ],
182
+ );
183
+ const run = await startRun({ flow: f, registry: r, ...deterministic() }).runToEnd();
184
+ expect(run.status).toBe('succeeded');
185
+ expect(run.nodeStates.l.status).toBe('succeeded');
186
+ expect(run.nodeStates.x.status).toBe('skipped');
187
+ expect(run.nodeStates.afterL.status).toBe('succeeded');
188
+ expect(run.nodeStates.afterX.status).toBe('skipped');
189
+ });
190
+
191
+ it('routes: a dead branch edge gates a node even when a data edge is live', async () => {
192
+ // Branch edges are guards: a node sitting on an untaken branch must be
193
+ // skipped even if a plain data edge from an always-executed ancestor is
194
+ // live — otherwise the data edge smuggles it past the gate.
195
+ const r = makeRegistry();
196
+ r.register({ type: 'router', label: 'Router' }, async () => ({ $branch: 'taken' }));
197
+ r.register({ type: 'probe', label: 'Probe' }, async () => ({ ok: true }));
198
+ const f = flow(
199
+ [node('src', 'probe'), node('r', 'router'), node('gated', 'probe')],
200
+ [
201
+ { id: 'e0', source: 'src', target: 'r' },
202
+ { id: 'e1', source: 'r', target: 'gated', sourceHandle: 'untaken' },
203
+ { id: 'e2', source: 'src', target: 'gated' },
204
+ ],
205
+ );
206
+ const run = await startRun({ flow: f, registry: r, ...deterministic() }).runToEnd();
207
+ expect(run.status).toBe('succeeded');
208
+ expect(run.nodeStates.gated.status).toBe('skipped');
209
+ });
210
+
211
+ it('routes: node with one live and one skipped parent still executes', async () => {
212
+ const r = makeRegistry();
213
+ r.register({ type: 'router', label: 'Router' }, async () => ({ $branch: 'a' }));
214
+ r.register({ type: 'probe', label: 'Probe' }, async () => ({ ok: true }));
215
+ const f = flow(
216
+ [node('r', 'router'), node('a', 'probe'), node('b', 'probe'), node('join', 'probe')],
217
+ [
218
+ { id: 'e1', source: 'r', target: 'a', sourceHandle: 'a' },
219
+ { id: 'e2', source: 'r', target: 'b', sourceHandle: 'b' },
220
+ { id: 'e3', source: 'a', target: 'join' },
221
+ { id: 'e4', source: 'b', target: 'join' },
222
+ ],
223
+ );
224
+ const run = await startRun({ flow: f, registry: r, ...deterministic() }).runToEnd();
225
+ expect(run.nodeStates.b.status).toBe('skipped');
226
+ expect(run.nodeStates.join.status).toBe('succeeded');
227
+ });
228
+
229
+ it('exposes the run input to node implementations, defaulting to {}', async () => {
230
+ const registry = makeRegistry();
231
+ registry.register(
232
+ { type: 'reads-input', label: 'Reads Input' },
233
+ async (ctx) => ({ got: ctx.runInput.city }),
234
+ );
235
+ const f = flow([node('a', 'reads-input')], []);
236
+ const withInput = await startRun({
237
+ flow: f, registry, input: { city: 'Belfast' }, ...deterministic(),
238
+ }).runToEnd();
239
+ expect(withInput.nodeStates.a.output).toEqual({ got: 'Belfast' });
240
+
241
+ const without = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
242
+ expect(without.nodeStates.a.output).toEqual({ got: undefined });
243
+ });
244
+
245
+ it('resolves {"$secret": NAME} config values before execution', async () => {
246
+ const registry = makeRegistry();
247
+ registry.register(
248
+ { type: 'uses-key', label: 'Uses Key' },
249
+ async (ctx) => ({ key: ctx.config.apiKey }),
250
+ );
251
+ const f = flow([node('a', 'uses-key', { config: { apiKey: { $secret: 'API_KEY' } } })], []);
252
+ const run = await startRun({
253
+ flow: f, registry, secrets: { API_KEY: 'shh' }, ...deterministic(),
254
+ }).runToEnd();
255
+ expect(run.nodeStates.a.output).toEqual({ key: 'shh' });
256
+ });
257
+
258
+ it('fails the node when a referenced secret is missing', async () => {
259
+ const registry = makeRegistry();
260
+ registry.register({ type: 'uses-key', label: 'Uses Key' }, async (ctx) => ({ key: ctx.config.apiKey }));
261
+ const f = flow([node('a', 'uses-key', { config: { apiKey: { $secret: 'NOPE' } } })], []);
262
+ const run = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
263
+ expect(run.status).toBe('failed');
264
+ expect(run.nodeStates.a.error).toContain('Missing secret: NOPE');
265
+ });
266
+
267
+ it('passes runner-supplied secrets to node implementations, defaulting to {}', async () => {
268
+ const registry = makeRegistry();
269
+ registry.register(
270
+ { type: 'reads-secret', label: 'Reads Secret' },
271
+ async (ctx) => ({ got: ctx.secrets.FOO }),
272
+ );
273
+ const f = flow([node('a', 'reads-secret')], []);
274
+
275
+ const withSecrets = await startRun({
276
+ flow: f, registry, secrets: { FOO: 'bar' }, ...deterministic(),
277
+ }).runToEnd();
278
+ expect(withSecrets.nodeStates.a.output).toEqual({ got: 'bar' });
279
+
280
+ const withoutSecrets = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
281
+ expect(withoutSecrets.status).toBe('succeeded');
282
+ expect(withoutSecrets.nodeStates.a.output).toEqual({ got: undefined });
283
+ });
284
+ });
285
+
286
+ describe('environments and safe mode', () => {
287
+ it('exposes ctx.vars and ctx.safeMode to node implementations, defaulting to {} and false', async () => {
288
+ const registry = makeRegistry();
289
+ registry.register(
290
+ { type: 'reads-ctx', label: 'Reads Ctx' },
291
+ async (ctx) => ({ base: ctx.vars.API_BASE, safe: ctx.safeMode }),
292
+ );
293
+ const f = flow([node('a', 'reads-ctx')], []);
294
+
295
+ const withEnv = await startRun({
296
+ flow: f, registry, vars: { API_BASE: 'http://x' }, safeMode: true, ...deterministic(),
297
+ }).runToEnd();
298
+ expect(withEnv.nodeStates.a.output).toEqual({ base: 'http://x', safe: true });
299
+
300
+ const without = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
301
+ expect(without.nodeStates.a.output).toEqual({ base: undefined, safe: false });
302
+ });
303
+
304
+ it('records environment and safeMode on the run at start', () => {
305
+ const handle = startRun({
306
+ flow: linearFlow(), registry: makeRegistry(),
307
+ environment: 'prod', safeMode: true, ...deterministic(),
308
+ });
309
+ expect(handle.run.environment).toBe('prod');
310
+ expect(handle.run.safeMode).toBe(true);
311
+ });
312
+
313
+ it('resolves {"$env": NAME} config values from vars', async () => {
314
+ const registry = makeRegistry();
315
+ registry.register(
316
+ { type: 'uses-url', label: 'Uses Url' },
317
+ async (ctx) => ({ url: ctx.config.apiUrl }),
318
+ );
319
+ const f = flow([node('a', 'uses-url', { config: { apiUrl: { $env: 'API_URL' } } })], []);
320
+ const run = await startRun({
321
+ flow: f, registry, vars: { API_URL: 'http://host' }, ...deterministic(),
322
+ }).runToEnd();
323
+ expect(run.nodeStates.a.output).toEqual({ url: 'http://host' });
324
+ });
325
+
326
+ it('fails the node when a referenced $env var is missing', async () => {
327
+ const registry = makeRegistry();
328
+ registry.register({ type: 'uses-url', label: 'Uses Url' }, async (ctx) => ({ url: ctx.config.apiUrl }));
329
+ const f = flow([node('a', 'uses-url', { config: { apiUrl: { $env: 'NOPE' } } })], []);
330
+ const run = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
331
+ expect(run.status).toBe('failed');
332
+ expect(run.nodeStates.a.error).toContain('Missing environment variable: NOPE');
333
+ });
334
+
335
+ it('resolves {"$env": NAME} deep inside config values, nested objects and arrays included', async () => {
336
+ const registry = makeRegistry();
337
+ registry.register(
338
+ { type: 'uses-defaults', label: 'Uses Defaults' },
339
+ async (ctx) => ({ got: ctx.config.defaults }),
340
+ );
341
+ const f = flow(
342
+ [
343
+ node('a', 'uses-defaults', {
344
+ config: {
345
+ defaults: {
346
+ projectId: { $env: 'SEED' },
347
+ nested: { deep: { token: { $env: 'TOKEN' } } },
348
+ list: [{ id: { $env: 'SEED' } }, 'plain'],
349
+ },
350
+ },
351
+ }),
352
+ ],
353
+ [],
354
+ );
355
+ const run = await startRun({
356
+ flow: f, registry, vars: { SEED: 'seed-1', TOKEN: 'tok-2' }, ...deterministic(),
357
+ }).runToEnd();
358
+ expect(run.nodeStates.a.output).toEqual({
359
+ got: {
360
+ projectId: 'seed-1',
361
+ nested: { deep: { token: 'tok-2' } },
362
+ list: [{ id: 'seed-1' }, 'plain'],
363
+ },
364
+ });
365
+ });
366
+
367
+ it('fails the node when a nested $env ref in config is missing', async () => {
368
+ const registry = makeRegistry();
369
+ registry.register(
370
+ { type: 'uses-defaults', label: 'Uses Defaults' },
371
+ async (ctx) => ({ got: ctx.config.defaults }),
372
+ );
373
+ const f = flow(
374
+ [node('a', 'uses-defaults', { config: { defaults: { nested: { id: { $env: 'MISSING' } } } } })],
375
+ [],
376
+ );
377
+ const run = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
378
+ expect(run.status).toBe('failed');
379
+ expect(run.nodeStates.a.error).toContain('Missing environment variable: MISSING');
380
+ });
381
+
382
+ it('keeps {"$secret": NAME} top-level-only in config, even nested inside an object', async () => {
383
+ const registry = makeRegistry();
384
+ registry.register(
385
+ { type: 'uses-defaults', label: 'Uses Defaults' },
386
+ async (ctx) => ({ got: ctx.config.defaults }),
387
+ );
388
+ const f = flow(
389
+ [node('a', 'uses-defaults', { config: { defaults: { nested: { apiKey: { $secret: 'API_KEY' } } } } })],
390
+ [],
391
+ );
392
+ const run = await startRun({
393
+ flow: f, registry, secrets: { API_KEY: 'shh' }, ...deterministic(),
394
+ }).runToEnd();
395
+ // Nested $secret is not a config-level ref: it passes through untouched
396
+ // (secrets stay top-level only, unlike $env).
397
+ expect(run.nodeStates.a.output).toEqual({ got: { nested: { apiKey: { $secret: 'API_KEY' } } } });
398
+ });
399
+
400
+ it('resolves {"$env": NAME} deep inside the run-input payload, arrays included', async () => {
401
+ const registry = makeRegistry();
402
+ registry.register(
403
+ { type: 'reads-input', label: 'Reads Input' },
404
+ async (ctx) => ({ got: ctx.runInput }),
405
+ );
406
+ const f = flow([node('a', 'reads-input')], []);
407
+ const run = await startRun({
408
+ flow: f,
409
+ registry,
410
+ vars: { SEED: 'seed-1', TOKEN: 'tok-2' },
411
+ input: {
412
+ projectId: { $env: 'SEED' },
413
+ nested: { deep: { token: { $env: 'TOKEN' } } },
414
+ list: [{ id: { $env: 'SEED' } }, 'plain'],
415
+ },
416
+ ...deterministic(),
417
+ }).runToEnd();
418
+ expect(run.nodeStates.a.output).toEqual({
419
+ got: {
420
+ projectId: 'seed-1',
421
+ nested: { deep: { token: 'tok-2' } },
422
+ list: [{ id: 'seed-1' }, 'plain'],
423
+ },
424
+ });
425
+ });
426
+
427
+ it('fails run start when a $env var in the run-input payload is missing', () => {
428
+ const registry = makeRegistry();
429
+ registry.register({ type: 'reads-input', label: 'Reads Input' }, async (ctx) => ctx.runInput);
430
+ const f = flow([node('a', 'reads-input')], []);
431
+ expect(() =>
432
+ startRun({ flow: f, registry, input: { projectId: { $env: 'MISSING' } }, ...deterministic() }),
433
+ ).toThrow('Missing environment variable: MISSING');
434
+ });
435
+
436
+ it('sets mutationBlocked when a mutation node completes under safe mode', async () => {
437
+ const registry = makeRegistry();
438
+ registry.register(
439
+ { type: 'writer', label: 'Writer', effects: 'mutation' },
440
+ async () => ({ committed: false }),
441
+ );
442
+ const f = flow([node('a', 'writer')], []);
443
+
444
+ const safe = await startRun({ flow: f, registry, safeMode: true, ...deterministic() }).runToEnd();
445
+ expect(safe.nodeStates.a.status).toBe('succeeded');
446
+ expect(safe.nodeStates.a.mutationBlocked).toBe(true);
447
+
448
+ const live = await startRun({ flow: f, registry, safeMode: false, ...deterministic() }).runToEnd();
449
+ expect(live.nodeStates.a.mutationBlocked).toBeUndefined();
450
+ });
451
+
452
+ it('does not set mutationBlocked on a read node under safe mode', async () => {
453
+ const registry = makeRegistry();
454
+ const f = flow([node('a', 'emit', { config: { value: 'x' } })], []);
455
+ const run = await startRun({ flow: f, registry, safeMode: true, ...deterministic() }).runToEnd();
456
+ expect(run.nodeStates.a.mutationBlocked).toBeUndefined();
457
+ });
458
+
459
+ it('sets mutationBlocked on a mutation node inside a loop body under safe mode', async () => {
460
+ const registry = makeRegistry();
461
+ registerLoopNodes(registry);
462
+ registry.register(
463
+ { type: 'writer', label: 'Writer', effects: 'mutation' },
464
+ async (ctx) => ({ n: ctx.input.n }),
465
+ );
466
+ const f = flow(
467
+ [
468
+ node('fe', 'ForEach', { config: { items: [1, 2] } }),
469
+ node('body', 'writer', { inputMap: { n: { sourceNodeId: 'fe', sourceField: 'item' } } }),
470
+ node('col', 'Collect', { inputMap: { value: { sourceNodeId: 'body', sourceField: 'n' } } }),
471
+ ],
472
+ [
473
+ { id: 'e1', source: 'fe', target: 'body' },
474
+ { id: 'e2', source: 'body', target: 'col' },
475
+ ],
476
+ );
477
+ const run = await startRun({ flow: f, registry, safeMode: true, ...deterministic() }).runToEnd();
478
+ expect(run.status).toBe('succeeded');
479
+ expect(run.nodeStates.body.mutationBlocked).toBe(true);
480
+ });
481
+ });
482
+
483
+ describe('pinning', () => {
484
+ it('does not call the implementation for a pinned node', async () => {
485
+ const registry = makeRegistry();
486
+ registry.register(
487
+ { type: 'never-run', label: 'Never Run' },
488
+ async () => {
489
+ throw new Error('should not run');
490
+ },
491
+ );
492
+ const f = flow([node('a', 'never-run')], []);
493
+ const run = await startRun({
494
+ flow: f, registry, pins: { a: { ok: true } }, ...deterministic(),
495
+ }).runToEnd();
496
+ expect(run.status).toBe('succeeded');
497
+ expect(run.nodeStates.a.output).toEqual({ ok: true });
498
+ });
499
+
500
+ it('feeds downstream mapping from the pinned output', async () => {
501
+ const run = await startRun({
502
+ flow: linearFlow(),
503
+ registry: makeRegistry(),
504
+ pins: { a: { value: 'pinned!' } },
505
+ ...deterministic(),
506
+ }).runToEnd();
507
+ expect(run.status).toBe('succeeded');
508
+ expect(run.nodeStates.b.output).toEqual({ echoed: 'pinned!' });
509
+ });
510
+
511
+ it('records no trace sample for a pinned node but does for downstream executed nodes', async () => {
512
+ const trace = new InMemoryTraceSink();
513
+ await startRun({
514
+ flow: linearFlow(),
515
+ registry: makeRegistry(),
516
+ trace,
517
+ pins: { a: { value: 'pinned!' } },
518
+ ...deterministic(),
519
+ }).runToEnd();
520
+ expect(trace.samplesFor('a')).toHaveLength(0);
521
+ expect(trace.samplesFor('b')).toHaveLength(1);
522
+ });
523
+
524
+ it('sets pinned: true and status succeeded, and logs "using pinned output"', async () => {
525
+ const logs: LogLine[] = [];
526
+ const run = await startRun({
527
+ flow: linearFlow(),
528
+ registry: makeRegistry(),
529
+ pins: { a: { value: 'pinned!' } },
530
+ events: { onLog: (l) => logs.push(l) },
531
+ ...deterministic(),
532
+ }).runToEnd();
533
+ expect(run.nodeStates.a.status).toBe('succeeded');
534
+ expect(run.nodeStates.a.pinned).toBe(true);
535
+ expect(logs.some((l) => l.nodeId === 'a' && l.level === 'info' && l.message === 'a: using pinned output')).toBe(true);
536
+ });
537
+ });
538
+
539
+ describe('ForEach/Collect loops', () => {
540
+ function loopRegistry(): NodeRegistry {
541
+ const r = makeRegistry();
542
+ registerLoopNodes(r);
543
+ r.register(
544
+ { type: 'double', label: 'Double' },
545
+ async (ctx) => ({ doubled: Number(ctx.input.n) * 2 }),
546
+ );
547
+ return r;
548
+ }
549
+
550
+ const doubleLoopFlow = (items: unknown[], extraForEachConfig: Record<string, unknown> = {}) =>
551
+ flow(
552
+ [
553
+ node('fe', 'ForEach', { config: { items, ...extraForEachConfig } }),
554
+ node('body', 'double', { inputMap: { n: { sourceNodeId: 'fe', sourceField: 'item' } } }),
555
+ node('col', 'Collect', { inputMap: { value: { sourceNodeId: 'body', sourceField: 'doubled' } } }),
556
+ node('after', 'echo', { inputMap: { value: { sourceNodeId: 'col', sourceField: 'count' } } }),
557
+ ],
558
+ [
559
+ { id: 'e1', source: 'fe', target: 'body' },
560
+ { id: 'e2', source: 'body', target: 'col' },
561
+ { id: 'e3', source: 'col', target: 'after' },
562
+ ],
563
+ );
564
+
565
+ it('runs a basic 3-item loop: per-iteration body outputs, Collect gathers them, downstream sees {items, count}', async () => {
566
+ const run = await startRun({
567
+ flow: doubleLoopFlow([1, 2, 3]), registry: loopRegistry(), ...deterministic(),
568
+ }).runToEnd();
569
+ expect(run.status).toBe('succeeded');
570
+ expect(run.nodeStates.col.output).toEqual({ items: [2, 4, 6], count: 3 });
571
+ expect(run.nodeStates.after.output).toEqual({ echoed: 3 });
572
+ expect(run.nodeStates.body.iteration).toEqual({ index: 2, total: 3 });
573
+ expect(run.nodeStates.fe.iteration).toEqual({ index: 2, total: 3 });
574
+
575
+ // Every loop-body execution is recorded, ordered by iteration.
576
+ expect(run.nodeStates.body.executions).toHaveLength(3);
577
+ expect(run.nodeStates.body.executions!.map((e) => e.iteration.index)).toEqual([0, 1, 2]);
578
+ expect(run.nodeStates.body.executions!.map((e) => e.output)).toEqual([
579
+ { doubled: 2 }, { doubled: 4 }, { doubled: 6 },
580
+ ]);
581
+ for (const e of run.nodeStates.body.executions!) {
582
+ expect(e.iteration.total).toBe(3);
583
+ expect(e.status).toBe('succeeded');
584
+ }
585
+
586
+ // The ForEach node itself accumulates one record per iteration too.
587
+ expect(run.nodeStates.fe.executions).toHaveLength(3);
588
+ expect(run.nodeStates.fe.executions!.map((e) => e.output)).toEqual([
589
+ { item: 1, index: 0, total: 3 },
590
+ { item: 2, index: 1, total: 3 },
591
+ { item: 3, index: 2, total: 3 },
592
+ ]);
593
+
594
+ // Nodes outside the loop region never get an executions array.
595
+ expect(run.nodeStates.after.executions).toBeUndefined();
596
+ expect(run.nodeStates.col.executions).toBeUndefined();
597
+ });
598
+
599
+ it('chunks items by batchSize, passing each chunk as `item`', async () => {
600
+ const r = loopRegistry();
601
+ r.register(
602
+ { type: 'sumChunk', label: 'Sum Chunk' },
603
+ async (ctx) => ({ sum: (ctx.input.chunk as number[]).reduce((a, b) => a + b, 0) }),
604
+ );
605
+ const f = flow(
606
+ [
607
+ node('fe', 'ForEach', { config: { items: [1, 2, 3, 4, 5], batchSize: 2 } }),
608
+ node('body', 'sumChunk', { inputMap: { chunk: { sourceNodeId: 'fe', sourceField: 'item' } } }),
609
+ node('col', 'Collect', { inputMap: { value: { sourceNodeId: 'body', sourceField: 'sum' } } }),
610
+ ],
611
+ [
612
+ { id: 'e1', source: 'fe', target: 'body' },
613
+ { id: 'e2', source: 'body', target: 'col' },
614
+ ],
615
+ );
616
+ const run = await startRun({ flow: f, registry: r, ...deterministic() }).runToEnd();
617
+ expect(run.status).toBe('succeeded');
618
+ // [1,2] -> 3, [3,4] -> 7, [5] -> 5
619
+ expect(run.nodeStates.col.output).toEqual({ items: [3, 7, 5], count: 3 });
620
+ expect(run.nodeStates.fe.output).toEqual({ item: [5], index: 2, total: 3 });
621
+ });
622
+
623
+ it('truncates iterations at maxIterations and logs a warning', async () => {
624
+ const logs: LogLine[] = [];
625
+ const run = await startRun({
626
+ flow: doubleLoopFlow([1, 2, 3, 4, 5], { maxIterations: 2 }),
627
+ registry: loopRegistry(),
628
+ events: { onLog: (l) => logs.push(l) },
629
+ ...deterministic(),
630
+ }).runToEnd();
631
+ expect(run.status).toBe('succeeded');
632
+ expect(run.nodeStates.col.output).toEqual({ items: [2, 4], count: 2 });
633
+ expect(logs.some((l) => l.level === 'warn' && l.message.includes('maxIterations 2 reached, truncating 3 iterations'))).toBe(true);
634
+ });
635
+
636
+ it('an empty items array skips the body and Collect outputs {items: [], count: 0}', async () => {
637
+ const run = await startRun({
638
+ flow: doubleLoopFlow([]), registry: loopRegistry(), ...deterministic(),
639
+ }).runToEnd();
640
+ expect(run.status).toBe('succeeded');
641
+ expect(run.nodeStates.body.status).toBe('skipped');
642
+ expect(run.nodeStates.body.iteration).toEqual({ index: 0, total: 0 });
643
+ expect(run.nodeStates.col.output).toEqual({ items: [], count: 0 });
644
+ expect(run.nodeStates.after.output).toEqual({ echoed: 0 });
645
+ });
646
+
647
+ it('a non-array "items" input fails the ForEach node like any other node error', async () => {
648
+ const f = doubleLoopFlow([1, 2, 3]);
649
+ f.nodes.find((n) => n.id === 'fe')!.config = { items: 'not-an-array' };
650
+ const run = await startRun({ flow: f, registry: loopRegistry(), ...deterministic() }).runToEnd();
651
+ expect(run.status).toBe('failed');
652
+ expect(run.nodeStates.fe.status).toBe('failed');
653
+ expect(run.nodeStates.body.status).toBe('skipped');
654
+ });
655
+
656
+ it('runToEnd() and manual step()-ing to completion produce equivalent results', async () => {
657
+ const stepped = startRun({ flow: doubleLoopFlow([1, 2, 3]), registry: loopRegistry(), ...deterministic() });
658
+ let steps = 0;
659
+ while (await stepped.step()) steps += 1;
660
+ const runToEndResult = await startRun({
661
+ flow: doubleLoopFlow([1, 2, 3]), registry: loopRegistry(), ...deterministic(),
662
+ }).runToEnd();
663
+ expect(stepped.run).toEqual(runToEndResult);
664
+ // Sanity: more than one step() call happened per iteration (ForEach + body), plus Collect + after.
665
+ expect(steps).toBeGreaterThan(3);
666
+ });
667
+
668
+ it('steps through iteration by iteration with sane intermediate per-step states', async () => {
669
+ const handle = startRun({ flow: doubleLoopFlow([1, 2, 3]), registry: loopRegistry(), ...deterministic() });
670
+
671
+ expect(await handle.step()).toBe(true); // ForEach iteration 0
672
+ expect(handle.run.nodeStates.fe.output).toEqual({ item: 1, index: 0, total: 3 });
673
+ expect(handle.run.nodeStates.fe.iteration).toEqual({ index: 0, total: 3 });
674
+ expect(handle.run.nodeStates.body.status).toBe('queued');
675
+
676
+ expect(await handle.step()).toBe(true); // body iteration 0
677
+ expect(handle.run.nodeStates.body.output).toEqual({ doubled: 2 });
678
+ expect(handle.run.nodeStates.body.iteration).toEqual({ index: 0, total: 3 });
679
+
680
+ expect(await handle.step()).toBe(true); // ForEach iteration 1
681
+ expect(handle.run.nodeStates.fe.output).toEqual({ item: 2, index: 1, total: 3 });
682
+
683
+ expect(await handle.step()).toBe(true); // body iteration 1
684
+ expect(handle.run.nodeStates.body.output).toEqual({ doubled: 4 });
685
+
686
+ expect(await handle.step()).toBe(true); // ForEach iteration 2
687
+ expect(await handle.step()).toBe(true); // body iteration 2
688
+
689
+ expect(await handle.step()).toBe(true); // Collect
690
+ expect(handle.run.nodeStates.col.output).toEqual({ items: [2, 4, 6], count: 3 });
691
+
692
+ // 'after' is the last node — the run finishes within this same step() call.
693
+ expect(await handle.step()).toBe(false);
694
+ expect(handle.run.nodeStates.after.output).toEqual({ echoed: 3 });
695
+ expect(handle.run.status).toBe('succeeded');
696
+ });
697
+
698
+ it('resets branch-skip state per iteration so a Route/Conditional-style node inside the body re-decides every time', async () => {
699
+ const r = loopRegistry();
700
+ r.register(
701
+ { type: 'parity', label: 'Parity' },
702
+ async (ctx) => ({ n: ctx.input.n, $branch: Number(ctx.input.n) % 2 === 0 ? 'even' : 'odd' }),
703
+ );
704
+ r.register({ type: 'tag', label: 'Tag' }, async (ctx) => ({ hit: ctx.config.tag }));
705
+ r.register(
706
+ { type: 'merge', label: 'Merge' },
707
+ async (ctx) => ({ value: ctx.input.evenVal ?? ctx.input.oddVal }),
708
+ );
709
+ const f = flow(
710
+ [
711
+ node('fe', 'ForEach', { config: { items: [1, 2, 3] } }),
712
+ node('router', 'parity', { inputMap: { n: { sourceNodeId: 'fe', sourceField: 'item' } } }),
713
+ node('evenLeaf', 'tag', { config: { tag: 'E' } }),
714
+ node('oddLeaf', 'tag', { config: { tag: 'O' } }),
715
+ node('merge', 'merge', {
716
+ inputMap: {
717
+ evenVal: { sourceNodeId: 'evenLeaf', sourceField: 'hit' },
718
+ oddVal: { sourceNodeId: 'oddLeaf', sourceField: 'hit' },
719
+ },
720
+ }),
721
+ node('col', 'Collect', { inputMap: { value: { sourceNodeId: 'merge', sourceField: 'value' } } }),
722
+ ],
723
+ [
724
+ { id: 'e1', source: 'fe', target: 'router' },
725
+ { id: 'e2', source: 'router', target: 'evenLeaf', sourceHandle: 'even' },
726
+ { id: 'e3', source: 'router', target: 'oddLeaf', sourceHandle: 'odd' },
727
+ { id: 'e4', source: 'evenLeaf', target: 'merge' },
728
+ { id: 'e5', source: 'oddLeaf', target: 'merge' },
729
+ { id: 'e6', source: 'merge', target: 'col' },
730
+ ],
731
+ );
732
+ const run = await startRun({ flow: f, registry: r, ...deterministic() }).runToEnd();
733
+ expect(run.status).toBe('succeeded');
734
+ expect(run.nodeStates.col.output).toEqual({ items: ['O', 'E', 'O'], count: 3 });
735
+ // Last iteration (n=3, odd): evenLeaf skipped, oddLeaf ran, merge picked it up.
736
+ expect(run.nodeStates.evenLeaf.status).toBe('skipped');
737
+ expect(run.nodeStates.oddLeaf.status).toBe('succeeded');
738
+ expect(run.nodeStates.merge.output).toEqual({ value: 'O' });
739
+
740
+ // Items are [1, 2, 3] -> odd, even, odd: evenLeaf only ran iteration 1,
741
+ // oddLeaf ran iterations 0 and 2. Skipped iterations must not appear.
742
+ expect(run.nodeStates.evenLeaf.executions).toHaveLength(1);
743
+ expect(run.nodeStates.evenLeaf.executions![0].iteration.index).toBe(1);
744
+ expect(run.nodeStates.oddLeaf.executions).toHaveLength(2);
745
+ expect(run.nodeStates.oddLeaf.executions!.map((e) => e.iteration.index)).toEqual([0, 2]);
746
+ });
747
+
748
+ it('a pinned body node reuses its pin every iteration and records no trace samples', async () => {
749
+ const trace = new InMemoryTraceSink();
750
+ const run = await startRun({
751
+ flow: doubleLoopFlow([1, 2, 3]),
752
+ registry: loopRegistry(),
753
+ trace,
754
+ pins: { body: { doubled: 99 } },
755
+ ...deterministic(),
756
+ }).runToEnd();
757
+ expect(run.status).toBe('succeeded');
758
+ expect(run.nodeStates.col.output).toEqual({ items: [99, 99, 99], count: 3 });
759
+ expect(run.nodeStates.body.pinned).toBe(true);
760
+ expect(run.nodeStates.body.iteration).toEqual({ index: 2, total: 3 });
761
+ expect(trace.samplesFor('body')).toHaveLength(0);
762
+
763
+ // A record is still appended per iteration, carrying the pinned output.
764
+ expect(run.nodeStates.body.executions).toHaveLength(3);
765
+ expect(run.nodeStates.body.executions!.map((e) => e.iteration.index)).toEqual([0, 1, 2]);
766
+ expect(run.nodeStates.body.executions!.every((e) => e.status === 'succeeded' && e.output && (e.output as { doubled: number }).doubled === 99)).toBe(true);
767
+ });
768
+
769
+ it('a failure in a later iteration fails the run and does not clobber the failed node\'s state', async () => {
770
+ const r = loopRegistry();
771
+ r.register({ type: 'boomOnTwo', label: 'Boom On Two' }, async (ctx) => {
772
+ if (Number(ctx.input.n) === 2) throw new Error('bad item');
773
+ return { n: ctx.input.n };
774
+ });
775
+ const f = flow(
776
+ [
777
+ node('fe', 'ForEach', { config: { items: [1, 2, 3] } }),
778
+ node('body', 'boomOnTwo', { inputMap: { n: { sourceNodeId: 'fe', sourceField: 'item' } } }),
779
+ node('col', 'Collect', { inputMap: { value: { sourceNodeId: 'body', sourceField: 'n' } } }),
780
+ node('after', 'echo', { inputMap: { value: { sourceNodeId: 'col', sourceField: 'count' } } }),
781
+ ],
782
+ [
783
+ { id: 'e1', source: 'fe', target: 'body' },
784
+ { id: 'e2', source: 'body', target: 'col' },
785
+ { id: 'e3', source: 'col', target: 'after' },
786
+ ],
787
+ );
788
+ const run = await startRun({ flow: f, registry: r, ...deterministic() }).runToEnd();
789
+ expect(run.status).toBe('failed');
790
+ expect(run.nodeStates.body.status).toBe('failed');
791
+ expect(run.nodeStates.body.error).toBe('bad item');
792
+ expect(run.nodeStates.col.status).toBe('skipped');
793
+ expect(run.nodeStates.after.status).toBe('skipped');
794
+
795
+ // Iteration 0 (n=1) succeeded, iteration 1 (n=2) failed; the run stops there.
796
+ expect(run.nodeStates.body.executions).toHaveLength(2);
797
+ expect(run.nodeStates.body.executions![0]).toMatchObject({
798
+ iteration: { index: 0, total: 3 }, status: 'succeeded',
799
+ });
800
+ expect(run.nodeStates.body.executions![1]).toMatchObject({
801
+ iteration: { index: 1, total: 3 }, status: 'failed', error: 'bad item',
802
+ });
803
+ });
804
+ });
805
+
806
+ describe('ForEach with a parallel non-region path', () => {
807
+ it('nodes topologically interleaved beside the region still execute', async () => {
808
+ const r = new NodeRegistry();
809
+ registerLoopNodes(r);
810
+ r.register(
811
+ { type: 'emit', label: 'Emit', configSchema: { fields: [{ name: 'value', type: 'string' }] } },
812
+ async (ctx) => ({ value: ctx.config.value }),
813
+ );
814
+ r.register(
815
+ { type: 'join', label: 'Join' },
816
+ async (ctx) => ({ side: ctx.input.side, count: ctx.input.count }),
817
+ );
818
+ // Topo order interleaves `side` between fe and col (both are sources).
819
+ const f = flow(
820
+ [
821
+ node('fe', 'ForEach', { config: { items: [1, 2] } }),
822
+ node('side', 'emit', { config: { value: 'parallel' } }),
823
+ node('body', 'emit', {
824
+ config: { value: 'x' },
825
+ inputMap: { n: { sourceNodeId: 'fe', sourceField: 'item' } },
826
+ }),
827
+ node('col', 'Collect', { inputMap: { value: { sourceNodeId: 'body', sourceField: 'value' } } }),
828
+ node('final', 'join', {
829
+ inputMap: {
830
+ side: { sourceNodeId: 'side', sourceField: 'value' },
831
+ count: { sourceNodeId: 'col', sourceField: 'count' },
832
+ },
833
+ }),
834
+ ],
835
+ [
836
+ { id: 'e1', source: 'fe', target: 'body' },
837
+ { id: 'e2', source: 'body', target: 'col' },
838
+ { id: 'e3', source: 'col', target: 'final' },
839
+ { id: 'e4', source: 'side', target: 'final' },
840
+ ],
841
+ );
842
+ const run = await startRun({ flow: f, registry: r, ...deterministic() }).runToEnd();
843
+ expect(run.status).toBe('succeeded');
844
+ expect(run.nodeStates.side.status).toBe('succeeded');
845
+ expect(run.nodeStates.final.output).toEqual({ side: 'parallel', count: 2 });
846
+ });
847
+ });
848
+
849
+ describe('Subflow node', () => {
850
+ // A flow that runs one Subflow node ('sub', targeting workflow 'child') and
851
+ // passes its output through a Result. Input for the child is a single mapped
852
+ // field so we can assert what the runner receives.
853
+ const subflowRegistry = (): NodeRegistry => {
854
+ const r = new NodeRegistry();
855
+ registerFlowControlNodes(r);
856
+ r.register(
857
+ { type: 'Result', label: 'Result', inputSchema: { fields: [{ name: 'data', type: 'object' }] } },
858
+ async (ctx) => ctx.input,
859
+ );
860
+ return r;
861
+ };
862
+ const subflowFlow = (workflowId = 'child'): WorkflowDefinition =>
863
+ flow(
864
+ [
865
+ node('sub', 'Subflow', { config: { workflowId } }),
866
+ node('res', 'Result', { inputMap: { data: { sourceNodeId: 'sub', sourceField: '$' } } }),
867
+ ],
868
+ [{ id: 'e', source: 'sub', target: 'res', targetHandle: 'data' }],
869
+ );
870
+
871
+ it('runs the child via the host runner and passes its output downstream', async () => {
872
+ const calls: Array<[string, Record<string, unknown>, string]> = [];
873
+ const run = await startRun({
874
+ flow: subflowFlow(),
875
+ registry: subflowRegistry(),
876
+ ...deterministic(),
877
+ subflowRunner: async (workflowId, input, callerNodeId) => {
878
+ calls.push([workflowId, input, callerNodeId]);
879
+ return { status: 'succeeded', output: { answer: 42 } };
880
+ },
881
+ }).runToEnd();
882
+ expect(run.status).toBe('succeeded');
883
+ expect(calls).toEqual([['child', {}, 'sub']]);
884
+ expect(run.nodeStates.sub.output).toEqual({ answer: 42 });
885
+ expect(run.nodeStates.res.output).toEqual({ data: { answer: 42 } });
886
+ });
887
+
888
+ it('wraps a non-object child output under `output`', async () => {
889
+ const run = await startRun({
890
+ flow: subflowFlow(),
891
+ registry: subflowRegistry(),
892
+ ...deterministic(),
893
+ subflowRunner: async () => ({ status: 'succeeded', output: 7 }),
894
+ }).runToEnd();
895
+ expect(run.nodeStates.sub.output).toEqual({ output: 7 });
896
+ });
897
+
898
+ it('propagates a failed child as a node failure', async () => {
899
+ const run = await startRun({
900
+ flow: subflowFlow(),
901
+ registry: subflowRegistry(),
902
+ ...deterministic(),
903
+ subflowRunner: async () => ({ status: 'failed', error: 'child boom' }),
904
+ }).runToEnd();
905
+ expect(run.status).toBe('failed');
906
+ expect(run.nodeStates.sub.status).toBe('failed');
907
+ expect(run.nodeStates.sub.error).toBe('child boom');
908
+ expect(run.nodeStates.res.status).toBe('skipped');
909
+ });
910
+
911
+ it('fails clearly when no host runner is provided', async () => {
912
+ const run = await startRun({
913
+ flow: subflowFlow(),
914
+ registry: subflowRegistry(),
915
+ ...deterministic(),
916
+ }).runToEnd();
917
+ expect(run.status).toBe('failed');
918
+ expect(run.nodeStates.sub.error).toBe('subflows need a host that can look up workflows');
919
+ });
920
+ });
921
+
922
+ describe('optional (fail-soft) nodes', () => {
923
+ it('an optional node failure does not abort the run; independent branches finish and the run succeeds', async () => {
924
+ // root → { bad (optional, throws), good (independent), dependent (consumes bad) }
925
+ const f = flow(
926
+ [
927
+ node('root', 'emit', { config: { value: 'go' } }),
928
+ node('bad', 'boom', { optional: true }),
929
+ node('good', 'emit', { config: { value: 'ok' } }),
930
+ node('dependent', 'echo', { inputMap: { value: { sourceNodeId: 'bad', sourceField: 'value' } } }),
931
+ ],
932
+ [
933
+ { id: 'e1', source: 'root', target: 'bad' },
934
+ { id: 'e2', source: 'root', target: 'good' },
935
+ { id: 'e3', source: 'bad', target: 'dependent' },
936
+ ],
937
+ );
938
+ const run = await startRun({ flow: f, registry: makeRegistry(), ...deterministic() }).runToEnd();
939
+
940
+ expect(run.status).toBe('succeeded'); // fail-soft: the optional failure did not abort
941
+ expect(run.nodeStates.bad.status).toBe('failed'); // still recorded as failed (visible)
942
+ expect(run.nodeStates.bad.error).toBe('kaboom');
943
+ expect(run.nodeStates.good.status).toBe('succeeded'); // independent branch ran
944
+ expect(run.nodeStates.dependent.status).toBe('skipped'); // dead edge from the output-less failed node
945
+ });
946
+
947
+ it('a NON-optional node failure still aborts the run (default unchanged)', async () => {
948
+ const f = flow(
949
+ [
950
+ node('root', 'emit', { config: { value: 'go' } }),
951
+ node('bad', 'boom'), // not optional
952
+ node('after', 'emit', { config: { value: 'never' } }),
953
+ ],
954
+ [
955
+ { id: 'e1', source: 'root', target: 'bad' },
956
+ { id: 'e2', source: 'bad', target: 'after' },
957
+ ],
958
+ );
959
+ const run = await startRun({ flow: f, registry: makeRegistry(), ...deterministic() }).runToEnd();
960
+
961
+ expect(run.status).toBe('failed');
962
+ expect(run.nodeStates.bad.status).toBe('failed');
963
+ expect(run.nodeStates.after.status).toBe('skipped');
964
+ });
965
+ });
966
+
967
+ describe('per-node retry', () => {
968
+ const flakyRegistry = (failCount: number): { registry: NodeRegistry; calls: () => number } => {
969
+ const r = new NodeRegistry();
970
+ let calls = 0;
971
+ r.register({ type: 'flaky', label: 'Flaky' }, async () => {
972
+ calls += 1;
973
+ if (calls <= failCount) throw new Error(`fail-${calls}`);
974
+ return { ok: true };
975
+ });
976
+ return { registry: r, calls: () => calls };
977
+ };
978
+
979
+ const alwaysFailRegistry = (): { registry: NodeRegistry; calls: () => number } => {
980
+ const r = new NodeRegistry();
981
+ let calls = 0;
982
+ r.register({ type: 'always-fails', label: 'Always Fails' }, async () => {
983
+ calls += 1;
984
+ throw new Error(`fail-${calls}`);
985
+ });
986
+ return { registry: r, calls: () => calls };
987
+ };
988
+
989
+ it('retries a failing implementation and succeeds on the final attempt, logging a warning per retry', async () => {
990
+ const { registry, calls } = flakyRegistry(2);
991
+ const logs: LogLine[] = [];
992
+ const f = flow([node('a', 'flaky', { retry: { maxTries: 3 } })], []);
993
+ const run = await startRun({
994
+ flow: f,
995
+ registry,
996
+ events: { onLog: (l) => logs.push(l) },
997
+ ...deterministic(),
998
+ }).runToEnd();
999
+
1000
+ expect(run.status).toBe('succeeded');
1001
+ expect(run.nodeStates.a.status).toBe('succeeded');
1002
+ expect(run.nodeStates.a.output).toEqual({ ok: true });
1003
+ expect(calls()).toBe(3);
1004
+ const warnLogs = logs.filter((l) => l.level === 'warn' && l.nodeId === 'a');
1005
+ expect(warnLogs).toHaveLength(2);
1006
+ expect(warnLogs[0].message).toBe('retry 1/3 after error: fail-1');
1007
+ expect(warnLogs[1].message).toBe('retry 2/3 after error: fail-2');
1008
+ });
1009
+
1010
+ it('exhausts retries and fails the run when the implementation always throws', async () => {
1011
+ const { registry, calls } = alwaysFailRegistry();
1012
+ const f = flow([node('a', 'always-fails', { retry: { maxTries: 2 } })], []);
1013
+ const run = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
1014
+
1015
+ expect(run.status).toBe('failed');
1016
+ expect(run.nodeStates.a.status).toBe('failed');
1017
+ expect(run.nodeStates.a.error).toBe('fail-2');
1018
+ expect(calls()).toBe(2);
1019
+ expect(run.nodeStates.a.attempts).toBe(2);
1020
+ });
1021
+
1022
+ it('stamps attempts on the final state when a retried node succeeds after retrying', async () => {
1023
+ const { registry } = flakyRegistry(2);
1024
+ const f = flow([node('a', 'flaky', { retry: { maxTries: 3 } })], []);
1025
+ const run = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
1026
+
1027
+ expect(run.nodeStates.a.status).toBe('succeeded');
1028
+ expect(run.nodeStates.a.attempts).toBe(3);
1029
+ });
1030
+
1031
+ it('does not stamp attempts when a node without retry fails on its only attempt', async () => {
1032
+ const { registry } = alwaysFailRegistry();
1033
+ const f = flow([node('a', 'always-fails')], []);
1034
+ const run = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
1035
+
1036
+ expect(run.nodeStates.a.status).toBe('failed');
1037
+ expect(run.nodeStates.a.attempts).toBeUndefined();
1038
+ });
1039
+
1040
+ it('composes with optional: retries exhaust, then fail-softs and the run continues', async () => {
1041
+ const { registry, calls } = alwaysFailRegistry();
1042
+ // give 'b' its own independent working impl so we can observe the run continuing
1043
+ registry.register({ type: 'ok-node', label: 'Ok' }, async () => ({ ok: true }));
1044
+ const f = flow(
1045
+ [
1046
+ node('a', 'always-fails', { retry: { maxTries: 2 }, optional: true }),
1047
+ node('b', 'ok-node'),
1048
+ ],
1049
+ [],
1050
+ );
1051
+ const run = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
1052
+
1053
+ expect(run.status).toBe('succeeded');
1054
+ expect(run.nodeStates.a.status).toBe('failed');
1055
+ expect(run.nodeStates.a.error).toBe('fail-2');
1056
+ expect(run.nodeStates.b.status).toBe('succeeded');
1057
+ expect(calls()).toBe(2);
1058
+ });
1059
+
1060
+ it('a node without retry calls the implementation exactly once (unchanged behavior)', async () => {
1061
+ const { registry, calls } = alwaysFailRegistry();
1062
+ const f = flow([node('a', 'always-fails')], []);
1063
+ const run = await startRun({ flow: f, registry, ...deterministic() }).runToEnd();
1064
+
1065
+ expect(run.status).toBe('failed');
1066
+ expect(run.nodeStates.a.error).toBe('fail-1');
1067
+ expect(calls()).toBe(1);
1068
+ });
1069
+ });
1070
+
1071
+ describe('mock runs', () => {
1072
+ const dbRegistry = (): { registry: NodeRegistry; calls: () => number } => {
1073
+ const r = new NodeRegistry();
1074
+ let calls = 0;
1075
+ r.register(
1076
+ { type: 'db-read', label: 'DB Read', traceKind: 'db' },
1077
+ async () => {
1078
+ calls += 1;
1079
+ return { rows: [] };
1080
+ },
1081
+ );
1082
+ return { registry: r, calls: () => calls };
1083
+ };
1084
+
1085
+ it('a mocked node returns the canned output verbatim without calling the implementation', async () => {
1086
+ const { registry, calls } = dbRegistry();
1087
+ const f = flow([node('a', 'db-read')], []);
1088
+ const run = await startRun({
1089
+ flow: f,
1090
+ registry,
1091
+ mockRun: true,
1092
+ mocks: { a: { rows: [{ id: 1 }] } },
1093
+ ...deterministic(),
1094
+ }).runToEnd();
1095
+
1096
+ expect(calls()).toBe(0);
1097
+ expect(run.status).toBe('succeeded');
1098
+ expect(run.nodeStates.a.output).toEqual({ rows: [{ id: 1 }] });
1099
+ expect(run.nodeStates.a.mocked).toBe(true);
1100
+ });
1101
+
1102
+ it('a mocked node output flows downstream via inputMap', async () => {
1103
+ const { registry } = dbRegistry();
1104
+ registry.register(
1105
+ { type: 'echo', label: 'Echo', inputSchema: { fields: [{ name: 'value', type: 'string', required: true }] } },
1106
+ async (ctx) => ({ echoed: ctx.input.value }),
1107
+ );
1108
+ const f = flow(
1109
+ [
1110
+ node('a', 'db-read'),
1111
+ node('b', 'echo', { inputMap: { value: { sourceNodeId: 'a', sourceField: 'rows' } } }),
1112
+ ],
1113
+ [{ id: 'e1', source: 'a', target: 'b' }],
1114
+ );
1115
+ const run = await startRun({
1116
+ flow: f,
1117
+ registry,
1118
+ mockRun: true,
1119
+ mocks: { a: { rows: ['x'] } },
1120
+ ...deterministic(),
1121
+ }).runToEnd();
1122
+
1123
+ expect(run.status).toBe('succeeded');
1124
+ expect(run.nodeStates.b.output).toEqual({ echoed: ['x'] });
1125
+ });
1126
+
1127
+ it('an unmocked traceKind db node in a mock run fails loud with the binding message and fails the run', async () => {
1128
+ const { registry, calls } = dbRegistry();
1129
+ const f = flow([node('a', 'db-read', { label: 'Load Users' })], []);
1130
+ const run = await startRun({
1131
+ flow: f,
1132
+ registry,
1133
+ mockRun: true,
1134
+ ...deterministic(),
1135
+ }).runToEnd();
1136
+
1137
+ expect(calls()).toBe(0);
1138
+ expect(run.status).toBe('failed');
1139
+ expect(run.nodeStates.a.status).toBe('failed');
1140
+ expect(run.nodeStates.a.error).toContain('would touch real infrastructure');
1141
+ expect(run.nodeStates.a.error).toContain('Load Users');
1142
+ expect(run.nodeStates.a.error).toContain('db');
1143
+ });
1144
+
1145
+ it('a traceKind compute node (or absent traceKind) executes its real implementation in a mock run', async () => {
1146
+ const run = await startRun({
1147
+ flow: linearFlow(),
1148
+ registry: makeRegistry(),
1149
+ mockRun: true,
1150
+ ...deterministic(),
1151
+ }).runToEnd();
1152
+
1153
+ expect(run.status).toBe('succeeded');
1154
+ expect(run.nodeStates.b.output).toEqual({ echoed: 'hi' });
1155
+ expect(run.nodeStates.b.mocked).toBeUndefined();
1156
+ });
1157
+
1158
+ it('retry config on a mocked node is ignored: no retries, no attempts stamped', async () => {
1159
+ const { registry, calls } = dbRegistry();
1160
+ const f = flow([node('a', 'db-read', { retry: { maxTries: 3 } })], []);
1161
+ const run = await startRun({
1162
+ flow: f,
1163
+ registry,
1164
+ mockRun: true,
1165
+ mocks: { a: { rows: [] } },
1166
+ ...deterministic(),
1167
+ }).runToEnd();
1168
+
1169
+ expect(calls()).toBe(0);
1170
+ expect(run.nodeStates.a.status).toBe('succeeded');
1171
+ expect(run.nodeStates.a.attempts).toBeUndefined();
1172
+ });
1173
+
1174
+ it('mockRun: false ignores a mocks map entirely — the implementation is called for real', async () => {
1175
+ const { registry, calls } = dbRegistry();
1176
+ const f = flow([node('a', 'db-read')], []);
1177
+ const run = await startRun({
1178
+ flow: f,
1179
+ registry,
1180
+ mockRun: false,
1181
+ mocks: { a: { rows: ['should-be-ignored'] } },
1182
+ ...deterministic(),
1183
+ }).runToEnd();
1184
+
1185
+ expect(calls()).toBe(1);
1186
+ expect(run.status).toBe('succeeded');
1187
+ expect(run.nodeStates.a.output).toEqual({ rows: [] });
1188
+ expect(run.nodeStates.a.mocked).toBeUndefined();
1189
+ });
1190
+
1191
+ it('an optional mocked-infra node with no mock fails soft: run continues, node marked failed', async () => {
1192
+ const { registry } = dbRegistry();
1193
+ registry.register({ type: 'ok-node', label: 'Ok' }, async () => ({ ok: true }));
1194
+ const f = flow(
1195
+ [
1196
+ node('a', 'db-read', { optional: true }),
1197
+ node('b', 'ok-node'),
1198
+ ],
1199
+ [],
1200
+ );
1201
+ const run = await startRun({ flow: f, registry, mockRun: true, ...deterministic() }).runToEnd();
1202
+
1203
+ expect(run.status).toBe('succeeded');
1204
+ expect(run.nodeStates.a.status).toBe('failed');
1205
+ expect(run.nodeStates.a.error).toContain('would touch real infrastructure');
1206
+ expect(run.nodeStates.b.status).toBe('succeeded');
1207
+ });
1208
+
1209
+ it('a mocked node whose config carries a $secret ref succeeds with the canned output even with no secrets configured', async () => {
1210
+ const { registry, calls } = dbRegistry();
1211
+ const f = flow([node('a', 'db-read', { config: { apiKey: { $secret: 'API_KEY' } } })], []);
1212
+ const run = await startRun({
1213
+ flow: f,
1214
+ registry,
1215
+ mockRun: true,
1216
+ mocks: { a: { rows: [] } },
1217
+ // Deliberately NO secrets — a fresh no-secrets project.
1218
+ ...deterministic(),
1219
+ }).runToEnd();
1220
+
1221
+ expect(calls()).toBe(0);
1222
+ expect(run.status).toBe('succeeded');
1223
+ expect(run.nodeStates.a.mocked).toBe(true);
1224
+ expect(run.nodeStates.a.output).toEqual({ rows: [] });
1225
+ });
1226
+
1227
+ it('the same $secret-carrying node, unmocked, fails with the infrastructure message — not "Missing secret" — with no secrets configured', async () => {
1228
+ const { registry, calls } = dbRegistry();
1229
+ const f = flow([node('a', 'db-read', { label: 'Load Users', config: { apiKey: { $secret: 'API_KEY' } } })], []);
1230
+ const run = await startRun({
1231
+ flow: f,
1232
+ registry,
1233
+ mockRun: true,
1234
+ // No mocks entry for "a" and no secrets configured.
1235
+ ...deterministic(),
1236
+ }).runToEnd();
1237
+
1238
+ expect(calls()).toBe(0);
1239
+ expect(run.status).toBe('failed');
1240
+ expect(run.nodeStates.a.error).toContain('would touch real infrastructure');
1241
+ expect(run.nodeStates.a.error).not.toContain('Missing secret');
1242
+ });
1243
+ });
1244
+
1245
+ describe('explainUndefinedRead', () => {
1246
+ it('enriches "Cannot read properties of undefined" messages with sorted input keys', () => {
1247
+ const msg = explainUndefinedRead("Cannot read properties of undefined (reading 'id')", { a: 1, b: 1 });
1248
+ expect(msg).toBe(
1249
+ "Cannot read properties of undefined (reading 'id') — this node received [a, b]. Check the node's inputMap and the run input.",
1250
+ );
1251
+ });
1252
+
1253
+ it('enriches "Cannot read properties of null" messages', () => {
1254
+ const msg = explainUndefinedRead("Cannot read properties of null (reading 'foo')", { x: 1 });
1255
+ expect(msg).toBe(
1256
+ "Cannot read properties of null (reading 'foo') — this node received [x]. Check the node's inputMap and the run input.",
1257
+ );
1258
+ });
1259
+
1260
+ it('enriches the Safari/older-engine "is not an object" shape', () => {
1261
+ const msg = explainUndefinedRead("undefined is not an object (evaluating 'a.id')", {});
1262
+ expect(msg).toBe(
1263
+ "undefined is not an object (evaluating 'a.id') — this node received (none). Check the node's inputMap and the run input.",
1264
+ );
1265
+ });
1266
+
1267
+ it('names mapped fields that resolved to undefined — the usual culprit', () => {
1268
+ const msg = explainUndefinedRead(
1269
+ "Cannot read properties of undefined (reading 'id')",
1270
+ { userId: 'u1', params: undefined, query: undefined },
1271
+ );
1272
+ expect(msg).toContain('this node received [userId].');
1273
+ expect(msg).toContain('These mapped fields resolved to undefined: params, query.');
1274
+ });
1275
+
1276
+ it('renders (none) for an empty key list', () => {
1277
+ const msg = explainUndefinedRead("Cannot read properties of undefined (reading 'id')", {});
1278
+ expect(msg).toContain('received (none)');
1279
+ });
1280
+
1281
+ it('leaves unrelated messages byte-identical', () => {
1282
+ expect(explainUndefinedRead('boom', { a: 1 })).toBe('boom');
1283
+ expect(explainUndefinedRead('some other TypeError', { a: 1 })).toBe('some other TypeError');
1284
+ });
1285
+ });
1286
+
1287
+ describe('undefined-property-read error enrichment via executeNode', () => {
1288
+ const paramsRegistry = (): NodeRegistry => {
1289
+ const r = new NodeRegistry();
1290
+ r.register(
1291
+ { type: 'emit', label: 'Emit', configSchema: { fields: [{ name: 'value', type: 'string' }] } },
1292
+ async (ctx) => ({ value: ctx.config.value }),
1293
+ );
1294
+ r.register({ type: 'reads-params-id', label: 'Reads params.id' }, async (ctx) => {
1295
+ // Mirrors a real HTTP op reading a path param that was never mapped.
1296
+ return { id: (ctx.input.params as { id: string }).id };
1297
+ });
1298
+ return r;
1299
+ };
1300
+
1301
+ it('enriches the error with resolved input keys when a mapped input is missing the read property', async () => {
1302
+ const f = flow(
1303
+ [
1304
+ node('a', 'emit', { config: { value: 'u-1' } }),
1305
+ node('b', 'reads-params-id', { inputMap: { userId: { sourceNodeId: 'a', sourceField: 'value' } } }),
1306
+ ],
1307
+ [{ id: 'e1', source: 'a', target: 'b' }],
1308
+ );
1309
+ const run = await startRun({ flow: f, registry: paramsRegistry(), ...deterministic() }).runToEnd();
1310
+ expect(run.status).toBe('failed');
1311
+ expect(run.nodeStates.b.status).toBe('failed');
1312
+ expect(run.nodeStates.b.error).toContain("Cannot read properties of undefined (reading 'id')");
1313
+ expect(run.nodeStates.b.error).toContain('received [userId]');
1314
+ });
1315
+
1316
+ it('renders (none) when the node received no mapped input at all', async () => {
1317
+ const f = flow([node('a', 'reads-params-id')], []);
1318
+ const run = await startRun({ flow: f, registry: paramsRegistry(), ...deterministic() }).runToEnd();
1319
+ expect(run.status).toBe('failed');
1320
+ expect(run.nodeStates.a.error).toContain("Cannot read properties of undefined (reading 'id')");
1321
+ expect(run.nodeStates.a.error).toContain('received (none)');
1322
+ });
1323
+
1324
+ it('leaves a plain Error message exactly unchanged', async () => {
1325
+ const f = flow([node('a', 'boom')], []);
1326
+ const run = await startRun({ flow: f, registry: makeRegistry(), ...deterministic() }).runToEnd();
1327
+ expect(run.nodeStates.a.error).toBe('kaboom');
1328
+ });
1329
+
1330
+ it('leaves an unrelated TypeError message exactly unchanged', async () => {
1331
+ const r = new NodeRegistry();
1332
+ r.register({ type: 'weird-type-error', label: 'Weird' }, async () => {
1333
+ throw new TypeError('value is not a function');
1334
+ });
1335
+ const f = flow([node('a', 'weird-type-error')], []);
1336
+ const run = await startRun({ flow: f, registry: r, ...deterministic() }).runToEnd();
1337
+ expect(run.nodeStates.a.error).toBe('value is not a function');
1338
+ });
1339
+ });