kubiya-control-plane-api 0.1.0__py3-none-any.whl → 0.3.4__py3-none-any.whl

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.

Potentially problematic release.


This version of kubiya-control-plane-api might be problematic. Click here for more details.

Files changed (185) hide show
  1. control_plane_api/README.md +266 -0
  2. control_plane_api/__init__.py +0 -0
  3. control_plane_api/__version__.py +1 -0
  4. control_plane_api/alembic/README +1 -0
  5. control_plane_api/alembic/env.py +98 -0
  6. control_plane_api/alembic/script.py.mako +28 -0
  7. control_plane_api/alembic/versions/1382bec74309_initial_migration_with_all_models.py +251 -0
  8. control_plane_api/alembic/versions/1f54bc2a37e3_add_analytics_tables.py +162 -0
  9. control_plane_api/alembic/versions/2e4cb136dc10_rename_toolset_ids_to_skill_ids_in_teams.py +30 -0
  10. control_plane_api/alembic/versions/31cd69a644ce_add_skill_templates_table.py +28 -0
  11. control_plane_api/alembic/versions/89e127caa47d_add_jobs_and_job_executions_tables.py +161 -0
  12. control_plane_api/alembic/versions/add_llm_models_table.py +51 -0
  13. control_plane_api/alembic/versions/b0e10697f212_add_runtime_column_to_teams_simple.py +42 -0
  14. control_plane_api/alembic/versions/ce43b24b63bf_add_execution_trigger_source_and_fix_.py +155 -0
  15. control_plane_api/alembic/versions/d4eaf16e3f8d_rename_toolsets_to_skills.py +84 -0
  16. control_plane_api/alembic/versions/efa2dc427da1_rename_metadata_to_custom_metadata.py +32 -0
  17. control_plane_api/alembic/versions/f973b431d1ce_add_workflow_executor_to_skill_types.py +44 -0
  18. control_plane_api/alembic.ini +148 -0
  19. control_plane_api/api/index.py +12 -0
  20. control_plane_api/app/__init__.py +11 -0
  21. control_plane_api/app/activities/__init__.py +20 -0
  22. control_plane_api/app/activities/agent_activities.py +379 -0
  23. control_plane_api/app/activities/team_activities.py +410 -0
  24. control_plane_api/app/activities/temporal_cloud_activities.py +577 -0
  25. control_plane_api/app/config/__init__.py +35 -0
  26. control_plane_api/app/config/api_config.py +354 -0
  27. control_plane_api/app/config/model_pricing.py +318 -0
  28. control_plane_api/app/config.py +95 -0
  29. control_plane_api/app/database.py +135 -0
  30. control_plane_api/app/exceptions.py +408 -0
  31. control_plane_api/app/lib/__init__.py +11 -0
  32. control_plane_api/app/lib/job_executor.py +312 -0
  33. control_plane_api/app/lib/kubiya_client.py +235 -0
  34. control_plane_api/app/lib/litellm_pricing.py +166 -0
  35. control_plane_api/app/lib/planning_tools/__init__.py +22 -0
  36. control_plane_api/app/lib/planning_tools/agents.py +155 -0
  37. control_plane_api/app/lib/planning_tools/base.py +189 -0
  38. control_plane_api/app/lib/planning_tools/environments.py +214 -0
  39. control_plane_api/app/lib/planning_tools/resources.py +240 -0
  40. control_plane_api/app/lib/planning_tools/teams.py +198 -0
  41. control_plane_api/app/lib/policy_enforcer_client.py +939 -0
  42. control_plane_api/app/lib/redis_client.py +436 -0
  43. control_plane_api/app/lib/supabase.py +71 -0
  44. control_plane_api/app/lib/temporal_client.py +138 -0
  45. control_plane_api/app/lib/validation/__init__.py +20 -0
  46. control_plane_api/app/lib/validation/runtime_validation.py +287 -0
  47. control_plane_api/app/main.py +128 -0
  48. control_plane_api/app/middleware/__init__.py +8 -0
  49. control_plane_api/app/middleware/auth.py +513 -0
  50. control_plane_api/app/middleware/exception_handler.py +267 -0
  51. control_plane_api/app/middleware/rate_limiting.py +384 -0
  52. control_plane_api/app/middleware/request_id.py +202 -0
  53. control_plane_api/app/models/__init__.py +27 -0
  54. control_plane_api/app/models/agent.py +79 -0
  55. control_plane_api/app/models/analytics.py +206 -0
  56. control_plane_api/app/models/associations.py +81 -0
  57. control_plane_api/app/models/environment.py +63 -0
  58. control_plane_api/app/models/execution.py +93 -0
  59. control_plane_api/app/models/job.py +179 -0
  60. control_plane_api/app/models/llm_model.py +75 -0
  61. control_plane_api/app/models/presence.py +49 -0
  62. control_plane_api/app/models/project.py +47 -0
  63. control_plane_api/app/models/session.py +38 -0
  64. control_plane_api/app/models/team.py +66 -0
  65. control_plane_api/app/models/workflow.py +55 -0
  66. control_plane_api/app/policies/README.md +121 -0
  67. control_plane_api/app/policies/approved_users.rego +62 -0
  68. control_plane_api/app/policies/business_hours.rego +51 -0
  69. control_plane_api/app/policies/rate_limiting.rego +100 -0
  70. control_plane_api/app/policies/tool_restrictions.rego +86 -0
  71. control_plane_api/app/routers/__init__.py +4 -0
  72. control_plane_api/app/routers/agents.py +364 -0
  73. control_plane_api/app/routers/agents_v2.py +1260 -0
  74. control_plane_api/app/routers/analytics.py +1014 -0
  75. control_plane_api/app/routers/context_manager.py +562 -0
  76. control_plane_api/app/routers/environment_context.py +270 -0
  77. control_plane_api/app/routers/environments.py +715 -0
  78. control_plane_api/app/routers/execution_environment.py +517 -0
  79. control_plane_api/app/routers/executions.py +1911 -0
  80. control_plane_api/app/routers/health.py +92 -0
  81. control_plane_api/app/routers/health_v2.py +326 -0
  82. control_plane_api/app/routers/integrations.py +274 -0
  83. control_plane_api/app/routers/jobs.py +1344 -0
  84. control_plane_api/app/routers/models.py +82 -0
  85. control_plane_api/app/routers/models_v2.py +361 -0
  86. control_plane_api/app/routers/policies.py +639 -0
  87. control_plane_api/app/routers/presence.py +234 -0
  88. control_plane_api/app/routers/projects.py +902 -0
  89. control_plane_api/app/routers/runners.py +379 -0
  90. control_plane_api/app/routers/runtimes.py +172 -0
  91. control_plane_api/app/routers/secrets.py +155 -0
  92. control_plane_api/app/routers/skills.py +1001 -0
  93. control_plane_api/app/routers/skills_definitions.py +140 -0
  94. control_plane_api/app/routers/task_planning.py +1256 -0
  95. control_plane_api/app/routers/task_queues.py +654 -0
  96. control_plane_api/app/routers/team_context.py +270 -0
  97. control_plane_api/app/routers/teams.py +1400 -0
  98. control_plane_api/app/routers/worker_queues.py +1545 -0
  99. control_plane_api/app/routers/workers.py +935 -0
  100. control_plane_api/app/routers/workflows.py +204 -0
  101. control_plane_api/app/runtimes/__init__.py +6 -0
  102. control_plane_api/app/runtimes/validation.py +344 -0
  103. control_plane_api/app/schemas/job_schemas.py +295 -0
  104. control_plane_api/app/services/__init__.py +1 -0
  105. control_plane_api/app/services/agno_service.py +619 -0
  106. control_plane_api/app/services/litellm_service.py +190 -0
  107. control_plane_api/app/services/policy_service.py +525 -0
  108. control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
  109. control_plane_api/app/skills/__init__.py +44 -0
  110. control_plane_api/app/skills/base.py +229 -0
  111. control_plane_api/app/skills/business_intelligence.py +189 -0
  112. control_plane_api/app/skills/data_visualization.py +154 -0
  113. control_plane_api/app/skills/docker.py +104 -0
  114. control_plane_api/app/skills/file_generation.py +94 -0
  115. control_plane_api/app/skills/file_system.py +110 -0
  116. control_plane_api/app/skills/python.py +92 -0
  117. control_plane_api/app/skills/registry.py +65 -0
  118. control_plane_api/app/skills/shell.py +102 -0
  119. control_plane_api/app/skills/workflow_executor.py +469 -0
  120. control_plane_api/app/utils/workflow_executor.py +354 -0
  121. control_plane_api/app/workflows/__init__.py +11 -0
  122. control_plane_api/app/workflows/agent_execution.py +507 -0
  123. control_plane_api/app/workflows/agent_execution_with_skills.py +222 -0
  124. control_plane_api/app/workflows/namespace_provisioning.py +326 -0
  125. control_plane_api/app/workflows/team_execution.py +399 -0
  126. control_plane_api/scripts/seed_models.py +239 -0
  127. control_plane_api/worker/__init__.py +0 -0
  128. control_plane_api/worker/activities/__init__.py +0 -0
  129. control_plane_api/worker/activities/agent_activities.py +1241 -0
  130. control_plane_api/worker/activities/approval_activities.py +234 -0
  131. control_plane_api/worker/activities/runtime_activities.py +388 -0
  132. control_plane_api/worker/activities/skill_activities.py +267 -0
  133. control_plane_api/worker/activities/team_activities.py +1217 -0
  134. control_plane_api/worker/config/__init__.py +31 -0
  135. control_plane_api/worker/config/worker_config.py +275 -0
  136. control_plane_api/worker/control_plane_client.py +529 -0
  137. control_plane_api/worker/examples/analytics_integration_example.py +362 -0
  138. control_plane_api/worker/models/__init__.py +1 -0
  139. control_plane_api/worker/models/inputs.py +89 -0
  140. control_plane_api/worker/runtimes/__init__.py +31 -0
  141. control_plane_api/worker/runtimes/base.py +789 -0
  142. control_plane_api/worker/runtimes/claude_code_runtime.py +1443 -0
  143. control_plane_api/worker/runtimes/default_runtime.py +617 -0
  144. control_plane_api/worker/runtimes/factory.py +173 -0
  145. control_plane_api/worker/runtimes/validation.py +93 -0
  146. control_plane_api/worker/services/__init__.py +1 -0
  147. control_plane_api/worker/services/agent_executor.py +422 -0
  148. control_plane_api/worker/services/agent_executor_v2.py +383 -0
  149. control_plane_api/worker/services/analytics_collector.py +457 -0
  150. control_plane_api/worker/services/analytics_service.py +464 -0
  151. control_plane_api/worker/services/approval_tools.py +310 -0
  152. control_plane_api/worker/services/approval_tools_agno.py +207 -0
  153. control_plane_api/worker/services/cancellation_manager.py +177 -0
  154. control_plane_api/worker/services/data_visualization.py +827 -0
  155. control_plane_api/worker/services/jira_tools.py +257 -0
  156. control_plane_api/worker/services/runtime_analytics.py +328 -0
  157. control_plane_api/worker/services/session_service.py +194 -0
  158. control_plane_api/worker/services/skill_factory.py +175 -0
  159. control_plane_api/worker/services/team_executor.py +574 -0
  160. control_plane_api/worker/services/team_executor_v2.py +465 -0
  161. control_plane_api/worker/services/workflow_executor_tools.py +1418 -0
  162. control_plane_api/worker/tests/__init__.py +1 -0
  163. control_plane_api/worker/tests/e2e/__init__.py +0 -0
  164. control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
  165. control_plane_api/worker/tests/integration/__init__.py +0 -0
  166. control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
  167. control_plane_api/worker/tests/unit/__init__.py +0 -0
  168. control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
  169. control_plane_api/worker/utils/__init__.py +1 -0
  170. control_plane_api/worker/utils/chunk_batcher.py +305 -0
  171. control_plane_api/worker/utils/retry_utils.py +60 -0
  172. control_plane_api/worker/utils/streaming_utils.py +373 -0
  173. control_plane_api/worker/worker.py +753 -0
  174. control_plane_api/worker/workflows/__init__.py +0 -0
  175. control_plane_api/worker/workflows/agent_execution.py +589 -0
  176. control_plane_api/worker/workflows/team_execution.py +429 -0
  177. kubiya_control_plane_api-0.3.4.dist-info/METADATA +229 -0
  178. kubiya_control_plane_api-0.3.4.dist-info/RECORD +182 -0
  179. kubiya_control_plane_api-0.3.4.dist-info/entry_points.txt +2 -0
  180. kubiya_control_plane_api-0.3.4.dist-info/top_level.txt +1 -0
  181. kubiya_control_plane_api-0.1.0.dist-info/METADATA +0 -66
  182. kubiya_control_plane_api-0.1.0.dist-info/RECORD +0 -5
  183. kubiya_control_plane_api-0.1.0.dist-info/top_level.txt +0 -1
  184. {kubiya_control_plane_api-0.1.0.dist-info/licenses → control_plane_api}/LICENSE +0 -0
  185. {kubiya_control_plane_api-0.1.0.dist-info → kubiya_control_plane_api-0.3.4.dist-info}/WHEEL +0 -0
