machinaos 0.0.76 → 0.0.78
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/README.md +143 -107
- package/client/dist/assets/ActionBar-Du2MSFSz.js +1 -0
- package/client/dist/assets/ApiKeyInput-k2LBmBjb.js +1 -0
- package/client/dist/assets/ApiKeyPanel-C_bV9U0X.js +1 -0
- package/client/dist/assets/ApiUsageSection-CmVfwZzL.js +1 -0
- package/client/dist/assets/EmailPanel-CeKIMGu-.js +1 -0
- package/client/dist/assets/OAuthPanel-KA3t3Q2K.js +1 -0
- package/client/dist/assets/QrPairingPanel-NgNpJNuk.js +1 -0
- package/client/dist/assets/RateLimitSection-Du5YNVIA.js +1 -0
- package/client/dist/assets/StatusCard-DNLyayXc.js +1 -0
- package/client/dist/assets/index-DQ0nwhec.js +257 -0
- package/client/dist/assets/index-DxmbVskS.css +1 -0
- package/client/dist/assets/vendor-flow-CZmBvHRo.js +1 -0
- package/client/dist/assets/vendor-icons-CVrPjN2Q.js +22 -0
- package/client/dist/assets/vendor-markdown-CRou3yQ5.js +62 -0
- package/client/dist/assets/vendor-misc-C4VxKHs5.js +1 -0
- package/client/dist/assets/vendor-query-SzWcOU0G.js +1 -0
- package/client/dist/assets/vendor-radix-Dnos29jG.js +56 -0
- package/client/dist/assets/vendor-react-DvWIbVx0.js +1 -0
- package/client/dist/index.html +37 -3
- package/client/index.html +28 -1
- package/client/package.json +44 -40
- package/client/src/App.tsx +2 -0
- package/client/src/Dashboard.tsx +157 -45
- package/client/src/ParameterPanel.tsx +3 -5
- package/client/src/adapters/nodeSpecToDescription.ts +1 -0
- package/client/src/assets/icons/NodeIcon.tsx +32 -0
- package/client/src/assets/icons/index.ts +4 -0
- package/client/src/assets/icons/stripe.svg +1 -0
- package/client/src/assets/icons/themedGlyphs.ts +404 -0
- package/client/src/components/AIAgentNode.tsx +77 -53
- package/client/src/components/GenericNode.tsx +34 -52
- package/client/src/components/OutputPanel.tsx +64 -147
- package/client/src/components/ParameterRenderer.tsx +5 -3
- package/client/src/components/SkillEditorModal.tsx +9 -18
- package/client/src/components/SquareNode.tsx +97 -115
- package/client/src/components/StartNode.tsx +32 -42
- package/client/src/components/SvgFilterDefs.tsx +54 -0
- package/client/src/components/TeamMonitorNode.tsx +12 -14
- package/client/src/components/ToolkitNode.tsx +35 -60
- package/client/src/components/TriggerNode.tsx +43 -77
- package/client/src/components/__tests__/CredentialsModal.test.tsx +49 -45
- package/client/src/components/credentials/CredentialsModal.tsx +98 -30
- package/client/src/components/credentials/CredentialsPalette.tsx +73 -5
- package/client/src/components/credentials/catalogueAdapter.ts +17 -1
- package/client/src/components/credentials/panels/ApiKeyPanel.tsx +102 -37
- package/client/src/components/credentials/panels/EmailPanel.tsx +7 -19
- package/client/src/components/credentials/panels/OAuthPanel.tsx +5 -1
- package/client/src/components/credentials/panels/QrPairingPanel.tsx +1 -3
- package/client/src/components/credentials/primitives/ActionBar.tsx +7 -11
- package/client/src/components/credentials/primitives/OAuthConnect.tsx +19 -28
- package/client/src/components/credentials/sections/ProviderDefaultsSection.tsx +24 -3
- package/client/src/components/credentials/types.ts +12 -2
- package/client/src/components/credentials/useCredentialPanel.ts +43 -19
- package/client/src/components/icons/AIProviderIcons.tsx +16 -0
- package/client/src/components/onboarding/OnboardingWizard.tsx +23 -63
- package/client/src/components/onboarding/nodeRoleClasses.ts +23 -0
- package/client/src/components/onboarding/steps/CanvasStep.tsx +15 -21
- package/client/src/components/onboarding/steps/ConceptsStep.tsx +2 -11
- package/client/src/components/onboarding/steps/GetStartedStep.tsx +2 -10
- package/client/src/components/parameterPanel/InputSection.tsx +9 -7
- package/client/src/components/parameterPanel/MasterSkillEditor.tsx +84 -198
- package/client/src/components/parameterPanel/MiddleSection.tsx +57 -80
- package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +31 -25
- package/client/src/components/parameterPanel/__tests__/InputSection.test.tsx +7 -2
- package/client/src/components/ui/AIResultModal.tsx +1 -1
- package/client/src/components/ui/CollapsibleSection.tsx +9 -5
- package/client/src/components/ui/CommandPalette.tsx +147 -0
- package/client/src/components/ui/CommandPaletteHost.tsx +189 -0
- package/client/src/components/ui/ComponentItem.tsx +13 -7
- package/client/src/components/ui/ComponentPalette.tsx +24 -13
- package/client/src/components/ui/ConsolePanel.tsx +19 -11
- package/client/src/components/ui/DropCap.tsx +28 -0
- package/client/src/components/ui/EditableNodeLabel.tsx +10 -2
- package/client/src/components/ui/InputNodesPanel.tsx +1 -1
- package/client/src/components/ui/Modal.tsx +38 -6
- package/client/src/components/ui/OutputDisplayPanel.tsx +1 -1
- package/client/src/components/ui/SettingsPanel.tsx +42 -13
- package/client/src/components/ui/StatusBar.tsx +108 -0
- package/client/src/components/ui/ThemeSwitcher.tsx +109 -0
- package/client/src/components/ui/TopToolbar.tsx +42 -25
- package/client/src/components/ui/WorkflowSidebar.tsx +32 -16
- package/client/src/components/ui/action-button.tsx +40 -15
- package/client/src/components/ui/button.tsx +24 -1
- package/client/src/components/ui/dropdown-menu.tsx +24 -2
- package/client/src/components/ui/input.tsx +19 -2
- package/client/src/components/ui/select.tsx +15 -0
- package/client/src/components/ui/textarea.tsx +15 -2
- package/client/src/contexts/AuthContext.tsx +148 -109
- package/client/src/contexts/ThemeContext.tsx +93 -17
- package/client/src/contexts/WebSocketContext.tsx +373 -206
- package/client/src/contexts/__tests__/AuthContext.test.tsx +221 -0
- package/client/src/hooks/__tests__/useDragVariable.test.ts +7 -1
- package/client/src/hooks/__tests__/useWorkflowOpsListener.test.ts +142 -0
- package/client/src/hooks/useAppTheme.ts +209 -7
- package/client/src/hooks/useAutoSkillEdges.ts +7 -2
- package/client/src/hooks/useCatalogueQuery.ts +67 -1
- package/client/src/hooks/useDragVariable.ts +1 -1
- package/client/src/hooks/useNodeAllowlist.ts +115 -8
- package/client/src/hooks/useOnboarding.ts +20 -8
- package/client/src/hooks/useParameterPanel.ts +2 -1
- package/client/src/hooks/useReactFlowNodes.ts +2 -1
- package/client/src/hooks/useSound.ts +185 -0
- package/client/src/hooks/useWorkflowManagement.ts +6 -8
- package/client/src/hooks/useWorkflowOpsListener.ts +90 -0
- package/client/src/index.css +65 -3
- package/client/src/lib/__tests__/connectionConfig.test.ts +91 -0
- package/client/src/lib/aiModelProviders.ts +8 -0
- package/client/src/lib/connectionConfig.ts +107 -0
- package/client/src/lib/queryPersist.ts +13 -5
- package/client/src/lib/sound.ts +393 -0
- package/client/src/main.tsx +20 -0
- package/client/src/store/useAppStore.ts +26 -0
- package/client/src/styles/canvasAnimations.ts +37 -36
- package/client/src/styles/theme.ts +36 -20
- package/client/src/test/setup.ts +1 -0
- package/client/src/themes/atomic.css +253 -0
- package/client/src/themes/base.css +373 -0
- package/client/src/themes/cyber.css +890 -0
- package/client/src/themes/dark.css +70 -0
- package/client/src/themes/edo.css +246 -0
- package/client/src/themes/greek.css +293 -0
- package/client/src/themes/light.css +78 -0
- package/client/src/themes/plague.css +253 -0
- package/client/src/themes/renaissance.css +727 -0
- package/client/src/themes/rot.css +249 -0
- package/client/src/themes/steampunk.css +272 -0
- package/client/src/themes/surveillance.css +289 -0
- package/client/src/themes/wasteland.css +250 -0
- package/client/src/types/INodeProperties.ts +5 -0
- package/client/src/types/NodeTypes.ts +11 -1
- package/client/src/types/__tests__/cloudEvents.test.ts +99 -0
- package/client/src/types/cloudEvents.ts +78 -0
- package/client/src/vite-env.d.ts +7 -0
- package/client/tsconfig.json +1 -1
- package/client/vite.config.js +62 -2
- package/install.ps1 +1 -1
- package/install.sh +1 -1
- package/machina/commands/build.py +51 -7
- package/machina/pyproject.toml +4 -0
- package/machina/supervisor.py +12 -2
- package/machina/tree.py +71 -21
- package/package.json +4 -4
- package/scripts/install.js +16 -1
- package/server/config/ai_cli_providers.json +54 -0
- package/server/config/credential_providers.json +109 -2
- package/server/config/llm_defaults.json +24 -0
- package/server/config/model_registry.json +338 -499
- package/server/config/node_allowlist.json +16 -1
- package/server/config/pricing.json +8 -0
- package/server/constants.py +38 -15
- package/server/core/container.py +2 -2
- package/server/core/credentials_database.py +35 -2
- package/server/core/logging.py +4 -3
- package/server/main.py +99 -13
- package/server/models/node_metadata.py +1 -0
- package/server/nodejs/package.json +8 -6
- package/server/nodejs/src/index.ts +22 -5
- package/server/nodes/README.md +31 -4
- package/server/nodes/agent/_inline.py +2 -0
- package/server/nodes/agent/_specialized.py +6 -3
- package/server/nodes/agent/ai_agent.py +13 -3
- package/server/nodes/agent/chat_agent.py +6 -3
- package/server/nodes/agent/claude_code_agent.py +287 -75
- package/server/nodes/agent/codex_agent.py +239 -0
- package/server/nodes/agent/deep_agent.py +3 -3
- package/server/nodes/agent/rlm_agent.py +3 -3
- package/server/nodes/android/__init__.py +31 -1
- package/server/nodes/android/_base.py +9 -5
- package/server/{services/android_service.py → nodes/android/_dispatcher.py} +2 -2
- package/server/nodes/android/_handlers.py +154 -0
- package/server/nodes/android/_option_loaders.py +44 -0
- package/server/nodes/android/_refresh.py +127 -0
- package/server/{services/android → nodes/android/_relay}/client.py +4 -4
- package/server/{routers/android.py → nodes/android/_router.py} +27 -8
- package/server/nodes/browser/browser.py +2 -2
- package/server/nodes/code/_base.py +6 -2
- package/server/nodes/code/_claude_code.py +134 -0
- package/server/nodes/document/embedding_generator.py +3 -3
- package/server/nodes/document/http_scraper.py +3 -3
- package/server/nodes/document/vector_store.py +5 -5
- package/server/nodes/email/__init__.py +11 -1
- package/server/nodes/email/_filters.py +21 -0
- package/server/{services/himalaya_service.py → nodes/email/_himalaya.py} +6 -10
- package/server/{services/email_service.py → nodes/email/_service.py} +9 -13
- package/server/nodes/email/email_read.py +1 -1
- package/server/nodes/email/email_receive.py +54 -5
- package/server/nodes/email/email_send.py +1 -1
- package/server/nodes/filesystem/shell.py +24 -1
- package/server/nodes/google/__init__.py +55 -1
- package/server/{services/handlers/google_auth.py → nodes/google/_auth_helper.py} +8 -5
- package/server/nodes/google/_base.py +2 -2
- package/server/nodes/google/_credentials.py +5 -5
- package/server/nodes/google/_filters.py +25 -0
- package/server/nodes/google/_handlers.py +57 -0
- package/server/{services/google_oauth.py → nodes/google/_oauth.py} +195 -162
- package/server/nodes/google/_option_loaders.py +107 -0
- package/server/nodes/google/_refresh.py +66 -0
- package/server/nodes/google/_router.py +131 -0
- package/server/nodes/google/gmail_receive.py +41 -4
- package/server/nodes/groups.py +1 -0
- package/server/nodes/location/_credentials.py +45 -1
- package/server/{services/maps.py → nodes/location/_service.py} +18 -3
- package/server/nodes/location/gmaps_create.py +4 -4
- package/server/nodes/location/gmaps_locations.py +4 -4
- package/server/nodes/location/gmaps_nearby_places.py +4 -4
- package/server/nodes/model/_base.py +8 -3
- package/server/nodes/model/_credentials.py +96 -8
- package/server/nodes/model/_local_validator.py +345 -0
- package/server/nodes/model/lmstudio_chat_model.py +23 -0
- package/server/nodes/model/ollama_chat_model.py +25 -0
- package/server/nodes/proxy/_usage.py +2 -2
- package/server/nodes/proxy/proxy_config.py +14 -14
- package/server/nodes/proxy/proxy_request.py +4 -4
- package/server/nodes/scraper/_credentials.py +29 -1
- package/server/nodes/scraper/apify_actor.py +9 -9
- package/server/nodes/scraper/crawlee_scraper.py +5 -5
- package/server/nodes/search/brave_search.py +4 -0
- package/server/nodes/search/perplexity_search.py +9 -0
- package/server/nodes/search/serper_search.py +3 -0
- package/server/nodes/skill/simple_memory.py +12 -0
- package/server/nodes/social/_base.py +2 -2
- package/server/nodes/stripe/__init__.py +46 -0
- package/server/nodes/stripe/_credentials.py +33 -0
- package/server/nodes/stripe/_handlers.py +270 -0
- package/server/nodes/stripe/_install.py +127 -0
- package/server/nodes/stripe/_source.py +174 -0
- package/server/nodes/stripe/stripe_action.py +81 -0
- package/server/nodes/stripe/stripe_receive.py +92 -0
- package/server/nodes/telegram/_credentials.py +52 -1
- package/server/nodes/telegram/_handlers.py +19 -18
- package/server/nodes/telegram/_service.py +134 -32
- package/server/nodes/telegram/telegram_send.py +5 -6
- package/server/nodes/text/file_handler.py +2 -2
- package/server/nodes/text/text_generator.py +2 -2
- package/server/nodes/tool/agent_builder.py +630 -0
- package/server/nodes/tool/task_manager.py +144 -2
- package/server/nodes/twitter/__init__.py +38 -1
- package/server/nodes/twitter/_base.py +7 -7
- package/server/nodes/twitter/_credentials.py +1 -1
- package/server/nodes/twitter/_filters.py +37 -0
- package/server/nodes/twitter/_handlers.py +77 -0
- package/server/nodes/twitter/_oauth.py +124 -0
- package/server/nodes/twitter/_refresh.py +78 -0
- package/server/nodes/twitter/_router.py +29 -0
- package/server/nodes/twitter/twitter_receive.py +4 -0
- package/server/nodes/visuals.json +64 -19
- package/server/nodes/whatsapp/__init__.py +45 -5
- package/server/nodes/whatsapp/_base.py +3 -3
- package/server/nodes/whatsapp/_filters.py +137 -0
- package/server/nodes/whatsapp/_handlers.py +167 -0
- package/server/nodes/whatsapp/_option_loaders.py +68 -0
- package/server/nodes/whatsapp/_refresh.py +62 -0
- package/server/nodes/whatsapp/_runtime.py +1 -1
- package/server/pyproject.toml +29 -7
- package/server/routers/schemas.py +2 -2
- package/server/routers/webhook.py +26 -9
- package/server/routers/websocket.py +149 -810
- package/server/services/ai.py +89 -8
- package/server/services/auth.py +220 -43
- package/server/services/claude_oauth.py +126 -100
- package/server/services/cli_agent/__init__.py +78 -0
- package/server/services/cli_agent/_handlers.py +237 -0
- package/server/services/cli_agent/config.py +112 -0
- package/server/services/cli_agent/factory.py +48 -0
- package/server/services/cli_agent/lockfile.py +141 -0
- package/server/services/cli_agent/mcp_server.py +482 -0
- package/server/services/cli_agent/protocol.py +173 -0
- package/server/services/cli_agent/providers/__init__.py +9 -0
- package/server/services/cli_agent/providers/anthropic_claude.py +419 -0
- package/server/services/cli_agent/providers/google_gemini.py +80 -0
- package/server/services/cli_agent/providers/openai_codex.py +310 -0
- package/server/services/cli_agent/service.py +607 -0
- package/server/services/cli_agent/session.py +618 -0
- package/server/services/cli_agent/types.py +227 -0
- package/server/services/cli_agent/workflow_tools.py +233 -0
- package/server/services/credential_registry.py +26 -1
- package/server/services/deployment/manager.py +26 -145
- package/server/services/deployment/poll_registry.py +59 -0
- package/server/services/event_waiter.py +76 -246
- package/server/services/events/__init__.py +54 -0
- package/server/services/events/cli.py +78 -0
- package/server/services/events/daemon.py +163 -0
- package/server/services/events/envelope.py +281 -0
- package/server/services/events/lifecycle.py +99 -0
- package/server/services/events/oauth_lifecycle.py +534 -0
- package/server/services/events/polling.py +60 -0
- package/server/services/events/push.py +36 -0
- package/server/services/events/source.py +63 -0
- package/server/services/events/triggers.py +118 -0
- package/server/services/events/verifiers/__init__.py +25 -0
- package/server/services/events/verifiers/base.py +28 -0
- package/server/services/events/verifiers/github.py +25 -0
- package/server/services/events/verifiers/hmac_basic.py +32 -0
- package/server/services/events/verifiers/standard_webhooks.py +47 -0
- package/server/services/events/verifiers/stripe.py +42 -0
- package/server/services/events/webhook.py +105 -0
- package/server/services/handlers/tools.py +28 -186
- package/server/services/llm/config.py +7 -0
- package/server/services/llm/factory.py +8 -2
- package/server/services/memory/__init__.py +52 -0
- package/server/services/memory/jsonl.py +80 -0
- package/server/services/memory/markdown.py +65 -0
- package/server/services/memory/state.py +112 -0
- package/server/services/memory/vector_store.py +40 -0
- package/server/services/model_registry.py +76 -0
- package/server/services/node_allowlist.py +71 -15
- package/server/services/node_executor.py +2 -2
- package/server/services/node_output_schemas.py +21 -10
- package/server/services/node_spec.py +1 -1
- package/server/services/oauth_utils.py +1 -1
- package/server/services/plugin/__init__.py +2 -0
- package/server/services/plugin/base.py +44 -2
- package/server/services/plugin/credential.py +288 -1
- package/server/services/plugin/deps.py +105 -0
- package/server/services/plugin/edge_walker.py +12 -4
- package/server/services/plugin/oauth.py +381 -0
- package/server/services/plugin/polling.py +247 -0
- package/server/services/plugin/registry.py +145 -0
- package/server/services/plugin/singleton.py +65 -0
- package/server/services/plugin/ws.py +81 -0
- package/server/services/process_service.py +31 -2
- package/server/services/status_broadcaster.py +155 -238
- package/server/services/temporal/workflow.py +7 -7
- package/server/services/workflow.py +21 -3
- package/server/services/ws_handler_registry.py +111 -28
- package/server/skills/GUIDE.md +16 -1
- package/server/skills/assistant/agent-builder-skill/SKILL.md +166 -0
- package/server/skills/payments_agent/stripe-skill/SKILL.md +306 -0
- package/server/tests/credentials/test_auth_service.py +16 -9
- package/server/tests/credentials/test_credential_broadcasts.py +219 -0
- package/server/tests/credentials/test_google_oauth.py +6 -6
- package/server/tests/credentials/test_oauth_utils.py +1 -1
- package/server/tests/credentials/test_twitter_oauth.py +2 -2
- package/server/tests/credentials/test_websocket_handlers.py +44 -20
- package/server/tests/llm/test_factory.py +1 -0
- package/server/tests/llm/test_wiring.py +5 -1
- package/server/tests/nodes/_compat.py +24 -24
- package/server/tests/nodes/test_agent_builder.py +439 -0
- package/server/tests/nodes/test_ai_tools.py +18 -14
- package/server/tests/nodes/test_code_fs_process.py +17 -8
- package/server/tests/nodes/test_email.py +10 -9
- package/server/tests/nodes/test_google_workspace.py +2 -2
- package/server/tests/nodes/test_specialized_agents.py +100 -53
- package/server/tests/nodes/test_stripe_plugin.py +293 -0
- package/server/tests/nodes/test_telegram_social.py +4 -4
- package/server/tests/nodes/test_twitter.py +1 -1
- package/server/tests/nodes/test_web_automation.py +2 -2
- package/server/tests/nodes/test_whatsapp.py +9 -9
- package/server/tests/services/cli_agent/__init__.py +0 -0
- package/server/tests/services/cli_agent/test_mcp_server.py +432 -0
- package/server/tests/services/cli_agent/test_providers.py +358 -0
- package/server/tests/services/cli_agent/test_service.py +298 -0
- package/server/tests/services/memory/__init__.py +0 -0
- package/server/tests/services/memory/test_jsonl.py +188 -0
- package/server/tests/services/test_events.py +333 -0
- package/server/tests/test_node_spec.py +56 -16
- package/server/tests/test_plugin_helpers.py +116 -0
- package/server/tests/test_plugin_self_containment.py +486 -0
- package/server/tests/test_status_broadcasts.py +425 -0
- package/workflows/{AI Assistant_workflow-1777421105154-0m4snkzjf.json → AI Assistant_workflow-1778504793388-ou1m1tz2x.json } +70 -266
- package/workflows/{AI Employee_workflow-1777720598005-u4cm858dv.json → AI Employee_example_workflow-1777720598005-u4cm858dv.json } +112 -112
- package/workflows/Claude Assistant_workflow-1778380124051-mdibn807c.json +709 -0
- package/client/dist/assets/ActionBar-vzPpSR77.js +0 -1
- package/client/dist/assets/ApiKeyInput-Ds7AKFe8.js +0 -1
- package/client/dist/assets/ApiKeyPanel-gfblELep.js +0 -1
- package/client/dist/assets/ApiUsageSection-BMNWTe2r.js +0 -1
- package/client/dist/assets/EmailPanel-B1Om64p5.js +0 -1
- package/client/dist/assets/OAuthPanel-CXyQYGBz.js +0 -1
- package/client/dist/assets/QrPairingPanel-BgNuI1we.js +0 -1
- package/client/dist/assets/RateLimitSection-YYK8sx1T.js +0 -1
- package/client/dist/assets/StatusCard-DuYA5hJR.js +0 -1
- package/client/dist/assets/index-D9tZfgvi.js +0 -363
- package/client/dist/assets/index-al7snTkG.css +0 -1
- package/client/src/components/credentials/providers.tsx +0 -177
- package/server/routers/google.py +0 -277
- package/server/routers/maps.py +0 -142
- package/server/routers/twitter.py +0 -365
- package/server/services/claude_code_service.py +0 -106
- package/server/services/memory.py +0 -159
- package/server/services/node_option_loaders/__init__.py +0 -77
- package/server/services/node_option_loaders/android_loaders.py +0 -55
- package/server/services/node_option_loaders/google_loaders.py +0 -97
- package/server/services/node_option_loaders/whatsapp_loaders.py +0 -69
- package/server/services/twitter_oauth.py +0 -411
- package/server/services/websocket_client.py +0 -29
- /package/server/{services/android → nodes/android/_relay}/__init__.py +0 -0
- /package/server/{services/android → nodes/android/_relay}/broadcaster.py +0 -0
- /package/server/{services/android → nodes/android/_relay}/manager.py +0 -0
- /package/server/{services/android → nodes/android/_relay}/protocol.py +0 -0
- /package/server/{services/browser_service.py → nodes/browser/_service.py} +0 -0
- /package/server/{services/whatsapp_service.py → nodes/whatsapp/_service.py} +0 -0
- /package/server/skills/{task_agent → assistant}/write-todos-skill/SKILL.md +0 -0
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import React, { createContext, useContext, useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
|
14
|
+
import ReconnectingWebSocket from 'partysocket/ws';
|
|
14
15
|
import { API_CONFIG } from '../config/api';
|
|
15
16
|
import { useAppStore } from '../store/useAppStore';
|
|
16
17
|
import { useAuth } from './AuthContext';
|
|
17
18
|
import { queryClient } from '../lib/queryClient';
|
|
18
19
|
import { queryKeys } from '../lib/queryConfig';
|
|
19
|
-
import {
|
|
20
|
+
import { invalidateCatalogue } from '../hooks/useCatalogueQuery';
|
|
20
21
|
import { nodeParamsQueryKey } from '../hooks/useNodeParamsQuery';
|
|
22
|
+
import type { WorkflowEvent } from '../types/cloudEvents';
|
|
23
|
+
import { WS_CLOSE, WS_RECONNECT } from '../lib/connectionConfig';
|
|
21
24
|
import {
|
|
22
25
|
useNodeStatusStore,
|
|
23
26
|
useNodeStatusForId,
|
|
@@ -173,9 +176,17 @@ export interface RateLimitStats {
|
|
|
173
176
|
pause_reason?: string;
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
/**
|
|
180
|
+
* In-memory validation-result cache for an API-key provider.
|
|
181
|
+
*
|
|
182
|
+
* NOT a "do we have a stored key" answer — that comes from the
|
|
183
|
+
* `useCatalogueQuery['credentialCatalogue']` `provider.stored` field
|
|
184
|
+
* (single source of truth, derived from the encrypted DB on each
|
|
185
|
+
* `get_credential_catalogue` round-trip). Mixing the two flags caused
|
|
186
|
+
* cross-tab drift; the duplicated `hasKey` field was retired.
|
|
187
|
+
*/
|
|
176
188
|
export interface ApiKeyStatus {
|
|
177
189
|
valid: boolean;
|
|
178
|
-
hasKey?: boolean;
|
|
179
190
|
message?: string;
|
|
180
191
|
models?: string[];
|
|
181
192
|
timestamp?: number;
|
|
@@ -311,6 +322,12 @@ interface WebSocketContextValue {
|
|
|
311
322
|
// Generic request method
|
|
312
323
|
sendRequest: <T = any>(type: string, data?: Record<string, any>) => Promise<T>;
|
|
313
324
|
|
|
325
|
+
// Generic broadcast subscription. Returns an unsubscribe fn.
|
|
326
|
+
// Use for ad-hoc backend-pushed events like `workflow_ops_apply`
|
|
327
|
+
// (see server/services/status_broadcaster.send_custom_event) so a
|
|
328
|
+
// new listener doesn't require a new switch case + state slice.
|
|
329
|
+
addEventListener: (type: string, handler: (data: any) => void) => () => void;
|
|
330
|
+
|
|
314
331
|
// Node Parameters
|
|
315
332
|
getNodeParameters: (nodeId: string) => Promise<NodeParameters | null>;
|
|
316
333
|
getAllNodeParameters: (nodeIds: string[]) => Promise<Record<string, NodeParameters>>;
|
|
@@ -492,10 +509,20 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
492
509
|
// Per-workflow compaction stats: workflow_id -> session_id -> CompactionStats (n8n pattern)
|
|
493
510
|
const [allCompactionStats, setAllCompactionStats] = useState<Record<string, Record<string, CompactionStats>>>({});
|
|
494
511
|
|
|
495
|
-
|
|
496
|
-
|
|
512
|
+
// PartySocket's `ReconnectingWebSocket` implements the native WebSocket
|
|
513
|
+
// surface (`send`, `close`, `readyState`, `addEventListener`, `onopen`,
|
|
514
|
+
// `onmessage`, `onclose`, `onerror`) so consumers — including the request
|
|
515
|
+
// correlation map and ping loop below — work unchanged. The ref's element
|
|
516
|
+
// type tightens to the library's class so any feature-specific calls
|
|
517
|
+
// (`shouldReconnect`, `reconnect()`) type-check.
|
|
518
|
+
const wsRef = useRef<ReconnectingWebSocket | null>(null);
|
|
497
519
|
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
498
520
|
const pendingRequestsRef = useRef<Map<string, PendingRequest>>(new Map());
|
|
521
|
+
// Generic broadcast subscribers ({type -> Set<handler>}) for events
|
|
522
|
+
// surfaced via send_custom_event on the backend. Lets new features
|
|
523
|
+
// listen for ad-hoc broadcasts without growing the switch statement
|
|
524
|
+
// or the context state shape.
|
|
525
|
+
const eventListenersRef = useRef<Map<string, Set<(data: any) => void>>>(new Map());
|
|
499
526
|
// Pending-send queue for backpressure + replay across reconnects.
|
|
500
527
|
// Drained inside `ws.onopen` after the init burst. Source of truth
|
|
501
528
|
// for currentWorkflowId is `useAppStore.getState().currentWorkflow?.id`
|
|
@@ -518,7 +545,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
518
545
|
|
|
519
546
|
// Fetch deployment status for the new workflow (n8n pattern)
|
|
520
547
|
// This ensures the deploy button shows correct state when switching workflows
|
|
521
|
-
if (wsRef.current?.readyState ===
|
|
548
|
+
if (wsRef.current?.readyState === ReconnectingWebSocket.OPEN) {
|
|
522
549
|
const fetchDeploymentStatus = async () => {
|
|
523
550
|
try {
|
|
524
551
|
const requestId = generateRequestId();
|
|
@@ -652,7 +679,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
652
679
|
}
|
|
653
680
|
// Catalogue 'stored' flags derive from api_keys + oauth state;
|
|
654
681
|
// re-sync once after the bulk status lands.
|
|
655
|
-
queryClient
|
|
682
|
+
invalidateCatalogue(queryClient);
|
|
656
683
|
}
|
|
657
684
|
break;
|
|
658
685
|
|
|
@@ -664,16 +691,26 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
664
691
|
}));
|
|
665
692
|
// Sidebar catalogue's `stored` flag depends on server-side
|
|
666
693
|
// api_keys + oauth state; refresh it.
|
|
667
|
-
queryClient
|
|
694
|
+
invalidateCatalogue(queryClient);
|
|
668
695
|
}
|
|
669
696
|
break;
|
|
670
697
|
|
|
671
|
-
case 'credential_catalogue_updated':
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
|
|
698
|
+
case 'credential_catalogue_updated': {
|
|
699
|
+
// The backend wraps a CloudEvents v1.0 `WorkflowEvent` envelope
|
|
700
|
+
// inside `data` (see server/services/status_broadcaster.py and
|
|
701
|
+
// server/services/events/envelope.py). The legacy outer wire
|
|
702
|
+
// key stays as the dispatch tag for back-compat, but the
|
|
703
|
+
// envelope's `id` / `time` / nested `type` (e.g.
|
|
704
|
+
// `credential.api_key.saved`) are now statically typed for any
|
|
705
|
+
// future consumer that wants ordering, dedup, or fine-grained
|
|
706
|
+
// glob dispatch via `matchesType()`.
|
|
707
|
+
const event = data as WorkflowEvent<{ provider: string; customer_id?: string }>;
|
|
708
|
+
// Today the only action is to refetch the catalogue; the
|
|
709
|
+
// envelope is read for telemetry / future dispatch.
|
|
710
|
+
void event;
|
|
711
|
+
invalidateCatalogue(queryClient);
|
|
676
712
|
break;
|
|
713
|
+
}
|
|
677
714
|
|
|
678
715
|
case 'android_status':
|
|
679
716
|
setAndroidStatus(data || defaultAndroidStatus);
|
|
@@ -681,7 +718,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
681
718
|
|
|
682
719
|
case 'whatsapp_status':
|
|
683
720
|
setWhatsappStatus(data || defaultWhatsAppStatus);
|
|
684
|
-
queryClient
|
|
721
|
+
invalidateCatalogue(queryClient);
|
|
685
722
|
break;
|
|
686
723
|
|
|
687
724
|
case 'twitter_oauth_complete':
|
|
@@ -694,7 +731,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
694
731
|
name: data.name,
|
|
695
732
|
profile_image_url: data.profile_image_url,
|
|
696
733
|
});
|
|
697
|
-
queryClient
|
|
734
|
+
invalidateCatalogue(queryClient);
|
|
698
735
|
}
|
|
699
736
|
break;
|
|
700
737
|
|
|
@@ -707,7 +744,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
707
744
|
name: data.name,
|
|
708
745
|
profile_image_url: data.profile_image_url,
|
|
709
746
|
});
|
|
710
|
-
queryClient
|
|
747
|
+
invalidateCatalogue(queryClient);
|
|
711
748
|
}
|
|
712
749
|
break;
|
|
713
750
|
|
|
@@ -719,7 +756,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
719
756
|
email: data.email || null,
|
|
720
757
|
name: data.name,
|
|
721
758
|
});
|
|
722
|
-
queryClient
|
|
759
|
+
invalidateCatalogue(queryClient);
|
|
723
760
|
}
|
|
724
761
|
break;
|
|
725
762
|
|
|
@@ -733,7 +770,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
733
770
|
bot_id: data.bot_id || null,
|
|
734
771
|
owner_chat_id: data.owner_chat_id ?? null,
|
|
735
772
|
});
|
|
736
|
-
queryClient
|
|
773
|
+
invalidateCatalogue(queryClient);
|
|
737
774
|
}
|
|
738
775
|
break;
|
|
739
776
|
|
|
@@ -804,6 +841,53 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
804
841
|
}
|
|
805
842
|
break;
|
|
806
843
|
|
|
844
|
+
case 'agent_progress': {
|
|
845
|
+
// CloudEvents v1.0 envelope from broadcaster.broadcast_agent_progress.
|
|
846
|
+
// Inner payload: { node_id, iteration, max_iterations, phase? }.
|
|
847
|
+
// Routes into nodeStatusStore (same per-workflow / per-node slot
|
|
848
|
+
// the existing useNodeStatus consumers read) so the AI-agent body
|
|
849
|
+
// can render "iteration / max_iterations" live without a parallel
|
|
850
|
+
// store. Wire-key parity with `credential_catalogue_updated`.
|
|
851
|
+
const envelope = data as WorkflowEvent<{
|
|
852
|
+
node_id?: string;
|
|
853
|
+
iteration?: number;
|
|
854
|
+
max_iterations?: number;
|
|
855
|
+
phase?: string;
|
|
856
|
+
}> | undefined;
|
|
857
|
+
const inner = envelope?.data;
|
|
858
|
+
const targetNodeId = inner?.node_id || envelope?.subject;
|
|
859
|
+
const progressWorkflowId =
|
|
860
|
+
envelope?.workflow_id || message.workflow_id || 'unknown';
|
|
861
|
+
if (targetNodeId && inner) {
|
|
862
|
+
const store = useNodeStatusStore.getState();
|
|
863
|
+
const previous =
|
|
864
|
+
store.allStatuses[progressWorkflowId]?.[targetNodeId] ||
|
|
865
|
+
({} as NodeStatus);
|
|
866
|
+
// Defensive: an agent_progress event implies the agent IS
|
|
867
|
+
// mid-loop. Set status='executing' even if no prior
|
|
868
|
+
// node_status broadcast arrived first (race or edge case
|
|
869
|
+
// where the agent finishes in a single step). Without this,
|
|
870
|
+
// AIAgentNode's `isExecuting && iteration != null` gate
|
|
871
|
+
// hides the badge entirely.
|
|
872
|
+
const carriedStatus =
|
|
873
|
+
previous.status === 'success' || previous.status === 'error'
|
|
874
|
+
? previous.status
|
|
875
|
+
: 'executing';
|
|
876
|
+
store.setStatus(progressWorkflowId, targetNodeId, {
|
|
877
|
+
...previous,
|
|
878
|
+
status: carriedStatus,
|
|
879
|
+
workflow_id: progressWorkflowId,
|
|
880
|
+
data: {
|
|
881
|
+
...(previous.data || {}),
|
|
882
|
+
iteration: inner.iteration,
|
|
883
|
+
max_iterations: inner.max_iterations,
|
|
884
|
+
...(inner.phase ? { phase: inner.phase } : {}),
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
break;
|
|
889
|
+
}
|
|
890
|
+
|
|
807
891
|
case 'node_status_cleared':
|
|
808
892
|
// Handle broadcast from server when node status is cleared
|
|
809
893
|
if (node_id || message.node_id) {
|
|
@@ -889,6 +973,57 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
889
973
|
break;
|
|
890
974
|
}
|
|
891
975
|
|
|
976
|
+
case 'deployment_snapshot': {
|
|
977
|
+
// CloudEvents v1.0 envelope from broadcaster._send_deployment_snapshot.
|
|
978
|
+
// Pushed once per WS connect so the FE can reconcile its stale
|
|
979
|
+
// `deploymentStatus.isRunning=true` after a backend restart that
|
|
980
|
+
// wiped DeploymentManager._deployments. Empty list is meaningful
|
|
981
|
+
// and forces a reset — the prior bug was: backend restart wiped
|
|
982
|
+
// the deployment dict, FE never got a `stopped` broadcast (because
|
|
983
|
+
// there was nothing to broadcast about), so the Start button
|
|
984
|
+
// stayed showing "Stop" forever.
|
|
985
|
+
const envelope = data as WorkflowEvent<{
|
|
986
|
+
running_workflow_ids?: string[];
|
|
987
|
+
}> | undefined;
|
|
988
|
+
const runningIds = envelope?.data?.running_workflow_ids ?? [];
|
|
989
|
+
const runningSet = new Set(runningIds);
|
|
990
|
+
const store = useAppStore.getState();
|
|
991
|
+
const currentId = store.currentWorkflow?.id;
|
|
992
|
+
|
|
993
|
+
// Reconcile per-workflow execution state in workflowUIStates.
|
|
994
|
+
// Anything currently flagged isExecuting=true that isn't in
|
|
995
|
+
// the snapshot's running set gets cleared (the load-bearing
|
|
996
|
+
// reset for stale state after backend restart). Anything in
|
|
997
|
+
// the snapshot gets flagged true.
|
|
998
|
+
const existingStates = store.workflowUIStates ?? {};
|
|
999
|
+
for (const [wid, ui] of Object.entries(existingStates)) {
|
|
1000
|
+
if (ui?.isExecuting && !runningSet.has(wid)) {
|
|
1001
|
+
store.setWorkflowExecuting(wid, false);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
for (const wid of runningIds) {
|
|
1005
|
+
store.setWorkflowExecuting(wid, true);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// Reconcile the toolbar `deploymentStatus` for the active workflow
|
|
1009
|
+
setDeploymentStatus(prev => {
|
|
1010
|
+
const next: DeploymentStatus = { ...prev };
|
|
1011
|
+
if (currentId && runningSet.has(currentId)) {
|
|
1012
|
+
next.isRunning = true;
|
|
1013
|
+
next.status = 'running';
|
|
1014
|
+
next.workflow_id = currentId;
|
|
1015
|
+
} else if (currentId && !runningSet.has(currentId) && prev.workflow_id === currentId) {
|
|
1016
|
+
// Previously thought current workflow was deployed; backend says no.
|
|
1017
|
+
next.isRunning = false;
|
|
1018
|
+
next.status = 'stopped';
|
|
1019
|
+
next.workflow_id = null;
|
|
1020
|
+
next.activeRuns = 0;
|
|
1021
|
+
}
|
|
1022
|
+
return next;
|
|
1023
|
+
});
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
892
1027
|
case 'deployment_status':
|
|
893
1028
|
// Handle deployment status updates (event-driven, no iterations)
|
|
894
1029
|
// Per-workflow scoping (n8n pattern): Only apply updates for current workflow
|
|
@@ -1141,8 +1276,21 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1141
1276
|
console.error('[WebSocket] Server error:', message.code, message.message);
|
|
1142
1277
|
break;
|
|
1143
1278
|
|
|
1144
|
-
default:
|
|
1279
|
+
default: {
|
|
1280
|
+
// Generic broadcast dispatch -- any listener registered via
|
|
1281
|
+
// addEventListener(type, handler) gets the message data. Lets
|
|
1282
|
+
// backend-only features (e.g. workflow_ops_apply) ship without
|
|
1283
|
+
// adding a switch case + state slice every time.
|
|
1284
|
+
const listeners = eventListenersRef.current.get(type);
|
|
1285
|
+
if (listeners && listeners.size > 0) {
|
|
1286
|
+
for (const handler of listeners) {
|
|
1287
|
+
try { handler(data); } catch (err) {
|
|
1288
|
+
console.error(`[WebSocket] Listener for '${type}' threw:`, err);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1145
1292
|
break;
|
|
1293
|
+
}
|
|
1146
1294
|
}
|
|
1147
1295
|
} catch (error) {
|
|
1148
1296
|
console.error('[WebSocket] Failed to parse message:', error);
|
|
@@ -1153,7 +1301,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1153
1301
|
// fresh request_id and a reset timeout budget; responses correlate via the
|
|
1154
1302
|
// existing pendingRequestsRef map. Per-call abortController cancels the
|
|
1155
1303
|
// queue-side timeout that was running while the request was waiting in line.
|
|
1156
|
-
const drainPendingSends = useCallback((ws:
|
|
1304
|
+
const drainPendingSends = useCallback((ws: ReconnectingWebSocket) => {
|
|
1157
1305
|
const queue = pendingSendQueueRef.current;
|
|
1158
1306
|
pendingSendQueueRef.current = [];
|
|
1159
1307
|
for (const queued of queue) {
|
|
@@ -1194,16 +1342,34 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1194
1342
|
}
|
|
1195
1343
|
}, []);
|
|
1196
1344
|
|
|
1197
|
-
// Connect to WebSocket
|
|
1345
|
+
// Connect to WebSocket. Uses PartySocket's `ReconnectingWebSocket` —
|
|
1346
|
+
// a native-WebSocket-compatible class with built-in jittered exponential
|
|
1347
|
+
// backoff, message replay, and intentional-close (code 1000) handling.
|
|
1348
|
+
// Replaces the previous flat 3 s `setTimeout(reconnect)` loop.
|
|
1349
|
+
// Backoff envelope is configured via `WS_RECONNECT` in
|
|
1350
|
+
// `lib/connectionConfig.ts` so a future tuning pass is a one-file edit.
|
|
1351
|
+
// Ref: https://docs.partykit.io/reference/partysocket-api/
|
|
1198
1352
|
const connect = useCallback(() => {
|
|
1199
|
-
if (wsRef.current?.readyState ===
|
|
1353
|
+
if (wsRef.current?.readyState === ReconnectingWebSocket.OPEN) {
|
|
1200
1354
|
return;
|
|
1201
1355
|
}
|
|
1202
1356
|
|
|
1203
1357
|
const wsUrl = getWebSocketUrl();
|
|
1204
1358
|
|
|
1205
1359
|
try {
|
|
1206
|
-
const ws = new
|
|
1360
|
+
const ws = new ReconnectingWebSocket(wsUrl, [], {
|
|
1361
|
+
minReconnectionDelay: WS_RECONNECT.MIN_DELAY_MS,
|
|
1362
|
+
maxReconnectionDelay: WS_RECONNECT.MAX_DELAY_MS,
|
|
1363
|
+
reconnectionDelayGrowFactor: WS_RECONNECT.GROW_FACTOR,
|
|
1364
|
+
// Reconnect indefinitely while the page is open. Intentional
|
|
1365
|
+
// closes via `ws.close(1000, ...)` (logout / unmount) skip the
|
|
1366
|
+
// reconnect path because PartySocket inspects the close code.
|
|
1367
|
+
maxRetries: Infinity,
|
|
1368
|
+
// Send-while-disconnected buffer; replayed automatically on the
|
|
1369
|
+
// next OPEN. Mirrors the previous `pendingSendQueueRef` cap intent
|
|
1370
|
+
// for opportunistic out-of-band sends.
|
|
1371
|
+
maxEnqueuedMessages: WS_RECONNECT.MAX_ENQUEUED_MESSAGES,
|
|
1372
|
+
});
|
|
1207
1373
|
|
|
1208
1374
|
ws.onopen = async () => {
|
|
1209
1375
|
setIsConnected(true);
|
|
@@ -1219,51 +1385,65 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1219
1385
|
|
|
1220
1386
|
// Start ping interval
|
|
1221
1387
|
pingIntervalRef.current = setInterval(() => {
|
|
1222
|
-
if (ws.readyState ===
|
|
1388
|
+
if (ws.readyState === ReconnectingWebSocket.OPEN) {
|
|
1223
1389
|
ws.send(JSON.stringify({ type: 'ping' }));
|
|
1224
1390
|
}
|
|
1225
1391
|
}, 30000);
|
|
1226
1392
|
|
|
1227
|
-
//
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
const requestId = `init_${provider}_${Date.now()}`;
|
|
1233
|
-
const timeout = setTimeout(() => reject(new Error('Timeout')), 5000);
|
|
1234
|
-
|
|
1235
|
-
const handler = (event: MessageEvent) => {
|
|
1236
|
-
try {
|
|
1237
|
-
const msg = JSON.parse(event.data);
|
|
1238
|
-
if (msg.request_id === requestId) {
|
|
1239
|
-
clearTimeout(timeout);
|
|
1240
|
-
ws.removeEventListener('message', handler);
|
|
1241
|
-
resolve(msg);
|
|
1242
|
-
}
|
|
1243
|
-
} catch {}
|
|
1244
|
-
};
|
|
1245
|
-
|
|
1246
|
-
ws.addEventListener('message', handler);
|
|
1247
|
-
ws.send(JSON.stringify({ type: 'get_stored_api_key', provider, request_id: requestId }));
|
|
1248
|
-
});
|
|
1249
|
-
|
|
1250
|
-
if (response.hasKey) {
|
|
1251
|
-
setApiKeyStatuses(prev => ({
|
|
1252
|
-
...prev,
|
|
1253
|
-
[provider]: { hasKey: true, valid: true }
|
|
1254
|
-
}));
|
|
1255
|
-
}
|
|
1256
|
-
} catch {
|
|
1257
|
-
// Ignore errors during initial check
|
|
1258
|
-
}
|
|
1259
|
-
}
|
|
1393
|
+
// Drain any sends that were queued while the socket was reconnecting.
|
|
1394
|
+
// Runs BEFORE setIsReady(true) so isReady-gated callers don't race
|
|
1395
|
+
// an empty pendingRequestsRef. Mirrors socket.io-client's offline
|
|
1396
|
+
// buffer + Apollo RetryLink semantics.
|
|
1397
|
+
drainPendingSends(ws);
|
|
1260
1398
|
|
|
1261
|
-
//
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1399
|
+
// Wave 32: flip `isReady` IMMEDIATELY on socket open + queue drain.
|
|
1400
|
+
// The page-state restore (terminal / chat / console history) fires
|
|
1401
|
+
// in the BACKGROUND below — its results trickle into state writes
|
|
1402
|
+
// when each request settles, but UI interaction is no longer blocked
|
|
1403
|
+
// behind a serial 5-up-to-25-second `Promise.allSettled` await.
|
|
1404
|
+
//
|
|
1405
|
+
// Why this matters: tab-blur + WS reconnect previously left the user
|
|
1406
|
+
// staring at an unresponsive workflow until init-burst finished.
|
|
1407
|
+
// First click "did nothing" because catalogue / nodeSpec / credentials
|
|
1408
|
+
// queries gate on `isReady` and stayed disabled. The cache (warmed
|
|
1409
|
+
// from localStorage via PersistQueryClientProvider for `nodeSpec` /
|
|
1410
|
+
// `nodeGroups` / `pluginCatalogue` / `skillContent`) carries the
|
|
1411
|
+
// visible state until refreshes land.
|
|
1412
|
+
//
|
|
1413
|
+
// Wave 32 also dropped the legacy hardcoded `probeApiKey` loop over
|
|
1414
|
+
// `['openai', 'anthropic', 'gemini', 'google_maps', 'android_remote']`.
|
|
1415
|
+
// Those probes were redundant — credential state has TWO authoritative
|
|
1416
|
+
// sources already:
|
|
1417
|
+
// 1. The backend's `initial_status` broadcast (handled at line ~638)
|
|
1418
|
+
// pushes the full `api_keys` map on every reconnect.
|
|
1419
|
+
// 2. The catalogue (TanStack Query `useCatalogueQuery`) carries the
|
|
1420
|
+
// `provider.stored` flag for every provider; refetched via the
|
|
1421
|
+
// debounced `invalidateCatalogue(queryClient)` helper that 8+
|
|
1422
|
+
// credential CloudEvent handlers already fire (`api_key_status`,
|
|
1423
|
+
// `credential_catalogue_updated`, `whatsapp_status`,
|
|
1424
|
+
// `twitter_oauth_complete`, `google_oauth_complete`,
|
|
1425
|
+
// `google_status`, `telegram_status`, `initial_status`).
|
|
1426
|
+
// No frontend should hardcode a provider list — adding a new
|
|
1427
|
+
// provider should be a backend-only edit.
|
|
1428
|
+
setIsReady(true);
|
|
1266
1429
|
|
|
1430
|
+
// Single-shot catalogue invalidate so any credential mutations that
|
|
1431
|
+
// landed on the server while the socket was disconnected propagate
|
|
1432
|
+
// to the in-memory cache immediately. Debounced (300ms trailing) so
|
|
1433
|
+
// it coalesces with other broadcast-driven invalidations.
|
|
1434
|
+
invalidateCatalogue(queryClient);
|
|
1435
|
+
|
|
1436
|
+
// Page-state restore (background). Fire-and-forget — these writes
|
|
1437
|
+
// hydrate panels that PersistQueryClient doesn't cache (terminal /
|
|
1438
|
+
// chat / console history come straight from the server's per-request
|
|
1439
|
+
// log read, not from a query cache).
|
|
1440
|
+
const sendBurstRequest = <T = any>(payload: object, idPrefix: string): Promise<T> =>
|
|
1441
|
+
new Promise<T>((resolve, reject) => {
|
|
1442
|
+
const requestId = `${idPrefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1443
|
+
const timeout = setTimeout(() => {
|
|
1444
|
+
ws.removeEventListener('message', handler);
|
|
1445
|
+
reject(new Error('Timeout'));
|
|
1446
|
+
}, 5000);
|
|
1267
1447
|
const handler = (event: MessageEvent) => {
|
|
1268
1448
|
try {
|
|
1269
1449
|
const msg = JSON.parse(event.data);
|
|
@@ -1274,124 +1454,77 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1274
1454
|
}
|
|
1275
1455
|
} catch {}
|
|
1276
1456
|
};
|
|
1277
|
-
|
|
1278
1457
|
ws.addEventListener('message', handler);
|
|
1279
|
-
ws.send(JSON.stringify({
|
|
1458
|
+
ws.send(JSON.stringify({ ...payload, request_id: requestId }));
|
|
1280
1459
|
});
|
|
1281
1460
|
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1461
|
+
void (async () => {
|
|
1462
|
+
try {
|
|
1463
|
+
const terminalResponse = await sendBurstRequest<any>(
|
|
1464
|
+
{ type: 'get_terminal_logs' },
|
|
1465
|
+
'terminal_logs',
|
|
1466
|
+
);
|
|
1467
|
+
if (terminalResponse.success && terminalResponse.logs) {
|
|
1468
|
+
const logs: TerminalLogEntry[] = terminalResponse.logs.map((log: any) => ({
|
|
1469
|
+
timestamp: log.timestamp || new Date().toISOString(),
|
|
1470
|
+
level: log.level || 'info',
|
|
1471
|
+
message: log.message || '',
|
|
1472
|
+
source: log.source,
|
|
1473
|
+
details: log.details,
|
|
1474
|
+
})).reverse();
|
|
1475
|
+
setTerminalLogs(logs);
|
|
1476
|
+
}
|
|
1477
|
+
} catch {
|
|
1478
|
+
// Ignore errors loading terminal logs
|
|
1292
1479
|
}
|
|
1293
|
-
}
|
|
1294
|
-
// Ignore errors loading terminal logs
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
// Load chat message history from database
|
|
1298
|
-
try {
|
|
1299
|
-
const chatResponse = await new Promise<any>((resolve, reject) => {
|
|
1300
|
-
const requestId = `chat_messages_${Date.now()}`;
|
|
1301
|
-
const timeout = setTimeout(() => reject(new Error('Timeout')), 5000);
|
|
1302
|
-
|
|
1303
|
-
const handler = (event: MessageEvent) => {
|
|
1304
|
-
try {
|
|
1305
|
-
const msg = JSON.parse(event.data);
|
|
1306
|
-
if (msg.request_id === requestId) {
|
|
1307
|
-
clearTimeout(timeout);
|
|
1308
|
-
ws.removeEventListener('message', handler);
|
|
1309
|
-
resolve(msg);
|
|
1310
|
-
}
|
|
1311
|
-
} catch {}
|
|
1312
|
-
};
|
|
1480
|
+
})();
|
|
1313
1481
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
// re-used as the workflow identifier on the chat side so the
|
|
1317
|
-
// panel only shows messages tied to this workflow. Falls
|
|
1318
|
-
// back to "default" when no workflow is selected (initial
|
|
1319
|
-
// bootstrap before the user opens any workflow).
|
|
1482
|
+
void (async () => {
|
|
1483
|
+
try {
|
|
1320
1484
|
const workflowId = useAppStore.getState().currentWorkflow?.id || 'default';
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1485
|
+
const chatResponse = await sendBurstRequest<any>(
|
|
1486
|
+
{ type: 'get_chat_messages', session_id: workflowId },
|
|
1487
|
+
'chat_messages',
|
|
1488
|
+
);
|
|
1489
|
+
if (chatResponse.success && chatResponse.messages) {
|
|
1490
|
+
const messages: ChatMessage[] = chatResponse.messages.map((msg: any) => ({
|
|
1491
|
+
role: msg.role as 'user' | 'assistant',
|
|
1492
|
+
message: msg.message,
|
|
1493
|
+
timestamp: msg.timestamp,
|
|
1494
|
+
}));
|
|
1495
|
+
setChatMessages(messages);
|
|
1496
|
+
}
|
|
1497
|
+
} catch {
|
|
1498
|
+
// Ignore errors loading chat messages
|
|
1331
1499
|
}
|
|
1332
|
-
}
|
|
1333
|
-
// Ignore errors loading chat messages
|
|
1334
|
-
}
|
|
1500
|
+
})();
|
|
1335
1501
|
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
const consoleRequestId = `console_${Date.now()}`;
|
|
1339
|
-
const consoleResponse = await new Promise<any>((resolve, reject) => {
|
|
1340
|
-
const timeout = setTimeout(() => reject(new Error('Timeout')), 5000);
|
|
1341
|
-
|
|
1342
|
-
const handler = (event: MessageEvent) => {
|
|
1343
|
-
try {
|
|
1344
|
-
const msg = JSON.parse(event.data);
|
|
1345
|
-
if (msg.request_id === consoleRequestId) {
|
|
1346
|
-
clearTimeout(timeout);
|
|
1347
|
-
ws.removeEventListener('message', handler);
|
|
1348
|
-
resolve(msg);
|
|
1349
|
-
}
|
|
1350
|
-
} catch {}
|
|
1351
|
-
};
|
|
1352
|
-
|
|
1353
|
-
ws.addEventListener('message', handler);
|
|
1354
|
-
// Scope to the currently-open workflow so the panel only
|
|
1355
|
-
// shows console output from this workflow's runs.
|
|
1502
|
+
void (async () => {
|
|
1503
|
+
try {
|
|
1356
1504
|
const consoleWorkflowId = useAppStore.getState().currentWorkflow?.id;
|
|
1357
|
-
|
|
1358
|
-
type: 'get_console_logs',
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
setConsoleLogs(logs);
|
|
1505
|
+
const consoleResponse = await sendBurstRequest<any>(
|
|
1506
|
+
{ type: 'get_console_logs', limit: 100, workflow_id: consoleWorkflowId },
|
|
1507
|
+
'console',
|
|
1508
|
+
);
|
|
1509
|
+
if (consoleResponse.success && consoleResponse.logs) {
|
|
1510
|
+
const logs: ConsoleLogEntry[] = consoleResponse.logs.map((log: any) => ({
|
|
1511
|
+
node_id: log.node_id,
|
|
1512
|
+
label: log.label,
|
|
1513
|
+
timestamp: log.timestamp,
|
|
1514
|
+
data: log.data,
|
|
1515
|
+
formatted: log.formatted,
|
|
1516
|
+
format: log.format,
|
|
1517
|
+
workflow_id: log.workflow_id,
|
|
1518
|
+
source_node_id: log.source_node_id,
|
|
1519
|
+
source_node_type: log.source_node_type,
|
|
1520
|
+
source_node_label: log.source_node_label,
|
|
1521
|
+
}));
|
|
1522
|
+
setConsoleLogs(logs);
|
|
1523
|
+
}
|
|
1524
|
+
} catch {
|
|
1525
|
+
// Ignore errors loading console logs
|
|
1379
1526
|
}
|
|
1380
|
-
}
|
|
1381
|
-
// Ignore errors loading console logs
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
// Drain any sends that were queued while the socket was reconnecting.
|
|
1385
|
-
// Must run BEFORE setIsReady(true) so isReady-gated callers don't race
|
|
1386
|
-
// an empty pendingRequestsRef. Mirrors socket.io-client's offline buffer
|
|
1387
|
-
// and Apollo RetryLink semantics.
|
|
1388
|
-
drainPendingSends(ws);
|
|
1389
|
-
|
|
1390
|
-
// Init burst complete — flip `isReady` so queries that gate on
|
|
1391
|
-
// it (catalogue, node specs, node parameters, user settings,
|
|
1392
|
-
// credential panels) fire once against a stable socket instead
|
|
1393
|
-
// of racing the serial init awaits above.
|
|
1394
|
-
setIsReady(true);
|
|
1527
|
+
})();
|
|
1395
1528
|
};
|
|
1396
1529
|
|
|
1397
1530
|
ws.onmessage = handleMessage;
|
|
@@ -1419,9 +1552,10 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1419
1552
|
pendingRequestsRef.current.clear();
|
|
1420
1553
|
}
|
|
1421
1554
|
|
|
1422
|
-
// Pending-send queue handling: only drop on intentional close
|
|
1423
|
-
//
|
|
1424
|
-
|
|
1555
|
+
// Pending-send queue handling: only drop on intentional close
|
|
1556
|
+
// (RFC 6455 §7.4.1 Normal Closure, code 1000). Transient closes
|
|
1557
|
+
// preserve the queue so the next onopen drain replays them.
|
|
1558
|
+
if (event.code === WS_CLOSE.NORMAL_CLOSURE) {
|
|
1425
1559
|
if (pendingSendQueueRef.current.length > 0) {
|
|
1426
1560
|
for (const queued of pendingSendQueueRef.current) {
|
|
1427
1561
|
try {
|
|
@@ -1441,12 +1575,13 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1441
1575
|
pingIntervalRef.current = null;
|
|
1442
1576
|
}
|
|
1443
1577
|
|
|
1444
|
-
//
|
|
1445
|
-
|
|
1578
|
+
// PartySocket performs the reconnect itself when
|
|
1579
|
+
// `event.code !== WS_CLOSE.NORMAL_CLOSURE`, honouring the
|
|
1580
|
+
// `WS_RECONNECT` envelope passed at construction time. Surface
|
|
1581
|
+
// "reconnecting" to the UI for transient closes; intentional
|
|
1582
|
+
// closes (logout / unmount) leave the flag false.
|
|
1583
|
+
if (event.code !== WS_CLOSE.NORMAL_CLOSURE) {
|
|
1446
1584
|
setReconnecting(true);
|
|
1447
|
-
reconnectTimeoutRef.current = setTimeout(() => {
|
|
1448
|
-
connect();
|
|
1449
|
-
}, 3000);
|
|
1450
1585
|
}
|
|
1451
1586
|
};
|
|
1452
1587
|
|
|
@@ -1456,15 +1591,18 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1456
1591
|
|
|
1457
1592
|
wsRef.current = ws;
|
|
1458
1593
|
} catch (error) {
|
|
1594
|
+
// Construction-time error (e.g. malformed URL). PartySocket has
|
|
1595
|
+
// not installed its retry loop yet, so just log — there is
|
|
1596
|
+
// nothing to reconnect to. The runtime path (network drops after
|
|
1597
|
+
// successful construction) is covered by PartySocket's internal
|
|
1598
|
+
// jittered backoff.
|
|
1459
1599
|
console.error('[WebSocket] Failed to create connection:', error);
|
|
1460
|
-
setReconnecting(true);
|
|
1461
|
-
reconnectTimeoutRef.current = setTimeout(connect, 3000);
|
|
1462
1600
|
}
|
|
1463
1601
|
}, [handleMessage, drainPendingSends]);
|
|
1464
1602
|
|
|
1465
1603
|
// Request current status
|
|
1466
1604
|
const requestStatus = useCallback(() => {
|
|
1467
|
-
if (wsRef.current?.readyState ===
|
|
1605
|
+
if (wsRef.current?.readyState === ReconnectingWebSocket.OPEN) {
|
|
1468
1606
|
wsRef.current.send(JSON.stringify({ type: 'get_status' }));
|
|
1469
1607
|
}
|
|
1470
1608
|
}, []);
|
|
@@ -1524,7 +1662,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1524
1662
|
// the currently-open workflow so other workflows' history survives.
|
|
1525
1663
|
const clearConsoleLogs = useCallback(() => {
|
|
1526
1664
|
setConsoleLogs([]);
|
|
1527
|
-
if (wsRef.current?.readyState ===
|
|
1665
|
+
if (wsRef.current?.readyState === ReconnectingWebSocket.OPEN) {
|
|
1528
1666
|
const workflowId = useAppStore.getState().currentWorkflow?.id;
|
|
1529
1667
|
wsRef.current.send(JSON.stringify({
|
|
1530
1668
|
type: 'clear_console_logs',
|
|
@@ -1537,7 +1675,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1537
1675
|
const clearTerminalLogs = useCallback(() => {
|
|
1538
1676
|
setTerminalLogs([]);
|
|
1539
1677
|
// Also notify server to clear its terminal log history
|
|
1540
|
-
if (wsRef.current?.readyState ===
|
|
1678
|
+
if (wsRef.current?.readyState === ReconnectingWebSocket.OPEN) {
|
|
1541
1679
|
wsRef.current.send(JSON.stringify({ type: 'clear_terminal_logs' }));
|
|
1542
1680
|
}
|
|
1543
1681
|
}, []);
|
|
@@ -1548,7 +1686,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1548
1686
|
// Uses direct WebSocket send to avoid dependency on sendRequest (which is defined later).
|
|
1549
1687
|
const clearChatMessages = useCallback(() => {
|
|
1550
1688
|
setChatMessages([]);
|
|
1551
|
-
if (wsRef.current?.readyState ===
|
|
1689
|
+
if (wsRef.current?.readyState === ReconnectingWebSocket.OPEN) {
|
|
1552
1690
|
const workflowId = useAppStore.getState().currentWorkflow?.id || 'default';
|
|
1553
1691
|
wsRef.current.send(JSON.stringify({
|
|
1554
1692
|
type: 'clear_chat_messages',
|
|
@@ -1665,7 +1803,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
1665
1803
|
const effectiveTimeout = (timeoutMs === -1 || !useTimeout) ? -1 : actualTimeout;
|
|
1666
1804
|
|
|
1667
1805
|
// FAST PATH: socket open — send immediately.
|
|
1668
|
-
if (wsRef.current?.readyState ===
|
|
1806
|
+
if (wsRef.current?.readyState === ReconnectingWebSocket.OPEN) {
|
|
1669
1807
|
const requestId = generateRequestId();
|
|
1670
1808
|
let timeout: NodeJS.Timeout | null = null;
|
|
1671
1809
|
if (effectiveTimeout > 0) {
|
|
@@ -2113,17 +2251,24 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
2113
2251
|
provider,
|
|
2114
2252
|
api_key: apiKey
|
|
2115
2253
|
});
|
|
2254
|
+
// Backend returns one of:
|
|
2255
|
+
// { success: true, valid: true, models } — key is good
|
|
2256
|
+
// { success: true, valid: false, message } — clean rejection (401/403/timeout/etc)
|
|
2257
|
+
// { success: false, error } — handler bug (uncaught exception)
|
|
2258
|
+
// Surface the message field first; fall back to error so the toast
|
|
2259
|
+
// shows the actual reason instead of a generic "Validation failed".
|
|
2116
2260
|
const result = {
|
|
2117
|
-
valid: response.valid
|
|
2118
|
-
message: response.message,
|
|
2261
|
+
valid: response.valid === true,
|
|
2262
|
+
message: response.message ?? response.error,
|
|
2119
2263
|
models: response.models
|
|
2120
2264
|
};
|
|
2121
2265
|
|
|
2122
|
-
// Update apiKeyStatuses on successful validation
|
|
2266
|
+
// Update apiKeyStatuses on successful validation. "is stored"
|
|
2267
|
+
// lives on catalogue.provider.stored — don't duplicate it here.
|
|
2123
2268
|
if (result.valid) {
|
|
2124
2269
|
setApiKeyStatuses(prev => ({
|
|
2125
2270
|
...prev,
|
|
2126
|
-
[provider]: {
|
|
2271
|
+
[provider]: { valid: true, models: result.models }
|
|
2127
2272
|
}));
|
|
2128
2273
|
}
|
|
2129
2274
|
|
|
@@ -2151,12 +2296,13 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
2151
2296
|
models: response.models,
|
|
2152
2297
|
};
|
|
2153
2298
|
|
|
2154
|
-
// Mirror into apiKeyStatuses so consumers reading from
|
|
2155
|
-
// stay in sync without an extra round-trip.
|
|
2299
|
+
// Mirror models into apiKeyStatuses so consumers reading from
|
|
2300
|
+
// context stay in sync without an extra round-trip. "is stored"
|
|
2301
|
+
// lives on the catalogue's provider.stored, not here.
|
|
2156
2302
|
if (result.hasKey) {
|
|
2157
2303
|
setApiKeyStatuses(prev => ({
|
|
2158
2304
|
...prev,
|
|
2159
|
-
[provider]: {
|
|
2305
|
+
[provider]: { valid: true, models: result.models }
|
|
2160
2306
|
}));
|
|
2161
2307
|
}
|
|
2162
2308
|
|
|
@@ -2180,11 +2326,14 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
2180
2326
|
});
|
|
2181
2327
|
const success = response.success !== false;
|
|
2182
2328
|
|
|
2183
|
-
// Update apiKeyStatuses on successful save
|
|
2329
|
+
// Update apiKeyStatuses on successful save. The 'valid: true'
|
|
2330
|
+
// here is optimistic — save_api_key doesn't actually validate
|
|
2331
|
+
// upstream. Catalogue refetch (via the credential.api_key.saved
|
|
2332
|
+
// broadcast) is the truthful source for "is stored".
|
|
2184
2333
|
if (success) {
|
|
2185
2334
|
setApiKeyStatuses(prev => ({
|
|
2186
2335
|
...prev,
|
|
2187
|
-
[provider]: {
|
|
2336
|
+
[provider]: { valid: true, models }
|
|
2188
2337
|
}));
|
|
2189
2338
|
}
|
|
2190
2339
|
|
|
@@ -2198,14 +2347,16 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
2198
2347
|
const deleteApiKeyAsync = useCallback(async (provider: string): Promise<boolean> => {
|
|
2199
2348
|
try {
|
|
2200
2349
|
await sendRequest<any>('delete_api_key', { provider });
|
|
2201
|
-
|
|
2202
|
-
//
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2350
|
+
// Don't optimistically clear apiKeyStatuses[provider] here. The
|
|
2351
|
+
// backend's `api_key_status` broadcast (fired before this reply
|
|
2352
|
+
// lands) already wrote `{valid: false, hasKey: false, message:
|
|
2353
|
+
// 'deleted'}` to every connected client. The catalogue refetch
|
|
2354
|
+
// (debounced 300 ms after `credential_catalogue_updated`) will
|
|
2355
|
+
// flip `provider.stored` to false. A local optimistic clear here
|
|
2356
|
+
// raced with the broadcast and produced a green-flash mid-delete:
|
|
2357
|
+
// broadcast → red, optimistic clear → green (validation undefined
|
|
2358
|
+
// but stored still cached true), catalogue refetch → gray.
|
|
2359
|
+
// Trusting the broadcast pipeline gives a clean red → gray.
|
|
2209
2360
|
return true;
|
|
2210
2361
|
} catch (error) {
|
|
2211
2362
|
console.error('[WebSocket] Failed to delete API key:', error);
|
|
@@ -2522,7 +2673,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
2522
2673
|
}
|
|
2523
2674
|
|
|
2524
2675
|
// Skip if already connected
|
|
2525
|
-
if (wsRef.current?.readyState ===
|
|
2676
|
+
if (wsRef.current?.readyState === ReconnectingWebSocket.OPEN) {
|
|
2526
2677
|
return;
|
|
2527
2678
|
}
|
|
2528
2679
|
|
|
@@ -2553,7 +2704,7 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
2553
2704
|
}
|
|
2554
2705
|
pendingSendQueueRef.current = [];
|
|
2555
2706
|
}
|
|
2556
|
-
wsRef.current.close(
|
|
2707
|
+
wsRef.current.close(WS_CLOSE.NORMAL_CLOSURE, 'User logged out');
|
|
2557
2708
|
wsRef.current = null;
|
|
2558
2709
|
setIsConnected(false);
|
|
2559
2710
|
setIsReady(false);
|
|
@@ -2564,7 +2715,6 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
2564
2715
|
useEffect(() => {
|
|
2565
2716
|
return () => {
|
|
2566
2717
|
isMountedRef.current = false;
|
|
2567
|
-
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
|
2568
2718
|
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
|
|
2569
2719
|
// Drain the pending-send queue so any in-flight awaiters fail fast on
|
|
2570
2720
|
// unmount instead of dangling forever.
|
|
@@ -2579,8 +2729,8 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
2579
2729
|
}
|
|
2580
2730
|
pendingSendQueueRef.current = [];
|
|
2581
2731
|
}
|
|
2582
|
-
if (wsRef.current?.readyState ===
|
|
2583
|
-
wsRef.current.close(
|
|
2732
|
+
if (wsRef.current?.readyState === ReconnectingWebSocket.OPEN) {
|
|
2733
|
+
wsRef.current.close(WS_CLOSE.NORMAL_CLOSURE, 'Component unmounted');
|
|
2584
2734
|
}
|
|
2585
2735
|
};
|
|
2586
2736
|
}, []);
|
|
@@ -2636,6 +2786,23 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|
|
2636
2786
|
// Generic request method
|
|
2637
2787
|
sendRequest,
|
|
2638
2788
|
|
|
2789
|
+
// Generic broadcast subscription
|
|
2790
|
+
addEventListener: (type: string, handler: (data: any) => void) => {
|
|
2791
|
+
let set = eventListenersRef.current.get(type);
|
|
2792
|
+
if (!set) {
|
|
2793
|
+
set = new Set();
|
|
2794
|
+
eventListenersRef.current.set(type, set);
|
|
2795
|
+
}
|
|
2796
|
+
set.add(handler);
|
|
2797
|
+
return () => {
|
|
2798
|
+
const current = eventListenersRef.current.get(type);
|
|
2799
|
+
if (current) {
|
|
2800
|
+
current.delete(handler);
|
|
2801
|
+
if (current.size === 0) eventListenersRef.current.delete(type);
|
|
2802
|
+
}
|
|
2803
|
+
};
|
|
2804
|
+
},
|
|
2805
|
+
|
|
2639
2806
|
// Node Parameters
|
|
2640
2807
|
getNodeParameters: getNodeParametersAsync,
|
|
2641
2808
|
getAllNodeParameters: getAllNodeParametersAsync,
|