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.
- control_plane_api/README.md +266 -0
- control_plane_api/__init__.py +0 -0
- control_plane_api/__version__.py +1 -0
- control_plane_api/alembic/README +1 -0
- control_plane_api/alembic/env.py +98 -0
- control_plane_api/alembic/script.py.mako +28 -0
- control_plane_api/alembic/versions/1382bec74309_initial_migration_with_all_models.py +251 -0
- control_plane_api/alembic/versions/1f54bc2a37e3_add_analytics_tables.py +162 -0
- control_plane_api/alembic/versions/2e4cb136dc10_rename_toolset_ids_to_skill_ids_in_teams.py +30 -0
- control_plane_api/alembic/versions/31cd69a644ce_add_skill_templates_table.py +28 -0
- control_plane_api/alembic/versions/89e127caa47d_add_jobs_and_job_executions_tables.py +161 -0
- control_plane_api/alembic/versions/add_llm_models_table.py +51 -0
- control_plane_api/alembic/versions/b0e10697f212_add_runtime_column_to_teams_simple.py +42 -0
- control_plane_api/alembic/versions/ce43b24b63bf_add_execution_trigger_source_and_fix_.py +155 -0
- control_plane_api/alembic/versions/d4eaf16e3f8d_rename_toolsets_to_skills.py +84 -0
- control_plane_api/alembic/versions/efa2dc427da1_rename_metadata_to_custom_metadata.py +32 -0
- control_plane_api/alembic/versions/f973b431d1ce_add_workflow_executor_to_skill_types.py +44 -0
- control_plane_api/alembic.ini +148 -0
- control_plane_api/api/index.py +12 -0
- control_plane_api/app/__init__.py +11 -0
- control_plane_api/app/activities/__init__.py +20 -0
- control_plane_api/app/activities/agent_activities.py +379 -0
- control_plane_api/app/activities/team_activities.py +410 -0
- control_plane_api/app/activities/temporal_cloud_activities.py +577 -0
- control_plane_api/app/config/__init__.py +35 -0
- control_plane_api/app/config/api_config.py +354 -0
- control_plane_api/app/config/model_pricing.py +318 -0
- control_plane_api/app/config.py +95 -0
- control_plane_api/app/database.py +135 -0
- control_plane_api/app/exceptions.py +408 -0
- control_plane_api/app/lib/__init__.py +11 -0
- control_plane_api/app/lib/job_executor.py +312 -0
- control_plane_api/app/lib/kubiya_client.py +235 -0
- control_plane_api/app/lib/litellm_pricing.py +166 -0
- control_plane_api/app/lib/planning_tools/__init__.py +22 -0
- control_plane_api/app/lib/planning_tools/agents.py +155 -0
- control_plane_api/app/lib/planning_tools/base.py +189 -0
- control_plane_api/app/lib/planning_tools/environments.py +214 -0
- control_plane_api/app/lib/planning_tools/resources.py +240 -0
- control_plane_api/app/lib/planning_tools/teams.py +198 -0
- control_plane_api/app/lib/policy_enforcer_client.py +939 -0
- control_plane_api/app/lib/redis_client.py +436 -0
- control_plane_api/app/lib/supabase.py +71 -0
- control_plane_api/app/lib/temporal_client.py +138 -0
- control_plane_api/app/lib/validation/__init__.py +20 -0
- control_plane_api/app/lib/validation/runtime_validation.py +287 -0
- control_plane_api/app/main.py +128 -0
- control_plane_api/app/middleware/__init__.py +8 -0
- control_plane_api/app/middleware/auth.py +513 -0
- control_plane_api/app/middleware/exception_handler.py +267 -0
- control_plane_api/app/middleware/rate_limiting.py +384 -0
- control_plane_api/app/middleware/request_id.py +202 -0
- control_plane_api/app/models/__init__.py +27 -0
- control_plane_api/app/models/agent.py +79 -0
- control_plane_api/app/models/analytics.py +206 -0
- control_plane_api/app/models/associations.py +81 -0
- control_plane_api/app/models/environment.py +63 -0
- control_plane_api/app/models/execution.py +93 -0
- control_plane_api/app/models/job.py +179 -0
- control_plane_api/app/models/llm_model.py +75 -0
- control_plane_api/app/models/presence.py +49 -0
- control_plane_api/app/models/project.py +47 -0
- control_plane_api/app/models/session.py +38 -0
- control_plane_api/app/models/team.py +66 -0
- control_plane_api/app/models/workflow.py +55 -0
- control_plane_api/app/policies/README.md +121 -0
- control_plane_api/app/policies/approved_users.rego +62 -0
- control_plane_api/app/policies/business_hours.rego +51 -0
- control_plane_api/app/policies/rate_limiting.rego +100 -0
- control_plane_api/app/policies/tool_restrictions.rego +86 -0
- control_plane_api/app/routers/__init__.py +4 -0
- control_plane_api/app/routers/agents.py +364 -0
- control_plane_api/app/routers/agents_v2.py +1260 -0
- control_plane_api/app/routers/analytics.py +1014 -0
- control_plane_api/app/routers/context_manager.py +562 -0
- control_plane_api/app/routers/environment_context.py +270 -0
- control_plane_api/app/routers/environments.py +715 -0
- control_plane_api/app/routers/execution_environment.py +517 -0
- control_plane_api/app/routers/executions.py +1911 -0
- control_plane_api/app/routers/health.py +92 -0
- control_plane_api/app/routers/health_v2.py +326 -0
- control_plane_api/app/routers/integrations.py +274 -0
- control_plane_api/app/routers/jobs.py +1344 -0
- control_plane_api/app/routers/models.py +82 -0
- control_plane_api/app/routers/models_v2.py +361 -0
- control_plane_api/app/routers/policies.py +639 -0
- control_plane_api/app/routers/presence.py +234 -0
- control_plane_api/app/routers/projects.py +902 -0
- control_plane_api/app/routers/runners.py +379 -0
- control_plane_api/app/routers/runtimes.py +172 -0
- control_plane_api/app/routers/secrets.py +155 -0
- control_plane_api/app/routers/skills.py +1001 -0
- control_plane_api/app/routers/skills_definitions.py +140 -0
- control_plane_api/app/routers/task_planning.py +1256 -0
- control_plane_api/app/routers/task_queues.py +654 -0
- control_plane_api/app/routers/team_context.py +270 -0
- control_plane_api/app/routers/teams.py +1400 -0
- control_plane_api/app/routers/worker_queues.py +1545 -0
- control_plane_api/app/routers/workers.py +935 -0
- control_plane_api/app/routers/workflows.py +204 -0
- control_plane_api/app/runtimes/__init__.py +6 -0
- control_plane_api/app/runtimes/validation.py +344 -0
- control_plane_api/app/schemas/job_schemas.py +295 -0
- control_plane_api/app/services/__init__.py +1 -0
- control_plane_api/app/services/agno_service.py +619 -0
- control_plane_api/app/services/litellm_service.py +190 -0
- control_plane_api/app/services/policy_service.py +525 -0
- control_plane_api/app/services/temporal_cloud_provisioning.py +150 -0
- control_plane_api/app/skills/__init__.py +44 -0
- control_plane_api/app/skills/base.py +229 -0
- control_plane_api/app/skills/business_intelligence.py +189 -0
- control_plane_api/app/skills/data_visualization.py +154 -0
- control_plane_api/app/skills/docker.py +104 -0
- control_plane_api/app/skills/file_generation.py +94 -0
- control_plane_api/app/skills/file_system.py +110 -0
- control_plane_api/app/skills/python.py +92 -0
- control_plane_api/app/skills/registry.py +65 -0
- control_plane_api/app/skills/shell.py +102 -0
- control_plane_api/app/skills/workflow_executor.py +469 -0
- control_plane_api/app/utils/workflow_executor.py +354 -0
- control_plane_api/app/workflows/__init__.py +11 -0
- control_plane_api/app/workflows/agent_execution.py +507 -0
- control_plane_api/app/workflows/agent_execution_with_skills.py +222 -0
- control_plane_api/app/workflows/namespace_provisioning.py +326 -0
- control_plane_api/app/workflows/team_execution.py +399 -0
- control_plane_api/scripts/seed_models.py +239 -0
- control_plane_api/worker/__init__.py +0 -0
- control_plane_api/worker/activities/__init__.py +0 -0
- control_plane_api/worker/activities/agent_activities.py +1241 -0
- control_plane_api/worker/activities/approval_activities.py +234 -0
- control_plane_api/worker/activities/runtime_activities.py +388 -0
- control_plane_api/worker/activities/skill_activities.py +267 -0
- control_plane_api/worker/activities/team_activities.py +1217 -0
- control_plane_api/worker/config/__init__.py +31 -0
- control_plane_api/worker/config/worker_config.py +275 -0
- control_plane_api/worker/control_plane_client.py +529 -0
- control_plane_api/worker/examples/analytics_integration_example.py +362 -0
- control_plane_api/worker/models/__init__.py +1 -0
- control_plane_api/worker/models/inputs.py +89 -0
- control_plane_api/worker/runtimes/__init__.py +31 -0
- control_plane_api/worker/runtimes/base.py +789 -0
- control_plane_api/worker/runtimes/claude_code_runtime.py +1443 -0
- control_plane_api/worker/runtimes/default_runtime.py +617 -0
- control_plane_api/worker/runtimes/factory.py +173 -0
- control_plane_api/worker/runtimes/validation.py +93 -0
- control_plane_api/worker/services/__init__.py +1 -0
- control_plane_api/worker/services/agent_executor.py +422 -0
- control_plane_api/worker/services/agent_executor_v2.py +383 -0
- control_plane_api/worker/services/analytics_collector.py +457 -0
- control_plane_api/worker/services/analytics_service.py +464 -0
- control_plane_api/worker/services/approval_tools.py +310 -0
- control_plane_api/worker/services/approval_tools_agno.py +207 -0
- control_plane_api/worker/services/cancellation_manager.py +177 -0
- control_plane_api/worker/services/data_visualization.py +827 -0
- control_plane_api/worker/services/jira_tools.py +257 -0
- control_plane_api/worker/services/runtime_analytics.py +328 -0
- control_plane_api/worker/services/session_service.py +194 -0
- control_plane_api/worker/services/skill_factory.py +175 -0
- control_plane_api/worker/services/team_executor.py +574 -0
- control_plane_api/worker/services/team_executor_v2.py +465 -0
- control_plane_api/worker/services/workflow_executor_tools.py +1418 -0
- control_plane_api/worker/tests/__init__.py +1 -0
- control_plane_api/worker/tests/e2e/__init__.py +0 -0
- control_plane_api/worker/tests/e2e/test_execution_flow.py +571 -0
- control_plane_api/worker/tests/integration/__init__.py +0 -0
- control_plane_api/worker/tests/integration/test_control_plane_integration.py +308 -0
- control_plane_api/worker/tests/unit/__init__.py +0 -0
- control_plane_api/worker/tests/unit/test_control_plane_client.py +401 -0
- control_plane_api/worker/utils/__init__.py +1 -0
- control_plane_api/worker/utils/chunk_batcher.py +305 -0
- control_plane_api/worker/utils/retry_utils.py +60 -0
- control_plane_api/worker/utils/streaming_utils.py +373 -0
- control_plane_api/worker/worker.py +753 -0
- control_plane_api/worker/workflows/__init__.py +0 -0
- control_plane_api/worker/workflows/agent_execution.py +589 -0
- control_plane_api/worker/workflows/team_execution.py +429 -0
- kubiya_control_plane_api-0.3.4.dist-info/METADATA +229 -0
- kubiya_control_plane_api-0.3.4.dist-info/RECORD +182 -0
- kubiya_control_plane_api-0.3.4.dist-info/entry_points.txt +2 -0
- kubiya_control_plane_api-0.3.4.dist-info/top_level.txt +1 -0
- kubiya_control_plane_api-0.1.0.dist-info/METADATA +0 -66
- kubiya_control_plane_api-0.1.0.dist-info/RECORD +0 -5
- kubiya_control_plane_api-0.1.0.dist-info/top_level.txt +0 -1
- {kubiya_control_plane_api-0.1.0.dist-info/licenses → control_plane_api}/LICENSE +0 -0
- {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
|
+
]
|