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,902 @@
1
+ """
2
+ Projects router - Jira-style multi-project management.
3
+
4
+ This router handles project CRUD operations and manages associations
5
+ between projects, agents, and teams.
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
+
15
+ from control_plane_api.app.middleware.auth import get_current_organization
16
+ from control_plane_api.app.lib.supabase import get_supabase
17
+
18
+ logger = structlog.get_logger()
19
+
20
+ router = APIRouter()
21
+
22
+
23
+ # Pydantic schemas
24
+ class ProjectCreate(BaseModel):
25
+ name: str = Field(..., description="Project name")
26
+ key: str = Field(..., description="Short project key (e.g., JIRA, PROJ)", min_length=2, max_length=50)
27
+ description: str | None = Field(None, description="Project description")
28
+ goals: str | None = Field(None, description="Project goals and objectives")
29
+ settings: dict = Field(default_factory=dict, description="Project settings")
30
+ visibility: str = Field("private", description="Project visibility: private or org")
31
+ restrict_to_environment: bool = Field(False, description="Restrict to specific runners/environment")
32
+ policy_ids: List[str] = Field(default_factory=list, description="List of OPA policy IDs for access control")
33
+ default_model: str | None = Field(None, description="Default LLM model for this project")
34
+
35
+
36
+ class ProjectUpdate(BaseModel):
37
+ name: str | None = None
38
+ key: str | None = None
39
+ description: str | None = None
40
+ goals: str | None = None
41
+ settings: dict | None = None
42
+ status: str | None = None
43
+ visibility: str | None = None
44
+ restrict_to_environment: bool | None = None
45
+ policy_ids: List[str] | None = None
46
+ default_model: str | None = None
47
+
48
+
49
+ class ProjectResponse(BaseModel):
50
+ id: str
51
+ organization_id: str
52
+ name: str
53
+ key: str
54
+ description: str | None
55
+ goals: str | None
56
+ settings: dict
57
+ status: str
58
+ visibility: str
59
+ owner_id: str | None
60
+ owner_email: str | None
61
+ restrict_to_environment: bool = False
62
+ policy_ids: List[str] = []
63
+ default_model: str | None = None
64
+ created_at: str
65
+ updated_at: str
66
+ archived_at: str | None
67
+
68
+ # Counts
69
+ agent_count: int = 0
70
+ team_count: int = 0
71
+
72
+
73
+ class ProjectAgentAdd(BaseModel):
74
+ agent_id: str = Field(..., description="Agent UUID to add to project")
75
+ role: str | None = Field(None, description="Agent role in project")
76
+
77
+
78
+ class ProjectTeamAdd(BaseModel):
79
+ team_id: str = Field(..., description="Team UUID to add to project")
80
+ role: str | None = Field(None, description="Team role in project")
81
+
82
+
83
+ def ensure_default_project(organization: dict) -> Optional[dict]:
84
+ """
85
+ Ensure the organization has a default project.
86
+ Creates one if it doesn't exist.
87
+
88
+ Returns the default project or None if creation failed.
89
+ """
90
+ try:
91
+ client = get_supabase()
92
+
93
+ # Check if default project exists
94
+ existing = (
95
+ client.table("projects")
96
+ .select("*")
97
+ .eq("organization_id", organization["id"])
98
+ .eq("key", "DEFAULT")
99
+ .execute()
100
+ )
101
+
102
+ if existing.data:
103
+ return existing.data[0]
104
+
105
+ # Create default project
106
+ project_id = str(uuid.uuid4())
107
+ now = datetime.utcnow().isoformat()
108
+
109
+ default_project = {
110
+ "id": project_id,
111
+ "organization_id": organization["id"],
112
+ "name": "Default",
113
+ "key": "DEFAULT",
114
+ "description": "Default project for agents and teams",
115
+ "settings": {
116
+ "policy_ids": [],
117
+ "default_model": None,
118
+ "goals": None,
119
+ "restrict_to_environment": False
120
+ },
121
+ "status": "active",
122
+ "visibility": "org",
123
+ "owner_id": organization.get("user_id"),
124
+ "owner_email": organization.get("user_email"),
125
+ "created_at": now,
126
+ "updated_at": now,
127
+ }
128
+
129
+ result = client.table("projects").insert(default_project).execute()
130
+
131
+ if result.data:
132
+ logger.info(
133
+ "default_project_created",
134
+ project_id=project_id,
135
+ org_id=organization["id"],
136
+ )
137
+ return result.data[0]
138
+
139
+ return None
140
+
141
+ except Exception as e:
142
+ logger.error("ensure_default_project_failed", error=str(e), org_id=organization.get("id"))
143
+ return None
144
+
145
+
146
+ def get_default_project_id(organization: dict) -> Optional[str]:
147
+ """
148
+ Get the default project ID for an organization.
149
+ Creates the default project if it doesn't exist.
150
+
151
+ Returns the project ID or None if creation failed.
152
+ """
153
+ project = ensure_default_project(organization)
154
+ return project["id"] if project else None
155
+
156
+
157
+ @router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED)
158
+ async def create_project(
159
+ project_data: ProjectCreate,
160
+ request: Request,
161
+ organization: dict = Depends(get_current_organization),
162
+ ):
163
+ """Create a new project"""
164
+ try:
165
+ client = get_supabase()
166
+
167
+ # Check if key already exists for this organization
168
+ existing = (
169
+ client.table("projects")
170
+ .select("id")
171
+ .eq("organization_id", organization["id"])
172
+ .eq("key", project_data.key.upper())
173
+ .execute()
174
+ )
175
+
176
+ if existing.data:
177
+ raise HTTPException(
178
+ status_code=status.HTTP_409_CONFLICT,
179
+ detail=f"Project with key '{project_data.key.upper()}' already exists"
180
+ )
181
+
182
+ project_id = str(uuid.uuid4())
183
+ now = datetime.utcnow().isoformat()
184
+
185
+ project_record = {
186
+ "id": project_id,
187
+ "organization_id": organization["id"],
188
+ "name": project_data.name,
189
+ "key": project_data.key.upper(),
190
+ "description": project_data.description,
191
+ # Store policy_ids, default_model, goals, and restrict_to_environment in settings JSON field
192
+ "settings": {
193
+ **project_data.settings,
194
+ "policy_ids": project_data.policy_ids,
195
+ "default_model": project_data.default_model,
196
+ "goals": project_data.goals,
197
+ "restrict_to_environment": project_data.restrict_to_environment
198
+ },
199
+ "status": "active",
200
+ "visibility": project_data.visibility,
201
+ "owner_id": organization.get("user_id"),
202
+ "owner_email": organization.get("user_email"),
203
+ "created_at": now,
204
+ "updated_at": now,
205
+ }
206
+
207
+ result = client.table("projects").insert(project_record).execute()
208
+
209
+ if not result.data:
210
+ raise HTTPException(
211
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
212
+ detail="Failed to create project"
213
+ )
214
+
215
+ project = result.data[0]
216
+
217
+ logger.info(
218
+ "project_created",
219
+ project_id=project_id,
220
+ project_key=project["key"],
221
+ org_id=organization["id"],
222
+ )
223
+
224
+ # Extract policy_ids, default_model, goals, and restrict_to_environment from settings for response
225
+ policy_ids = project.get("settings", {}).get("policy_ids", [])
226
+ default_model = project.get("settings", {}).get("default_model")
227
+ goals = project.get("settings", {}).get("goals")
228
+ restrict_to_environment = project.get("settings", {}).get("restrict_to_environment", False)
229
+
230
+ return ProjectResponse(
231
+ **{**project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
232
+ agent_count=0,
233
+ team_count=0,
234
+ )
235
+
236
+ except HTTPException:
237
+ raise
238
+ except Exception as e:
239
+ logger.error("project_creation_failed", error=str(e), org_id=organization["id"])
240
+ raise HTTPException(
241
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
242
+ detail=f"Failed to create project: {str(e)}"
243
+ )
244
+
245
+
246
+ @router.get("/default", response_model=ProjectResponse)
247
+ async def get_default_project(
248
+ request: Request,
249
+ organization: dict = Depends(get_current_organization),
250
+ ):
251
+ """Get the default project for the organization (creates if doesn't exist)"""
252
+ try:
253
+ client = get_supabase()
254
+
255
+ # Ensure default project exists
256
+ default_project = ensure_default_project(organization)
257
+
258
+ if not default_project:
259
+ raise HTTPException(
260
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
261
+ detail="Failed to get or create default project"
262
+ )
263
+
264
+ # Get counts for the default project
265
+ agent_count_result = (
266
+ client.table("project_agents")
267
+ .select("id", count="exact")
268
+ .eq("project_id", default_project["id"])
269
+ .execute()
270
+ )
271
+ agent_count = agent_count_result.count or 0
272
+
273
+ team_count_result = (
274
+ client.table("project_teams")
275
+ .select("id", count="exact")
276
+ .eq("project_id", default_project["id"])
277
+ .execute()
278
+ )
279
+ team_count = team_count_result.count or 0
280
+
281
+ # Extract settings fields
282
+ policy_ids = default_project.get("settings", {}).get("policy_ids", [])
283
+ default_model = default_project.get("settings", {}).get("default_model")
284
+ goals = default_project.get("settings", {}).get("goals")
285
+ restrict_to_environment = default_project.get("settings", {}).get("restrict_to_environment", False)
286
+
287
+ return ProjectResponse(
288
+ **{**default_project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
289
+ agent_count=agent_count,
290
+ team_count=team_count,
291
+ )
292
+
293
+ except HTTPException:
294
+ raise
295
+ except Exception as e:
296
+ logger.error("get_default_project_failed", error=str(e), org_id=organization["id"])
297
+ raise HTTPException(
298
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
299
+ detail=f"Failed to get default project: {str(e)}"
300
+ )
301
+
302
+
303
+ @router.get("", response_model=List[ProjectResponse])
304
+ async def list_projects(
305
+ request: Request,
306
+ status_filter: str | None = None,
307
+ organization: dict = Depends(get_current_organization),
308
+ ):
309
+ """List all projects in the organization"""
310
+ try:
311
+ client = get_supabase()
312
+
313
+ # Ensure default project exists for this organization
314
+ ensure_default_project(organization)
315
+
316
+ # Query projects
317
+ query = client.table("projects").select("*").eq("organization_id", organization["id"])
318
+
319
+ if status_filter:
320
+ query = query.eq("status", status_filter)
321
+
322
+ query = query.order("created_at", desc=True)
323
+ result = query.execute()
324
+
325
+ if not result.data:
326
+ return []
327
+
328
+ # Batch fetch all agent counts in one query
329
+ project_ids = [project["id"] for project in result.data]
330
+ agent_counts_result = (
331
+ client.table("project_agents")
332
+ .select("project_id")
333
+ .in_("project_id", project_ids)
334
+ .execute()
335
+ )
336
+
337
+ # Count agents per project
338
+ agent_count_map = {}
339
+ for item in agent_counts_result.data or []:
340
+ project_id = item["project_id"]
341
+ agent_count_map[project_id] = agent_count_map.get(project_id, 0) + 1
342
+
343
+ # Batch fetch all team counts in one query
344
+ team_counts_result = (
345
+ client.table("project_teams")
346
+ .select("project_id")
347
+ .in_("project_id", project_ids)
348
+ .execute()
349
+ )
350
+
351
+ # Count teams per project
352
+ team_count_map = {}
353
+ for item in team_counts_result.data or []:
354
+ project_id = item["project_id"]
355
+ team_count_map[project_id] = team_count_map.get(project_id, 0) + 1
356
+
357
+ # Build response with pre-fetched counts
358
+ projects = []
359
+ for project in result.data:
360
+ # Extract policy_ids, default_model, goals, and restrict_to_environment from settings for response
361
+ policy_ids = project.get("settings", {}).get("policy_ids", [])
362
+ default_model = project.get("settings", {}).get("default_model")
363
+ goals = project.get("settings", {}).get("goals")
364
+ restrict_to_environment = project.get("settings", {}).get("restrict_to_environment", False)
365
+
366
+ projects.append(
367
+ ProjectResponse(
368
+ **{**project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
369
+ agent_count=agent_count_map.get(project["id"], 0),
370
+ team_count=team_count_map.get(project["id"], 0),
371
+ )
372
+ )
373
+
374
+ logger.info(
375
+ "projects_listed",
376
+ count=len(projects),
377
+ org_id=organization["id"],
378
+ )
379
+
380
+ return projects
381
+
382
+ except Exception as e:
383
+ logger.error("projects_list_failed", error=str(e), org_id=organization["id"])
384
+ raise HTTPException(
385
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
386
+ detail=f"Failed to list projects: {str(e)}"
387
+ )
388
+
389
+
390
+ @router.get("/{project_id}", response_model=ProjectResponse)
391
+ async def get_project(
392
+ project_id: str,
393
+ request: Request,
394
+ organization: dict = Depends(get_current_organization),
395
+ ):
396
+ """Get a specific project by ID"""
397
+ try:
398
+ client = get_supabase()
399
+
400
+ result = (
401
+ client.table("projects")
402
+ .select("*")
403
+ .eq("id", project_id)
404
+ .eq("organization_id", organization["id"])
405
+ .single()
406
+ .execute()
407
+ )
408
+
409
+ if not result.data:
410
+ raise HTTPException(status_code=404, detail="Project not found")
411
+
412
+ project = result.data
413
+
414
+ # Get counts
415
+ agent_count_result = (
416
+ client.table("project_agents")
417
+ .select("id", count="exact")
418
+ .eq("project_id", project_id)
419
+ .execute()
420
+ )
421
+ agent_count = agent_count_result.count or 0
422
+
423
+ team_count_result = (
424
+ client.table("project_teams")
425
+ .select("id", count="exact")
426
+ .eq("project_id", project_id)
427
+ .execute()
428
+ )
429
+ team_count = team_count_result.count or 0
430
+
431
+ # Extract policy_ids, default_model, goals, and restrict_to_environment from settings for response
432
+ policy_ids = project.get("settings", {}).get("policy_ids", [])
433
+ default_model = project.get("settings", {}).get("default_model")
434
+ goals = project.get("settings", {}).get("goals")
435
+ restrict_to_environment = project.get("settings", {}).get("restrict_to_environment", False)
436
+
437
+ return ProjectResponse(
438
+ **{**project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
439
+ agent_count=agent_count,
440
+ team_count=team_count,
441
+ )
442
+
443
+ except HTTPException:
444
+ raise
445
+ except Exception as e:
446
+ logger.error("project_get_failed", error=str(e), project_id=project_id)
447
+ raise HTTPException(
448
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
449
+ detail=f"Failed to get project: {str(e)}"
450
+ )
451
+
452
+
453
+ @router.patch("/{project_id}", response_model=ProjectResponse)
454
+ async def update_project(
455
+ project_id: str,
456
+ project_data: ProjectUpdate,
457
+ request: Request,
458
+ organization: dict = Depends(get_current_organization),
459
+ ):
460
+ """Update a project"""
461
+ try:
462
+ client = get_supabase()
463
+
464
+ # Check if project exists
465
+ existing = (
466
+ client.table("projects")
467
+ .select("id")
468
+ .eq("id", project_id)
469
+ .eq("organization_id", organization["id"])
470
+ .execute()
471
+ )
472
+
473
+ if not existing.data:
474
+ raise HTTPException(status_code=404, detail="Project not found")
475
+
476
+ # Build update dict
477
+ update_data = project_data.model_dump(exclude_unset=True)
478
+
479
+ # Handle policy_ids, default_model, goals, and restrict_to_environment - store in settings if provided
480
+ settings_updates = {}
481
+ if "policy_ids" in update_data:
482
+ settings_updates["policy_ids"] = update_data.pop("policy_ids")
483
+ if "default_model" in update_data:
484
+ settings_updates["default_model"] = update_data.pop("default_model")
485
+ if "goals" in update_data:
486
+ settings_updates["goals"] = update_data.pop("goals")
487
+ if "restrict_to_environment" in update_data:
488
+ settings_updates["restrict_to_environment"] = update_data.pop("restrict_to_environment")
489
+
490
+ # Apply settings updates if any
491
+ if settings_updates:
492
+ if "settings" in update_data:
493
+ update_data["settings"].update(settings_updates)
494
+ else:
495
+ # Need to merge with existing settings
496
+ existing_project = (
497
+ client.table("projects")
498
+ .select("settings")
499
+ .eq("id", project_id)
500
+ .single()
501
+ .execute()
502
+ )
503
+ existing_settings = existing_project.data.get("settings", {}) if existing_project.data else {}
504
+ update_data["settings"] = {**existing_settings, **settings_updates}
505
+
506
+ # Uppercase key if provided
507
+ if "key" in update_data:
508
+ update_data["key"] = update_data["key"].upper()
509
+
510
+ update_data["updated_at"] = datetime.utcnow().isoformat()
511
+
512
+ # Update project
513
+ result = (
514
+ client.table("projects")
515
+ .update(update_data)
516
+ .eq("id", project_id)
517
+ .eq("organization_id", organization["id"])
518
+ .execute()
519
+ )
520
+
521
+ if not result.data:
522
+ raise HTTPException(
523
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
524
+ detail="Failed to update project"
525
+ )
526
+
527
+ project = result.data[0]
528
+
529
+ # Get counts
530
+ agent_count_result = (
531
+ client.table("project_agents")
532
+ .select("id", count="exact")
533
+ .eq("project_id", project_id)
534
+ .execute()
535
+ )
536
+ agent_count = agent_count_result.count or 0
537
+
538
+ team_count_result = (
539
+ client.table("project_teams")
540
+ .select("id", count="exact")
541
+ .eq("project_id", project_id)
542
+ .execute()
543
+ )
544
+ team_count = team_count_result.count or 0
545
+
546
+ logger.info(
547
+ "project_updated",
548
+ project_id=project_id,
549
+ org_id=organization["id"],
550
+ )
551
+
552
+ # Extract policy_ids, default_model, goals, and restrict_to_environment from settings for response
553
+ policy_ids = project.get("settings", {}).get("policy_ids", [])
554
+ default_model = project.get("settings", {}).get("default_model")
555
+ goals = project.get("settings", {}).get("goals")
556
+ restrict_to_environment = project.get("settings", {}).get("restrict_to_environment", False)
557
+
558
+ return ProjectResponse(
559
+ **{**project, "policy_ids": policy_ids, "default_model": default_model, "goals": goals, "restrict_to_environment": restrict_to_environment},
560
+ agent_count=agent_count,
561
+ team_count=team_count,
562
+ )
563
+
564
+ except HTTPException:
565
+ raise
566
+ except Exception as e:
567
+ logger.error("project_update_failed", error=str(e), project_id=project_id)
568
+ raise HTTPException(
569
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
570
+ detail=f"Failed to update project: {str(e)}"
571
+ )
572
+
573
+
574
+ @router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
575
+ async def delete_project(
576
+ project_id: str,
577
+ request: Request,
578
+ organization: dict = Depends(get_current_organization),
579
+ ):
580
+ """Delete a project (cascades to associations)"""
581
+ try:
582
+ client = get_supabase()
583
+
584
+ result = (
585
+ client.table("projects")
586
+ .delete()
587
+ .eq("id", project_id)
588
+ .eq("organization_id", organization["id"])
589
+ .execute()
590
+ )
591
+
592
+ if not result.data:
593
+ raise HTTPException(status_code=404, detail="Project not found")
594
+
595
+ logger.info("project_deleted", project_id=project_id, org_id=organization["id"])
596
+
597
+ return None
598
+
599
+ except HTTPException:
600
+ raise
601
+ except Exception as e:
602
+ logger.error("project_delete_failed", error=str(e), project_id=project_id)
603
+ raise HTTPException(
604
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
605
+ detail=f"Failed to delete project: {str(e)}"
606
+ )
607
+
608
+
609
+ # Agent associations
610
+ @router.post("/{project_id}/agents", status_code=status.HTTP_201_CREATED)
611
+ async def add_agent_to_project(
612
+ project_id: str,
613
+ agent_data: ProjectAgentAdd,
614
+ request: Request,
615
+ organization: dict = Depends(get_current_organization),
616
+ ):
617
+ """Add an agent to a project"""
618
+ try:
619
+ client = get_supabase()
620
+
621
+ # Verify project exists
622
+ project_check = (
623
+ client.table("projects")
624
+ .select("id")
625
+ .eq("id", project_id)
626
+ .eq("organization_id", organization["id"])
627
+ .execute()
628
+ )
629
+
630
+ if not project_check.data:
631
+ raise HTTPException(status_code=404, detail="Project not found")
632
+
633
+ # Verify agent exists and belongs to org
634
+ agent_check = (
635
+ client.table("agents")
636
+ .select("id")
637
+ .eq("id", agent_data.agent_id)
638
+ .eq("organization_id", organization["id"])
639
+ .execute()
640
+ )
641
+
642
+ if not agent_check.data:
643
+ raise HTTPException(status_code=404, detail="Agent not found")
644
+
645
+ # Add association
646
+ association = {
647
+ "id": str(uuid.uuid4()),
648
+ "project_id": project_id,
649
+ "agent_id": agent_data.agent_id,
650
+ "role": agent_data.role,
651
+ "added_at": datetime.utcnow().isoformat(),
652
+ "added_by": organization.get("user_id"),
653
+ }
654
+
655
+ result = client.table("project_agents").insert(association).execute()
656
+
657
+ if not result.data:
658
+ raise HTTPException(
659
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
660
+ detail="Failed to add agent to project"
661
+ )
662
+
663
+ logger.info(
664
+ "agent_added_to_project",
665
+ project_id=project_id,
666
+ agent_id=agent_data.agent_id,
667
+ org_id=organization["id"],
668
+ )
669
+
670
+ return result.data[0]
671
+
672
+ except HTTPException:
673
+ raise
674
+ except Exception as e:
675
+ logger.error("add_agent_to_project_failed", error=str(e))
676
+ raise HTTPException(
677
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
678
+ detail=f"Failed to add agent: {str(e)}"
679
+ )
680
+
681
+
682
+ @router.get("/{project_id}/agents")
683
+ async def list_project_agents(
684
+ project_id: str,
685
+ request: Request,
686
+ organization: dict = Depends(get_current_organization),
687
+ ):
688
+ """List all agents in a project"""
689
+ try:
690
+ client = get_supabase()
691
+
692
+ # Get project agents with agent details
693
+ result = (
694
+ client.table("project_agents")
695
+ .select("*, agents(*)")
696
+ .eq("project_id", project_id)
697
+ .execute()
698
+ )
699
+
700
+ logger.info(
701
+ "project_agents_listed",
702
+ project_id=project_id,
703
+ count=len(result.data),
704
+ org_id=organization["id"],
705
+ )
706
+
707
+ return result.data
708
+
709
+ except Exception as e:
710
+ logger.error("list_project_agents_failed", error=str(e))
711
+ raise HTTPException(
712
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
713
+ detail=f"Failed to list agents: {str(e)}"
714
+ )
715
+
716
+
717
+ @router.delete("/{project_id}/agents/{agent_id}", status_code=status.HTTP_204_NO_CONTENT)
718
+ async def remove_agent_from_project(
719
+ project_id: str,
720
+ agent_id: str,
721
+ request: Request,
722
+ organization: dict = Depends(get_current_organization),
723
+ ):
724
+ """Remove an agent from a project"""
725
+ try:
726
+ client = get_supabase()
727
+
728
+ result = (
729
+ client.table("project_agents")
730
+ .delete()
731
+ .eq("project_id", project_id)
732
+ .eq("agent_id", agent_id)
733
+ .execute()
734
+ )
735
+
736
+ if not result.data:
737
+ raise HTTPException(status_code=404, detail="Association not found")
738
+
739
+ logger.info(
740
+ "agent_removed_from_project",
741
+ project_id=project_id,
742
+ agent_id=agent_id,
743
+ org_id=organization["id"],
744
+ )
745
+
746
+ return None
747
+
748
+ except HTTPException:
749
+ raise
750
+ except Exception as e:
751
+ logger.error("remove_agent_from_project_failed", error=str(e))
752
+ raise HTTPException(
753
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
754
+ detail=f"Failed to remove agent: {str(e)}"
755
+ )
756
+
757
+
758
+ # Team associations (similar to agents)
759
+ @router.post("/{project_id}/teams", status_code=status.HTTP_201_CREATED)
760
+ async def add_team_to_project(
761
+ project_id: str,
762
+ team_data: ProjectTeamAdd,
763
+ request: Request,
764
+ organization: dict = Depends(get_current_organization),
765
+ ):
766
+ """Add a team to a project"""
767
+ try:
768
+ client = get_supabase()
769
+
770
+ # Verify project and team exist
771
+ project_check = (
772
+ client.table("projects")
773
+ .select("id")
774
+ .eq("id", project_id)
775
+ .eq("organization_id", organization["id"])
776
+ .execute()
777
+ )
778
+
779
+ if not project_check.data:
780
+ raise HTTPException(status_code=404, detail="Project not found")
781
+
782
+ team_check = (
783
+ client.table("teams")
784
+ .select("id")
785
+ .eq("id", team_data.team_id)
786
+ .eq("organization_id", organization["id"])
787
+ .execute()
788
+ )
789
+
790
+ if not team_check.data:
791
+ raise HTTPException(status_code=404, detail="Team not found")
792
+
793
+ # Add association
794
+ association = {
795
+ "id": str(uuid.uuid4()),
796
+ "project_id": project_id,
797
+ "team_id": team_data.team_id,
798
+ "role": team_data.role,
799
+ "added_at": datetime.utcnow().isoformat(),
800
+ "added_by": organization.get("user_id"),
801
+ }
802
+
803
+ result = client.table("project_teams").insert(association).execute()
804
+
805
+ if not result.data:
806
+ raise HTTPException(
807
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
808
+ detail="Failed to add team to project"
809
+ )
810
+
811
+ logger.info(
812
+ "team_added_to_project",
813
+ project_id=project_id,
814
+ team_id=team_data.team_id,
815
+ org_id=organization["id"],
816
+ )
817
+
818
+ return result.data[0]
819
+
820
+ except HTTPException:
821
+ raise
822
+ except Exception as e:
823
+ logger.error("add_team_to_project_failed", error=str(e))
824
+ raise HTTPException(
825
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
826
+ detail=f"Failed to add team: {str(e)}"
827
+ )
828
+
829
+
830
+ @router.get("/{project_id}/teams")
831
+ async def list_project_teams(
832
+ project_id: str,
833
+ request: Request,
834
+ organization: dict = Depends(get_current_organization),
835
+ ):
836
+ """List all teams in a project"""
837
+ try:
838
+ client = get_supabase()
839
+
840
+ result = (
841
+ client.table("project_teams")
842
+ .select("*, teams(*)")
843
+ .eq("project_id", project_id)
844
+ .execute()
845
+ )
846
+
847
+ logger.info(
848
+ "project_teams_listed",
849
+ project_id=project_id,
850
+ count=len(result.data),
851
+ org_id=organization["id"],
852
+ )
853
+
854
+ return result.data
855
+
856
+ except Exception as e:
857
+ logger.error("list_project_teams_failed", error=str(e))
858
+ raise HTTPException(
859
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
860
+ detail=f"Failed to list teams: {str(e)}"
861
+ )
862
+
863
+
864
+ @router.delete("/{project_id}/teams/{team_id}", status_code=status.HTTP_204_NO_CONTENT)
865
+ async def remove_team_from_project(
866
+ project_id: str,
867
+ team_id: str,
868
+ request: Request,
869
+ organization: dict = Depends(get_current_organization),
870
+ ):
871
+ """Remove a team from a project"""
872
+ try:
873
+ client = get_supabase()
874
+
875
+ result = (
876
+ client.table("project_teams")
877
+ .delete()
878
+ .eq("project_id", project_id)
879
+ .eq("team_id", team_id)
880
+ .execute()
881
+ )
882
+
883
+ if not result.data:
884
+ raise HTTPException(status_code=404, detail="Association not found")
885
+
886
+ logger.info(
887
+ "team_removed_from_project",
888
+ project_id=project_id,
889
+ team_id=team_id,
890
+ org_id=organization["id"],
891
+ )
892
+
893
+ return None
894
+
895
+ except HTTPException:
896
+ raise
897
+ except Exception as e:
898
+ logger.error("remove_team_from_project_failed", error=str(e))
899
+ raise HTTPException(
900
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
901
+ detail=f"Failed to remove team: {str(e)}"
902
+ )