machinaos 0.0.1 → 0.0.7

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 (422) hide show
  1. package/.env.template +71 -71
  2. package/LICENSE +21 -21
  3. package/README.md +163 -87
  4. package/bin/cli.js +62 -106
  5. package/client/.dockerignore +45 -45
  6. package/client/Dockerfile +68 -68
  7. package/client/dist/assets/index-DFSC53FP.css +1 -0
  8. package/client/dist/assets/index-fJ-1gTf5.js +613 -0
  9. package/client/dist/index.html +14 -0
  10. package/client/eslint.config.js +34 -16
  11. package/client/nginx.conf +66 -66
  12. package/client/package.json +61 -48
  13. package/client/src/App.tsx +27 -27
  14. package/client/src/Dashboard.tsx +1200 -1172
  15. package/client/src/ParameterPanel.tsx +302 -300
  16. package/client/src/components/AIAgentNode.tsx +315 -321
  17. package/client/src/components/APIKeyValidator.tsx +117 -117
  18. package/client/src/components/ClaudeChatModelNode.tsx +17 -17
  19. package/client/src/components/CredentialsModal.tsx +1200 -306
  20. package/client/src/components/GeminiChatModelNode.tsx +17 -17
  21. package/client/src/components/GenericNode.tsx +356 -356
  22. package/client/src/components/LocationParameterPanel.tsx +153 -153
  23. package/client/src/components/ModelNode.tsx +285 -285
  24. package/client/src/components/OpenAIChatModelNode.tsx +17 -17
  25. package/client/src/components/OutputPanel.tsx +470 -470
  26. package/client/src/components/ParameterRenderer.tsx +1873 -1873
  27. package/client/src/components/SkillEditorModal.tsx +3 -3
  28. package/client/src/components/SquareNode.tsx +812 -796
  29. package/client/src/components/ToolkitNode.tsx +365 -365
  30. package/client/src/components/auth/LoginPage.tsx +247 -247
  31. package/client/src/components/auth/ProtectedRoute.tsx +59 -59
  32. package/client/src/components/base/BaseChatModelNode.tsx +270 -270
  33. package/client/src/components/icons/AIProviderIcons.tsx +50 -50
  34. package/client/src/components/maps/GoogleMapsPicker.tsx +136 -136
  35. package/client/src/components/maps/MapsPreviewPanel.tsx +109 -109
  36. package/client/src/components/maps/index.ts +25 -25
  37. package/client/src/components/parameterPanel/InputSection.tsx +1094 -1094
  38. package/client/src/components/parameterPanel/LocationPanelLayout.tsx +64 -64
  39. package/client/src/components/parameterPanel/MapsSection.tsx +91 -91
  40. package/client/src/components/parameterPanel/MiddleSection.tsx +867 -571
  41. package/client/src/components/parameterPanel/OutputSection.tsx +80 -80
  42. package/client/src/components/parameterPanel/ParameterPanelLayout.tsx +81 -81
  43. package/client/src/components/parameterPanel/ToolSchemaEditor.tsx +436 -436
  44. package/client/src/components/parameterPanel/index.ts +41 -41
  45. package/client/src/components/shared/DataPanel.tsx +142 -142
  46. package/client/src/components/shared/JSONTreeRenderer.tsx +105 -105
  47. package/client/src/components/ui/AIResultModal.tsx +203 -203
  48. package/client/src/components/ui/ApiKeyInput.tsx +93 -0
  49. package/client/src/components/ui/CodeEditor.tsx +81 -81
  50. package/client/src/components/ui/CollapsibleSection.tsx +87 -87
  51. package/client/src/components/ui/ComponentItem.tsx +153 -153
  52. package/client/src/components/ui/ComponentPalette.tsx +320 -320
  53. package/client/src/components/ui/ConsolePanel.tsx +151 -43
  54. package/client/src/components/ui/ErrorBoundary.tsx +195 -195
  55. package/client/src/components/ui/InputNodesPanel.tsx +203 -203
  56. package/client/src/components/ui/MapSelector.tsx +313 -313
  57. package/client/src/components/ui/Modal.tsx +151 -148
  58. package/client/src/components/ui/NodeOutputPanel.tsx +1150 -1150
  59. package/client/src/components/ui/OutputDisplayPanel.tsx +381 -381
  60. package/client/src/components/ui/QRCodeDisplay.tsx +182 -0
  61. package/client/src/components/ui/TopToolbar.tsx +736 -736
  62. package/client/src/components/ui/WorkflowSidebar.tsx +293 -293
  63. package/client/src/config/antdTheme.ts +186 -186
  64. package/client/src/contexts/AuthContext.tsx +221 -221
  65. package/client/src/contexts/ThemeContext.tsx +42 -42
  66. package/client/src/contexts/WebSocketContext.tsx +2144 -1971
  67. package/client/src/factories/baseChatModelFactory.ts +255 -255
  68. package/client/src/hooks/useAndroidOperations.ts +118 -164
  69. package/client/src/hooks/useApiKeyValidation.ts +106 -106
  70. package/client/src/hooks/useApiKeys.ts +238 -238
  71. package/client/src/hooks/useAppTheme.ts +17 -17
  72. package/client/src/hooks/useComponentPalette.ts +50 -50
  73. package/client/src/hooks/useDragAndDrop.ts +123 -123
  74. package/client/src/hooks/useDragVariable.ts +88 -88
  75. package/client/src/hooks/useExecution.ts +319 -313
  76. package/client/src/hooks/useParameterPanel.ts +176 -176
  77. package/client/src/hooks/useReactFlowNodes.ts +188 -188
  78. package/client/src/hooks/useToolSchema.ts +209 -209
  79. package/client/src/hooks/useWhatsApp.ts +196 -196
  80. package/client/src/hooks/useWorkflowManagement.ts +45 -45
  81. package/client/src/index.css +314 -314
  82. package/client/src/nodeDefinitions/aiAgentNodes.ts +335 -335
  83. package/client/src/nodeDefinitions/aiModelNodes.ts +340 -340
  84. package/client/src/nodeDefinitions/androidServiceNodes.ts +383 -383
  85. package/client/src/nodeDefinitions/chatNodes.ts +135 -135
  86. package/client/src/nodeDefinitions/codeNodes.ts +54 -54
  87. package/client/src/nodeDefinitions/index.ts +14 -14
  88. package/client/src/nodeDefinitions/locationNodes.ts +462 -462
  89. package/client/src/nodeDefinitions/schedulerNodes.ts +220 -220
  90. package/client/src/nodeDefinitions/skillNodes.ts +17 -5
  91. package/client/src/nodeDefinitions/utilityNodes.ts +284 -284
  92. package/client/src/nodeDefinitions/whatsappNodes.ts +821 -865
  93. package/client/src/nodeDefinitions.ts +101 -103
  94. package/client/src/services/dynamicParameterService.ts +95 -95
  95. package/client/src/services/execution/aiAgentExecutionService.ts +34 -34
  96. package/client/src/services/executionService.ts +227 -231
  97. package/client/src/services/workflowApi.ts +91 -91
  98. package/client/src/store/useAppStore.ts +578 -581
  99. package/client/src/styles/theme.ts +513 -508
  100. package/client/src/styles/zIndex.ts +16 -16
  101. package/client/src/types/ComponentTypes.ts +38 -38
  102. package/client/src/types/INodeProperties.ts +287 -287
  103. package/client/src/types/NodeTypes.ts +27 -27
  104. package/client/src/utils/formatters.ts +32 -32
  105. package/client/src/utils/googleMapsLoader.ts +139 -139
  106. package/client/src/utils/locationUtils.ts +84 -84
  107. package/client/src/utils/nodeUtils.ts +30 -30
  108. package/client/src/utils/workflow.ts +29 -29
  109. package/client/src/vite-env.d.ts +12 -12
  110. package/client/tailwind.config.js +59 -59
  111. package/client/tsconfig.json +25 -25
  112. package/client/vite.config.js +35 -35
  113. package/install.ps1 +308 -0
  114. package/install.sh +343 -0
  115. package/package.json +81 -70
  116. package/scripts/build.js +174 -51
  117. package/scripts/clean.js +40 -40
  118. package/scripts/start.js +234 -210
  119. package/scripts/stop.js +301 -325
  120. package/server/.dockerignore +44 -44
  121. package/server/Dockerfile +45 -45
  122. package/server/constants.py +244 -249
  123. package/server/core/cache.py +460 -460
  124. package/server/core/config.py +127 -127
  125. package/server/core/container.py +98 -98
  126. package/server/core/database.py +1296 -1210
  127. package/server/core/logging.py +313 -313
  128. package/server/main.py +288 -288
  129. package/server/middleware/__init__.py +5 -5
  130. package/server/middleware/auth.py +89 -89
  131. package/server/models/auth.py +52 -52
  132. package/server/models/cache.py +24 -24
  133. package/server/models/database.py +235 -210
  134. package/server/models/nodes.py +435 -455
  135. package/server/pyproject.toml +75 -72
  136. package/server/requirements.txt +83 -83
  137. package/server/routers/android.py +294 -294
  138. package/server/routers/auth.py +203 -203
  139. package/server/routers/database.py +150 -150
  140. package/server/routers/maps.py +141 -141
  141. package/server/routers/nodejs_compat.py +288 -288
  142. package/server/routers/webhook.py +90 -90
  143. package/server/routers/websocket.py +2239 -2127
  144. package/server/routers/whatsapp.py +761 -761
  145. package/server/routers/workflow.py +199 -199
  146. package/server/services/ai.py +2444 -2414
  147. package/server/services/android_service.py +588 -588
  148. package/server/services/auth.py +130 -130
  149. package/server/services/chat_client.py +160 -160
  150. package/server/services/deployment/manager.py +706 -706
  151. package/server/services/event_waiter.py +675 -785
  152. package/server/services/execution/executor.py +1351 -1351
  153. package/server/services/execution/models.py +1 -1
  154. package/server/services/handlers/__init__.py +122 -126
  155. package/server/services/handlers/ai.py +390 -355
  156. package/server/services/handlers/android.py +69 -260
  157. package/server/services/handlers/code.py +278 -278
  158. package/server/services/handlers/http.py +193 -193
  159. package/server/services/handlers/tools.py +146 -32
  160. package/server/services/handlers/triggers.py +107 -107
  161. package/server/services/handlers/utility.py +822 -822
  162. package/server/services/handlers/whatsapp.py +423 -476
  163. package/server/services/maps.py +288 -288
  164. package/server/services/memory_store.py +103 -103
  165. package/server/services/node_executor.py +372 -375
  166. package/server/services/scheduler.py +155 -155
  167. package/server/services/skill_loader.py +1 -1
  168. package/server/services/status_broadcaster.py +834 -826
  169. package/server/services/temporal/__init__.py +23 -23
  170. package/server/services/temporal/activities.py +344 -344
  171. package/server/services/temporal/client.py +76 -76
  172. package/server/services/temporal/executor.py +147 -147
  173. package/server/services/temporal/worker.py +251 -251
  174. package/server/services/temporal/workflow.py +355 -355
  175. package/server/services/temporal/ws_client.py +236 -236
  176. package/server/services/text.py +110 -110
  177. package/server/services/user_auth.py +172 -172
  178. package/server/services/websocket_client.py +29 -29
  179. package/server/services/workflow.py +597 -597
  180. package/server/skills/android-skill/SKILL.md +4 -4
  181. package/server/skills/code-skill/SKILL.md +123 -89
  182. package/server/skills/maps-skill/SKILL.md +3 -3
  183. package/server/skills/memory-skill/SKILL.md +1 -1
  184. package/server/skills/web-search-skill/SKILL.md +154 -0
  185. package/server/skills/whatsapp-skill/SKILL.md +3 -3
  186. package/server/uv.lock +461 -100
  187. package/server/whatsapp-rpc/.dockerignore +30 -30
  188. package/server/whatsapp-rpc/Dockerfile +44 -44
  189. package/server/whatsapp-rpc/Dockerfile.web +17 -17
  190. package/server/whatsapp-rpc/README.md +139 -139
  191. package/server/whatsapp-rpc/bin/whatsapp-rpc-server +0 -0
  192. package/server/whatsapp-rpc/cli.js +95 -95
  193. package/server/whatsapp-rpc/configs/config.yaml +6 -6
  194. package/server/whatsapp-rpc/docker-compose.yml +35 -35
  195. package/server/whatsapp-rpc/docs/API.md +410 -410
  196. package/server/whatsapp-rpc/node_modules/.package-lock.json +259 -0
  197. package/server/whatsapp-rpc/node_modules/chalk/license +9 -0
  198. package/server/whatsapp-rpc/node_modules/chalk/package.json +83 -0
  199. package/server/whatsapp-rpc/node_modules/chalk/readme.md +297 -0
  200. package/server/whatsapp-rpc/node_modules/chalk/source/index.d.ts +325 -0
  201. package/server/whatsapp-rpc/node_modules/chalk/source/index.js +225 -0
  202. package/server/whatsapp-rpc/node_modules/chalk/source/utilities.js +33 -0
  203. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  204. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  205. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  206. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  207. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  208. package/server/whatsapp-rpc/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  209. package/server/whatsapp-rpc/node_modules/commander/LICENSE +22 -0
  210. package/server/whatsapp-rpc/node_modules/commander/Readme.md +1148 -0
  211. package/server/whatsapp-rpc/node_modules/commander/esm.mjs +16 -0
  212. package/server/whatsapp-rpc/node_modules/commander/index.js +26 -0
  213. package/server/whatsapp-rpc/node_modules/commander/lib/argument.js +145 -0
  214. package/server/whatsapp-rpc/node_modules/commander/lib/command.js +2179 -0
  215. package/server/whatsapp-rpc/node_modules/commander/lib/error.js +43 -0
  216. package/server/whatsapp-rpc/node_modules/commander/lib/help.js +462 -0
  217. package/server/whatsapp-rpc/node_modules/commander/lib/option.js +329 -0
  218. package/server/whatsapp-rpc/node_modules/commander/lib/suggestSimilar.js +100 -0
  219. package/server/whatsapp-rpc/node_modules/commander/package-support.json +16 -0
  220. package/server/whatsapp-rpc/node_modules/commander/package.json +80 -0
  221. package/server/whatsapp-rpc/node_modules/commander/typings/esm.d.mts +3 -0
  222. package/server/whatsapp-rpc/node_modules/commander/typings/index.d.ts +884 -0
  223. package/server/whatsapp-rpc/node_modules/cross-spawn/LICENSE +21 -0
  224. package/server/whatsapp-rpc/node_modules/cross-spawn/README.md +89 -0
  225. package/server/whatsapp-rpc/node_modules/cross-spawn/index.js +39 -0
  226. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/enoent.js +59 -0
  227. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/parse.js +91 -0
  228. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/util/escape.js +47 -0
  229. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/util/readShebang.js +23 -0
  230. package/server/whatsapp-rpc/node_modules/cross-spawn/lib/util/resolveCommand.js +52 -0
  231. package/server/whatsapp-rpc/node_modules/cross-spawn/package.json +73 -0
  232. package/server/whatsapp-rpc/node_modules/execa/index.d.ts +955 -0
  233. package/server/whatsapp-rpc/node_modules/execa/index.js +309 -0
  234. package/server/whatsapp-rpc/node_modules/execa/lib/command.js +119 -0
  235. package/server/whatsapp-rpc/node_modules/execa/lib/error.js +87 -0
  236. package/server/whatsapp-rpc/node_modules/execa/lib/kill.js +102 -0
  237. package/server/whatsapp-rpc/node_modules/execa/lib/pipe.js +42 -0
  238. package/server/whatsapp-rpc/node_modules/execa/lib/promise.js +36 -0
  239. package/server/whatsapp-rpc/node_modules/execa/lib/stdio.js +49 -0
  240. package/server/whatsapp-rpc/node_modules/execa/lib/stream.js +133 -0
  241. package/server/whatsapp-rpc/node_modules/execa/lib/verbose.js +19 -0
  242. package/server/whatsapp-rpc/node_modules/execa/license +9 -0
  243. package/server/whatsapp-rpc/node_modules/execa/package.json +90 -0
  244. package/server/whatsapp-rpc/node_modules/execa/readme.md +822 -0
  245. package/server/whatsapp-rpc/node_modules/get-stream/license +9 -0
  246. package/server/whatsapp-rpc/node_modules/get-stream/package.json +53 -0
  247. package/server/whatsapp-rpc/node_modules/get-stream/readme.md +291 -0
  248. package/server/whatsapp-rpc/node_modules/get-stream/source/array-buffer.js +84 -0
  249. package/server/whatsapp-rpc/node_modules/get-stream/source/array.js +32 -0
  250. package/server/whatsapp-rpc/node_modules/get-stream/source/buffer.js +20 -0
  251. package/server/whatsapp-rpc/node_modules/get-stream/source/contents.js +101 -0
  252. package/server/whatsapp-rpc/node_modules/get-stream/source/index.d.ts +119 -0
  253. package/server/whatsapp-rpc/node_modules/get-stream/source/index.js +5 -0
  254. package/server/whatsapp-rpc/node_modules/get-stream/source/string.js +36 -0
  255. package/server/whatsapp-rpc/node_modules/get-stream/source/utils.js +11 -0
  256. package/server/whatsapp-rpc/node_modules/get-them-args/LICENSE +21 -0
  257. package/server/whatsapp-rpc/node_modules/get-them-args/README.md +95 -0
  258. package/server/whatsapp-rpc/node_modules/get-them-args/index.js +97 -0
  259. package/server/whatsapp-rpc/node_modules/get-them-args/package.json +36 -0
  260. package/server/whatsapp-rpc/node_modules/human-signals/LICENSE +201 -0
  261. package/server/whatsapp-rpc/node_modules/human-signals/README.md +168 -0
  262. package/server/whatsapp-rpc/node_modules/human-signals/build/src/core.js +273 -0
  263. package/server/whatsapp-rpc/node_modules/human-signals/build/src/main.d.ts +73 -0
  264. package/server/whatsapp-rpc/node_modules/human-signals/build/src/main.js +70 -0
  265. package/server/whatsapp-rpc/node_modules/human-signals/build/src/realtime.js +16 -0
  266. package/server/whatsapp-rpc/node_modules/human-signals/build/src/signals.js +34 -0
  267. package/server/whatsapp-rpc/node_modules/human-signals/package.json +61 -0
  268. package/server/whatsapp-rpc/node_modules/is-stream/index.d.ts +81 -0
  269. package/server/whatsapp-rpc/node_modules/is-stream/index.js +29 -0
  270. package/server/whatsapp-rpc/node_modules/is-stream/license +9 -0
  271. package/server/whatsapp-rpc/node_modules/is-stream/package.json +44 -0
  272. package/server/whatsapp-rpc/node_modules/is-stream/readme.md +60 -0
  273. package/server/whatsapp-rpc/node_modules/isexe/LICENSE +15 -0
  274. package/server/whatsapp-rpc/node_modules/isexe/README.md +51 -0
  275. package/server/whatsapp-rpc/node_modules/isexe/index.js +57 -0
  276. package/server/whatsapp-rpc/node_modules/isexe/mode.js +41 -0
  277. package/server/whatsapp-rpc/node_modules/isexe/package.json +31 -0
  278. package/server/whatsapp-rpc/node_modules/isexe/test/basic.js +221 -0
  279. package/server/whatsapp-rpc/node_modules/isexe/windows.js +42 -0
  280. package/server/whatsapp-rpc/node_modules/kill-port/.editorconfig +12 -0
  281. package/server/whatsapp-rpc/node_modules/kill-port/.gitattributes +1 -0
  282. package/server/whatsapp-rpc/node_modules/kill-port/LICENSE +21 -0
  283. package/server/whatsapp-rpc/node_modules/kill-port/README.md +140 -0
  284. package/server/whatsapp-rpc/node_modules/kill-port/cli.js +25 -0
  285. package/server/whatsapp-rpc/node_modules/kill-port/example.js +21 -0
  286. package/server/whatsapp-rpc/node_modules/kill-port/index.js +46 -0
  287. package/server/whatsapp-rpc/node_modules/kill-port/logo.png +0 -0
  288. package/server/whatsapp-rpc/node_modules/kill-port/package.json +41 -0
  289. package/server/whatsapp-rpc/node_modules/kill-port/pnpm-lock.yaml +4606 -0
  290. package/server/whatsapp-rpc/node_modules/kill-port/test.js +16 -0
  291. package/server/whatsapp-rpc/node_modules/merge-stream/LICENSE +21 -0
  292. package/server/whatsapp-rpc/node_modules/merge-stream/README.md +78 -0
  293. package/server/whatsapp-rpc/node_modules/merge-stream/index.js +41 -0
  294. package/server/whatsapp-rpc/node_modules/merge-stream/package.json +19 -0
  295. package/server/whatsapp-rpc/node_modules/mimic-fn/index.d.ts +52 -0
  296. package/server/whatsapp-rpc/node_modules/mimic-fn/index.js +71 -0
  297. package/server/whatsapp-rpc/node_modules/mimic-fn/license +9 -0
  298. package/server/whatsapp-rpc/node_modules/mimic-fn/package.json +45 -0
  299. package/server/whatsapp-rpc/node_modules/mimic-fn/readme.md +90 -0
  300. package/server/whatsapp-rpc/node_modules/npm-run-path/index.d.ts +90 -0
  301. package/server/whatsapp-rpc/node_modules/npm-run-path/index.js +52 -0
  302. package/server/whatsapp-rpc/node_modules/npm-run-path/license +9 -0
  303. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/index.d.ts +31 -0
  304. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/index.js +12 -0
  305. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/license +9 -0
  306. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/package.json +41 -0
  307. package/server/whatsapp-rpc/node_modules/npm-run-path/node_modules/path-key/readme.md +57 -0
  308. package/server/whatsapp-rpc/node_modules/npm-run-path/package.json +49 -0
  309. package/server/whatsapp-rpc/node_modules/npm-run-path/readme.md +104 -0
  310. package/server/whatsapp-rpc/node_modules/onetime/index.d.ts +59 -0
  311. package/server/whatsapp-rpc/node_modules/onetime/index.js +41 -0
  312. package/server/whatsapp-rpc/node_modules/onetime/license +9 -0
  313. package/server/whatsapp-rpc/node_modules/onetime/package.json +45 -0
  314. package/server/whatsapp-rpc/node_modules/onetime/readme.md +94 -0
  315. package/server/whatsapp-rpc/node_modules/path-key/index.d.ts +40 -0
  316. package/server/whatsapp-rpc/node_modules/path-key/index.js +16 -0
  317. package/server/whatsapp-rpc/node_modules/path-key/license +9 -0
  318. package/server/whatsapp-rpc/node_modules/path-key/package.json +39 -0
  319. package/server/whatsapp-rpc/node_modules/path-key/readme.md +61 -0
  320. package/server/whatsapp-rpc/node_modules/shebang-command/index.js +19 -0
  321. package/server/whatsapp-rpc/node_modules/shebang-command/license +9 -0
  322. package/server/whatsapp-rpc/node_modules/shebang-command/package.json +34 -0
  323. package/server/whatsapp-rpc/node_modules/shebang-command/readme.md +34 -0
  324. package/server/whatsapp-rpc/node_modules/shebang-regex/index.d.ts +22 -0
  325. package/server/whatsapp-rpc/node_modules/shebang-regex/index.js +2 -0
  326. package/server/whatsapp-rpc/node_modules/shebang-regex/license +9 -0
  327. package/server/whatsapp-rpc/node_modules/shebang-regex/package.json +35 -0
  328. package/server/whatsapp-rpc/node_modules/shebang-regex/readme.md +33 -0
  329. package/server/whatsapp-rpc/node_modules/shell-exec/LICENSE +21 -0
  330. package/server/whatsapp-rpc/node_modules/shell-exec/README.md +60 -0
  331. package/server/whatsapp-rpc/node_modules/shell-exec/index.js +47 -0
  332. package/server/whatsapp-rpc/node_modules/shell-exec/package.json +29 -0
  333. package/server/whatsapp-rpc/node_modules/signal-exit/LICENSE.txt +16 -0
  334. package/server/whatsapp-rpc/node_modules/signal-exit/README.md +74 -0
  335. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.d.ts +12 -0
  336. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.d.ts.map +1 -0
  337. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.js +10 -0
  338. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/browser.js.map +1 -0
  339. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.d.ts +48 -0
  340. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.d.ts.map +1 -0
  341. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.js +279 -0
  342. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/index.js.map +1 -0
  343. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/package.json +3 -0
  344. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.d.ts +29 -0
  345. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.d.ts.map +1 -0
  346. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.js +42 -0
  347. package/server/whatsapp-rpc/node_modules/signal-exit/dist/cjs/signals.js.map +1 -0
  348. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.d.ts +12 -0
  349. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.d.ts.map +1 -0
  350. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.js +4 -0
  351. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/browser.js.map +1 -0
  352. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.d.ts +48 -0
  353. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.d.ts.map +1 -0
  354. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.js +275 -0
  355. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/index.js.map +1 -0
  356. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/package.json +3 -0
  357. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.d.ts +29 -0
  358. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.d.ts.map +1 -0
  359. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.js +39 -0
  360. package/server/whatsapp-rpc/node_modules/signal-exit/dist/mjs/signals.js.map +1 -0
  361. package/server/whatsapp-rpc/node_modules/signal-exit/package.json +106 -0
  362. package/server/whatsapp-rpc/node_modules/strip-final-newline/index.js +14 -0
  363. package/server/whatsapp-rpc/node_modules/strip-final-newline/license +9 -0
  364. package/server/whatsapp-rpc/node_modules/strip-final-newline/package.json +43 -0
  365. package/server/whatsapp-rpc/node_modules/strip-final-newline/readme.md +35 -0
  366. package/server/whatsapp-rpc/node_modules/which/CHANGELOG.md +166 -0
  367. package/server/whatsapp-rpc/node_modules/which/LICENSE +15 -0
  368. package/server/whatsapp-rpc/node_modules/which/README.md +54 -0
  369. package/server/whatsapp-rpc/node_modules/which/bin/node-which +52 -0
  370. package/server/whatsapp-rpc/node_modules/which/package.json +43 -0
  371. package/server/whatsapp-rpc/node_modules/which/which.js +125 -0
  372. package/server/whatsapp-rpc/package-lock.json +272 -0
  373. package/server/whatsapp-rpc/package.json +30 -30
  374. package/server/whatsapp-rpc/schema.json +1294 -1294
  375. package/server/whatsapp-rpc/scripts/clean.cjs +66 -66
  376. package/server/whatsapp-rpc/scripts/cli.js +162 -162
  377. package/server/whatsapp-rpc/src/go/whatsapp/history.go +166 -166
  378. package/server/whatsapp-rpc/src/python/pyproject.toml +15 -15
  379. package/server/whatsapp-rpc/src/python/whatsapp_rpc/__init__.py +4 -4
  380. package/server/whatsapp-rpc/src/python/whatsapp_rpc/client.py +427 -427
  381. package/server/whatsapp-rpc/web/app.py +609 -609
  382. package/server/whatsapp-rpc/web/requirements.txt +6 -6
  383. package/server/whatsapp-rpc/web/rpc_client.py +427 -427
  384. package/server/whatsapp-rpc/web/static/openapi.yaml +59 -59
  385. package/server/whatsapp-rpc/web/templates/base.html +149 -149
  386. package/server/whatsapp-rpc/web/templates/contacts.html +240 -240
  387. package/server/whatsapp-rpc/web/templates/dashboard.html +319 -319
  388. package/server/whatsapp-rpc/web/templates/groups.html +328 -328
  389. package/server/whatsapp-rpc/web/templates/messages.html +465 -465
  390. package/server/whatsapp-rpc/web/templates/messaging.html +680 -680
  391. package/server/whatsapp-rpc/web/templates/send.html +258 -258
  392. package/server/whatsapp-rpc/web/templates/settings.html +459 -459
  393. package/client/src/components/ui/AndroidSettingsPanel.tsx +0 -401
  394. package/client/src/components/ui/WhatsAppSettingsPanel.tsx +0 -345
  395. package/client/src/nodeDefinitions/androidDeviceNodes.ts +0 -140
  396. package/docker-compose.prod.yml +0 -107
  397. package/docker-compose.yml +0 -104
  398. package/docs-MachinaOs/README.md +0 -85
  399. package/docs-MachinaOs/deployment/docker.mdx +0 -228
  400. package/docs-MachinaOs/deployment/production.mdx +0 -345
  401. package/docs-MachinaOs/docs.json +0 -75
  402. package/docs-MachinaOs/faq.mdx +0 -309
  403. package/docs-MachinaOs/favicon.svg +0 -5
  404. package/docs-MachinaOs/installation.mdx +0 -160
  405. package/docs-MachinaOs/introduction.mdx +0 -114
  406. package/docs-MachinaOs/logo/dark.svg +0 -6
  407. package/docs-MachinaOs/logo/light.svg +0 -6
  408. package/docs-MachinaOs/nodes/ai-agent.mdx +0 -216
  409. package/docs-MachinaOs/nodes/ai-models.mdx +0 -240
  410. package/docs-MachinaOs/nodes/android.mdx +0 -411
  411. package/docs-MachinaOs/nodes/overview.mdx +0 -181
  412. package/docs-MachinaOs/nodes/schedulers.mdx +0 -316
  413. package/docs-MachinaOs/nodes/webhooks.mdx +0 -330
  414. package/docs-MachinaOs/nodes/whatsapp.mdx +0 -305
  415. package/docs-MachinaOs/quickstart.mdx +0 -119
  416. package/docs-MachinaOs/tutorials/ai-agent-workflow.mdx +0 -177
  417. package/docs-MachinaOs/tutorials/android-automation.mdx +0 -242
  418. package/docs-MachinaOs/tutorials/first-workflow.mdx +0 -134
  419. package/docs-MachinaOs/tutorials/whatsapp-automation.mdx +0 -185
  420. package/nul +0 -0
  421. package/scripts/check-ports.ps1 +0 -33
  422. package/scripts/kill-port.ps1 +0 -154
