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,715 @@
1
+ """
2
+ Environments router - Clean API for environment management.
3
+
4
+ This router provides /environments endpoints that map to the environments table.
5
+ The naming "environments" is internal - externally we call them "environments".
6
+ """
7
+
8
+ from fastapi import APIRouter, Depends, HTTPException, status, Request
9
+ from typing import List, Optional
10
+ from datetime import datetime
11
+ from pydantic import BaseModel, Field
12
+ import structlog
13
+ import uuid
14
+ import os
15
+
16
+ from control_plane_api.app.middleware.auth import get_current_organization
17
+ from control_plane_api.app.lib.supabase import get_supabase
18
+ from control_plane_api.app.lib.temporal_client import get_temporal_client
19
+
20
+ logger = structlog.get_logger()
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ # Execution Environment Model (shared with agents/teams)
26
+ class ExecutionEnvironment(BaseModel):
27
+ """Execution environment configuration - env vars, secrets, and integration credentials"""
28
+ env_vars: dict[str, str] = Field(default_factory=dict, description="Environment variables (key-value pairs)")
29
+ secrets: list[str] = Field(default_factory=list, description="Secret names from Kubiya vault")
30
+ integration_ids: list[str] = Field(default_factory=list, description="Integration UUIDs for delegated credentials")
31
+
32
+
33
+ # Pydantic schemas
34
+ class EnvironmentCreate(BaseModel):
35
+ name: str = Field(..., description="Environment name (e.g., default, production)", min_length=2, max_length=100)
36
+ display_name: str | None = Field(None, description="User-friendly display name")
37
+ description: str | None = Field(None, description="Environment description")
38
+ tags: List[str] = Field(default_factory=list, description="Tags for categorization")
39
+ settings: dict = Field(default_factory=dict, description="Environment settings")
40
+ execution_environment: ExecutionEnvironment | None = Field(None, description="Execution environment configuration")
41
+ # Note: priority and policy_ids not supported by environments table
42
+
43
+
44
+ class EnvironmentUpdate(BaseModel):
45
+ name: str | None = None
46
+ display_name: str | None = None
47
+ description: str | None = None
48
+ tags: List[str] | None = None
49
+ settings: dict | None = None
50
+ status: str | None = None
51
+ execution_environment: ExecutionEnvironment | None = None
52
+ # Note: priority and policy_ids not supported by environments table
53
+
54
+
55
+ class EnvironmentResponse(BaseModel):
56
+ id: str
57
+ organization_id: str
58
+ name: str
59
+ display_name: str | None
60
+ description: str | None
61
+ tags: List[str]
62
+ settings: dict
63
+ status: str
64
+ created_at: str
65
+ updated_at: str
66
+ created_by: str | None
67
+
68
+ # Temporal Cloud provisioning fields
69
+ worker_token: str | None = None
70
+ provisioning_workflow_id: str | None = None
71
+ provisioned_at: str | None = None
72
+ error_message: str | None = None
73
+ temporal_namespace_id: str | None = None
74
+
75
+ # Worker metrics (deprecated at environment level, use worker_queues)
76
+ active_workers: int = 0
77
+ idle_workers: int = 0
78
+ busy_workers: int = 0
79
+
80
+ # Skills (populated from associations)
81
+ skill_ids: List[str] = []
82
+ skills: List[dict] = []
83
+
84
+ # Execution environment configuration
85
+ execution_environment: dict = {}
86
+
87
+
88
+ class WorkerCommandResponse(BaseModel):
89
+ """Response with worker registration command"""
90
+ worker_token: str
91
+ environment_name: str
92
+ command: str
93
+ command_parts: dict
94
+ namespace_status: str
95
+ can_register: bool
96
+ provisioning_workflow_id: str | None = None
97
+
98
+
99
+ def ensure_default_environment(organization: dict) -> Optional[dict]:
100
+ """
101
+ Ensure the organization has a default environment.
102
+ Creates one if it doesn't exist.
103
+ """
104
+ try:
105
+ client = get_supabase()
106
+
107
+ # Check if default environment exists
108
+ existing = (
109
+ client.table("environments")
110
+ .select("*")
111
+ .eq("organization_id", organization["id"])
112
+ .eq("name", "default")
113
+ .execute()
114
+ )
115
+
116
+ if existing.data:
117
+ return existing.data[0]
118
+
119
+ # Create default environment
120
+ env_id = str(uuid.uuid4())
121
+ now = datetime.utcnow().isoformat()
122
+
123
+ default_env = {
124
+ "id": env_id,
125
+ "organization_id": organization["id"],
126
+ "name": "default",
127
+ "display_name": "Default Environment",
128
+ "description": "Default environment for all workers",
129
+ "tags": [],
130
+ "settings": {},
131
+ "status": "active",
132
+ "created_at": now,
133
+ "updated_at": now,
134
+ "created_by": organization.get("user_id"),
135
+ }
136
+
137
+ result = client.table("environments").insert(default_env).execute()
138
+
139
+ if result.data:
140
+ logger.info(
141
+ "default_environment_created",
142
+ environment_id=env_id,
143
+ org_id=organization["id"],
144
+ )
145
+ return result.data[0]
146
+
147
+ return None
148
+
149
+ except Exception as e:
150
+ logger.error("ensure_default_environment_failed", error=str(e), org_id=organization.get("id"))
151
+ return None
152
+
153
+
154
+ def get_environment_skills(client, organization_id: str, environment_id: str) -> tuple[List[str], List[dict]]:
155
+ """Get skills associated with an environment"""
156
+ try:
157
+ # Get associations with full skill data
158
+ result = (
159
+ client.table("skill_associations")
160
+ .select("skill_id, configuration_override, skills(*)")
161
+ .eq("organization_id", organization_id)
162
+ .eq("entity_type", "environment")
163
+ .eq("entity_id", environment_id)
164
+ .execute()
165
+ )
166
+
167
+ skill_ids = []
168
+ skills = []
169
+
170
+ for item in result.data:
171
+ skill_data = item.get("skills")
172
+ if skill_data:
173
+ skill_ids.append(skill_data["id"])
174
+
175
+ # Merge configuration with override
176
+ config = skill_data.get("configuration", {})
177
+ override = item.get("configuration_override")
178
+ if override:
179
+ config = {**config, **override}
180
+
181
+ skills.append({
182
+ "id": skill_data["id"],
183
+ "name": skill_data["name"],
184
+ "type": skill_data["skill_type"],
185
+ "description": skill_data.get("description"),
186
+ "enabled": skill_data.get("enabled", True),
187
+ "configuration": config,
188
+ })
189
+
190
+ return skill_ids, skills
191
+
192
+ except Exception as e:
193
+ logger.error("get_environment_skills_failed", error=str(e), environment_id=environment_id)
194
+ return [], []
195
+
196
+
197
+ @router.post("", response_model=EnvironmentResponse, status_code=status.HTTP_201_CREATED)
198
+ async def create_environment(
199
+ env_data: EnvironmentCreate,
200
+ request: Request,
201
+ organization: dict = Depends(get_current_organization),
202
+ ):
203
+ """
204
+ Create a new environment.
205
+
206
+ If this is the first environment for the organization, it will trigger
207
+ Temporal Cloud namespace provisioning workflow.
208
+ """
209
+ try:
210
+ client = get_supabase()
211
+
212
+ # Check if environment name already exists
213
+ existing = (
214
+ client.table("environments")
215
+ .select("id")
216
+ .eq("organization_id", organization["id"])
217
+ .eq("name", env_data.name)
218
+ .execute()
219
+ )
220
+
221
+ if existing.data:
222
+ raise HTTPException(
223
+ status_code=status.HTTP_409_CONFLICT,
224
+ detail=f"Environment with name '{env_data.name}' already exists"
225
+ )
226
+
227
+ # Check if this is the first environment
228
+ all_envs = (
229
+ client.table("environments")
230
+ .select("id")
231
+ .eq("organization_id", organization["id"])
232
+ .execute()
233
+ )
234
+ is_first_env = len(all_envs.data or []) == 0
235
+
236
+ # Check if namespace already exists
237
+ namespace_result = (
238
+ client.table("temporal_namespaces")
239
+ .select("*")
240
+ .eq("organization_id", organization["id"])
241
+ .execute()
242
+ )
243
+ has_namespace = bool(namespace_result.data)
244
+ needs_provisioning = is_first_env and not has_namespace
245
+
246
+ env_id = str(uuid.uuid4())
247
+ now = datetime.utcnow().isoformat()
248
+
249
+ # Set initial status
250
+ initial_status = "provisioning" if needs_provisioning else "ready"
251
+
252
+ env_record = {
253
+ "id": env_id,
254
+ "organization_id": organization["id"],
255
+ "name": env_data.name,
256
+ "display_name": env_data.display_name or env_data.name,
257
+ "description": env_data.description,
258
+ "tags": env_data.tags,
259
+ "settings": env_data.settings,
260
+ "status": initial_status,
261
+ "created_at": now,
262
+ "updated_at": now,
263
+ "created_by": organization.get("user_id"),
264
+ "worker_token": str(uuid.uuid4()),
265
+ "execution_environment": env_data.execution_environment.model_dump() if env_data.execution_environment else {},
266
+ }
267
+
268
+ result = client.table("environments").insert(env_record).execute()
269
+
270
+ if not result.data:
271
+ raise HTTPException(
272
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
273
+ detail="Failed to create environment"
274
+ )
275
+
276
+ environment = result.data[0]
277
+
278
+ # Trigger namespace provisioning if needed
279
+ if needs_provisioning:
280
+ try:
281
+ from control_plane_api.app.workflows.namespace_provisioning import (
282
+ ProvisionTemporalNamespaceWorkflow,
283
+ ProvisionNamespaceInput,
284
+ )
285
+
286
+ temporal_client = await get_temporal_client()
287
+ account_id = os.environ.get("TEMPORAL_CLOUD_ACCOUNT_ID", "default-account")
288
+
289
+ workflow_input = ProvisionNamespaceInput(
290
+ organization_id=organization["id"],
291
+ organization_name=organization.get("name", organization["id"]),
292
+ task_queue_id=env_id,
293
+ account_id=account_id,
294
+ region=os.environ.get("TEMPORAL_CLOUD_REGION", "aws-us-east-1"),
295
+ )
296
+
297
+ workflow_handle = await temporal_client.start_workflow(
298
+ ProvisionTemporalNamespaceWorkflow.run,
299
+ workflow_input,
300
+ id=f"provision-namespace-{organization['id']}",
301
+ task_queue="agent-control-plane",
302
+ )
303
+
304
+ client.table("environments").update({
305
+ "provisioning_workflow_id": workflow_handle.id,
306
+ "updated_at": datetime.utcnow().isoformat(),
307
+ }).eq("id", env_id).execute()
308
+
309
+ environment["provisioning_workflow_id"] = workflow_handle.id
310
+
311
+ logger.info(
312
+ "namespace_provisioning_workflow_started",
313
+ workflow_id=workflow_handle.id,
314
+ environment_id=env_id,
315
+ org_id=organization["id"],
316
+ )
317
+ except Exception as e:
318
+ logger.error(
319
+ "failed_to_start_provisioning_workflow",
320
+ error=str(e),
321
+ environment_id=env_id,
322
+ org_id=organization["id"],
323
+ )
324
+ client.table("environments").update({
325
+ "status": "error",
326
+ "error_message": f"Failed to start provisioning: {str(e)}",
327
+ "updated_at": datetime.utcnow().isoformat(),
328
+ }).eq("id", env_id).execute()
329
+ environment["status"] = "error"
330
+ environment["error_message"] = f"Failed to start provisioning: {str(e)}"
331
+
332
+ logger.info(
333
+ "environment_created",
334
+ environment_id=env_id,
335
+ environment_name=environment["name"],
336
+ org_id=organization["id"],
337
+ needs_provisioning=needs_provisioning,
338
+ )
339
+
340
+ return EnvironmentResponse(
341
+ **environment,
342
+ active_workers=0,
343
+ idle_workers=0,
344
+ busy_workers=0,
345
+ skill_ids=[],
346
+ skills=[],
347
+ )
348
+
349
+ except HTTPException:
350
+ raise
351
+ except Exception as e:
352
+ logger.error("environment_creation_failed", error=str(e), org_id=organization["id"])
353
+ raise HTTPException(
354
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
355
+ detail=f"Failed to create environment: {str(e)}"
356
+ )
357
+
358
+
359
+ @router.get("", response_model=List[EnvironmentResponse])
360
+ async def list_environments(
361
+ request: Request,
362
+ status_filter: str | None = None,
363
+ organization: dict = Depends(get_current_organization),
364
+ ):
365
+ """List all environments in the organization"""
366
+ try:
367
+ client = get_supabase()
368
+
369
+ # Ensure default environment exists
370
+ ensure_default_environment(organization)
371
+
372
+ # Query environments
373
+ query = client.table("environments").select("*").eq("organization_id", organization["id"])
374
+
375
+ if status_filter:
376
+ query = query.eq("status", status_filter)
377
+
378
+ query = query.order("created_at", desc=False)
379
+ result = query.execute()
380
+
381
+ if not result.data:
382
+ return []
383
+
384
+ # BATCH FETCH: Get all skills for all environments in one query
385
+ environment_ids = [env["id"] for env in result.data]
386
+ skills_result = (
387
+ client.table("skill_associations")
388
+ .select("entity_id, skill_id, configuration_override, skills(*)")
389
+ .eq("organization_id", organization["id"])
390
+ .eq("entity_type", "environment")
391
+ .in_("entity_id", environment_ids)
392
+ .execute()
393
+ )
394
+
395
+ # Group skills by environment_id
396
+ skills_by_env = {}
397
+ for item in skills_result.data or []:
398
+ env_id = item["entity_id"]
399
+ skill_data = item.get("skills")
400
+ if skill_data:
401
+ if env_id not in skills_by_env:
402
+ skills_by_env[env_id] = {"ids": [], "data": []}
403
+
404
+ # Merge configuration with override
405
+ config = skill_data.get("configuration", {})
406
+ override = item.get("configuration_override")
407
+ if override:
408
+ config = {**config, **override}
409
+
410
+ skills_by_env[env_id]["ids"].append(skill_data["id"])
411
+ skills_by_env[env_id]["data"].append({
412
+ "id": skill_data["id"],
413
+ "name": skill_data["name"],
414
+ "type": skill_data["skill_type"],
415
+ "description": skill_data.get("description"),
416
+ "enabled": skill_data.get("enabled", True),
417
+ "configuration": config,
418
+ })
419
+
420
+ # Build environment responses
421
+ environments = []
422
+ for env in result.data:
423
+ env_skills = skills_by_env.get(env["id"], {"ids": [], "data": []})
424
+
425
+ environments.append(
426
+ EnvironmentResponse(
427
+ **env,
428
+ active_workers=0,
429
+ idle_workers=0,
430
+ busy_workers=0,
431
+ skill_ids=env_skills["ids"],
432
+ skills=env_skills["data"],
433
+ )
434
+ )
435
+
436
+ logger.info(
437
+ "environments_listed",
438
+ count=len(environments),
439
+ org_id=organization["id"],
440
+ )
441
+
442
+ return environments
443
+
444
+ except Exception as e:
445
+ logger.error("environments_list_failed", error=str(e), org_id=organization["id"])
446
+ raise HTTPException(
447
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
448
+ detail=f"Failed to list environments: {str(e)}"
449
+ )
450
+
451
+
452
+ @router.get("/{environment_id}", response_model=EnvironmentResponse)
453
+ async def get_environment(
454
+ environment_id: str,
455
+ request: Request,
456
+ organization: dict = Depends(get_current_organization),
457
+ ):
458
+ """Get a specific environment by ID"""
459
+ try:
460
+ client = get_supabase()
461
+
462
+ result = (
463
+ client.table("environments")
464
+ .select("*")
465
+ .eq("id", environment_id)
466
+ .eq("organization_id", organization["id"])
467
+ .single()
468
+ .execute()
469
+ )
470
+
471
+ if not result.data:
472
+ raise HTTPException(status_code=404, detail="Environment not found")
473
+
474
+ environment = result.data
475
+
476
+ # Get skills
477
+ skill_ids, skills = get_environment_skills(client, organization["id"], environment_id)
478
+
479
+ return EnvironmentResponse(
480
+ **environment,
481
+ active_workers=0,
482
+ idle_workers=0,
483
+ busy_workers=0,
484
+ skill_ids=skill_ids,
485
+ skills=skills,
486
+ )
487
+
488
+ except HTTPException:
489
+ raise
490
+ except Exception as e:
491
+ logger.error("environment_get_failed", error=str(e), environment_id=environment_id)
492
+ raise HTTPException(
493
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
494
+ detail=f"Failed to get environment: {str(e)}"
495
+ )
496
+
497
+
498
+ @router.patch("/{environment_id}", response_model=EnvironmentResponse)
499
+ async def update_environment(
500
+ environment_id: str,
501
+ env_data: EnvironmentUpdate,
502
+ request: Request,
503
+ organization: dict = Depends(get_current_organization),
504
+ ):
505
+ """Update an environment"""
506
+ try:
507
+ client = get_supabase()
508
+
509
+ # Check if environment exists
510
+ existing = (
511
+ client.table("environments")
512
+ .select("id")
513
+ .eq("id", environment_id)
514
+ .eq("organization_id", organization["id"])
515
+ .execute()
516
+ )
517
+
518
+ if not existing.data:
519
+ raise HTTPException(status_code=404, detail="Environment not found")
520
+
521
+ # Build update dict
522
+ update_data = env_data.model_dump(exclude_unset=True)
523
+
524
+ # Convert execution_environment Pydantic model to dict if present
525
+ if "execution_environment" in update_data and update_data["execution_environment"]:
526
+ if hasattr(update_data["execution_environment"], "model_dump"):
527
+ update_data["execution_environment"] = update_data["execution_environment"].model_dump()
528
+
529
+ update_data["updated_at"] = datetime.utcnow().isoformat()
530
+
531
+ # Update environment
532
+ result = (
533
+ client.table("environments")
534
+ .update(update_data)
535
+ .eq("id", environment_id)
536
+ .eq("organization_id", organization["id"])
537
+ .execute()
538
+ )
539
+
540
+ if not result.data:
541
+ raise HTTPException(
542
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
543
+ detail="Failed to update environment"
544
+ )
545
+
546
+ environment = result.data[0]
547
+
548
+ # Get skills
549
+ skill_ids, skills = get_environment_skills(client, organization["id"], environment_id)
550
+
551
+ logger.info(
552
+ "environment_updated",
553
+ environment_id=environment_id,
554
+ org_id=organization["id"],
555
+ )
556
+
557
+ return EnvironmentResponse(
558
+ **environment,
559
+ active_workers=0,
560
+ idle_workers=0,
561
+ busy_workers=0,
562
+ skill_ids=skill_ids,
563
+ skills=skills,
564
+ )
565
+
566
+ except HTTPException:
567
+ raise
568
+ except Exception as e:
569
+ logger.error("environment_update_failed", error=str(e), environment_id=environment_id)
570
+ raise HTTPException(
571
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
572
+ detail=f"Failed to update environment: {str(e)}"
573
+ )
574
+
575
+
576
+ @router.delete("/{environment_id}", status_code=status.HTTP_204_NO_CONTENT)
577
+ async def delete_environment(
578
+ environment_id: str,
579
+ request: Request,
580
+ organization: dict = Depends(get_current_organization),
581
+ ):
582
+ """Delete an environment"""
583
+ try:
584
+ client = get_supabase()
585
+
586
+ # Prevent deleting default environment
587
+ env_check = (
588
+ client.table("environments")
589
+ .select("name")
590
+ .eq("id", environment_id)
591
+ .eq("organization_id", organization["id"])
592
+ .single()
593
+ .execute()
594
+ )
595
+
596
+ if env_check.data and env_check.data.get("name") == "default":
597
+ raise HTTPException(
598
+ status_code=status.HTTP_400_BAD_REQUEST,
599
+ detail="Cannot delete the default environment"
600
+ )
601
+
602
+ result = (
603
+ client.table("environments")
604
+ .delete()
605
+ .eq("id", environment_id)
606
+ .eq("organization_id", organization["id"])
607
+ .execute()
608
+ )
609
+
610
+ if not result.data:
611
+ raise HTTPException(status_code=404, detail="Environment not found")
612
+
613
+ logger.info("environment_deleted", environment_id=environment_id, org_id=organization["id"])
614
+
615
+ return None
616
+
617
+ except HTTPException:
618
+ raise
619
+ except Exception as e:
620
+ logger.error("environment_delete_failed", error=str(e), environment_id=environment_id)
621
+ raise HTTPException(
622
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
623
+ detail=f"Failed to delete environment: {str(e)}"
624
+ )
625
+
626
+
627
+ @router.get("/{environment_id}/worker-command", response_model=WorkerCommandResponse)
628
+ async def get_worker_registration_command(
629
+ environment_id: str,
630
+ request: Request,
631
+ organization: dict = Depends(get_current_organization),
632
+ ):
633
+ """
634
+ Get the worker registration command for an environment.
635
+
636
+ Returns the kubiya worker start command with the worker token.
637
+ """
638
+ try:
639
+ client = get_supabase()
640
+
641
+ # Get environment
642
+ result = (
643
+ client.table("environments")
644
+ .select("*")
645
+ .eq("id", environment_id)
646
+ .eq("organization_id", organization["id"])
647
+ .single()
648
+ .execute()
649
+ )
650
+
651
+ if not result.data:
652
+ raise HTTPException(status_code=404, detail="Environment not found")
653
+
654
+ environment = result.data
655
+ worker_token = environment.get("worker_token")
656
+
657
+ # Generate worker_token if it doesn't exist
658
+ if not worker_token:
659
+ worker_token = str(uuid.uuid4())
660
+ client.table("environments").update({
661
+ "worker_token": worker_token,
662
+ "updated_at": datetime.utcnow().isoformat(),
663
+ }).eq("id", environment_id).execute()
664
+
665
+ logger.info(
666
+ "worker_token_generated",
667
+ environment_id=environment_id,
668
+ org_id=organization["id"],
669
+ )
670
+
671
+ environment_name = environment["name"]
672
+ namespace_status = environment.get("status", "unknown")
673
+ provisioning_workflow_id = environment.get("provisioning_workflow_id")
674
+
675
+ # Check if namespace is ready
676
+ can_register = namespace_status in ["ready", "active"]
677
+
678
+ # Build command
679
+ command = f"kubiya worker start --token {worker_token} --environment {environment_name}"
680
+
681
+ command_parts = {
682
+ "binary": "kubiya",
683
+ "subcommand": "worker start",
684
+ "flags": {
685
+ "--token": worker_token,
686
+ "--environment": environment_name,
687
+ },
688
+ }
689
+
690
+ logger.info(
691
+ "worker_command_retrieved",
692
+ environment_id=environment_id,
693
+ can_register=can_register,
694
+ status=namespace_status,
695
+ org_id=organization["id"],
696
+ )
697
+
698
+ return WorkerCommandResponse(
699
+ worker_token=worker_token,
700
+ environment_name=environment_name,
701
+ command=command,
702
+ command_parts=command_parts,
703
+ namespace_status=namespace_status,
704
+ can_register=can_register,
705
+ provisioning_workflow_id=provisioning_workflow_id,
706
+ )
707
+
708
+ except HTTPException:
709
+ raise
710
+ except Exception as e:
711
+ logger.error("worker_command_get_failed", error=str(e), environment_id=environment_id)
712
+ raise HTTPException(
713
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
714
+ detail=f"Failed to get worker command: {str(e)}"
715
+ )