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
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""OAuth 2.0 PKCE shared infrastructure (Wave 11.I, milestone S).
|
|
2
|
+
|
|
3
|
+
Two pieces consumed by every OAuth-using plugin:
|
|
4
|
+
|
|
5
|
+
1. :class:`OAuthStateStore` -- in-memory PKCE/state store with TTL.
|
|
6
|
+
Both Twitter (hand-rolled PKCE) and Google (``google_auth_oauthlib``-
|
|
7
|
+
wrapped) drive their callback CSRF check through one of these. The
|
|
8
|
+
pre-Wave-11.I plugin folders each hand-rolled their own ``dict`` +
|
|
9
|
+
``cleanup_expired_states`` helper -- collapsed into this one class.
|
|
10
|
+
|
|
11
|
+
2. :class:`OAuth2PKCEClient` -- abstract base for plugins that hand-roll
|
|
12
|
+
the OAuth 2.0 PKCE dance themselves (Twitter pattern). Subclass it
|
|
13
|
+
when the upstream API exposes a vanilla token endpoint and the
|
|
14
|
+
`Flow` class from `google_auth_oauthlib` doesn't fit. Override
|
|
15
|
+
:meth:`fetch_user_info` to translate the provider's profile API into
|
|
16
|
+
the unified ``{id, username, name, ...}`` shape consumed by the
|
|
17
|
+
lifecycle helpers in :mod:`services.events.oauth_lifecycle`.
|
|
18
|
+
|
|
19
|
+
Plugins that compose around an upstream library (Google's
|
|
20
|
+
``google_auth_oauthlib.flow.Flow``) skip the subclass and instantiate
|
|
21
|
+
:class:`OAuthStateStore` directly. The lifecycle factory in
|
|
22
|
+
:mod:`services.events.oauth_lifecycle` is duck-typed against the
|
|
23
|
+
methods listed below -- both subclass and composition paths feed it
|
|
24
|
+
without changes.
|
|
25
|
+
|
|
26
|
+
Persistence: in-memory only. State resets on server restart -- a popup
|
|
27
|
+
opened across a restart will fail the callback CSRF check. Same as
|
|
28
|
+
pre-extraction behaviour. Persistent backing (Redis / DB) is a
|
|
29
|
+
documented follow-up.
|
|
30
|
+
|
|
31
|
+
Cleanup: lazy. The store exposes :meth:`OAuthStateStore.cleanup_expired`
|
|
32
|
+
so plugins can prune from a periodic task; called on demand from the
|
|
33
|
+
callback path so a flood of abandoned popups eventually self-clears
|
|
34
|
+
without a background sweeper.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import base64
|
|
40
|
+
import hashlib
|
|
41
|
+
import secrets
|
|
42
|
+
import time
|
|
43
|
+
from abc import ABC, abstractmethod
|
|
44
|
+
from typing import Any, ClassVar, Dict, List, Optional
|
|
45
|
+
from urllib.parse import urlencode
|
|
46
|
+
|
|
47
|
+
import httpx
|
|
48
|
+
|
|
49
|
+
from core.logging import get_logger
|
|
50
|
+
|
|
51
|
+
logger = get_logger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ============================================================================
|
|
55
|
+
# OAuthStateStore
|
|
56
|
+
# ============================================================================
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class OAuthStateStore:
|
|
60
|
+
"""In-memory PKCE/state store with TTL cleanup.
|
|
61
|
+
|
|
62
|
+
One instance per plugin (or one shared module-level instance).
|
|
63
|
+
Stores a payload (typically ``{code_verifier, redirect_uri,
|
|
64
|
+
state_data, created_at}``) keyed by the random ``state`` parameter
|
|
65
|
+
the OAuth flow round-trips through the browser.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
DEFAULT_TTL_SECONDS = 600 # 10 minutes -- auth codes themselves expire faster
|
|
69
|
+
|
|
70
|
+
def __init__(self, *, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> None:
|
|
71
|
+
self.ttl_seconds = ttl_seconds
|
|
72
|
+
self._states: Dict[str, Dict[str, Any]] = {}
|
|
73
|
+
|
|
74
|
+
def put(self, state: str, payload: Dict[str, Any]) -> None:
|
|
75
|
+
"""Store a payload under ``state``. Stamps ``created_at``."""
|
|
76
|
+
record = dict(payload)
|
|
77
|
+
record.setdefault("created_at", time.time())
|
|
78
|
+
self._states[state] = record
|
|
79
|
+
|
|
80
|
+
def take(self, state: str) -> Optional[Dict[str, Any]]:
|
|
81
|
+
"""Pop and return the payload for ``state``. One-shot.
|
|
82
|
+
|
|
83
|
+
This is the callback-path read: the state is consumed exactly
|
|
84
|
+
once when the user lands back on the redirect URI with the
|
|
85
|
+
``code`` parameter.
|
|
86
|
+
"""
|
|
87
|
+
return self._states.pop(state, None)
|
|
88
|
+
|
|
89
|
+
def peek(self, state: str) -> Optional[Dict[str, Any]]:
|
|
90
|
+
"""Return the payload without removing it (read-only check).
|
|
91
|
+
|
|
92
|
+
Used by callback handlers that need to read the redirect_uri /
|
|
93
|
+
state_data BEFORE running the code exchange (which calls
|
|
94
|
+
:meth:`take`).
|
|
95
|
+
"""
|
|
96
|
+
return self._states.get(state)
|
|
97
|
+
|
|
98
|
+
def cleanup_expired(self) -> int:
|
|
99
|
+
"""Remove states older than ``ttl_seconds``. Returns count removed."""
|
|
100
|
+
now = time.time()
|
|
101
|
+
expired = [
|
|
102
|
+
state
|
|
103
|
+
for state, record in self._states.items()
|
|
104
|
+
if now - record.get("created_at", 0) > self.ttl_seconds
|
|
105
|
+
]
|
|
106
|
+
for state in expired:
|
|
107
|
+
self._states.pop(state, None)
|
|
108
|
+
if expired:
|
|
109
|
+
logger.debug(f"OAuthStateStore: cleaned {len(expired)} expired states")
|
|
110
|
+
return len(expired)
|
|
111
|
+
|
|
112
|
+
def __len__(self) -> int:
|
|
113
|
+
return len(self._states)
|
|
114
|
+
|
|
115
|
+
def __contains__(self, state: object) -> bool:
|
|
116
|
+
return state in self._states
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ============================================================================
|
|
120
|
+
# PKCE helpers (RFC 7636)
|
|
121
|
+
# ============================================================================
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _generate_code_verifier() -> str:
|
|
125
|
+
"""Cryptographically random PKCE code verifier (43-128 chars)."""
|
|
126
|
+
random_bytes = secrets.token_bytes(96)
|
|
127
|
+
verifier = base64.urlsafe_b64encode(random_bytes).rstrip(b"=").decode("ascii")
|
|
128
|
+
return verifier[:128]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _generate_code_challenge(code_verifier: str) -> str:
|
|
132
|
+
"""S256 code challenge: ``BASE64URL(SHA256(code_verifier))``."""
|
|
133
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
134
|
+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _generate_state() -> str:
|
|
138
|
+
"""Random state parameter for CSRF protection (URL-safe)."""
|
|
139
|
+
return secrets.token_urlsafe(32)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ============================================================================
|
|
143
|
+
# OAuth2PKCEClient (subclass-mode -- Twitter pattern)
|
|
144
|
+
# ============================================================================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class OAuth2PKCEClient(ABC):
|
|
148
|
+
"""Hand-rolled OAuth 2.0 PKCE client.
|
|
149
|
+
|
|
150
|
+
Concrete subclasses override :attr:`provider`,
|
|
151
|
+
:attr:`authorization_endpoint`, :attr:`token_endpoint`,
|
|
152
|
+
optionally :attr:`revocation_endpoint`, and the
|
|
153
|
+
:meth:`fetch_user_info` translation. Everything else (PKCE state
|
|
154
|
+
management, code exchange, token refresh, optional revocation)
|
|
155
|
+
lives on this base.
|
|
156
|
+
|
|
157
|
+
Plugins that compose around a third-party Flow class (e.g.
|
|
158
|
+
Google's ``google_auth_oauthlib.flow.Flow``) skip this base and
|
|
159
|
+
instantiate :class:`OAuthStateStore` directly. The lifecycle
|
|
160
|
+
factory is duck-typed against these method signatures -- both
|
|
161
|
+
paths feed it identically.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
provider: ClassVar[str] = ""
|
|
165
|
+
authorization_endpoint: ClassVar[str] = ""
|
|
166
|
+
token_endpoint: ClassVar[str] = ""
|
|
167
|
+
revocation_endpoint: ClassVar[str] = "" # optional
|
|
168
|
+
|
|
169
|
+
# Per-class shared state store. Plugins can override at class level
|
|
170
|
+
# if they want isolated TTLs / multiple instances.
|
|
171
|
+
state_store: ClassVar[OAuthStateStore] = OAuthStateStore()
|
|
172
|
+
|
|
173
|
+
DEFAULT_SCOPES: ClassVar[List[str]] = []
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
client_id: str,
|
|
178
|
+
redirect_uri: str,
|
|
179
|
+
client_secret: Optional[str] = None,
|
|
180
|
+
scopes: Optional[List[str]] = None,
|
|
181
|
+
) -> None:
|
|
182
|
+
if not self.provider:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"{type(self).__name__} must set the ``provider`` ClassVar"
|
|
185
|
+
)
|
|
186
|
+
self.client_id = client_id
|
|
187
|
+
self.client_secret = client_secret
|
|
188
|
+
self.redirect_uri = redirect_uri
|
|
189
|
+
self.scopes = list(scopes) if scopes is not None else list(self.DEFAULT_SCOPES)
|
|
190
|
+
|
|
191
|
+
# ---- authorization-url generation ----------------------------------
|
|
192
|
+
|
|
193
|
+
def generate_authorization_url(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
state_data: Optional[Dict[str, Any]] = None,
|
|
197
|
+
extra_params: Optional[Dict[str, str]] = None,
|
|
198
|
+
) -> Dict[str, str]:
|
|
199
|
+
"""Return the redirect URL + state + verifier.
|
|
200
|
+
|
|
201
|
+
Stashes ``code_verifier`` + ``redirect_uri`` (and any
|
|
202
|
+
caller-supplied ``state_data``) in :attr:`state_store` keyed by
|
|
203
|
+
the random state. The callback handler reads this back to
|
|
204
|
+
complete the exchange.
|
|
205
|
+
"""
|
|
206
|
+
state = _generate_state()
|
|
207
|
+
code_verifier = _generate_code_verifier()
|
|
208
|
+
code_challenge = _generate_code_challenge(code_verifier)
|
|
209
|
+
|
|
210
|
+
self.state_store.put(state, {
|
|
211
|
+
"code_verifier": code_verifier,
|
|
212
|
+
"redirect_uri": self.redirect_uri,
|
|
213
|
+
"data": state_data or {},
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
params = {
|
|
217
|
+
"response_type": "code",
|
|
218
|
+
"client_id": self.client_id,
|
|
219
|
+
"redirect_uri": self.redirect_uri,
|
|
220
|
+
"scope": " ".join(self.scopes),
|
|
221
|
+
"state": state,
|
|
222
|
+
"code_challenge": code_challenge,
|
|
223
|
+
"code_challenge_method": "S256",
|
|
224
|
+
}
|
|
225
|
+
if extra_params:
|
|
226
|
+
params.update(extra_params)
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
"url": f"{self.authorization_endpoint}?{urlencode(params)}",
|
|
230
|
+
"state": state,
|
|
231
|
+
"code_verifier": code_verifier,
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# ---- token endpoint helpers ----------------------------------------
|
|
235
|
+
|
|
236
|
+
def _token_request_auth(self) -> tuple[Dict[str, str], Dict[str, str]]:
|
|
237
|
+
"""Build (extra_body, extra_headers) for the token endpoint.
|
|
238
|
+
|
|
239
|
+
Confidential clients (with ``client_secret``) send Basic auth
|
|
240
|
+
in the header. Public clients put ``client_id`` in the body.
|
|
241
|
+
"""
|
|
242
|
+
if self.client_secret:
|
|
243
|
+
credentials = base64.b64encode(
|
|
244
|
+
f"{self.client_id}:{self.client_secret}".encode()
|
|
245
|
+
).decode()
|
|
246
|
+
return {}, {"Authorization": f"Basic {credentials}"}
|
|
247
|
+
return {"client_id": self.client_id}, {}
|
|
248
|
+
|
|
249
|
+
async def exchange_code(self, code: str, state: str) -> Dict[str, Any]:
|
|
250
|
+
"""Exchange an auth code for tokens. Pops the state."""
|
|
251
|
+
record = self.state_store.take(state)
|
|
252
|
+
if not record:
|
|
253
|
+
logger.error(f"[{self.provider}] Invalid or expired OAuth state")
|
|
254
|
+
return {"success": False, "error": "Invalid or expired state"}
|
|
255
|
+
|
|
256
|
+
body = {
|
|
257
|
+
"grant_type": "authorization_code",
|
|
258
|
+
"code": code,
|
|
259
|
+
"redirect_uri": record.get("redirect_uri", self.redirect_uri),
|
|
260
|
+
"code_verifier": record["code_verifier"],
|
|
261
|
+
}
|
|
262
|
+
extra_body, extra_headers = self._token_request_auth()
|
|
263
|
+
body.update(extra_body)
|
|
264
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded", **extra_headers}
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
268
|
+
response = await client.post(
|
|
269
|
+
self.token_endpoint, data=body, headers=headers,
|
|
270
|
+
)
|
|
271
|
+
except httpx.HTTPError as exc:
|
|
272
|
+
logger.error(f"[{self.provider}] HTTP error during token exchange: {exc}")
|
|
273
|
+
return {"success": False, "error": str(exc)}
|
|
274
|
+
|
|
275
|
+
if response.status_code != 200:
|
|
276
|
+
error_data = response.json() if response.text else {}
|
|
277
|
+
logger.error(
|
|
278
|
+
f"[{self.provider}] Token exchange failed",
|
|
279
|
+
status=response.status_code, error=error_data,
|
|
280
|
+
)
|
|
281
|
+
return {
|
|
282
|
+
"success": False,
|
|
283
|
+
"error": error_data.get(
|
|
284
|
+
"error_description",
|
|
285
|
+
error_data.get("error", "Token exchange failed"),
|
|
286
|
+
),
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
data = response.json()
|
|
290
|
+
return {
|
|
291
|
+
"success": True,
|
|
292
|
+
"access_token": data.get("access_token"),
|
|
293
|
+
"refresh_token": data.get("refresh_token"),
|
|
294
|
+
"expires_in": data.get("expires_in"),
|
|
295
|
+
"scope": data.get("scope"),
|
|
296
|
+
"token_type": data.get("token_type", "Bearer"),
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
|
|
300
|
+
"""Trade a refresh token for a fresh access token."""
|
|
301
|
+
body = {"grant_type": "refresh_token", "refresh_token": refresh_token}
|
|
302
|
+
extra_body, extra_headers = self._token_request_auth()
|
|
303
|
+
body.update(extra_body)
|
|
304
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded", **extra_headers}
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
308
|
+
response = await client.post(
|
|
309
|
+
self.token_endpoint, data=body, headers=headers,
|
|
310
|
+
)
|
|
311
|
+
except httpx.HTTPError as exc:
|
|
312
|
+
logger.error(f"[{self.provider}] HTTP error during token refresh: {exc}")
|
|
313
|
+
return {"success": False, "error": str(exc)}
|
|
314
|
+
|
|
315
|
+
if response.status_code != 200:
|
|
316
|
+
error_data = response.json() if response.text else {}
|
|
317
|
+
return {
|
|
318
|
+
"success": False,
|
|
319
|
+
"error": error_data.get("error_description", "Token refresh failed"),
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
data = response.json()
|
|
323
|
+
return {
|
|
324
|
+
"success": True,
|
|
325
|
+
"access_token": data.get("access_token"),
|
|
326
|
+
"refresh_token": data.get("refresh_token"),
|
|
327
|
+
"expires_in": data.get("expires_in"),
|
|
328
|
+
"scope": data.get("scope"),
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async def revoke_token(
|
|
332
|
+
self, token: str, token_type: str = "access_token",
|
|
333
|
+
) -> Dict[str, Any]:
|
|
334
|
+
"""Revoke an access or refresh token (best-effort).
|
|
335
|
+
|
|
336
|
+
Subclasses without :attr:`revocation_endpoint` get a no-op
|
|
337
|
+
``{"success": True, "skipped": True}`` so callers can always
|
|
338
|
+
attempt a revoke without branching.
|
|
339
|
+
"""
|
|
340
|
+
if not self.revocation_endpoint:
|
|
341
|
+
return {"success": True, "skipped": True}
|
|
342
|
+
|
|
343
|
+
body = {"token": token, "token_type_hint": token_type}
|
|
344
|
+
extra_body, extra_headers = self._token_request_auth()
|
|
345
|
+
body.update(extra_body)
|
|
346
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded", **extra_headers}
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
350
|
+
response = await client.post(
|
|
351
|
+
self.revocation_endpoint, data=body, headers=headers,
|
|
352
|
+
)
|
|
353
|
+
except httpx.HTTPError as exc:
|
|
354
|
+
logger.error(f"[{self.provider}] HTTP error during token revoke: {exc}")
|
|
355
|
+
return {"success": False, "error": str(exc)}
|
|
356
|
+
|
|
357
|
+
if response.status_code == 200:
|
|
358
|
+
return {"success": True}
|
|
359
|
+
error_data = response.json() if response.text else {}
|
|
360
|
+
return {
|
|
361
|
+
"success": False,
|
|
362
|
+
"error": error_data.get("error_description", "Revocation failed"),
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
# ---- subclass override --------------------------------------------
|
|
366
|
+
|
|
367
|
+
@abstractmethod
|
|
368
|
+
async def fetch_user_info(self, access_token: str) -> Dict[str, Any]:
|
|
369
|
+
"""Translate the provider's profile API into a unified shape.
|
|
370
|
+
|
|
371
|
+
MUST return ``{success: bool, ...}`` -- on success, include
|
|
372
|
+
``id`` and at least one of ``username`` / ``email`` / ``name``.
|
|
373
|
+
The lifecycle factory uses these to populate the broadcast
|
|
374
|
+
envelope's ``subject`` and the connection-status payload.
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
__all__ = [
|
|
379
|
+
"OAuthStateStore",
|
|
380
|
+
"OAuth2PKCEClient",
|
|
381
|
+
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""PollingTriggerNode -- template-method polling-trigger base class.
|
|
2
|
+
|
|
3
|
+
Wave 11.I, milestone L. Subclass owns the four divergence points
|
|
4
|
+
(``setup_service``, ``fetch_ids``, ``fetch_detail``, optional
|
|
5
|
+
``post_emit``); the base owns the loop body, the seen-id baseline,
|
|
6
|
+
the cancellation surface, and per-cycle error isolation.
|
|
7
|
+
|
|
8
|
+
Auto-registers a poll-coroutine factory in
|
|
9
|
+
:mod:`services.deployment.poll_registry` so
|
|
10
|
+
``DeploymentManager._setup_event_trigger`` looks the plugin up by
|
|
11
|
+
node type instead of branching on a hardcoded list.
|
|
12
|
+
|
|
13
|
+
Design choice (over lift-and-shift): the gmail and email pollers in
|
|
14
|
+
``services/deployment/manager.py`` had identical structure -- 80 LOC
|
|
15
|
+
vs 37 LOC, only the four seams differed. Pulling the loop into a
|
|
16
|
+
mixin collapses ~120 LOC of duplicated polling control flow into one
|
|
17
|
+
~40 LOC body, plus ~15 LOC per concrete poller.
|
|
18
|
+
|
|
19
|
+
Cancellation contract
|
|
20
|
+
---------------------
|
|
21
|
+
Mirrors the existing ``setup_polling_trigger`` consumer in
|
|
22
|
+
``services/deployment/triggers.py``:
|
|
23
|
+
|
|
24
|
+
- The deployment manager passes ``is_running_fn``; the loop checks
|
|
25
|
+
it before every sleep and after wake.
|
|
26
|
+
- ``asyncio.CancelledError`` is re-raised, never swallowed (per
|
|
27
|
+
https://docs.python.org/3/library/asyncio-task.html#task-cancellation).
|
|
28
|
+
|
|
29
|
+
Per-cycle error isolation: a transient ``Exception`` from
|
|
30
|
+
``fetch_ids`` or ``fetch_detail`` logs at ERROR and the loop sleeps
|
|
31
|
+
to the next interval. Permanent errors (auth revoked, account
|
|
32
|
+
deleted) currently keep retrying indefinitely with the same log
|
|
33
|
+
line; ``tenacity``-backed classification is a follow-up extension
|
|
34
|
+
point documented inline.
|
|
35
|
+
|
|
36
|
+
Out of scope (commit-stage)
|
|
37
|
+
---------------------------
|
|
38
|
+
- ``WorkflowEvent.message(...)`` envelope wrapping: ``setup_polling_trigger``
|
|
39
|
+
consumes raw dicts today; envelope adoption is the U milestone test pass.
|
|
40
|
+
- Persistent watermark cursor: ``seen_ids`` is in-memory and resets
|
|
41
|
+
on restart. Backlog dropped on restart -- existing behaviour.
|
|
42
|
+
- Twitter polling: ``twitterReceive`` declares ``mode="polling"`` but
|
|
43
|
+
has no concrete subclass. Subclassing this base is the conversion path.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import asyncio
|
|
49
|
+
from typing import Any, Callable, ClassVar, Dict, Optional, Set, Tuple
|
|
50
|
+
|
|
51
|
+
from core.logging import get_logger
|
|
52
|
+
from services.plugin.trigger import TriggerNode
|
|
53
|
+
|
|
54
|
+
logger = get_logger(__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PollingTriggerNode(TriggerNode, abstract=True):
|
|
58
|
+
"""Polling trigger with a unified loop body.
|
|
59
|
+
|
|
60
|
+
Subclass MUST override:
|
|
61
|
+
|
|
62
|
+
- :meth:`setup_service` -- build the auth client / SDK handle.
|
|
63
|
+
- :meth:`fetch_ids` -- return the current visible IDs (set[str]).
|
|
64
|
+
- :meth:`fetch_detail` -- return the event payload for one ID.
|
|
65
|
+
|
|
66
|
+
Subclass MAY override:
|
|
67
|
+
|
|
68
|
+
- :meth:`post_emit` -- side effect after enqueue (e.g. mark-as-read).
|
|
69
|
+
- :attr:`poll_interval_clamp` -- (min, max) seconds for the
|
|
70
|
+
``poll_interval`` param. Default (10, 3600).
|
|
71
|
+
- :attr:`type_alias` -- a second registration key for plugins
|
|
72
|
+
that want to be reachable by a legacy type name without
|
|
73
|
+
changing the plugin's primary ``type``. Currently unused after
|
|
74
|
+
Wave 11.I milestone P retired the gmail alias; kept on the base
|
|
75
|
+
class as a documented escape hatch for future renames.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
mode: ClassVar[str] = "polling"
|
|
79
|
+
|
|
80
|
+
# (min_seconds, max_seconds) for the user-supplied poll_interval.
|
|
81
|
+
poll_interval_clamp: ClassVar[Tuple[int, int]] = (10, 3600)
|
|
82
|
+
|
|
83
|
+
# Optional secondary registration key. Registers under both
|
|
84
|
+
# ``cls.type`` and ``cls.type_alias`` for plugins with a legacy
|
|
85
|
+
# alias.
|
|
86
|
+
type_alias: ClassVar[str] = ""
|
|
87
|
+
|
|
88
|
+
# ---- subclass hooks ------------------------------------------------
|
|
89
|
+
|
|
90
|
+
async def setup_service(self, params: Dict[str, Any]) -> Any:
|
|
91
|
+
"""Build the auth client / service handle that ``fetch_ids`` and
|
|
92
|
+
``fetch_detail`` will close over.
|
|
93
|
+
|
|
94
|
+
Plugins that don't need a long-lived handle can return any
|
|
95
|
+
opaque value (or the ``params`` dict itself). The returned
|
|
96
|
+
value is passed unchanged to the other hooks.
|
|
97
|
+
"""
|
|
98
|
+
raise NotImplementedError
|
|
99
|
+
|
|
100
|
+
async def fetch_ids(
|
|
101
|
+
self, service: Any, params: Dict[str, Any]
|
|
102
|
+
) -> Set[str]:
|
|
103
|
+
"""Return the current set of visible IDs for one poll cycle.
|
|
104
|
+
|
|
105
|
+
Called once for the baseline pass at loop start, then once per
|
|
106
|
+
``poll_interval`` thereafter. The diff against the previous
|
|
107
|
+
``seen`` set is the source of truth for "what's new".
|
|
108
|
+
"""
|
|
109
|
+
raise NotImplementedError
|
|
110
|
+
|
|
111
|
+
async def fetch_detail(
|
|
112
|
+
self, service: Any, msg_id: str, params: Dict[str, Any]
|
|
113
|
+
) -> Dict[str, Any]:
|
|
114
|
+
"""Fetch the full event payload for one ID.
|
|
115
|
+
|
|
116
|
+
Called once per new ID per cycle. The returned dict goes
|
|
117
|
+
directly onto the deployment manager's queue (no envelope
|
|
118
|
+
wrapping in this commit -- see milestone U / module docstring).
|
|
119
|
+
"""
|
|
120
|
+
raise NotImplementedError
|
|
121
|
+
|
|
122
|
+
async def post_emit(
|
|
123
|
+
self, service: Any, msg_id: str, params: Dict[str, Any]
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Optional side effect AFTER the payload was enqueued.
|
|
126
|
+
|
|
127
|
+
Default no-op. Override for "mark-as-read" or similar
|
|
128
|
+
post-processing that should not block the emit and whose
|
|
129
|
+
failure shouldn't kill the loop.
|
|
130
|
+
"""
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# ---- registration --------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def __init_subclass__(cls, abstract: bool = False, **kwargs):
|
|
136
|
+
super().__init_subclass__(abstract=abstract, **kwargs)
|
|
137
|
+
if abstract or not cls.type:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Auto-register a poll-coroutine factory for the deployment
|
|
141
|
+
# manager (the in-process Run-button path uses :meth:`execute`
|
|
142
|
+
# which we override below). Idempotent on re-import via
|
|
143
|
+
# IdempotentRegistry.
|
|
144
|
+
from services.deployment.poll_registry import (
|
|
145
|
+
register_poll_coroutine_factory,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def factory(node_id: str, params: Dict[str, Any]) -> Callable:
|
|
149
|
+
instance = cls()
|
|
150
|
+
return instance._build_poll_coroutine(node_id, params)
|
|
151
|
+
|
|
152
|
+
register_poll_coroutine_factory(cls.type, factory)
|
|
153
|
+
if cls.type_alias:
|
|
154
|
+
# Same factory under the legacy alias so consumers that
|
|
155
|
+
# key by the alias (manager.py POLLING_TRIGGER_TYPES) still
|
|
156
|
+
# find the factory until the rename lands.
|
|
157
|
+
register_poll_coroutine_factory(cls.type_alias, factory)
|
|
158
|
+
|
|
159
|
+
# ---- shared loop ---------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _clamp_interval(self, raw: Any) -> int:
|
|
162
|
+
lo, hi = self.poll_interval_clamp
|
|
163
|
+
try:
|
|
164
|
+
value = int(raw)
|
|
165
|
+
except (TypeError, ValueError):
|
|
166
|
+
value = self.default_poll_interval
|
|
167
|
+
return max(lo, min(hi, value))
|
|
168
|
+
|
|
169
|
+
def _build_poll_coroutine(
|
|
170
|
+
self, node_id: str, params: Dict[str, Any]
|
|
171
|
+
) -> Callable[[asyncio.Queue, Callable[[], bool]], Any]:
|
|
172
|
+
"""Return the bound poll coroutine the deployment manager
|
|
173
|
+
consumes. Closes over ``self``, ``node_id``, and ``params``.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
async def poll(
|
|
177
|
+
queue: asyncio.Queue, is_running_fn: Callable[[], bool]
|
|
178
|
+
) -> None:
|
|
179
|
+
try:
|
|
180
|
+
service = await self.setup_service(params)
|
|
181
|
+
except Exception as exc: # noqa: BLE001 -- single setup failure
|
|
182
|
+
logger.error(
|
|
183
|
+
"Polling trigger setup failed",
|
|
184
|
+
node_id=node_id, node_type=self.type, error=str(exc),
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
interval = self._clamp_interval(params.get("poll_interval"))
|
|
189
|
+
|
|
190
|
+
# Baseline -- avoid re-emitting items the user has had since
|
|
191
|
+
# before the deployment was live. On baseline failure, fall
|
|
192
|
+
# through with an empty set: the next cycle will emit
|
|
193
|
+
# everything currently visible (matches pre-migration gmail
|
|
194
|
+
# behaviour at line 728-736 of services/deployment/manager.py).
|
|
195
|
+
seen: Set[str] = set()
|
|
196
|
+
try:
|
|
197
|
+
seen = await self.fetch_ids(service, params)
|
|
198
|
+
logger.info(
|
|
199
|
+
"Polling trigger baseline established",
|
|
200
|
+
node_id=node_id, node_type=self.type, seen=len(seen),
|
|
201
|
+
)
|
|
202
|
+
except Exception as exc: # noqa: BLE001
|
|
203
|
+
logger.warning(
|
|
204
|
+
"Polling trigger baseline failed; treating all as new",
|
|
205
|
+
node_id=node_id, node_type=self.type, error=str(exc),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
cycle = 0
|
|
209
|
+
while is_running_fn():
|
|
210
|
+
await asyncio.sleep(interval)
|
|
211
|
+
if not is_running_fn():
|
|
212
|
+
break
|
|
213
|
+
cycle += 1
|
|
214
|
+
try:
|
|
215
|
+
current = await self.fetch_ids(service, params)
|
|
216
|
+
new_ids = current - seen
|
|
217
|
+
if not new_ids:
|
|
218
|
+
continue
|
|
219
|
+
logger.debug(
|
|
220
|
+
"Polling trigger cycle",
|
|
221
|
+
node_id=node_id, node_type=self.type, cycle=cycle,
|
|
222
|
+
current=len(current), seen=len(seen),
|
|
223
|
+
new=len(new_ids),
|
|
224
|
+
)
|
|
225
|
+
for msg_id in new_ids:
|
|
226
|
+
seen.add(msg_id)
|
|
227
|
+
payload = await self.fetch_detail(
|
|
228
|
+
service, msg_id, params
|
|
229
|
+
)
|
|
230
|
+
await queue.put(payload)
|
|
231
|
+
# post_emit failure must NOT block the next emit
|
|
232
|
+
# nor kill the loop; mirrors pre-migration
|
|
233
|
+
# try/except at gmail :760-763 / email :799-806.
|
|
234
|
+
try:
|
|
235
|
+
await self.post_emit(service, msg_id, params)
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
except asyncio.CancelledError:
|
|
239
|
+
raise
|
|
240
|
+
except Exception as exc: # noqa: BLE001 -- per-cycle isolation
|
|
241
|
+
logger.error(
|
|
242
|
+
"Polling trigger cycle error; retrying next interval",
|
|
243
|
+
node_id=node_id, node_type=self.type,
|
|
244
|
+
error=str(exc),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return poll
|