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,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