@@ -1,826 +1,834 @@
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
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
+ # Save to database for persistence
667
+ try:
668
+ from core.container import container
669
+ database = container.database()
670
+ await database.add_console_log(log_data)
671
+ except Exception as e:
672
+ logger.warning(f"[StatusBroadcaster] Failed to persist console log: {e}")
673
+
674
+ # Broadcast to all clients
675
+ await self.broadcast({
676
+ "type": "console_log",
677
+ "data": log_data
678
+ })
679
+
680
+ logger.debug(f"[StatusBroadcaster] Console log broadcast: label={log_data.get('label')}")
681
+
682
+ def get_console_logs(self, workflow_id: Optional[str] = None) -> List[Dict[str, Any]]:
683
+ """Get console log history, optionally filtered by workflow_id."""
684
+ if "console_logs" not in self._status:
685
+ return []
686
+
687
+ if workflow_id:
688
+ return [
689
+ log for log in self._status["console_logs"]
690
+ if log.get("workflow_id") == workflow_id
691
+ ]
692
+ return list(self._status["console_logs"])
693
+
694
+ async def clear_console_logs(self, workflow_id: Optional[str] = None):
695
+ """Clear console log history."""
696
+ if "console_logs" not in self._status:
697
+ self._status["console_logs"] = []
698
+ return
699
+
700
+ if workflow_id:
701
+ self._status["console_logs"] = [
702
+ log for log in self._status["console_logs"]
703
+ if log.get("workflow_id") != workflow_id
704
+ ]
705
+ else:
706
+ self._status["console_logs"] = []
707
+
708
+ await self.broadcast({
709
+ "type": "console_logs_cleared",
710
+ "workflow_id": workflow_id
711
+ })
712
+
713
+ # =========================================================================
714
+ # Terminal Log Updates
715
+ # =========================================================================
716
+
717
+ async def broadcast_terminal_log(self, log_data: Dict[str, Any]):
718
+ """Broadcast a terminal log entry to all connected clients.
719
+
720
+ Used by the WebSocket logging handler to stream server logs to the frontend.
721
+
722
+ Args:
723
+ log_data: Dict containing:
724
+ - timestamp: ISO timestamp
725
+ - level: Log level (debug, info, warning, error)
726
+ - message: The log message
727
+ - source: Logger name/module (e.g., 'workflow', 'ai', 'android')
728
+ - details: Optional additional context
729
+ """
730
+ # Initialize terminal logs if not present
731
+ if "terminal_logs" not in self._status:
732
+ self._status["terminal_logs"] = []
733
+
734
+ # Add to terminal log history (keep last 200 entries)
735
+ self._status["terminal_logs"].append(log_data)
736
+ if len(self._status["terminal_logs"]) > 200:
737
+ self._status["terminal_logs"] = self._status["terminal_logs"][-200:]
738
+
739
+ # Broadcast to all clients
740
+ await self.broadcast({
741
+ "type": "terminal_log",
742
+ "data": log_data
743
+ })
744
+
745
+ def get_terminal_logs(self) -> List[Dict[str, Any]]:
746
+ """Get terminal log history."""
747
+ if "terminal_logs" not in self._status:
748
+ return []
749
+ return list(self._status["terminal_logs"])
750
+
751
+ async def clear_terminal_logs(self):
752
+ """Clear terminal log history."""
753
+ self._status["terminal_logs"] = []
754
+ await self.broadcast({
755
+ "type": "terminal_logs_cleared"
756
+ })
757
+
758
+ # =========================================================================
759
+ # Generic Updates
760
+ # =========================================================================
761
+
762
+ async def send_custom_event(self, event_type: str, data: Any):
763
+ """Send a custom event to all connected clients AND dispatch to event waiters.
764
+
765
+ Uses dispatch_async() directly since we're in an async context.
766
+ The sync dispatch() is for thread contexts like APScheduler callbacks.
767
+ See DESIGN.md section "Cross-Thread Event Dispatch" for pattern details.
768
+ """
769
+ # Broadcast to all WebSocket clients
770
+ await self.broadcast({
771
+ "type": event_type,
772
+ "data": data
773
+ })
774
+
775
+ # Dispatch to event waiters (for trigger nodes)
776
+ # Use dispatch_async directly - we're in async context
777
+ try:
778
+ from services import event_waiter
779
+ event_data = data if isinstance(data, dict) else {"data": data}
780
+ resolved_count = await event_waiter.dispatch_async(event_type, event_data)
781
+ if resolved_count > 0:
782
+ logger.info(f"[StatusBroadcaster] Event {event_type} resolved {resolved_count} waiters")
783
+ except Exception as e:
784
+ logger.error(f"[StatusBroadcaster] Failed to dispatch to event waiters: {e}")
785
+
786
+ # =========================================================================
787
+ # Getters
788
+ # =========================================================================
789
+
790
+ def get_status(self) -> Dict[str, Any]:
791
+ """Get the full current status."""
792
+ return self._status.copy()
793
+
794
+ def get_android_status(self) -> Dict[str, Any]:
795
+ """Get Android connection status."""
796
+ return self._status["android"].copy()
797
+
798
+ def get_node_status(self, node_id: str) -> Optional[Dict[str, Any]]:
799
+ """Get a specific node's status."""
800
+ return self._status["nodes"].get(node_id)
801
+
802
+ async def clear_node_status(self, node_id: str) -> bool:
803
+ """Clear a node's status and output from the cache."""
804
+ if node_id in self._status["nodes"]:
805
+ del self._status["nodes"][node_id]
806
+ logger.info(f"[StatusBroadcaster] Cleared node status: {node_id}")
807
+ # Broadcast that node status was cleared
808
+ await self.broadcast({
809
+ "type": "node_status_cleared",
810
+ "node_id": node_id
811
+ })
812
+ return True
813
+ return False
814
+
815
+ def get_variable(self, name: str) -> Any:
816
+ """Get a variable value."""
817
+ return self._status["variables"].get(name)
818
+
819
+ @property
820
+ def connection_count(self) -> int:
821
+ """Get the number of active WebSocket connections."""
822
+ return len(self._connections)
823
+
824
+
825
+ # Global singleton instance
826
+ _broadcaster: Optional[StatusBroadcaster] = None
827
+
828
+
829
+ def get_status_broadcaster() -> StatusBroadcaster:
830
+ """Get or create the global StatusBroadcaster instance."""
831
+ global _broadcaster
832
+ if _broadcaster is None:
833
+ _broadcaster = StatusBroadcaster()
834
+ return _broadcaster