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,1211 +1,1297 @@
1
- """Modern async database service with SQLModel and SQLAlchemy 2.0."""
2
-
3
- from datetime import datetime, timedelta, timezone
4
- from typing import Dict, Any, List, Optional
5
- from sqlmodel import SQLModel, select, Session
6
- from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
7
- from sqlalchemy.exc import IntegrityError
8
- from contextlib import asynccontextmanager
9
-
10
- from core.config import Settings
11
- from models.database import NodeParameter, Workflow, Execution, APIKey, APIKeyValidation, NodeOutput, ConversationMessage, ToolSchema, UserSkill, ChatMessage
12
- from models.cache import CacheEntry # SQLite-backed cache for Redis alternative
13
- from models.auth import User # Import User model to ensure table creation
14
- from core.logging import get_logger
15
-
16
- logger = get_logger(__name__)
17
-
18
-
19
- class Database:
20
- """Async database service with SQLModel."""
21
-
22
- def __init__(self, settings: Settings):
23
- self.settings = settings
24
- self.engine = None
25
- self.async_session = None
26
-
27
- async def startup(self):
28
- """Initialize database connection and create tables."""
29
- try:
30
- # Disable verbose database and asyncio logging
31
- import logging
32
- logging.getLogger("aiosqlite").setLevel(logging.WARNING)
33
- logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
34
- logging.getLogger("sqlalchemy.dialects").setLevel(logging.WARNING)
35
- logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
36
-
37
- # Create async engine
38
- self.engine = create_async_engine(
39
- self.settings.database_url,
40
- echo=self.settings.database_echo,
41
- pool_size=self.settings.database_pool_size,
42
- max_overflow=self.settings.database_max_overflow,
43
- future=True
44
- )
45
-
46
- # Create session factory
47
- self.async_session = async_sessionmaker(
48
- bind=self.engine,
49
- class_=AsyncSession,
50
- expire_on_commit=False
51
- )
52
-
53
- # Create tables
54
- async with self.engine.begin() as conn:
55
- await conn.run_sync(SQLModel.metadata.create_all)
56
-
57
- logger.info("Database initialized successfully")
58
-
59
- except Exception as e:
60
- logger.error("Database startup failed", error=str(e))
61
- raise
62
-
63
- async def shutdown(self):
64
- """Close database connections."""
65
- if self.engine:
66
- await self.engine.dispose()
67
- logger.info("Database connections closed")
68
-
69
- @asynccontextmanager
70
- async def get_session(self):
71
- """Get async database session."""
72
- if not self.async_session:
73
- raise RuntimeError("Database not initialized")
74
-
75
- async with self.async_session() as session:
76
- try:
77
- yield session
78
- except Exception:
79
- await session.rollback()
80
- raise
81
- finally:
82
- await session.close()
83
-
84
- # ============================================================================
85
- # Node Parameters
86
- # ============================================================================
87
-
88
- async def save_node_parameters(self, node_id: str, parameters: Dict[str, Any]) -> bool:
89
- """Save or update node parameters."""
90
- try:
91
- async with self.get_session() as session:
92
- # Try to get existing parameter
93
- stmt = select(NodeParameter).where(NodeParameter.node_id == node_id)
94
- result = await session.execute(stmt)
95
- existing = result.scalar_one_or_none()
96
-
97
- if existing:
98
- existing.parameters = parameters
99
- else:
100
- existing = NodeParameter(
101
- node_id=node_id,
102
- parameters=parameters
103
- )
104
- session.add(existing)
105
-
106
- await session.commit()
107
- return True
108
-
109
- except Exception as e:
110
- logger.error("Failed to save node parameters", node_id=node_id, error=str(e))
111
- return False
112
-
113
- async def get_node_parameters(self, node_id: str) -> Optional[Dict[str, Any]]:
114
- """Get node parameters."""
115
- try:
116
- async with self.get_session() as session:
117
- stmt = select(NodeParameter).where(NodeParameter.node_id == node_id)
118
- result = await session.execute(stmt)
119
- parameter = result.scalar_one_or_none()
120
-
121
- return parameter.parameters if parameter else None
122
-
123
- except Exception as e:
124
- logger.error("Failed to get node parameters", node_id=node_id, error=str(e))
125
- return None
126
-
127
- async def delete_node_parameters(self, node_id: str) -> bool:
128
- """Delete node parameters."""
129
- try:
130
- async with self.get_session() as session:
131
- stmt = select(NodeParameter).where(NodeParameter.node_id == node_id)
132
- result = await session.execute(stmt)
133
- parameter = result.scalar_one_or_none()
134
-
135
- if parameter:
136
- await session.delete(parameter)
137
- await session.commit()
138
-
139
- return True
140
-
141
- except Exception as e:
142
- logger.error("Failed to delete node parameters", node_id=node_id, error=str(e))
143
- return False
144
-
145
- # ============================================================================
146
- # Workflows
147
- # ============================================================================
148
-
149
- async def save_workflow(self, workflow_id: str, name: str, data: Dict[str, Any],
150
- description: Optional[str] = None) -> bool:
151
- """Save or update workflow."""
152
- try:
153
- async with self.get_session() as session:
154
- stmt = select(Workflow).where(Workflow.id == workflow_id)
155
- result = await session.execute(stmt)
156
- existing = result.scalar_one_or_none()
157
-
158
- if existing:
159
- existing.name = name
160
- existing.description = description
161
- existing.data = data
162
- else:
163
- existing = Workflow(
164
- id=workflow_id,
165
- name=name,
166
- description=description,
167
- data=data
168
- )
169
- session.add(existing)
170
-
171
- await session.commit()
172
- return True
173
-
174
- except Exception as e:
175
- logger.error("Failed to save workflow", workflow_id=workflow_id, error=str(e))
176
- return False
177
-
178
- async def get_workflow(self, workflow_id: str) -> Optional[Workflow]:
179
- """Get workflow by ID."""
180
- try:
181
- async with self.get_session() as session:
182
- stmt = select(Workflow).where(Workflow.id == workflow_id)
183
- result = await session.execute(stmt)
184
- return result.scalar_one_or_none()
185
-
186
- except Exception as e:
187
- logger.error("Failed to get workflow", workflow_id=workflow_id, error=str(e))
188
- return None
189
-
190
- async def get_all_workflows(self) -> List[Workflow]:
191
- """Get all workflows."""
192
- try:
193
- async with self.get_session() as session:
194
- stmt = select(Workflow).order_by(Workflow.updated_at.desc())
195
- result = await session.execute(stmt)
196
- return result.scalars().all()
197
-
198
- except Exception as e:
199
- logger.error("Failed to get all workflows", error=str(e))
200
- return []
201
-
202
- async def delete_workflow(self, workflow_id: str) -> bool:
203
- """Delete workflow."""
204
- try:
205
- async with self.get_session() as session:
206
- stmt = select(Workflow).where(Workflow.id == workflow_id)
207
- result = await session.execute(stmt)
208
- workflow = result.scalar_one_or_none()
209
-
210
- if workflow:
211
- await session.delete(workflow)
212
- await session.commit()
213
-
214
- return True
215
-
216
- except Exception as e:
217
- logger.error("Failed to delete workflow", workflow_id=workflow_id, error=str(e))
218
- return False
219
-
220
- # ============================================================================
221
- # Executions
222
- # ============================================================================
223
-
224
- async def save_execution(self, execution_id: str, workflow_id: str, node_id: str,
225
- status: str, result: Optional[Dict[str, Any]] = None,
226
- error: Optional[str] = None, execution_time: Optional[float] = None) -> bool:
227
- """Save execution result."""
228
- try:
229
- async with self.get_session() as session:
230
- execution = Execution(
231
- id=execution_id,
232
- workflow_id=workflow_id,
233
- node_id=node_id,
234
- status=status,
235
- result=result,
236
- error=error,
237
- execution_time=execution_time
238
- )
239
- session.add(execution)
240
- await session.commit()
241
- return True
242
-
243
- except Exception as e:
244
- logger.error("Failed to save execution", execution_id=execution_id, error=str(e))
245
- return False
246
-
247
- async def get_execution(self, execution_id: str) -> Optional[Execution]:
248
- """Get execution by ID."""
249
- try:
250
- async with self.get_session() as session:
251
- stmt = select(Execution).where(Execution.id == execution_id)
252
- result = await session.execute(stmt)
253
- return result.scalar_one_or_none()
254
-
255
- except Exception as e:
256
- logger.error("Failed to get execution", execution_id=execution_id, error=str(e))
257
- return None
258
-
259
- # ============================================================================
260
- # API Keys
261
- # ============================================================================
262
-
263
- async def save_api_key(self, key_id: str, provider: str, session_id: str,
264
- key_encrypted: str, key_hash: str,
265
- models: Optional[List[str]] = None) -> bool:
266
- """Save encrypted API key."""
267
- logger.info(f"Database save_api_key called with key_id: {key_id}, provider: {provider}")
268
-
269
- try:
270
- async with self.get_session() as session:
271
- api_key = APIKey(
272
- id=key_id,
273
- provider=provider,
274
- session_id=session_id,
275
- key_encrypted=key_encrypted,
276
- key_hash=key_hash,
277
- models={"models": models} if models else None,
278
- last_validated=datetime.now(timezone.utc)
279
- )
280
- session.add(api_key)
281
- await session.commit()
282
- logger.info(f"Successfully saved new API key: {key_id}")
283
- return True
284
-
285
- except IntegrityError as e:
286
- logger.info(f"API key {key_id} already exists, attempting update. Error: {str(e)}")
287
- # Key already exists, update it
288
- try:
289
- async with self.get_session() as session:
290
- stmt = select(APIKey).where(APIKey.id == key_id)
291
- result = await session.execute(stmt)
292
- existing = result.scalar_one_or_none()
293
-
294
- if existing:
295
- logger.info(f"Found existing API key {key_id}, updating...")
296
- existing.key_encrypted = key_encrypted
297
- existing.key_hash = key_hash
298
- existing.models = {"models": models} if models else None
299
- existing.last_validated = datetime.now(timezone.utc)
300
- await session.commit()
301
- logger.info(f"Successfully updated API key: {key_id}")
302
- return True
303
- else:
304
- logger.error(f"Could not find existing API key {key_id} for update")
305
- return False
306
- except Exception as update_e:
307
- logger.error(f"Failed to update API key {key_id}", error=str(update_e))
308
- return False
309
-
310
- except Exception as e:
311
- logger.error("Failed to save API key", provider=provider, error=str(e))
312
- import traceback
313
- logger.error("Full traceback", traceback=traceback.format_exc())
314
- return False
315
-
316
- async def get_api_key(self, key_id: str) -> Optional[APIKey]:
317
- """Get API key by ID."""
318
- try:
319
- async with self.get_session() as session:
320
- stmt = select(APIKey).where(APIKey.id == key_id)
321
- result = await session.execute(stmt)
322
- return result.scalar_one_or_none()
323
-
324
- except Exception as e:
325
- logger.error("Failed to get API key", key_id=key_id, error=str(e))
326
- return None
327
-
328
- async def get_api_key_by_provider(self, provider: str, session_id: str = "default") -> Optional[APIKey]:
329
- """Get API key by provider and session."""
330
- try:
331
- async with self.get_session() as session:
332
- stmt = select(APIKey).where(
333
- APIKey.provider == provider,
334
- APIKey.session_id == session_id,
335
- APIKey.is_valid == True
336
- )
337
- result = await session.execute(stmt)
338
- return result.scalar_one_or_none()
339
-
340
- except Exception as e:
341
- logger.error("Failed to get API key by provider", provider=provider, error=str(e))
342
- return None
343
-
344
- async def delete_api_key(self, provider: str, session_id: str = "default") -> bool:
345
- """Delete API key."""
346
- try:
347
- async with self.get_session() as session:
348
- stmt = select(APIKey).where(
349
- APIKey.provider == provider,
350
- APIKey.session_id == session_id
351
- )
352
- result = await session.execute(stmt)
353
- api_key = result.scalar_one_or_none()
354
-
355
- if api_key:
356
- await session.delete(api_key)
357
- await session.commit()
358
- logger.debug("API key deleted", provider=provider, session_id=session_id)
359
-
360
- return True
361
-
362
- except Exception as e:
363
- logger.error("Failed to delete API key", provider=provider, error=str(e))
364
- return False
365
-
366
- # ============================================================================
367
- # API Key Validation Cache
368
- # ============================================================================
369
-
370
- async def save_api_key_validation(self, key_hash: str) -> bool:
371
- """Save API key validation status."""
372
- try:
373
- async with self.get_session() as session:
374
- validation = APIKeyValidation(
375
- key_hash=key_hash,
376
- validated=True
377
- )
378
- session.add(validation)
379
- await session.commit()
380
- return True
381
-
382
- except IntegrityError:
383
- # Already exists, update timestamp
384
- async with self.get_session() as session:
385
- stmt = select(APIKeyValidation).where(APIKeyValidation.key_hash == key_hash)
386
- result = await session.execute(stmt)
387
- existing = result.scalar_one_or_none()
388
-
389
- if existing:
390
- existing.timestamp = datetime.now(timezone.utc)
391
- await session.commit()
392
- return True
393
- return False
394
-
395
- except Exception as e:
396
- logger.error("Failed to save API key validation", key_hash=key_hash, error=str(e))
397
- return False
398
-
399
- async def is_api_key_validated(self, key_hash: str) -> bool:
400
- """Check if API key is validated."""
401
- try:
402
- async with self.get_session() as session:
403
- stmt = select(APIKeyValidation).where(APIKeyValidation.key_hash == key_hash)
404
- result = await session.execute(stmt)
405
- validation = result.scalar_one_or_none()
406
- return validation is not None and validation.validated
407
-
408
- except Exception as e:
409
- logger.error("Failed to check API key validation", key_hash=key_hash, error=str(e))
410
- return False
411
-
412
- # ============================================================================
413
- # Node Outputs
414
- # ============================================================================
415
-
416
- async def save_node_output(self, node_id: str, session_id: str, output_name: str,
417
- data: Dict[str, Any]) -> bool:
418
- """Save or update node output."""
419
- try:
420
- async with self.get_session() as session:
421
- # Try to get existing output
422
- stmt = select(NodeOutput).where(
423
- NodeOutput.node_id == node_id,
424
- NodeOutput.session_id == session_id,
425
- NodeOutput.output_name == output_name
426
- )
427
- result = await session.execute(stmt)
428
- existing = result.scalar_one_or_none()
429
-
430
- action = "updated"
431
- if existing:
432
- existing.data = data
433
- else:
434
- action = "inserted"
435
- existing = NodeOutput(
436
- node_id=node_id,
437
- session_id=session_id,
438
- output_name=output_name,
439
- data=data
440
- )
441
- session.add(existing)
442
-
443
- await session.commit()
444
- logger.info("[DB] Node output saved", action=action, node_id=node_id, session_id=session_id, output_name=output_name)
445
- return True
446
-
447
- except Exception as e:
448
- logger.error("Failed to save node output", node_id=node_id, error=str(e))
449
- import traceback
450
- traceback.print_exc()
451
- return False
452
-
453
- async def get_node_output(self, node_id: str, session_id: str = "default",
454
- output_name: str = "output_0") -> Optional[Dict[str, Any]]:
455
- """Get node output data."""
456
- try:
457
- async with self.get_session() as session:
458
- stmt = select(NodeOutput).where(
459
- NodeOutput.node_id == node_id,
460
- NodeOutput.session_id == session_id,
461
- NodeOutput.output_name == output_name
462
- )
463
- result = await session.execute(stmt)
464
- output = result.scalar_one_or_none()
465
-
466
- return output.data if output else None
467
-
468
- except Exception as e:
469
- logger.error("Failed to get node output", node_id=node_id, error=str(e))
470
- return None
471
-
472
- async def delete_node_output(self, node_id: str) -> int:
473
- """Delete all outputs for a node (any session). Returns count deleted."""
474
- try:
475
- async with self.get_session() as session:
476
- stmt = select(NodeOutput).where(NodeOutput.node_id == node_id)
477
- result = await session.execute(stmt)
478
- outputs = result.scalars().all()
479
-
480
- count = len(outputs)
481
- for output in outputs:
482
- await session.delete(output)
483
-
484
- await session.commit()
485
- logger.info("Deleted node outputs", node_id=node_id, count=count)
486
- return count
487
-
488
- except Exception as e:
489
- logger.error("Failed to delete node output", node_id=node_id, error=str(e))
490
- return 0
491
-
492
- async def clear_session_outputs(self, session_id: str = "default") -> int:
493
- """Clear all outputs for a session. Returns count deleted."""
494
- try:
495
- async with self.get_session() as session:
496
- stmt = select(NodeOutput).where(NodeOutput.session_id == session_id)
497
- result = await session.execute(stmt)
498
- outputs = result.scalars().all()
499
-
500
- count = len(outputs)
501
- for output in outputs:
502
- await session.delete(output)
503
-
504
- await session.commit()
505
- logger.info("Cleared session outputs", session_id=session_id, count=count)
506
- return count
507
-
508
- except Exception as e:
509
- logger.error("Failed to clear session outputs", session_id=session_id, error=str(e))
510
- return 0
511
-
512
- # ============================================================================
513
- # Conversation Messages (AI Memory)
514
- # ============================================================================
515
-
516
- async def add_conversation_message(self, session_id: str, role: str, content: str) -> bool:
517
- """Add a message to conversation history."""
518
- try:
519
- async with self.get_session() as session:
520
- message = ConversationMessage(
521
- session_id=session_id,
522
- role=role,
523
- content=content
524
- )
525
- session.add(message)
526
- await session.commit()
527
- logger.info(f"[Memory] Added {role} message to session '{session_id}'")
528
- return True
529
-
530
- except Exception as e:
531
- logger.error("Failed to add conversation message", session_id=session_id, error=str(e))
532
- return False
533
-
534
- async def get_conversation_messages(self, session_id: str, window_size: Optional[int] = None) -> List[Dict[str, Any]]:
535
- """Get conversation messages, optionally limited to last N."""
536
- try:
537
- async with self.get_session() as session:
538
- stmt = select(ConversationMessage).where(
539
- ConversationMessage.session_id == session_id
540
- ).order_by(ConversationMessage.created_at.asc())
541
-
542
- result = await session.execute(stmt)
543
- messages = result.scalars().all()
544
-
545
- # Apply window limit if specified
546
- if window_size and window_size > 0:
547
- messages = messages[-window_size:]
548
-
549
- return [
550
- {
551
- "role": m.role,
552
- "content": m.content,
553
- "timestamp": m.created_at.isoformat()
554
- }
555
- for m in messages
556
- ]
557
-
558
- except Exception as e:
559
- logger.error("Failed to get conversation messages", session_id=session_id, error=str(e))
560
- return []
561
-
562
- async def clear_conversation(self, session_id: str) -> int:
563
- """Clear all messages in a conversation session. Returns count deleted."""
564
- try:
565
- async with self.get_session() as session:
566
- stmt = select(ConversationMessage).where(
567
- ConversationMessage.session_id == session_id
568
- )
569
- result = await session.execute(stmt)
570
- messages = result.scalars().all()
571
-
572
- count = len(messages)
573
- for message in messages:
574
- await session.delete(message)
575
-
576
- await session.commit()
577
- logger.info(f"[Memory] Cleared {count} messages from session '{session_id}'")
578
- return count
579
-
580
- except Exception as e:
581
- logger.error("Failed to clear conversation", session_id=session_id, error=str(e))
582
- return 0
583
-
584
- async def get_all_conversation_sessions(self) -> List[Dict[str, Any]]:
585
- """Get info about all conversation sessions."""
586
- try:
587
- async with self.get_session() as session:
588
- # Get distinct session IDs with message count
589
- from sqlalchemy import func as sql_func
590
- stmt = select(
591
- ConversationMessage.session_id,
592
- sql_func.count(ConversationMessage.id).label('message_count'),
593
- sql_func.min(ConversationMessage.created_at).label('created_at')
594
- ).group_by(ConversationMessage.session_id)
595
-
596
- result = await session.execute(stmt)
597
- rows = result.all()
598
-
599
- return [
600
- {
601
- "session_id": row.session_id,
602
- "message_count": row.message_count,
603
- "created_at": row.created_at.isoformat() if row.created_at else None
604
- }
605
- for row in rows
606
- ]
607
-
608
- except Exception as e:
609
- logger.error("Failed to get conversation sessions", error=str(e))
610
- return []
611
-
612
- # ============================================================================
613
- # Chat Messages (Console Panel persistence)
614
- # ============================================================================
615
-
616
- async def add_chat_message(self, session_id: str, role: str, message: str) -> bool:
617
- """Add a chat message to the console panel history."""
618
- try:
619
- async with self.get_session() as session:
620
- chat_msg = ChatMessage(
621
- session_id=session_id,
622
- role=role,
623
- message=message
624
- )
625
- session.add(chat_msg)
626
- await session.commit()
627
- logger.debug(f"[Chat] Added {role} message to session '{session_id}'")
628
- return True
629
-
630
- except Exception as e:
631
- logger.error("Failed to add chat message", session_id=session_id, error=str(e))
632
- return False
633
-
634
- async def get_chat_messages(self, session_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
635
- """Get chat messages for a session, optionally limited to last N."""
636
- try:
637
- async with self.get_session() as session:
638
- stmt = select(ChatMessage).where(
639
- ChatMessage.session_id == session_id
640
- ).order_by(ChatMessage.created_at.asc())
641
-
642
- result = await session.execute(stmt)
643
- messages = result.scalars().all()
644
-
645
- # Apply limit if specified
646
- if limit and limit > 0:
647
- messages = messages[-limit:]
648
-
649
- return [
650
- {
651
- "role": m.role,
652
- "message": m.message,
653
- "timestamp": m.created_at.isoformat()
654
- }
655
- for m in messages
656
- ]
657
-
658
- except Exception as e:
659
- logger.error("Failed to get chat messages", session_id=session_id, error=str(e))
660
- return []
661
-
662
- async def clear_chat_messages(self, session_id: str) -> int:
663
- """Clear all chat messages for a session. Returns count deleted."""
664
- try:
665
- async with self.get_session() as session:
666
- stmt = select(ChatMessage).where(
667
- ChatMessage.session_id == session_id
668
- )
669
- result = await session.execute(stmt)
670
- messages = result.scalars().all()
671
-
672
- count = len(messages)
673
- for message in messages:
674
- await session.delete(message)
675
-
676
- await session.commit()
677
- logger.info(f"[Chat] Cleared {count} messages from session '{session_id}'")
678
- return count
679
-
680
- except Exception as e:
681
- logger.error("Failed to clear chat messages", session_id=session_id, error=str(e))
682
- return 0
683
-
684
- async def get_chat_sessions(self) -> List[Dict[str, Any]]:
685
- """Get list of all chat sessions with message counts."""
686
- try:
687
- async with self.get_session() as session:
688
- from sqlalchemy import func as sa_func
689
- stmt = select(
690
- ChatMessage.session_id,
691
- sa_func.count(ChatMessage.id).label('message_count'),
692
- sa_func.max(ChatMessage.created_at).label('last_message_at')
693
- ).group_by(ChatMessage.session_id).order_by(sa_func.max(ChatMessage.created_at).desc())
694
-
695
- result = await session.execute(stmt)
696
- rows = result.all()
697
-
698
- return [
699
- {
700
- "session_id": row.session_id,
701
- "message_count": row.message_count,
702
- "last_message_at": row.last_message_at.isoformat() if row.last_message_at else None
703
- }
704
- for row in rows
705
- ]
706
-
707
- except Exception as e:
708
- logger.error("Failed to get chat sessions", error=str(e))
709
- return []
710
-
711
- # ============================================================================
712
- # Cache Entries (SQLite-backed Redis alternative)
713
- # ============================================================================
714
-
715
- async def get_cache_entry(self, key: str) -> Optional[str]:
716
- """Get cache value by key. Returns None if expired or not found."""
717
- import time
718
- try:
719
- async with self.get_session() as session:
720
- stmt = select(CacheEntry).where(CacheEntry.key == key)
721
- result = await session.execute(stmt)
722
- entry = result.scalar_one_or_none()
723
-
724
- if not entry:
725
- return None
726
-
727
- # Check expiration
728
- if entry.expires_at and entry.expires_at < time.time():
729
- # Entry expired - delete it
730
- await session.delete(entry)
731
- await session.commit()
732
- return None
733
-
734
- return entry.value
735
-
736
- except Exception as e:
737
- logger.error("Failed to get cache entry", key=key, error=str(e))
738
- return None
739
-
740
- async def set_cache_entry(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
741
- """Set cache value with optional TTL in seconds."""
742
- import time
743
- try:
744
- expires_at = time.time() + ttl if ttl else None
745
-
746
- async with self.get_session() as session:
747
- # Try to get existing entry
748
- stmt = select(CacheEntry).where(CacheEntry.key == key)
749
- result = await session.execute(stmt)
750
- existing = result.scalar_one_or_none()
751
-
752
- if existing:
753
- existing.value = value
754
- existing.expires_at = expires_at
755
- existing.created_at = time.time()
756
- else:
757
- entry = CacheEntry(
758
- key=key,
759
- value=value,
760
- expires_at=expires_at,
761
- created_at=time.time()
762
- )
763
- session.add(entry)
764
-
765
- await session.commit()
766
- return True
767
-
768
- except Exception as e:
769
- logger.error("Failed to set cache entry", key=key, error=str(e))
770
- return False
771
-
772
- async def delete_cache_entry(self, key: str) -> bool:
773
- """Delete cache entry by key."""
774
- try:
775
- async with self.get_session() as session:
776
- stmt = select(CacheEntry).where(CacheEntry.key == key)
777
- result = await session.execute(stmt)
778
- entry = result.scalar_one_or_none()
779
-
780
- if entry:
781
- await session.delete(entry)
782
- await session.commit()
783
-
784
- return True
785
-
786
- except Exception as e:
787
- logger.error("Failed to delete cache entry", key=key, error=str(e))
788
- return False
789
-
790
- async def delete_cache_pattern(self, pattern: str) -> int:
791
- """Delete cache entries matching pattern (uses SQL LIKE)."""
792
- try:
793
- # Convert glob pattern to SQL LIKE pattern
794
- sql_pattern = pattern.replace("*", "%")
795
-
796
- async with self.get_session() as session:
797
- stmt = select(CacheEntry).where(CacheEntry.key.like(sql_pattern))
798
- result = await session.execute(stmt)
799
- entries = result.scalars().all()
800
-
801
- count = len(entries)
802
- for entry in entries:
803
- await session.delete(entry)
804
-
805
- await session.commit()
806
- logger.debug("Deleted cache entries", pattern=pattern, count=count)
807
- return count
808
-
809
- except Exception as e:
810
- logger.error("Failed to delete cache pattern", pattern=pattern, error=str(e))
811
- return 0
812
-
813
- async def cleanup_expired_cache(self) -> int:
814
- """Remove all expired cache entries. Returns count deleted."""
815
- import time
816
- try:
817
- async with self.get_session() as session:
818
- stmt = select(CacheEntry).where(
819
- CacheEntry.expires_at.isnot(None),
820
- CacheEntry.expires_at < time.time()
821
- )
822
- result = await session.execute(stmt)
823
- entries = result.scalars().all()
824
-
825
- count = len(entries)
826
- for entry in entries:
827
- await session.delete(entry)
828
-
829
- await session.commit()
830
- if count > 0:
831
- logger.info("Cleaned up expired cache entries", count=count)
832
- return count
833
-
834
- except Exception as e:
835
- logger.error("Failed to cleanup expired cache", error=str(e))
836
- return 0
837
-
838
- async def cache_exists(self, key: str) -> bool:
839
- """Check if cache key exists and is not expired."""
840
- import time
841
- try:
842
- async with self.get_session() as session:
843
- stmt = select(CacheEntry).where(CacheEntry.key == key)
844
- result = await session.execute(stmt)
845
- entry = result.scalar_one_or_none()
846
-
847
- if not entry:
848
- return False
849
-
850
- # Check expiration
851
- if entry.expires_at and entry.expires_at < time.time():
852
- return False
853
-
854
- return True
855
-
856
- except Exception as e:
857
- logger.error("Failed to check cache exists", key=key, error=str(e))
858
- return False
859
-
860
- # ============================================================================
861
- # Tool Schemas (Source of truth for tool node configurations)
862
- # ============================================================================
863
-
864
- async def save_tool_schema(self, node_id: str, tool_name: str, tool_description: str,
865
- schema_config: Dict[str, Any],
866
- connected_services: Optional[Dict[str, Any]] = None) -> bool:
867
- """Save or update tool schema for a node."""
868
- try:
869
- async with self.get_session() as session:
870
- # Try to get existing schema
871
- stmt = select(ToolSchema).where(ToolSchema.node_id == node_id)
872
- result = await session.execute(stmt)
873
- existing = result.scalar_one_or_none()
874
-
875
- action = "updated"
876
- if existing:
877
- existing.tool_name = tool_name
878
- existing.tool_description = tool_description
879
- existing.schema_config = schema_config
880
- existing.connected_services = connected_services
881
- else:
882
- action = "created"
883
- existing = ToolSchema(
884
- node_id=node_id,
885
- tool_name=tool_name,
886
- tool_description=tool_description,
887
- schema_config=schema_config,
888
- connected_services=connected_services
889
- )
890
- session.add(existing)
891
-
892
- await session.commit()
893
- logger.info(f"[DB] Tool schema {action}", node_id=node_id, tool_name=tool_name)
894
- return True
895
-
896
- except Exception as e:
897
- logger.error("Failed to save tool schema", node_id=node_id, error=str(e))
898
- return False
899
-
900
- async def get_tool_schema(self, node_id: str) -> Optional[Dict[str, Any]]:
901
- """Get tool schema for a node."""
902
- try:
903
- async with self.get_session() as session:
904
- stmt = select(ToolSchema).where(ToolSchema.node_id == node_id)
905
- result = await session.execute(stmt)
906
- schema = result.scalar_one_or_none()
907
-
908
- if not schema:
909
- return None
910
-
911
- return {
912
- "node_id": schema.node_id,
913
- "tool_name": schema.tool_name,
914
- "tool_description": schema.tool_description,
915
- "schema_config": schema.schema_config,
916
- "connected_services": schema.connected_services,
917
- "created_at": schema.created_at.isoformat() if schema.created_at else None,
918
- "updated_at": schema.updated_at.isoformat() if schema.updated_at else None
919
- }
920
-
921
- except Exception as e:
922
- logger.error("Failed to get tool schema", node_id=node_id, error=str(e))
923
- return None
924
-
925
- async def delete_tool_schema(self, node_id: str) -> bool:
926
- """Delete tool schema for a node."""
927
- try:
928
- async with self.get_session() as session:
929
- stmt = select(ToolSchema).where(ToolSchema.node_id == node_id)
930
- result = await session.execute(stmt)
931
- schema = result.scalar_one_or_none()
932
-
933
- if schema:
934
- await session.delete(schema)
935
- await session.commit()
936
- logger.info("[DB] Tool schema deleted", node_id=node_id)
937
-
938
- return True
939
-
940
- except Exception as e:
941
- logger.error("Failed to delete tool schema", node_id=node_id, error=str(e))
942
- return False
943
-
944
- async def get_all_tool_schemas(self) -> List[Dict[str, Any]]:
945
- """Get all tool schemas."""
946
- try:
947
- async with self.get_session() as session:
948
- stmt = select(ToolSchema).order_by(ToolSchema.updated_at.desc())
949
- result = await session.execute(stmt)
950
- schemas = result.scalars().all()
951
-
952
- return [
953
- {
954
- "node_id": s.node_id,
955
- "tool_name": s.tool_name,
956
- "tool_description": s.tool_description,
957
- "schema_config": s.schema_config,
958
- "connected_services": s.connected_services,
959
- "updated_at": s.updated_at.isoformat() if s.updated_at else None
960
- }
961
- for s in schemas
962
- ]
963
-
964
- except Exception as e:
965
- logger.error("Failed to get all tool schemas", error=str(e))
966
- return []
967
-
968
- # ============================================================================
969
- # Android Relay Session Persistence
970
- # ============================================================================
971
-
972
- async def save_android_relay_session(
973
- self,
974
- relay_url: str,
975
- api_key: str,
976
- device_id: str,
977
- device_name: Optional[str] = None,
978
- session_token: Optional[str] = None
979
- ) -> bool:
980
- """Save Android relay pairing session for auto-reconnect on server restart.
981
-
982
- Args:
983
- relay_url: WebSocket relay URL
984
- api_key: API key for relay authentication
985
- device_id: Paired Android device ID
986
- device_name: Paired device name
987
- session_token: Relay session token
988
- """
989
- import json
990
- try:
991
- session_data = json.dumps({
992
- "relay_url": relay_url,
993
- "api_key": api_key,
994
- "device_id": device_id,
995
- "device_name": device_name,
996
- "session_token": session_token
997
- })
998
- # No TTL - session persists until explicitly cleared
999
- return await self.set_cache_entry("android_relay_session", session_data)
1000
- except Exception as e:
1001
- logger.error("Failed to save Android relay session", error=str(e))
1002
- return False
1003
-
1004
- async def get_android_relay_session(self) -> Optional[Dict[str, Any]]:
1005
- """Get stored Android relay session for auto-reconnect.
1006
-
1007
- Returns:
1008
- Session data dict or None if not found
1009
- """
1010
- import json
1011
- try:
1012
- value = await self.get_cache_entry("android_relay_session")
1013
- if value:
1014
- return json.loads(value)
1015
- return None
1016
- except Exception as e:
1017
- logger.error("Failed to get Android relay session", error=str(e))
1018
- return None
1019
-
1020
- async def clear_android_relay_session(self) -> bool:
1021
- """Clear stored Android relay session (on explicit disconnect)."""
1022
- try:
1023
- return await self.delete_cache_entry("android_relay_session")
1024
- except Exception as e:
1025
- logger.error("Failed to clear Android relay session", error=str(e))
1026
- return False
1027
-
1028
- # ============================================================================
1029
- # User Skills (Custom skills for Chat Agent)
1030
- # ============================================================================
1031
-
1032
- async def create_user_skill(
1033
- self,
1034
- name: str,
1035
- display_name: str,
1036
- description: str,
1037
- instructions: str,
1038
- allowed_tools: Optional[str] = None,
1039
- category: str = "custom",
1040
- icon: str = "star",
1041
- color: str = "#6366F1",
1042
- metadata_json: Optional[Dict[str, Any]] = None,
1043
- created_by: Optional[int] = None
1044
- ) -> Optional[Dict[str, Any]]:
1045
- """Create a new user skill."""
1046
- try:
1047
- async with self.get_session() as session:
1048
- skill = UserSkill(
1049
- name=name,
1050
- display_name=display_name,
1051
- description=description,
1052
- instructions=instructions,
1053
- allowed_tools=allowed_tools,
1054
- category=category,
1055
- icon=icon,
1056
- color=color,
1057
- metadata_json=metadata_json,
1058
- created_by=created_by
1059
- )
1060
- session.add(skill)
1061
- await session.commit()
1062
- await session.refresh(skill)
1063
-
1064
- logger.info(f"[DB] Created user skill: {name}")
1065
- return self._skill_to_dict(skill)
1066
-
1067
- except IntegrityError:
1068
- logger.error(f"User skill with name '{name}' already exists")
1069
- return None
1070
- except Exception as e:
1071
- logger.error("Failed to create user skill", name=name, error=str(e))
1072
- return None
1073
-
1074
- async def get_user_skill(self, name: str) -> Optional[Dict[str, Any]]:
1075
- """Get user skill by name."""
1076
- try:
1077
- async with self.get_session() as session:
1078
- stmt = select(UserSkill).where(UserSkill.name == name)
1079
- result = await session.execute(stmt)
1080
- skill = result.scalar_one_or_none()
1081
-
1082
- return self._skill_to_dict(skill) if skill else None
1083
-
1084
- except Exception as e:
1085
- logger.error("Failed to get user skill", name=name, error=str(e))
1086
- return None
1087
-
1088
- async def get_user_skill_by_id(self, skill_id: int) -> Optional[Dict[str, Any]]:
1089
- """Get user skill by ID."""
1090
- try:
1091
- async with self.get_session() as session:
1092
- stmt = select(UserSkill).where(UserSkill.id == skill_id)
1093
- result = await session.execute(stmt)
1094
- skill = result.scalar_one_or_none()
1095
-
1096
- return self._skill_to_dict(skill) if skill else None
1097
-
1098
- except Exception as e:
1099
- logger.error("Failed to get user skill by id", skill_id=skill_id, error=str(e))
1100
- return None
1101
-
1102
- async def get_all_user_skills(self, active_only: bool = True) -> List[Dict[str, Any]]:
1103
- """Get all user skills, optionally filtered by active status."""
1104
- try:
1105
- async with self.get_session() as session:
1106
- if active_only:
1107
- stmt = select(UserSkill).where(UserSkill.is_active == True).order_by(UserSkill.display_name)
1108
- else:
1109
- stmt = select(UserSkill).order_by(UserSkill.display_name)
1110
-
1111
- result = await session.execute(stmt)
1112
- skills = result.scalars().all()
1113
-
1114
- return [self._skill_to_dict(s) for s in skills]
1115
-
1116
- except Exception as e:
1117
- logger.error("Failed to get all user skills", error=str(e))
1118
- return []
1119
-
1120
- async def update_user_skill(
1121
- self,
1122
- name: str,
1123
- display_name: Optional[str] = None,
1124
- description: Optional[str] = None,
1125
- instructions: Optional[str] = None,
1126
- allowed_tools: Optional[str] = None,
1127
- category: Optional[str] = None,
1128
- icon: Optional[str] = None,
1129
- color: Optional[str] = None,
1130
- metadata_json: Optional[Dict[str, Any]] = None,
1131
- is_active: Optional[bool] = None
1132
- ) -> Optional[Dict[str, Any]]:
1133
- """Update an existing user skill."""
1134
- try:
1135
- async with self.get_session() as session:
1136
- stmt = select(UserSkill).where(UserSkill.name == name)
1137
- result = await session.execute(stmt)
1138
- skill = result.scalar_one_or_none()
1139
-
1140
- if not skill:
1141
- logger.error(f"User skill '{name}' not found for update")
1142
- return None
1143
-
1144
- # Update only provided fields
1145
- if display_name is not None:
1146
- skill.display_name = display_name
1147
- if description is not None:
1148
- skill.description = description
1149
- if instructions is not None:
1150
- skill.instructions = instructions
1151
- if allowed_tools is not None:
1152
- skill.allowed_tools = allowed_tools
1153
- if category is not None:
1154
- skill.category = category
1155
- if icon is not None:
1156
- skill.icon = icon
1157
- if color is not None:
1158
- skill.color = color
1159
- if metadata_json is not None:
1160
- skill.metadata_json = metadata_json
1161
- if is_active is not None:
1162
- skill.is_active = is_active
1163
-
1164
- await session.commit()
1165
- await session.refresh(skill)
1166
-
1167
- logger.info(f"[DB] Updated user skill: {name}")
1168
- return self._skill_to_dict(skill)
1169
-
1170
- except Exception as e:
1171
- logger.error("Failed to update user skill", name=name, error=str(e))
1172
- return None
1173
-
1174
- async def delete_user_skill(self, name: str) -> bool:
1175
- """Delete a user skill by name."""
1176
- try:
1177
- async with self.get_session() as session:
1178
- stmt = select(UserSkill).where(UserSkill.name == name)
1179
- result = await session.execute(stmt)
1180
- skill = result.scalar_one_or_none()
1181
-
1182
- if skill:
1183
- await session.delete(skill)
1184
- await session.commit()
1185
- logger.info(f"[DB] Deleted user skill: {name}")
1186
- return True
1187
-
1188
- return False
1189
-
1190
- except Exception as e:
1191
- logger.error("Failed to delete user skill", name=name, error=str(e))
1192
- return False
1193
-
1194
- def _skill_to_dict(self, skill: UserSkill) -> Dict[str, Any]:
1195
- """Convert UserSkill model to dictionary."""
1196
- return {
1197
- "id": skill.id,
1198
- "name": skill.name,
1199
- "display_name": skill.display_name,
1200
- "description": skill.description,
1201
- "instructions": skill.instructions,
1202
- "allowed_tools": skill.allowed_tools.split(",") if skill.allowed_tools else [],
1203
- "category": skill.category,
1204
- "icon": skill.icon,
1205
- "color": skill.color,
1206
- "metadata": skill.metadata_json,
1207
- "is_active": skill.is_active,
1208
- "created_by": skill.created_by,
1209
- "created_at": skill.created_at.isoformat() if skill.created_at else None,
1210
- "updated_at": skill.updated_at.isoformat() if skill.updated_at else None
1
+ """Modern async database service with SQLModel and SQLAlchemy 2.0."""
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+ from typing import Dict, Any, List, Optional
5
+ from sqlmodel import SQLModel, select, Session
6
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
7
+ from sqlalchemy.exc import IntegrityError
8
+ from contextlib import asynccontextmanager
9
+
10
+ from core.config import Settings
11
+ from models.database import NodeParameter, Workflow, Execution, APIKey, APIKeyValidation, NodeOutput, ConversationMessage, ToolSchema, UserSkill, ChatMessage
12
+ from models.cache import CacheEntry # SQLite-backed cache for Redis alternative
13
+ from models.auth import User # Import User model to ensure table creation
14
+ from core.logging import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class Database:
20
+ """Async database service with SQLModel."""
21
+
22
+ def __init__(self, settings: Settings):
23
+ self.settings = settings
24
+ self.engine = None
25
+ self.async_session = None
26
+
27
+ async def startup(self):
28
+ """Initialize database connection and create tables."""
29
+ try:
30
+ # Disable verbose database and asyncio logging
31
+ import logging
32
+ logging.getLogger("aiosqlite").setLevel(logging.WARNING)
33
+ logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
34
+ logging.getLogger("sqlalchemy.dialects").setLevel(logging.WARNING)
35
+ logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
36
+
37
+ # Create async engine
38
+ self.engine = create_async_engine(
39
+ self.settings.database_url,
40
+ echo=self.settings.database_echo,
41
+ pool_size=self.settings.database_pool_size,
42
+ max_overflow=self.settings.database_max_overflow,
43
+ future=True
44
+ )
45
+
46
+ # Create session factory
47
+ self.async_session = async_sessionmaker(
48
+ bind=self.engine,
49
+ class_=AsyncSession,
50
+ expire_on_commit=False
51
+ )
52
+
53
+ # Create tables
54
+ async with self.engine.begin() as conn:
55
+ await conn.run_sync(SQLModel.metadata.create_all)
56
+
57
+ logger.info("Database initialized successfully")
58
+
59
+ except Exception as e:
60
+ logger.error("Database startup failed", error=str(e))
61
+ raise
62
+
63
+ async def shutdown(self):
64
+ """Close database connections."""
65
+ if self.engine:
66
+ await self.engine.dispose()
67
+ logger.info("Database connections closed")
68
+
69
+ @asynccontextmanager
70
+ async def get_session(self):
71
+ """Get async database session."""
72
+ if not self.async_session:
73
+ raise RuntimeError("Database not initialized")
74
+
75
+ async with self.async_session() as session:
76
+ try:
77
+ yield session
78
+ except Exception:
79
+ await session.rollback()
80
+ raise
81
+ finally:
82
+ await session.close()
83
+
84
+ # ============================================================================
85
+ # Node Parameters
86
+ # ============================================================================
87
+
88
+ async def save_node_parameters(self, node_id: str, parameters: Dict[str, Any]) -> bool:
89
+ """Save or update node parameters."""
90
+ try:
91
+ async with self.get_session() as session:
92
+ # Try to get existing parameter
93
+ stmt = select(NodeParameter).where(NodeParameter.node_id == node_id)
94
+ result = await session.execute(stmt)
95
+ existing = result.scalar_one_or_none()
96
+
97
+ if existing:
98
+ existing.parameters = parameters
99
+ else:
100
+ existing = NodeParameter(
101
+ node_id=node_id,
102
+ parameters=parameters
103
+ )
104
+ session.add(existing)
105
+
106
+ await session.commit()
107
+ return True
108
+
109
+ except Exception as e:
110
+ logger.error("Failed to save node parameters", node_id=node_id, error=str(e))
111
+ return False
112
+
113
+ async def get_node_parameters(self, node_id: str) -> Optional[Dict[str, Any]]:
114
+ """Get node parameters."""
115
+ try:
116
+ async with self.get_session() as session:
117
+ stmt = select(NodeParameter).where(NodeParameter.node_id == node_id)
118
+ result = await session.execute(stmt)
119
+ parameter = result.scalar_one_or_none()
120
+
121
+ return parameter.parameters if parameter else None
122
+
123
+ except Exception as e:
124
+ logger.error("Failed to get node parameters", node_id=node_id, error=str(e))
125
+ return None
126
+
127
+ async def delete_node_parameters(self, node_id: str) -> bool:
128
+ """Delete node parameters."""
129
+ try:
130
+ async with self.get_session() as session:
131
+ stmt = select(NodeParameter).where(NodeParameter.node_id == node_id)
132
+ result = await session.execute(stmt)
133
+ parameter = result.scalar_one_or_none()
134
+
135
+ if parameter:
136
+ await session.delete(parameter)
137
+ await session.commit()
138
+
139
+ return True
140
+
141
+ except Exception as e:
142
+ logger.error("Failed to delete node parameters", node_id=node_id, error=str(e))
143
+ return False
144
+
145
+ # ============================================================================
146
+ # Workflows
147
+ # ============================================================================
148
+
149
+ async def save_workflow(self, workflow_id: str, name: str, data: Dict[str, Any],
150
+ description: Optional[str] = None) -> bool:
151
+ """Save or update workflow."""
152
+ try:
153
+ async with self.get_session() as session:
154
+ stmt = select(Workflow).where(Workflow.id == workflow_id)
155
+ result = await session.execute(stmt)
156
+ existing = result.scalar_one_or_none()
157
+
158
+ if existing:
159
+ existing.name = name
160
+ existing.description = description
161
+ existing.data = data
162
+ else:
163
+ existing = Workflow(
164
+ id=workflow_id,
165
+ name=name,
166
+ description=description,
167
+ data=data
168
+ )
169
+ session.add(existing)
170
+
171
+ await session.commit()
172
+ return True
173
+
174
+ except Exception as e:
175
+ logger.error("Failed to save workflow", workflow_id=workflow_id, error=str(e))
176
+ return False
177
+
178
+ async def get_workflow(self, workflow_id: str) -> Optional[Workflow]:
179
+ """Get workflow by ID."""
180
+ try:
181
+ async with self.get_session() as session:
182
+ stmt = select(Workflow).where(Workflow.id == workflow_id)
183
+ result = await session.execute(stmt)
184
+ return result.scalar_one_or_none()
185
+
186
+ except Exception as e:
187
+ logger.error("Failed to get workflow", workflow_id=workflow_id, error=str(e))
188
+ return None
189
+
190
+ async def get_all_workflows(self) -> List[Workflow]:
191
+ """Get all workflows."""
192
+ try:
193
+ async with self.get_session() as session:
194
+ stmt = select(Workflow).order_by(Workflow.updated_at.desc())
195
+ result = await session.execute(stmt)
196
+ return result.scalars().all()
197
+
198
+ except Exception as e:
199
+ logger.error("Failed to get all workflows", error=str(e))
200
+ return []
201
+
202
+ async def delete_workflow(self, workflow_id: str) -> bool:
203
+ """Delete workflow."""
204
+ try:
205
+ async with self.get_session() as session:
206
+ stmt = select(Workflow).where(Workflow.id == workflow_id)
207
+ result = await session.execute(stmt)
208
+ workflow = result.scalar_one_or_none()
209
+
210
+ if workflow:
211
+ await session.delete(workflow)
212
+ await session.commit()
213
+
214
+ return True
215
+
216
+ except Exception as e:
217
+ logger.error("Failed to delete workflow", workflow_id=workflow_id, error=str(e))
218
+ return False
219
+
220
+ # ============================================================================
221
+ # Executions
222
+ # ============================================================================
223
+
224
+ async def save_execution(self, execution_id: str, workflow_id: str, node_id: str,
225
+ status: str, result: Optional[Dict[str, Any]] = None,
226
+ error: Optional[str] = None, execution_time: Optional[float] = None) -> bool:
227
+ """Save execution result."""
228
+ try:
229
+ async with self.get_session() as session:
230
+ execution = Execution(
231
+ id=execution_id,
232
+ workflow_id=workflow_id,
233
+ node_id=node_id,
234
+ status=status,
235
+ result=result,
236
+ error=error,
237
+ execution_time=execution_time
238
+ )
239
+ session.add(execution)
240
+ await session.commit()
241
+ return True
242
+
243
+ except Exception as e:
244
+ logger.error("Failed to save execution", execution_id=execution_id, error=str(e))
245
+ return False
246
+
247
+ async def get_execution(self, execution_id: str) -> Optional[Execution]:
248
+ """Get execution by ID."""
249
+ try:
250
+ async with self.get_session() as session:
251
+ stmt = select(Execution).where(Execution.id == execution_id)
252
+ result = await session.execute(stmt)
253
+ return result.scalar_one_or_none()
254
+
255
+ except Exception as e:
256
+ logger.error("Failed to get execution", execution_id=execution_id, error=str(e))
257
+ return None
258
+
259
+ # ============================================================================
260
+ # API Keys
261
+ # ============================================================================
262
+
263
+ async def save_api_key(self, key_id: str, provider: str, session_id: str,
264
+ key_encrypted: str, key_hash: str,
265
+ models: Optional[List[str]] = None) -> bool:
266
+ """Save encrypted API key."""
267
+ logger.info(f"Database save_api_key called with key_id: {key_id}, provider: {provider}")
268
+
269
+ try:
270
+ async with self.get_session() as session:
271
+ api_key = APIKey(
272
+ id=key_id,
273
+ provider=provider,
274
+ session_id=session_id,
275
+ key_encrypted=key_encrypted,
276
+ key_hash=key_hash,
277
+ models={"models": models} if models else None,
278
+ last_validated=datetime.now(timezone.utc)
279
+ )
280
+ session.add(api_key)
281
+ await session.commit()
282
+ logger.info(f"Successfully saved new API key: {key_id}")
283
+ return True
284
+
285
+ except IntegrityError as e:
286
+ logger.info(f"API key {key_id} already exists, attempting update. Error: {str(e)}")
287
+ # Key already exists, update it
288
+ try:
289
+ async with self.get_session() as session:
290
+ stmt = select(APIKey).where(APIKey.id == key_id)
291
+ result = await session.execute(stmt)
292
+ existing = result.scalar_one_or_none()
293
+
294
+ if existing:
295
+ logger.info(f"Found existing API key {key_id}, updating...")
296
+ existing.key_encrypted = key_encrypted
297
+ existing.key_hash = key_hash
298
+ existing.models = {"models": models} if models else None
299
+ existing.last_validated = datetime.now(timezone.utc)
300
+ await session.commit()
301
+ logger.info(f"Successfully updated API key: {key_id}")
302
+ return True
303
+ else:
304
+ logger.error(f"Could not find existing API key {key_id} for update")
305
+ return False
306
+ except Exception as update_e:
307
+ logger.error(f"Failed to update API key {key_id}", error=str(update_e))
308
+ return False
309
+
310
+ except Exception as e:
311
+ logger.error("Failed to save API key", provider=provider, error=str(e))
312
+ import traceback
313
+ logger.error("Full traceback", traceback=traceback.format_exc())
314
+ return False
315
+
316
+ async def get_api_key(self, key_id: str) -> Optional[APIKey]:
317
+ """Get API key by ID."""
318
+ try:
319
+ async with self.get_session() as session:
320
+ stmt = select(APIKey).where(APIKey.id == key_id)
321
+ result = await session.execute(stmt)
322
+ return result.scalar_one_or_none()
323
+
324
+ except Exception as e:
325
+ logger.error("Failed to get API key", key_id=key_id, error=str(e))
326
+ return None
327
+
328
+ async def get_api_key_by_provider(self, provider: str, session_id: str = "default") -> Optional[APIKey]:
329
+ """Get API key by provider and session."""
330
+ try:
331
+ async with self.get_session() as session:
332
+ stmt = select(APIKey).where(
333
+ APIKey.provider == provider,
334
+ APIKey.session_id == session_id,
335
+ APIKey.is_valid == True
336
+ )
337
+ result = await session.execute(stmt)
338
+ return result.scalar_one_or_none()
339
+
340
+ except Exception as e:
341
+ logger.error("Failed to get API key by provider", provider=provider, error=str(e))
342
+ return None
343
+
344
+ async def delete_api_key(self, provider: str, session_id: str = "default") -> bool:
345
+ """Delete API key."""
346
+ try:
347
+ async with self.get_session() as session:
348
+ stmt = select(APIKey).where(
349
+ APIKey.provider == provider,
350
+ APIKey.session_id == session_id
351
+ )
352
+ result = await session.execute(stmt)
353
+ api_key = result.scalar_one_or_none()
354
+
355
+ if api_key:
356
+ await session.delete(api_key)
357
+ await session.commit()
358
+ logger.debug("API key deleted", provider=provider, session_id=session_id)
359
+
360
+ return True
361
+
362
+ except Exception as e:
363
+ logger.error("Failed to delete API key", provider=provider, error=str(e))
364
+ return False
365
+
366
+ # ============================================================================
367
+ # API Key Validation Cache
368
+ # ============================================================================
369
+
370
+ async def save_api_key_validation(self, key_hash: str) -> bool:
371
+ """Save API key validation status."""
372
+ try:
373
+ async with self.get_session() as session:
374
+ validation = APIKeyValidation(
375
+ key_hash=key_hash,
376
+ validated=True
377
+ )
378
+ session.add(validation)
379
+ await session.commit()
380
+ return True
381
+
382
+ except IntegrityError:
383
+ # Already exists, update timestamp
384
+ async with self.get_session() as session:
385
+ stmt = select(APIKeyValidation).where(APIKeyValidation.key_hash == key_hash)
386
+ result = await session.execute(stmt)
387
+ existing = result.scalar_one_or_none()
388
+
389
+ if existing:
390
+ existing.timestamp = datetime.now(timezone.utc)
391
+ await session.commit()
392
+ return True
393
+ return False
394
+
395
+ except Exception as e:
396
+ logger.error("Failed to save API key validation", key_hash=key_hash, error=str(e))
397
+ return False
398
+
399
+ async def is_api_key_validated(self, key_hash: str) -> bool:
400
+ """Check if API key is validated."""
401
+ try:
402
+ async with self.get_session() as session:
403
+ stmt = select(APIKeyValidation).where(APIKeyValidation.key_hash == key_hash)
404
+ result = await session.execute(stmt)
405
+ validation = result.scalar_one_or_none()
406
+ return validation is not None and validation.validated
407
+
408
+ except Exception as e:
409
+ logger.error("Failed to check API key validation", key_hash=key_hash, error=str(e))
410
+ return False
411
+
412
+ # ============================================================================
413
+ # Node Outputs
414
+ # ============================================================================
415
+
416
+ async def save_node_output(self, node_id: str, session_id: str, output_name: str,
417
+ data: Dict[str, Any]) -> bool:
418
+ """Save or update node output."""
419
+ try:
420
+ async with self.get_session() as session:
421
+ # Try to get existing output
422
+ stmt = select(NodeOutput).where(
423
+ NodeOutput.node_id == node_id,
424
+ NodeOutput.session_id == session_id,
425
+ NodeOutput.output_name == output_name
426
+ )
427
+ result = await session.execute(stmt)
428
+ existing = result.scalar_one_or_none()
429
+
430
+ action = "updated"
431
+ if existing:
432
+ existing.data = data
433
+ else:
434
+ action = "inserted"
435
+ existing = NodeOutput(
436
+ node_id=node_id,
437
+ session_id=session_id,
438
+ output_name=output_name,
439
+ data=data
440
+ )
441
+ session.add(existing)
442
+
443
+ await session.commit()
444
+ logger.info("[DB] Node output saved", action=action, node_id=node_id, session_id=session_id, output_name=output_name)
445
+ return True
446
+
447
+ except Exception as e:
448
+ logger.error("Failed to save node output", node_id=node_id, error=str(e))
449
+ import traceback
450
+ traceback.print_exc()
451
+ return False
452
+
453
+ async def get_node_output(self, node_id: str, session_id: str = "default",
454
+ output_name: str = "output_0") -> Optional[Dict[str, Any]]:
455
+ """Get node output data."""
456
+ try:
457
+ async with self.get_session() as session:
458
+ stmt = select(NodeOutput).where(
459
+ NodeOutput.node_id == node_id,
460
+ NodeOutput.session_id == session_id,
461
+ NodeOutput.output_name == output_name
462
+ )
463
+ result = await session.execute(stmt)
464
+ output = result.scalar_one_or_none()
465
+
466
+ return output.data if output else None
467
+
468
+ except Exception as e:
469
+ logger.error("Failed to get node output", node_id=node_id, error=str(e))
470
+ return None
471
+
472
+ async def delete_node_output(self, node_id: str) -> int:
473
+ """Delete all outputs for a node (any session). Returns count deleted."""
474
+ try:
475
+ async with self.get_session() as session:
476
+ stmt = select(NodeOutput).where(NodeOutput.node_id == node_id)
477
+ result = await session.execute(stmt)
478
+ outputs = result.scalars().all()
479
+
480
+ count = len(outputs)
481
+ for output in outputs:
482
+ await session.delete(output)
483
+
484
+ await session.commit()
485
+ logger.info("Deleted node outputs", node_id=node_id, count=count)
486
+ return count
487
+
488
+ except Exception as e:
489
+ logger.error("Failed to delete node output", node_id=node_id, error=str(e))
490
+ return 0
491
+
492
+ async def clear_session_outputs(self, session_id: str = "default") -> int:
493
+ """Clear all outputs for a session. Returns count deleted."""
494
+ try:
495
+ async with self.get_session() as session:
496
+ stmt = select(NodeOutput).where(NodeOutput.session_id == session_id)
497
+ result = await session.execute(stmt)
498
+ outputs = result.scalars().all()
499
+
500
+ count = len(outputs)
501
+ for output in outputs:
502
+ await session.delete(output)
503
+
504
+ await session.commit()
505
+ logger.info("Cleared session outputs", session_id=session_id, count=count)
506
+ return count
507
+
508
+ except Exception as e:
509
+ logger.error("Failed to clear session outputs", session_id=session_id, error=str(e))
510
+ return 0
511
+
512
+ # ============================================================================
513
+ # Conversation Messages (AI Memory)
514
+ # ============================================================================
515
+
516
+ async def add_conversation_message(self, session_id: str, role: str, content: str) -> bool:
517
+ """Add a message to conversation history."""
518
+ try:
519
+ async with self.get_session() as session:
520
+ message = ConversationMessage(
521
+ session_id=session_id,
522
+ role=role,
523
+ content=content
524
+ )
525
+ session.add(message)
526
+ await session.commit()
527
+ logger.info(f"[Memory] Added {role} message to session '{session_id}'")
528
+ return True
529
+
530
+ except Exception as e:
531
+ logger.error("Failed to add conversation message", session_id=session_id, error=str(e))
532
+ return False
533
+
534
+ async def get_conversation_messages(self, session_id: str, window_size: Optional[int] = None) -> List[Dict[str, Any]]:
535
+ """Get conversation messages, optionally limited to last N."""
536
+ try:
537
+ async with self.get_session() as session:
538
+ stmt = select(ConversationMessage).where(
539
+ ConversationMessage.session_id == session_id
540
+ ).order_by(ConversationMessage.created_at.asc())
541
+
542
+ result = await session.execute(stmt)
543
+ messages = result.scalars().all()
544
+
545
+ # Apply window limit if specified
546
+ if window_size and window_size > 0:
547
+ messages = messages[-window_size:]
548
+
549
+ return [
550
+ {
551
+ "role": m.role,
552
+ "content": m.content,
553
+ "timestamp": m.created_at.isoformat()
554
+ }
555
+ for m in messages
556
+ ]
557
+
558
+ except Exception as e:
559
+ logger.error("Failed to get conversation messages", session_id=session_id, error=str(e))
560
+ return []
561
+
562
+ async def clear_conversation(self, session_id: str) -> int:
563
+ """Clear all messages in a conversation session. Returns count deleted."""
564
+ try:
565
+ async with self.get_session() as session:
566
+ stmt = select(ConversationMessage).where(
567
+ ConversationMessage.session_id == session_id
568
+ )
569
+ result = await session.execute(stmt)
570
+ messages = result.scalars().all()
571
+
572
+ count = len(messages)
573
+ for message in messages:
574
+ await session.delete(message)
575
+
576
+ await session.commit()
577
+ logger.info(f"[Memory] Cleared {count} messages from session '{session_id}'")
578
+ return count
579
+
580
+ except Exception as e:
581
+ logger.error("Failed to clear conversation", session_id=session_id, error=str(e))
582
+ return 0
583
+
584
+ async def get_all_conversation_sessions(self) -> List[Dict[str, Any]]:
585
+ """Get info about all conversation sessions."""
586
+ try:
587
+ async with self.get_session() as session:
588
+ # Get distinct session IDs with message count
589
+ from sqlalchemy import func as sql_func
590
+ stmt = select(
591
+ ConversationMessage.session_id,
592
+ sql_func.count(ConversationMessage.id).label('message_count'),
593
+ sql_func.min(ConversationMessage.created_at).label('created_at')
594
+ ).group_by(ConversationMessage.session_id)
595
+
596
+ result = await session.execute(stmt)
597
+ rows = result.all()
598
+
599
+ return [
600
+ {
601
+ "session_id": row.session_id,
602
+ "message_count": row.message_count,
603
+ "created_at": row.created_at.isoformat() if row.created_at else None
604
+ }
605
+ for row in rows
606
+ ]
607
+
608
+ except Exception as e:
609
+ logger.error("Failed to get conversation sessions", error=str(e))
610
+ return []
611
+
612
+ # ============================================================================
613
+ # Chat Messages (Console Panel persistence)
614
+ # ============================================================================
615
+
616
+ async def add_chat_message(self, session_id: str, role: str, message: str) -> bool:
617
+ """Add a chat message to the console panel history."""
618
+ try:
619
+ async with self.get_session() as session:
620
+ chat_msg = ChatMessage(
621
+ session_id=session_id,
622
+ role=role,
623
+ message=message
624
+ )
625
+ session.add(chat_msg)
626
+ await session.commit()
627
+ logger.debug(f"[Chat] Added {role} message to session '{session_id}'")
628
+ return True
629
+
630
+ except Exception as e:
631
+ logger.error("Failed to add chat message", session_id=session_id, error=str(e))
632
+ return False
633
+
634
+ async def get_chat_messages(self, session_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
635
+ """Get chat messages for a session, optionally limited to last N."""
636
+ try:
637
+ async with self.get_session() as session:
638
+ stmt = select(ChatMessage).where(
639
+ ChatMessage.session_id == session_id
640
+ ).order_by(ChatMessage.created_at.asc())
641
+
642
+ result = await session.execute(stmt)
643
+ messages = result.scalars().all()
644
+
645
+ # Apply limit if specified
646
+ if limit and limit > 0:
647
+ messages = messages[-limit:]
648
+
649
+ return [
650
+ {
651
+ "role": m.role,
652
+ "message": m.message,
653
+ "timestamp": m.created_at.isoformat()
654
+ }
655
+ for m in messages
656
+ ]
657
+
658
+ except Exception as e:
659
+ logger.error("Failed to get chat messages", session_id=session_id, error=str(e))
660
+ return []
661
+
662
+ async def clear_chat_messages(self, session_id: str) -> int:
663
+ """Clear all chat messages for a session. Returns count deleted."""
664
+ try:
665
+ async with self.get_session() as session:
666
+ stmt = select(ChatMessage).where(
667
+ ChatMessage.session_id == session_id
668
+ )
669
+ result = await session.execute(stmt)
670
+ messages = result.scalars().all()
671
+
672
+ count = len(messages)
673
+ for message in messages:
674
+ await session.delete(message)
675
+
676
+ await session.commit()
677
+ logger.info(f"[Chat] Cleared {count} messages from session '{session_id}'")
678
+ return count
679
+
680
+ except Exception as e:
681
+ logger.error("Failed to clear chat messages", session_id=session_id, error=str(e))
682
+ return 0
683
+
684
+ async def get_chat_sessions(self) -> List[Dict[str, Any]]:
685
+ """Get list of all chat sessions with message counts."""
686
+ try:
687
+ async with self.get_session() as session:
688
+ from sqlalchemy import func as sa_func
689
+ stmt = select(
690
+ ChatMessage.session_id,
691
+ sa_func.count(ChatMessage.id).label('message_count'),
692
+ sa_func.max(ChatMessage.created_at).label('last_message_at')
693
+ ).group_by(ChatMessage.session_id).order_by(sa_func.max(ChatMessage.created_at).desc())
694
+
695
+ result = await session.execute(stmt)
696
+ rows = result.all()
697
+
698
+ return [
699
+ {
700
+ "session_id": row.session_id,
701
+ "message_count": row.message_count,
702
+ "last_message_at": row.last_message_at.isoformat() if row.last_message_at else None
703
+ }
704
+ for row in rows
705
+ ]
706
+
707
+ except Exception as e:
708
+ logger.error("Failed to get chat sessions", error=str(e))
709
+ return []
710
+
711
+ # ============================================================================
712
+ # Console Logs (Console Panel persistence)
713
+ # ============================================================================
714
+
715
+ async def add_console_log(self, log_data: Dict[str, Any]) -> bool:
716
+ """Add a console log entry to the database."""
717
+ from models.database import ConsoleLog
718
+ import json
719
+
720
+ try:
721
+ async with self.get_session() as session:
722
+ console_log = ConsoleLog(
723
+ node_id=log_data.get("node_id", ""),
724
+ label=log_data.get("label", ""),
725
+ workflow_id=log_data.get("workflow_id"),
726
+ data=json.dumps(log_data.get("data", {})),
727
+ formatted=log_data.get("formatted", ""),
728
+ format=log_data.get("format", "text"),
729
+ source_node_id=log_data.get("source_node_id"),
730
+ source_node_type=log_data.get("source_node_type"),
731
+ source_node_label=log_data.get("source_node_label"),
732
+ )
733
+ session.add(console_log)
734
+ await session.commit()
735
+ logger.debug(f"[Console] Added log from node '{log_data.get('node_id')}'")
736
+ return True
737
+
738
+ except Exception as e:
739
+ logger.error("Failed to add console log", error=str(e))
740
+ return False
741
+
742
+ async def get_console_logs(self, limit: int = 100) -> List[Dict[str, Any]]:
743
+ """Get console logs, optionally limited to last N entries."""
744
+ from models.database import ConsoleLog
745
+ import json
746
+
747
+ try:
748
+ async with self.get_session() as session:
749
+ stmt = select(ConsoleLog).order_by(ConsoleLog.created_at.desc()).limit(limit)
750
+
751
+ result = await session.execute(stmt)
752
+ logs = result.scalars().all()
753
+
754
+ # Return in chronological order (oldest first)
755
+ return [
756
+ {
757
+ "node_id": log.node_id,
758
+ "label": log.label,
759
+ "workflow_id": log.workflow_id,
760
+ "data": json.loads(log.data) if log.data else {},
761
+ "formatted": log.formatted,
762
+ "format": log.format,
763
+ "source_node_id": log.source_node_id,
764
+ "source_node_type": log.source_node_type,
765
+ "source_node_label": log.source_node_label,
766
+ "timestamp": log.created_at.isoformat(),
767
+ }
768
+ for log in reversed(logs)
769
+ ]
770
+
771
+ except Exception as e:
772
+ logger.error("Failed to get console logs", error=str(e))
773
+ return []
774
+
775
+ async def clear_console_logs(self) -> int:
776
+ """Clear all console logs. Returns count deleted."""
777
+ from models.database import ConsoleLog
778
+
779
+ try:
780
+ async with self.get_session() as session:
781
+ stmt = select(ConsoleLog)
782
+ result = await session.execute(stmt)
783
+ logs = result.scalars().all()
784
+
785
+ count = len(logs)
786
+ for log in logs:
787
+ await session.delete(log)
788
+
789
+ await session.commit()
790
+ logger.info(f"[Console] Cleared {count} console logs")
791
+ return count
792
+
793
+ except Exception as e:
794
+ logger.error("Failed to clear console logs", error=str(e))
795
+ return 0
796
+
797
+ # ============================================================================
798
+ # Cache Entries (SQLite-backed Redis alternative)
799
+ # ============================================================================
800
+
801
+ async def get_cache_entry(self, key: str) -> Optional[str]:
802
+ """Get cache value by key. Returns None if expired or not found."""
803
+ import time
804
+ try:
805
+ async with self.get_session() as session:
806
+ stmt = select(CacheEntry).where(CacheEntry.key == key)
807
+ result = await session.execute(stmt)
808
+ entry = result.scalar_one_or_none()
809
+
810
+ if not entry:
811
+ return None
812
+
813
+ # Check expiration
814
+ if entry.expires_at and entry.expires_at < time.time():
815
+ # Entry expired - delete it
816
+ await session.delete(entry)
817
+ await session.commit()
818
+ return None
819
+
820
+ return entry.value
821
+
822
+ except Exception as e:
823
+ logger.error("Failed to get cache entry", key=key, error=str(e))
824
+ return None
825
+
826
+ async def set_cache_entry(self, key: str, value: str, ttl: Optional[int] = None) -> bool:
827
+ """Set cache value with optional TTL in seconds."""
828
+ import time
829
+ try:
830
+ expires_at = time.time() + ttl if ttl else None
831
+
832
+ async with self.get_session() as session:
833
+ # Try to get existing entry
834
+ stmt = select(CacheEntry).where(CacheEntry.key == key)
835
+ result = await session.execute(stmt)
836
+ existing = result.scalar_one_or_none()
837
+
838
+ if existing:
839
+ existing.value = value
840
+ existing.expires_at = expires_at
841
+ existing.created_at = time.time()
842
+ else:
843
+ entry = CacheEntry(
844
+ key=key,
845
+ value=value,
846
+ expires_at=expires_at,
847
+ created_at=time.time()
848
+ )
849
+ session.add(entry)
850
+
851
+ await session.commit()
852
+ return True
853
+
854
+ except Exception as e:
855
+ logger.error("Failed to set cache entry", key=key, error=str(e))
856
+ return False
857
+
858
+ async def delete_cache_entry(self, key: str) -> bool:
859
+ """Delete cache entry by key."""
860
+ try:
861
+ async with self.get_session() as session:
862
+ stmt = select(CacheEntry).where(CacheEntry.key == key)
863
+ result = await session.execute(stmt)
864
+ entry = result.scalar_one_or_none()
865
+
866
+ if entry:
867
+ await session.delete(entry)
868
+ await session.commit()
869
+
870
+ return True
871
+
872
+ except Exception as e:
873
+ logger.error("Failed to delete cache entry", key=key, error=str(e))
874
+ return False
875
+
876
+ async def delete_cache_pattern(self, pattern: str) -> int:
877
+ """Delete cache entries matching pattern (uses SQL LIKE)."""
878
+ try:
879
+ # Convert glob pattern to SQL LIKE pattern
880
+ sql_pattern = pattern.replace("*", "%")
881
+
882
+ async with self.get_session() as session:
883
+ stmt = select(CacheEntry).where(CacheEntry.key.like(sql_pattern))
884
+ result = await session.execute(stmt)
885
+ entries = result.scalars().all()
886
+
887
+ count = len(entries)
888
+ for entry in entries:
889
+ await session.delete(entry)
890
+
891
+ await session.commit()
892
+ logger.debug("Deleted cache entries", pattern=pattern, count=count)
893
+ return count
894
+
895
+ except Exception as e:
896
+ logger.error("Failed to delete cache pattern", pattern=pattern, error=str(e))
897
+ return 0
898
+
899
+ async def cleanup_expired_cache(self) -> int:
900
+ """Remove all expired cache entries. Returns count deleted."""
901
+ import time
902
+ try:
903
+ async with self.get_session() as session:
904
+ stmt = select(CacheEntry).where(
905
+ CacheEntry.expires_at.isnot(None),
906
+ CacheEntry.expires_at < time.time()
907
+ )
908
+ result = await session.execute(stmt)
909
+ entries = result.scalars().all()
910
+
911
+ count = len(entries)
912
+ for entry in entries:
913
+ await session.delete(entry)
914
+
915
+ await session.commit()
916
+ if count > 0:
917
+ logger.info("Cleaned up expired cache entries", count=count)
918
+ return count
919
+
920
+ except Exception as e:
921
+ logger.error("Failed to cleanup expired cache", error=str(e))
922
+ return 0
923
+
924
+ async def cache_exists(self, key: str) -> bool:
925
+ """Check if cache key exists and is not expired."""
926
+ import time
927
+ try:
928
+ async with self.get_session() as session:
929
+ stmt = select(CacheEntry).where(CacheEntry.key == key)
930
+ result = await session.execute(stmt)
931
+ entry = result.scalar_one_or_none()
932
+
933
+ if not entry:
934
+ return False
935
+
936
+ # Check expiration
937
+ if entry.expires_at and entry.expires_at < time.time():
938
+ return False
939
+
940
+ return True
941
+
942
+ except Exception as e:
943
+ logger.error("Failed to check cache exists", key=key, error=str(e))
944
+ return False
945
+
946
+ # ============================================================================
947
+ # Tool Schemas (Source of truth for tool node configurations)
948
+ # ============================================================================
949
+
950
+ async def save_tool_schema(self, node_id: str, tool_name: str, tool_description: str,
951
+ schema_config: Dict[str, Any],
952
+ connected_services: Optional[Dict[str, Any]] = None) -> bool:
953
+ """Save or update tool schema for a node."""
954
+ try:
955
+ async with self.get_session() as session:
956
+ # Try to get existing schema
957
+ stmt = select(ToolSchema).where(ToolSchema.node_id == node_id)
958
+ result = await session.execute(stmt)
959
+ existing = result.scalar_one_or_none()
960
+
961
+ action = "updated"
962
+ if existing:
963
+ existing.tool_name = tool_name
964
+ existing.tool_description = tool_description
965
+ existing.schema_config = schema_config
966
+ existing.connected_services = connected_services
967
+ else:
968
+ action = "created"
969
+ existing = ToolSchema(
970
+ node_id=node_id,
971
+ tool_name=tool_name,
972
+ tool_description=tool_description,
973
+ schema_config=schema_config,
974
+ connected_services=connected_services
975
+ )
976
+ session.add(existing)
977
+
978
+ await session.commit()
979
+ logger.info(f"[DB] Tool schema {action}", node_id=node_id, tool_name=tool_name)
980
+ return True
981
+
982
+ except Exception as e:
983
+ logger.error("Failed to save tool schema", node_id=node_id, error=str(e))
984
+ return False
985
+
986
+ async def get_tool_schema(self, node_id: str) -> Optional[Dict[str, Any]]:
987
+ """Get tool schema for a node."""
988
+ try:
989
+ async with self.get_session() as session:
990
+ stmt = select(ToolSchema).where(ToolSchema.node_id == node_id)
991
+ result = await session.execute(stmt)
992
+ schema = result.scalar_one_or_none()
993
+
994
+ if not schema:
995
+ return None
996
+
997
+ return {
998
+ "node_id": schema.node_id,
999
+ "tool_name": schema.tool_name,
1000
+ "tool_description": schema.tool_description,
1001
+ "schema_config": schema.schema_config,
1002
+ "connected_services": schema.connected_services,
1003
+ "created_at": schema.created_at.isoformat() if schema.created_at else None,
1004
+ "updated_at": schema.updated_at.isoformat() if schema.updated_at else None
1005
+ }
1006
+
1007
+ except Exception as e:
1008
+ logger.error("Failed to get tool schema", node_id=node_id, error=str(e))
1009
+ return None
1010
+
1011
+ async def delete_tool_schema(self, node_id: str) -> bool:
1012
+ """Delete tool schema for a node."""
1013
+ try:
1014
+ async with self.get_session() as session:
1015
+ stmt = select(ToolSchema).where(ToolSchema.node_id == node_id)
1016
+ result = await session.execute(stmt)
1017
+ schema = result.scalar_one_or_none()
1018
+
1019
+ if schema:
1020
+ await session.delete(schema)
1021
+ await session.commit()
1022
+ logger.info("[DB] Tool schema deleted", node_id=node_id)
1023
+
1024
+ return True
1025
+
1026
+ except Exception as e:
1027
+ logger.error("Failed to delete tool schema", node_id=node_id, error=str(e))
1028
+ return False
1029
+
1030
+ async def get_all_tool_schemas(self) -> List[Dict[str, Any]]:
1031
+ """Get all tool schemas."""
1032
+ try:
1033
+ async with self.get_session() as session:
1034
+ stmt = select(ToolSchema).order_by(ToolSchema.updated_at.desc())
1035
+ result = await session.execute(stmt)
1036
+ schemas = result.scalars().all()
1037
+
1038
+ return [
1039
+ {
1040
+ "node_id": s.node_id,
1041
+ "tool_name": s.tool_name,
1042
+ "tool_description": s.tool_description,
1043
+ "schema_config": s.schema_config,
1044
+ "connected_services": s.connected_services,
1045
+ "updated_at": s.updated_at.isoformat() if s.updated_at else None
1046
+ }
1047
+ for s in schemas
1048
+ ]
1049
+
1050
+ except Exception as e:
1051
+ logger.error("Failed to get all tool schemas", error=str(e))
1052
+ return []
1053
+
1054
+ # ============================================================================
1055
+ # Android Relay Session Persistence
1056
+ # ============================================================================
1057
+
1058
+ async def save_android_relay_session(
1059
+ self,
1060
+ relay_url: str,
1061
+ api_key: str,
1062
+ device_id: str,
1063
+ device_name: Optional[str] = None,
1064
+ session_token: Optional[str] = None
1065
+ ) -> bool:
1066
+ """Save Android relay pairing session for auto-reconnect on server restart.
1067
+
1068
+ Args:
1069
+ relay_url: WebSocket relay URL
1070
+ api_key: API key for relay authentication
1071
+ device_id: Paired Android device ID
1072
+ device_name: Paired device name
1073
+ session_token: Relay session token
1074
+ """
1075
+ import json
1076
+ try:
1077
+ session_data = json.dumps({
1078
+ "relay_url": relay_url,
1079
+ "api_key": api_key,
1080
+ "device_id": device_id,
1081
+ "device_name": device_name,
1082
+ "session_token": session_token
1083
+ })
1084
+ # No TTL - session persists until explicitly cleared
1085
+ return await self.set_cache_entry("android_relay_session", session_data)
1086
+ except Exception as e:
1087
+ logger.error("Failed to save Android relay session", error=str(e))
1088
+ return False
1089
+
1090
+ async def get_android_relay_session(self) -> Optional[Dict[str, Any]]:
1091
+ """Get stored Android relay session for auto-reconnect.
1092
+
1093
+ Returns:
1094
+ Session data dict or None if not found
1095
+ """
1096
+ import json
1097
+ try:
1098
+ value = await self.get_cache_entry("android_relay_session")
1099
+ if value:
1100
+ return json.loads(value)
1101
+ return None
1102
+ except Exception as e:
1103
+ logger.error("Failed to get Android relay session", error=str(e))
1104
+ return None
1105
+
1106
+ async def clear_android_relay_session(self) -> bool:
1107
+ """Clear stored Android relay session (on explicit disconnect)."""
1108
+ try:
1109
+ return await self.delete_cache_entry("android_relay_session")
1110
+ except Exception as e:
1111
+ logger.error("Failed to clear Android relay session", error=str(e))
1112
+ return False
1113
+
1114
+ # ============================================================================
1115
+ # User Skills (Custom skills for Zeenie)
1116
+ # ============================================================================
1117
+
1118
+ async def create_user_skill(
1119
+ self,
1120
+ name: str,
1121
+ display_name: str,
1122
+ description: str,
1123
+ instructions: str,
1124
+ allowed_tools: Optional[str] = None,
1125
+ category: str = "custom",
1126
+ icon: str = "star",
1127
+ color: str = "#6366F1",
1128
+ metadata_json: Optional[Dict[str, Any]] = None,
1129
+ created_by: Optional[int] = None
1130
+ ) -> Optional[Dict[str, Any]]:
1131
+ """Create a new user skill."""
1132
+ try:
1133
+ async with self.get_session() as session:
1134
+ skill = UserSkill(
1135
+ name=name,
1136
+ display_name=display_name,
1137
+ description=description,
1138
+ instructions=instructions,
1139
+ allowed_tools=allowed_tools,
1140
+ category=category,
1141
+ icon=icon,
1142
+ color=color,
1143
+ metadata_json=metadata_json,
1144
+ created_by=created_by
1145
+ )
1146
+ session.add(skill)
1147
+ await session.commit()
1148
+ await session.refresh(skill)
1149
+
1150
+ logger.info(f"[DB] Created user skill: {name}")
1151
+ return self._skill_to_dict(skill)
1152
+
1153
+ except IntegrityError:
1154
+ logger.error(f"User skill with name '{name}' already exists")
1155
+ return None
1156
+ except Exception as e:
1157
+ logger.error("Failed to create user skill", name=name, error=str(e))
1158
+ return None
1159
+
1160
+ async def get_user_skill(self, name: str) -> Optional[Dict[str, Any]]:
1161
+ """Get user skill by name."""
1162
+ try:
1163
+ async with self.get_session() as session:
1164
+ stmt = select(UserSkill).where(UserSkill.name == name)
1165
+ result = await session.execute(stmt)
1166
+ skill = result.scalar_one_or_none()
1167
+
1168
+ return self._skill_to_dict(skill) if skill else None
1169
+
1170
+ except Exception as e:
1171
+ logger.error("Failed to get user skill", name=name, error=str(e))
1172
+ return None
1173
+
1174
+ async def get_user_skill_by_id(self, skill_id: int) -> Optional[Dict[str, Any]]:
1175
+ """Get user skill by ID."""
1176
+ try:
1177
+ async with self.get_session() as session:
1178
+ stmt = select(UserSkill).where(UserSkill.id == skill_id)
1179
+ result = await session.execute(stmt)
1180
+ skill = result.scalar_one_or_none()
1181
+
1182
+ return self._skill_to_dict(skill) if skill else None
1183
+
1184
+ except Exception as e:
1185
+ logger.error("Failed to get user skill by id", skill_id=skill_id, error=str(e))
1186
+ return None
1187
+
1188
+ async def get_all_user_skills(self, active_only: bool = True) -> List[Dict[str, Any]]:
1189
+ """Get all user skills, optionally filtered by active status."""
1190
+ try:
1191
+ async with self.get_session() as session:
1192
+ if active_only:
1193
+ stmt = select(UserSkill).where(UserSkill.is_active == True).order_by(UserSkill.display_name)
1194
+ else:
1195
+ stmt = select(UserSkill).order_by(UserSkill.display_name)
1196
+
1197
+ result = await session.execute(stmt)
1198
+ skills = result.scalars().all()
1199
+
1200
+ return [self._skill_to_dict(s) for s in skills]
1201
+
1202
+ except Exception as e:
1203
+ logger.error("Failed to get all user skills", error=str(e))
1204
+ return []
1205
+
1206
+ async def update_user_skill(
1207
+ self,
1208
+ name: str,
1209
+ display_name: Optional[str] = None,
1210
+ description: Optional[str] = None,
1211
+ instructions: Optional[str] = None,
1212
+ allowed_tools: Optional[str] = None,
1213
+ category: Optional[str] = None,
1214
+ icon: Optional[str] = None,
1215
+ color: Optional[str] = None,
1216
+ metadata_json: Optional[Dict[str, Any]] = None,
1217
+ is_active: Optional[bool] = None
1218
+ ) -> Optional[Dict[str, Any]]:
1219
+ """Update an existing user skill."""
1220
+ try:
1221
+ async with self.get_session() as session:
1222
+ stmt = select(UserSkill).where(UserSkill.name == name)
1223
+ result = await session.execute(stmt)
1224
+ skill = result.scalar_one_or_none()
1225
+
1226
+ if not skill:
1227
+ logger.error(f"User skill '{name}' not found for update")
1228
+ return None
1229
+
1230
+ # Update only provided fields
1231
+ if display_name is not None:
1232
+ skill.display_name = display_name
1233
+ if description is not None:
1234
+ skill.description = description
1235
+ if instructions is not None:
1236
+ skill.instructions = instructions
1237
+ if allowed_tools is not None:
1238
+ skill.allowed_tools = allowed_tools
1239
+ if category is not None:
1240
+ skill.category = category
1241
+ if icon is not None:
1242
+ skill.icon = icon
1243
+ if color is not None:
1244
+ skill.color = color
1245
+ if metadata_json is not None:
1246
+ skill.metadata_json = metadata_json
1247
+ if is_active is not None:
1248
+ skill.is_active = is_active
1249
+
1250
+ await session.commit()
1251
+ await session.refresh(skill)
1252
+
1253
+ logger.info(f"[DB] Updated user skill: {name}")
1254
+ return self._skill_to_dict(skill)
1255
+
1256
+ except Exception as e:
1257
+ logger.error("Failed to update user skill", name=name, error=str(e))
1258
+ return None
1259
+
1260
+ async def delete_user_skill(self, name: str) -> bool:
1261
+ """Delete a user skill by name."""
1262
+ try:
1263
+ async with self.get_session() as session:
1264
+ stmt = select(UserSkill).where(UserSkill.name == name)
1265
+ result = await session.execute(stmt)
1266
+ skill = result.scalar_one_or_none()
1267
+
1268
+ if skill:
1269
+ await session.delete(skill)
1270
+ await session.commit()
1271
+ logger.info(f"[DB] Deleted user skill: {name}")
1272
+ return True
1273
+
1274
+ return False
1275
+
1276
+ except Exception as e:
1277
+ logger.error("Failed to delete user skill", name=name, error=str(e))
1278
+ return False
1279
+
1280
+ def _skill_to_dict(self, skill: UserSkill) -> Dict[str, Any]:
1281
+ """Convert UserSkill model to dictionary."""
1282
+ return {
1283
+ "id": skill.id,
1284
+ "name": skill.name,
1285
+ "display_name": skill.display_name,
1286
+ "description": skill.description,
1287
+ "instructions": skill.instructions,
1288
+ "allowed_tools": skill.allowed_tools.split(",") if skill.allowed_tools else [],
1289
+ "category": skill.category,
1290
+ "icon": skill.icon,
1291
+ "color": skill.color,
1292
+ "metadata": skill.metadata_json,
1293
+ "is_active": skill.is_active,
1294
+ "created_by": skill.created_by,
1295
+ "created_at": skill.created_at.isoformat() if skill.created_at else None,
1296
+ "updated_at": skill.updated_at.isoformat() if skill.updated_at else None
1211
1297
  }