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,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Job execution logic for routing and parameter substitution.
|
|
3
|
+
|
|
4
|
+
This module handles:
|
|
5
|
+
- Dynamic executor routing (auto/specific queue/environment)
|
|
6
|
+
- Prompt template parameter substitution
|
|
7
|
+
- Worker queue selection based on availability
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import structlog
|
|
12
|
+
from typing import Dict, Any, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
logger = structlog.get_logger()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def substitute_prompt_parameters(prompt_template: str, parameters: Dict[str, Any]) -> str:
|
|
18
|
+
"""
|
|
19
|
+
Substitute parameters in prompt template.
|
|
20
|
+
|
|
21
|
+
Template variables use {{variable_name}} syntax.
|
|
22
|
+
|
|
23
|
+
Example:
|
|
24
|
+
prompt_template = "Run a backup of {{database}} at {{time}}"
|
|
25
|
+
parameters = {"database": "production", "time": "5pm"}
|
|
26
|
+
result = "Run a backup of production at 5pm"
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
prompt_template: Prompt template with {{variables}}
|
|
30
|
+
parameters: Dictionary of parameter values
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Prompt with substituted values
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ValueError: If required parameters are missing
|
|
37
|
+
"""
|
|
38
|
+
# Find all variables in template
|
|
39
|
+
variables = re.findall(r"\{\{(\w+)\}\}", prompt_template)
|
|
40
|
+
|
|
41
|
+
# Check for missing parameters
|
|
42
|
+
missing = [var for var in variables if var not in parameters]
|
|
43
|
+
if missing:
|
|
44
|
+
raise ValueError(f"Missing required parameters: {', '.join(missing)}")
|
|
45
|
+
|
|
46
|
+
# Substitute variables
|
|
47
|
+
result = prompt_template
|
|
48
|
+
for var_name, var_value in parameters.items():
|
|
49
|
+
result = result.replace(f"{{{{{var_name}}}}}", str(var_value))
|
|
50
|
+
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def get_available_worker_queues(
|
|
55
|
+
organization_id: str,
|
|
56
|
+
environment_name: Optional[str] = None
|
|
57
|
+
) -> list[Dict[str, Any]]:
|
|
58
|
+
"""
|
|
59
|
+
Get list of worker queues with active workers.
|
|
60
|
+
|
|
61
|
+
Queries the worker_queues table and counts active workers from Redis heartbeats.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
organization_id: Organization ID for multi-tenant filtering
|
|
65
|
+
environment_name: Optional environment name filter
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
List of worker queue dictionaries with metadata
|
|
69
|
+
"""
|
|
70
|
+
from control_plane_api.app.lib.supabase import get_supabase
|
|
71
|
+
from control_plane_api.app.routers.worker_queues import get_active_workers_from_redis
|
|
72
|
+
|
|
73
|
+
client = get_supabase()
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
# Query worker_queues table for active queues
|
|
77
|
+
query = (
|
|
78
|
+
client.table("worker_queues")
|
|
79
|
+
.select("id, name, environment_id, status, environments(name)")
|
|
80
|
+
.eq("organization_id", organization_id)
|
|
81
|
+
.eq("status", "active")
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Filter by environment if specified
|
|
85
|
+
if environment_name:
|
|
86
|
+
# First get the environment ID
|
|
87
|
+
env_result = (
|
|
88
|
+
client.table("environments")
|
|
89
|
+
.select("id")
|
|
90
|
+
.eq("organization_id", organization_id)
|
|
91
|
+
.eq("name", environment_name)
|
|
92
|
+
.maybe_single()
|
|
93
|
+
.execute()
|
|
94
|
+
)
|
|
95
|
+
if env_result.data:
|
|
96
|
+
query = query.eq("environment_id", env_result.data["id"])
|
|
97
|
+
else:
|
|
98
|
+
logger.warning(
|
|
99
|
+
"environment_not_found",
|
|
100
|
+
organization_id=organization_id,
|
|
101
|
+
environment_name=environment_name
|
|
102
|
+
)
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
result = query.execute()
|
|
106
|
+
|
|
107
|
+
if not result.data:
|
|
108
|
+
logger.info("no_active_worker_queues_found", organization_id=organization_id)
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
# Get active workers from Redis heartbeats
|
|
112
|
+
active_workers_data = await get_active_workers_from_redis(organization_id)
|
|
113
|
+
|
|
114
|
+
logger.info(
|
|
115
|
+
"checking_active_workers",
|
|
116
|
+
organization_id=organization_id,
|
|
117
|
+
total_queues_in_db=len(result.data),
|
|
118
|
+
active_workers_count=len(active_workers_data)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Count workers per queue
|
|
122
|
+
worker_counts = {}
|
|
123
|
+
for worker_id, worker_data in active_workers_data.items():
|
|
124
|
+
queue_id = worker_data.get("worker_queue_id")
|
|
125
|
+
if queue_id:
|
|
126
|
+
worker_counts[queue_id] = worker_counts.get(queue_id, 0) + 1
|
|
127
|
+
|
|
128
|
+
# Transform to expected format
|
|
129
|
+
worker_queues = []
|
|
130
|
+
for queue in result.data:
|
|
131
|
+
active_worker_count = worker_counts.get(queue["id"], 0)
|
|
132
|
+
|
|
133
|
+
logger.debug(
|
|
134
|
+
"checking_queue_for_active_workers",
|
|
135
|
+
queue_id=queue["id"],
|
|
136
|
+
queue_name=queue.get("name"),
|
|
137
|
+
active_worker_count=active_worker_count
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Only include queues with active workers
|
|
141
|
+
if active_worker_count > 0:
|
|
142
|
+
env_data = queue.get("environments", {})
|
|
143
|
+
worker_queues.append({
|
|
144
|
+
"queue_name": queue["id"], # Use queue ID as the task queue name
|
|
145
|
+
"environment_name": env_data.get("name") if env_data else None,
|
|
146
|
+
"active_workers": active_worker_count,
|
|
147
|
+
"idle_workers": 0, # Not tracked separately
|
|
148
|
+
"total_workers": active_worker_count,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
logger.info(
|
|
152
|
+
"found_available_worker_queues",
|
|
153
|
+
organization_id=organization_id,
|
|
154
|
+
count=len(worker_queues),
|
|
155
|
+
worker_counts=worker_counts
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return worker_queues
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.error("failed_to_get_available_worker_queues", error=str(e), organization_id=organization_id)
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def select_worker_queue(
|
|
166
|
+
organization_id: str,
|
|
167
|
+
executor_type: str,
|
|
168
|
+
worker_queue_name: Optional[str] = None,
|
|
169
|
+
environment_name: Optional[str] = None,
|
|
170
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
171
|
+
"""
|
|
172
|
+
Select appropriate worker queue for job execution.
|
|
173
|
+
|
|
174
|
+
Routing logic:
|
|
175
|
+
- AUTO: Select first available queue with idle workers (prefer idle over active)
|
|
176
|
+
- SPECIFIC_QUEUE: Use provided worker_queue_name
|
|
177
|
+
- ENVIRONMENT: Select first available queue in specified environment
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
organization_id: Organization ID
|
|
181
|
+
executor_type: Routing type ("auto", "specific_queue", "environment")
|
|
182
|
+
worker_queue_name: Explicit queue name (for SPECIFIC_QUEUE)
|
|
183
|
+
environment_name: Environment name (for ENVIRONMENT routing)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Tuple of (worker_queue_name, environment_name) or (None, None) if no workers available
|
|
187
|
+
"""
|
|
188
|
+
if executor_type == "specific_queue":
|
|
189
|
+
if not worker_queue_name:
|
|
190
|
+
raise ValueError("worker_queue_name is required for 'specific_queue' executor type")
|
|
191
|
+
return worker_queue_name, environment_name
|
|
192
|
+
|
|
193
|
+
# AUTO or ENVIRONMENT routing - need to find available workers
|
|
194
|
+
available_queues = await get_available_worker_queues(organization_id, environment_name)
|
|
195
|
+
|
|
196
|
+
if not available_queues:
|
|
197
|
+
logger.warning(
|
|
198
|
+
"no_available_worker_queues",
|
|
199
|
+
organization_id=organization_id,
|
|
200
|
+
executor_type=executor_type,
|
|
201
|
+
environment_name=environment_name,
|
|
202
|
+
)
|
|
203
|
+
return None, None
|
|
204
|
+
|
|
205
|
+
# Sort by idle workers first, then by total workers
|
|
206
|
+
# This ensures we prefer queues with capacity
|
|
207
|
+
available_queues.sort(
|
|
208
|
+
key=lambda q: (q["idle_workers"], q["total_workers"]),
|
|
209
|
+
reverse=True
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
selected = available_queues[0]
|
|
213
|
+
logger.info(
|
|
214
|
+
"selected_worker_queue",
|
|
215
|
+
organization_id=organization_id,
|
|
216
|
+
queue_name=selected["queue_name"],
|
|
217
|
+
environment_name=selected["environment_name"],
|
|
218
|
+
idle_workers=selected["idle_workers"],
|
|
219
|
+
total_workers=selected["total_workers"],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return selected["queue_name"], selected["environment_name"]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
async def resolve_job_entity(
|
|
226
|
+
supabase_client,
|
|
227
|
+
organization_id: str,
|
|
228
|
+
planning_mode: str,
|
|
229
|
+
entity_type: Optional[str],
|
|
230
|
+
entity_id: Optional[str],
|
|
231
|
+
) -> Tuple[str, str, str]:
|
|
232
|
+
"""
|
|
233
|
+
Resolve job entity (agent/team/workflow) and return execution details.
|
|
234
|
+
|
|
235
|
+
For predefined modes, validates that the entity exists and returns its details.
|
|
236
|
+
For on_the_fly mode, returns None values (planner will determine execution).
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
supabase_client: Supabase client instance
|
|
240
|
+
organization_id: Organization ID
|
|
241
|
+
planning_mode: Planning mode (on_the_fly, predefined_agent, etc.)
|
|
242
|
+
entity_type: Entity type (agent/team/workflow)
|
|
243
|
+
entity_id: Entity ID
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Tuple of (execution_type, entity_id, entity_name)
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
ValueError: If entity doesn't exist or validation fails
|
|
250
|
+
"""
|
|
251
|
+
if planning_mode == "on_the_fly":
|
|
252
|
+
# Planner will determine execution
|
|
253
|
+
return "agent", None, None
|
|
254
|
+
|
|
255
|
+
# Validate entity exists
|
|
256
|
+
if not entity_type or not entity_id:
|
|
257
|
+
raise ValueError(f"entity_type and entity_id are required for planning_mode '{planning_mode}'")
|
|
258
|
+
|
|
259
|
+
table_name = f"{entity_type}s" # agents, teams, workflows
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
result = (
|
|
263
|
+
supabase_client.table(table_name)
|
|
264
|
+
.select("id, name")
|
|
265
|
+
.eq("id", entity_id)
|
|
266
|
+
.eq("organization_id", organization_id)
|
|
267
|
+
.execute()
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
if not result.data:
|
|
271
|
+
raise ValueError(f"{entity_type} with ID {entity_id} not found")
|
|
272
|
+
|
|
273
|
+
entity = result.data[0]
|
|
274
|
+
return entity_type, entity["id"], entity["name"]
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.error(
|
|
278
|
+
"failed_to_resolve_job_entity",
|
|
279
|
+
error=str(e),
|
|
280
|
+
planning_mode=planning_mode,
|
|
281
|
+
entity_type=entity_type,
|
|
282
|
+
entity_id=entity_id,
|
|
283
|
+
)
|
|
284
|
+
raise ValueError(f"Failed to resolve {entity_type}: {str(e)}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def merge_execution_config(
|
|
288
|
+
base_config: Dict[str, Any],
|
|
289
|
+
override_config: Optional[Dict[str, Any]] = None
|
|
290
|
+
) -> Dict[str, Any]:
|
|
291
|
+
"""
|
|
292
|
+
Merge base job config with execution-specific overrides.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
base_config: Base configuration from job definition
|
|
296
|
+
override_config: Optional overrides for this execution
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Merged configuration dictionary
|
|
300
|
+
"""
|
|
301
|
+
if not override_config:
|
|
302
|
+
return base_config.copy()
|
|
303
|
+
|
|
304
|
+
# Deep merge
|
|
305
|
+
merged = base_config.copy()
|
|
306
|
+
for key, value in override_config.items():
|
|
307
|
+
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
|
308
|
+
merged[key] = {**merged[key], **value}
|
|
309
|
+
else:
|
|
310
|
+
merged[key] = value
|
|
311
|
+
|
|
312
|
+
return merged
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Kubiya API client for authentication and runner management"""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional, Dict, List
|
|
6
|
+
import structlog
|
|
7
|
+
|
|
8
|
+
logger = structlog.get_logger()
|
|
9
|
+
|
|
10
|
+
KUBIYA_API_BASE = os.environ.get("KUBIYA_API_BASE", "https://api.kubiya.ai")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class KubiyaClient:
|
|
14
|
+
"""Client for Kubiya API"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, api_base: str = KUBIYA_API_BASE):
|
|
17
|
+
self.api_base = api_base.rstrip("/")
|
|
18
|
+
self.client = httpx.AsyncClient(timeout=30.0)
|
|
19
|
+
|
|
20
|
+
async def validate_token_and_get_org(self, token: str) -> Optional[Dict]:
|
|
21
|
+
"""
|
|
22
|
+
Validate token with Kubiya API and get organization details.
|
|
23
|
+
Automatically tries both Bearer (Auth0 idToken) and UserKey (API key) authentication.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
token: Authentication token (Bearer/idToken or UserKey/API key)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Dict with organization details:
|
|
30
|
+
{
|
|
31
|
+
"id": "org-uuid",
|
|
32
|
+
"name": "Organization Name",
|
|
33
|
+
"slug": "org-slug"
|
|
34
|
+
}
|
|
35
|
+
None if invalid token
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
# Try Bearer authentication first (Auth0 idToken)
|
|
39
|
+
response = await self.client.get(
|
|
40
|
+
f"{self.api_base}/api/v1/users/me",
|
|
41
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# If Bearer fails with 401, try UserKey (API key)
|
|
45
|
+
if response.status_code == 401:
|
|
46
|
+
logger.debug("kubiya_bearer_auth_failed_trying_userkey")
|
|
47
|
+
response = await self.client.get(
|
|
48
|
+
f"{self.api_base}/api/v1/users/me",
|
|
49
|
+
headers={"Authorization": f"UserKey {token}"},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if response.status_code == 200:
|
|
53
|
+
data = response.json()
|
|
54
|
+
|
|
55
|
+
# Log full response for debugging
|
|
56
|
+
logger.info(
|
|
57
|
+
"kubiya_api_response",
|
|
58
|
+
response_keys=list(data.keys()),
|
|
59
|
+
has_org=bool(data.get("org")),
|
|
60
|
+
has_org_id=bool(data.get("org_id")),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Extract organization from response
|
|
64
|
+
# Kubiya API returns org/org_id at root level, not nested
|
|
65
|
+
org_id = data.get("org") or data.get("org_id") or data.get("organization", {}).get("uuid")
|
|
66
|
+
org_name = data.get("org_name") or data.get("organization_name") or data.get("organization", {}).get("name")
|
|
67
|
+
org_slug = data.get("org_slug") or data.get("organization_slug") or data.get("organization", {}).get("slug")
|
|
68
|
+
|
|
69
|
+
org_data = {
|
|
70
|
+
"id": org_id,
|
|
71
|
+
"name": org_name,
|
|
72
|
+
"slug": org_slug,
|
|
73
|
+
"user_id": data.get("uuid") or data.get("id"),
|
|
74
|
+
"user_email": data.get("email"),
|
|
75
|
+
"user_name": data.get("name"),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
logger.info(
|
|
79
|
+
"kubiya_token_validated",
|
|
80
|
+
org_id=org_data["id"],
|
|
81
|
+
org_name=org_data["name"],
|
|
82
|
+
user_email=org_data.get("user_email"),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return org_data
|
|
86
|
+
|
|
87
|
+
else:
|
|
88
|
+
logger.warning(
|
|
89
|
+
"kubiya_token_invalid",
|
|
90
|
+
status_code=response.status_code,
|
|
91
|
+
)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.error("kubiya_api_error", error=str(e))
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
async def get_runners(self, token: str, org_id: str) -> List[Dict]:
|
|
99
|
+
"""
|
|
100
|
+
Get available runners for organization from Kubiya API.
|
|
101
|
+
Automatically tries both Bearer and UserKey authentication methods.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
token: Authentication token (Bearer/idToken or UserKey/API key)
|
|
105
|
+
org_id: Organization UUID
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of runner dicts:
|
|
109
|
+
[
|
|
110
|
+
{
|
|
111
|
+
"name": "runner-name",
|
|
112
|
+
"wss_url": "...",
|
|
113
|
+
"task_id": "...",
|
|
114
|
+
...
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
# Try Bearer authentication first (Auth0 idToken)
|
|
120
|
+
response = await self.client.get(
|
|
121
|
+
f"{self.api_base}/api/v3/runners",
|
|
122
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# If Bearer fails with 401, try UserKey (API key)
|
|
126
|
+
if response.status_code == 401:
|
|
127
|
+
logger.info(
|
|
128
|
+
"kubiya_runners_bearer_failed_trying_userkey",
|
|
129
|
+
org_id=org_id,
|
|
130
|
+
)
|
|
131
|
+
response = await self.client.get(
|
|
132
|
+
f"{self.api_base}/api/v3/runners",
|
|
133
|
+
headers={"Authorization": f"UserKey {token}"},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if response.status_code == 200:
|
|
137
|
+
runners = response.json()
|
|
138
|
+
|
|
139
|
+
# Handle both array response and object response
|
|
140
|
+
if isinstance(runners, dict):
|
|
141
|
+
# If it's a dict, extract the array from common keys
|
|
142
|
+
runners = runners.get('runners', runners.get('data', []))
|
|
143
|
+
|
|
144
|
+
# Ensure it's a list
|
|
145
|
+
if not isinstance(runners, list):
|
|
146
|
+
logger.warning(
|
|
147
|
+
"kubiya_runners_unexpected_format",
|
|
148
|
+
type=type(runners).__name__,
|
|
149
|
+
)
|
|
150
|
+
runners = []
|
|
151
|
+
|
|
152
|
+
logger.info(
|
|
153
|
+
"kubiya_runners_fetched",
|
|
154
|
+
org_id=org_id,
|
|
155
|
+
runner_count=len(runners),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return runners
|
|
159
|
+
|
|
160
|
+
else:
|
|
161
|
+
logger.warning(
|
|
162
|
+
"kubiya_runners_fetch_failed",
|
|
163
|
+
status_code=response.status_code,
|
|
164
|
+
)
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error("kubiya_runners_error", error=str(e))
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
async def register_runner_heartbeat(
|
|
172
|
+
self, token: str, org_id: str, runner_name: str, metadata: Dict = None
|
|
173
|
+
) -> bool:
|
|
174
|
+
"""
|
|
175
|
+
Register runner heartbeat with Kubiya API.
|
|
176
|
+
|
|
177
|
+
Called by workers to report they're alive and polling.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
token: Service token for worker
|
|
181
|
+
org_id: Organization UUID
|
|
182
|
+
runner_name: Runner name
|
|
183
|
+
metadata: Additional metadata (capabilities, version, etc.)
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
True if successful, False otherwise
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
response = await self.client.post(
|
|
190
|
+
f"{self.api_base}/api/v1/runners/heartbeat",
|
|
191
|
+
headers={"Authorization": f"UserKey {token}"},
|
|
192
|
+
json={
|
|
193
|
+
"organization_id": org_id,
|
|
194
|
+
"runner_name": runner_name,
|
|
195
|
+
"status": "active",
|
|
196
|
+
"metadata": metadata or {},
|
|
197
|
+
"task_queue": f"{org_id}.{runner_name}",
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if response.status_code in [200, 201, 204]:
|
|
202
|
+
logger.info(
|
|
203
|
+
"kubiya_heartbeat_sent",
|
|
204
|
+
org_id=org_id,
|
|
205
|
+
runner_name=runner_name,
|
|
206
|
+
)
|
|
207
|
+
return True
|
|
208
|
+
else:
|
|
209
|
+
logger.warning(
|
|
210
|
+
"kubiya_heartbeat_failed",
|
|
211
|
+
status_code=response.status_code,
|
|
212
|
+
)
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.error("kubiya_heartbeat_error", error=str(e))
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
async def close(self):
|
|
220
|
+
"""Close the HTTP client"""
|
|
221
|
+
await self.client.aclose()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# Singleton instance
|
|
225
|
+
_kubiya_client: Optional[KubiyaClient] = None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_kubiya_client() -> KubiyaClient:
|
|
229
|
+
"""Get or create Kubiya client singleton"""
|
|
230
|
+
global _kubiya_client
|
|
231
|
+
|
|
232
|
+
if _kubiya_client is None:
|
|
233
|
+
_kubiya_client = KubiyaClient()
|
|
234
|
+
|
|
235
|
+
return _kubiya_client
|