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