@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
|
@@ -0,0 +1,1879 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import { InMemoryTraceSink, runOutput, startRun } from '../engine';
|
|
3
|
+
import type {
|
|
4
|
+
FieldMapping, FlowRun, LogLine, NodeRegistry, ScenarioDefinition, StartRunOptions,
|
|
5
|
+
SubflowResult, WorkflowDefinition, WorkflowNode, WorkflowRun,
|
|
6
|
+
} from '../engine';
|
|
7
|
+
import { createDefaultRegistry } from '../nodes';
|
|
8
|
+
import { createLoginFlow } from '../flows/login-flow';
|
|
9
|
+
import { createWeatherFlow } from '../flows/weather-flow';
|
|
10
|
+
import { createAnomalyFlows } from '../flows/anomaly-flows';
|
|
11
|
+
import { createCitySweepFlow } from '../flows/city-sweep-flow';
|
|
12
|
+
import { createPradarFlows } from '../flows/pradar-flows';
|
|
13
|
+
import { loadWorkspace, parseFlow, saveWorkspace, serializeFlow } from './persistence';
|
|
14
|
+
import { fetchNodeMeta } from './nodeMeta';
|
|
15
|
+
import {
|
|
16
|
+
cancelServerRun,
|
|
17
|
+
createOperationOnServer,
|
|
18
|
+
deleteWorkflowOnServer,
|
|
19
|
+
fetchWorkflows,
|
|
20
|
+
deleteEnvironmentSecret as deleteEnvironmentSecretOnServer,
|
|
21
|
+
listEnvironments,
|
|
22
|
+
loginEnvironment as loginEnvironmentOnServer,
|
|
23
|
+
putWorkflow,
|
|
24
|
+
runnerHealthy,
|
|
25
|
+
setEnvironmentAuth as setEnvironmentAuthOnServer,
|
|
26
|
+
setEnvironmentSecret as setEnvironmentSecretOnServer,
|
|
27
|
+
setServingMode as setServingModeOnServer,
|
|
28
|
+
startServerRun,
|
|
29
|
+
stepServerRun,
|
|
30
|
+
subscribeServerRun,
|
|
31
|
+
testWorkflow as testWorkflowOnServer,
|
|
32
|
+
} from './serverRunner';
|
|
33
|
+
import type {
|
|
34
|
+
EnvAuth, ErrorHandlerTag, EnvironmentSummary, ScenarioTestReport, ServerRunOptions,
|
|
35
|
+
} from './serverRunner';
|
|
36
|
+
import { cancelAgent, fetchAgentDiff, revertAgent, startAgent, streamAgent } from './agentClient';
|
|
37
|
+
import type { AgentEvent, AgentIntent, AgentKind, StartAgentOptions } from './agentClient';
|
|
38
|
+
import { fetchSetupStatus } from './setupClient';
|
|
39
|
+
import type { SetupStatus } from './setupClient';
|
|
40
|
+
|
|
41
|
+
export interface WorkflowSummary {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
folder?: string;
|
|
45
|
+
/** On-disk relative path (from the runner's ApiStore), when known. */
|
|
46
|
+
path?: string;
|
|
47
|
+
/** HTTP trigger method/path, when this operation is routed. */
|
|
48
|
+
http?: { method: string; path: string };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Per-operation path/http metadata, keyed by id — sourced from the runner's
|
|
52
|
+
* `/workflows` `operations` array (the store's `WorkflowDefinition`s don't
|
|
53
|
+
* carry `path`, since path is a filesystem concept, not a flow field). */
|
|
54
|
+
export type OpMeta = Map<string, { path: string; http?: { method: string; path: string } }>;
|
|
55
|
+
|
|
56
|
+
/** Drop one flow's scenario-test report (no-op when absent) — scenario edits
|
|
57
|
+
* make the last report stale. */
|
|
58
|
+
function dropReport(
|
|
59
|
+
reports: Record<string, ScenarioTestReport>,
|
|
60
|
+
flowId: string,
|
|
61
|
+
): Record<string, ScenarioTestReport> {
|
|
62
|
+
if (!(flowId in reports)) return reports;
|
|
63
|
+
const { [flowId]: _stale, ...rest } = reports;
|
|
64
|
+
return rest;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
/** A finished run plus builder-side context the engine doesn't track. */
|
|
69
|
+
export type RunHistoryEntry = WorkflowRun & {
|
|
70
|
+
scenarioName?: string;
|
|
71
|
+
/** Present only when this run was fired by an error-handler op. */
|
|
72
|
+
errorHandler?: ErrorHandlerTag;
|
|
73
|
+
/** True when the run executed against scenario mocks (Mock mode) — a mocked
|
|
74
|
+
* run must never be mistaken for a real one in history. */
|
|
75
|
+
mock?: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
interface BuilderState {
|
|
79
|
+
flow: WorkflowDefinition;
|
|
80
|
+
/** Flows other than the active one. */
|
|
81
|
+
shelf: WorkflowDefinition[];
|
|
82
|
+
/** id/name of every workflow, active first. */
|
|
83
|
+
workflows: WorkflowSummary[];
|
|
84
|
+
createWorkflow(): void;
|
|
85
|
+
switchWorkflow(id: string): void;
|
|
86
|
+
moveWorkflowToFolder(id: string, folder: string | null): void;
|
|
87
|
+
/** id -> path/http metadata sourced from the runner's `/workflows` `operations` array. */
|
|
88
|
+
opMeta: OpMeta;
|
|
89
|
+
/**
|
|
90
|
+
* Create a new operation (a routed or internal flow) at `${api}/${folder ?
|
|
91
|
+
* folder + '/' : ''}${slug(name)}`, save it to the runner at that exact
|
|
92
|
+
* path (new nested ops can't rely on PUT's existing-path lookup), refresh
|
|
93
|
+
* from the runner, and select it. The runner refuses to overwrite an
|
|
94
|
+
* existing operation at that path (409) — that failure is surfaced in the
|
|
95
|
+
* returned result rather than silently swallowed, and the workspace is not
|
|
96
|
+
* refreshed/switched on failure.
|
|
97
|
+
*/
|
|
98
|
+
createOperation(input: {
|
|
99
|
+
api: string;
|
|
100
|
+
folder?: string;
|
|
101
|
+
name: string;
|
|
102
|
+
method?: string;
|
|
103
|
+
httpPath?: string;
|
|
104
|
+
}): Promise<{ ok: boolean; error?: string }>;
|
|
105
|
+
/**
|
|
106
|
+
* Delete one or more operations by flow id (an API delete passes every op id
|
|
107
|
+
* under it). Removes each on the runner, then re-syncs — if the open flow was
|
|
108
|
+
* among them, syncFromRunner falls back to another flow automatically.
|
|
109
|
+
*/
|
|
110
|
+
deleteOperations(ids: string[]): Promise<{ ok: boolean; error?: string }>;
|
|
111
|
+
registry: NodeRegistry;
|
|
112
|
+
trace: InMemoryTraceSink;
|
|
113
|
+
run: WorkflowRun | null;
|
|
114
|
+
logs: LogLine[];
|
|
115
|
+
selectedNodeId: string | null;
|
|
116
|
+
activeRun: FlowRun | null;
|
|
117
|
+
dockTab: 'logs' | 'output' | 'infra';
|
|
118
|
+
setDockTab(tab: 'logs' | 'output' | 'infra'): void;
|
|
119
|
+
/** 'auto' resolves to the runner when it's reachable, browser otherwise. */
|
|
120
|
+
executionMode: 'auto' | 'browser' | 'server';
|
|
121
|
+
setExecutionMode(mode: 'auto' | 'browser' | 'server'): void;
|
|
122
|
+
/** Where the next run will actually execute. */
|
|
123
|
+
effectiveMode(): 'browser' | 'server';
|
|
124
|
+
/** null = not yet checked. */
|
|
125
|
+
runnerOnline: boolean | null;
|
|
126
|
+
/** True when the reachable runner is serving EMBERFLOW_MOCK responses
|
|
127
|
+
* (from /healthz's `mock` field) rather than live execution. False when
|
|
128
|
+
* offline or not mocked. */
|
|
129
|
+
runnerMock: boolean;
|
|
130
|
+
/** Flip the runner's serving mode (mock answers from scenario expects; real
|
|
131
|
+
* executes). Re-checks the runner so `runnerMock` reflects the live state. */
|
|
132
|
+
setServingMode(mode: 'real' | 'mock'): Promise<void>;
|
|
133
|
+
checkRunner(): Promise<void>;
|
|
134
|
+
/** Fetch consumer-node metadata from the runner and merge definition-only entries into the registry. */
|
|
135
|
+
syncNodeMeta(): Promise<void>;
|
|
136
|
+
|
|
137
|
+
/** Runner environments (key names only; empty when the runner is offline). */
|
|
138
|
+
environments: EnvironmentSummary[];
|
|
139
|
+
/** The runner's default environment name. */
|
|
140
|
+
environmentsDefault: string;
|
|
141
|
+
/** The environment the next run points at (persisted, like executionMode). */
|
|
142
|
+
selectedEnvironment: string;
|
|
143
|
+
/** When on, mutation nodes dry-run their writes (persisted). */
|
|
144
|
+
safeMode: boolean;
|
|
145
|
+
/** Refresh environments from the runner; clears the list when offline. */
|
|
146
|
+
fetchEnvironments(): Promise<void>;
|
|
147
|
+
/** Select an environment; protected environments force safe mode on. */
|
|
148
|
+
selectEnvironment(name: string): void;
|
|
149
|
+
/**
|
|
150
|
+
* Fire the environment's configured login request, then re-fetch
|
|
151
|
+
* environments so `environments[].auth.authenticated` reflects the
|
|
152
|
+
* runner's freshly-persisted credential. Rethrows on failure.
|
|
153
|
+
*/
|
|
154
|
+
loginEnvironment(name: string): Promise<void>;
|
|
155
|
+
/**
|
|
156
|
+
* Set a secret's value on an environment, then re-fetch environments so
|
|
157
|
+
* `environments[].secretKeys` reflects the runner's freshly-persisted key.
|
|
158
|
+
* The value transits studio→runner only over this call.
|
|
159
|
+
*/
|
|
160
|
+
setEnvironmentSecret(name: string, key: string, value: string): Promise<void>;
|
|
161
|
+
/** Delete a secret from an environment, then re-fetch environments. */
|
|
162
|
+
deleteEnvironmentSecret(name: string, key: string): Promise<void>;
|
|
163
|
+
/**
|
|
164
|
+
* Set (or, with `null`, clear) an environment's login-auth config, then
|
|
165
|
+
* re-fetch environments so `environments[].auth` reflects the change.
|
|
166
|
+
*/
|
|
167
|
+
setEnvironmentAuth(name: string, auth: EnvAuth | null): Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* Toggle safe mode. Turning it OFF on a protected environment requires
|
|
170
|
+
* `confirmName` to equal the selected environment; otherwise it is a no-op
|
|
171
|
+
* and returns false (the UI drives the typed-confirm dialog).
|
|
172
|
+
*/
|
|
173
|
+
setSafeMode(on: boolean, confirmName?: string): boolean;
|
|
174
|
+
/** Whether the workspace mirrors the runner's files or local storage. */
|
|
175
|
+
workspaceSource: 'local' | 'server';
|
|
176
|
+
/** Adopt the runner's workflow set as the workspace (one-shot on adoption). */
|
|
177
|
+
syncFromRunner(): Promise<void>;
|
|
178
|
+
/** Live server-side run id, when executing in server mode. */
|
|
179
|
+
activeServerRunId: string | null;
|
|
180
|
+
/** True while a stepped run is in progress (started via Step, not Run) — drives
|
|
181
|
+
* the toolbar's step-over affordance. Cleared when the run finishes or resets. */
|
|
182
|
+
stepMode: boolean;
|
|
183
|
+
sidebarOpen: boolean;
|
|
184
|
+
toggleSidebar(): void;
|
|
185
|
+
/** Whether the bottom dock panel is mounted (persisted). */
|
|
186
|
+
dockOpen: boolean;
|
|
187
|
+
toggleDock(): void;
|
|
188
|
+
/** Whether the right-hand inspector panel is mounted (persisted). */
|
|
189
|
+
inspectorOpen: boolean;
|
|
190
|
+
toggleInspector(): void;
|
|
191
|
+
/** Whether the Agent panel is open — a persistent surface for chatting with
|
|
192
|
+
* the agent about the open operation (starting a run auto-opens it). */
|
|
193
|
+
agentPanelOpen: boolean;
|
|
194
|
+
toggleAgentPanel(): void;
|
|
195
|
+
/** True while the agent panel is in environment-setup mode: its chat input
|
|
196
|
+
* dispatches setup-environments instead of edit-flow. Entered from the
|
|
197
|
+
* Environments dialog's call-to-action; cleared when the panel closes. */
|
|
198
|
+
agentEnvSetup: boolean;
|
|
199
|
+
beginEnvironmentSetup(): void;
|
|
200
|
+
/** Dispatch the infrastructure scout (scout-infrastructure intent): reads the
|
|
201
|
+
* project's deps/config/ORM/env-refs and writes emberflow/infrastructure.json.
|
|
202
|
+
* Shared by the Welcome checklist and the Dock's Infra tab empty state. */
|
|
203
|
+
beginInfrastructureScout(): void;
|
|
204
|
+
/** Whether the first-run Welcome/Setup checklist dialog is open. Auto-opened
|
|
205
|
+
* on a fresh project (see WelcomeDialog); always reachable from the Toolbar. */
|
|
206
|
+
welcomeOpen: boolean;
|
|
207
|
+
setWelcomeOpen(open: boolean): void;
|
|
208
|
+
/** Whether the Settings dialog is open. Lifted to the store so the Welcome
|
|
209
|
+
* checklist's "coding agent" row can deep-link into it. */
|
|
210
|
+
settingsOpen: boolean;
|
|
211
|
+
setSettingsOpen(open: boolean): void;
|
|
212
|
+
/** True while Settings was deep-linked from the Welcome checklist — the
|
|
213
|
+
* dialog then shows a back arrow that returns to the checklist. Cleared on
|
|
214
|
+
* any close so a later toolbar-opened Settings doesn't inherit it. */
|
|
215
|
+
settingsFromWelcome: boolean;
|
|
216
|
+
openSettingsFromWelcome(): void;
|
|
217
|
+
/** Latest /setup-status snapshot. WelcomeDialog refreshes it on mount and on
|
|
218
|
+
* open; shared here so the StatusBar's setup-progress chip stays in sync. */
|
|
219
|
+
setupStatus: SetupStatus | null;
|
|
220
|
+
refreshSetupStatus(): Promise<SetupStatus | null>;
|
|
221
|
+
/** Open the agent panel (no toggle). */
|
|
222
|
+
openAgentPanel(): void;
|
|
223
|
+
/** Where the run console docks (persisted). Default 'right'. */
|
|
224
|
+
/** null = no explicit choice — the register decides (technical → bottom, simple → right). */
|
|
225
|
+
consolePosition: 'right' | 'bottom' | null;
|
|
226
|
+
setConsolePosition(position: 'right' | 'bottom'): void;
|
|
227
|
+
/**
|
|
228
|
+
* Which register the runbook document speaks: 'simple' shows outcomes,
|
|
229
|
+
* 'technical' reveals trace kinds, type names and the technical outcome line.
|
|
230
|
+
* Persisted; default simple.
|
|
231
|
+
*/
|
|
232
|
+
viewRegister: 'simple' | 'technical';
|
|
233
|
+
setViewRegister(register: 'simple' | 'technical'): void;
|
|
234
|
+
/**
|
|
235
|
+
* Run whose console the user dismissed. The console is available whenever a
|
|
236
|
+
* watched run exists; dismissing hides it, reopening (or a new run) shows it.
|
|
237
|
+
*/
|
|
238
|
+
runConsoleDismissedId: string | null;
|
|
239
|
+
dismissRunConsole(): void;
|
|
240
|
+
reopenRunConsole(): void;
|
|
241
|
+
/**
|
|
242
|
+
* Run ids the user has explicitly opened the console for. In the simple
|
|
243
|
+
* register, starting a run does not summon the console — only an explicit
|
|
244
|
+
* toolbar open does, tracked here so that run's console then behaves like
|
|
245
|
+
* the technical register's for its remaining lifetime. Session-only, not
|
|
246
|
+
* persisted; the technical register ignores this set entirely (always open).
|
|
247
|
+
*/
|
|
248
|
+
runConsoleOpenedIds: Set<string>;
|
|
249
|
+
/** Finished runs, newest first, across all workflows (session-scoped). */
|
|
250
|
+
runHistory: RunHistoryEntry[];
|
|
251
|
+
/** Log lines captured for each finished run. */
|
|
252
|
+
logsByRun: Record<string, LogLine[]>;
|
|
253
|
+
recordRun(run: WorkflowRun, errorHandler?: ErrorHandlerTag): void;
|
|
254
|
+
/** Scenario that started the live run (tags its history entry). */
|
|
255
|
+
activeScenarioId: string | null;
|
|
256
|
+
/** Run the flow to the end with a named scenario's input as the run payload. */
|
|
257
|
+
runScenario(scenarioId: string): Promise<void>;
|
|
258
|
+
/** Start a stepped run with a scenario's input: first node executes, Step continues. */
|
|
259
|
+
stepScenario(scenarioId: string): Promise<void>;
|
|
260
|
+
/**
|
|
261
|
+
* Latest scenario-suite test report per flow id, from POST
|
|
262
|
+
* /workflows/:id/test (server/testRunner.ts — no expectation logic is
|
|
263
|
+
* duplicated studio-side). Studio test runs never enter runHistory: they
|
|
264
|
+
* don't go through recordRun/SSE.
|
|
265
|
+
*/
|
|
266
|
+
scenarioTestReports: Record<string, ScenarioTestReport>;
|
|
267
|
+
/** Flow id currently awaiting its test report, or null when idle. */
|
|
268
|
+
scenarioTestPending: string | null;
|
|
269
|
+
/** Run flowId's scenario suite on the runner and store the report. */
|
|
270
|
+
testWorkflow(flowId: string, environment?: string): Promise<void>;
|
|
271
|
+
addScenario(
|
|
272
|
+
name: string,
|
|
273
|
+
input: Record<string, unknown>,
|
|
274
|
+
description?: string,
|
|
275
|
+
expect?: ScenarioDefinition['expect'],
|
|
276
|
+
): void;
|
|
277
|
+
updateScenario(id: string, patch: Partial<Omit<ScenarioDefinition, 'id'>>): void;
|
|
278
|
+
removeScenario(id: string): void;
|
|
279
|
+
/** Whether the currently-active run executes against mocks — stamped at run
|
|
280
|
+
* start, copied onto its history entry when it finishes. */
|
|
281
|
+
activeRunMock: boolean;
|
|
282
|
+
/** Load a past run's snapshot (statuses + logs) into the view. */
|
|
283
|
+
viewRun(runId: string): void;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A studio-triggered coding-agent run (new-scenario/edit-node/edit-flow),
|
|
287
|
+
* streamed live from the server's /agent endpoints. null when no agent run
|
|
288
|
+
* has been started this session (or after it's been dismissed).
|
|
289
|
+
*/
|
|
290
|
+
agentRun: {
|
|
291
|
+
id: string;
|
|
292
|
+
events: AgentEvent[];
|
|
293
|
+
status: 'running' | 'done' | 'error';
|
|
294
|
+
/** The instruction the user sent — shown as the first (user) message in the panel. */
|
|
295
|
+
instruction?: string;
|
|
296
|
+
diff?: string;
|
|
297
|
+
files?: string[];
|
|
298
|
+
} | null;
|
|
299
|
+
/** Persisted agent+model picked in Settings; used as runAgent's default opts. */
|
|
300
|
+
agentChoice: AgentChoice;
|
|
301
|
+
setAgentChoice(choice: AgentChoice): void;
|
|
302
|
+
/** Start a coding-agent run and stream its events into `agentRun`. */
|
|
303
|
+
runAgent(intent: AgentIntent, opts?: StartAgentOptions): Promise<void>;
|
|
304
|
+
/**
|
|
305
|
+
* The operation currently being scaffolded by the create flow. Its stub is
|
|
306
|
+
* already selected; the canvas shows a "waiting for the agent" holding pattern
|
|
307
|
+
* until the agent finishes writing it. Cleared when the agent run completes.
|
|
308
|
+
*/
|
|
309
|
+
buildingOperationId: string | null;
|
|
310
|
+
/**
|
|
311
|
+
* Create a stub operation (name + HTTP route) at `location`, select it so the
|
|
312
|
+
* canvas shows the shell immediately, then kick an `edit-flow` agent run to
|
|
313
|
+
* build out its logic. Powers the New API / New operation modal — the user
|
|
314
|
+
* sees what they're building before the agent fills it in.
|
|
315
|
+
*/
|
|
316
|
+
createAndBuild(input: {
|
|
317
|
+
location: string;
|
|
318
|
+
name: string;
|
|
319
|
+
method: string;
|
|
320
|
+
httpPath: string;
|
|
321
|
+
goal: string;
|
|
322
|
+
}): Promise<{ ok: boolean; error?: string }>;
|
|
323
|
+
/** Revert the agent run's file changes, then reload flows from the runner. */
|
|
324
|
+
revertAgentRun(): Promise<void>;
|
|
325
|
+
/** Hide the agent console without affecting the underlying run. */
|
|
326
|
+
dismissAgentRun(): void;
|
|
327
|
+
|
|
328
|
+
addNode(type: string, position: { x: number; y: number }): void;
|
|
329
|
+
moveNode(id: string, position: { x: number; y: number }): void;
|
|
330
|
+
/** Persist a display node's canvas size (metadata.size). */
|
|
331
|
+
resizeNode(id: string, size: { width: number; height: number }): void;
|
|
332
|
+
removeNode(id: string): void;
|
|
333
|
+
removeEdge(id: string): void;
|
|
334
|
+
connect(source: string, target: string, targetHandle?: string, sourceHandle?: string): void;
|
|
335
|
+
selectNode(id: string | null): void;
|
|
336
|
+
renameFlow(name: string): void;
|
|
337
|
+
/** Replace (or clear) the active flow's `http` trigger — Inspector's HTTP
|
|
338
|
+
* section is the only writer; pass undefined to demote the flow back to
|
|
339
|
+
* an internal sub-flow. */
|
|
340
|
+
setFlowHttp(http: WorkflowDefinition['http']): void;
|
|
341
|
+
renameNode(id: string, label: string): void;
|
|
342
|
+
updateNodeConfig(id: string, key: string, value: unknown): void;
|
|
343
|
+
/** Set (or clear with undefined) a node's retry policy — engine retries the
|
|
344
|
+
* implementation call maxTries total times with waitMs between attempts. */
|
|
345
|
+
setNodeRetry(id: string, retry: WorkflowNode['retry']): void;
|
|
346
|
+
/** Seed an empty ("") default for `param` under the first Input node's
|
|
347
|
+
* `config.defaults.params`, creating nested objects immutably as needed,
|
|
348
|
+
* then saves — the same fix `doctor --fix` applies. Never overwrites an
|
|
349
|
+
* existing value; no-op if the flow has no Input node. */
|
|
350
|
+
seedParamDefault(param: string): void;
|
|
351
|
+
setInputMapping(nodeId: string, field: string, mapping: FieldMapping | null): void;
|
|
352
|
+
pinNodeOutput(nodeId: string, output: unknown): void;
|
|
353
|
+
unpinNode(nodeId: string): void;
|
|
354
|
+
/**
|
|
355
|
+
* Execute one node directly against the given input (browser-side),
|
|
356
|
+
* capturing logs and recording a trace sample. Does not touch run state.
|
|
357
|
+
*/
|
|
358
|
+
runNodeIsolated(
|
|
359
|
+
nodeId: string,
|
|
360
|
+
input: Record<string, unknown>,
|
|
361
|
+
): Promise<{ output?: unknown; error?: string; logs: LogLine[] }>;
|
|
362
|
+
|
|
363
|
+
stepRun(): Promise<void>;
|
|
364
|
+
runToEnd(): Promise<void>;
|
|
365
|
+
resetRun(): void;
|
|
366
|
+
|
|
367
|
+
saveFlow(): void;
|
|
368
|
+
loadFlow(): void;
|
|
369
|
+
exportFlow(): string;
|
|
370
|
+
importFlow(json: string): void;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const registry = createDefaultRegistry();
|
|
374
|
+
|
|
375
|
+
/** Max depth of nested Subflow calls (ancestry chain length) before a run fails. */
|
|
376
|
+
export const SUBFLOW_DEPTH_CAP = 8;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* A prefixed copy of a child-run log line, folded into the parent's stream:
|
|
380
|
+
* label becomes "<ChildFlow> › <nodeLabel>" and the console id is namespaced
|
|
381
|
+
* under the calling Subflow node so console clicks don't collide with parent
|
|
382
|
+
* ids.
|
|
383
|
+
*/
|
|
384
|
+
function prefixSubflowLog(line: LogLine, childName: string, callerNodeId: string): LogLine {
|
|
385
|
+
return {
|
|
386
|
+
...line,
|
|
387
|
+
nodeLabel: `${childName} › ${line.nodeLabel ?? ''}`,
|
|
388
|
+
nodeId: line.nodeId ? `${callerNodeId}/${line.nodeId}` : callerNodeId,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Build the cycle/depth error a subflow guard reports for a given ancestry. */
|
|
393
|
+
function subflowGuardError(ancestry: string[], workflowId: string): string | null {
|
|
394
|
+
if (ancestry.length >= SUBFLOW_DEPTH_CAP) {
|
|
395
|
+
return `subflow depth cap (${SUBFLOW_DEPTH_CAP}) exceeded`;
|
|
396
|
+
}
|
|
397
|
+
if (ancestry.includes(workflowId)) {
|
|
398
|
+
return `subflow cycle: ${[...ancestry, workflowId].join(' → ')}`;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function touched(flow: WorkflowDefinition): WorkflowDefinition {
|
|
404
|
+
return { ...flow, updatedAt: new Date().toISOString() };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function summaries(
|
|
408
|
+
active: WorkflowDefinition,
|
|
409
|
+
shelf: WorkflowDefinition[],
|
|
410
|
+
opMeta: OpMeta = new Map(),
|
|
411
|
+
): WorkflowSummary[] {
|
|
412
|
+
return [active, ...shelf].map((f) => {
|
|
413
|
+
const meta = opMeta.get(f.id);
|
|
414
|
+
return {
|
|
415
|
+
id: f.id,
|
|
416
|
+
name: f.name,
|
|
417
|
+
folder: f.folder,
|
|
418
|
+
...(meta ? { path: meta.path, http: meta.http } : {}),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** slug: lowercase, spaces -> '-', strip anything not URL-safe (alnum/hyphen). */
|
|
424
|
+
function slug(name: string): string {
|
|
425
|
+
return name
|
|
426
|
+
.trim()
|
|
427
|
+
.toLowerCase()
|
|
428
|
+
.replace(/\s+/g, '-')
|
|
429
|
+
.replace(/[^a-z0-9-]/g, '');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Derive nodeId -> pinned output from a flow's node metadata. */
|
|
433
|
+
export function pinsOf(flow: WorkflowDefinition): Record<string, unknown> {
|
|
434
|
+
const pins: Record<string, unknown> = {};
|
|
435
|
+
for (const n of flow.nodes) {
|
|
436
|
+
if (n.metadata && 'pinnedOutput' in n.metadata) pins[n.id] = n.metadata.pinnedOutput;
|
|
437
|
+
}
|
|
438
|
+
return pins;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function emptyFlow(): WorkflowDefinition {
|
|
442
|
+
const now = new Date().toISOString();
|
|
443
|
+
return {
|
|
444
|
+
id: crypto.randomUUID(),
|
|
445
|
+
name: 'Untitled Flow',
|
|
446
|
+
version: 1,
|
|
447
|
+
nodes: [],
|
|
448
|
+
edges: [],
|
|
449
|
+
createdAt: now,
|
|
450
|
+
updatedAt: now,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const workspace = loadWorkspace();
|
|
455
|
+
const exampleFlows = [
|
|
456
|
+
createWeatherFlow(),
|
|
457
|
+
createLoginFlow(),
|
|
458
|
+
...createAnomalyFlows(),
|
|
459
|
+
createCitySweepFlow(),
|
|
460
|
+
...createPradarFlows(),
|
|
461
|
+
];
|
|
462
|
+
// Saved flows win; example flows the workspace doesn't know yet are merged in.
|
|
463
|
+
const exampleById = new Map(exampleFlows.map((f) => [f.id, f]));
|
|
464
|
+
const savedFlows = (workspace?.flows ?? []).map((f) => {
|
|
465
|
+
// Backfill the preferred environment onto older saved copies of built-in
|
|
466
|
+
// flows that predate the field, without clobbering user edits.
|
|
467
|
+
if (f.environment === undefined && exampleById.get(f.id)?.environment) {
|
|
468
|
+
return { ...f, environment: exampleById.get(f.id)!.environment };
|
|
469
|
+
}
|
|
470
|
+
return f;
|
|
471
|
+
});
|
|
472
|
+
const savedIds = new Set(savedFlows.map((f) => f.id));
|
|
473
|
+
const initialFlows = [...savedFlows, ...exampleFlows.filter((f) => !savedIds.has(f.id))];
|
|
474
|
+
const initialFlow =
|
|
475
|
+
initialFlows.find((f) => f.id === workspace?.activeId) ?? initialFlows[0];
|
|
476
|
+
const initialShelf = initialFlows.filter((f) => f.id !== initialFlow.id);
|
|
477
|
+
|
|
478
|
+
const ENV_KEY = 'emberflow.environment';
|
|
479
|
+
const SAFE_KEY = 'emberflow.safeMode';
|
|
480
|
+
const SIDEBAR_KEY = 'emberflow.panel.sidebar';
|
|
481
|
+
const DOCK_KEY = 'emberflow.panel.dock';
|
|
482
|
+
const INSPECTOR_KEY = 'emberflow.panel.inspector';
|
|
483
|
+
const REGISTER_KEY = 'emberflow.view.register';
|
|
484
|
+
const CONSOLE_POSITION_KEY = 'emberflow.console.position';
|
|
485
|
+
const AGENT_CHOICE_KEY = 'emberflow.agentChoice';
|
|
486
|
+
|
|
487
|
+
function persistEnvironment(name: string): void {
|
|
488
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(ENV_KEY, name);
|
|
489
|
+
}
|
|
490
|
+
function persistSafeMode(on: boolean): void {
|
|
491
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(SAFE_KEY, on ? '1' : '0');
|
|
492
|
+
}
|
|
493
|
+
/** Panels default open: only an explicit stored '0' hides them. */
|
|
494
|
+
function loadPanel(key: string): boolean {
|
|
495
|
+
return typeof localStorage !== 'undefined' ? localStorage.getItem(key) !== '0' : true;
|
|
496
|
+
}
|
|
497
|
+
function persistPanel(key: string, open: boolean): void {
|
|
498
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(key, open ? '1' : '0');
|
|
499
|
+
}
|
|
500
|
+
const initialSelectedEnvironment =
|
|
501
|
+
typeof localStorage !== 'undefined' ? localStorage.getItem(ENV_KEY) ?? '' : '';
|
|
502
|
+
// Simple register by default: only an explicit stored 'technical' opts in.
|
|
503
|
+
// Legacy stored value 'business' (pre-rename) normalizes to 'simple'.
|
|
504
|
+
const initialViewRegister: 'simple' | 'technical' =
|
|
505
|
+
typeof localStorage !== 'undefined' && localStorage.getItem(REGISTER_KEY) === 'technical'
|
|
506
|
+
? 'technical'
|
|
507
|
+
: 'simple';
|
|
508
|
+
// null = user never chose; the effective position follows the register
|
|
509
|
+
// (technical reads best as a bottom log; simple as a right column).
|
|
510
|
+
const storedConsolePosition = typeof localStorage !== 'undefined' ? localStorage.getItem(CONSOLE_POSITION_KEY) : null;
|
|
511
|
+
const initialConsolePosition: 'right' | 'bottom' | null =
|
|
512
|
+
storedConsolePosition === 'bottom' || storedConsolePosition === 'right' ? storedConsolePosition : null;
|
|
513
|
+
|
|
514
|
+
/** The position the console actually renders at, honoring an explicit choice first. */
|
|
515
|
+
export function effectiveConsolePosition(
|
|
516
|
+
position: 'right' | 'bottom' | null,
|
|
517
|
+
register: 'simple' | 'technical',
|
|
518
|
+
): 'right' | 'bottom' {
|
|
519
|
+
return position ?? (register === 'technical' ? 'bottom' : 'right');
|
|
520
|
+
}
|
|
521
|
+
// Safe by default: only an explicit stored '0' opts out.
|
|
522
|
+
const initialSafeMode =
|
|
523
|
+
typeof localStorage !== 'undefined' ? localStorage.getItem(SAFE_KEY) !== '0' : true;
|
|
524
|
+
|
|
525
|
+
export interface AgentChoice {
|
|
526
|
+
agent?: AgentKind;
|
|
527
|
+
model?: string;
|
|
528
|
+
reasoning?: 'low' | 'medium' | 'high';
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function persistAgentChoice(choice: AgentChoice): void {
|
|
532
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(AGENT_CHOICE_KEY, JSON.stringify(choice));
|
|
533
|
+
}
|
|
534
|
+
function loadAgentChoice(): AgentChoice {
|
|
535
|
+
if (typeof localStorage === 'undefined') return {};
|
|
536
|
+
try {
|
|
537
|
+
const raw = localStorage.getItem(AGENT_CHOICE_KEY);
|
|
538
|
+
if (!raw) return {};
|
|
539
|
+
const parsed = JSON.parse(raw) as AgentChoice;
|
|
540
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
541
|
+
} catch {
|
|
542
|
+
return {};
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const initialAgentChoice = loadAgentChoice();
|
|
546
|
+
// One-shot: apply the active flow's preferred environment the first time the
|
|
547
|
+
// runner's environment list arrives (the flow opened before it was available).
|
|
548
|
+
let flowEnvSynced = false;
|
|
549
|
+
// While stepping, selection follows the step cursor so the Inspector shows the
|
|
550
|
+
// node that just executed. Cleared by run-to-end/scenario runs.
|
|
551
|
+
let stepFollow = false;
|
|
552
|
+
|
|
553
|
+
export const useBuilderStore = create<BuilderState>((set, get) => {
|
|
554
|
+
const snapshotRun = (handle: FlowRun): void => {
|
|
555
|
+
set({ run: { ...handle.run, nodeStates: { ...handle.run.nodeStates } } });
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/** Whether the selected environment is protected (writes are expensive there). */
|
|
559
|
+
const selectedEnvProtected = (): boolean => {
|
|
560
|
+
const s = get();
|
|
561
|
+
return s.environments.find((e) => e.name === s.selectedEnvironment)?.protected ?? false;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
/** Env/safety options for a server run, adding the confirm token when unsafe-on-protected. */
|
|
565
|
+
const serverRunOptions = (): ServerRunOptions => {
|
|
566
|
+
const s = get();
|
|
567
|
+
const needsConfirm = selectedEnvProtected() && !s.safeMode;
|
|
568
|
+
return {
|
|
569
|
+
...(s.selectedEnvironment ? { environment: s.selectedEnvironment } : {}),
|
|
570
|
+
safeMode: s.safeMode,
|
|
571
|
+
...(needsConfirm ? { confirm: s.selectedEnvironment } : {}),
|
|
572
|
+
};
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
/** One consciously-unsafe run at a time: revert to safe after it finishes. */
|
|
576
|
+
const revertUnsafeAfterRun = (): void => {
|
|
577
|
+
if (selectedEnvProtected() && !get().safeMode) {
|
|
578
|
+
set({ safeMode: true });
|
|
579
|
+
persistSafeMode(true);
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Auto-select the current flow's preferred environment when the runner
|
|
585
|
+
* offers one, so a prod-targeting flow doesn't silently run against local.
|
|
586
|
+
* Applied on explicit flow open and once when environments first load; not on
|
|
587
|
+
* every runner poll, so a manual override mid-session isn't fought.
|
|
588
|
+
*/
|
|
589
|
+
const applyFlowEnvironment = (): void => {
|
|
590
|
+
const s = get();
|
|
591
|
+
const preferred = s.flow.environment;
|
|
592
|
+
if (!preferred || preferred === s.selectedEnvironment) return;
|
|
593
|
+
if (!s.environments.some((e) => e.name === preferred)) return;
|
|
594
|
+
get().selectEnvironment(preferred);
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// Selection follows the step cursor: pick the most recently completed node.
|
|
598
|
+
const selectLatestCompleted = () => {
|
|
599
|
+
const states = Object.entries(get().run?.nodeStates ?? {});
|
|
600
|
+
let latest: string | null = null;
|
|
601
|
+
let latestAt = '';
|
|
602
|
+
for (const [id, st] of states) {
|
|
603
|
+
if ((st.status === 'succeeded' || st.status === 'failed') && st.completedAt && st.completedAt >= latestAt) {
|
|
604
|
+
latestAt = st.completedAt;
|
|
605
|
+
latest = id;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (latest) set({ selectedNodeId: latest });
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Browser-side Subflow runner. Resolves the child workflow across the active
|
|
613
|
+
* flow + shelf, runs it to completion on the same registry (browser env:
|
|
614
|
+
* empty secrets/vars, honouring safe mode), forwards its logs into the parent
|
|
615
|
+
* stream (prefixed), and returns its collected Result output. The ancestry
|
|
616
|
+
* chain — threaded through this closure, not global state — enforces the
|
|
617
|
+
* depth cap and catches A→B→A cycles.
|
|
618
|
+
*/
|
|
619
|
+
const makeSubflowRunner = (
|
|
620
|
+
ancestry: string[],
|
|
621
|
+
): NonNullable<StartRunOptions['subflowRunner']> => {
|
|
622
|
+
return async (workflowId, input, callerNodeId): Promise<SubflowResult> => {
|
|
623
|
+
const guard = subflowGuardError(ancestry, workflowId);
|
|
624
|
+
if (guard) return { status: 'failed', error: guard };
|
|
625
|
+
const s = get();
|
|
626
|
+
const childFlow = [s.flow, ...s.shelf].find((f) => f.id === workflowId);
|
|
627
|
+
if (!childFlow) return { status: 'failed', error: `Unknown workflow: ${workflowId}` };
|
|
628
|
+
try {
|
|
629
|
+
const handle = startRun({
|
|
630
|
+
flow: childFlow,
|
|
631
|
+
registry: s.registry,
|
|
632
|
+
trace: s.trace,
|
|
633
|
+
pins: pinsOf(childFlow),
|
|
634
|
+
input,
|
|
635
|
+
environment: 'browser',
|
|
636
|
+
safeMode: s.safeMode,
|
|
637
|
+
subflowRunner: makeSubflowRunner([...ancestry, workflowId]),
|
|
638
|
+
events: {
|
|
639
|
+
onLog: (line) =>
|
|
640
|
+
set((st) => ({
|
|
641
|
+
logs: [...st.logs, prefixSubflowLog(line, childFlow.name, callerNodeId)],
|
|
642
|
+
})),
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
const childRun = await handle.runToEnd();
|
|
646
|
+
if (childRun.status !== 'succeeded') {
|
|
647
|
+
return { status: 'failed', error: `subflow "${childFlow.name}" ${childRun.status}` };
|
|
648
|
+
}
|
|
649
|
+
return { status: 'succeeded', output: runOutput(childRun, childFlow) };
|
|
650
|
+
} catch (err) {
|
|
651
|
+
return { status: 'failed', error: err instanceof Error ? err.message : String(err) };
|
|
652
|
+
}
|
|
653
|
+
};
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const ensureRun = (
|
|
657
|
+
input?: Record<string, unknown>,
|
|
658
|
+
scenarioMocks?: Record<string, unknown>,
|
|
659
|
+
): FlowRun | null => {
|
|
660
|
+
const existing = get().activeRun;
|
|
661
|
+
if (existing && existing.run.status === 'running') return existing;
|
|
662
|
+
const mockRun = get().runnerMock;
|
|
663
|
+
set({ activeRunMock: mockRun });
|
|
664
|
+
try {
|
|
665
|
+
const handle = startRun({
|
|
666
|
+
flow: get().flow,
|
|
667
|
+
registry: get().registry,
|
|
668
|
+
trace: get().trace,
|
|
669
|
+
pins: pinsOf(get().flow),
|
|
670
|
+
input,
|
|
671
|
+
// Browser-engine runs honour Mock mode the same way the runner does:
|
|
672
|
+
// op-level mocks under any scenario's own map, fail-loud at infra.
|
|
673
|
+
...(mockRun ? { mockRun: true, mocks: { ...get().flow.mocks, ...scenarioMocks } } : {}),
|
|
674
|
+
// Browser execution has no env file: it runs as environment 'browser'
|
|
675
|
+
// (empty secrets/vars) but still honours safe mode over HTTP.
|
|
676
|
+
environment: 'browser',
|
|
677
|
+
safeMode: get().safeMode,
|
|
678
|
+
subflowRunner: makeSubflowRunner([get().flow.id]),
|
|
679
|
+
events: {
|
|
680
|
+
onNodeStateChange: () => snapshotRun(handle),
|
|
681
|
+
onLog: (line) => set((s) => ({ logs: [...s.logs, line] })),
|
|
682
|
+
onRunFinished: () => {
|
|
683
|
+
snapshotRun(handle);
|
|
684
|
+
get().recordRun({ ...handle.run, nodeStates: { ...handle.run.nodeStates } });
|
|
685
|
+
set({ activeRun: null });
|
|
686
|
+
revertUnsafeAfterRun();
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
set({ activeRun: handle, logs: [] });
|
|
691
|
+
snapshotRun(handle);
|
|
692
|
+
return handle;
|
|
693
|
+
} catch (err) {
|
|
694
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
695
|
+
set((s) => ({
|
|
696
|
+
logs: [
|
|
697
|
+
...s.logs,
|
|
698
|
+
{
|
|
699
|
+
timestamp: new Date().toISOString(),
|
|
700
|
+
level: 'error',
|
|
701
|
+
runId: 'validation',
|
|
702
|
+
message,
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
}));
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const reportRunError = (message: string): void => {
|
|
711
|
+
set((s) => ({
|
|
712
|
+
logs: [
|
|
713
|
+
...s.logs,
|
|
714
|
+
{ timestamp: new Date().toISOString(), level: 'error', runId: 'runner', message },
|
|
715
|
+
],
|
|
716
|
+
}));
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
/** Start a server-side run (if none live) and wire its SSE events into the store. */
|
|
720
|
+
const ensureServerRun = async (
|
|
721
|
+
mode: 'run' | 'step',
|
|
722
|
+
input?: Record<string, unknown>,
|
|
723
|
+
scenarioName?: string,
|
|
724
|
+
): Promise<string | null> => {
|
|
725
|
+
const existing = get().activeServerRunId;
|
|
726
|
+
if (existing) return existing;
|
|
727
|
+
const flow = get().flow;
|
|
728
|
+
// Remember whether this run executed against mocks — the history entry is
|
|
729
|
+
// tagged so a mocked run is never mistaken for a real one later.
|
|
730
|
+
set({ activeRunMock: get().runnerMock });
|
|
731
|
+
try {
|
|
732
|
+
const runId = await startServerRun(flow, mode, pinsOf(flow), input, {
|
|
733
|
+
...serverRunOptions(),
|
|
734
|
+
...(scenarioName ? { scenarioName } : {}),
|
|
735
|
+
});
|
|
736
|
+
const initial: WorkflowRun = {
|
|
737
|
+
id: runId,
|
|
738
|
+
workflowId: flow.id,
|
|
739
|
+
status: 'running',
|
|
740
|
+
startedAt: new Date().toISOString(),
|
|
741
|
+
nodeStates: Object.fromEntries(flow.nodes.map((n) => [n.id, { status: 'queued' as const }])),
|
|
742
|
+
};
|
|
743
|
+
set({ activeServerRunId: runId, run: initial, logs: [] });
|
|
744
|
+
subscribeServerRun(runId, {
|
|
745
|
+
onNodeState: (nodeId, state) => {
|
|
746
|
+
set((s) => {
|
|
747
|
+
if (!s.run || s.run.id !== runId) return {};
|
|
748
|
+
const follow =
|
|
749
|
+
stepFollow && (state.status === 'succeeded' || state.status === 'failed')
|
|
750
|
+
? { selectedNodeId: nodeId }
|
|
751
|
+
: {};
|
|
752
|
+
return { run: { ...s.run, nodeStates: { ...s.run.nodeStates, [nodeId]: state } }, ...follow };
|
|
753
|
+
});
|
|
754
|
+
},
|
|
755
|
+
onLog: (line) => set((s) => ({ logs: [...s.logs, line] })),
|
|
756
|
+
onFinished: (run, errorHandler) => {
|
|
757
|
+
set({ run });
|
|
758
|
+
get().recordRun(run, errorHandler);
|
|
759
|
+
set({ activeServerRunId: null });
|
|
760
|
+
revertUnsafeAfterRun();
|
|
761
|
+
},
|
|
762
|
+
onError: (message) => {
|
|
763
|
+
reportRunError(message);
|
|
764
|
+
set({ activeServerRunId: null });
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
return runId;
|
|
768
|
+
} catch (err) {
|
|
769
|
+
reportRunError(err instanceof Error ? err.message : String(err));
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
flow: initialFlow,
|
|
776
|
+
shelf: initialShelf,
|
|
777
|
+
workflows: summaries(initialFlow, initialShelf),
|
|
778
|
+
opMeta: new Map(),
|
|
779
|
+
registry,
|
|
780
|
+
trace: new InMemoryTraceSink(),
|
|
781
|
+
run: null,
|
|
782
|
+
logs: [],
|
|
783
|
+
selectedNodeId: null,
|
|
784
|
+
activeRun: null,
|
|
785
|
+
dockTab: 'logs',
|
|
786
|
+
sidebarOpen: loadPanel(SIDEBAR_KEY),
|
|
787
|
+
dockOpen: loadPanel(DOCK_KEY),
|
|
788
|
+
inspectorOpen: loadPanel(INSPECTOR_KEY),
|
|
789
|
+
agentPanelOpen: false,
|
|
790
|
+
agentEnvSetup: false,
|
|
791
|
+
welcomeOpen: false,
|
|
792
|
+
settingsOpen: false,
|
|
793
|
+
settingsFromWelcome: false,
|
|
794
|
+
setupStatus: null,
|
|
795
|
+
viewRegister: initialViewRegister,
|
|
796
|
+
consolePosition: initialConsolePosition,
|
|
797
|
+
runConsoleDismissedId: null,
|
|
798
|
+
runConsoleOpenedIds: new Set(),
|
|
799
|
+
runHistory: [],
|
|
800
|
+
logsByRun: {},
|
|
801
|
+
activeScenarioId: null,
|
|
802
|
+
scenarioTestReports: {},
|
|
803
|
+
activeRunMock: false,
|
|
804
|
+
scenarioTestPending: null,
|
|
805
|
+
agentRun: null,
|
|
806
|
+
buildingOperationId: null,
|
|
807
|
+
agentChoice: initialAgentChoice,
|
|
808
|
+
setAgentChoice(choice) {
|
|
809
|
+
set({ agentChoice: choice });
|
|
810
|
+
persistAgentChoice(choice);
|
|
811
|
+
},
|
|
812
|
+
executionMode: (() => {
|
|
813
|
+
const saved =
|
|
814
|
+
typeof localStorage !== 'undefined' ? localStorage.getItem('emberflow.mode') : null;
|
|
815
|
+
return saved === 'browser' || saved === 'server' ? saved : 'auto';
|
|
816
|
+
})(),
|
|
817
|
+
runnerOnline: null,
|
|
818
|
+
runnerMock: false,
|
|
819
|
+
workspaceSource: 'local',
|
|
820
|
+
activeServerRunId: null,
|
|
821
|
+
stepMode: false,
|
|
822
|
+
environments: [],
|
|
823
|
+
environmentsDefault: '',
|
|
824
|
+
selectedEnvironment: initialSelectedEnvironment,
|
|
825
|
+
safeMode: initialSafeMode,
|
|
826
|
+
|
|
827
|
+
setDockTab(tab) {
|
|
828
|
+
set({ dockTab: tab });
|
|
829
|
+
},
|
|
830
|
+
|
|
831
|
+
async fetchEnvironments() {
|
|
832
|
+
const list = await listEnvironments();
|
|
833
|
+
if (!list) {
|
|
834
|
+
set({ environments: [], environmentsDefault: '' });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// A runner with no environments file synthesizes a bare "local" env —
|
|
838
|
+
// that's "no environments yet" to the user, not a real choice: hide it
|
|
839
|
+
// so the dropdown shows the zero-environment onboarding state. Also
|
|
840
|
+
// drop any selection persisted from another project — a phantom "dev"
|
|
841
|
+
// chip in the status bar would claim an environment that doesn't exist.
|
|
842
|
+
if (list.configured === false) {
|
|
843
|
+
set({ environments: [], environmentsDefault: '', selectedEnvironment: '' });
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
set({ environments: list.environments, environmentsDefault: list.defaultEnvironment });
|
|
847
|
+
// Reconcile the persisted selection against what the runner actually offers.
|
|
848
|
+
const s = get();
|
|
849
|
+
const known = list.environments.some((e) => e.name === s.selectedEnvironment);
|
|
850
|
+
if (!known || !s.selectedEnvironment) {
|
|
851
|
+
get().selectEnvironment(list.defaultEnvironment);
|
|
852
|
+
} else {
|
|
853
|
+
// A protected selection always implies safe mode on load.
|
|
854
|
+
const env = list.environments.find((e) => e.name === s.selectedEnvironment);
|
|
855
|
+
if (env?.protected && !s.safeMode) {
|
|
856
|
+
set({ safeMode: true });
|
|
857
|
+
persistSafeMode(true);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
// First time the environments land, honor the active flow's preference
|
|
861
|
+
// (the flow was opened before the runner list was available).
|
|
862
|
+
if (!flowEnvSynced) {
|
|
863
|
+
flowEnvSynced = true;
|
|
864
|
+
applyFlowEnvironment();
|
|
865
|
+
}
|
|
866
|
+
},
|
|
867
|
+
|
|
868
|
+
selectEnvironment(name) {
|
|
869
|
+
const env = get().environments.find((e) => e.name === name);
|
|
870
|
+
set({ selectedEnvironment: name });
|
|
871
|
+
persistEnvironment(name);
|
|
872
|
+
// Protected environments force safe mode on selection.
|
|
873
|
+
if (env?.protected) {
|
|
874
|
+
set({ safeMode: true });
|
|
875
|
+
persistSafeMode(true);
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
|
|
879
|
+
async loginEnvironment(name) {
|
|
880
|
+
await loginEnvironmentOnServer(name);
|
|
881
|
+
await get().fetchEnvironments();
|
|
882
|
+
},
|
|
883
|
+
|
|
884
|
+
async setEnvironmentSecret(name, key, value) {
|
|
885
|
+
await setEnvironmentSecretOnServer(name, key, value);
|
|
886
|
+
await get().fetchEnvironments();
|
|
887
|
+
},
|
|
888
|
+
|
|
889
|
+
async deleteEnvironmentSecret(name, key) {
|
|
890
|
+
await deleteEnvironmentSecretOnServer(name, key);
|
|
891
|
+
await get().fetchEnvironments();
|
|
892
|
+
},
|
|
893
|
+
|
|
894
|
+
async setEnvironmentAuth(name, auth) {
|
|
895
|
+
await setEnvironmentAuthOnServer(name, auth);
|
|
896
|
+
await get().fetchEnvironments();
|
|
897
|
+
},
|
|
898
|
+
|
|
899
|
+
setSafeMode(on, confirmName) {
|
|
900
|
+
// Disabling safe mode on a protected env demands the typed-back env name.
|
|
901
|
+
if (!on && selectedEnvProtected() && confirmName !== get().selectedEnvironment) {
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
set({ safeMode: on });
|
|
905
|
+
persistSafeMode(on);
|
|
906
|
+
return true;
|
|
907
|
+
},
|
|
908
|
+
|
|
909
|
+
setExecutionMode(mode) {
|
|
910
|
+
set({ executionMode: mode });
|
|
911
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem('emberflow.mode', mode);
|
|
912
|
+
if (mode !== 'browser') void get().checkRunner();
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
effectiveMode() {
|
|
916
|
+
const s = get();
|
|
917
|
+
if (s.executionMode === 'auto') return s.runnerOnline ? 'server' : 'browser';
|
|
918
|
+
return s.executionMode;
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
async setServingMode(mode) {
|
|
922
|
+
await setServingModeOnServer(mode);
|
|
923
|
+
await get().checkRunner();
|
|
924
|
+
},
|
|
925
|
+
|
|
926
|
+
async checkRunner() {
|
|
927
|
+
const was = get().runnerOnline;
|
|
928
|
+
const { online, mock } = await runnerHealthy();
|
|
929
|
+
set({ runnerOnline: online, runnerMock: mock });
|
|
930
|
+
// Environments live on the runner; refresh alongside the health check.
|
|
931
|
+
if (online) await get().fetchEnvironments();
|
|
932
|
+
else set({ environments: [], environmentsDefault: '' });
|
|
933
|
+
// One-shot adoption: when the runner first comes online and we're still
|
|
934
|
+
// showing the local workspace, replace it with the runner's files.
|
|
935
|
+
if (online && was !== true && get().workspaceSource === 'local') {
|
|
936
|
+
await get().syncFromRunner();
|
|
937
|
+
}
|
|
938
|
+
},
|
|
939
|
+
|
|
940
|
+
async syncNodeMeta() {
|
|
941
|
+
const meta = await fetchNodeMeta();
|
|
942
|
+
if (meta.length === 0) return;
|
|
943
|
+
const registry = get().registry;
|
|
944
|
+
for (const m of meta) {
|
|
945
|
+
const { source, ...definition } = m;
|
|
946
|
+
registry.registerDefinition(definition, source);
|
|
947
|
+
}
|
|
948
|
+
// Force palette/inspector consumers to re-read the registry: a new
|
|
949
|
+
// top-level reference (Zustand uses Object.is) sharing the same node data.
|
|
950
|
+
set({ registry: registry.withSameNodes() });
|
|
951
|
+
},
|
|
952
|
+
|
|
953
|
+
async syncFromRunner() {
|
|
954
|
+
const payload = await fetchWorkflows();
|
|
955
|
+
if (!payload || payload.flows.length === 0) return;
|
|
956
|
+
const { flows, operations } = payload;
|
|
957
|
+
const opMeta: OpMeta = new Map(
|
|
958
|
+
operations.map((op) => [op.id, { path: op.path, http: op.http }]),
|
|
959
|
+
);
|
|
960
|
+
const current = get().flow.id;
|
|
961
|
+
const flow = flows.find((f) => f.id === current) ?? flows[0];
|
|
962
|
+
const shelf = flows.filter((f) => f.id !== flow.id);
|
|
963
|
+
hydrating(() =>
|
|
964
|
+
set({
|
|
965
|
+
flow,
|
|
966
|
+
shelf,
|
|
967
|
+
opMeta,
|
|
968
|
+
workflows: summaries(flow, shelf, opMeta),
|
|
969
|
+
workspaceSource: 'server',
|
|
970
|
+
run: null,
|
|
971
|
+
logs: [],
|
|
972
|
+
activeRun: null,
|
|
973
|
+
selectedNodeId: null,
|
|
974
|
+
}),
|
|
975
|
+
);
|
|
976
|
+
},
|
|
977
|
+
|
|
978
|
+
async createOperation(input) {
|
|
979
|
+
const { api, folder, name, method, httpPath } = input;
|
|
980
|
+
const id = `${api}/${folder ? `${folder}/` : ''}${slug(name)}`;
|
|
981
|
+
const now = new Date().toISOString();
|
|
982
|
+
const inputNode: WorkflowNode = {
|
|
983
|
+
id: 'input',
|
|
984
|
+
type: 'Input',
|
|
985
|
+
label: 'Input',
|
|
986
|
+
position: { x: 0, y: 0 },
|
|
987
|
+
config: {},
|
|
988
|
+
};
|
|
989
|
+
const nodes: WorkflowNode[] = [inputNode];
|
|
990
|
+
const edges: WorkflowDefinition['edges'] = [];
|
|
991
|
+
if (method) {
|
|
992
|
+
const responseNode: WorkflowNode = {
|
|
993
|
+
id: 'response',
|
|
994
|
+
type: 'Response',
|
|
995
|
+
label: 'Response',
|
|
996
|
+
position: { x: 320, y: 0 },
|
|
997
|
+
config: {},
|
|
998
|
+
};
|
|
999
|
+
nodes.push(responseNode);
|
|
1000
|
+
edges.push({ id: crypto.randomUUID(), source: 'input', target: 'response' });
|
|
1001
|
+
}
|
|
1002
|
+
const flow: WorkflowDefinition = {
|
|
1003
|
+
id,
|
|
1004
|
+
name,
|
|
1005
|
+
version: 1,
|
|
1006
|
+
nodes,
|
|
1007
|
+
edges,
|
|
1008
|
+
createdAt: now,
|
|
1009
|
+
updatedAt: now,
|
|
1010
|
+
...(method && httpPath ? { http: { method, path: httpPath } } : {}),
|
|
1011
|
+
};
|
|
1012
|
+
const result = await createOperationOnServer(flow, id);
|
|
1013
|
+
if (!result.ok) return result;
|
|
1014
|
+
await get().syncFromRunner();
|
|
1015
|
+
get().switchWorkflow(id);
|
|
1016
|
+
return result;
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
async deleteOperations(ids) {
|
|
1020
|
+
if (ids.length === 0) return { ok: true };
|
|
1021
|
+
for (const id of ids) {
|
|
1022
|
+
const result = await deleteWorkflowOnServer(id);
|
|
1023
|
+
if (!result.ok) return result;
|
|
1024
|
+
}
|
|
1025
|
+
// Re-sync from the runner; if the open flow was deleted, syncFromRunner
|
|
1026
|
+
// falls back to another flow (or leaves state as-is if none remain).
|
|
1027
|
+
await get().syncFromRunner();
|
|
1028
|
+
return { ok: true };
|
|
1029
|
+
},
|
|
1030
|
+
|
|
1031
|
+
async createAndBuild(input) {
|
|
1032
|
+
const { location, name, method, httpPath, goal } = input;
|
|
1033
|
+
const parts = location.split('/').filter(Boolean);
|
|
1034
|
+
const api = parts[0] || 'default';
|
|
1035
|
+
const folder = parts.slice(1).join('/') || undefined;
|
|
1036
|
+
// 1) Create the stub op (Input → Response + http trigger) and select it,
|
|
1037
|
+
// so the canvas shows the shell of what's being built right away.
|
|
1038
|
+
const created = await get().createOperation({ api, folder, name, method, httpPath });
|
|
1039
|
+
if (!created.ok) return created;
|
|
1040
|
+
const stubId = `${api}/${folder ? `${folder}/` : ''}${slug(name)}`;
|
|
1041
|
+
// 2) Flag the holding pattern, then hand the stub to the agent. Send the
|
|
1042
|
+
// user's goal verbatim (it shows as their message in the panel); the
|
|
1043
|
+
// edit-flow prompt already tells the agent to build the operation out.
|
|
1044
|
+
set({ buildingOperationId: stubId });
|
|
1045
|
+
// scaffold: this op is a placeholder-named shell — the agent's first step
|
|
1046
|
+
// is to rename it properly, then build it out (see the edit-flow prompt).
|
|
1047
|
+
void get().runAgent({ action: 'edit-flow', flowId: stubId, instruction: goal, scaffold: true });
|
|
1048
|
+
return { ok: true };
|
|
1049
|
+
},
|
|
1050
|
+
|
|
1051
|
+
async runAgent(intent, opts) {
|
|
1052
|
+
const effectiveOpts = opts ?? get().agentChoice;
|
|
1053
|
+
// If a build is in flight, remember the op being built + the op ids that
|
|
1054
|
+
// existed before the run. If the agent renames the op (the id changes),
|
|
1055
|
+
// syncFromRunner can't find the old id and falls back to flows[0], leaving
|
|
1056
|
+
// the built op unselected — so we re-select the newly-added op on finish.
|
|
1057
|
+
const buildingId = get().buildingOperationId;
|
|
1058
|
+
const priorIds = buildingId
|
|
1059
|
+
? new Set([get().flow.id, ...get().shelf.map((f) => f.id)])
|
|
1060
|
+
: null;
|
|
1061
|
+
let agentRunId: string;
|
|
1062
|
+
try {
|
|
1063
|
+
agentRunId = await startAgent(intent, effectiveOpts);
|
|
1064
|
+
} catch (err) {
|
|
1065
|
+
set({
|
|
1066
|
+
agentRun: {
|
|
1067
|
+
id: '',
|
|
1068
|
+
events: [{ type: 'error', text: err instanceof Error ? err.message : String(err) }],
|
|
1069
|
+
status: 'error',
|
|
1070
|
+
},
|
|
1071
|
+
// Open the panel so the failure is visible — without this, a start
|
|
1072
|
+
// error (e.g. no agent CLI on PATH) lands in a closed panel and the
|
|
1073
|
+
// click appears to do nothing.
|
|
1074
|
+
agentPanelOpen: true,
|
|
1075
|
+
});
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
set({
|
|
1080
|
+
agentRun: { id: agentRunId, events: [], status: 'running', instruction: intent.instruction },
|
|
1081
|
+
agentPanelOpen: true,
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// Live canvas: while the agent works, poll the runner (GET /workflows reads
|
|
1085
|
+
// disk fresh) so the open operation reflects the agent's edits AS THEY LAND
|
|
1086
|
+
// — not only at the end. Preserves run/logs/selection (unlike syncFromRunner)
|
|
1087
|
+
// and follows a mid-run rename. Partial reads throw and are swallowed; the
|
|
1088
|
+
// next tick recovers.
|
|
1089
|
+
let livePoll: ReturnType<typeof setInterval> | undefined;
|
|
1090
|
+
const pollOnce = async (): Promise<void> => {
|
|
1091
|
+
try {
|
|
1092
|
+
const payload = await fetchWorkflows();
|
|
1093
|
+
if (!payload || payload.flows.length === 0) return;
|
|
1094
|
+
const { flows, operations } = payload;
|
|
1095
|
+
const opMeta: OpMeta = new Map(operations.map((op) => [op.id, { path: op.path, http: op.http }]));
|
|
1096
|
+
const currentId = get().flow.id;
|
|
1097
|
+
let flow = flows.find((f) => f.id === currentId);
|
|
1098
|
+
// Rename mid-run: the open id is gone — follow the op that's new since
|
|
1099
|
+
// the run started (the rename target).
|
|
1100
|
+
if (!flow && priorIds) flow = flows.find((f) => !priorIds.has(f.id));
|
|
1101
|
+
if (!flow) return;
|
|
1102
|
+
const shelf = flows.filter((f) => f.id !== flow.id);
|
|
1103
|
+
hydrating(() =>
|
|
1104
|
+
set({ flow, shelf, opMeta, workflows: summaries(flow, shelf, opMeta) }),
|
|
1105
|
+
);
|
|
1106
|
+
// Once real nodes appear (beyond the Input→terminus stub), drop the
|
|
1107
|
+
// holding pattern so the live canvas shows instead of "waiting…".
|
|
1108
|
+
if (get().buildingOperationId && flow.nodes.length > 2) set({ buildingOperationId: null });
|
|
1109
|
+
} catch {
|
|
1110
|
+
// transient (partial read / runner blip) — next tick recovers
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
const stopPoll = (): void => {
|
|
1114
|
+
if (livePoll) {
|
|
1115
|
+
clearInterval(livePoll);
|
|
1116
|
+
livePoll = undefined;
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
livePoll = setInterval(() => void pollOnce(), 2000);
|
|
1120
|
+
|
|
1121
|
+
const finish = async (status: 'done' | 'error') => {
|
|
1122
|
+
stopPoll();
|
|
1123
|
+
set((s) => (s.agentRun && s.agentRun.id === agentRunId ? { agentRun: { ...s.agentRun, status } } : {}));
|
|
1124
|
+
// The build (if any) is over — drop the canvas holding pattern.
|
|
1125
|
+
set({ buildingOperationId: null });
|
|
1126
|
+
if (status === 'done') {
|
|
1127
|
+
// Reload the flow the agent just edited so the canvas shows the change
|
|
1128
|
+
// live — the whole point is watching your instruction take effect.
|
|
1129
|
+
// Also refresh runner health/environments immediately: setup-auth and
|
|
1130
|
+
// setup-environments runs change the env list, and waiting out the
|
|
1131
|
+
// 10s poll makes the dropdown look broken right after "done".
|
|
1132
|
+
void get().checkRunner();
|
|
1133
|
+
try {
|
|
1134
|
+
await get().syncFromRunner();
|
|
1135
|
+
// Keep the built op selected even if the agent renamed it: if the
|
|
1136
|
+
// id we were building is gone, select whichever op is new since the
|
|
1137
|
+
// run started (the rename target).
|
|
1138
|
+
if (buildingId && get().flow.id !== buildingId) {
|
|
1139
|
+
const added = [get().flow, ...get().shelf].find((f) => priorIds && !priorIds.has(f.id));
|
|
1140
|
+
if (added) get().switchWorkflow(added.id);
|
|
1141
|
+
}
|
|
1142
|
+
} catch {
|
|
1143
|
+
// reload best-effort — the diff below still shows what changed
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
const { diff, files } = await fetchAgentDiff(agentRunId);
|
|
1147
|
+
set((s) => (s.agentRun && s.agentRun.id === agentRunId ? { agentRun: { ...s.agentRun, diff, files } } : {}));
|
|
1148
|
+
} catch {
|
|
1149
|
+
// Diff fetch failure isn't fatal — the console still shows the event stream.
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
|
|
1154
|
+
streamAgent(agentRunId, (event) => {
|
|
1155
|
+
set((s) =>
|
|
1156
|
+
s.agentRun && s.agentRun.id === agentRunId
|
|
1157
|
+
? { agentRun: { ...s.agentRun, events: [...s.agentRun.events, event] } }
|
|
1158
|
+
: {},
|
|
1159
|
+
);
|
|
1160
|
+
if (event.type === 'done') void finish('done');
|
|
1161
|
+
else if (event.type === 'error') void finish('error');
|
|
1162
|
+
});
|
|
1163
|
+
},
|
|
1164
|
+
|
|
1165
|
+
async revertAgentRun() {
|
|
1166
|
+
const agentRun = get().agentRun;
|
|
1167
|
+
if (!agentRun) return;
|
|
1168
|
+
await revertAgent(agentRun.id);
|
|
1169
|
+
// The diff is now stale (files restored) — clear it so the panel stops
|
|
1170
|
+
// showing the old diff + a live Revert button.
|
|
1171
|
+
set((s) =>
|
|
1172
|
+
s.agentRun && s.agentRun.id === agentRun.id
|
|
1173
|
+
? { agentRun: { ...s.agentRun, diff: undefined, files: undefined } }
|
|
1174
|
+
: {},
|
|
1175
|
+
);
|
|
1176
|
+
await get().syncFromRunner();
|
|
1177
|
+
},
|
|
1178
|
+
|
|
1179
|
+
dismissAgentRun() {
|
|
1180
|
+
const agentRun = get().agentRun;
|
|
1181
|
+
if (agentRun && agentRun.status === 'running') void cancelAgent(agentRun.id);
|
|
1182
|
+
set({ agentRun: null });
|
|
1183
|
+
},
|
|
1184
|
+
|
|
1185
|
+
toggleSidebar() {
|
|
1186
|
+
set((s) => {
|
|
1187
|
+
const open = !s.sidebarOpen;
|
|
1188
|
+
persistPanel(SIDEBAR_KEY, open);
|
|
1189
|
+
return { sidebarOpen: open };
|
|
1190
|
+
});
|
|
1191
|
+
},
|
|
1192
|
+
|
|
1193
|
+
toggleDock() {
|
|
1194
|
+
set((s) => {
|
|
1195
|
+
const open = !s.dockOpen;
|
|
1196
|
+
persistPanel(DOCK_KEY, open);
|
|
1197
|
+
return { dockOpen: open };
|
|
1198
|
+
});
|
|
1199
|
+
},
|
|
1200
|
+
|
|
1201
|
+
toggleInspector() {
|
|
1202
|
+
set((s) => {
|
|
1203
|
+
const open = !s.inspectorOpen;
|
|
1204
|
+
persistPanel(INSPECTOR_KEY, open);
|
|
1205
|
+
return { inspectorOpen: open };
|
|
1206
|
+
});
|
|
1207
|
+
},
|
|
1208
|
+
|
|
1209
|
+
beginEnvironmentSetup() {
|
|
1210
|
+
set({ agentPanelOpen: true, agentEnvSetup: true });
|
|
1211
|
+
},
|
|
1212
|
+
|
|
1213
|
+
beginInfrastructureScout() {
|
|
1214
|
+
void get().runAgent({
|
|
1215
|
+
action: 'scout-infrastructure',
|
|
1216
|
+
instruction:
|
|
1217
|
+
"Scan this project's dependencies, config files, ORM schemas, env-var references and HTTP clients, and write emberflow/infrastructure.json describing the databases, APIs and providers it already uses.",
|
|
1218
|
+
});
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1221
|
+
setWelcomeOpen(open) {
|
|
1222
|
+
set({ welcomeOpen: open });
|
|
1223
|
+
},
|
|
1224
|
+
|
|
1225
|
+
setSettingsOpen(open) {
|
|
1226
|
+
set(open ? { settingsOpen: true } : { settingsOpen: false, settingsFromWelcome: false });
|
|
1227
|
+
},
|
|
1228
|
+
|
|
1229
|
+
openSettingsFromWelcome() {
|
|
1230
|
+
set({ welcomeOpen: false, settingsOpen: true, settingsFromWelcome: true });
|
|
1231
|
+
},
|
|
1232
|
+
|
|
1233
|
+
async refreshSetupStatus() {
|
|
1234
|
+
const status = await fetchSetupStatus();
|
|
1235
|
+
if (status) set({ setupStatus: status });
|
|
1236
|
+
return status;
|
|
1237
|
+
},
|
|
1238
|
+
|
|
1239
|
+
openAgentPanel() {
|
|
1240
|
+
set({ agentPanelOpen: true });
|
|
1241
|
+
},
|
|
1242
|
+
|
|
1243
|
+
toggleAgentPanel() {
|
|
1244
|
+
// Closing the panel leaves environment-setup mode — reopening it later
|
|
1245
|
+
// should be about the open operation again.
|
|
1246
|
+
set((s) => ({ agentPanelOpen: !s.agentPanelOpen, ...(s.agentPanelOpen ? { agentEnvSetup: false } : {}) }));
|
|
1247
|
+
},
|
|
1248
|
+
|
|
1249
|
+
setViewRegister(register) {
|
|
1250
|
+
set({ viewRegister: register });
|
|
1251
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(REGISTER_KEY, register);
|
|
1252
|
+
},
|
|
1253
|
+
|
|
1254
|
+
setConsolePosition(position) {
|
|
1255
|
+
set({ consolePosition: position });
|
|
1256
|
+
if (typeof localStorage !== 'undefined') localStorage.setItem(CONSOLE_POSITION_KEY, position);
|
|
1257
|
+
},
|
|
1258
|
+
|
|
1259
|
+
dismissRunConsole() {
|
|
1260
|
+
set({ runConsoleDismissedId: get().run?.id ?? null });
|
|
1261
|
+
},
|
|
1262
|
+
|
|
1263
|
+
reopenRunConsole() {
|
|
1264
|
+
set((s) => {
|
|
1265
|
+
const runId = s.run?.id;
|
|
1266
|
+
const runConsoleOpenedIds = runId
|
|
1267
|
+
? new Set(s.runConsoleOpenedIds).add(runId)
|
|
1268
|
+
: s.runConsoleOpenedIds;
|
|
1269
|
+
return { runConsoleDismissedId: null, runConsoleOpenedIds };
|
|
1270
|
+
});
|
|
1271
|
+
},
|
|
1272
|
+
|
|
1273
|
+
recordRun(run, errorHandler) {
|
|
1274
|
+
set((s) => {
|
|
1275
|
+
const scenario = s.activeScenarioId
|
|
1276
|
+
? s.flow.scenarios?.find((sc) => sc.id === s.activeScenarioId)
|
|
1277
|
+
: undefined;
|
|
1278
|
+
const entry: RunHistoryEntry = {
|
|
1279
|
+
...run,
|
|
1280
|
+
...(scenario ? { scenarioName: scenario.name } : {}),
|
|
1281
|
+
...(errorHandler ? { errorHandler } : {}),
|
|
1282
|
+
...(s.activeRunMock ? { mock: true } : {}),
|
|
1283
|
+
};
|
|
1284
|
+
return {
|
|
1285
|
+
runHistory: [entry, ...s.runHistory].slice(0, 50),
|
|
1286
|
+
logsByRun: { ...s.logsByRun, [run.id]: s.logs },
|
|
1287
|
+
};
|
|
1288
|
+
});
|
|
1289
|
+
},
|
|
1290
|
+
|
|
1291
|
+
viewRun(runId) {
|
|
1292
|
+
const entry = get().runHistory.find((r) => r.id === runId);
|
|
1293
|
+
if (entry) {
|
|
1294
|
+
set((s) => ({ run: entry, logs: s.logsByRun[runId] ?? [], activeRun: null }));
|
|
1295
|
+
}
|
|
1296
|
+
},
|
|
1297
|
+
|
|
1298
|
+
createWorkflow() {
|
|
1299
|
+
const flow = emptyFlow();
|
|
1300
|
+
set((s) => {
|
|
1301
|
+
const shelf = [...s.shelf, s.flow];
|
|
1302
|
+
return {
|
|
1303
|
+
flow,
|
|
1304
|
+
shelf,
|
|
1305
|
+
workflows: summaries(flow, shelf, s.opMeta),
|
|
1306
|
+
run: null,
|
|
1307
|
+
logs: [],
|
|
1308
|
+
activeRun: null,
|
|
1309
|
+
selectedNodeId: null,
|
|
1310
|
+
};
|
|
1311
|
+
});
|
|
1312
|
+
revertUnsafeAfterRun();
|
|
1313
|
+
},
|
|
1314
|
+
|
|
1315
|
+
moveWorkflowToFolder(id, folder) {
|
|
1316
|
+
set((s) => {
|
|
1317
|
+
const assign = (f: WorkflowDefinition): WorkflowDefinition => {
|
|
1318
|
+
if (f.id !== id) return f;
|
|
1319
|
+
const next = { ...f };
|
|
1320
|
+
if (folder) next.folder = folder;
|
|
1321
|
+
else delete next.folder;
|
|
1322
|
+
return next;
|
|
1323
|
+
};
|
|
1324
|
+
const flow = assign(s.flow);
|
|
1325
|
+
const shelf = s.shelf.map(assign);
|
|
1326
|
+
return { flow, shelf, workflows: summaries(flow, shelf, s.opMeta) };
|
|
1327
|
+
});
|
|
1328
|
+
},
|
|
1329
|
+
|
|
1330
|
+
switchWorkflow(id) {
|
|
1331
|
+
let switched = false;
|
|
1332
|
+
set((s) => {
|
|
1333
|
+
if (s.flow.id === id) return {};
|
|
1334
|
+
const target = s.shelf.find((f) => f.id === id);
|
|
1335
|
+
if (!target) return {};
|
|
1336
|
+
switched = true;
|
|
1337
|
+
const shelf = [...s.shelf.filter((f) => f.id !== id), s.flow];
|
|
1338
|
+
return {
|
|
1339
|
+
flow: target,
|
|
1340
|
+
shelf,
|
|
1341
|
+
workflows: summaries(target, shelf, s.opMeta),
|
|
1342
|
+
run: null,
|
|
1343
|
+
logs: [],
|
|
1344
|
+
activeRun: null,
|
|
1345
|
+
selectedNodeId: null,
|
|
1346
|
+
};
|
|
1347
|
+
});
|
|
1348
|
+
// Switching flows ends any consciously-unsafe session, then honors the
|
|
1349
|
+
// newly-opened flow's preferred environment (if any).
|
|
1350
|
+
if (switched) {
|
|
1351
|
+
revertUnsafeAfterRun();
|
|
1352
|
+
applyFlowEnvironment();
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1355
|
+
|
|
1356
|
+
addNode(type, position) {
|
|
1357
|
+
const definition = get().registry.get(type).definition;
|
|
1358
|
+
set((s) => ({
|
|
1359
|
+
flow: touched({
|
|
1360
|
+
...s.flow,
|
|
1361
|
+
nodes: [
|
|
1362
|
+
...s.flow.nodes,
|
|
1363
|
+
{ id: crypto.randomUUID(), type, label: definition.label, position, config: {} },
|
|
1364
|
+
],
|
|
1365
|
+
}),
|
|
1366
|
+
}));
|
|
1367
|
+
},
|
|
1368
|
+
|
|
1369
|
+
moveNode(id, position) {
|
|
1370
|
+
set((s) => ({
|
|
1371
|
+
flow: {
|
|
1372
|
+
...s.flow,
|
|
1373
|
+
nodes: s.flow.nodes.map((n) => (n.id === id ? { ...n, position } : n)),
|
|
1374
|
+
},
|
|
1375
|
+
}));
|
|
1376
|
+
},
|
|
1377
|
+
|
|
1378
|
+
resizeNode(id, size) {
|
|
1379
|
+
// Layout-only, like moveNode: no updatedAt bump on every drag tick.
|
|
1380
|
+
set((s) => ({
|
|
1381
|
+
flow: {
|
|
1382
|
+
...s.flow,
|
|
1383
|
+
nodes: s.flow.nodes.map((n) =>
|
|
1384
|
+
n.id === id ? { ...n, metadata: { ...n.metadata, size } } : n,
|
|
1385
|
+
),
|
|
1386
|
+
},
|
|
1387
|
+
}));
|
|
1388
|
+
},
|
|
1389
|
+
|
|
1390
|
+
removeNode(id) {
|
|
1391
|
+
set((s) => ({
|
|
1392
|
+
flow: touched({
|
|
1393
|
+
...s.flow,
|
|
1394
|
+
nodes: s.flow.nodes.filter((n) => n.id !== id),
|
|
1395
|
+
edges: s.flow.edges.filter((e) => e.source !== id && e.target !== id),
|
|
1396
|
+
}),
|
|
1397
|
+
selectedNodeId: s.selectedNodeId === id ? null : s.selectedNodeId,
|
|
1398
|
+
}));
|
|
1399
|
+
},
|
|
1400
|
+
|
|
1401
|
+
removeEdge(id) {
|
|
1402
|
+
set((s) => {
|
|
1403
|
+
const edge = s.flow.edges.find((e) => e.id === id);
|
|
1404
|
+
let nodes = s.flow.nodes;
|
|
1405
|
+
// A field edge carries a mapping; deleting the edge deletes the mapping.
|
|
1406
|
+
if (edge?.targetHandle) {
|
|
1407
|
+
nodes = nodes.map((n) => {
|
|
1408
|
+
if (n.id !== edge.target) return n;
|
|
1409
|
+
const mapping = n.inputMap?.[edge.targetHandle!];
|
|
1410
|
+
if (!mapping || mapping.sourceNodeId !== edge.source) return n;
|
|
1411
|
+
const inputMap = { ...n.inputMap };
|
|
1412
|
+
delete inputMap[edge.targetHandle!];
|
|
1413
|
+
return { ...n, inputMap };
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
return {
|
|
1417
|
+
flow: touched({ ...s.flow, nodes, edges: s.flow.edges.filter((e) => e.id !== id) }),
|
|
1418
|
+
};
|
|
1419
|
+
});
|
|
1420
|
+
},
|
|
1421
|
+
|
|
1422
|
+
// connect/removeNode/moveNode/resizeNode: the runbook view has no
|
|
1423
|
+
// authoring surface today (it's read-only, projection-driven). These
|
|
1424
|
+
// stay for the file/agent authoring path — flows are built by editing
|
|
1425
|
+
// workflow JSON or via agent tooling that calls this store directly —
|
|
1426
|
+
// and for the canvas-based editing surface planned for later.
|
|
1427
|
+
connect(source, target, targetHandle, sourceHandle) {
|
|
1428
|
+
set((s) => {
|
|
1429
|
+
let nodes = s.flow.nodes;
|
|
1430
|
+
if (targetHandle) {
|
|
1431
|
+
nodes = nodes.map((n) =>
|
|
1432
|
+
n.id === target
|
|
1433
|
+
? {
|
|
1434
|
+
...n,
|
|
1435
|
+
inputMap: {
|
|
1436
|
+
...n.inputMap,
|
|
1437
|
+
[targetHandle]: { sourceNodeId: source, sourceField: '$' },
|
|
1438
|
+
},
|
|
1439
|
+
}
|
|
1440
|
+
: n,
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
return {
|
|
1444
|
+
flow: touched({
|
|
1445
|
+
...s.flow,
|
|
1446
|
+
nodes,
|
|
1447
|
+
edges: [
|
|
1448
|
+
...s.flow.edges,
|
|
1449
|
+
{ id: crypto.randomUUID(), source, target, targetHandle, sourceHandle },
|
|
1450
|
+
],
|
|
1451
|
+
}),
|
|
1452
|
+
};
|
|
1453
|
+
});
|
|
1454
|
+
},
|
|
1455
|
+
|
|
1456
|
+
selectNode(id) {
|
|
1457
|
+
// Selecting a node always surfaces the Inspector — a click that lands
|
|
1458
|
+
// nowhere visible reads as a dead click. Deselection leaves panels alone.
|
|
1459
|
+
if (id !== null && !get().inspectorOpen) {
|
|
1460
|
+
set({ selectedNodeId: id, inspectorOpen: true });
|
|
1461
|
+
persistPanel(INSPECTOR_KEY, true);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
set({ selectedNodeId: id });
|
|
1465
|
+
},
|
|
1466
|
+
|
|
1467
|
+
renameFlow(name) {
|
|
1468
|
+
set((s) => {
|
|
1469
|
+
const flow = touched({ ...s.flow, name });
|
|
1470
|
+
return { flow, workflows: summaries(flow, s.shelf, s.opMeta) };
|
|
1471
|
+
});
|
|
1472
|
+
},
|
|
1473
|
+
|
|
1474
|
+
setFlowHttp(http) {
|
|
1475
|
+
set((s) => {
|
|
1476
|
+
const flow = touched({ ...s.flow, http });
|
|
1477
|
+
return { flow, workflows: summaries(flow, s.shelf, s.opMeta) };
|
|
1478
|
+
});
|
|
1479
|
+
},
|
|
1480
|
+
|
|
1481
|
+
renameNode(id, label) {
|
|
1482
|
+
set((s) => ({
|
|
1483
|
+
flow: touched({
|
|
1484
|
+
...s.flow,
|
|
1485
|
+
nodes: s.flow.nodes.map((n) => (n.id === id ? { ...n, label } : n)),
|
|
1486
|
+
}),
|
|
1487
|
+
}));
|
|
1488
|
+
},
|
|
1489
|
+
|
|
1490
|
+
updateNodeConfig(id, key, value) {
|
|
1491
|
+
set((s) => ({
|
|
1492
|
+
flow: touched({
|
|
1493
|
+
...s.flow,
|
|
1494
|
+
nodes: s.flow.nodes.map((n) =>
|
|
1495
|
+
n.id === id ? { ...n, config: { ...n.config, [key]: value } } : n,
|
|
1496
|
+
),
|
|
1497
|
+
}),
|
|
1498
|
+
}));
|
|
1499
|
+
},
|
|
1500
|
+
|
|
1501
|
+
setNodeRetry(id, retry) {
|
|
1502
|
+
set((s) => ({
|
|
1503
|
+
flow: touched({
|
|
1504
|
+
...s.flow,
|
|
1505
|
+
nodes: s.flow.nodes.map((n) => {
|
|
1506
|
+
if (n.id !== id) return n;
|
|
1507
|
+
const { retry: _drop, ...rest } = n;
|
|
1508
|
+
return retry ? { ...rest, retry } : rest;
|
|
1509
|
+
}),
|
|
1510
|
+
}),
|
|
1511
|
+
}));
|
|
1512
|
+
},
|
|
1513
|
+
|
|
1514
|
+
seedParamDefault(param) {
|
|
1515
|
+
const inputNode = get().flow.nodes.find((n) => n.type === 'Input');
|
|
1516
|
+
if (!inputNode) return;
|
|
1517
|
+
const existingDefaults = (inputNode.config?.defaults as Record<string, unknown> | undefined) ?? {};
|
|
1518
|
+
const existingParams =
|
|
1519
|
+
(existingDefaults.params as Record<string, unknown> | undefined) ?? {};
|
|
1520
|
+
if (existingParams[param] !== undefined) return;
|
|
1521
|
+
set((s) => ({
|
|
1522
|
+
flow: touched({
|
|
1523
|
+
...s.flow,
|
|
1524
|
+
nodes: s.flow.nodes.map((n) =>
|
|
1525
|
+
n.id === inputNode.id
|
|
1526
|
+
? {
|
|
1527
|
+
...n,
|
|
1528
|
+
config: {
|
|
1529
|
+
...n.config,
|
|
1530
|
+
defaults: {
|
|
1531
|
+
...existingDefaults,
|
|
1532
|
+
params: { ...existingParams, [param]: '' },
|
|
1533
|
+
},
|
|
1534
|
+
},
|
|
1535
|
+
}
|
|
1536
|
+
: n,
|
|
1537
|
+
),
|
|
1538
|
+
}),
|
|
1539
|
+
}));
|
|
1540
|
+
get().saveFlow();
|
|
1541
|
+
},
|
|
1542
|
+
|
|
1543
|
+
setInputMapping(nodeId, field, mapping) {
|
|
1544
|
+
set((s) => ({
|
|
1545
|
+
flow: touched({
|
|
1546
|
+
...s.flow,
|
|
1547
|
+
nodes: s.flow.nodes.map((n) => {
|
|
1548
|
+
if (n.id !== nodeId) return n;
|
|
1549
|
+
const inputMap = { ...n.inputMap };
|
|
1550
|
+
if (mapping) inputMap[field] = mapping;
|
|
1551
|
+
else delete inputMap[field];
|
|
1552
|
+
return { ...n, inputMap };
|
|
1553
|
+
}),
|
|
1554
|
+
}),
|
|
1555
|
+
}));
|
|
1556
|
+
},
|
|
1557
|
+
|
|
1558
|
+
pinNodeOutput(nodeId, output) {
|
|
1559
|
+
set((s) => ({
|
|
1560
|
+
flow: touched({
|
|
1561
|
+
...s.flow,
|
|
1562
|
+
nodes: s.flow.nodes.map((n) =>
|
|
1563
|
+
n.id === nodeId
|
|
1564
|
+
? { ...n, metadata: { ...n.metadata, pinnedOutput: output } }
|
|
1565
|
+
: n,
|
|
1566
|
+
),
|
|
1567
|
+
}),
|
|
1568
|
+
}));
|
|
1569
|
+
},
|
|
1570
|
+
|
|
1571
|
+
unpinNode(nodeId) {
|
|
1572
|
+
set((s) => ({
|
|
1573
|
+
flow: touched({
|
|
1574
|
+
...s.flow,
|
|
1575
|
+
nodes: s.flow.nodes.map((n) => {
|
|
1576
|
+
if (n.id !== nodeId || !n.metadata || !('pinnedOutput' in n.metadata)) return n;
|
|
1577
|
+
const metadata = { ...n.metadata };
|
|
1578
|
+
delete metadata.pinnedOutput;
|
|
1579
|
+
if (Object.keys(metadata).length === 0) {
|
|
1580
|
+
const { metadata: _dropped, ...rest } = n;
|
|
1581
|
+
return rest;
|
|
1582
|
+
}
|
|
1583
|
+
return { ...n, metadata };
|
|
1584
|
+
}),
|
|
1585
|
+
}),
|
|
1586
|
+
}));
|
|
1587
|
+
},
|
|
1588
|
+
|
|
1589
|
+
async runNodeIsolated(nodeId, input) {
|
|
1590
|
+
const s = get();
|
|
1591
|
+
const node = s.flow.nodes.find((n) => n.id === nodeId);
|
|
1592
|
+
if (!node) return { error: `Unknown node: ${nodeId}`, logs: [] };
|
|
1593
|
+
if (!s.registry.has(node.type)) {
|
|
1594
|
+
return { error: `Unknown node type: ${node.type}`, logs: [] };
|
|
1595
|
+
}
|
|
1596
|
+
const { implementation } = s.registry.get(node.type);
|
|
1597
|
+
const logs: LogLine[] = [];
|
|
1598
|
+
const runId = `isolated-${crypto.randomUUID().slice(0, 8)}`;
|
|
1599
|
+
const log = (level: LogLine['level'], message: string): void => {
|
|
1600
|
+
logs.push({
|
|
1601
|
+
timestamp: new Date().toISOString(),
|
|
1602
|
+
level,
|
|
1603
|
+
runId,
|
|
1604
|
+
nodeId: node.id,
|
|
1605
|
+
nodeLabel: node.label,
|
|
1606
|
+
message,
|
|
1607
|
+
});
|
|
1608
|
+
};
|
|
1609
|
+
const startedAt = new Date().toISOString();
|
|
1610
|
+
const sample = (status: 'succeeded' | 'failed', output?: unknown) => {
|
|
1611
|
+
s.trace.record({
|
|
1612
|
+
id: crypto.randomUUID(),
|
|
1613
|
+
workflowId: s.flow.id,
|
|
1614
|
+
runId,
|
|
1615
|
+
nodeId: node.id,
|
|
1616
|
+
nodeType: node.type,
|
|
1617
|
+
nodeLabel: node.label,
|
|
1618
|
+
input,
|
|
1619
|
+
output,
|
|
1620
|
+
status,
|
|
1621
|
+
startedAt,
|
|
1622
|
+
completedAt: new Date().toISOString(),
|
|
1623
|
+
});
|
|
1624
|
+
};
|
|
1625
|
+
try {
|
|
1626
|
+
const output = await implementation({
|
|
1627
|
+
input,
|
|
1628
|
+
config: node.config,
|
|
1629
|
+
secrets: {},
|
|
1630
|
+
vars: {},
|
|
1631
|
+
safeMode: false,
|
|
1632
|
+
runInput: {},
|
|
1633
|
+
log,
|
|
1634
|
+
runSubflow: (workflowId, subInput) =>
|
|
1635
|
+
makeSubflowRunner([s.flow.id])(workflowId, subInput, node.id),
|
|
1636
|
+
});
|
|
1637
|
+
sample('succeeded', output);
|
|
1638
|
+
return { output, logs };
|
|
1639
|
+
} catch (err) {
|
|
1640
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
1641
|
+
sample('failed');
|
|
1642
|
+
return { error, logs };
|
|
1643
|
+
}
|
|
1644
|
+
},
|
|
1645
|
+
|
|
1646
|
+
async testWorkflow(flowId, environment) {
|
|
1647
|
+
set({ scenarioTestPending: flowId });
|
|
1648
|
+
try {
|
|
1649
|
+
const report = await testWorkflowOnServer(flowId, environment);
|
|
1650
|
+
set((s) => ({
|
|
1651
|
+
scenarioTestReports: { ...s.scenarioTestReports, [flowId]: report },
|
|
1652
|
+
}));
|
|
1653
|
+
} finally {
|
|
1654
|
+
set((s) => (s.scenarioTestPending === flowId ? { scenarioTestPending: null } : {}));
|
|
1655
|
+
}
|
|
1656
|
+
},
|
|
1657
|
+
|
|
1658
|
+
async runScenario(scenarioId) {
|
|
1659
|
+
const scenario = get().flow.scenarios?.find((sc) => sc.id === scenarioId);
|
|
1660
|
+
if (!scenario) return;
|
|
1661
|
+
stepFollow = false;
|
|
1662
|
+
get().resetRun();
|
|
1663
|
+
set({ stepMode: false });
|
|
1664
|
+
set({ activeScenarioId: scenarioId });
|
|
1665
|
+
if (get().effectiveMode() === 'server') {
|
|
1666
|
+
await ensureServerRun('run', scenario.input, scenario.name);
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
const handle = ensureRun(scenario.input, scenario.mocks);
|
|
1670
|
+
if (handle) await handle.runToEnd();
|
|
1671
|
+
},
|
|
1672
|
+
|
|
1673
|
+
async stepScenario(scenarioId) {
|
|
1674
|
+
const scenario = get().flow.scenarios?.find((sc) => sc.id === scenarioId);
|
|
1675
|
+
if (!scenario) return;
|
|
1676
|
+
stepFollow = true;
|
|
1677
|
+
get().resetRun();
|
|
1678
|
+
set({ stepMode: true });
|
|
1679
|
+
set({ activeScenarioId: scenarioId });
|
|
1680
|
+
if (get().effectiveMode() === 'server') {
|
|
1681
|
+
const runId = await ensureServerRun('step', scenario.input, scenario.name);
|
|
1682
|
+
if (runId) {
|
|
1683
|
+
try {
|
|
1684
|
+
const done = await stepServerRun(runId);
|
|
1685
|
+
if (done) set({ activeServerRunId: null });
|
|
1686
|
+
} catch (err) {
|
|
1687
|
+
reportRunError(err instanceof Error ? err.message : String(err));
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
const handle = ensureRun(scenario.input, scenario.mocks);
|
|
1693
|
+
if (handle) {
|
|
1694
|
+
await handle.step();
|
|
1695
|
+
selectLatestCompleted();
|
|
1696
|
+
}
|
|
1697
|
+
},
|
|
1698
|
+
|
|
1699
|
+
addScenario(name, input, description, expect) {
|
|
1700
|
+
const scenario: ScenarioDefinition = {
|
|
1701
|
+
id: crypto.randomUUID(),
|
|
1702
|
+
name,
|
|
1703
|
+
...(description ? { description } : {}),
|
|
1704
|
+
input,
|
|
1705
|
+
...(expect ? { expect } : {}),
|
|
1706
|
+
};
|
|
1707
|
+
set((s) => ({
|
|
1708
|
+
flow: touched({ ...s.flow, scenarios: [...(s.flow.scenarios ?? []), scenario] }),
|
|
1709
|
+
// A scenario change makes the last test report stale — drop it so the
|
|
1710
|
+
// panel never shows a ✓/✗ for expectations that no longer exist.
|
|
1711
|
+
scenarioTestReports: dropReport(s.scenarioTestReports, s.flow.id),
|
|
1712
|
+
}));
|
|
1713
|
+
},
|
|
1714
|
+
|
|
1715
|
+
updateScenario(id, patch) {
|
|
1716
|
+
set((s) => ({
|
|
1717
|
+
flow: touched({
|
|
1718
|
+
...s.flow,
|
|
1719
|
+
scenarios: (s.flow.scenarios ?? []).map((sc) =>
|
|
1720
|
+
sc.id === id ? { ...sc, ...patch } : sc,
|
|
1721
|
+
),
|
|
1722
|
+
}),
|
|
1723
|
+
scenarioTestReports: dropReport(s.scenarioTestReports, s.flow.id),
|
|
1724
|
+
}));
|
|
1725
|
+
},
|
|
1726
|
+
|
|
1727
|
+
removeScenario(id) {
|
|
1728
|
+
set((s) => {
|
|
1729
|
+
const scenarios = (s.flow.scenarios ?? []).filter((sc) => sc.id !== id);
|
|
1730
|
+
const flow = { ...s.flow };
|
|
1731
|
+
if (scenarios.length > 0) flow.scenarios = scenarios;
|
|
1732
|
+
else delete flow.scenarios;
|
|
1733
|
+
return {
|
|
1734
|
+
flow: touched(flow),
|
|
1735
|
+
scenarioTestReports: dropReport(s.scenarioTestReports, s.flow.id),
|
|
1736
|
+
};
|
|
1737
|
+
});
|
|
1738
|
+
},
|
|
1739
|
+
|
|
1740
|
+
async stepRun() {
|
|
1741
|
+
// A plain step/run is not scenario-driven; only tag history for runs
|
|
1742
|
+
// started via runScenario.
|
|
1743
|
+
if (!get().activeRun && !get().activeServerRunId) set({ activeScenarioId: null });
|
|
1744
|
+
stepFollow = true;
|
|
1745
|
+
set({ stepMode: true, runConsoleDismissedId: null });
|
|
1746
|
+
if (get().effectiveMode() === 'server') {
|
|
1747
|
+
const runId = await ensureServerRun('step');
|
|
1748
|
+
if (runId) {
|
|
1749
|
+
try {
|
|
1750
|
+
const done = await stepServerRun(runId);
|
|
1751
|
+
if (done) set({ activeServerRunId: null });
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
reportRunError(err instanceof Error ? err.message : String(err));
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const handle = ensureRun();
|
|
1759
|
+
if (handle) {
|
|
1760
|
+
await handle.step();
|
|
1761
|
+
selectLatestCompleted();
|
|
1762
|
+
}
|
|
1763
|
+
},
|
|
1764
|
+
|
|
1765
|
+
async runToEnd() {
|
|
1766
|
+
stepFollow = false;
|
|
1767
|
+
set({ stepMode: false, runConsoleDismissedId: null });
|
|
1768
|
+
if (!get().activeRun && !get().activeServerRunId) set({ activeScenarioId: null });
|
|
1769
|
+
if (get().effectiveMode() === 'server') {
|
|
1770
|
+
const existing = get().activeServerRunId;
|
|
1771
|
+
if (existing) {
|
|
1772
|
+
// A stepped run is already live on the runner — walk it to the end.
|
|
1773
|
+
try {
|
|
1774
|
+
let done = false;
|
|
1775
|
+
while (!done) done = await stepServerRun(existing);
|
|
1776
|
+
set({ activeServerRunId: null });
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
reportRunError(err instanceof Error ? err.message : String(err));
|
|
1779
|
+
}
|
|
1780
|
+
return;
|
|
1781
|
+
}
|
|
1782
|
+
await ensureServerRun('run');
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
const handle = ensureRun();
|
|
1786
|
+
if (handle) await handle.runToEnd();
|
|
1787
|
+
},
|
|
1788
|
+
|
|
1789
|
+
resetRun() {
|
|
1790
|
+
get().activeRun?.cancel();
|
|
1791
|
+
const serverRunId = get().activeServerRunId;
|
|
1792
|
+
if (serverRunId) void cancelServerRun(serverRunId);
|
|
1793
|
+
// A fresh run clears any prior dismissal so the console re-surfaces —
|
|
1794
|
+
// in technical it auto-opens (useRunConsole), so Run always shows logs.
|
|
1795
|
+
set({ run: null, logs: [], activeRun: null, activeServerRunId: null, stepMode: false, runConsoleDismissedId: null });
|
|
1796
|
+
},
|
|
1797
|
+
|
|
1798
|
+
saveFlow() {
|
|
1799
|
+
const s = get();
|
|
1800
|
+
const flows = [s.flow, ...s.shelf];
|
|
1801
|
+
if (s.workspaceSource === 'server') {
|
|
1802
|
+
// Runner is source of truth: push every flow, keep localStorage as backup.
|
|
1803
|
+
for (const f of flows) void putWorkflow(f);
|
|
1804
|
+
}
|
|
1805
|
+
saveWorkspace({ flows, activeId: s.flow.id });
|
|
1806
|
+
},
|
|
1807
|
+
|
|
1808
|
+
loadFlow() {
|
|
1809
|
+
const ws = loadWorkspace();
|
|
1810
|
+
if (!ws) return;
|
|
1811
|
+
const flow = ws.flows.find((f) => f.id === ws.activeId) ?? ws.flows[0];
|
|
1812
|
+
const shelf = ws.flows.filter((f) => f.id !== flow.id);
|
|
1813
|
+
hydrating(() =>
|
|
1814
|
+
set({
|
|
1815
|
+
flow,
|
|
1816
|
+
shelf,
|
|
1817
|
+
workflows: summaries(flow, shelf, get().opMeta),
|
|
1818
|
+
run: null,
|
|
1819
|
+
logs: [],
|
|
1820
|
+
activeRun: null,
|
|
1821
|
+
selectedNodeId: null,
|
|
1822
|
+
}),
|
|
1823
|
+
);
|
|
1824
|
+
},
|
|
1825
|
+
|
|
1826
|
+
exportFlow() {
|
|
1827
|
+
return serializeFlow(get().flow);
|
|
1828
|
+
},
|
|
1829
|
+
|
|
1830
|
+
importFlow(json) {
|
|
1831
|
+
const imported = parseFlow(json);
|
|
1832
|
+
set((s) => {
|
|
1833
|
+
// Imported flow becomes (or replaces) a workflow and is made active.
|
|
1834
|
+
const shelf = [...s.shelf.filter((f) => f.id !== imported.id), s.flow].filter(
|
|
1835
|
+
(f) => f.id !== imported.id,
|
|
1836
|
+
);
|
|
1837
|
+
return {
|
|
1838
|
+
flow: imported,
|
|
1839
|
+
shelf,
|
|
1840
|
+
workflows: summaries(imported, shelf, s.opMeta),
|
|
1841
|
+
run: null,
|
|
1842
|
+
logs: [],
|
|
1843
|
+
activeRun: null,
|
|
1844
|
+
selectedNodeId: null,
|
|
1845
|
+
};
|
|
1846
|
+
});
|
|
1847
|
+
},
|
|
1848
|
+
};
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
// Autosave: any flow/shelf change (layout, resize, config, scenarios) persists
|
|
1852
|
+
// after a short idle, so nothing is lost to a reload without hitting Save.
|
|
1853
|
+
// Changes that merely hydrate the workspace (runner sync, local load) are not
|
|
1854
|
+
// edits and must not save — writing them back would echo runner state forever.
|
|
1855
|
+
let autosaveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1856
|
+
export let suppressAutosave = false;
|
|
1857
|
+
export function hydrating<T>(fn: () => T): T {
|
|
1858
|
+
suppressAutosave = true;
|
|
1859
|
+
try {
|
|
1860
|
+
return fn();
|
|
1861
|
+
} finally {
|
|
1862
|
+
suppressAutosave = false;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
useBuilderStore.subscribe((state, prev) => {
|
|
1866
|
+
if (suppressAutosave) return;
|
|
1867
|
+
if (state.flow === prev.flow && state.shelf === prev.shelf) return;
|
|
1868
|
+
clearTimeout(autosaveTimer);
|
|
1869
|
+
autosaveTimer = setTimeout(() => {
|
|
1870
|
+
// A background tab must not save: two open tabs would take turns
|
|
1871
|
+
// clobbering each other's workspace (last writer wins).
|
|
1872
|
+
if (typeof document !== 'undefined' && !document.hasFocus()) return;
|
|
1873
|
+
useBuilderStore.getState().saveFlow();
|
|
1874
|
+
}, 800);
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
// Merge consumer-node metadata from the runner (if reachable) into the
|
|
1878
|
+
// registry so custom project nodes appear in the palette/inspector.
|
|
1879
|
+
void useBuilderStore.getState().syncNodeMeta();
|