@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,1111 @@
1
+ import { existsSync, watch } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import express, { type Request, type Response } from 'express';
6
+ import {
7
+ findScenario,
8
+ isArtifact,
9
+ mergeMocks,
10
+ validateFlow,
11
+ verifyArtifact,
12
+ type ScenarioDefinition,
13
+ type WorkflowDefinition,
14
+ } from '../src/engine';
15
+ import { createLoginFlow } from '../src/flows/login-flow';
16
+ import { createWeatherFlow } from '../src/flows/weather-flow';
17
+ import { createAnomalyFlows } from '../src/flows/anomaly-flows';
18
+ import { createPradarFlows } from '../src/flows/pradar-flows';
19
+ import { RunRegistry, type RunEvent } from './runRegistry';
20
+ import {
21
+ loadEnvironments,
22
+ resolveRunSafety,
23
+ setEnvironmentAuth,
24
+ setEnvironmentSecret,
25
+ deleteEnvironmentSecret,
26
+ type EnvAuth,
27
+ } from './environments';
28
+ import { performLogin } from './login';
29
+ import { loadInfrastructure } from './infrastructure';
30
+ import { configPathFor, loadProjectConfig } from './projectConfig';
31
+ import { buildApiStore, buildRegistries, requireProjectWhenExplicit } from './projectMode';
32
+ import { nodesPayload } from './nodesPayload';
33
+ import { openBrowser } from './openBrowser';
34
+ import { AgentRunManager, AgentStartError } from './agents/runManager';
35
+ import { isMountablePath } from './pathGuard';
36
+ import { detectAgents } from './agents/detect';
37
+ import type { AgentEvent } from './agents/types';
38
+ import type { AgentIntent } from './agents/prompt';
39
+ import { ExpressAdapter } from './runtime/expressAdapter';
40
+ import { makeOperationHandler } from './httpOperations';
41
+ import { mockResponseFor } from './mockHandler';
42
+ import { createDefaultVerifierRegistry } from './auth/verifiers';
43
+ import { isPathWithin } from './pathSafety';
44
+ import { attachCredential } from './authAttach';
45
+ import { redactSecrets } from './redact';
46
+ import { runScenarioSuiteFor, ScenarioTestUsageError } from './testRunner';
47
+ import { diagnoseOperation } from '../src/engine/diagnostics';
48
+ import { seedParamDefaults } from './normalizeFlow';
49
+
50
+ const PORT = Number(process.env.EMBERFLOW_RUNNER_PORT ?? 8092);
51
+ const HOST = '127.0.0.1';
52
+ const HEARTBEAT_MS = 15_000;
53
+
54
+ // Runtime serving mode: mock vs real. EMBERFLOW_MOCK only seeds the initial
55
+ // value at boot — POST /serving flips it live thereafter (see below). Every
56
+ // mounted operation route dispatches on this flag per-request rather than
57
+ // being built (or not) once at boot for a fixed mode.
58
+ // Provisional: EMBERFLOW_MOCK=1 forces mock regardless of environments.
59
+ // Reseeded below once environmentsFile is loaded, to also default to mock
60
+ // when no environments are configured.
61
+ let servingMode: 'real' | 'mock' = process.env.EMBERFLOW_MOCK === '1' ? 'mock' : 'real';
62
+
63
+ /**
64
+ * Wire up an SSE response safely: guards every write against a client that has
65
+ * already disconnected (writing to an ended/destroyed response throws
66
+ * ERR_STREAM_WRITE_AFTER_END, which — unhandled — crashes the whole runner),
67
+ * swallows stream errors, and drives a heartbeat. Returns { write, close } plus
68
+ * an `onClose` you pass your unsubscribe to. Both event streams use this.
69
+ */
70
+ function openSse(req: Request, res: Response, onClientGone: () => void): {
71
+ write: (s: string) => void;
72
+ end: () => void;
73
+ } {
74
+ res.writeHead(200, {
75
+ 'Content-Type': 'text/event-stream',
76
+ 'Cache-Control': 'no-cache',
77
+ Connection: 'keep-alive',
78
+ });
79
+ res.flushHeaders();
80
+ let closed = false;
81
+ const write = (s: string): void => {
82
+ if (closed || res.writableEnded || res.destroyed) return;
83
+ try {
84
+ res.write(s);
85
+ } catch {
86
+ // client vanished mid-write — treat as closed
87
+ closed = true;
88
+ }
89
+ };
90
+ const end = (): void => {
91
+ if (closed) return;
92
+ closed = true;
93
+ try {
94
+ res.end();
95
+ } catch {
96
+ /* already gone */
97
+ }
98
+ };
99
+ const heartbeat = setInterval(() => write(': heartbeat\n\n'), HEARTBEAT_MS);
100
+ const cleanup = (): void => {
101
+ closed = true;
102
+ clearInterval(heartbeat);
103
+ onClientGone();
104
+ };
105
+ // A stream 'error' (EPIPE/write-after-end) must be handled or Node throws it
106
+ // as an uncaught exception and the process dies.
107
+ res.on('error', cleanup);
108
+ req.on('close', cleanup);
109
+ return { write, end };
110
+ }
111
+
112
+ const projectDir = process.env.EMBERFLOW_PROJECT
113
+ ? resolve(process.cwd(), process.env.EMBERFLOW_PROJECT)
114
+ : process.cwd();
115
+ const project = requireProjectWhenExplicit(
116
+ await loadProjectConfig(projectDir),
117
+ process.env.EMBERFLOW_PROJECT,
118
+ projectDir,
119
+ );
120
+ if (project) console.log(`[runner] project mode: ${project.root} (flows: ${project.flowsDir})`);
121
+
122
+ // Malformed environments file fails the boot loudly, by design.
123
+ const environmentsFile = loadEnvironments(project ? project.root : process.cwd());
124
+
125
+ // Default-mock: a project that never configured environments (loadEnvironments
126
+ // synthesized its bare "local" fallback) has nothing real to serve against, so
127
+ // boot into mock unless EMBERFLOW_MOCK already forced a mode above. A project
128
+ // WITH an environments file (or legacy secrets) keeps booting real exactly as
129
+ // before — even when its environments carry no vars/secrets yet. Reads the
130
+ // SAME environmentsFile loaded above — no second file read that could disagree.
131
+ if (process.env.EMBERFLOW_MOCK !== '1' && !environmentsFile.configured) {
132
+ servingMode = 'mock';
133
+ }
134
+
135
+ // Validation-only registry; RunRegistry owns the execution registry.
136
+ const { validation: validationRegistry, execution: executionRegistry } = buildRegistries(project);
137
+
138
+ // Workflow files: seed with the example flows the first time we boot (skipped in project mode).
139
+ // Runs the flows/ -> apis/default/ migration on boot when needed.
140
+ const apiStore = buildApiStore(project);
141
+ // Subflow nodes resolve child workflows from the same file store.
142
+ const runs = new RunRegistry((id) => apiStore.load(id), executionRegistry, project?.errorOperation);
143
+ if (!project) {
144
+ for (const flow of [
145
+ createWeatherFlow(),
146
+ createLoginFlow(),
147
+ ...createAnomalyFlows(),
148
+ ...createPradarFlows(),
149
+ ]) {
150
+ if (!apiStore.load(flow.id)) apiStore.save(flow, flow.id.includes('/') ? flow.id : `default/${flow.id}`);
151
+ }
152
+ }
153
+
154
+ const agentRuns = new AgentRunManager(
155
+ project ? project.root : projectDir,
156
+ apiStore.dir,
157
+ apiStore.pathOf.bind(apiStore),
158
+ () => nodesPayload(validationRegistry).nodes.map(({ type, label, description }) => ({ type, label, description })),
159
+ project?.language ?? 'typescript',
160
+ // Fresh per run: re-read emberflow/infrastructure.json so a scout that ran
161
+ // earlier this session primes later prompts. Malformed → null (agent guesses).
162
+ () => loadInfrastructure(project ? project.root : projectDir),
163
+ );
164
+
165
+ // Hot-reload agent-authored nodes: watch the project's config and rebuild the
166
+ // node registries IN-PROCESS (no process restart), so registering a node in
167
+ // `registerNodes` takes effect live. Critically, this survives an in-flight
168
+ // agent run — the agent edits the config to author a node mid-build, and a full
169
+ // restart (what `tsx watch` would do) kills its stream + child process. Under
170
+ // `tsx watch`, the project dir is excluded from the watcher (see run.sh) so tsx
171
+ // doesn't reboot and undo this.
172
+ if (project) {
173
+ const cfgPath = configPathFor(project.root);
174
+ if (cfgPath) {
175
+ let reloadTimer: ReturnType<typeof setTimeout> | undefined;
176
+ watch(cfgPath, () => {
177
+ if (reloadTimer) clearTimeout(reloadTimer);
178
+ reloadTimer = setTimeout(() => {
179
+ void (async () => {
180
+ try {
181
+ const fresh = await loadProjectConfig(project.root, { fresh: true });
182
+ const rebuilt = buildRegistries(fresh);
183
+ validationRegistry.adopt(rebuilt.validation);
184
+ executionRegistry.adopt(rebuilt.execution);
185
+ console.log(
186
+ `[runner] reloaded project nodes in-process — ${executionRegistry.list().length} registered (no restart)`,
187
+ );
188
+ } catch (err) {
189
+ console.warn(
190
+ `[runner] project config reload failed, keeping previous nodes: ${err instanceof Error ? err.message : String(err)}`,
191
+ );
192
+ }
193
+ })();
194
+ }, 150);
195
+ });
196
+ }
197
+ }
198
+
199
+ const app = express();
200
+ // strict: false so a literal `null` JSON body (used by PUT
201
+ // /environments/:name/auth to clear auth config) parses instead of being
202
+ // rejected as invalid JSON — strict mode only accepts objects/arrays.
203
+ app.use(express.json({ limit: '5mb', strict: false }));
204
+
205
+ const api = express.Router();
206
+
207
+ api.get('/healthz', (_req, res) => {
208
+ res.json({ status: 'ok', mock: servingMode === 'mock' });
209
+ });
210
+
211
+ // Runtime serving-mode switch: mock is a LIVE flag, not a boot-time
212
+ // decision. EMBERFLOW_MOCK only seeds the initial value (below); this route
213
+ // flips it while the process keeps running, so every already-mounted
214
+ // operation route (built once at boot with BOTH a real and a mock handler,
215
+ // see the mounting loop further down) starts dispatching to the other
216
+ // handler on the very next request — no remount, no restart.
217
+ api.post('/serving', (req: Request, res: Response) => {
218
+ const { mode } = (req.body ?? {}) as { mode?: unknown };
219
+ if (mode !== 'real' && mode !== 'mock') {
220
+ res.status(400).json({ error: `mode must be 'real' or 'mock', got: ${String(mode)}` });
221
+ return;
222
+ }
223
+ if (mode !== servingMode) {
224
+ servingMode = mode;
225
+ console.log(`[emberflow] serving mode → ${servingMode}`);
226
+ }
227
+ res.status(204).end();
228
+ });
229
+
230
+ api.get('/nodes', (_req, res) => {
231
+ res.json(nodesPayload(validationRegistry));
232
+ });
233
+
234
+ // Validate a flow against the runner's LIVE registry (built-ins + project
235
+ // nodes) so the CLI `validate` command doesn't false-reject ops that use
236
+ // project-registered node types (which a local createDefaultRegistry lacks).
237
+ api.post('/validate', (req, res) => {
238
+ const body = (req.body ?? {}) as { flow?: unknown };
239
+ const flow = (body.flow ?? req.body) as WorkflowDefinition;
240
+ if (!flow || typeof flow !== 'object' || !Array.isArray((flow as { nodes?: unknown }).nodes)) {
241
+ res.status(400).json({ error: 'Body must be a flow (or { flow })' });
242
+ return;
243
+ }
244
+ const issues = validateFlow(flow, validationRegistry);
245
+ res.json({ valid: !issues.some((i) => i.severity === 'error'), issues });
246
+ });
247
+
248
+ api.get('/samples', (req: Request, res: Response) => {
249
+ const nodeId = req.query.nodeId;
250
+ if (typeof nodeId !== 'string' || nodeId.length === 0) {
251
+ res.status(400).json({ error: 'Query param nodeId is required' });
252
+ return;
253
+ }
254
+ // Samples are recorded per-node across runs and may originate from any
255
+ // environment, so redact against EVERY environment's secrets. Each env is
256
+ // applied as an independent pass (not one merged map): two envs may define
257
+ // the same secret KEY with different VALUES, and a merged map would keep
258
+ // only one of them, letting the other pass through raw. Record-time
259
+ // storage stays raw; this is response-time only.
260
+ let out: unknown = { samples: runs.samplesFor(nodeId) };
261
+ for (const env of Object.values(environmentsFile.environments)) {
262
+ out = redactSecrets(out, env.secrets);
263
+ }
264
+ res.json(out);
265
+ });
266
+
267
+ api.get('/environments', (_req, res) => {
268
+ // The agent (setup-environments / setup-auth) edits emberflow.environments.json
269
+ // directly on disk — re-read it here so the studio sees new environments
270
+ // without a runner restart. Mutate the shared object in place (every other
271
+ // route closes over it); a malformed mid-edit file keeps the last good state.
272
+ try {
273
+ const fresh = loadEnvironments(project ? project.root : process.cwd());
274
+ environmentsFile.defaultEnvironment = fresh.defaultEnvironment;
275
+ environmentsFile.environments = fresh.environments;
276
+ environmentsFile.configured = fresh.configured;
277
+ } catch {
278
+ // keep serving the last successfully loaded state
279
+ }
280
+ res.json({
281
+ // False while the project has no environments file — the studio shows
282
+ // its zero-environment onboarding state instead of the synthesized
283
+ // "local" fallback entry.
284
+ configured: environmentsFile.configured,
285
+ defaultEnvironment: environmentsFile.defaultEnvironment,
286
+ environments: Object.entries(environmentsFile.environments).map(([name, env]) => ({
287
+ name,
288
+ protected: !!env.protected,
289
+ varKeys: Object.keys(env.vars),
290
+ secretKeys: Object.keys(env.secrets),
291
+ auth: {
292
+ configured: !!env.auth,
293
+ authenticated: !!(env.auth && env.secrets[env.auth.attach.secretRef]),
294
+ ...(env.auth ? { secretRef: env.auth.attach.secretRef, config: env.auth } : {}),
295
+ },
296
+ })),
297
+ });
298
+ });
299
+
300
+ // The infrastructure scout writes emberflow/infrastructure.json on disk; like
301
+ // /environments, re-read it per request so the studio's Infra tab reflects a
302
+ // just-completed scout without a runner restart. Keep-last-good semantics:
303
+ // a MALFORMED mid-write file (loadInfrastructure → null while the file exists)
304
+ // keeps the last successfully-loaded manifest served; a genuinely ABSENT file
305
+ // reports not-present and clears the cache.
306
+ let lastGoodInfrastructure = loadInfrastructure(project ? project.root : process.cwd());
307
+ function readInfrastructure(): ReturnType<typeof loadInfrastructure> {
308
+ const infraPath = join(project ? project.root : process.cwd(), 'emberflow', 'infrastructure.json');
309
+ if (!existsSync(infraPath)) {
310
+ lastGoodInfrastructure = null;
311
+ return null;
312
+ }
313
+ const fresh = loadInfrastructure(project ? project.root : process.cwd());
314
+ if (fresh) lastGoodInfrastructure = fresh;
315
+ return fresh ?? lastGoodInfrastructure;
316
+ }
317
+
318
+ api.get('/infrastructure', (_req: Request, res: Response) => {
319
+ const manifest = readInfrastructure();
320
+ if (!manifest) {
321
+ res.json({ present: false });
322
+ return;
323
+ }
324
+ res.json({ present: true, manifest });
325
+ });
326
+
327
+ // A second POST for the same environment while one is already in flight
328
+ // awaits the same promise instead of double-firing the upstream login
329
+ // request. Different environments don't block each other.
330
+ const loginsInFlight = new Map<string, Promise<{ secretRef: string }>>();
331
+
332
+ api.post('/environments/:name/login', async (req: Request, res: Response) => {
333
+ const name = String(req.params.name);
334
+ const envDef = environmentsFile.environments[name];
335
+ if (!envDef) {
336
+ res.status(404).json({ error: `unknown environment "${name}"` });
337
+ return;
338
+ }
339
+ if (!envDef.auth?.login) {
340
+ res.status(400).json({ error: `environment "${name}" has no auth.login configured` });
341
+ return;
342
+ }
343
+ try {
344
+ let inFlight = loginsInFlight.get(name);
345
+ if (!inFlight) {
346
+ inFlight = performLogin(project ? project.root : process.cwd(), name, envDef).finally(() => {
347
+ loginsInFlight.delete(name);
348
+ });
349
+ loginsInFlight.set(name, inFlight);
350
+ }
351
+ const { secretRef } = await inFlight;
352
+ // Reload from disk so this in-process copy (and subsequent GET/runs) see
353
+ // the secret performLogin just persisted via setEnvironmentSecret.
354
+ const reloaded = loadEnvironments(project ? project.root : process.cwd());
355
+ environmentsFile.environments[name] = reloaded.environments[name];
356
+ res.status(200).json({ authenticated: true, secretRef });
357
+ } catch (err) {
358
+ // Never leak the captured credential — only the failure reason.
359
+ const message = err instanceof Error ? err.message : String(err);
360
+ res.status(502).json({ error: message });
361
+ }
362
+ });
363
+
364
+ /** Reloads `environmentsFile.environments[name]` from disk after a write, so
365
+ * this in-process copy (and subsequent GET/runs) see what the writer just
366
+ * persisted — same reload pattern as the login route above. */
367
+ function refreshEnvironmentFromDisk(name: string): void {
368
+ const reloaded = loadEnvironments(project ? project.root : process.cwd());
369
+ environmentsFile.environments[name] = reloaded.environments[name];
370
+ }
371
+
372
+ api.put('/environments/:name/auth', async (req: Request, res: Response) => {
373
+ const name = String(req.params.name);
374
+ if (!environmentsFile.environments[name]) {
375
+ res.status(404).json({ error: `unknown environment "${name}"` });
376
+ return;
377
+ }
378
+ const auth = (req.body ?? null) as EnvAuth | null;
379
+ try {
380
+ await setEnvironmentAuth(project ? project.root : process.cwd(), name, auth);
381
+ refreshEnvironmentFromDisk(name);
382
+ res.status(204).end();
383
+ } catch (err) {
384
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
385
+ }
386
+ });
387
+
388
+ api.put('/environments/:name/secrets/:key', async (req: Request, res: Response) => {
389
+ const name = String(req.params.name);
390
+ const key = String(req.params.key);
391
+ if (!environmentsFile.environments[name]) {
392
+ res.status(404).json({ error: `unknown environment "${name}"` });
393
+ return;
394
+ }
395
+ const value = (req.body as { value?: unknown } | undefined)?.value;
396
+ if (typeof value !== 'string') {
397
+ res.status(400).json({ error: 'Body must include { value: string }' });
398
+ return;
399
+ }
400
+ try {
401
+ // Never log or echo `value` — it is a secret and must not transit back
402
+ // to the caller or appear in any log line.
403
+ await setEnvironmentSecret(project ? project.root : process.cwd(), name, key, value);
404
+ refreshEnvironmentFromDisk(name);
405
+ res.status(204).end();
406
+ } catch (err) {
407
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
408
+ }
409
+ });
410
+
411
+ api.delete('/environments/:name/secrets/:key', async (req: Request, res: Response) => {
412
+ const name = String(req.params.name);
413
+ const key = String(req.params.key);
414
+ if (!environmentsFile.environments[name]) {
415
+ res.status(404).json({ error: `unknown environment "${name}"` });
416
+ return;
417
+ }
418
+ try {
419
+ await deleteEnvironmentSecret(project ? project.root : process.cwd(), name, key);
420
+ refreshEnvironmentFromDisk(name);
421
+ res.status(204).end();
422
+ } catch (err) {
423
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
424
+ }
425
+ });
426
+
427
+ api.get('/workflows', (_req, res) => {
428
+ res.json({ flows: apiStore.list(), operations: apiStore.listSummaries() });
429
+ });
430
+
431
+ api.put('/workflows/:id', (req: Request, res: Response) => {
432
+ const body = req.body as WorkflowDefinition | undefined;
433
+ if (!body || typeof body !== 'object' || Array.isArray(body) || body.id !== String(req.params.id)) {
434
+ res.status(400).json({ error: 'Body must be a flow object whose id matches the URL' });
435
+ return;
436
+ }
437
+ // Normalize before validation/persist so what's saved to disk (and served
438
+ // back to studio/CLI) already carries seeded param defaults.
439
+ const { flow } = seedParamDefaults(body);
440
+ try {
441
+ const relPath = apiStore.pathOf(flow.id) ?? `default/${flow.id}`;
442
+ apiStore.save(flow, relPath);
443
+ } catch (err) {
444
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
445
+ return;
446
+ }
447
+ res.status(200).json({ ok: true });
448
+ });
449
+
450
+ api.delete('/workflows/:id', (req, res) => {
451
+ if (apiStore.remove(req.params.id)) res.status(204).end();
452
+ else res.status(404).json({ error: `Unknown workflow: ${req.params.id}` });
453
+ });
454
+
455
+ // Studio "Test" button: runs one op's `expect`-carrying scenarios in-process,
456
+ // reusing this already-booted server's apiStore/executionRegistry/
457
+ // environmentsFile (no re-read from disk, no drift from in-memory writes)
458
+ // via the shared seam server/testRunner.ts exports — same suite/evaluation/
459
+ // redaction path the CLI's `emberflow test` uses, NO second expectation
460
+ // engine. Deliberately bypasses RunRegistry (bare `startRun`, same as the
461
+ // CLI path): these runs must never surface as SSE events or run history.
462
+ api.post('/workflows/:id/test', async (req: Request, res: Response) => {
463
+ const opId = String(req.params.id);
464
+ if (!apiStore.load(opId)) {
465
+ res.status(404).json({ error: `Unknown workflow: ${opId}` });
466
+ return;
467
+ }
468
+ const { environment } = (req.body ?? {}) as { environment?: unknown };
469
+ if (environment !== undefined && typeof environment !== 'string') {
470
+ res.status(400).json({ error: 'environment must be a string' });
471
+ return;
472
+ }
473
+ try {
474
+ const report = await runScenarioSuiteFor(
475
+ { apiStore, registry: executionRegistry, environmentsFile },
476
+ { opId, environmentName: environment },
477
+ );
478
+ res.json(report);
479
+ } catch (err) {
480
+ if (err instanceof ScenarioTestUsageError) {
481
+ res.status(400).json({ error: err.message });
482
+ return;
483
+ }
484
+ // Unexpected error (e.g. startRun rejecting an invalid flow): the studio
485
+ // consumes this route, so answer JSON like every sibling route — never
486
+ // Express's default HTML 500 page.
487
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
488
+ }
489
+ });
490
+
491
+ // `emberflow doctor` / studio diagnostics panel: loads the op + its
492
+ // scenarios sidecar exactly as POST /workflows/:id/test does (apiStore.load
493
+ // merges <op>.scenarios.json in), then runs the pure src/engine/diagnostics
494
+ // check — no execution, no environment needed.
495
+ api.get('/workflows/:id/diagnostics', (req: Request, res: Response) => {
496
+ const opId = String(req.params.id);
497
+ const flow = apiStore.load(opId);
498
+ if (!flow) {
499
+ res.status(404).json({ error: `Unknown workflow: ${opId}` });
500
+ return;
501
+ }
502
+ const infraNodes: Array<{ id: string; traceKind: string }> = [];
503
+ const mutationSourcesByNode: Record<string, string> = {};
504
+ for (const n of flow.nodes) {
505
+ if (!validationRegistry.has(n.type)) continue;
506
+ const { definition, implementation } = validationRegistry.get(n.type);
507
+ const { traceKind } = definition;
508
+ if (traceKind === 'db' || traceKind === 'http' || traceKind === 'llm') {
509
+ infraNodes.push({ id: n.id, traceKind });
510
+ }
511
+ if (definition.effects === 'mutation' && typeof implementation === 'function') {
512
+ mutationSourcesByNode[n.id] = String(implementation);
513
+ }
514
+ }
515
+ const diagnostics = diagnoseOperation(flow, flow.scenarios, {
516
+ infraNodes,
517
+ mutationSourcesByNode,
518
+ languageDrift: project?.languageDrift,
519
+ });
520
+ res.json({ diagnostics });
521
+ });
522
+
523
+ /** A relative `apis/` path is safe when it has no `..` segment, isn't
524
+ * absolute, and doesn't carry a Windows-only backslash separator that could
525
+ * otherwise escape `apiStore.dir` via `path.join` on Windows — it must stay
526
+ * inside the project's apis/ directory. */
527
+ function isSafeApiPath(path: string): boolean {
528
+ return isPathWithin(apiStore.dir, path);
529
+ }
530
+
531
+ // The studio creates new operations here (not via PUT /workflows/:id): a
532
+ // brand-new operation isn't yet in apiStore's id->path index, so PUT's
533
+ // `pathOf(id) ?? default/${id}` fallback would bury a nested new op under
534
+ // `default/` instead of at its intended api/folder path. This endpoint takes
535
+ // the intended path explicitly.
536
+ api.post('/operations', (req: Request, res: Response) => {
537
+ const body = req.body as { flow?: WorkflowDefinition; path?: string } | undefined;
538
+ const rawFlow = body?.flow;
539
+ const path = body?.path;
540
+ if (!rawFlow || typeof rawFlow !== 'object' || Array.isArray(rawFlow)) {
541
+ res.status(400).json({ error: 'Body must include a flow object' });
542
+ return;
543
+ }
544
+ if (!isSafeApiPath(path ?? '')) {
545
+ res.status(400).json({ error: 'Body must include a safe relative path (no ".." or absolute paths)' });
546
+ return;
547
+ }
548
+ // The id-as-path invariant: createOperation always mints id === path, so a
549
+ // mismatch means the caller isn't using this endpoint as intended — reject
550
+ // rather than let the op's id and its on-disk path drift apart.
551
+ if (rawFlow.id !== path) {
552
+ res.status(400).json({ error: 'flow.id must equal path' });
553
+ return;
554
+ }
555
+ // This endpoint is for creating brand-new operations, not overwriting ones
556
+ // that already exist at that path (that's PUT /workflows/:id) — silently
557
+ // clobbering an existing file here would be data loss.
558
+ if (apiStore.existsAt(path!)) {
559
+ res.status(409).json({ error: `operation already exists at ${path}` });
560
+ return;
561
+ }
562
+ // Normalize before persist — belt-and-braces alongside `emberflow create`'s
563
+ // own scaffolding, so a new op is guaranteed to already satisfy the
564
+ // missing-param-default guard whatever created it.
565
+ const { flow } = seedParamDefaults(rawFlow);
566
+ try {
567
+ apiStore.save(flow, path!);
568
+ } catch (err) {
569
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
570
+ return;
571
+ }
572
+ res.status(201).json({ ok: true });
573
+ });
574
+
575
+ /** Deep-copy a flow with every node's metadata.pinnedOutput removed. */
576
+ function stripMetadataPins(flow: WorkflowDefinition): WorkflowDefinition {
577
+ const copy = structuredClone(flow);
578
+ for (const node of copy.nodes) {
579
+ if (!node.metadata) continue;
580
+ delete node.metadata.pinnedOutput;
581
+ if (Object.keys(node.metadata).length === 0) delete node.metadata;
582
+ }
583
+ return copy;
584
+ }
585
+
586
+ api.post('/runs', async (req: Request, res: Response) => {
587
+ const { flow, artifact, mode, pins, input, env, environment, safeMode, confirm, scenarioName } = req.body ?? {};
588
+ if (mode !== 'run' && mode !== 'step') {
589
+ res.status(400).json({ error: 'Body must include mode: "run"|"step"' });
590
+ return;
591
+ }
592
+ if (scenarioName !== undefined && typeof scenarioName !== 'string') {
593
+ res.status(400).json({ error: 'scenarioName must be a string' });
594
+ return;
595
+ }
596
+ if (pins !== undefined && (typeof pins !== 'object' || pins === null || Array.isArray(pins))) {
597
+ res.status(400).json({ error: 'pins must be a plain object of nodeId -> output' });
598
+ return;
599
+ }
600
+ if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) {
601
+ res.status(400).json({ error: 'input must be a plain object' });
602
+ return;
603
+ }
604
+ if (environment !== undefined && typeof environment !== 'string') {
605
+ res.status(400).json({ error: 'environment must be a string' });
606
+ return;
607
+ }
608
+ if (safeMode !== undefined && typeof safeMode !== 'boolean') {
609
+ res.status(400).json({ error: 'safeMode must be a boolean' });
610
+ return;
611
+ }
612
+ if (confirm !== undefined && typeof confirm !== 'string') {
613
+ res.status(400).json({ error: 'confirm must be a string' });
614
+ return;
615
+ }
616
+
617
+ const environmentName = environment ?? environmentsFile.defaultEnvironment;
618
+ const environmentDef = environmentsFile.environments[environmentName];
619
+ if (!environmentDef) {
620
+ res.status(400).json({ error: `Unknown environment: '${environmentName}'` });
621
+ return;
622
+ }
623
+ const safety = resolveRunSafety(environmentName, environmentDef, { safeMode, confirm });
624
+ if (!safety.ok) {
625
+ res.status(400).json({ error: safety.error });
626
+ return;
627
+ }
628
+
629
+ // Artifact runs force production semantics; env may also request it explicitly.
630
+ const isArtifactRun = artifact !== undefined;
631
+ const production = isArtifactRun || env === 'production';
632
+
633
+ // Production rejects request pins (metadata pins are ignored, never derived).
634
+ if (production && pins !== undefined && Object.keys(pins).length > 0) {
635
+ res.status(400).json({ error: 'pins are not allowed in production runs' });
636
+ return;
637
+ }
638
+
639
+ let effectiveFlow: WorkflowDefinition;
640
+ if (isArtifactRun) {
641
+ if (!isArtifact(artifact)) {
642
+ res.status(400).json({ error: 'artifact must be a valid emberflow/v1 artifact' });
643
+ return;
644
+ }
645
+ const drift = await verifyArtifact(artifact, runs.executionRegistry);
646
+ if (drift.length > 0) {
647
+ res.status(400).json({ error: `implementation drift: ${drift.join(', ')}` });
648
+ return;
649
+ }
650
+ effectiveFlow = artifact.flow;
651
+ } else if (flow) {
652
+ effectiveFlow = flow;
653
+ } else {
654
+ res.status(400).json({ error: 'Body must include either flow or artifact' });
655
+ return;
656
+ }
657
+
658
+ // In production, strip metadata pins so no node can be pin-shortcut.
659
+ if (production) effectiveFlow = stripMetadataPins(effectiveFlow);
660
+
661
+ const errors = validateFlow(effectiveFlow, validationRegistry).filter((i) => i.severity === 'error');
662
+ if (errors.length > 0) {
663
+ res.status(400).json({ error: errors.map((i) => i.message).join('; ') });
664
+ return;
665
+ }
666
+
667
+ // Dev runs honour request pins; production runs never do.
668
+ const effectivePins = production ? undefined : pins;
669
+
670
+ // Mock serving mode reads `servingMode` live (a module-level flag flipped
671
+ // by POST /serving, not a boot-time decision) — a production/artifact run
672
+ // always runs for real regardless of serving mode, same as the pins guard
673
+ // above. Nothing real is ever touched in a mock run: no credential
674
+ // auto-attach, no infra implementation calls — the engine's mockRun
675
+ // short-circuit is the ONLY thing that changes execution.
676
+ const mockRun = servingMode === 'mock' && !production;
677
+ let effectiveMocks: Record<string, unknown> | undefined;
678
+ if (mockRun) {
679
+ let scenario: ScenarioDefinition | undefined;
680
+ if (scenarioName !== undefined) {
681
+ scenario = findScenario(effectiveFlow, scenarioName);
682
+ if (!scenario) {
683
+ res.status(400).json({ error: `Unknown scenario: "${scenarioName}"` });
684
+ return;
685
+ }
686
+ }
687
+ effectiveMocks = mergeMocks(effectiveFlow, scenario);
688
+ }
689
+
690
+ // Auto-attach the environment's credential (if configured) before the run
691
+ // starts. Non-destructive: attachCredential leaves caller-supplied
692
+ // header/cookie values untouched. Never log the secret value. Skipped
693
+ // entirely in a mock run — nothing real is ever touched.
694
+ let effectiveInput = input;
695
+ if (!mockRun) {
696
+ const attach = environmentDef.auth?.attach;
697
+ if (attach) {
698
+ const secretValue = environmentDef.secrets[attach.secretRef];
699
+ if (secretValue !== undefined) {
700
+ effectiveInput = {
701
+ ...(input ?? {}),
702
+ headers: attachCredential((input ?? {}).headers ?? {}, attach, secretValue),
703
+ };
704
+ }
705
+ }
706
+ }
707
+
708
+ let runId: string;
709
+ let handle;
710
+ try {
711
+ ({ runId, handle } = runs.create(effectiveFlow, {
712
+ secrets: environmentDef.secrets,
713
+ vars: environmentDef.vars,
714
+ environment: environmentName,
715
+ safeMode: safety.safeMode,
716
+ pins: effectivePins,
717
+ input: effectiveInput,
718
+ mockRun,
719
+ mocks: effectiveMocks,
720
+ }));
721
+ } catch (err) {
722
+ res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
723
+ return;
724
+ }
725
+
726
+ if (mode === 'run') {
727
+ handle.runToEnd().catch((err: unknown) => {
728
+ console.error(`[run ${runId}] runToEnd failed:`, err);
729
+ });
730
+ }
731
+ res.status(201).json({ runId });
732
+ });
733
+
734
+ api.post('/runs/:id/step', async (req, res) => {
735
+ const handle = runs.get(req.params.id);
736
+ if (!handle) {
737
+ res.status(404).json({ error: `Unknown run: ${req.params.id}` });
738
+ return;
739
+ }
740
+ const more = await handle.step();
741
+ res.json({ done: !more });
742
+ });
743
+
744
+ api.post('/runs/:id/cancel', (req, res) => {
745
+ const handle = runs.get(req.params.id);
746
+ if (!handle) {
747
+ res.status(404).json({ error: `Unknown run: ${req.params.id}` });
748
+ return;
749
+ }
750
+ handle.cancel();
751
+ res.status(204).end();
752
+ });
753
+
754
+ api.get('/runs/:id/events', (req, res) => {
755
+ const runId = req.params.id;
756
+ if (!runs.get(runId)) {
757
+ res.status(404).json({ error: `Unknown run: ${runId}` });
758
+ return;
759
+ }
760
+
761
+ let unsubscribe: (() => void) | undefined;
762
+ const sse = openSse(req, res, () => unsubscribe?.());
763
+
764
+ const listener = (event: RunEvent): void => {
765
+ sse.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
766
+ if (event.type === 'finished') {
767
+ unsubscribe?.();
768
+ sse.end();
769
+ }
770
+ };
771
+
772
+ unsubscribe = runs.subscribe(runId, listener);
773
+ });
774
+
775
+ api.get('/agent/available', (_req: Request, res: Response) => {
776
+ res.json({ agents: detectAgents() });
777
+ });
778
+
779
+ // First-run onboarding aggregate for the Welcome checklist (studio's
780
+ // WelcomeDialog). Recomputed per request (cheap; mirrors /environments'
781
+ // re-read-on-GET) so a freshly-detected agent, a just-written environments
782
+ // file, or an installed skill shows up without a runner restart. All fields
783
+ // are derived from what already exists on disk/PATH — no probes, no side
784
+ // effects.
785
+ api.get('/setup-status', (_req: Request, res: Response) => {
786
+ const root = project ? project.root : process.cwd();
787
+ // Fresh environments read (malformed mid-edit file → last good state).
788
+ let envs = environmentsFile;
789
+ try {
790
+ envs = loadEnvironments(root);
791
+ } catch {
792
+ // keep the last successfully loaded state
793
+ }
794
+ const envList = Object.values(envs.environments);
795
+ const ops = apiStore.list();
796
+ // A pristine project ships exactly the single `default/hello` example op.
797
+ const onlyHello = ops.length === 1 && ops[0]?.id === 'default/hello';
798
+ res.json({
799
+ agents: detectAgents(),
800
+ environments: {
801
+ configured: envs.configured,
802
+ // The synthesized "local" fallback is "no environments yet" — report 0.
803
+ count: envs.configured ? envList.length : 0,
804
+ protectedCount: envList.filter((e) => e.protected).length,
805
+ anyAuthConfigured: envList.some((e) => !!e.auth),
806
+ },
807
+ // Skills count as installed at either scope: repo (`init --local`) or the
808
+ // user's home dir (`init --global`) — both teach the agent equally.
809
+ skills: {
810
+ claude: skillInstalled(root, 'claude'),
811
+ codex: skillInstalled(root, 'codex'),
812
+ },
813
+ language: project?.language ?? 'typescript',
814
+ ops: { count: ops.length, onlyHello },
815
+ servingMode,
816
+ // Infrastructure scout: present + a shallow summary (scannedAt/itemCount)
817
+ // from the same loader the /infrastructure route uses (re-read per request).
818
+ infrastructure: infraStatus(),
819
+ });
820
+ });
821
+
822
+ /** Whether the emberflow-basics skill exists for a harness, repo- or home-scoped. */
823
+ function skillInstalled(root: string, harness: 'claude' | 'codex'): boolean {
824
+ return [root, homedir()].some((base) =>
825
+ existsSync(join(base, `.${harness}`, 'skills', 'emberflow-basics', 'SKILL.md')),
826
+ );
827
+ }
828
+
829
+ /** The /setup-status `infrastructure` field: presence + a shallow summary. */
830
+ function infraStatus(): { present: boolean; scannedAt?: string; itemCount?: number } {
831
+ const manifest = readInfrastructure();
832
+ if (!manifest) return { present: false };
833
+ return {
834
+ present: true,
835
+ ...(manifest.scannedAt ? { scannedAt: manifest.scannedAt } : {}),
836
+ itemCount: manifest.items.length,
837
+ };
838
+ }
839
+
840
+ api.post('/agent', (req: Request, res: Response) => {
841
+ const { agent, model, intent } = req.body ?? {};
842
+ if (agent !== undefined && agent !== 'codex' && agent !== 'claude') {
843
+ res.status(400).json({ error: `Unsupported agent: ${agent}` });
844
+ return;
845
+ }
846
+ if (!intent || typeof intent !== 'object' || typeof intent.action !== 'string' || typeof intent.instruction !== 'string') {
847
+ res.status(400).json({ error: 'Body must include intent: { action, flowId, instruction, ... }' });
848
+ return;
849
+ }
850
+ if (
851
+ intent.action !== 'new-scenario' &&
852
+ intent.action !== 'edit-node' &&
853
+ intent.action !== 'edit-flow' &&
854
+ intent.action !== 'new-operation' &&
855
+ intent.action !== 'setup-auth' &&
856
+ intent.action !== 'setup-environments' &&
857
+ intent.action !== 'scout-infrastructure' &&
858
+ intent.action !== 'cover-operation' &&
859
+ intent.action !== 'ask'
860
+ ) {
861
+ res.status(400).json({
862
+ error: `Unsupported intent.action: ${intent.action}. Must be one of new-scenario, edit-node, edit-flow, new-operation, setup-auth, setup-environments, scout-infrastructure, cover-operation, ask.`,
863
+ });
864
+ return;
865
+ }
866
+ if (intent.action === 'new-operation') {
867
+ if (typeof intent.location !== 'string') {
868
+ res.status(400).json({ error: 'Body must include intent: { action: "new-operation", location, instruction }' });
869
+ return;
870
+ }
871
+ } else if (intent.action === 'setup-auth') {
872
+ if (typeof intent.environment !== 'string') {
873
+ res.status(400).json({ error: 'Body must include intent: { action: "setup-auth", environment, instruction }' });
874
+ return;
875
+ }
876
+ } else if (intent.action === 'setup-environments' || intent.action === 'scout-infrastructure') {
877
+ // instruction alone is required, already validated above; no flowId/environment needed.
878
+ } else if (intent.action === 'ask') {
879
+ if (intent.flowId !== undefined && typeof intent.flowId !== 'string') {
880
+ res.status(400).json({ error: 'ask intent: flowId must be a string when present' });
881
+ return;
882
+ }
883
+ } else if (typeof intent.flowId !== 'string') {
884
+ res.status(400).json({ error: 'Body must include intent: { action, flowId, instruction, ... }' });
885
+ return;
886
+ }
887
+ if (model !== undefined && typeof model !== 'string') {
888
+ res.status(400).json({ error: 'model must be a string' });
889
+ return;
890
+ }
891
+ const { reasoning } = req.body ?? {};
892
+ if (reasoning !== undefined && reasoning !== 'low' && reasoning !== 'medium' && reasoning !== 'high') {
893
+ res.status(400).json({ error: "reasoning must be 'low' | 'medium' | 'high'" });
894
+ return;
895
+ }
896
+
897
+ let agentRunId: string;
898
+ try {
899
+ agentRunId = agentRuns.start(intent as AgentIntent, { agent, model, reasoning });
900
+ } catch (err) {
901
+ const status = err instanceof AgentStartError ? err.status : 400;
902
+ res.status(status).json({ error: err instanceof Error ? err.message : String(err) });
903
+ return;
904
+ }
905
+ res.status(201).json({ agentRunId });
906
+ });
907
+
908
+ api.get('/agent/:id/events', (req, res) => {
909
+ const runId = req.params.id;
910
+ if (!agentRuns.has(runId)) {
911
+ res.status(404).json({ error: `Unknown agent run: ${runId}` });
912
+ return;
913
+ }
914
+
915
+ let unsubscribe: (() => void) | undefined;
916
+ const sse = openSse(req, res, () => unsubscribe?.());
917
+
918
+ const listener = (event: AgentEvent): void => {
919
+ sse.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
920
+ if (event.type === 'done' || event.type === 'error') {
921
+ unsubscribe?.();
922
+ sse.end();
923
+ }
924
+ };
925
+
926
+ unsubscribe = agentRuns.subscribe(runId, listener);
927
+ });
928
+
929
+ api.get('/agent/:id/diff', (req, res) => {
930
+ const result = agentRuns.diff(req.params.id);
931
+ if (!result) {
932
+ res.status(404).json({ error: `Unknown agent run: ${req.params.id}` });
933
+ return;
934
+ }
935
+ res.json(result);
936
+ });
937
+
938
+ api.post('/agent/:id/revert', (req, res) => {
939
+ const result = agentRuns.revert(req.params.id);
940
+ if (!result) {
941
+ res.status(404).json({ error: `Unknown agent run: ${req.params.id}` });
942
+ return;
943
+ }
944
+ res.json(result);
945
+ });
946
+
947
+ api.post('/agent/:id/cancel', (req, res) => {
948
+ if (!agentRuns.cancel(req.params.id)) {
949
+ res.status(404).json({ error: `Unknown agent run: ${req.params.id}` });
950
+ return;
951
+ }
952
+ res.json({ cancelled: true });
953
+ });
954
+
955
+ app.use(api); // root paths — CLI/MCP compatibility
956
+ app.use('/api', api); // same-origin studio paths
957
+
958
+ // Routed operations run with the project's DEFAULT environment's secrets/vars,
959
+ // resolved once here at boot (mirrors how POST /runs resolves environmentDef
960
+ // for a request, but there's no per-request environment override for a live
961
+ // HTTP endpoint — it always runs as the default environment).
962
+ const defaultEnvironmentName = environmentsFile.defaultEnvironment;
963
+ const defaultEnvironmentDef = environmentsFile.environments[defaultEnvironmentName];
964
+ if (!defaultEnvironmentDef) {
965
+ throw new Error(`defaultEnvironment '${defaultEnvironmentName}' is not defined in emberflow.environments.json`);
966
+ }
967
+ const operationRunEnv = {
968
+ secrets: defaultEnvironmentDef.secrets,
969
+ vars: defaultEnvironmentDef.vars,
970
+ environment: defaultEnvironmentName,
971
+ safeMode: false,
972
+ };
973
+
974
+ // Internal routes mounted at root via `app.use(api)` above. A routed
975
+ // operation whose http.path collides with one of these would silently never
976
+ // be reached (the internal route, registered first, always wins) — guarded
977
+ // against below rather than left as a silent trap.
978
+ const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/agent', '/serving'];
979
+
980
+ function collidesWithReservedPath(path: string): boolean {
981
+ return RESERVED_ROOT_PATHS.some((reserved) => path === reserved || path.startsWith(`${reserved}/`));
982
+ }
983
+
984
+
985
+ // Mount every operation with `http` metadata as a live endpoint, at the ROOT
986
+ // (not under /api) — e.g. an op with http.path '/ping' serves GET /ping.
987
+ // NOTE: operations are read once at boot; adding/editing an operation's http
988
+ // trigger after boot needs a restart to take effect (live re-mount is a later
989
+ // increment). A duplicate method+path across operations throws here — a
990
+ // clear, loud boot failure rather than a silently shadowed route.
991
+ const verifiers = createDefaultVerifierRegistry();
992
+ project?.registerVerifiers?.(verifiers);
993
+
994
+ const adapter = new ExpressAdapter(app);
995
+ const candidateOps = apiStore.list().filter((op) => op.http);
996
+ let invalidPathCount = 0;
997
+ let reservedCollisionCount = 0;
998
+ const routedOps = candidateOps.filter((op) => {
999
+ const path = op.http!.path;
1000
+ if (!isMountablePath(path)) {
1001
+ invalidPathCount += 1;
1002
+ console.warn(
1003
+ `[runner] operation ${op.id} has an invalid/root path "${path}" — skipping (would shadow the studio)`,
1004
+ );
1005
+ return false;
1006
+ }
1007
+ if (collidesWithReservedPath(path)) {
1008
+ reservedCollisionCount += 1;
1009
+ console.warn(
1010
+ `[runner] operation ${op.id} path ${op.http!.method} ${path} collides with a reserved internal route — skipping`,
1011
+ );
1012
+ return false;
1013
+ }
1014
+ return true;
1015
+ });
1016
+ if (servingMode === 'mock') {
1017
+ console.log('[emberflow] MOCK MODE — responses come from scenario expectations; no nodes execute, no auth enforced');
1018
+ }
1019
+
1020
+ /** Mock-mode route handler: NO auth, NO node execution — answers purely from
1021
+ * the op's scenario `expect`s via `mockResponseFor` (Task 5). Scenarios are
1022
+ * re-read from disk per request (via `apiStore.load`, already a cheap full
1023
+ * rescan used elsewhere in this file) rather than captured once at boot, so
1024
+ * a mock author editing a scenario's `expect` sees it on the next request
1025
+ * without restarting the runner. */
1026
+ function makeMockHandler(op: WorkflowDefinition): import('express').RequestHandler {
1027
+ return (req, res) => {
1028
+ const fresh = apiStore.load(op.id) ?? op;
1029
+ const { status, body } = mockResponseFor(fresh, fresh.scenarios ?? [], {
1030
+ headers: req.headers as Record<string, unknown>,
1031
+ query: req.query as Record<string, unknown>,
1032
+ });
1033
+ res.setHeader('x-emberflow-mock', 'true');
1034
+ res.status(status).json(body ?? null);
1035
+ };
1036
+ }
1037
+
1038
+ let failClosedCount = 0;
1039
+ for (const op of routedOps) {
1040
+ // Build BOTH handlers once at mount time (mirroring today's boot-time
1041
+ // resolveAuth + makeOperationHandler construction exactly on the real
1042
+ // side) and dispatch between them per-request on the live `servingMode`
1043
+ // flag — mock is now a runtime switch, not a structural boot decision.
1044
+ // Auth is resolved once here, same as before; it is never re-resolved
1045
+ // per request.
1046
+ const mockHandler = makeMockHandler(op);
1047
+ let realHandler: import('express').RequestHandler;
1048
+ try {
1049
+ const policy = apiStore.resolveAuth(op.id);
1050
+ realHandler = makeOperationHandler({ runs, op, env: operationRunEnv, policy, verifiers });
1051
+ } catch (err) {
1052
+ // resolveAuth throws when a PRESENT _meta.json in this op's ancestor
1053
+ // chain is corrupt (partial write, typo, etc.) — we cannot know what
1054
+ // auth policy was intended, so we must not mount the op open. Contain
1055
+ // the blast radius to just this op/route: deny it with a 500 rather
1056
+ // than skipping it (which would leave the route unmounted and 404,
1057
+ // less honest than "misconfigured") or, worse, silently falling back to
1058
+ // public. The rest of the runner still boots normally. This fail-closed
1059
+ // handler only applies to the real path — mock mode still answers from
1060
+ // scenarios regardless of a broken auth config, since mock never
1061
+ // enforces auth.
1062
+ failClosedCount += 1;
1063
+ console.error(`[runner] operation ${op.id} has broken auth config — mounted as fail-closed (500): ${String(err)}`);
1064
+ realHandler = (_req, res) => res.status(500).json({ error: 'auth misconfigured' });
1065
+ }
1066
+ adapter.registerOperation({ method: op.http!.method, path: op.http!.path }, (req, res) => {
1067
+ if (servingMode === 'mock') mockHandler(req, res, () => {});
1068
+ else void realHandler(req, res, () => {});
1069
+ });
1070
+ }
1071
+ console.log(
1072
+ `[runner] mounted ${routedOps.length} HTTP operation(s)${invalidPathCount > 0 ? ` (${invalidPathCount} skipped — invalid/root path)` : ''}${reservedCollisionCount > 0 ? ` (${reservedCollisionCount} skipped — reserved route collision)` : ''}${failClosedCount > 0 ? ` (${failClosedCount} fail-closed — broken auth config)` : ''}`,
1073
+ );
1074
+
1075
+ const serveStudio = process.env.EMBERFLOW_SERVE_STUDIO === '1';
1076
+ if (serveStudio) {
1077
+ // fileURLToPath, not import.meta.dirname — the latter is undefined on Node
1078
+ // < 20.11, which would silently break studio serving for consumers on older
1079
+ // Node (404 at the studio root).
1080
+ const here = dirname(fileURLToPath(import.meta.url));
1081
+ // studio-dist ships at the PACKAGE ROOT (a `files` entry), next to server/ in
1082
+ // source but two levels up from the compiled `dist/server/index.js`. Probe
1083
+ // both layouts so JS consumers on plain-node dist serve the studio too, not
1084
+ // just tsx/source runs (`../studio-dist` alone resolves to a non-existent
1085
+ // `dist/studio-dist` under dist and 404s the studio root).
1086
+ const studioDir = [join(here, '..', 'studio-dist'), join(here, '..', '..', 'studio-dist')].find(
1087
+ existsSync,
1088
+ );
1089
+ if (studioDir) {
1090
+ app.use(express.static(studioDir));
1091
+ // SPA fallback: any non-API GET returns index.html.
1092
+ app.get(/^\/(?!api\/).*/, (_req, res) => res.sendFile(join(studioDir, 'index.html')));
1093
+ } else {
1094
+ console.warn(
1095
+ `[runner] EMBERFLOW_SERVE_STUDIO=1 but no studio-dist found near ${here} — run \`npm run build:studio\``,
1096
+ );
1097
+ }
1098
+ }
1099
+
1100
+ app.listen(PORT, HOST, () => {
1101
+ console.log(`[emberflow-runner] listening on http://${HOST}:${PORT} — ${runs.nodeCount} nodes registered`);
1102
+ if (serveStudio) {
1103
+ const url = `http://${HOST}:${PORT}`;
1104
+ console.log(`[runner] studio at ${url}`);
1105
+ // Opt-in only. Under `tsx watch` the server reboots on every file change,
1106
+ // and an unconditional open spawns a browser tab per restart (a tab storm
1107
+ // during rapid edits). run.sh opens the browser once after readiness, so
1108
+ // the default is off; set EMBERFLOW_OPEN_BROWSER=1 for a one-shot launch.
1109
+ if (process.env.EMBERFLOW_OPEN_BROWSER === '1') openBrowser(url);
1110
+ }
1111
+ });