machinaos 0.0.1
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/.env.template +71 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/cli.js +159 -0
- package/client/.dockerignore +45 -0
- package/client/Dockerfile +68 -0
- package/client/eslint.config.js +29 -0
- package/client/index.html +13 -0
- package/client/nginx.conf +66 -0
- package/client/package.json +48 -0
- package/client/src/App.tsx +27 -0
- package/client/src/Dashboard.tsx +1173 -0
- package/client/src/ParameterPanel.tsx +301 -0
- package/client/src/components/AIAgentNode.tsx +321 -0
- package/client/src/components/APIKeyValidator.tsx +118 -0
- package/client/src/components/ClaudeChatModelNode.tsx +18 -0
- package/client/src/components/ConditionalEdge.tsx +189 -0
- package/client/src/components/CredentialsModal.tsx +306 -0
- package/client/src/components/EdgeConditionEditor.tsx +443 -0
- package/client/src/components/GeminiChatModelNode.tsx +18 -0
- package/client/src/components/GenericNode.tsx +357 -0
- package/client/src/components/LocationParameterPanel.tsx +154 -0
- package/client/src/components/ModelNode.tsx +286 -0
- package/client/src/components/OpenAIChatModelNode.tsx +18 -0
- package/client/src/components/OutputPanel.tsx +471 -0
- package/client/src/components/ParameterRenderer.tsx +1874 -0
- package/client/src/components/SkillEditorModal.tsx +417 -0
- package/client/src/components/SquareNode.tsx +797 -0
- package/client/src/components/StartNode.tsx +250 -0
- package/client/src/components/ToolkitNode.tsx +365 -0
- package/client/src/components/TriggerNode.tsx +463 -0
- package/client/src/components/auth/LoginPage.tsx +247 -0
- package/client/src/components/auth/ProtectedRoute.tsx +59 -0
- package/client/src/components/base/BaseChatModelNode.tsx +271 -0
- package/client/src/components/icons/AIProviderIcons.tsx +50 -0
- package/client/src/components/maps/GoogleMapsPicker.tsx +137 -0
- package/client/src/components/maps/MapsPreviewPanel.tsx +110 -0
- package/client/src/components/maps/index.ts +26 -0
- package/client/src/components/parameterPanel/InputSection.tsx +1094 -0
- package/client/src/components/parameterPanel/LocationPanelLayout.tsx +65 -0
- package/client/src/components/parameterPanel/MapsSection.tsx +92 -0
- package/client/src/components/parameterPanel/MiddleSection.tsx +571 -0
- package/client/src/components/parameterPanel/OutputSection.tsx +81 -0
- package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +82 -0
- package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -0
- package/client/src/components/parameterPanel/index.ts +42 -0
- package/client/src/components/shared/DataPanel.tsx +142 -0
- package/client/src/components/shared/JSONTreeRenderer.tsx +106 -0
- package/client/src/components/ui/AIResultModal.tsx +204 -0
- package/client/src/components/ui/AndroidSettingsPanel.tsx +401 -0
- package/client/src/components/ui/CodeEditor.tsx +81 -0
- package/client/src/components/ui/CollapsibleSection.tsx +88 -0
- package/client/src/components/ui/ComponentItem.tsx +154 -0
- package/client/src/components/ui/ComponentPalette.tsx +321 -0
- package/client/src/components/ui/ConsolePanel.tsx +1074 -0
- package/client/src/components/ui/ErrorBoundary.tsx +196 -0
- package/client/src/components/ui/InputNodesPanel.tsx +204 -0
- package/client/src/components/ui/MapSelector.tsx +314 -0
- package/client/src/components/ui/Modal.tsx +149 -0
- package/client/src/components/ui/NodeContextMenu.tsx +192 -0
- package/client/src/components/ui/NodeOutputPanel.tsx +1150 -0
- package/client/src/components/ui/OutputDisplayPanel.tsx +381 -0
- package/client/src/components/ui/SettingsPanel.tsx +243 -0
- package/client/src/components/ui/TopToolbar.tsx +736 -0
- package/client/src/components/ui/WhatsAppSettingsPanel.tsx +345 -0
- package/client/src/components/ui/WorkflowSidebar.tsx +294 -0
- package/client/src/config/antdTheme.ts +186 -0
- package/client/src/config/api.ts +54 -0
- package/client/src/contexts/AuthContext.tsx +221 -0
- package/client/src/contexts/ThemeContext.tsx +42 -0
- package/client/src/contexts/WebSocketContext.tsx +1971 -0
- package/client/src/factories/baseChatModelFactory.ts +256 -0
- package/client/src/hooks/useAndroidOperations.ts +164 -0
- package/client/src/hooks/useApiKeyValidation.ts +107 -0
- package/client/src/hooks/useApiKeys.ts +238 -0
- package/client/src/hooks/useAppTheme.ts +17 -0
- package/client/src/hooks/useComponentPalette.ts +51 -0
- package/client/src/hooks/useCopyPaste.ts +155 -0
- package/client/src/hooks/useDragAndDrop.ts +124 -0
- package/client/src/hooks/useDragVariable.ts +88 -0
- package/client/src/hooks/useExecution.ts +313 -0
- package/client/src/hooks/useParameterPanel.ts +176 -0
- package/client/src/hooks/useReactFlowNodes.ts +189 -0
- package/client/src/hooks/useToolSchema.ts +209 -0
- package/client/src/hooks/useWhatsApp.ts +196 -0
- package/client/src/hooks/useWorkflowManagement.ts +46 -0
- package/client/src/index.css +315 -0
- package/client/src/main.tsx +19 -0
- package/client/src/nodeDefinitions/aiAgentNodes.ts +336 -0
- package/client/src/nodeDefinitions/aiModelNodes.ts +340 -0
- package/client/src/nodeDefinitions/androidDeviceNodes.ts +140 -0
- package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -0
- package/client/src/nodeDefinitions/chatNodes.ts +135 -0
- package/client/src/nodeDefinitions/codeNodes.ts +54 -0
- package/client/src/nodeDefinitions/documentNodes.ts +379 -0
- package/client/src/nodeDefinitions/index.ts +15 -0
- package/client/src/nodeDefinitions/locationNodes.ts +463 -0
- package/client/src/nodeDefinitions/schedulerNodes.ts +220 -0
- package/client/src/nodeDefinitions/skillNodes.ts +211 -0
- package/client/src/nodeDefinitions/toolNodes.ts +198 -0
- package/client/src/nodeDefinitions/utilityNodes.ts +284 -0
- package/client/src/nodeDefinitions/whatsappNodes.ts +865 -0
- package/client/src/nodeDefinitions/workflowNodes.ts +41 -0
- package/client/src/nodeDefinitions.ts +104 -0
- package/client/src/schemas/workflowSchema.ts +264 -0
- package/client/src/services/dynamicParameterService.ts +96 -0
- package/client/src/services/execution/aiAgentExecutionService.ts +35 -0
- package/client/src/services/executionService.ts +232 -0
- package/client/src/services/workflowApi.ts +91 -0
- package/client/src/store/useAppStore.ts +582 -0
- package/client/src/styles/theme.ts +508 -0
- package/client/src/styles/zIndex.ts +17 -0
- package/client/src/types/ComponentTypes.ts +39 -0
- package/client/src/types/EdgeCondition.ts +231 -0
- package/client/src/types/INodeProperties.ts +288 -0
- package/client/src/types/NodeTypes.ts +28 -0
- package/client/src/utils/formatters.ts +33 -0
- package/client/src/utils/googleMapsLoader.ts +140 -0
- package/client/src/utils/locationUtils.ts +85 -0
- package/client/src/utils/nodeUtils.ts +31 -0
- package/client/src/utils/workflow.ts +30 -0
- package/client/src/utils/workflowExport.ts +120 -0
- package/client/src/vite-env.d.ts +12 -0
- package/client/tailwind.config.js +60 -0
- package/client/tsconfig.json +25 -0
- package/client/tsconfig.node.json +11 -0
- package/client/vite.config.js +35 -0
- package/docker-compose.prod.yml +107 -0
- package/docker-compose.yml +104 -0
- package/docs-MachinaOs/README.md +85 -0
- package/docs-MachinaOs/deployment/docker.mdx +228 -0
- package/docs-MachinaOs/deployment/production.mdx +345 -0
- package/docs-MachinaOs/docs.json +75 -0
- package/docs-MachinaOs/faq.mdx +309 -0
- package/docs-MachinaOs/favicon.svg +5 -0
- package/docs-MachinaOs/installation.mdx +160 -0
- package/docs-MachinaOs/introduction.mdx +114 -0
- package/docs-MachinaOs/logo/dark.svg +6 -0
- package/docs-MachinaOs/logo/light.svg +6 -0
- package/docs-MachinaOs/nodes/ai-agent.mdx +216 -0
- package/docs-MachinaOs/nodes/ai-models.mdx +240 -0
- package/docs-MachinaOs/nodes/android.mdx +411 -0
- package/docs-MachinaOs/nodes/overview.mdx +181 -0
- package/docs-MachinaOs/nodes/schedulers.mdx +316 -0
- package/docs-MachinaOs/nodes/webhooks.mdx +330 -0
- package/docs-MachinaOs/nodes/whatsapp.mdx +305 -0
- package/docs-MachinaOs/quickstart.mdx +119 -0
- package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +177 -0
- package/docs-MachinaOs/tutorials/android-automation.mdx +242 -0
- package/docs-MachinaOs/tutorials/first-workflow.mdx +134 -0
- package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +185 -0
- package/nul +0 -0
- package/package.json +70 -0
- package/scripts/build.js +158 -0
- package/scripts/check-ports.ps1 +33 -0
- package/scripts/clean.js +40 -0
- package/scripts/docker.js +93 -0
- package/scripts/kill-port.ps1 +154 -0
- package/scripts/start.js +210 -0
- package/scripts/stop.js +325 -0
- package/server/.dockerignore +44 -0
- package/server/Dockerfile +45 -0
- package/server/constants.py +249 -0
- package/server/core/__init__.py +1 -0
- package/server/core/cache.py +461 -0
- package/server/core/config.py +128 -0
- package/server/core/container.py +99 -0
- package/server/core/database.py +1211 -0
- package/server/core/logging.py +314 -0
- package/server/main.py +289 -0
- package/server/middleware/__init__.py +5 -0
- package/server/middleware/auth.py +89 -0
- package/server/models/__init__.py +1 -0
- package/server/models/auth.py +52 -0
- package/server/models/cache.py +24 -0
- package/server/models/database.py +211 -0
- package/server/models/nodes.py +455 -0
- package/server/package.json +9 -0
- package/server/pyproject.toml +72 -0
- package/server/requirements.txt +83 -0
- package/server/routers/__init__.py +1 -0
- package/server/routers/android.py +294 -0
- package/server/routers/auth.py +203 -0
- package/server/routers/database.py +151 -0
- package/server/routers/maps.py +142 -0
- package/server/routers/nodejs_compat.py +289 -0
- package/server/routers/webhook.py +90 -0
- package/server/routers/websocket.py +2127 -0
- package/server/routers/whatsapp.py +761 -0
- package/server/routers/workflow.py +200 -0
- package/server/services/__init__.py +1 -0
- package/server/services/ai.py +2415 -0
- package/server/services/android/__init__.py +27 -0
- package/server/services/android/broadcaster.py +114 -0
- package/server/services/android/client.py +608 -0
- package/server/services/android/manager.py +78 -0
- package/server/services/android/protocol.py +165 -0
- package/server/services/android_service.py +588 -0
- package/server/services/auth.py +131 -0
- package/server/services/chat_client.py +160 -0
- package/server/services/deployment/__init__.py +12 -0
- package/server/services/deployment/manager.py +706 -0
- package/server/services/deployment/state.py +47 -0
- package/server/services/deployment/triggers.py +275 -0
- package/server/services/event_waiter.py +785 -0
- package/server/services/execution/__init__.py +77 -0
- package/server/services/execution/cache.py +769 -0
- package/server/services/execution/conditions.py +373 -0
- package/server/services/execution/dlq.py +132 -0
- package/server/services/execution/executor.py +1351 -0
- package/server/services/execution/models.py +531 -0
- package/server/services/execution/recovery.py +235 -0
- package/server/services/handlers/__init__.py +126 -0
- package/server/services/handlers/ai.py +355 -0
- package/server/services/handlers/android.py +260 -0
- package/server/services/handlers/code.py +278 -0
- package/server/services/handlers/document.py +598 -0
- package/server/services/handlers/http.py +193 -0
- package/server/services/handlers/polyglot.py +105 -0
- package/server/services/handlers/tools.py +845 -0
- package/server/services/handlers/triggers.py +107 -0
- package/server/services/handlers/utility.py +822 -0
- package/server/services/handlers/whatsapp.py +476 -0
- package/server/services/maps.py +289 -0
- package/server/services/memory_store.py +103 -0
- package/server/services/node_executor.py +375 -0
- package/server/services/parameter_resolver.py +218 -0
- package/server/services/polyglot_client.py +169 -0
- package/server/services/scheduler.py +155 -0
- package/server/services/skill_loader.py +417 -0
- package/server/services/status_broadcaster.py +826 -0
- package/server/services/temporal/__init__.py +23 -0
- package/server/services/temporal/activities.py +344 -0
- package/server/services/temporal/client.py +76 -0
- package/server/services/temporal/executor.py +147 -0
- package/server/services/temporal/worker.py +251 -0
- package/server/services/temporal/workflow.py +355 -0
- package/server/services/temporal/ws_client.py +236 -0
- package/server/services/text.py +111 -0
- package/server/services/user_auth.py +172 -0
- package/server/services/websocket_client.py +29 -0
- package/server/services/workflow.py +597 -0
- package/server/skills/android-skill/SKILL.md +82 -0
- package/server/skills/assistant-personality/SKILL.md +45 -0
- package/server/skills/code-skill/SKILL.md +140 -0
- package/server/skills/http-skill/SKILL.md +161 -0
- package/server/skills/maps-skill/SKILL.md +170 -0
- package/server/skills/memory-skill/SKILL.md +154 -0
- package/server/skills/scheduler-skill/SKILL.md +84 -0
- package/server/skills/whatsapp-skill/SKILL.md +283 -0
- package/server/uv.lock +2916 -0
- package/server/whatsapp-rpc/.dockerignore +30 -0
- package/server/whatsapp-rpc/Dockerfile +44 -0
- package/server/whatsapp-rpc/Dockerfile.web +17 -0
- package/server/whatsapp-rpc/README.md +139 -0
- package/server/whatsapp-rpc/cli.js +95 -0
- package/server/whatsapp-rpc/configs/config.yaml +7 -0
- package/server/whatsapp-rpc/docker-compose.yml +35 -0
- package/server/whatsapp-rpc/docs/API.md +410 -0
- package/server/whatsapp-rpc/go.mod +67 -0
- package/server/whatsapp-rpc/go.sum +203 -0
- package/server/whatsapp-rpc/package.json +30 -0
- package/server/whatsapp-rpc/schema.json +1294 -0
- package/server/whatsapp-rpc/scripts/clean.cjs +66 -0
- package/server/whatsapp-rpc/scripts/cli.js +162 -0
- package/server/whatsapp-rpc/src/go/cmd/server/main.go +91 -0
- package/server/whatsapp-rpc/src/go/config/config.go +49 -0
- package/server/whatsapp-rpc/src/go/rpc/rpc.go +446 -0
- package/server/whatsapp-rpc/src/go/rpc/server.go +112 -0
- package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -0
- package/server/whatsapp-rpc/src/go/whatsapp/messages.go +390 -0
- package/server/whatsapp-rpc/src/go/whatsapp/service.go +2130 -0
- package/server/whatsapp-rpc/src/go/whatsapp/types.go +261 -0
- package/server/whatsapp-rpc/src/python/pyproject.toml +15 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -0
- package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -0
- package/server/whatsapp-rpc/web/app.py +609 -0
- package/server/whatsapp-rpc/web/requirements.txt +6 -0
- package/server/whatsapp-rpc/web/rpc_client.py +427 -0
- package/server/whatsapp-rpc/web/static/openapi.yaml +59 -0
- package/server/whatsapp-rpc/web/templates/base.html +150 -0
- package/server/whatsapp-rpc/web/templates/contacts.html +240 -0
- package/server/whatsapp-rpc/web/templates/dashboard.html +320 -0
- package/server/whatsapp-rpc/web/templates/groups.html +328 -0
- package/server/whatsapp-rpc/web/templates/messages.html +465 -0
- package/server/whatsapp-rpc/web/templates/messaging.html +681 -0
- package/server/whatsapp-rpc/web/templates/send.html +259 -0
- package/server/whatsapp-rpc/web/templates/settings.html +459 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
"""WebSocket Status Broadcaster Service.
|
|
2
|
+
|
|
3
|
+
Manages WebSocket connections and broadcasts status updates to all connected clients.
|
|
4
|
+
Supports all node types, variable updates, and workflow state changes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import orjson
|
|
10
|
+
from typing import Set, Dict, Any, Optional, List
|
|
11
|
+
from fastapi import WebSocket
|
|
12
|
+
from core.logging import get_logger
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StatusBroadcaster:
|
|
18
|
+
"""Manages WebSocket connections and broadcasts status updates."""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self._connections: Set[WebSocket] = set()
|
|
22
|
+
self._lock = asyncio.Lock()
|
|
23
|
+
|
|
24
|
+
# Current state for all status types
|
|
25
|
+
self._status: Dict[str, Any] = {
|
|
26
|
+
"android": {
|
|
27
|
+
"connected": False,
|
|
28
|
+
"paired": False,
|
|
29
|
+
"device_id": None,
|
|
30
|
+
"device_name": None,
|
|
31
|
+
"connected_devices": [],
|
|
32
|
+
"connection_type": None,
|
|
33
|
+
"qr_data": None,
|
|
34
|
+
"session_token": None
|
|
35
|
+
},
|
|
36
|
+
"whatsapp": {
|
|
37
|
+
"connected": False,
|
|
38
|
+
"has_session": False,
|
|
39
|
+
"running": False,
|
|
40
|
+
"pairing": False,
|
|
41
|
+
"device_id": None,
|
|
42
|
+
"qr": None
|
|
43
|
+
},
|
|
44
|
+
"api_keys": {}, # provider -> validation status
|
|
45
|
+
"nodes": {}, # node_id -> node status
|
|
46
|
+
"variables": {}, # variable_name -> value
|
|
47
|
+
"workflow": {
|
|
48
|
+
"executing": False,
|
|
49
|
+
"current_node": None
|
|
50
|
+
},
|
|
51
|
+
"workflow_lock": {
|
|
52
|
+
"locked": False,
|
|
53
|
+
"workflow_id": None,
|
|
54
|
+
"locked_at": None,
|
|
55
|
+
"reason": None
|
|
56
|
+
},
|
|
57
|
+
"deployment": {
|
|
58
|
+
"isRunning": False,
|
|
59
|
+
"activeRuns": 0,
|
|
60
|
+
"status": "idle",
|
|
61
|
+
"workflow_id": None
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async def connect(self, websocket: WebSocket):
|
|
66
|
+
"""Accept a new WebSocket connection."""
|
|
67
|
+
await websocket.accept()
|
|
68
|
+
async with self._lock:
|
|
69
|
+
self._connections.add(websocket)
|
|
70
|
+
logger.info(f"[StatusBroadcaster] Client connected. Total: {len(self._connections)}")
|
|
71
|
+
|
|
72
|
+
# Fetch fresh WhatsApp status before sending initial_status
|
|
73
|
+
# This ensures client sees actual connection state (especially after auto-connect)
|
|
74
|
+
await self._refresh_whatsapp_status()
|
|
75
|
+
|
|
76
|
+
# Auto-reconnect Android relay if there's a stored session
|
|
77
|
+
await self._auto_reconnect_android_relay()
|
|
78
|
+
|
|
79
|
+
# Send current full status immediately
|
|
80
|
+
try:
|
|
81
|
+
await websocket.send_json({
|
|
82
|
+
"type": "initial_status",
|
|
83
|
+
"data": self._status
|
|
84
|
+
})
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.error(f"[StatusBroadcaster] Failed to send initial status: {e}")
|
|
87
|
+
|
|
88
|
+
async def disconnect(self, websocket: WebSocket):
|
|
89
|
+
"""Remove a WebSocket connection."""
|
|
90
|
+
async with self._lock:
|
|
91
|
+
self._connections.discard(websocket)
|
|
92
|
+
logger.info(f"[StatusBroadcaster] Client disconnected. Total: {len(self._connections)}")
|
|
93
|
+
|
|
94
|
+
async def broadcast(self, message: Dict[str, Any]):
|
|
95
|
+
"""Broadcast a message to all connected clients using TaskGroup.
|
|
96
|
+
|
|
97
|
+
Uses asyncio.TaskGroup (Python 3.11+) for structured concurrency:
|
|
98
|
+
- All tasks complete or cancel together
|
|
99
|
+
- Proper exception handling via ExceptionGroup
|
|
100
|
+
"""
|
|
101
|
+
if not self._connections:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# Get connections list while holding lock
|
|
105
|
+
async with self._lock:
|
|
106
|
+
connections_list = list(self._connections)
|
|
107
|
+
|
|
108
|
+
if not connections_list:
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
message_bytes = orjson.dumps(message).decode()
|
|
112
|
+
disconnected: set[WebSocket] = set()
|
|
113
|
+
|
|
114
|
+
async def send_to_client(connection: WebSocket):
|
|
115
|
+
"""Send message to a single client."""
|
|
116
|
+
try:
|
|
117
|
+
await connection.send_text(message_bytes)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.warning(f"[StatusBroadcaster] Send failed: {e}")
|
|
120
|
+
disconnected.add(connection)
|
|
121
|
+
|
|
122
|
+
# Execute all sends concurrently with TaskGroup
|
|
123
|
+
try:
|
|
124
|
+
async with asyncio.TaskGroup() as tg:
|
|
125
|
+
for conn in connections_list:
|
|
126
|
+
tg.create_task(send_to_client(conn))
|
|
127
|
+
except* Exception as eg:
|
|
128
|
+
# TaskGroup aggregates exceptions - log them but continue
|
|
129
|
+
for exc in eg.exceptions:
|
|
130
|
+
logger.warning(f"[StatusBroadcaster] TaskGroup exception: {exc}")
|
|
131
|
+
|
|
132
|
+
# Remove failed connections
|
|
133
|
+
if disconnected:
|
|
134
|
+
async with self._lock:
|
|
135
|
+
self._connections -= disconnected
|
|
136
|
+
|
|
137
|
+
# =========================================================================
|
|
138
|
+
# API Key Validation Status Updates
|
|
139
|
+
# =========================================================================
|
|
140
|
+
|
|
141
|
+
async def update_api_key_status(
|
|
142
|
+
self,
|
|
143
|
+
provider: str,
|
|
144
|
+
valid: bool,
|
|
145
|
+
message: Optional[str] = None,
|
|
146
|
+
has_key: bool = True,
|
|
147
|
+
models: Optional[List[str]] = None
|
|
148
|
+
):
|
|
149
|
+
"""Update API key validation status and broadcast."""
|
|
150
|
+
self._status["api_keys"][provider] = {
|
|
151
|
+
"valid": valid,
|
|
152
|
+
"hasKey": has_key,
|
|
153
|
+
"message": message,
|
|
154
|
+
"models": models or [],
|
|
155
|
+
"timestamp": asyncio.get_event_loop().time()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await self.broadcast({
|
|
159
|
+
"type": "api_key_status",
|
|
160
|
+
"provider": provider,
|
|
161
|
+
"data": self._status["api_keys"][provider]
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
def get_api_key_status(self, provider: str) -> Optional[Dict[str, Any]]:
|
|
165
|
+
"""Get API key validation status for a provider."""
|
|
166
|
+
return self._status["api_keys"].get(provider)
|
|
167
|
+
|
|
168
|
+
# =========================================================================
|
|
169
|
+
# Android Status Updates
|
|
170
|
+
# =========================================================================
|
|
171
|
+
|
|
172
|
+
async def update_android_status(
|
|
173
|
+
self,
|
|
174
|
+
connected: bool,
|
|
175
|
+
paired: bool = False,
|
|
176
|
+
device_id: Optional[str] = None,
|
|
177
|
+
device_name: Optional[str] = None,
|
|
178
|
+
connected_devices: Optional[List[str]] = None,
|
|
179
|
+
connection_type: Optional[str] = None,
|
|
180
|
+
qr_data: Optional[str] = None,
|
|
181
|
+
session_token: Optional[str] = None
|
|
182
|
+
):
|
|
183
|
+
"""Update Android relay connection status and broadcast."""
|
|
184
|
+
self._status["android"] = {
|
|
185
|
+
"connected": connected,
|
|
186
|
+
"paired": paired,
|
|
187
|
+
"device_id": device_id,
|
|
188
|
+
"device_name": device_name,
|
|
189
|
+
"connected_devices": connected_devices or [],
|
|
190
|
+
"connection_type": connection_type,
|
|
191
|
+
"qr_data": qr_data,
|
|
192
|
+
"session_token": session_token
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await self.broadcast({
|
|
196
|
+
"type": "android_status",
|
|
197
|
+
"data": self._status["android"]
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
# =========================================================================
|
|
201
|
+
# WhatsApp Status Updates
|
|
202
|
+
# =========================================================================
|
|
203
|
+
|
|
204
|
+
async def _refresh_whatsapp_status(self):
|
|
205
|
+
"""Fetch fresh WhatsApp status from Go service and update cache.
|
|
206
|
+
|
|
207
|
+
Called on client connect to ensure initial_status has accurate data.
|
|
208
|
+
Silently fails if WhatsApp service is unavailable.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
from routers.whatsapp import get_client
|
|
212
|
+
import time
|
|
213
|
+
|
|
214
|
+
client = await get_client()
|
|
215
|
+
status_data = await client.call("status")
|
|
216
|
+
|
|
217
|
+
self._status["whatsapp"] = {
|
|
218
|
+
"connected": status_data.get("connected", False),
|
|
219
|
+
"has_session": status_data.get("has_session", False),
|
|
220
|
+
"running": status_data.get("running", False),
|
|
221
|
+
"pairing": status_data.get("pairing", False),
|
|
222
|
+
"device_id": status_data.get("device_id"),
|
|
223
|
+
"qr": None,
|
|
224
|
+
"timestamp": time.time()
|
|
225
|
+
}
|
|
226
|
+
logger.debug(f"[StatusBroadcaster] Refreshed WhatsApp status: connected={status_data.get('connected')}")
|
|
227
|
+
except Exception as e:
|
|
228
|
+
# Don't fail client connection if WhatsApp service is down
|
|
229
|
+
logger.debug(f"[StatusBroadcaster] Could not refresh WhatsApp status: {e}")
|
|
230
|
+
|
|
231
|
+
async def _auto_reconnect_android_relay(self):
|
|
232
|
+
"""Auto-reconnect to Android relay if there's a stored pairing session.
|
|
233
|
+
|
|
234
|
+
Called on client connect to re-establish relay connection after server restart.
|
|
235
|
+
The stored session contains relay URL, API key, and paired device info.
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
# Check if already connected
|
|
239
|
+
from services.android.manager import get_current_relay_client
|
|
240
|
+
existing = get_current_relay_client()
|
|
241
|
+
if existing and existing.is_connected():
|
|
242
|
+
# Already connected, just refresh status
|
|
243
|
+
self._status["android"] = {
|
|
244
|
+
"connected": True,
|
|
245
|
+
"paired": existing.is_paired(),
|
|
246
|
+
"device_id": existing.paired_device_id,
|
|
247
|
+
"device_name": existing.paired_device_name,
|
|
248
|
+
"connected_devices": list(existing.get_connected_devices()),
|
|
249
|
+
"connection_type": "relay",
|
|
250
|
+
"qr_data": existing.qr_data,
|
|
251
|
+
"session_token": existing.session_token
|
|
252
|
+
}
|
|
253
|
+
logger.debug("[StatusBroadcaster] Android relay already connected")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
# Check for stored session
|
|
257
|
+
from core.container import container
|
|
258
|
+
database = container.database()
|
|
259
|
+
|
|
260
|
+
session = await database.get_android_relay_session()
|
|
261
|
+
if not session:
|
|
262
|
+
logger.debug("[StatusBroadcaster] No stored Android relay session")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
relay_url = session.get("relay_url")
|
|
266
|
+
api_key = session.get("api_key")
|
|
267
|
+
device_id = session.get("device_id")
|
|
268
|
+
device_name = session.get("device_name")
|
|
269
|
+
|
|
270
|
+
if not relay_url or not api_key:
|
|
271
|
+
logger.debug("[StatusBroadcaster] Stored session missing relay URL or API key")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
logger.info(f"[StatusBroadcaster] Auto-reconnecting to Android relay...",
|
|
275
|
+
relay_url=relay_url, device_id=device_id)
|
|
276
|
+
|
|
277
|
+
# Attempt to reconnect
|
|
278
|
+
from services.android.manager import get_relay_client
|
|
279
|
+
client, error = await get_relay_client(relay_url, api_key)
|
|
280
|
+
|
|
281
|
+
if client and client.is_connected():
|
|
282
|
+
logger.info("[StatusBroadcaster] Android relay reconnected successfully")
|
|
283
|
+
# Update status - connected to relay but need to check if still paired
|
|
284
|
+
# The relay server creates a new session on each connect, so pairing is lost
|
|
285
|
+
# Update the cached status to reflect the current state
|
|
286
|
+
self._status["android"] = {
|
|
287
|
+
"connected": True,
|
|
288
|
+
"paired": client.is_paired(),
|
|
289
|
+
"device_id": client.paired_device_id,
|
|
290
|
+
"device_name": client.paired_device_name,
|
|
291
|
+
"connected_devices": list(client.get_connected_devices()),
|
|
292
|
+
"connection_type": "relay",
|
|
293
|
+
"qr_data": client.qr_data,
|
|
294
|
+
"session_token": client.session_token
|
|
295
|
+
}
|
|
296
|
+
else:
|
|
297
|
+
logger.warning(f"[StatusBroadcaster] Failed to reconnect Android relay: {error}")
|
|
298
|
+
# Clear the stored session since reconnect failed
|
|
299
|
+
await database.clear_android_relay_session()
|
|
300
|
+
|
|
301
|
+
except Exception as e:
|
|
302
|
+
logger.debug(f"[StatusBroadcaster] Could not auto-reconnect Android relay: {e}")
|
|
303
|
+
|
|
304
|
+
async def update_whatsapp_status(
|
|
305
|
+
self,
|
|
306
|
+
connected: bool,
|
|
307
|
+
has_session: bool = False,
|
|
308
|
+
running: bool = False,
|
|
309
|
+
pairing: bool = False,
|
|
310
|
+
device_id: Optional[str] = None,
|
|
311
|
+
qr: Optional[str] = None
|
|
312
|
+
):
|
|
313
|
+
"""Update WhatsApp connection status and broadcast."""
|
|
314
|
+
import time
|
|
315
|
+
self._status["whatsapp"] = {
|
|
316
|
+
"connected": connected,
|
|
317
|
+
"has_session": has_session,
|
|
318
|
+
"running": running,
|
|
319
|
+
"pairing": pairing,
|
|
320
|
+
"device_id": device_id,
|
|
321
|
+
"qr": qr,
|
|
322
|
+
"timestamp": time.time()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await self.broadcast({
|
|
326
|
+
"type": "whatsapp_status",
|
|
327
|
+
"data": self._status["whatsapp"]
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
def get_whatsapp_status(self) -> Dict[str, Any]:
|
|
331
|
+
"""Get WhatsApp connection status."""
|
|
332
|
+
return self._status["whatsapp"].copy()
|
|
333
|
+
|
|
334
|
+
# =========================================================================
|
|
335
|
+
# Node Status Updates
|
|
336
|
+
# =========================================================================
|
|
337
|
+
|
|
338
|
+
async def update_node_status(
|
|
339
|
+
self,
|
|
340
|
+
node_id: str,
|
|
341
|
+
status: str, # "idle", "executing", "waiting", "success", "error"
|
|
342
|
+
data: Optional[Dict[str, Any]] = None,
|
|
343
|
+
workflow_id: Optional[str] = None
|
|
344
|
+
):
|
|
345
|
+
"""Update a specific node's status and broadcast.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
node_id: The node ID
|
|
349
|
+
status: Status string
|
|
350
|
+
data: Optional status data
|
|
351
|
+
workflow_id: Optional workflow ID to scope the status update (n8n pattern)
|
|
352
|
+
"""
|
|
353
|
+
logger.debug(f"[BROADCAST] update_node_status: node={node_id}, status={status}, workflow={workflow_id}, connections={len(self._connections)}")
|
|
354
|
+
self._status["nodes"][node_id] = {
|
|
355
|
+
"status": status,
|
|
356
|
+
"data": data or {},
|
|
357
|
+
"timestamp": asyncio.get_event_loop().time(),
|
|
358
|
+
"workflow_id": workflow_id
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await self.broadcast({
|
|
362
|
+
"type": "node_status",
|
|
363
|
+
"node_id": node_id,
|
|
364
|
+
"workflow_id": workflow_id,
|
|
365
|
+
"data": self._status["nodes"][node_id]
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
async def update_node_output(
|
|
369
|
+
self,
|
|
370
|
+
node_id: str,
|
|
371
|
+
output: Any,
|
|
372
|
+
workflow_id: Optional[str] = None
|
|
373
|
+
):
|
|
374
|
+
"""Update a node's output data and broadcast."""
|
|
375
|
+
if node_id not in self._status["nodes"]:
|
|
376
|
+
self._status["nodes"][node_id] = {"status": "idle", "data": {}}
|
|
377
|
+
|
|
378
|
+
self._status["nodes"][node_id]["output"] = output
|
|
379
|
+
if workflow_id:
|
|
380
|
+
self._status["nodes"][node_id]["workflow_id"] = workflow_id
|
|
381
|
+
|
|
382
|
+
await self.broadcast({
|
|
383
|
+
"type": "node_output",
|
|
384
|
+
"node_id": node_id,
|
|
385
|
+
"workflow_id": workflow_id,
|
|
386
|
+
"output": output
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
# =========================================================================
|
|
390
|
+
# Variable Updates
|
|
391
|
+
# =========================================================================
|
|
392
|
+
|
|
393
|
+
async def update_variable(self, name: str, value: Any):
|
|
394
|
+
"""Update a workflow variable and broadcast."""
|
|
395
|
+
self._status["variables"][name] = value
|
|
396
|
+
|
|
397
|
+
await self.broadcast({
|
|
398
|
+
"type": "variable_update",
|
|
399
|
+
"name": name,
|
|
400
|
+
"value": value
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
async def update_variables(self, variables: Dict[str, Any]):
|
|
404
|
+
"""Update multiple variables at once and broadcast."""
|
|
405
|
+
self._status["variables"].update(variables)
|
|
406
|
+
|
|
407
|
+
await self.broadcast({
|
|
408
|
+
"type": "variables_update",
|
|
409
|
+
"variables": variables
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
# =========================================================================
|
|
413
|
+
# Workflow Status Updates
|
|
414
|
+
# =========================================================================
|
|
415
|
+
|
|
416
|
+
async def update_workflow_status(
|
|
417
|
+
self,
|
|
418
|
+
executing: bool,
|
|
419
|
+
current_node: Optional[str] = None,
|
|
420
|
+
progress: Optional[float] = None
|
|
421
|
+
):
|
|
422
|
+
"""Update workflow execution status and broadcast."""
|
|
423
|
+
self._status["workflow"] = {
|
|
424
|
+
"executing": executing,
|
|
425
|
+
"current_node": current_node,
|
|
426
|
+
"progress": progress
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await self.broadcast({
|
|
430
|
+
"type": "workflow_status",
|
|
431
|
+
"data": self._status["workflow"]
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
async def update_deployment_status(
|
|
435
|
+
self,
|
|
436
|
+
is_running: bool,
|
|
437
|
+
status: str = "idle",
|
|
438
|
+
active_runs: int = 0,
|
|
439
|
+
workflow_id: Optional[str] = None,
|
|
440
|
+
data: Optional[Dict[str, Any]] = None,
|
|
441
|
+
error: Optional[str] = None
|
|
442
|
+
):
|
|
443
|
+
"""Update deployment status and broadcast.
|
|
444
|
+
|
|
445
|
+
Follows n8n/Conductor pattern where deployment state is tracked centrally.
|
|
446
|
+
See DESIGN.md for architecture details.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
is_running: Whether deployment is active
|
|
450
|
+
status: Current status (idle, starting, running, stopped, cancelled, error)
|
|
451
|
+
active_runs: Number of concurrent execution runs
|
|
452
|
+
workflow_id: The deployed workflow ID
|
|
453
|
+
data: Optional additional data (e.g., run_id, trigger info)
|
|
454
|
+
error: Optional error message if status is 'error'
|
|
455
|
+
"""
|
|
456
|
+
self._status["deployment"] = {
|
|
457
|
+
"isRunning": is_running,
|
|
458
|
+
"activeRuns": active_runs,
|
|
459
|
+
"status": status,
|
|
460
|
+
"workflow_id": workflow_id
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
# Broadcast deployment_status message (matches frontend handler)
|
|
464
|
+
await self.broadcast({
|
|
465
|
+
"type": "deployment_status",
|
|
466
|
+
"status": status,
|
|
467
|
+
"workflow_id": workflow_id,
|
|
468
|
+
"data": data,
|
|
469
|
+
"error": error
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
# =========================================================================
|
|
473
|
+
# Workflow Lock Management (Per-Workflow Locks - n8n pattern)
|
|
474
|
+
# =========================================================================
|
|
475
|
+
|
|
476
|
+
async def lock_workflow(
|
|
477
|
+
self,
|
|
478
|
+
workflow_id: str,
|
|
479
|
+
reason: str = "deployment"
|
|
480
|
+
) -> bool:
|
|
481
|
+
"""Lock a specific workflow to prevent concurrent modifications.
|
|
482
|
+
|
|
483
|
+
Per-workflow locking (n8n pattern): Each workflow has its own independent lock.
|
|
484
|
+
Multiple workflows can be locked simultaneously.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
workflow_id: The workflow ID to lock
|
|
488
|
+
reason: Reason for locking (e.g., "deployment", "execution")
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
True if lock acquired, False if THIS workflow is already locked
|
|
492
|
+
"""
|
|
493
|
+
import time
|
|
494
|
+
|
|
495
|
+
# Initialize workflow_locks if not present
|
|
496
|
+
if "workflow_locks" not in self._status:
|
|
497
|
+
self._status["workflow_locks"] = {}
|
|
498
|
+
|
|
499
|
+
# Check if THIS workflow is already locked
|
|
500
|
+
if workflow_id in self._status["workflow_locks"]:
|
|
501
|
+
existing_lock = self._status["workflow_locks"][workflow_id]
|
|
502
|
+
if existing_lock.get("locked"):
|
|
503
|
+
logger.warning(
|
|
504
|
+
f"[WorkflowLock] Workflow {workflow_id} is already locked "
|
|
505
|
+
f"for {existing_lock.get('reason')}"
|
|
506
|
+
)
|
|
507
|
+
return False
|
|
508
|
+
|
|
509
|
+
# Lock this specific workflow
|
|
510
|
+
lock_info = {
|
|
511
|
+
"locked": True,
|
|
512
|
+
"workflow_id": workflow_id,
|
|
513
|
+
"locked_at": time.time(),
|
|
514
|
+
"reason": reason
|
|
515
|
+
}
|
|
516
|
+
self._status["workflow_locks"][workflow_id] = lock_info
|
|
517
|
+
|
|
518
|
+
# Also update legacy single lock for backward compatibility
|
|
519
|
+
self._status["workflow_lock"] = lock_info.copy()
|
|
520
|
+
|
|
521
|
+
await self.broadcast({
|
|
522
|
+
"type": "workflow_lock",
|
|
523
|
+
"workflow_id": workflow_id,
|
|
524
|
+
"data": lock_info
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
logger.info(f"[WorkflowLock] Locked workflow {workflow_id} for {reason}")
|
|
528
|
+
return True
|
|
529
|
+
|
|
530
|
+
async def unlock_workflow(self, workflow_id: str) -> bool:
|
|
531
|
+
"""Unlock a specific workflow after deployment/execution completes.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
workflow_id: The workflow ID to unlock
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
True if unlocked successfully
|
|
538
|
+
"""
|
|
539
|
+
# Initialize workflow_locks if not present
|
|
540
|
+
if "workflow_locks" not in self._status:
|
|
541
|
+
self._status["workflow_locks"] = {}
|
|
542
|
+
|
|
543
|
+
# Check if this workflow is locked
|
|
544
|
+
if workflow_id not in self._status["workflow_locks"]:
|
|
545
|
+
logger.debug(f"[WorkflowLock] Workflow {workflow_id} not locked")
|
|
546
|
+
return True # Already unlocked
|
|
547
|
+
|
|
548
|
+
existing_lock = self._status["workflow_locks"].get(workflow_id, {})
|
|
549
|
+
if not existing_lock.get("locked"):
|
|
550
|
+
logger.debug(f"[WorkflowLock] Workflow {workflow_id} not locked")
|
|
551
|
+
return True
|
|
552
|
+
|
|
553
|
+
# Remove lock for this workflow
|
|
554
|
+
del self._status["workflow_locks"][workflow_id]
|
|
555
|
+
|
|
556
|
+
# Update legacy single lock if it was for this workflow
|
|
557
|
+
if self._status["workflow_lock"].get("workflow_id") == workflow_id:
|
|
558
|
+
self._status["workflow_lock"] = {
|
|
559
|
+
"locked": False,
|
|
560
|
+
"workflow_id": None,
|
|
561
|
+
"locked_at": None,
|
|
562
|
+
"reason": None
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
await self.broadcast({
|
|
566
|
+
"type": "workflow_lock",
|
|
567
|
+
"workflow_id": workflow_id,
|
|
568
|
+
"data": {
|
|
569
|
+
"locked": False,
|
|
570
|
+
"workflow_id": workflow_id,
|
|
571
|
+
"locked_at": None,
|
|
572
|
+
"reason": None
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
logger.info(f"[WorkflowLock] Unlocked workflow {workflow_id}")
|
|
577
|
+
return True
|
|
578
|
+
|
|
579
|
+
def is_workflow_locked(self, workflow_id: Optional[str] = None) -> bool:
|
|
580
|
+
"""Check if a specific workflow is locked.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
workflow_id: Workflow ID to check. If None, checks if any workflow is locked.
|
|
584
|
+
|
|
585
|
+
Returns:
|
|
586
|
+
True if the specified workflow is locked (or any if workflow_id is None)
|
|
587
|
+
"""
|
|
588
|
+
# Initialize workflow_locks if not present
|
|
589
|
+
if "workflow_locks" not in self._status:
|
|
590
|
+
self._status["workflow_locks"] = {}
|
|
591
|
+
|
|
592
|
+
if workflow_id is None:
|
|
593
|
+
# Check if ANY workflow is locked
|
|
594
|
+
return any(
|
|
595
|
+
lock.get("locked", False)
|
|
596
|
+
for lock in self._status["workflow_locks"].values()
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
# Check specific workflow
|
|
600
|
+
lock = self._status["workflow_locks"].get(workflow_id, {})
|
|
601
|
+
return lock.get("locked", False)
|
|
602
|
+
|
|
603
|
+
def get_workflow_lock(self, workflow_id: Optional[str] = None) -> Dict[str, Any]:
|
|
604
|
+
"""Get workflow lock status.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
workflow_id: Specific workflow to check. If None, returns legacy single lock.
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
Lock info for the specified workflow or legacy lock
|
|
611
|
+
"""
|
|
612
|
+
if workflow_id:
|
|
613
|
+
# Initialize workflow_locks if not present
|
|
614
|
+
if "workflow_locks" not in self._status:
|
|
615
|
+
self._status["workflow_locks"] = {}
|
|
616
|
+
|
|
617
|
+
lock = self._status["workflow_locks"].get(workflow_id, {})
|
|
618
|
+
return {
|
|
619
|
+
"locked": lock.get("locked", False),
|
|
620
|
+
"workflow_id": workflow_id,
|
|
621
|
+
"locked_at": lock.get("locked_at"),
|
|
622
|
+
"reason": lock.get("reason")
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
# Return legacy single lock for backward compatibility
|
|
626
|
+
return self._status["workflow_lock"].copy()
|
|
627
|
+
|
|
628
|
+
def get_all_workflow_locks(self) -> Dict[str, Dict[str, Any]]:
|
|
629
|
+
"""Get all active workflow locks."""
|
|
630
|
+
if "workflow_locks" not in self._status:
|
|
631
|
+
return {}
|
|
632
|
+
return {
|
|
633
|
+
wid: lock.copy()
|
|
634
|
+
for wid, lock in self._status["workflow_locks"].items()
|
|
635
|
+
if lock.get("locked")
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
# =========================================================================
|
|
639
|
+
# Console Log Updates
|
|
640
|
+
# =========================================================================
|
|
641
|
+
|
|
642
|
+
async def broadcast_console_log(self, log_data: Dict[str, Any]):
|
|
643
|
+
"""Broadcast a console log entry to all connected clients.
|
|
644
|
+
|
|
645
|
+
Used by Console nodes to send debug output to the frontend console panel.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
log_data: Dict containing:
|
|
649
|
+
- node_id: The console node ID
|
|
650
|
+
- label: User-defined label or default
|
|
651
|
+
- timestamp: ISO timestamp
|
|
652
|
+
- data: The logged data (any type)
|
|
653
|
+
- formatted: Pre-formatted string representation
|
|
654
|
+
- format: Format type (json, json_compact, text, table)
|
|
655
|
+
- workflow_id: Optional workflow ID for scoping
|
|
656
|
+
"""
|
|
657
|
+
# Initialize console logs if not present
|
|
658
|
+
if "console_logs" not in self._status:
|
|
659
|
+
self._status["console_logs"] = []
|
|
660
|
+
|
|
661
|
+
# Add to console log history (keep last 100 entries)
|
|
662
|
+
self._status["console_logs"].append(log_data)
|
|
663
|
+
if len(self._status["console_logs"]) > 100:
|
|
664
|
+
self._status["console_logs"] = self._status["console_logs"][-100:]
|
|
665
|
+
|
|
666
|
+
# Broadcast to all clients
|
|
667
|
+
await self.broadcast({
|
|
668
|
+
"type": "console_log",
|
|
669
|
+
"data": log_data
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
logger.debug(f"[StatusBroadcaster] Console log broadcast: label={log_data.get('label')}")
|
|
673
|
+
|
|
674
|
+
def get_console_logs(self, workflow_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
675
|
+
"""Get console log history, optionally filtered by workflow_id."""
|
|
676
|
+
if "console_logs" not in self._status:
|
|
677
|
+
return []
|
|
678
|
+
|
|
679
|
+
if workflow_id:
|
|
680
|
+
return [
|
|
681
|
+
log for log in self._status["console_logs"]
|
|
682
|
+
if log.get("workflow_id") == workflow_id
|
|
683
|
+
]
|
|
684
|
+
return list(self._status["console_logs"])
|
|
685
|
+
|
|
686
|
+
async def clear_console_logs(self, workflow_id: Optional[str] = None):
|
|
687
|
+
"""Clear console log history."""
|
|
688
|
+
if "console_logs" not in self._status:
|
|
689
|
+
self._status["console_logs"] = []
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
if workflow_id:
|
|
693
|
+
self._status["console_logs"] = [
|
|
694
|
+
log for log in self._status["console_logs"]
|
|
695
|
+
if log.get("workflow_id") != workflow_id
|
|
696
|
+
]
|
|
697
|
+
else:
|
|
698
|
+
self._status["console_logs"] = []
|
|
699
|
+
|
|
700
|
+
await self.broadcast({
|
|
701
|
+
"type": "console_logs_cleared",
|
|
702
|
+
"workflow_id": workflow_id
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
# =========================================================================
|
|
706
|
+
# Terminal Log Updates
|
|
707
|
+
# =========================================================================
|
|
708
|
+
|
|
709
|
+
async def broadcast_terminal_log(self, log_data: Dict[str, Any]):
|
|
710
|
+
"""Broadcast a terminal log entry to all connected clients.
|
|
711
|
+
|
|
712
|
+
Used by the WebSocket logging handler to stream server logs to the frontend.
|
|
713
|
+
|
|
714
|
+
Args:
|
|
715
|
+
log_data: Dict containing:
|
|
716
|
+
- timestamp: ISO timestamp
|
|
717
|
+
- level: Log level (debug, info, warning, error)
|
|
718
|
+
- message: The log message
|
|
719
|
+
- source: Logger name/module (e.g., 'workflow', 'ai', 'android')
|
|
720
|
+
- details: Optional additional context
|
|
721
|
+
"""
|
|
722
|
+
# Initialize terminal logs if not present
|
|
723
|
+
if "terminal_logs" not in self._status:
|
|
724
|
+
self._status["terminal_logs"] = []
|
|
725
|
+
|
|
726
|
+
# Add to terminal log history (keep last 200 entries)
|
|
727
|
+
self._status["terminal_logs"].append(log_data)
|
|
728
|
+
if len(self._status["terminal_logs"]) > 200:
|
|
729
|
+
self._status["terminal_logs"] = self._status["terminal_logs"][-200:]
|
|
730
|
+
|
|
731
|
+
# Broadcast to all clients
|
|
732
|
+
await self.broadcast({
|
|
733
|
+
"type": "terminal_log",
|
|
734
|
+
"data": log_data
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
def get_terminal_logs(self) -> List[Dict[str, Any]]:
|
|
738
|
+
"""Get terminal log history."""
|
|
739
|
+
if "terminal_logs" not in self._status:
|
|
740
|
+
return []
|
|
741
|
+
return list(self._status["terminal_logs"])
|
|
742
|
+
|
|
743
|
+
async def clear_terminal_logs(self):
|
|
744
|
+
"""Clear terminal log history."""
|
|
745
|
+
self._status["terminal_logs"] = []
|
|
746
|
+
await self.broadcast({
|
|
747
|
+
"type": "terminal_logs_cleared"
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
# =========================================================================
|
|
751
|
+
# Generic Updates
|
|
752
|
+
# =========================================================================
|
|
753
|
+
|
|
754
|
+
async def send_custom_event(self, event_type: str, data: Any):
|
|
755
|
+
"""Send a custom event to all connected clients AND dispatch to event waiters.
|
|
756
|
+
|
|
757
|
+
Uses dispatch_async() directly since we're in an async context.
|
|
758
|
+
The sync dispatch() is for thread contexts like APScheduler callbacks.
|
|
759
|
+
See DESIGN.md section "Cross-Thread Event Dispatch" for pattern details.
|
|
760
|
+
"""
|
|
761
|
+
# Broadcast to all WebSocket clients
|
|
762
|
+
await self.broadcast({
|
|
763
|
+
"type": event_type,
|
|
764
|
+
"data": data
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
# Dispatch to event waiters (for trigger nodes)
|
|
768
|
+
# Use dispatch_async directly - we're in async context
|
|
769
|
+
try:
|
|
770
|
+
from services import event_waiter
|
|
771
|
+
event_data = data if isinstance(data, dict) else {"data": data}
|
|
772
|
+
resolved_count = await event_waiter.dispatch_async(event_type, event_data)
|
|
773
|
+
if resolved_count > 0:
|
|
774
|
+
logger.info(f"[StatusBroadcaster] Event {event_type} resolved {resolved_count} waiters")
|
|
775
|
+
except Exception as e:
|
|
776
|
+
logger.error(f"[StatusBroadcaster] Failed to dispatch to event waiters: {e}")
|
|
777
|
+
|
|
778
|
+
# =========================================================================
|
|
779
|
+
# Getters
|
|
780
|
+
# =========================================================================
|
|
781
|
+
|
|
782
|
+
def get_status(self) -> Dict[str, Any]:
|
|
783
|
+
"""Get the full current status."""
|
|
784
|
+
return self._status.copy()
|
|
785
|
+
|
|
786
|
+
def get_android_status(self) -> Dict[str, Any]:
|
|
787
|
+
"""Get Android connection status."""
|
|
788
|
+
return self._status["android"].copy()
|
|
789
|
+
|
|
790
|
+
def get_node_status(self, node_id: str) -> Optional[Dict[str, Any]]:
|
|
791
|
+
"""Get a specific node's status."""
|
|
792
|
+
return self._status["nodes"].get(node_id)
|
|
793
|
+
|
|
794
|
+
async def clear_node_status(self, node_id: str) -> bool:
|
|
795
|
+
"""Clear a node's status and output from the cache."""
|
|
796
|
+
if node_id in self._status["nodes"]:
|
|
797
|
+
del self._status["nodes"][node_id]
|
|
798
|
+
logger.info(f"[StatusBroadcaster] Cleared node status: {node_id}")
|
|
799
|
+
# Broadcast that node status was cleared
|
|
800
|
+
await self.broadcast({
|
|
801
|
+
"type": "node_status_cleared",
|
|
802
|
+
"node_id": node_id
|
|
803
|
+
})
|
|
804
|
+
return True
|
|
805
|
+
return False
|
|
806
|
+
|
|
807
|
+
def get_variable(self, name: str) -> Any:
|
|
808
|
+
"""Get a variable value."""
|
|
809
|
+
return self._status["variables"].get(name)
|
|
810
|
+
|
|
811
|
+
@property
|
|
812
|
+
def connection_count(self) -> int:
|
|
813
|
+
"""Get the number of active WebSocket connections."""
|
|
814
|
+
return len(self._connections)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# Global singleton instance
|
|
818
|
+
_broadcaster: Optional[StatusBroadcaster] = None
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def get_status_broadcaster() -> StatusBroadcaster:
|
|
822
|
+
"""Get or create the global StatusBroadcaster instance."""
|
|
823
|
+
global _broadcaster
|
|
824
|
+
if _broadcaster is None:
|
|
825
|
+
_broadcaster = StatusBroadcaster()
|
|
826
|
+
return _broadcaster
|