machinaos 0.0.1

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