@@ -0,0 +1,436 @@
1
+ """Redis client for caching authentication tokens and user data using Upstash REST API."""
2
+
3
+ import os
4
+ import json
5
+ from typing import Optional, Any
6
+ import httpx
7
+ import structlog
8
+
9
+ logger = structlog.get_logger()
10
+
11
+ # Upstash Redis configuration
12
+ _redis_url: Optional[str] = None
13
+ _redis_token: Optional[str] = None
14
+
15
+
16
+ class UpstashRedisClient:
17
+ """Upstash Redis client using direct HTTP REST API calls (serverless-friendly)."""
18
+
19
+ def __init__(self, url: str, token: str):
20
+ self.url = url.rstrip('/')
21
+ self.token = token
22
+ self.headers = {
23
+ "Authorization": f"Bearer {token}",
24
+ "Content-Type": "application/json"
25
+ }
26
+
27
+ async def get(self, key: str) -> Optional[str]:
28
+ """Get value from Redis."""
29
+ try:
30
+ async with httpx.AsyncClient(timeout=5.0) as client:
31
+ response = await client.post(
32
+ f"{self.url}/get/{key}",
33
+ headers=self.headers
34
+ )
35
+
36
+ if response.status_code == 200:
37
+ result = response.json()
38
+ return result.get("result")
39
+
40
+ logger.warning("redis_get_failed", status=response.status_code, key=key[:20])
41
+ return None
42
+
43
+ except Exception as e:
44
+ logger.warning("redis_get_error", error=str(e), key=key[:20])
45
+ return None
46
+
47
+ async def set(self, key: str, value: str, ex: Optional[int] = None) -> bool:
48
+ """Set value in Redis with optional expiry (seconds)."""
49
+ try:
50
+ # Build command
51
+ if ex:
52
+ command = ["SET", key, value, "EX", str(ex)]
53
+ else:
54
+ command = ["SET", key, value]
55
+
56
+ async with httpx.AsyncClient(timeout=5.0) as client:
57
+ response = await client.post(
58
+ f"{self.url}/pipeline",
59
+ headers=self.headers,
60
+ json=[command]
61
+ )
62
+
63
+ if response.status_code == 200:
64
+ return True
65
+
66
+ logger.warning("redis_set_failed", status=response.status_code, key=key[:20])
67
+ return False
68
+
69
+ except Exception as e:
70
+ logger.warning("redis_set_error", error=str(e), key=key[:20])
71
+ return False
72
+
73
+ async def setex(self, key: str, seconds: int, value: str) -> bool:
74
+ """Set value in Redis with expiry (seconds). Alias for set with ex parameter."""
75
+ return await self.set(key, value, ex=seconds)
76
+
77
+ async def delete(self, key: str) -> bool:
78
+ """Delete a key from Redis."""
79
+ try:
80
+ command = ["DEL", key]
81
+
82
+ async with httpx.AsyncClient(timeout=5.0) as client:
83
+ response = await client.post(
84
+ f"{self.url}/pipeline",
85
+ headers=self.headers,
86
+ json=[command]
87
+ )
88
+
89
+ if response.status_code == 200:
90
+ return True
91
+
92
+ logger.warning("redis_delete_failed", status=response.status_code, key=key[:20])
93
+ return False
94
+
95
+ except Exception as e:
96
+ logger.warning("redis_delete_error", error=str(e), key=key[:20])
97
+ return False
98
+
99
+ async def hset(self, key: str, mapping: dict) -> bool:
100
+ """Set hash fields in Redis."""
101
+ try:
102
+ # Convert dict to list of field-value pairs
103
+ fields = []
104
+ for k, v in mapping.items():
105
+ fields.extend([k, str(v)])
106
+
107
+ command = ["HSET", key] + fields
108
+
109
+ async with httpx.AsyncClient(timeout=5.0) as client:
110
+ response = await client.post(
111
+ f"{self.url}/pipeline",
112
+ headers=self.headers,
113
+ json=[command]
114
+ )
115
+
116
+ if response.status_code == 200:
117
+ return True
118
+
119
+ logger.warning("redis_hset_failed", status=response.status_code, key=key[:20])
120
+ return False
121
+
122
+ except Exception as e:
123
+ logger.warning("redis_hset_error", error=str(e), key=key[:20])
124
+ return False
125
+
126
+ async def hgetall(self, key: str) -> Optional[dict]:
127
+ """Get all hash fields from Redis."""
128
+ try:
129
+ async with httpx.AsyncClient(timeout=5.0) as client:
130
+ response = await client.post(
131
+ f"{self.url}/pipeline",
132
+ headers=self.headers,
133
+ json=[["HGETALL", key]]
134
+ )
135
+
136
+ if response.status_code == 200:
137
+ result = response.json()
138
+ if result and isinstance(result, list) and len(result) > 0:
139
+ data = result[0].get("result", [])
140
+ # Convert list to dict [k1, v1, k2, v2] -> {k1: v1, k2: v2}
141
+ return {data[i]: data[i+1] for i in range(0, len(data), 2)} if data else {}
142
+
143
+ return None
144
+
145
+ except Exception as e:
146
+ logger.warning("redis_hgetall_error", error=str(e), key=key[:20])
147
+ return None
148
+
149
+ async def expire(self, key: str, seconds: int) -> bool:
150
+ """Set expiry on a key."""
151
+ try:
152
+ async with httpx.AsyncClient(timeout=5.0) as client:
153
+ response = await client.post(
154
+ f"{self.url}/pipeline",
155
+ headers=self.headers,
156
+ json=[["EXPIRE", key, str(seconds)]]
157
+ )
158
+
159
+ return response.status_code == 200
160
+
161
+ except Exception as e:
162
+ logger.warning("redis_expire_error", error=str(e), key=key[:20])
163
+ return False
164
+
165
+ async def sadd(self, key: str, *members: str) -> bool:
166
+ """Add members to a set."""
167
+ try:
168
+ command = ["SADD", key] + list(members)
169
+
170
+ async with httpx.AsyncClient(timeout=5.0) as client:
171
+ response = await client.post(
172
+ f"{self.url}/pipeline",
173
+ headers=self.headers,
174
+ json=[command]
175
+ )
176
+
177
+ return response.status_code == 200
178
+
179
+ except Exception as e:
180
+ logger.warning("redis_sadd_error", error=str(e), key=key[:20])
181
+ return False
182
+
183
+ async def scard(self, key: str) -> int:
184
+ """Get count of set members."""
185
+ try:
186
+ async with httpx.AsyncClient(timeout=5.0) as client:
187
+ response = await client.post(
188
+ f"{self.url}/pipeline",
189
+ headers=self.headers,
190
+ json=[["SCARD", key]]
191
+ )
192
+
193
+ if response.status_code == 200:
194
+ result = response.json()
195
+ if result and isinstance(result, list):
196
+ return result[0].get("result", 0)
197
+
198
+ return 0
199
+
200
+ except Exception as e:
201
+ logger.warning("redis_scard_error", error=str(e), key=key[:20])
202
+ return 0
203
+
204
+ async def lpush(self, key: str, *values: str) -> bool:
205
+ """Push values to start of list."""
206
+ try:
207
+ command = ["LPUSH", key] + list(values)
208
+
209
+ async with httpx.AsyncClient(timeout=5.0) as client:
210
+ response = await client.post(
211
+ f"{self.url}/pipeline",
212
+ headers=self.headers,
213
+ json=[command]
214
+ )
215
+
216
+ return response.status_code == 200
217
+
218
+ except Exception as e:
219
+ logger.warning("redis_lpush_error", error=str(e), key=key[:20])
220
+ return False
221
+
222
+ async def ltrim(self, key: str, start: int, stop: int) -> bool:
223
+ """Trim list to specified range."""
224
+ try:
225
+ async with httpx.AsyncClient(timeout=5.0) as client:
226
+ response = await client.post(
227
+ f"{self.url}/pipeline",
228
+ headers=self.headers,
229
+ json=[["LTRIM", key, str(start), str(stop)]]
230
+ )
231
+
232
+ return response.status_code == 200
233
+
234
+ except Exception as e:
235
+ logger.warning("redis_ltrim_error", error=str(e), key=key[:20])
236
+ return False
237
+
238
+ async def lrange(self, key: str, start: int, stop: int) -> list:
239
+ """Get range of list elements."""
240
+ try:
241
+ async with httpx.AsyncClient(timeout=5.0) as client:
242
+ response = await client.post(
243
+ f"{self.url}/pipeline",
244
+ headers=self.headers,
245
+ json=[["LRANGE", key, str(start), str(stop)]]
246
+ )
247
+
248
+ if response.status_code == 200:
249
+ result = response.json()
250
+ if result and isinstance(result, list):
251
+ return result[0].get("result", [])
252
+
253
+ return []
254
+
255
+ except Exception as e:
256
+ logger.warning("redis_lrange_error", error=str(e), key=key[:20])
257
+ return []
258
+
259
+ async def llen(self, key: str) -> int:
260
+ """Get length of list."""
261
+ try:
262
+ async with httpx.AsyncClient(timeout=5.0) as client:
263
+ response = await client.post(
264
+ f"{self.url}/pipeline",
265
+ headers=self.headers,
266
+ json=[["LLEN", key]]
267
+ )
268
+
269
+ if response.status_code == 200:
270
+ result = response.json()
271
+ if result and isinstance(result, list):
272
+ return result[0].get("result", 0)
273
+
274
+ return 0
275
+
276
+ except Exception as e:
277
+ logger.warning("redis_llen_error", error=str(e), key=key[:20])
278
+ return 0
279
+
280
+ async def publish(self, channel: str, message: str) -> bool:
281
+ """Publish message to Redis pub/sub channel."""
282
+ try:
283
+ async with httpx.AsyncClient(timeout=5.0) as client:
284
+ response = await client.post(
285
+ f"{self.url}/pipeline",
286
+ headers=self.headers,
287
+ json=[["PUBLISH", channel, message]]
288
+ )
289
+
290
+ if response.status_code == 200:
291
+ return True
292
+
293
+ logger.warning("redis_publish_failed", status=response.status_code, channel=channel[:20])
294
+ return False
295
+
296
+ except Exception as e:
297
+ logger.warning("redis_publish_error", error=str(e), channel=channel[:20])
298
+ return False
299
+
300
+
301
+ def get_redis_client() -> Optional[UpstashRedisClient]:
302
+ """
303
+ Get or create Upstash Redis client using REST API.
304
+
305
+ Returns:
306
+ Redis client instance or None if not configured
307
+ """
308
+ global _redis_url, _redis_token
309
+
310
+ # Try multiple environment variable names for compatibility
311
+ if not _redis_url:
312
+ _redis_url = (
313
+ os.getenv("KV_REST_API_URL") or
314
+ os.getenv("UPSTASH_REDIS_REST_URL") or
315
+ os.getenv("UPSTASH_REDIS_URL")
316
+ )
317
+
318
+ if not _redis_token:
319
+ _redis_token = (
320
+ os.getenv("KV_REST_API_TOKEN") or
321
+ os.getenv("UPSTASH_REDIS_REST_TOKEN") or
322
+ os.getenv("UPSTASH_REDIS_TOKEN")
323
+ )
324
+
325
+ if not _redis_url or not _redis_token:
326
+ logger.warning(
327
+ "redis_not_configured",
328
+ message="Redis REST API URL or TOKEN not set, caching disabled",
329
+ checked_vars=["KV_REST_API_URL", "KV_REST_API_TOKEN", "UPSTASH_*"]
330
+ )
331
+ return None
332
+
333
+ try:
334
+ client = UpstashRedisClient(url=_redis_url, token=_redis_token)
335
+ logger.debug("redis_client_created", url=_redis_url[:30] + "...")
336
+ return client
337
+ except Exception as e:
338
+ logger.error("redis_client_init_failed", error=str(e))
339
+ return None
340
+
341
+
342
+ # Worker-specific caching functions
343
+
344
+ async def cache_worker_heartbeat(
345
+ worker_id: str,
346
+ queue_id: str,
347
+ organization_id: str,
348
+ status: str,
349
+ last_heartbeat: str,
350
+ tasks_processed: int,
351
+ system_info: Optional[dict] = None,
352
+ ttl: int = 60
353
+ ) -> bool:
354
+ """
355
+ Cache worker heartbeat data in Redis.
356
+
357
+ Args:
358
+ worker_id: Worker UUID
359
+ queue_id: Queue UUID
360
+ organization_id: Organization ID
361
+ status: Worker status
362
+ last_heartbeat: ISO timestamp
363
+ tasks_processed: Task count
364
+ system_info: Optional system metrics
365
+ ttl: Cache TTL in seconds
366
+
367
+ Returns:
368
+ True if cached successfully
369
+ """
370
+ client = get_redis_client()
371
+ if not client:
372
+ return False
373
+
374
+ try:
375
+ data = {
376
+ "worker_id": worker_id,
377
+ "queue_id": queue_id,
378
+ "organization_id": organization_id,
379
+ "status": status,
380
+ "last_heartbeat": last_heartbeat,
381
+ "tasks_processed": tasks_processed,
382
+ }
383
+
384
+ if system_info:
385
+ data["system_info"] = json.dumps(system_info)
386
+
387
+ # Cache worker status
388
+ await client.hset(f"worker:{worker_id}:status", data)
389
+ await client.expire(f"worker:{worker_id}:status", ttl)
390
+
391
+ # Add to queue workers set
392
+ await client.sadd(f"queue:{queue_id}:workers", worker_id)
393
+ await client.expire(f"queue:{queue_id}:workers", ttl)
394
+
395
+ logger.debug("worker_heartbeat_cached", worker_id=worker_id[:8])
396
+ return True
397
+
398
+ except Exception as e:
399
+ logger.error("cache_worker_heartbeat_failed", error=str(e), worker_id=worker_id[:8])
400
+ return False
401
+
402
+
403
+ async def cache_worker_logs(worker_id: str, logs: list, ttl: int = 300) -> bool:
404
+ """Cache worker logs in Redis."""
405
+ client = get_redis_client()
406
+ if not client or not logs:
407
+ return False
408
+
409
+ try:
410
+ # Add logs to list
411
+ await client.lpush(f"worker:{worker_id}:logs", *logs)
412
+ # Keep only last 100 logs
413
+ await client.ltrim(f"worker:{worker_id}:logs", 0, 99)
414
+ # Set expiry
415
+ await client.expire(f"worker:{worker_id}:logs", ttl)
416
+
417
+ logger.debug("worker_logs_cached", worker_id=worker_id[:8], count=len(logs))
418
+ return True
419
+
420
+ except Exception as e:
421
+ logger.error("cache_worker_logs_failed", error=str(e), worker_id=worker_id[:8])
422
+ return False
423
+
424
+
425
+ async def get_queue_worker_count_cached(queue_id: str) -> Optional[int]:
426
+ """Get active worker count for queue from cache."""
427
+ client = get_redis_client()
428
+ if not client:
429
+ return None
430
+
431
+ try:
432
+ count = await client.scard(f"queue:{queue_id}:workers")
433
+ return count
434
+ except Exception as e:
435
+ logger.error("get_queue_worker_count_failed", error=str(e), queue_id=queue_id[:8])
436
+ return None
@@ -0,0 +1,71 @@
1
+ """Supabase client for Agent Control Plane"""
2
+
3
+ import os
4
+ from typing import Optional
5
+ from supabase import create_client, Client
6
+ import structlog
7
+
8
+ logger = structlog.get_logger()
9
+
10
+ _supabase_client: Optional[Client] = None
11
+
12
+
13
+ def get_supabase() -> Client:
14
+ """
15
+ Get or create Supabase client singleton.
16
+
17
+ Uses service role key for admin operations with RLS bypass.
18
+ The API will set organization context via middleware.
19
+
20
+ Returns:
21
+ Supabase client instance
22
+ """
23
+ global _supabase_client
24
+
25
+ if _supabase_client is not None:
26
+ return _supabase_client
27
+
28
+ supabase_url = os.environ.get("SUPABASE_URL") or os.environ.get("SUPABASE_SUPABASE_URL")
29
+ # Try multiple env var names for service key (Vercel Supabase integration uses different names)
30
+ supabase_key = (
31
+ os.environ.get("SUPABASE_SERVICE_KEY") or
32
+ os.environ.get("SUPABASE_SUPABASE_SERVICE_ROLE_KEY") or
33
+ os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
34
+ )
35
+
36
+ if not supabase_url or not supabase_key:
37
+ raise ValueError(
38
+ "SUPABASE_URL and SUPABASE_SERVICE_KEY environment variables are required. "
39
+ f"Found URL: {bool(supabase_url)}, Key: {bool(supabase_key)}"
40
+ )
41
+
42
+ _supabase_client = create_client(supabase_url, supabase_key)
43
+
44
+ logger.info("supabase_client_initialized", url=supabase_url)
45
+
46
+ return _supabase_client
47
+
48
+
49
+ def execute_with_org_context(org_id: str, query_func):
50
+ """
51
+ Execute a Supabase query with organization context for RLS.
52
+
53
+ Sets the app.current_org_id config parameter that RLS policies use.
54
+
55
+ Args:
56
+ org_id: Organization UUID
57
+ query_func: Function that performs the database operation
58
+
59
+ Returns:
60
+ Query result
61
+ """
62
+ client = get_supabase()
63
+
64
+ # Set organization context for RLS
65
+ # This uses the PostgreSQL set_config function via RPC
66
+ client.rpc("set_organization_context", {"org_id": org_id}).execute()
67
+
68
+ # Execute the query
69
+ result = query_func()
70
+
71
+ return result
@@ -0,0 +1,138 @@
1
+ """Temporal client for Agent Control Plane API"""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ import structlog
7
+ from temporalio.client import Client, TLSConfig
8
+
9
+ logger = structlog.get_logger()
10
+
11
+ _temporal_client: Optional[Client] = None
12
+
13
+
14
+ async def get_temporal_client() -> Client:
15
+ """
16
+ Get or create Temporal client singleton.
17
+
18
+ Supports mTLS authentication for Temporal Cloud.
19
+ This client is used by the API to submit workflows.
20
+
21
+ Returns:
22
+ Temporal client instance
23
+ """
24
+ global _temporal_client
25
+
26
+ if _temporal_client is not None:
27
+ return _temporal_client
28
+
29
+ temporal_host = os.environ.get("TEMPORAL_HOST")
30
+ temporal_namespace = os.environ.get("TEMPORAL_NAMESPACE")
31
+ temporal_api_key = os.environ.get("TEMPORAL_API_KEY")
32
+ # Strip whitespace and newlines from all env vars (common issue with env vars)
33
+ if temporal_host:
34
+ temporal_host = temporal_host.strip()
35
+ if temporal_namespace:
36
+ temporal_namespace = temporal_namespace.strip()
37
+ if temporal_api_key:
38
+ temporal_api_key = temporal_api_key.strip()
39
+ temporal_cert_path = os.environ.get("TEMPORAL_CLIENT_CERT_PATH")
40
+ temporal_key_path = os.environ.get("TEMPORAL_CLIENT_KEY_PATH")
41
+
42
+ if not temporal_host or not temporal_namespace:
43
+ raise ValueError(
44
+ "TEMPORAL_HOST and TEMPORAL_NAMESPACE environment variables are required"
45
+ )
46
+
47
+ try:
48
+ # Check if connecting to Temporal Cloud
49
+ is_cloud = "tmprl.cloud" in temporal_host or "api.temporal.io" in temporal_host
50
+
51
+ if is_cloud:
52
+ # Check authentication method: API Key or mTLS
53
+ if temporal_api_key:
54
+ # API Key authentication
55
+ logger.info("temporal_auth_method", method="api_key")
56
+
57
+ # Connect with TLS and API key
58
+ _temporal_client = await Client.connect(
59
+ temporal_host,
60
+ namespace=temporal_namespace,
61
+ tls=TLSConfig(), # TLS without client cert
62
+ rpc_metadata={"authorization": f"Bearer {temporal_api_key}"}
63
+ )
64
+ elif temporal_cert_path:
65
+ # mTLS authentication
66
+ logger.info("temporal_auth_method", method="mtls")
67
+
68
+ # Load client certificate
69
+ cert_path = Path(temporal_cert_path)
70
+ if not cert_path.exists():
71
+ raise FileNotFoundError(
72
+ f"Temporal client certificate not found at {cert_path}"
73
+ )
74
+
75
+ with open(cert_path, "rb") as f:
76
+ cert_content = f.read()
77
+
78
+ # Check if private key is in same file or separate
79
+ if b"BEGIN PRIVATE KEY" in cert_content or b"BEGIN RSA PRIVATE KEY" in cert_content:
80
+ # Key is in the same file
81
+ client_cert = cert_content
82
+ client_key = cert_content
83
+ else:
84
+ # Key must be in separate file
85
+ if not temporal_key_path:
86
+ raise ValueError(
87
+ "Private key not found in certificate file and no separate key path configured. "
88
+ "Please provide TEMPORAL_CLIENT_KEY_PATH environment variable."
89
+ )
90
+ key_path = Path(temporal_key_path)
91
+ with open(key_path, "rb") as f:
92
+ client_key = f.read()
93
+ client_cert = cert_content
94
+
95
+ # Create TLS config for mTLS
96
+ tls_config = TLSConfig(
97
+ client_cert=client_cert,
98
+ client_private_key=client_key,
99
+ )
100
+
101
+ # Connect to Temporal Cloud with mTLS
102
+ _temporal_client = await Client.connect(
103
+ temporal_host,
104
+ namespace=temporal_namespace,
105
+ tls=tls_config,
106
+ )
107
+ else:
108
+ raise ValueError(
109
+ "For Temporal Cloud connection, either TEMPORAL_API_KEY or TEMPORAL_CLIENT_CERT_PATH must be provided"
110
+ )
111
+ else:
112
+ # Local Temporal server (no authentication required)
113
+ _temporal_client = await Client.connect(
114
+ temporal_host,
115
+ namespace=temporal_namespace,
116
+ )
117
+
118
+ logger.info(
119
+ "temporal_client_connected",
120
+ host=temporal_host,
121
+ namespace=temporal_namespace,
122
+ )
123
+
124
+ return _temporal_client
125
+
126
+ except Exception as e:
127
+ logger.error("temporal_client_connection_failed", error=str(e))
128
+ raise
129
+
130
+
131
+ async def close_temporal_client() -> None:
132
+ """Close the Temporal client connection"""
133
+ global _temporal_client
134
+
135
+ if _temporal_client is not None:
136
+ await _temporal_client.close()
137
+ _temporal_client = None
138
+ logger.info("temporal_client_closed")
@@ -0,0 +1,20 @@
1
+ """
2
+ Shared validation module for runtime and model validation.
3
+
4
+ This module is shared between the API and worker to avoid circular dependencies.
5
+ It provides validation logic without depending on worker-specific types.
6
+ """
7
+
8
+ from .runtime_validation import (
9
+ validate_agent_for_runtime,
10
+ get_runtime_requirements_info,
11
+ list_all_runtime_requirements,
12
+ RUNTIME_REQUIREMENTS,
13
+ )
14
+
15
+ __all__ = [
16
+ "validate_agent_for_runtime",
17
+ "get_runtime_requirements_info",
18
+ "list_all_runtime_requirements",
19
+ "RUNTIME_REQUIREMENTS",
20
+ ]