@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.
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/commands.test.ts +185 -0
- package/bin/commands.ts +306 -0
- package/bin/emberflow.mjs +38 -0
- package/bin/init.test.ts +165 -0
- package/bin/init.ts +297 -0
- package/bin/runtime.mjs +105 -0
- package/bin/runtime.test.ts +167 -0
- package/bin/skillTargets.test.ts +108 -0
- package/bin/skillTargets.ts +36 -0
- package/bin/skillTemplates.test.ts +37 -0
- package/bin/tsconfig.json +23 -0
- package/dist/bin/commands.d.ts +24 -0
- package/dist/bin/commands.js +252 -0
- package/dist/bin/init.d.ts +17 -0
- package/dist/bin/init.js +241 -0
- package/dist/bin/runtime.d.mts +39 -0
- package/dist/bin/runtime.mjs +96 -0
- package/dist/bin/skillTargets.d.ts +13 -0
- package/dist/bin/skillTargets.js +22 -0
- package/dist/server/agents/claudeAdapter.d.ts +8 -0
- package/dist/server/agents/claudeAdapter.js +190 -0
- package/dist/server/agents/claudeParse.d.ts +2 -0
- package/dist/server/agents/claudeParse.js +162 -0
- package/dist/server/agents/codexAdapter.d.ts +13 -0
- package/dist/server/agents/codexAdapter.js +174 -0
- package/dist/server/agents/codexParse.d.ts +2 -0
- package/dist/server/agents/codexParse.js +74 -0
- package/dist/server/agents/detect.d.ts +23 -0
- package/dist/server/agents/detect.js +35 -0
- package/dist/server/agents/gitScope.d.ts +24 -0
- package/dist/server/agents/gitScope.js +95 -0
- package/dist/server/agents/modelRejectionHint.d.ts +8 -0
- package/dist/server/agents/modelRejectionHint.js +12 -0
- package/dist/server/agents/prompt.d.ts +79 -0
- package/dist/server/agents/prompt.js +364 -0
- package/dist/server/agents/runManager.d.ts +90 -0
- package/dist/server/agents/runManager.js +259 -0
- package/dist/server/agents/types.d.ts +15 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/apiStore.d.ts +60 -0
- package/dist/server/apiStore.js +249 -0
- package/dist/server/auth/enforce.d.ts +1 -0
- package/dist/server/auth/enforce.js +6 -0
- package/dist/server/auth/types.d.ts +1 -0
- package/dist/server/auth/types.js +1 -0
- package/dist/server/auth/verifiers.d.ts +1 -0
- package/dist/server/auth/verifiers.js +4 -0
- package/dist/server/authAttach.d.ts +19 -0
- package/dist/server/authAttach.js +79 -0
- package/dist/server/cli.d.ts +7 -0
- package/dist/server/cli.js +804 -0
- package/dist/server/client.d.ts +130 -0
- package/dist/server/client.js +247 -0
- package/dist/server/environments.d.ts +103 -0
- package/dist/server/environments.js +486 -0
- package/dist/server/flowStore.d.ts +37 -0
- package/dist/server/flowStore.js +117 -0
- package/dist/server/httpOperations.d.ts +32 -0
- package/dist/server/httpOperations.js +62 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1022 -0
- package/dist/server/infrastructure.d.ts +51 -0
- package/dist/server/infrastructure.js +116 -0
- package/dist/server/login.d.ts +21 -0
- package/dist/server/login.js +44 -0
- package/dist/server/mcp.d.ts +1 -0
- package/dist/server/mcp.js +182 -0
- package/dist/server/migrateFlows.d.ts +8 -0
- package/dist/server/migrateFlows.js +25 -0
- package/dist/server/mockHandler.d.ts +15 -0
- package/dist/server/mockHandler.js +35 -0
- package/dist/server/nodesPayload.d.ts +14 -0
- package/dist/server/nodesPayload.js +14 -0
- package/dist/server/normalizeFlow.d.ts +21 -0
- package/dist/server/normalizeFlow.js +48 -0
- package/dist/server/openBrowser.d.ts +8 -0
- package/dist/server/openBrowser.js +16 -0
- package/dist/server/operationResult.d.ts +12 -0
- package/dist/server/operationResult.js +26 -0
- package/dist/server/pathGuard.d.ts +1 -0
- package/dist/server/pathGuard.js +16 -0
- package/dist/server/pathSafety.d.ts +20 -0
- package/dist/server/pathSafety.js +35 -0
- package/dist/server/projectConfig.d.ts +47 -0
- package/dist/server/projectConfig.js +61 -0
- package/dist/server/projectMode.d.ts +26 -0
- package/dist/server/projectMode.js +44 -0
- package/dist/server/redact.d.ts +6 -0
- package/dist/server/redact.js +37 -0
- package/dist/server/runRegistry.d.ts +84 -0
- package/dist/server/runRegistry.js +200 -0
- package/dist/server/runtime/RuntimeAdapter.d.ts +22 -0
- package/dist/server/runtime/RuntimeAdapter.js +1 -0
- package/dist/server/runtime/expressAdapter.d.ts +19 -0
- package/dist/server/runtime/expressAdapter.js +56 -0
- package/dist/server/scenarioTest.d.ts +14 -0
- package/dist/server/scenarioTest.js +66 -0
- package/dist/server/subflowRunner.d.ts +32 -0
- package/dist/server/subflowRunner.js +72 -0
- package/dist/server/testRunner.d.ts +70 -0
- package/dist/server/testRunner.js +151 -0
- package/dist/src/config.d.ts +3 -0
- package/dist/src/config.js +2 -0
- package/dist/src/engine/authVerify.d.ts +33 -0
- package/dist/src/engine/authVerify.js +63 -0
- package/dist/src/engine/diagnostics.d.ts +62 -0
- package/dist/src/engine/diagnostics.js +148 -0
- package/dist/src/engine/executor.d.ts +151 -0
- package/dist/src/engine/executor.js +684 -0
- package/dist/src/engine/httpError.d.ts +7 -0
- package/dist/src/engine/httpError.js +14 -0
- package/dist/src/engine/index.d.ts +11 -0
- package/dist/src/engine/index.js +11 -0
- package/dist/src/engine/output.d.ts +11 -0
- package/dist/src/engine/output.js +20 -0
- package/dist/src/engine/publish.d.ts +30 -0
- package/dist/src/engine/publish.js +95 -0
- package/dist/src/engine/registry.d.ts +34 -0
- package/dist/src/engine/registry.js +67 -0
- package/dist/src/engine/scenarios.d.ts +12 -0
- package/dist/src/engine/scenarios.js +19 -0
- package/dist/src/engine/schemaCheck.d.ts +4 -0
- package/dist/src/engine/schemaCheck.js +34 -0
- package/dist/src/engine/trace.d.ts +12 -0
- package/dist/src/engine/trace.js +12 -0
- package/dist/src/engine/types.d.ts +308 -0
- package/dist/src/engine/types.js +1 -0
- package/dist/src/engine/validation.d.ts +26 -0
- package/dist/src/engine/validation.js +318 -0
- package/dist/src/flows/anomaly-flows.d.ts +23 -0
- package/dist/src/flows/anomaly-flows.js +151 -0
- package/dist/src/flows/city-sweep-flow.d.ts +10 -0
- package/dist/src/flows/city-sweep-flow.js +127 -0
- package/dist/src/flows/login-flow.d.ts +13 -0
- package/dist/src/flows/login-flow.js +145 -0
- package/dist/src/flows/pradar-flows.d.ts +5 -0
- package/dist/src/flows/pradar-flows.js +375 -0
- package/dist/src/flows/weather-flow.d.ts +12 -0
- package/dist/src/flows/weather-flow.js +150 -0
- package/dist/src/lib/pathParams.d.ts +3 -0
- package/dist/src/lib/pathParams.js +6 -0
- package/dist/src/nodes/anomaly.d.ts +2 -0
- package/dist/src/nodes/anomaly.js +556 -0
- package/dist/src/nodes/flow-control.d.ts +7 -0
- package/dist/src/nodes/flow-control.js +270 -0
- package/dist/src/nodes/index.d.ts +3 -0
- package/dist/src/nodes/index.js +20 -0
- package/dist/src/nodes/login.d.ts +7 -0
- package/dist/src/nodes/login.js +158 -0
- package/dist/src/nodes/loops.d.ts +11 -0
- package/dist/src/nodes/loops.js +60 -0
- package/dist/src/nodes/pradar/index.d.ts +7 -0
- package/dist/src/nodes/pradar/index.js +9 -0
- package/dist/src/nodes/pradar/logic.d.ts +189 -0
- package/dist/src/nodes/pradar/logic.js +370 -0
- package/dist/src/nodes/pradar/nodes.d.ts +42 -0
- package/dist/src/nodes/pradar/nodes.js +801 -0
- package/dist/src/nodes/requireAuth.d.ts +20 -0
- package/dist/src/nodes/requireAuth.js +48 -0
- package/dist/src/nodes/response.d.ts +8 -0
- package/dist/src/nodes/response.js +33 -0
- package/dist/src/nodes/weather.d.ts +33 -0
- package/dist/src/nodes/weather.js +260 -0
- package/package.json +92 -0
- package/server/agentRoute.test.ts +157 -0
- package/server/agents/__fixtures__/fake-claude-writer.mjs +23 -0
- package/server/agents/__fixtures__/fake-claude.mjs +55 -0
- package/server/agents/__fixtures__/fake-codex-writer.mjs +27 -0
- package/server/agents/__fixtures__/fake-codex.mjs +52 -0
- package/server/agents/__fixtures__/fake-version-empty.mjs +3 -0
- package/server/agents/__fixtures__/fake-version-fail.mjs +4 -0
- package/server/agents/__fixtures__/fake-version-garbage.mjs +4 -0
- package/server/agents/__fixtures__/fake-version-good.mjs +4 -0
- package/server/agents/__fixtures__/fake-version-hang.mjs +3 -0
- package/server/agents/claudeAdapter.test.ts +127 -0
- package/server/agents/claudeAdapter.ts +212 -0
- package/server/agents/claudeParse.ts +193 -0
- package/server/agents/codexAdapter.test.ts +125 -0
- package/server/agents/codexAdapter.ts +198 -0
- package/server/agents/codexParse.test.ts +58 -0
- package/server/agents/codexParse.ts +126 -0
- package/server/agents/detect.test.ts +65 -0
- package/server/agents/detect.ts +43 -0
- package/server/agents/gitScope.test.ts +129 -0
- package/server/agents/gitScope.ts +110 -0
- package/server/agents/modelRejectionHint.test.ts +30 -0
- package/server/agents/modelRejectionHint.ts +14 -0
- package/server/agents/prompt.test.ts +613 -0
- package/server/agents/prompt.ts +527 -0
- package/server/agents/runManager.test.ts +251 -0
- package/server/agents/runManager.ts +286 -0
- package/server/agents/types.ts +17 -0
- package/server/apiMount.test.ts +591 -0
- package/server/apiStore.test.ts +324 -0
- package/server/apiStore.ts +241 -0
- package/server/auth/enforce.test.ts +61 -0
- package/server/auth/enforce.ts +6 -0
- package/server/auth/types.ts +5 -0
- package/server/auth/verifiers.test.ts +37 -0
- package/server/auth/verifiers.ts +9 -0
- package/server/authAttach.test.ts +175 -0
- package/server/authAttach.ts +99 -0
- package/server/cli.ts +847 -0
- package/server/cliAuth.test.ts +229 -0
- package/server/cliCreate.test.ts +131 -0
- package/server/cliDoctor.test.ts +326 -0
- package/server/cliTest.test.ts +735 -0
- package/server/client.ts +314 -0
- package/server/demoProject.test.ts +29 -0
- package/server/diagnosticsRoute.test.ts +169 -0
- package/server/environments.test.ts +620 -0
- package/server/environments.ts +603 -0
- package/server/environmentsRoute.test.ts +385 -0
- package/server/errorWorkflow.test.ts +205 -0
- package/server/flowStore.test.ts +84 -0
- package/server/flowStore.ts +125 -0
- package/server/httpOperations.test.ts +377 -0
- package/server/httpOperations.ts +96 -0
- package/server/index.ts +1111 -0
- package/server/infrastructure.test.ts +154 -0
- package/server/infrastructure.ts +161 -0
- package/server/infrastructureRoute.test.ts +105 -0
- package/server/login.test.ts +153 -0
- package/server/login.ts +67 -0
- package/server/mcp.ts +278 -0
- package/server/migrateFlows.test.ts +55 -0
- package/server/migrateFlows.ts +23 -0
- package/server/mockHandler.test.ts +138 -0
- package/server/mockHandler.ts +52 -0
- package/server/mockRun.test.ts +367 -0
- package/server/mockServe.test.ts +467 -0
- package/server/nodesPayload.test.ts +29 -0
- package/server/nodesPayload.ts +26 -0
- package/server/normalizeFlow.test.ts +80 -0
- package/server/normalizeFlow.ts +59 -0
- package/server/openBrowser.test.ts +20 -0
- package/server/openBrowser.ts +22 -0
- package/server/operationResult.test.ts +67 -0
- package/server/operationResult.ts +27 -0
- package/server/pathGuard.test.ts +19 -0
- package/server/pathGuard.ts +12 -0
- package/server/pathSafety.test.ts +38 -0
- package/server/pathSafety.ts +33 -0
- package/server/projectConfig.test.ts +67 -0
- package/server/projectConfig.ts +101 -0
- package/server/projectMode.test.ts +106 -0
- package/server/projectMode.ts +59 -0
- package/server/redact.test.ts +88 -0
- package/server/redact.ts +35 -0
- package/server/redactEgress.test.ts +281 -0
- package/server/runRegistry.ts +270 -0
- package/server/runsAuth.test.ts +119 -0
- package/server/runtime/RuntimeAdapter.ts +24 -0
- package/server/runtime/expressAdapter.test.ts +38 -0
- package/server/runtime/expressAdapter.ts +70 -0
- package/server/scenarioTest.test.ts +148 -0
- package/server/scenarioTest.ts +88 -0
- package/server/setupStatus.test.ts +127 -0
- package/server/subflowRunner.ts +104 -0
- package/server/testRunner.ts +216 -0
- package/server/tsconfig.json +20 -0
- package/server/workflowTestRoute.test.ts +297 -0
- package/src/App.tsx +233 -0
- package/src/components/AgentConsole.tsx +461 -0
- package/src/components/BranchRulesEditor.tsx +156 -0
- package/src/components/CreateModal.tsx +275 -0
- package/src/components/Dock.tsx +487 -0
- package/src/components/EnvironmentDialog.tsx +569 -0
- package/src/components/EnvironmentPicker.tsx +357 -0
- package/src/components/EnvironmentsDialog.tsx +144 -0
- package/src/components/ExecutionPager.tsx +67 -0
- package/src/components/InfraPanel.test.tsx +103 -0
- package/src/components/InfraPanel.tsx +171 -0
- package/src/components/Inspector.tsx +1182 -0
- package/src/components/Json.tsx +50 -0
- package/src/components/JsonModal.tsx +52 -0
- package/src/components/LogMessage.tsx +61 -0
- package/src/components/NodeRunModal.tsx +213 -0
- package/src/components/RunLogPanel.tsx +397 -0
- package/src/components/RunbookView.tsx +1085 -0
- package/src/components/Scenarios.tsx +476 -0
- package/src/components/SettingsDialog.tsx +229 -0
- package/src/components/Sidebar.tsx +436 -0
- package/src/components/StatusBar.tsx +302 -0
- package/src/components/Toolbar.tsx +663 -0
- package/src/components/WelcomeDialog.test.tsx +107 -0
- package/src/components/WelcomeDialog.tsx +412 -0
- package/src/components/agentMarkdown.test.tsx +91 -0
- package/src/components/agentMarkdown.tsx +158 -0
- package/src/components/ui/badge.tsx +31 -0
- package/src/components/ui/button.tsx +46 -0
- package/src/components/ui/combobox.tsx +114 -0
- package/src/components/ui/command.tsx +82 -0
- package/src/components/ui/dialog.tsx +73 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/popover.tsx +32 -0
- package/src/components/ui/resizable.tsx +44 -0
- package/src/components/ui/tabs.tsx +36 -0
- package/src/config.ts +4 -0
- package/src/engine/authVerify.ts +81 -0
- package/src/engine/diagnostics.test.ts +437 -0
- package/src/engine/diagnostics.ts +211 -0
- package/src/engine/executor.test.ts +1339 -0
- package/src/engine/executor.ts +840 -0
- package/src/engine/httpError.test.ts +12 -0
- package/src/engine/httpError.ts +16 -0
- package/src/engine/index.ts +11 -0
- package/src/engine/output.ts +21 -0
- package/src/engine/publish.test.ts +176 -0
- package/src/engine/publish.ts +127 -0
- package/src/engine/registry.test.ts +64 -0
- package/src/engine/registry.ts +80 -0
- package/src/engine/scenarios.test.ts +55 -0
- package/src/engine/scenarios.ts +29 -0
- package/src/engine/schemaCheck.test.ts +21 -0
- package/src/engine/schemaCheck.ts +35 -0
- package/src/engine/trace.test.ts +19 -0
- package/src/engine/trace.ts +23 -0
- package/src/engine/types.test.ts +22 -0
- package/src/engine/types.ts +316 -0
- package/src/engine/validation.test.ts +314 -0
- package/src/engine/validation.ts +354 -0
- package/src/flows/anomaly-flows.test.ts +81 -0
- package/src/flows/anomaly-flows.ts +202 -0
- package/src/flows/city-sweep-flow.ts +131 -0
- package/src/flows/flows.test.ts +30 -0
- package/src/flows/login-flow.ts +150 -0
- package/src/flows/pradar-flows.test.ts +204 -0
- package/src/flows/pradar-flows.ts +419 -0
- package/src/flows/weather-flow.ts +154 -0
- package/src/globals.css +147 -0
- package/src/lib/pathParams.test.ts +24 -0
- package/src/lib/pathParams.ts +6 -0
- package/src/lib/registerLens.test.ts +79 -0
- package/src/lib/registerLens.ts +59 -0
- package/src/lib/runbookModel.test.ts +61 -0
- package/src/lib/runbookModel.ts +290 -0
- package/src/lib/runbookProjection.test.ts +417 -0
- package/src/lib/runbookProjection.ts +254 -0
- package/src/lib/useTailAnchor.ts +53 -0
- package/src/lib/utils.ts +7 -0
- package/src/main.tsx +13 -0
- package/src/nodes/anomaly.test.ts +98 -0
- package/src/nodes/anomaly.ts +637 -0
- package/src/nodes/effects-scan.test.ts +44 -0
- package/src/nodes/flow-control.test.ts +280 -0
- package/src/nodes/flow-control.ts +305 -0
- package/src/nodes/index.ts +22 -0
- package/src/nodes/login.test.ts +81 -0
- package/src/nodes/login.ts +186 -0
- package/src/nodes/loops.ts +69 -0
- package/src/nodes/pradar/index.ts +11 -0
- package/src/nodes/pradar/logic.test.ts +288 -0
- package/src/nodes/pradar/logic.ts +582 -0
- package/src/nodes/pradar/nodes.test.ts +146 -0
- package/src/nodes/pradar/nodes.ts +955 -0
- package/src/nodes/requireAuth.test.ts +75 -0
- package/src/nodes/requireAuth.ts +55 -0
- package/src/nodes/response.test.ts +33 -0
- package/src/nodes/response.ts +39 -0
- package/src/nodes/traceDetail.test.ts +39 -0
- package/src/nodes/weather.test.ts +65 -0
- package/src/nodes/weather.ts +306 -0
- package/src/store/agentClient.test.ts +103 -0
- package/src/store/agentClient.ts +131 -0
- package/src/store/apiTree.test.ts +17 -0
- package/src/store/apiTree.ts +73 -0
- package/src/store/builderStore.test.ts +1234 -0
- package/src/store/builderStore.ts +1879 -0
- package/src/store/infraClient.ts +63 -0
- package/src/store/nodeMeta.test.ts +24 -0
- package/src/store/nodeMeta.ts +21 -0
- package/src/store/persistence.test.ts +15 -0
- package/src/store/persistence.ts +54 -0
- package/src/store/serverRunner.ts +381 -0
- package/src/store/setupClient.ts +57 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/studio-dist/assets/index-DNJwW-hM.css +1 -0
- package/studio-dist/assets/index-O26dKRjW.js +65 -0
- package/studio-dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/studio-dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/studio-dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/studio-dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/studio-dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/studio-dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/studio-dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/studio-dist/favicon.svg +1 -0
- package/studio-dist/icons.svg +24 -0
- package/studio-dist/index.html +14 -0
- package/templates/skills/emberflow-basics/SKILL.md +316 -0
- package/templates/skills/emberflow-model-process/SKILL.md +209 -0
- package/templates/skills/emberflow-new-workflow/SKILL.md +211 -0
- package/templates/skills/emberflow-review-workflow/SKILL.md +148 -0
package/server/index.ts
ADDED
|
@@ -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
|
+
});
|