@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,1085 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { ChevronRightIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, SparklesIcon } from 'lucide-react';
|
|
3
|
+
import { Badge } from '@/components/ui/badge';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
import { useBuilderStore } from '../store/builderStore';
|
|
6
|
+
import { Json } from './Json';
|
|
7
|
+
import { buildRunbook } from '../lib/runbookModel';
|
|
8
|
+
import type { RunbookBranchGroup, RunbookItem, RunbookLoopGroup, RunbookStep } from '../lib/runbookModel';
|
|
9
|
+
import { iterationSummary, projectRunbook } from '../lib/runbookProjection';
|
|
10
|
+
import type { RunbookProjection, StepVisualStatus } from '../lib/runbookProjection';
|
|
11
|
+
import type {WorkflowRun } from '../engine';
|
|
12
|
+
|
|
13
|
+
function formatDuration(ms: number): string {
|
|
14
|
+
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Minimum gap between auto-scrolls — one deliberate move at a time, never a
|
|
18
|
+
* flurry. */
|
|
19
|
+
const SCROLL_MIN_GAP_MS = 1100;
|
|
20
|
+
|
|
21
|
+
/** Fade-out duration for outcome-text crossfades; the swap happens at the
|
|
22
|
+
* midpoint and the fade back in mirrors it, for a ~440ms round trip. */
|
|
23
|
+
const CROSSFADE_MS = 220;
|
|
24
|
+
|
|
25
|
+
/** Reveal floor between two terminal steps. A cheap flow finishes server-side
|
|
26
|
+
* in a single frame; releasing steps no faster than this turns that burst into
|
|
27
|
+
* a legible sequence you can actually read filling the document. Genuinely slow
|
|
28
|
+
* steps (LLM calls) already outlast the floor, so they never wait on it — the
|
|
29
|
+
* queue adds pacing only when the engine outran the eye. */
|
|
30
|
+
const STEP_REVEAL_MIN_MS = 350;
|
|
31
|
+
|
|
32
|
+
/** Per-iteration pace for a loop's mini-dots — a third of the step floor so a
|
|
33
|
+
* five-iteration loop fills in ~0.6s instead of stalling the document, but
|
|
34
|
+
* never faster than 120ms (below that the dots read as a flicker, not a fill). */
|
|
35
|
+
const LOOP_REVEAL_TICK_MS = Math.max(120, STEP_REVEAL_MIN_MS / 3);
|
|
36
|
+
|
|
37
|
+
interface PacedReveal {
|
|
38
|
+
/** Maps a step's real projection status to what the document should show: a
|
|
39
|
+
* terminal step waits at 'idle' until released, flashes 'active' as it is
|
|
40
|
+
* released, then settles to its real status. */
|
|
41
|
+
displayStatus: (nodeId: string, real: StepVisualStatus) => StepVisualStatus;
|
|
42
|
+
/** The step currently being released (rendered active), for scroll-follow;
|
|
43
|
+
* null when the queue is empty. */
|
|
44
|
+
activeDisplayId: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The paced-reveal queue. As steps reach a terminal status they are enqueued in
|
|
49
|
+
* document order and released one at a time at STEP_REVEAL_MIN_MS spacing; the
|
|
50
|
+
* released step renders 'active' (breathing) for one interval before settling.
|
|
51
|
+
* A terminal step that arrives alone once the floor has already elapsed (a slow
|
|
52
|
+
* node finishing, or a manual Step click between which the user paused far
|
|
53
|
+
* longer than the floor) is revealed immediately — the queue never adds latency
|
|
54
|
+
* on top of real latency. reduced-motion keeps the sequencing (it is
|
|
55
|
+
* information, not decoration); the breathing ring itself is stilled by CSS.
|
|
56
|
+
*/
|
|
57
|
+
function usePacedReveal(orderedStepIds: string[], projection: RunbookProjection, runId: string | null): PacedReveal {
|
|
58
|
+
const [revealed, setRevealed] = useState<Set<string>>(new Set());
|
|
59
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
60
|
+
|
|
61
|
+
const queueRef = useRef<string[]>([]);
|
|
62
|
+
const enqueuedRef = useRef<Set<string>>(new Set());
|
|
63
|
+
const activeRef = useRef<string | null>(null);
|
|
64
|
+
const lastReleaseRef = useRef(0);
|
|
65
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
66
|
+
const runIdRef = useRef<string | null>(runId);
|
|
67
|
+
|
|
68
|
+
const drain = useRef<() => void>(() => {});
|
|
69
|
+
drain.current = () => {
|
|
70
|
+
timerRef.current = null;
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const wait = lastReleaseRef.current + STEP_REVEAL_MIN_MS - now;
|
|
73
|
+
if (wait > 0) {
|
|
74
|
+
timerRef.current = setTimeout(() => drain.current(), wait);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// The step released last interval has now breathed long enough — settle it.
|
|
78
|
+
if (activeRef.current) {
|
|
79
|
+
const done = activeRef.current;
|
|
80
|
+
activeRef.current = null;
|
|
81
|
+
setRevealed((prev) => new Set(prev).add(done));
|
|
82
|
+
}
|
|
83
|
+
if (queueRef.current.length === 0) {
|
|
84
|
+
setActiveId(null);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const next = queueRef.current.shift()!;
|
|
88
|
+
activeRef.current = next;
|
|
89
|
+
lastReleaseRef.current = now;
|
|
90
|
+
setActiveId(next);
|
|
91
|
+
timerRef.current = setTimeout(() => drain.current(), STEP_REVEAL_MIN_MS);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const schedule = () => {
|
|
95
|
+
if (timerRef.current) return; // already draining
|
|
96
|
+
if (queueRef.current.length === 0 && activeRef.current === null) return;
|
|
97
|
+
timerRef.current = setTimeout(() => drain.current(), 0);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
// New run (or reset): forget the previous fill, and stamp the release clock
|
|
103
|
+
// to now so the first burst paces from step one rather than dumping instantly.
|
|
104
|
+
if (runId !== runIdRef.current) {
|
|
105
|
+
runIdRef.current = runId;
|
|
106
|
+
queueRef.current = [];
|
|
107
|
+
enqueuedRef.current = new Set();
|
|
108
|
+
activeRef.current = null;
|
|
109
|
+
lastReleaseRef.current = now;
|
|
110
|
+
if (timerRef.current) {
|
|
111
|
+
clearTimeout(timerRef.current);
|
|
112
|
+
timerRef.current = null;
|
|
113
|
+
}
|
|
114
|
+
setRevealed(new Set());
|
|
115
|
+
setActiveId(null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const id of orderedStepIds) {
|
|
119
|
+
const st = projection.steps.get(id)?.status;
|
|
120
|
+
if ((st === 'ran' || st === 'failed') && !enqueuedRef.current.has(id)) {
|
|
121
|
+
enqueuedRef.current.add(id);
|
|
122
|
+
const idle = queueRef.current.length === 0 && activeRef.current === null;
|
|
123
|
+
if (idle && now - lastReleaseRef.current >= STEP_REVEAL_MIN_MS) {
|
|
124
|
+
// Straggler: floor already elapsed, reveal with no artificial flash.
|
|
125
|
+
lastReleaseRef.current = now;
|
|
126
|
+
setRevealed((prev) => new Set(prev).add(id));
|
|
127
|
+
} else {
|
|
128
|
+
queueRef.current.push(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
schedule();
|
|
133
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
134
|
+
}, [projection, runId, orderedStepIds]);
|
|
135
|
+
|
|
136
|
+
useEffect(
|
|
137
|
+
() => () => {
|
|
138
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
139
|
+
},
|
|
140
|
+
[],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const displayStatus = (nodeId: string, real: StepVisualStatus): StepVisualStatus => {
|
|
144
|
+
if (activeId === nodeId) return 'active';
|
|
145
|
+
if (real === 'active') return 'active'; // engine is genuinely mid-flight here
|
|
146
|
+
if (real === 'ran' || real === 'failed') return revealed.has(nodeId) ? real : 'idle';
|
|
147
|
+
return real;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return { displayStatus, activeDisplayId: activeId };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Loop mini-dots fill at their own gentle pace: terminal iterations are
|
|
155
|
+
* revealed one tick at a time so a completed loop sweeps rather than snapping.
|
|
156
|
+
* Returns a display copy of `statuses` with not-yet-revealed terminal dots held
|
|
157
|
+
* at 'pending' and the one being revealed shown 'running'.
|
|
158
|
+
*/
|
|
159
|
+
function usePacedLoopStatuses(
|
|
160
|
+
statuses: ('done' | 'failed' | 'pending' | 'running')[],
|
|
161
|
+
resetKey: string,
|
|
162
|
+
): ('done' | 'failed' | 'pending' | 'running')[] {
|
|
163
|
+
const terminalIdx = useMemo(
|
|
164
|
+
() => statuses.map((s, i) => (s === 'done' || s === 'failed' ? i : -1)).filter((i) => i >= 0),
|
|
165
|
+
[statuses],
|
|
166
|
+
);
|
|
167
|
+
const [revealedCount, setRevealedCount] = useState(0);
|
|
168
|
+
const prevResetKey = useRef(resetKey);
|
|
169
|
+
if (resetKey !== prevResetKey.current) {
|
|
170
|
+
prevResetKey.current = resetKey;
|
|
171
|
+
// Reset synchronously during render so a fresh run never flashes the old fill.
|
|
172
|
+
if (revealedCount !== 0) setRevealedCount(0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (revealedCount >= terminalIdx.length) return;
|
|
177
|
+
const t = setTimeout(() => setRevealedCount((c) => c + 1), LOOP_REVEAL_TICK_MS);
|
|
178
|
+
return () => clearTimeout(t);
|
|
179
|
+
}, [revealedCount, terminalIdx.length]);
|
|
180
|
+
|
|
181
|
+
const revealedSet = new Set(terminalIdx.slice(0, revealedCount));
|
|
182
|
+
const releasingIdx = terminalIdx[revealedCount];
|
|
183
|
+
return statuses.map((s, i) => {
|
|
184
|
+
if (s === 'running') return 'running';
|
|
185
|
+
if (s === 'done' || s === 'failed') {
|
|
186
|
+
if (revealedSet.has(i)) return s;
|
|
187
|
+
if (i === releasingIdx) return 'running';
|
|
188
|
+
return 'pending';
|
|
189
|
+
}
|
|
190
|
+
return s;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Flattens the runbook tree to its step node ids in document (render) order —
|
|
195
|
+
* the order terminal steps are released in. */
|
|
196
|
+
function flattenStepIds(items: RunbookItem[], out: string[] = []): string[] {
|
|
197
|
+
for (const item of items) {
|
|
198
|
+
if (item.kind === 'step') out.push(item.nodeId);
|
|
199
|
+
else flattenStepIds(item.items, out);
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Crossfades text changes instead of snapping: fade the old text out, swap
|
|
205
|
+
* the displayed string once it's invisible, then let the caller's own
|
|
206
|
+
* transition fade it back in via the returned `fading` flag. */
|
|
207
|
+
function useCrossfadeText(text: string): { display: string; fading: boolean } {
|
|
208
|
+
const [display, setDisplay] = useState(text);
|
|
209
|
+
const [fading, setFading] = useState(false);
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (text === display) return;
|
|
213
|
+
setFading(true);
|
|
214
|
+
const timeout = setTimeout(() => {
|
|
215
|
+
setDisplay(text);
|
|
216
|
+
setFading(false);
|
|
217
|
+
}, CROSSFADE_MS);
|
|
218
|
+
return () => clearTimeout(timeout);
|
|
219
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
220
|
+
}, [text]);
|
|
221
|
+
|
|
222
|
+
return { display, fading };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Shared read-only context threaded through the item tree — avoids re-deriving
|
|
226
|
+
* the same store values at every nesting level. */
|
|
227
|
+
interface RunbookCtx {
|
|
228
|
+
projection: RunbookProjection;
|
|
229
|
+
run: WorkflowRun | null;
|
|
230
|
+
/** Reset key for per-group local state (manual overrides, loop chip
|
|
231
|
+
* selection): `${flow.id}:${run?.id}`. Flow id is part of the key because
|
|
232
|
+
* this view isn't remounted per flow and group keys are plain node ids that
|
|
233
|
+
* collide across flows — without it, toggles/chips on flow A bleed into
|
|
234
|
+
* flow B whenever the run identity alone doesn't change. */
|
|
235
|
+
resetKey: string;
|
|
236
|
+
selectedNodeId: string | null;
|
|
237
|
+
selectNode: (id: string | null) => void;
|
|
238
|
+
/** Registers/unregisters a step row's DOM element for the scroll-band
|
|
239
|
+
* auto-scroll effect, keyed by nodeId. */
|
|
240
|
+
registerRow: (nodeId: string, el: HTMLElement | null) => void;
|
|
241
|
+
/** Selected loop iteration index for the nearest enclosing loop group, set
|
|
242
|
+
* by clicking a rail chip; undefined outside a loop or before any chip is
|
|
243
|
+
* clicked, in which case rows show the live projection as normal. */
|
|
244
|
+
loopIterIndex?: number;
|
|
245
|
+
/** Simple register renders outcomes only; technical also reveals type names. */
|
|
246
|
+
register: 'simple' | 'technical';
|
|
247
|
+
/** Present when this flow is an HTTP operation: the request contract, rendered
|
|
248
|
+
* in place of the entry Input node's normal row so step 1 reads as the route. */
|
|
249
|
+
request?: {
|
|
250
|
+
inputNodeId: string;
|
|
251
|
+
method: string;
|
|
252
|
+
path: string;
|
|
253
|
+
fields: { name: string; type?: string; required?: boolean }[];
|
|
254
|
+
/** Input node defaults, used to build the example request body (technical). */
|
|
255
|
+
defaults: Record<string, unknown>;
|
|
256
|
+
};
|
|
257
|
+
/** Maps a step's real projection status through the paced-reveal queue to the
|
|
258
|
+
* status the document should actually show right now. */
|
|
259
|
+
displayStatus: (nodeId: string, real: StepVisualStatus) => StepVisualStatus;
|
|
260
|
+
/** Header toggle: expand every branch group regardless of taken state. */
|
|
261
|
+
expandAll: boolean;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The 9px status square: idle is an empty outline, ran/failed fill solid,
|
|
265
|
+
* active fills and breathes with the ember pulse, skipped dims. */
|
|
266
|
+
function StatusRing({ status }: { status: StepVisualStatus }) {
|
|
267
|
+
return (
|
|
268
|
+
<span
|
|
269
|
+
className={cn(
|
|
270
|
+
'flex size-[9px] shrink-0 items-center justify-center rounded-[3px] border border-border',
|
|
271
|
+
status === 'ran' && 'border-success bg-success',
|
|
272
|
+
status === 'failed' && 'border-destructive bg-destructive',
|
|
273
|
+
status === 'active' && 'ember-step-active border-highlight bg-highlight',
|
|
274
|
+
status === 'skipped' && 'opacity-40',
|
|
275
|
+
)}
|
|
276
|
+
>
|
|
277
|
+
{status === 'ran' && <span className="text-[7px] leading-none font-bold text-background">✓</span>}
|
|
278
|
+
</span>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Trace-kind badge labels + colors for the technical register. Colors are the
|
|
283
|
+
* brief's fixed palette (db blue, http violet, llm gold); compute stays muted.
|
|
284
|
+
* Borders render at 35% alpha so the chip reads as a quiet annotation, not a
|
|
285
|
+
* button. */
|
|
286
|
+
const KIND_LABEL: Record<string, string> = { db: 'SQL', http: 'HTTP', llm: 'LLM', compute: 'FN' };
|
|
287
|
+
const KIND_COLOR: Record<string, string> = { db: '#8fb8d8', http: '#c9a6e8', llm: '#d8c88f' };
|
|
288
|
+
|
|
289
|
+
const KIND_TITLE: Record<string, string> = {
|
|
290
|
+
db: 'Database query',
|
|
291
|
+
http: 'HTTP call',
|
|
292
|
+
llm: 'LLM call',
|
|
293
|
+
compute: 'Pure function',
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/** Small label for the "what it calls" line, keyed off kind. */
|
|
297
|
+
const KIND_DETAIL_LABEL: Record<string, string> = {
|
|
298
|
+
db: 'query',
|
|
299
|
+
http: 'endpoint',
|
|
300
|
+
llm: 'model',
|
|
301
|
+
compute: 'detail',
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/** Trace-kind badge with a hover card: the quiet chip expands into the step's
|
|
305
|
+
* full technical story — mechanism (full node description), the live trace
|
|
306
|
+
* line (receipt · exact duration · IO keys), and effect semantics. CSS-only
|
|
307
|
+
* hover; the card ignores pointer events so it never traps the mouse. */
|
|
308
|
+
function KindBadge({
|
|
309
|
+
kind,
|
|
310
|
+
typeName,
|
|
311
|
+
tech,
|
|
312
|
+
mutation,
|
|
313
|
+
error,
|
|
314
|
+
}: {
|
|
315
|
+
kind: 'db' | 'http' | 'llm' | 'compute';
|
|
316
|
+
typeName: string;
|
|
317
|
+
tech?: string;
|
|
318
|
+
mutation?: boolean;
|
|
319
|
+
error?: string;
|
|
320
|
+
}) {
|
|
321
|
+
const registry = useBuilderStore((st) => st.registry);
|
|
322
|
+
const definition = registry.has(typeName) ? registry.get(typeName).definition : undefined;
|
|
323
|
+
const label = KIND_LABEL[kind];
|
|
324
|
+
const color = KIND_COLOR[kind];
|
|
325
|
+
const chip =
|
|
326
|
+
kind === 'compute' ? (
|
|
327
|
+
<span className="rounded border border-border/35 px-1 font-mono text-[8.5px] leading-tight text-muted-foreground">
|
|
328
|
+
{label}
|
|
329
|
+
</span>
|
|
330
|
+
) : (
|
|
331
|
+
<span
|
|
332
|
+
className="rounded border px-1 font-mono text-[8.5px] leading-tight"
|
|
333
|
+
style={{ color, borderColor: `${color}59` }}
|
|
334
|
+
>
|
|
335
|
+
{label}
|
|
336
|
+
</span>
|
|
337
|
+
);
|
|
338
|
+
return (
|
|
339
|
+
<span className="group/badge relative shrink-0 cursor-help">
|
|
340
|
+
{chip}
|
|
341
|
+
<span className="pointer-events-none absolute top-full left-0 z-50 mt-1.5 hidden w-[380px] rounded-lg border border-border bg-card p-3 shadow-[0_18px_48px_rgb(0_0_0/0.55)] group-hover/badge:block">
|
|
342
|
+
<span className="flex items-baseline gap-2">
|
|
343
|
+
<span className="font-mono text-[9px] tracking-wide" style={kind !== 'compute' ? { color } : undefined}>
|
|
344
|
+
{KIND_TITLE[kind]}
|
|
345
|
+
</span>
|
|
346
|
+
<span className="font-mono text-[10.5px] text-foreground">{typeName}</span>
|
|
347
|
+
</span>
|
|
348
|
+
{definition?.description && (
|
|
349
|
+
<span className="mt-1.5 block text-[11.5px] leading-relaxed text-muted-foreground">
|
|
350
|
+
{definition.description}
|
|
351
|
+
</span>
|
|
352
|
+
)}
|
|
353
|
+
{definition?.traceDetail && (
|
|
354
|
+
<span className="mt-2 block">
|
|
355
|
+
<span
|
|
356
|
+
className="mr-1.5 font-mono text-[8.5px] tracking-wide uppercase"
|
|
357
|
+
style={kind !== 'compute' ? { color } : undefined}
|
|
358
|
+
>
|
|
359
|
+
{KIND_DETAIL_LABEL[kind]}
|
|
360
|
+
</span>
|
|
361
|
+
<span className="font-mono text-[10.5px] leading-relaxed break-words text-foreground/90">
|
|
362
|
+
{definition.traceDetail}
|
|
363
|
+
</span>
|
|
364
|
+
</span>
|
|
365
|
+
)}
|
|
366
|
+
{tech && (
|
|
367
|
+
<span className="mt-2 block border-t border-border/60 pt-2 font-mono text-[10.5px] leading-relaxed text-foreground/90">
|
|
368
|
+
{tech}
|
|
369
|
+
</span>
|
|
370
|
+
)}
|
|
371
|
+
{error && (
|
|
372
|
+
<span className="mt-1.5 block font-mono text-[10.5px] text-destructive-foreground">{error}</span>
|
|
373
|
+
)}
|
|
374
|
+
{mutation && (
|
|
375
|
+
<span className="mt-1.5 block text-[10px] text-warn">
|
|
376
|
+
⚡ mutation — dry-runs under safe mode, executes live otherwise
|
|
377
|
+
</span>
|
|
378
|
+
)}
|
|
379
|
+
</span>
|
|
380
|
+
</span>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function StepRow({ step, ctx }: { step: RunbookStep; ctx: RunbookCtx }) {
|
|
385
|
+
const registry = useBuilderStore((s) => s.registry);
|
|
386
|
+
const proj = ctx.projection.steps.get(step.nodeId);
|
|
387
|
+
const status = ctx.displayStatus(step.nodeId, proj?.status ?? 'idle');
|
|
388
|
+
const executed = status === 'ran' || status === 'failed' || status === 'active';
|
|
389
|
+
const technical = ctx.register === 'technical';
|
|
390
|
+
|
|
391
|
+
// Result and display nodes surface their collected payload right in the
|
|
392
|
+
// document — an inline expandable JSON box, so the run's actual result is
|
|
393
|
+
// readable without hopping to the Inspector. Auto-opens once it has run.
|
|
394
|
+
// Result (internal terminus), Response (HTTP terminus — its { status, body }
|
|
395
|
+
// is the operation's resulting JSON), and display-tagged nodes surface their
|
|
396
|
+
// output inline, auto-expanding once they run.
|
|
397
|
+
const showsResult =
|
|
398
|
+
step.typeName === 'Result' ||
|
|
399
|
+
step.typeName === 'Response' ||
|
|
400
|
+
(registry.has(step.typeName) && (registry.get(step.typeName).definition.tags?.includes('display') ?? false));
|
|
401
|
+
const resultState = ctx.run?.nodeStates[step.nodeId];
|
|
402
|
+
const resultValue = resultState?.output ?? resultState?.input;
|
|
403
|
+
const hasResult = showsResult && resultValue !== undefined;
|
|
404
|
+
const [resultOpen, setResultOpen] = useState(false);
|
|
405
|
+
const [requestOpen, setRequestOpen] = useState(false);
|
|
406
|
+
const wasExecuted = useRef(false);
|
|
407
|
+
if (hasResult && (status === 'ran' || status === 'failed') && !wasExecuted.current) {
|
|
408
|
+
wasExecuted.current = true;
|
|
409
|
+
if (!resultOpen) setResultOpen(true);
|
|
410
|
+
}
|
|
411
|
+
if (!hasResult && wasExecuted.current) wasExecuted.current = false;
|
|
412
|
+
|
|
413
|
+
// The outcome column: simple speaks the human outcome (last info log, else
|
|
414
|
+
// the step's description); technical speaks the projection's `tech` line (type
|
|
415
|
+
// · ms · in→out, `#N` receipt-prefixed) once the step has run. Before it runs,
|
|
416
|
+
// technical falls back to the description rather than the bare type name —
|
|
417
|
+
// repeating the type here would just double the type-mono badge beside the name.
|
|
418
|
+
// A loop chip is selected and this row lives in that loop's body with a
|
|
419
|
+
// recorded execution for the chosen iteration: show that iteration's data
|
|
420
|
+
// instead of the live (latest) projection. Falls through to the normal
|
|
421
|
+
// live text otherwise (e.g. iteration index has no execution yet).
|
|
422
|
+
let text = technical
|
|
423
|
+
? executed
|
|
424
|
+
? proj?.tech ?? step.typeName
|
|
425
|
+
: step.description || step.typeName
|
|
426
|
+
: executed
|
|
427
|
+
? proj?.outcome || step.simpleDescription
|
|
428
|
+
: step.simpleDescription;
|
|
429
|
+
let destructive = status === 'failed';
|
|
430
|
+
if (ctx.loopIterIndex != null) {
|
|
431
|
+
const exec = ctx.run?.nodeStates[step.nodeId]?.executions?.[ctx.loopIterIndex];
|
|
432
|
+
if (exec) {
|
|
433
|
+
text = iterationSummary(exec);
|
|
434
|
+
destructive = !!exec.error;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const { display, fading } = useCrossfadeText(text);
|
|
439
|
+
const selected = ctx.selectedNodeId === step.nodeId;
|
|
440
|
+
|
|
441
|
+
// For an HTTP operation, the entry Input node renders as the request contract:
|
|
442
|
+
// method + path, then the accepted fields (required marked). Register-aware —
|
|
443
|
+
// simple shows plain field names, technical adds `:type`. Keeps the row's
|
|
444
|
+
// number, status ring, and selection so it's still step 1 of the run.
|
|
445
|
+
const isRequest = ctx.request?.inputNodeId === step.nodeId;
|
|
446
|
+
if (isRequest && ctx.request) {
|
|
447
|
+
const req = ctx.request;
|
|
448
|
+
const methodColor = KIND_COLOR.http;
|
|
449
|
+
// Technical only: an example request body. Use a real scalar default when
|
|
450
|
+
// present, but replace internal {$env}/{$secret} refs (and absent fields)
|
|
451
|
+
// with a `<type>` placeholder — a client would POST a value, not the ref.
|
|
452
|
+
const exampleBody = Object.fromEntries(
|
|
453
|
+
req.fields.map((f) => {
|
|
454
|
+
const d = req.defaults[f.name];
|
|
455
|
+
const isRef = !!d && typeof d === 'object' && !Array.isArray(d) && ('$env' in d || '$secret' in d);
|
|
456
|
+
const usable = f.name in req.defaults && !isRef;
|
|
457
|
+
return [f.name, usable ? d : `<${f.type ?? 'any'}>`];
|
|
458
|
+
}),
|
|
459
|
+
);
|
|
460
|
+
return (
|
|
461
|
+
<>
|
|
462
|
+
<div
|
|
463
|
+
ref={(el) => ctx.registerRow(step.nodeId, el)}
|
|
464
|
+
role="button"
|
|
465
|
+
tabIndex={0}
|
|
466
|
+
onClick={() => ctx.selectNode(step.nodeId)}
|
|
467
|
+
onKeyDown={(e) => {
|
|
468
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
ctx.selectNode(step.nodeId);
|
|
471
|
+
}
|
|
472
|
+
}}
|
|
473
|
+
className={cn(
|
|
474
|
+
'-mx-2.5 flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 transition-colors hover:bg-highlight/5',
|
|
475
|
+
selected && 'bg-highlight/15 ring-2 ring-inset ring-highlight shadow-lg',
|
|
476
|
+
)}
|
|
477
|
+
>
|
|
478
|
+
<span className="w-11 shrink-0 text-right font-mono text-[10.5px] text-muted-foreground">{step.number}</span>
|
|
479
|
+
<StatusRing status={status} />
|
|
480
|
+
<span
|
|
481
|
+
className="shrink-0 rounded border px-1.5 py-px font-mono text-[10px] font-semibold tracking-wide"
|
|
482
|
+
style={{ color: methodColor, borderColor: `${methodColor}59` }}
|
|
483
|
+
>
|
|
484
|
+
{req.method}
|
|
485
|
+
</span>
|
|
486
|
+
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-foreground">{req.path}</span>
|
|
487
|
+
{req.fields.length > 0 && (
|
|
488
|
+
<span className="shrink-0 text-[10px] uppercase tracking-widest text-muted-foreground">
|
|
489
|
+
{req.fields.length} field{req.fields.length === 1 ? '' : 's'}
|
|
490
|
+
</span>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
{/* Technical: reveal the request shape as JSON on demand. Simple stays
|
|
494
|
+
clean — just the endpoint above. */}
|
|
495
|
+
{technical && req.fields.length > 0 && (
|
|
496
|
+
<div className="ml-[54px] mb-1">
|
|
497
|
+
<button
|
|
498
|
+
type="button"
|
|
499
|
+
onClick={() => setRequestOpen((o) => !o)}
|
|
500
|
+
aria-expanded={requestOpen}
|
|
501
|
+
className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground transition-colors hover:text-foreground"
|
|
502
|
+
>
|
|
503
|
+
<ChevronRightIcon className={cn('size-3 transition-transform', requestOpen && 'rotate-90')} />
|
|
504
|
+
Request
|
|
505
|
+
</button>
|
|
506
|
+
{requestOpen && (
|
|
507
|
+
<div className="mt-1">
|
|
508
|
+
<Json value={exampleBody} maxHeight={320} />
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
</div>
|
|
512
|
+
)}
|
|
513
|
+
</>
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return (
|
|
518
|
+
<>
|
|
519
|
+
<div
|
|
520
|
+
ref={(el) => ctx.registerRow(step.nodeId, el)}
|
|
521
|
+
role="button"
|
|
522
|
+
tabIndex={0}
|
|
523
|
+
onClick={() => ctx.selectNode(step.nodeId)}
|
|
524
|
+
onKeyDown={(e) => {
|
|
525
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
526
|
+
e.preventDefault();
|
|
527
|
+
ctx.selectNode(step.nodeId);
|
|
528
|
+
}
|
|
529
|
+
}}
|
|
530
|
+
className={cn(
|
|
531
|
+
'-mx-2.5 flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 transition-colors hover:bg-highlight/5',
|
|
532
|
+
selected && 'bg-highlight/15 font-medium ring-2 ring-inset ring-highlight shadow-lg',
|
|
533
|
+
)}
|
|
534
|
+
>
|
|
535
|
+
<span className="w-11 shrink-0 text-right font-mono text-[10.5px] text-muted-foreground">{step.number}</span>
|
|
536
|
+
<StatusRing status={status} />
|
|
537
|
+
<span className="shrink-0 truncate text-[13.5px]" style={{ fontWeight: 550 }}>
|
|
538
|
+
{step.label}
|
|
539
|
+
</span>
|
|
540
|
+
{technical && step.traceKind && (
|
|
541
|
+
<KindBadge
|
|
542
|
+
kind={step.traceKind}
|
|
543
|
+
typeName={step.typeName}
|
|
544
|
+
tech={proj?.tech}
|
|
545
|
+
mutation={step.mutation}
|
|
546
|
+
error={status === 'failed' ? ctx.run?.nodeStates[step.nodeId]?.error : undefined}
|
|
547
|
+
/>
|
|
548
|
+
)}
|
|
549
|
+
<span className={cn('shrink-0 font-mono text-[9.5px] text-muted-foreground', !technical && 'hidden')}>
|
|
550
|
+
{step.typeName}
|
|
551
|
+
</span>
|
|
552
|
+
{step.mutation && (
|
|
553
|
+
<span className="shrink-0 text-[11px] text-warn" title="Mutation: has side effects">
|
|
554
|
+
⚡
|
|
555
|
+
</span>
|
|
556
|
+
)}
|
|
557
|
+
{(() => {
|
|
558
|
+
if (!technical) return null;
|
|
559
|
+
const attempts = ctx.run?.nodeStates[step.nodeId]?.attempts;
|
|
560
|
+
return attempts !== undefined && attempts > 1 ? (
|
|
561
|
+
<span
|
|
562
|
+
className="shrink-0 rounded-sm border border-warn/40 px-1 font-mono text-[10px] text-warn"
|
|
563
|
+
title={`Implementation retried — ${attempts} attempts total`}
|
|
564
|
+
>
|
|
565
|
+
retried ×{attempts - 1}
|
|
566
|
+
</span>
|
|
567
|
+
) : null;
|
|
568
|
+
})()}
|
|
569
|
+
{ctx.run?.nodeStates[step.nodeId]?.mocked && (
|
|
570
|
+
<span
|
|
571
|
+
className="shrink-0 rounded-sm border border-warn/40 px-1 font-mono text-[10px] text-warn"
|
|
572
|
+
title="This node returned its scenario mock — nothing executed."
|
|
573
|
+
>
|
|
574
|
+
mocked
|
|
575
|
+
</span>
|
|
576
|
+
)}
|
|
577
|
+
{step.subflow && (
|
|
578
|
+
<span className="shrink-0 rounded border border-dashed border-border px-1 text-[10px] text-muted-foreground">
|
|
579
|
+
↳ subflow
|
|
580
|
+
</span>
|
|
581
|
+
)}
|
|
582
|
+
{step.decisionArms && step.decisionArms.length > 0 && (
|
|
583
|
+
<span className="shrink-0 rounded border border-border px-1 text-[10px] text-muted-foreground">
|
|
584
|
+
⑂ {step.decisionArms.length}
|
|
585
|
+
</span>
|
|
586
|
+
)}
|
|
587
|
+
<span
|
|
588
|
+
className={cn(
|
|
589
|
+
'min-w-0 flex-1 truncate transition-opacity ease-out',
|
|
590
|
+
technical ? 'font-mono text-[11px]' : 'text-[12.5px]',
|
|
591
|
+
destructive ? 'text-destructive' : status === 'active' ? 'text-highlight' : 'text-muted-foreground',
|
|
592
|
+
!executed && 'opacity-55',
|
|
593
|
+
fading && 'opacity-0',
|
|
594
|
+
)}
|
|
595
|
+
style={{ transitionDuration: `${CROSSFADE_MS}ms` }}
|
|
596
|
+
>
|
|
597
|
+
{display}
|
|
598
|
+
</span>
|
|
599
|
+
{status === 'ran' && proj?.durationMs != null && (
|
|
600
|
+
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{formatDuration(proj.durationMs)}</span>
|
|
601
|
+
)}
|
|
602
|
+
</div>
|
|
603
|
+
{hasResult && (
|
|
604
|
+
<div className="ml-[54px] mb-1">
|
|
605
|
+
<button
|
|
606
|
+
type="button"
|
|
607
|
+
onClick={() => setResultOpen((o) => !o)}
|
|
608
|
+
aria-expanded={resultOpen}
|
|
609
|
+
className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground transition-colors hover:text-foreground"
|
|
610
|
+
>
|
|
611
|
+
<ChevronRightIcon className={cn('size-3 transition-transform', resultOpen && 'rotate-90')} />
|
|
612
|
+
Result
|
|
613
|
+
</button>
|
|
614
|
+
{resultOpen && (
|
|
615
|
+
<div className="mt-1">
|
|
616
|
+
<Json value={resultValue} maxHeight={320} />
|
|
617
|
+
</div>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
)}
|
|
621
|
+
</>
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function BranchGroupRow({ group, ctx }: { group: RunbookBranchGroup; ctx: RunbookCtx }) {
|
|
626
|
+
const armKey = `${group.ownerId}::${group.arm}`;
|
|
627
|
+
const arm = ctx.projection.arms.get(armKey);
|
|
628
|
+
const taken = arm?.takenNow ?? false;
|
|
629
|
+
|
|
630
|
+
// A sibling arm of the same owner took the run and this one didn't — dim
|
|
631
|
+
// and collapse it (the panel-choreography feel: only the taken path stays
|
|
632
|
+
// lit), unless the user explicitly opened it.
|
|
633
|
+
const dimmed = useMemo(
|
|
634
|
+
() =>
|
|
635
|
+
!taken &&
|
|
636
|
+
[...ctx.projection.arms.entries()].some(
|
|
637
|
+
([key, a]) => key !== armKey && key.startsWith(`${group.ownerId}::`) && a.takenNow,
|
|
638
|
+
),
|
|
639
|
+
[ctx.projection.arms, armKey, group.ownerId, taken],
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
// Taken groups start open; others start closed. A group that becomes taken
|
|
643
|
+
// mid-run force-opens unless the user just closed it; a dimmed sibling
|
|
644
|
+
// force-collapses unless the user just opened it. Both overrides — and the
|
|
645
|
+
// open/closed baseline itself — forget themselves whenever the reset key
|
|
646
|
+
// changes (new run OR flow switch: node ids collide across flows).
|
|
647
|
+
const [openState, setOpen] = useState(taken);
|
|
648
|
+
const open = ctx.expandAll || openState;
|
|
649
|
+
const wasTaken = useRef(taken);
|
|
650
|
+
const manualOverride = useRef<'open' | 'closed' | null>(null);
|
|
651
|
+
const prevResetKey = useRef(ctx.resetKey);
|
|
652
|
+
|
|
653
|
+
useEffect(() => {
|
|
654
|
+
if (ctx.resetKey !== prevResetKey.current) {
|
|
655
|
+
prevResetKey.current = ctx.resetKey;
|
|
656
|
+
manualOverride.current = null;
|
|
657
|
+
wasTaken.current = taken;
|
|
658
|
+
setOpen(taken);
|
|
659
|
+
}
|
|
660
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
661
|
+
}, [ctx.resetKey]);
|
|
662
|
+
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
if (taken && !wasTaken.current && manualOverride.current !== 'closed') setOpen(true);
|
|
665
|
+
wasTaken.current = taken;
|
|
666
|
+
}, [taken]);
|
|
667
|
+
|
|
668
|
+
useEffect(() => {
|
|
669
|
+
if (dimmed && manualOverride.current !== 'open') setOpen(false);
|
|
670
|
+
}, [dimmed]);
|
|
671
|
+
|
|
672
|
+
const toggleOpen = () => {
|
|
673
|
+
setOpen((o) => {
|
|
674
|
+
manualOverride.current = o ? 'closed' : 'open';
|
|
675
|
+
return !o;
|
|
676
|
+
});
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
return (
|
|
680
|
+
<div className="py-0.5">
|
|
681
|
+
<div
|
|
682
|
+
role="button"
|
|
683
|
+
tabIndex={0}
|
|
684
|
+
onClick={toggleOpen}
|
|
685
|
+
onKeyDown={(e) => {
|
|
686
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
687
|
+
e.preventDefault();
|
|
688
|
+
toggleOpen();
|
|
689
|
+
}
|
|
690
|
+
}}
|
|
691
|
+
className={cn(
|
|
692
|
+
'-mx-2.5 flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1 transition-opacity duration-[400ms]',
|
|
693
|
+
(dimmed || (!open && !taken)) && 'opacity-50',
|
|
694
|
+
)}
|
|
695
|
+
>
|
|
696
|
+
<span className="w-11 shrink-0 text-right font-mono text-[10.5px] text-muted-foreground">{group.number}</span>
|
|
697
|
+
<ChevronRightIcon
|
|
698
|
+
className={cn('size-3 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90')}
|
|
699
|
+
/>
|
|
700
|
+
<span className="min-w-0 truncate text-[13px]">
|
|
701
|
+
If <span className="font-medium text-highlight">{group.arm}</span>:
|
|
702
|
+
</span>
|
|
703
|
+
{taken && (
|
|
704
|
+
<Badge variant="highlight" className="ml-auto shrink-0 text-[9px]">
|
|
705
|
+
this run
|
|
706
|
+
</Badge>
|
|
707
|
+
)}
|
|
708
|
+
</div>
|
|
709
|
+
{open && (
|
|
710
|
+
<div className={cn('ml-[54px] border-l-[1.5px] pl-3', taken ? 'border-highlight/40' : 'border-border')}>
|
|
711
|
+
<RunbookItems items={group.items} ctx={ctx} />
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
714
|
+
</div>
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const loopDotColor: Record<string, string> = {
|
|
719
|
+
pending: 'bg-muted-foreground/40',
|
|
720
|
+
done: 'bg-success',
|
|
721
|
+
failed: 'bg-destructive',
|
|
722
|
+
running: 'bg-highlight motion-safe:animate-pulse',
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
function LoopGroupRow({ group, ctx }: { group: RunbookLoopGroup; ctx: RunbookCtx }) {
|
|
726
|
+
const loop = ctx.projection.loops.get(group.forEachId);
|
|
727
|
+
const realStatuses = loop?.statuses ?? [];
|
|
728
|
+
// Mini-dots fill at the loop pace; the rail's presence and chip selection key
|
|
729
|
+
// off the real statuses so a chip is clickable the moment its iteration ran.
|
|
730
|
+
const statuses = usePacedLoopStatuses(realStatuses, ctx.resetKey);
|
|
731
|
+
// Chip rail only appears once at least one iteration has actually executed
|
|
732
|
+
// (not merely counted from an expected total) — dots alone carry the
|
|
733
|
+
// "nothing's happened yet" state.
|
|
734
|
+
const hasExecutions = realStatuses.some((s) => s !== 'pending');
|
|
735
|
+
|
|
736
|
+
// Which iteration chip is selected — swaps the body rows below to that
|
|
737
|
+
// execution's data. Clears whenever the reset key changes (new run OR flow
|
|
738
|
+
// switch: forEach ids collide across flows).
|
|
739
|
+
const [selectedIteration, setSelectedIteration] = useState<number | null>(null);
|
|
740
|
+
const prevResetKey = useRef(ctx.resetKey);
|
|
741
|
+
useEffect(() => {
|
|
742
|
+
if (ctx.resetKey !== prevResetKey.current) {
|
|
743
|
+
prevResetKey.current = ctx.resetKey;
|
|
744
|
+
setSelectedIteration(null);
|
|
745
|
+
}
|
|
746
|
+
}, [ctx.resetKey]);
|
|
747
|
+
|
|
748
|
+
const bodyCtx: RunbookCtx = { ...ctx, loopIterIndex: selectedIteration ?? undefined };
|
|
749
|
+
|
|
750
|
+
// The array being swept lives on the ForEach node's input; each chip is one
|
|
751
|
+
// element. Show the *current* element's value subtly beside the rail so you
|
|
752
|
+
// can read exactly what's being looped right now — the selected chip if the
|
|
753
|
+
// user picked one, else the running iteration, else the last that ran.
|
|
754
|
+
const feInput = ctx.run?.nodeStates[group.forEachId]?.input as Record<string, unknown> | undefined;
|
|
755
|
+
const items = Array.isArray(feInput?.items) ? (feInput!.items as unknown[]) : undefined;
|
|
756
|
+
const runningIdx = statuses.indexOf('running');
|
|
757
|
+
let lastRanIdx = -1;
|
|
758
|
+
for (let i = statuses.length - 1; i >= 0; i--) {
|
|
759
|
+
if (statuses[i] === 'done' || statuses[i] === 'failed') { lastRanIdx = i; break; }
|
|
760
|
+
}
|
|
761
|
+
const activeIdx = selectedIteration ?? (runningIdx >= 0 ? runningIdx : lastRanIdx);
|
|
762
|
+
const activeItem = items && activeIdx >= 0 ? items[activeIdx] : undefined;
|
|
763
|
+
const activeItemText = activeItem === undefined ? '' : compactValue(activeItem);
|
|
764
|
+
|
|
765
|
+
return (
|
|
766
|
+
<div className="py-0.5">
|
|
767
|
+
<div className="-mx-2.5 flex items-center gap-2.5 rounded-lg px-2.5 py-1">
|
|
768
|
+
<span className="w-11 shrink-0 text-right font-mono text-[10.5px] text-muted-foreground">{group.number}</span>
|
|
769
|
+
<span className="min-w-0 truncate text-[13px]">
|
|
770
|
+
For each — <span className="font-medium">{group.label}</span> ({loop?.count ?? 0})
|
|
771
|
+
</span>
|
|
772
|
+
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
773
|
+
{statuses.map((s, i) => (
|
|
774
|
+
<span key={i} className={cn('size-[5px] shrink-0 rounded-full', loopDotColor[s])} />
|
|
775
|
+
))}
|
|
776
|
+
</div>
|
|
777
|
+
</div>
|
|
778
|
+
<div className="ml-[54px] border-l-[1.5px] border-border pl-3">
|
|
779
|
+
{hasExecutions && (
|
|
780
|
+
<div className="mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 pt-1">
|
|
781
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
782
|
+
{statuses.map((s, i) => (
|
|
783
|
+
<button
|
|
784
|
+
key={i}
|
|
785
|
+
type="button"
|
|
786
|
+
onClick={() => setSelectedIteration(i)}
|
|
787
|
+
className={cn(
|
|
788
|
+
'flex items-center gap-1 rounded border border-border px-1.5 py-0.5 font-mono text-[9.5px] text-muted-foreground hover:border-highlight/40',
|
|
789
|
+
(selectedIteration === i || (selectedIteration === null && i === activeIdx)) &&
|
|
790
|
+
'border-highlight/60 text-foreground',
|
|
791
|
+
)}
|
|
792
|
+
>
|
|
793
|
+
{i + 1}
|
|
794
|
+
<span className={cn('size-1.5 rounded-full', loopDotColor[s])} />
|
|
795
|
+
</button>
|
|
796
|
+
))}
|
|
797
|
+
</div>
|
|
798
|
+
{activeItemText && (
|
|
799
|
+
<span
|
|
800
|
+
className="min-w-0 truncate font-mono text-[10px] text-muted-foreground/60"
|
|
801
|
+
title={typeof activeItem === 'string' ? activeItem : JSON.stringify(activeItem, null, 2)}
|
|
802
|
+
>
|
|
803
|
+
{activeItemText}
|
|
804
|
+
</span>
|
|
805
|
+
)}
|
|
806
|
+
</div>
|
|
807
|
+
)}
|
|
808
|
+
<RunbookItems items={group.items} ctx={bodyCtx} />
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/** One-line, ~80-char preview of a loop item — a bare string as-is, anything
|
|
815
|
+
* structured as compact JSON — for the subtle "what's looping now" annotation. */
|
|
816
|
+
function compactValue(value: unknown): string {
|
|
817
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
818
|
+
if (text === undefined) return '';
|
|
819
|
+
return text.length > 80 ? `${text.slice(0, 79)}…` : text;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function RunbookItems({ items, ctx }: { items: RunbookItem[]; ctx: RunbookCtx }) {
|
|
823
|
+
return (
|
|
824
|
+
<div className="flex flex-col gap-0.5">
|
|
825
|
+
{items.map((item) => {
|
|
826
|
+
if (item.kind === 'step') return <StepRow key={item.nodeId} step={item} ctx={ctx} />;
|
|
827
|
+
if (item.kind === 'branch') {
|
|
828
|
+
return <BranchGroupRow key={`${item.ownerId}::${item.arm}`} group={item} ctx={ctx} />;
|
|
829
|
+
}
|
|
830
|
+
return <LoopGroupRow key={item.forEachId} group={item} ctx={ctx} />;
|
|
831
|
+
})}
|
|
832
|
+
</div>
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function RunbookHeader({
|
|
837
|
+
name,
|
|
838
|
+
environment,
|
|
839
|
+
subtitle,
|
|
840
|
+
expandAll,
|
|
841
|
+
onToggleExpandAll,
|
|
842
|
+
}: {
|
|
843
|
+
name: string;
|
|
844
|
+
environment?: string;
|
|
845
|
+
subtitle: string;
|
|
846
|
+
expandAll: boolean;
|
|
847
|
+
onToggleExpandAll: () => void;
|
|
848
|
+
}) {
|
|
849
|
+
return (
|
|
850
|
+
<div className="mb-6">
|
|
851
|
+
<h1 className="text-[22px] font-bold tracking-tight">{name}</h1>
|
|
852
|
+
<div className="mt-1.5 flex items-center gap-2 text-[12px] text-muted-foreground">
|
|
853
|
+
{environment && (
|
|
854
|
+
<Badge variant="outline" className="text-[9px] uppercase">
|
|
855
|
+
{environment}
|
|
856
|
+
</Badge>
|
|
857
|
+
)}
|
|
858
|
+
<span className="truncate">{subtitle}</span>
|
|
859
|
+
<button
|
|
860
|
+
type="button"
|
|
861
|
+
onClick={onToggleExpandAll}
|
|
862
|
+
title={expandAll ? 'Collapse branches' : 'Expand all branches'}
|
|
863
|
+
aria-label={expandAll ? 'Collapse branches' : 'Expand all branches'}
|
|
864
|
+
aria-pressed={expandAll}
|
|
865
|
+
className={cn(
|
|
866
|
+
'ml-auto flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors',
|
|
867
|
+
expandAll ? 'bg-secondary/60 text-foreground' : 'text-muted-foreground hover:bg-secondary/40 hover:text-foreground',
|
|
868
|
+
)}
|
|
869
|
+
>
|
|
870
|
+
{expandAll ? <ChevronsDownUpIcon className="size-3.5" /> : <ChevronsUpDownIcon className="size-3.5" />}
|
|
871
|
+
</button>
|
|
872
|
+
</div>
|
|
873
|
+
</div>
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Calm end-of-document run readout. While the reveal queue is still draining
|
|
878
|
+
* (`filling`) it stays "Running" even though the engine already finished — the
|
|
879
|
+
* document isn't done until the last step has been read out. */
|
|
880
|
+
function RunbookFooter({ run, filling }: { run: WorkflowRun; filling: boolean }) {
|
|
881
|
+
const live = run.status === 'running' || filling;
|
|
882
|
+
const ran = Object.values(run.nodeStates).filter(
|
|
883
|
+
(s) => s.status === 'succeeded' || s.status === 'failed',
|
|
884
|
+
).length;
|
|
885
|
+
const label = live
|
|
886
|
+
? 'Running'
|
|
887
|
+
: run.status === 'succeeded'
|
|
888
|
+
? 'Completed'
|
|
889
|
+
: run.status === 'failed'
|
|
890
|
+
? 'Failed'
|
|
891
|
+
: 'Cancelled';
|
|
892
|
+
const tone = live
|
|
893
|
+
? 'text-highlight'
|
|
894
|
+
: run.status === 'failed'
|
|
895
|
+
? 'text-destructive'
|
|
896
|
+
: run.status === 'succeeded'
|
|
897
|
+
? 'text-success'
|
|
898
|
+
: 'text-muted-foreground';
|
|
899
|
+
return (
|
|
900
|
+
<div className="mt-8 flex items-center gap-2 border-t border-border/60 pt-3 text-[11px] text-muted-foreground">
|
|
901
|
+
<span className={cn('size-1.5 shrink-0 rounded-full bg-current', tone, live && 'motion-safe:animate-pulse')} />
|
|
902
|
+
<span className={cn('font-medium', tone)}>{label}</span>
|
|
903
|
+
<span className="font-mono text-[10.5px] text-muted-foreground/80">
|
|
904
|
+
{ran} step{ran === 1 ? '' : 's'} executed
|
|
905
|
+
</span>
|
|
906
|
+
</div>
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* The executable runbook: a linear document rendering of the flow — numbered
|
|
912
|
+
* steps, branch groups with taken/coverage state, and loop groups with an
|
|
913
|
+
* iteration rail — the primary way to read a flow.
|
|
914
|
+
* Row clicks drive selection; the Inspector (not a second detail panel here)
|
|
915
|
+
* shows the clicked node's full state.
|
|
916
|
+
*/
|
|
917
|
+
/** The canvas holding pattern shown while the agent scaffolds a just-created
|
|
918
|
+
* operation. The stub (name + route) is already selected; this stands in for
|
|
919
|
+
* the empty body until the agent writes the flow, which then loads live. */
|
|
920
|
+
function BuildingHolding({ route }: { route?: string }) {
|
|
921
|
+
return (
|
|
922
|
+
<div className="mt-10 flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-highlight/30 bg-highlight/[0.03] px-6 py-14 text-center">
|
|
923
|
+
<span className="ember-step-active flex size-11 items-center justify-center rounded-full bg-highlight/15 text-highlight">
|
|
924
|
+
<SparklesIcon className="size-5" />
|
|
925
|
+
</span>
|
|
926
|
+
<div className="flex flex-col gap-1">
|
|
927
|
+
<span className="text-[14px] font-medium text-foreground">Building this operation…</span>
|
|
928
|
+
<span className="max-w-sm text-[12.5px] leading-relaxed text-muted-foreground">
|
|
929
|
+
The agent is writing the flow. It appears here live the moment it’s done — you can watch its
|
|
930
|
+
progress in the Agent panel.
|
|
931
|
+
</span>
|
|
932
|
+
</div>
|
|
933
|
+
{route && (
|
|
934
|
+
<span className="rounded-md border border-border/60 bg-secondary/40 px-2 py-1 font-mono text-[11.5px] text-muted-foreground">
|
|
935
|
+
{route}
|
|
936
|
+
</span>
|
|
937
|
+
)}
|
|
938
|
+
<span className="flex items-center gap-1.5 text-[11px] uppercase tracking-widest text-highlight/80">
|
|
939
|
+
<span className="size-1.5 animate-pulse rounded-full bg-highlight" />
|
|
940
|
+
waiting for the agent
|
|
941
|
+
</span>
|
|
942
|
+
</div>
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
export function RunbookView({ register = 'simple' }: { register?: 'simple' | 'technical' } = {}) {
|
|
947
|
+
const flow = useBuilderStore((s) => s.flow);
|
|
948
|
+
const registry = useBuilderStore((s) => s.registry);
|
|
949
|
+
const buildingOperationId = useBuilderStore((s) => s.buildingOperationId);
|
|
950
|
+
const run = useBuilderStore((s) => s.run);
|
|
951
|
+
const logs = useBuilderStore((s) => s.logs);
|
|
952
|
+
const runHistory = useBuilderStore((s) => s.runHistory);
|
|
953
|
+
const selectedNodeId = useBuilderStore((s) => s.selectedNodeId);
|
|
954
|
+
const selectNode = useBuilderStore((s) => s.selectNode);
|
|
955
|
+
|
|
956
|
+
const doc = useMemo(() => buildRunbook(flow, registry), [flow, registry]);
|
|
957
|
+
const projection = useMemo(
|
|
958
|
+
() => projectRunbook(doc, run, logs, runHistory, flow.id),
|
|
959
|
+
[doc, run, logs, runHistory],
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
// Paced reveal: the document fills at a readable rhythm even when the engine
|
|
963
|
+
// finished in one frame. displayStatus trails the real projection; the run is
|
|
964
|
+
// still "filling" while activeDisplayId is non-null.
|
|
965
|
+
const orderedStepIds = useMemo(() => flattenStepIds(doc.items), [doc]);
|
|
966
|
+
const { displayStatus, activeDisplayId } = usePacedReveal(orderedStepIds, projection, run?.id ?? null);
|
|
967
|
+
|
|
968
|
+
// ── calm auto-scroll: keep the active row inside the 20%-75% band of the
|
|
969
|
+
// scroll container, at most once every SCROLL_MIN_GAP_MS. ──
|
|
970
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
971
|
+
const [expandAll, setExpandAll] = useState(false);
|
|
972
|
+
const rowRefsMap = useRef(new Map<string, HTMLElement>());
|
|
973
|
+
const registerRow = (nodeId: string, el: HTMLElement | null) => {
|
|
974
|
+
if (el) rowRefsMap.current.set(nodeId, el);
|
|
975
|
+
else rowRefsMap.current.delete(nodeId);
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
// Scroll follows the *display* cursor ONLY while there is live motion — the
|
|
979
|
+
// paced reveal draining, or an actively running (incl. stepped) run. At rest
|
|
980
|
+
// the document never repositions itself: the reader owns the scrollbar.
|
|
981
|
+
const activeNodeId = useMemo(() => {
|
|
982
|
+
if (activeDisplayId) return activeDisplayId;
|
|
983
|
+
if (run?.status !== 'running') return null;
|
|
984
|
+
const runningId = Object.entries(run.nodeStates).find(([, s]) => s.status === 'running')?.[0];
|
|
985
|
+
// Stepped runs sit 'running' between steps with no running node — follow
|
|
986
|
+
// the step cursor (selection tracks the just-executed step).
|
|
987
|
+
return runningId ?? selectedNodeId;
|
|
988
|
+
}, [activeDisplayId, run, selectedNodeId]);
|
|
989
|
+
|
|
990
|
+
const activeNodeIdRef = useRef(activeNodeId);
|
|
991
|
+
activeNodeIdRef.current = activeNodeId;
|
|
992
|
+
|
|
993
|
+
// Manual scroll input detaches the follow for the rest of this run — the
|
|
994
|
+
// wheel always beats the camera. A new run re-attaches.
|
|
995
|
+
const detachedRef = useRef(false);
|
|
996
|
+
useEffect(() => {
|
|
997
|
+
detachedRef.current = false;
|
|
998
|
+
}, [run?.id]);
|
|
999
|
+
useEffect(() => {
|
|
1000
|
+
const el = scrollRef.current;
|
|
1001
|
+
if (!el) return;
|
|
1002
|
+
const detach = () => {
|
|
1003
|
+
detachedRef.current = true;
|
|
1004
|
+
};
|
|
1005
|
+
el.addEventListener('wheel', detach, { passive: true });
|
|
1006
|
+
el.addEventListener('touchmove', detach, { passive: true });
|
|
1007
|
+
return () => {
|
|
1008
|
+
el.removeEventListener('wheel', detach);
|
|
1009
|
+
el.removeEventListener('touchmove', detach);
|
|
1010
|
+
};
|
|
1011
|
+
}, []);
|
|
1012
|
+
|
|
1013
|
+
useEffect(() => {
|
|
1014
|
+
let lastScrollAt = 0;
|
|
1015
|
+
const tick = setInterval(() => {
|
|
1016
|
+
const container = scrollRef.current;
|
|
1017
|
+
const id = activeNodeIdRef.current;
|
|
1018
|
+
if (!container || !id || detachedRef.current) return;
|
|
1019
|
+
const row = rowRefsMap.current.get(id);
|
|
1020
|
+
if (!row) return;
|
|
1021
|
+
const containerRect = container.getBoundingClientRect();
|
|
1022
|
+
const rowRect = row.getBoundingClientRect();
|
|
1023
|
+
const relativeTop = (rowRect.top - containerRect.top) / containerRect.height;
|
|
1024
|
+
if (relativeTop >= 0.2 && relativeTop <= 0.75) return; // already in the calm band
|
|
1025
|
+
const now = Date.now();
|
|
1026
|
+
if (now - lastScrollAt < SCROLL_MIN_GAP_MS) return;
|
|
1027
|
+
lastScrollAt = now;
|
|
1028
|
+
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
1029
|
+
}, 250);
|
|
1030
|
+
return () => clearInterval(tick);
|
|
1031
|
+
}, []);
|
|
1032
|
+
|
|
1033
|
+
// When the flow is an HTTP operation, the entry Input node IS the request
|
|
1034
|
+
// contract — surface method/path/fields there instead of a generic step.
|
|
1035
|
+
const request = useMemo(() => {
|
|
1036
|
+
if (!flow.http) return undefined;
|
|
1037
|
+
const entry = flow.nodes.find((n) => n.type === 'Input');
|
|
1038
|
+
if (!entry) return undefined;
|
|
1039
|
+
const rawFields = (entry.config?.fields as { name: string; type?: string; required?: boolean }[] | undefined) ?? [];
|
|
1040
|
+
return {
|
|
1041
|
+
inputNodeId: entry.id,
|
|
1042
|
+
method: flow.http.method,
|
|
1043
|
+
path: flow.http.path,
|
|
1044
|
+
fields: rawFields.filter((f) => f && typeof f.name === 'string'),
|
|
1045
|
+
defaults: (entry.config?.defaults as Record<string, unknown> | undefined) ?? {},
|
|
1046
|
+
};
|
|
1047
|
+
}, [flow]);
|
|
1048
|
+
|
|
1049
|
+
const ctx: RunbookCtx = {
|
|
1050
|
+
projection,
|
|
1051
|
+
run,
|
|
1052
|
+
resetKey: `${flow.id}:${run?.id ?? 'none'}`,
|
|
1053
|
+
selectedNodeId,
|
|
1054
|
+
selectNode,
|
|
1055
|
+
registerRow,
|
|
1056
|
+
register,
|
|
1057
|
+
displayStatus,
|
|
1058
|
+
expandAll,
|
|
1059
|
+
request,
|
|
1060
|
+
};
|
|
1061
|
+
const subtitle = flow.folder ?? `${flow.nodes.length} step${flow.nodes.length === 1 ? '' : 's'}`;
|
|
1062
|
+
const building = buildingOperationId === flow.id;
|
|
1063
|
+
|
|
1064
|
+
return (
|
|
1065
|
+
<div ref={scrollRef} className="h-full min-h-0 overflow-y-auto bg-background">
|
|
1066
|
+
<div className="mx-auto max-w-[800px] px-4 pt-[26px] pb-[140px]">
|
|
1067
|
+
<RunbookHeader
|
|
1068
|
+
name={flow.name}
|
|
1069
|
+
environment={flow.environment}
|
|
1070
|
+
subtitle={subtitle}
|
|
1071
|
+
expandAll={expandAll}
|
|
1072
|
+
onToggleExpandAll={() => setExpandAll((v) => !v)}
|
|
1073
|
+
/>
|
|
1074
|
+
{building ? (
|
|
1075
|
+
<BuildingHolding route={flow.http ? `${flow.http.method} ${flow.http.path}` : undefined} />
|
|
1076
|
+
) : (
|
|
1077
|
+
<>
|
|
1078
|
+
<RunbookItems items={doc.items} ctx={ctx} />
|
|
1079
|
+
{run && <RunbookFooter run={run} filling={activeDisplayId !== null} />}
|
|
1080
|
+
</>
|
|
1081
|
+
)}
|
|
1082
|
+
</div>
|
|
1083
|
+
</div>
|
|
1084
|
+
);
|
|
1085
|
+
}
|