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.
Files changed (288) hide show
  1. package/.env.template +71 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/bin/cli.js +159 -0
  5. package/client/.dockerignore +45 -0
  6. package/client/Dockerfile +68 -0
  7. package/client/eslint.config.js +29 -0
  8. package/client/index.html +13 -0
  9. package/client/nginx.conf +66 -0
  10. package/client/package.json +48 -0
  11. package/client/src/App.tsx +27 -0
  12. package/client/src/Dashboard.tsx +1173 -0
  13. package/client/src/ParameterPanel.tsx +301 -0
  14. package/client/src/components/AIAgentNode.tsx +321 -0
  15. package/client/src/components/APIKeyValidator.tsx +118 -0
  16. package/client/src/components/ClaudeChatModelNode.tsx +18 -0
  17. package/client/src/components/ConditionalEdge.tsx +189 -0
  18. package/client/src/components/CredentialsModal.tsx +306 -0
  19. package/client/src/components/EdgeConditionEditor.tsx +443 -0
  20. package/client/src/components/GeminiChatModelNode.tsx +18 -0
  21. package/client/src/components/GenericNode.tsx +357 -0
  22. package/client/src/components/LocationParameterPanel.tsx +154 -0
  23. package/client/src/components/ModelNode.tsx +286 -0
  24. package/client/src/components/OpenAIChatModelNode.tsx +18 -0
  25. package/client/src/components/OutputPanel.tsx +471 -0
  26. package/client/src/components/ParameterRenderer.tsx +1874 -0
  27. package/client/src/components/SkillEditorModal.tsx +417 -0
  28. package/client/src/components/SquareNode.tsx +797 -0
  29. package/client/src/components/StartNode.tsx +250 -0
  30. package/client/src/components/ToolkitNode.tsx +365 -0
  31. package/client/src/components/TriggerNode.tsx +463 -0
  32. package/client/src/components/auth/LoginPage.tsx +247 -0
  33. package/client/src/components/auth/ProtectedRoute.tsx +59 -0
  34. package/client/src/components/base/BaseChatModelNode.tsx +271 -0
  35. package/client/src/components/icons/AIProviderIcons.tsx +50 -0
  36. package/client/src/components/maps/GoogleMapsPicker.tsx +137 -0
  37. package/client/src/components/maps/MapsPreviewPanel.tsx +110 -0
  38. package/client/src/components/maps/index.ts +26 -0
  39. package/client/src/components/parameterPanel/InputSection.tsx +1094 -0
  40. package/client/src/components/parameterPanel/LocationPanelLayout.tsx +65 -0
  41. package/client/src/components/parameterPanel/MapsSection.tsx +92 -0
  42. package/client/src/components/parameterPanel/MiddleSection.tsx +571 -0
  43. package/client/src/components/parameterPanel/OutputSection.tsx +81 -0
  44. package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +82 -0
  45. package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -0
  46. package/client/src/components/parameterPanel/index.ts +42 -0
  47. package/client/src/components/shared/DataPanel.tsx +142 -0
  48. package/client/src/components/shared/JSONTreeRenderer.tsx +106 -0
  49. package/client/src/components/ui/AIResultModal.tsx +204 -0
  50. package/client/src/components/ui/AndroidSettingsPanel.tsx +401 -0
  51. package/client/src/components/ui/CodeEditor.tsx +81 -0
  52. package/client/src/components/ui/CollapsibleSection.tsx +88 -0
  53. package/client/src/components/ui/ComponentItem.tsx +154 -0
  54. package/client/src/components/ui/ComponentPalette.tsx +321 -0
  55. package/client/src/components/ui/ConsolePanel.tsx +1074 -0
  56. package/client/src/components/ui/ErrorBoundary.tsx +196 -0
  57. package/client/src/components/ui/InputNodesPanel.tsx +204 -0
  58. package/client/src/components/ui/MapSelector.tsx +314 -0
  59. package/client/src/components/ui/Modal.tsx +149 -0
  60. package/client/src/components/ui/NodeContextMenu.tsx +192 -0
  61. package/client/src/components/ui/NodeOutputPanel.tsx +1150 -0
  62. package/client/src/components/ui/OutputDisplayPanel.tsx +381 -0
  63. package/client/src/components/ui/SettingsPanel.tsx +243 -0
  64. package/client/src/components/ui/TopToolbar.tsx +736 -0
  65. package/client/src/components/ui/WhatsAppSettingsPanel.tsx +345 -0
  66. package/client/src/components/ui/WorkflowSidebar.tsx +294 -0
  67. package/client/src/config/antdTheme.ts +186 -0
  68. package/client/src/config/api.ts +54 -0
  69. package/client/src/contexts/AuthContext.tsx +221 -0
  70. package/client/src/contexts/ThemeContext.tsx +42 -0
  71. package/client/src/contexts/WebSocketContext.tsx +1971 -0
  72. package/client/src/factories/baseChatModelFactory.ts +256 -0
  73. package/client/src/hooks/useAndroidOperations.ts +164 -0
  74. package/client/src/hooks/useApiKeyValidation.ts +107 -0
  75. package/client/src/hooks/useApiKeys.ts +238 -0
  76. package/client/src/hooks/useAppTheme.ts +17 -0
  77. package/client/src/hooks/useComponentPalette.ts +51 -0
  78. package/client/src/hooks/useCopyPaste.ts +155 -0
  79. package/client/src/hooks/useDragAndDrop.ts +124 -0
  80. package/client/src/hooks/useDragVariable.ts +88 -0
  81. package/client/src/hooks/useExecution.ts +313 -0
  82. package/client/src/hooks/useParameterPanel.ts +176 -0
  83. package/client/src/hooks/useReactFlowNodes.ts +189 -0
  84. package/client/src/hooks/useToolSchema.ts +209 -0
  85. package/client/src/hooks/useWhatsApp.ts +196 -0
  86. package/client/src/hooks/useWorkflowManagement.ts +46 -0
  87. package/client/src/index.css +315 -0
  88. package/client/src/main.tsx +19 -0
  89. package/client/src/nodeDefinitions/aiAgentNodes.ts +336 -0
  90. package/client/src/nodeDefinitions/aiModelNodes.ts +340 -0
  91. package/client/src/nodeDefinitions/androidDeviceNodes.ts +140 -0
  92. package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -0
  93. package/client/src/nodeDefinitions/chatNodes.ts +135 -0
  94. package/client/src/nodeDefinitions/codeNodes.ts +54 -0
  95. package/client/src/nodeDefinitions/documentNodes.ts +379 -0
  96. package/client/src/nodeDefinitions/index.ts +15 -0
  97. package/client/src/nodeDefinitions/locationNodes.ts +463 -0
  98. package/client/src/nodeDefinitions/schedulerNodes.ts +220 -0
  99. package/client/src/nodeDefinitions/skillNodes.ts +211 -0
  100. package/client/src/nodeDefinitions/toolNodes.ts +198 -0
  101. package/client/src/nodeDefinitions/utilityNodes.ts +284 -0
  102. package/client/src/nodeDefinitions/whatsappNodes.ts +865 -0
  103. package/client/src/nodeDefinitions/workflowNodes.ts +41 -0
  104. package/client/src/nodeDefinitions.ts +104 -0
  105. package/client/src/schemas/workflowSchema.ts +264 -0
  106. package/client/src/services/dynamicParameterService.ts +96 -0
  107. package/client/src/services/execution/aiAgentExecutionService.ts +35 -0
  108. package/client/src/services/executionService.ts +232 -0
  109. package/client/src/services/workflowApi.ts +91 -0
  110. package/client/src/store/useAppStore.ts +582 -0
  111. package/client/src/styles/theme.ts +508 -0
  112. package/client/src/styles/zIndex.ts +17 -0
  113. package/client/src/types/ComponentTypes.ts +39 -0
  114. package/client/src/types/EdgeCondition.ts +231 -0
  115. package/client/src/types/INodeProperties.ts +288 -0
  116. package/client/src/types/NodeTypes.ts +28 -0
  117. package/client/src/utils/formatters.ts +33 -0
  118. package/client/src/utils/googleMapsLoader.ts +140 -0
  119. package/client/src/utils/locationUtils.ts +85 -0
  120. package/client/src/utils/nodeUtils.ts +31 -0
  121. package/client/src/utils/workflow.ts +30 -0
  122. package/client/src/utils/workflowExport.ts +120 -0
  123. package/client/src/vite-env.d.ts +12 -0
  124. package/client/tailwind.config.js +60 -0
  125. package/client/tsconfig.json +25 -0
  126. package/client/tsconfig.node.json +11 -0
  127. package/client/vite.config.js +35 -0
  128. package/docker-compose.prod.yml +107 -0
  129. package/docker-compose.yml +104 -0
  130. package/docs-MachinaOs/README.md +85 -0
  131. package/docs-MachinaOs/deployment/docker.mdx +228 -0
  132. package/docs-MachinaOs/deployment/production.mdx +345 -0
  133. package/docs-MachinaOs/docs.json +75 -0
  134. package/docs-MachinaOs/faq.mdx +309 -0
  135. package/docs-MachinaOs/favicon.svg +5 -0
  136. package/docs-MachinaOs/installation.mdx +160 -0
  137. package/docs-MachinaOs/introduction.mdx +114 -0
  138. package/docs-MachinaOs/logo/dark.svg +6 -0
  139. package/docs-MachinaOs/logo/light.svg +6 -0
  140. package/docs-MachinaOs/nodes/ai-agent.mdx +216 -0
  141. package/docs-MachinaOs/nodes/ai-models.mdx +240 -0
  142. package/docs-MachinaOs/nodes/android.mdx +411 -0
  143. package/docs-MachinaOs/nodes/overview.mdx +181 -0
  144. package/docs-MachinaOs/nodes/schedulers.mdx +316 -0
  145. package/docs-MachinaOs/nodes/webhooks.mdx +330 -0
  146. package/docs-MachinaOs/nodes/whatsapp.mdx +305 -0
  147. package/docs-MachinaOs/quickstart.mdx +119 -0
  148. package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +177 -0
  149. package/docs-MachinaOs/tutorials/android-automation.mdx +242 -0
  150. package/docs-MachinaOs/tutorials/first-workflow.mdx +134 -0
  151. package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +185 -0
  152. package/nul +0 -0
  153. package/package.json +70 -0
  154. package/scripts/build.js +158 -0
  155. package/scripts/check-ports.ps1 +33 -0
  156. package/scripts/clean.js +40 -0
  157. package/scripts/docker.js +93 -0
  158. package/scripts/kill-port.ps1 +154 -0
  159. package/scripts/start.js +210 -0
  160. package/scripts/stop.js +325 -0
  161. package/server/.dockerignore +44 -0
  162. package/server/Dockerfile +45 -0
  163. package/server/constants.py +249 -0
  164. package/server/core/__init__.py +1 -0
  165. package/server/core/cache.py +461 -0
  166. package/server/core/config.py +128 -0
  167. package/server/core/container.py +99 -0
  168. package/server/core/database.py +1211 -0
  169. package/server/core/logging.py +314 -0
  170. package/server/main.py +289 -0
  171. package/server/middleware/__init__.py +5 -0
  172. package/server/middleware/auth.py +89 -0
  173. package/server/models/__init__.py +1 -0
  174. package/server/models/auth.py +52 -0
  175. package/server/models/cache.py +24 -0
  176. package/server/models/database.py +211 -0
  177. package/server/models/nodes.py +455 -0
  178. package/server/package.json +9 -0
  179. package/server/pyproject.toml +72 -0
  180. package/server/requirements.txt +83 -0
  181. package/server/routers/__init__.py +1 -0
  182. package/server/routers/android.py +294 -0
  183. package/server/routers/auth.py +203 -0
  184. package/server/routers/database.py +151 -0
  185. package/server/routers/maps.py +142 -0
  186. package/server/routers/nodejs_compat.py +289 -0
  187. package/server/routers/webhook.py +90 -0
  188. package/server/routers/websocket.py +2127 -0
  189. package/server/routers/whatsapp.py +761 -0
  190. package/server/routers/workflow.py +200 -0
  191. package/server/services/__init__.py +1 -0
  192. package/server/services/ai.py +2415 -0
  193. package/server/services/android/__init__.py +27 -0
  194. package/server/services/android/broadcaster.py +114 -0
  195. package/server/services/android/client.py +608 -0
  196. package/server/services/android/manager.py +78 -0
  197. package/server/services/android/protocol.py +165 -0
  198. package/server/services/android_service.py +588 -0
  199. package/server/services/auth.py +131 -0
  200. package/server/services/chat_client.py +160 -0
  201. package/server/services/deployment/__init__.py +12 -0
  202. package/server/services/deployment/manager.py +706 -0
  203. package/server/services/deployment/state.py +47 -0
  204. package/server/services/deployment/triggers.py +275 -0
  205. package/server/services/event_waiter.py +785 -0
  206. package/server/services/execution/__init__.py +77 -0
  207. package/server/services/execution/cache.py +769 -0
  208. package/server/services/execution/conditions.py +373 -0
  209. package/server/services/execution/dlq.py +132 -0
  210. package/server/services/execution/executor.py +1351 -0
  211. package/server/services/execution/models.py +531 -0
  212. package/server/services/execution/recovery.py +235 -0
  213. package/server/services/handlers/__init__.py +126 -0
  214. package/server/services/handlers/ai.py +355 -0
  215. package/server/services/handlers/android.py +260 -0
  216. package/server/services/handlers/code.py +278 -0
  217. package/server/services/handlers/document.py +598 -0
  218. package/server/services/handlers/http.py +193 -0
  219. package/server/services/handlers/polyglot.py +105 -0
  220. package/server/services/handlers/tools.py +845 -0
  221. package/server/services/handlers/triggers.py +107 -0
  222. package/server/services/handlers/utility.py +822 -0
  223. package/server/services/handlers/whatsapp.py +476 -0
  224. package/server/services/maps.py +289 -0
  225. package/server/services/memory_store.py +103 -0
  226. package/server/services/node_executor.py +375 -0
  227. package/server/services/parameter_resolver.py +218 -0
  228. package/server/services/polyglot_client.py +169 -0
  229. package/server/services/scheduler.py +155 -0
  230. package/server/services/skill_loader.py +417 -0
  231. package/server/services/status_broadcaster.py +826 -0
  232. package/server/services/temporal/__init__.py +23 -0
  233. package/server/services/temporal/activities.py +344 -0
  234. package/server/services/temporal/client.py +76 -0
  235. package/server/services/temporal/executor.py +147 -0
  236. package/server/services/temporal/worker.py +251 -0
  237. package/server/services/temporal/workflow.py +355 -0
  238. package/server/services/temporal/ws_client.py +236 -0
  239. package/server/services/text.py +111 -0
  240. package/server/services/user_auth.py +172 -0
  241. package/server/services/websocket_client.py +29 -0
  242. package/server/services/workflow.py +597 -0
  243. package/server/skills/android-skill/SKILL.md +82 -0
  244. package/server/skills/assistant-personality/SKILL.md +45 -0
  245. package/server/skills/code-skill/SKILL.md +140 -0
  246. package/server/skills/http-skill/SKILL.md +161 -0
  247. package/server/skills/maps-skill/SKILL.md +170 -0
  248. package/server/skills/memory-skill/SKILL.md +154 -0
  249. package/server/skills/scheduler-skill/SKILL.md +84 -0
  250. package/server/skills/whatsapp-skill/SKILL.md +283 -0
  251. package/server/uv.lock +2916 -0
  252. package/server/whatsapp-rpc/.dockerignore +30 -0
  253. package/server/whatsapp-rpc/Dockerfile +44 -0
  254. package/server/whatsapp-rpc/Dockerfile.web +17 -0
  255. package/server/whatsapp-rpc/README.md +139 -0
  256. package/server/whatsapp-rpc/cli.js +95 -0
  257. package/server/whatsapp-rpc/configs/config.yaml +7 -0
  258. package/server/whatsapp-rpc/docker-compose.yml +35 -0
  259. package/server/whatsapp-rpc/docs/API.md +410 -0
  260. package/server/whatsapp-rpc/go.mod +67 -0
  261. package/server/whatsapp-rpc/go.sum +203 -0
  262. package/server/whatsapp-rpc/package.json +30 -0
  263. package/server/whatsapp-rpc/schema.json +1294 -0
  264. package/server/whatsapp-rpc/scripts/clean.cjs +66 -0
  265. package/server/whatsapp-rpc/scripts/cli.js +162 -0
  266. package/server/whatsapp-rpc/src/go/cmd/server/main.go +91 -0
  267. package/server/whatsapp-rpc/src/go/config/config.go +49 -0
  268. package/server/whatsapp-rpc/src/go/rpc/rpc.go +446 -0
  269. package/server/whatsapp-rpc/src/go/rpc/server.go +112 -0
  270. package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -0
  271. package/server/whatsapp-rpc/src/go/whatsapp/messages.go +390 -0
  272. package/server/whatsapp-rpc/src/go/whatsapp/service.go +2130 -0
  273. package/server/whatsapp-rpc/src/go/whatsapp/types.go +261 -0
  274. package/server/whatsapp-rpc/src/python/pyproject.toml +15 -0
  275. package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -0
  276. package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -0
  277. package/server/whatsapp-rpc/web/app.py +609 -0
  278. package/server/whatsapp-rpc/web/requirements.txt +6 -0
  279. package/server/whatsapp-rpc/web/rpc_client.py +427 -0
  280. package/server/whatsapp-rpc/web/static/openapi.yaml +59 -0
  281. package/server/whatsapp-rpc/web/templates/base.html +150 -0
  282. package/server/whatsapp-rpc/web/templates/contacts.html +240 -0
  283. package/server/whatsapp-rpc/web/templates/dashboard.html +320 -0
  284. package/server/whatsapp-rpc/web/templates/groups.html +328 -0
  285. package/server/whatsapp-rpc/web/templates/messages.html +465 -0
  286. package/server/whatsapp-rpc/web/templates/messaging.html +681 -0
  287. package/server/whatsapp-rpc/web/templates/send.html +259 -0
  288. 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