codeframe-ai 0.9.0__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.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,527 @@
1
+ """V2 Workspace router - delegates to core/workspace module.
2
+
3
+ This module provides v2-style API endpoints for workspace initialization
4
+ and management. Workspaces are the root container for all CodeFRAME state.
5
+
6
+ Routes:
7
+ POST /api/v2/workspaces - Initialize a new workspace
8
+ GET /api/v2/workspaces/current - Get current workspace info
9
+ PATCH /api/v2/workspaces/current - Update workspace (e.g., tech stack)
10
+ """
11
+
12
+ import json
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import List, Optional
16
+
17
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
18
+ from pydantic import BaseModel, Field, ValidationError
19
+
20
+ from codeframe.core import workspace as ws
21
+ from codeframe.lib.rate_limiter import rate_limit_standard
22
+ from codeframe.ui.dependencies import get_v2_workspace
23
+ from codeframe.core.workspace import WORKSPACE_CONFIG_FILENAME, Workspace
24
+ from codeframe.ui.response_models import api_error, ErrorCodes
25
+ from codeframe.ui.routers._helpers import atomic_write_json
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ router = APIRouter(prefix="/api/v2/workspaces", tags=["workspaces-v2"])
30
+
31
+
32
+ # ============================================================================
33
+ # Request/Response Models
34
+ # ============================================================================
35
+
36
+
37
+ class WorkspaceResponse(BaseModel):
38
+ """Response for workspace information."""
39
+
40
+ id: str
41
+ repo_path: str
42
+ state_dir: str
43
+ tech_stack: Optional[str]
44
+ created_at: str
45
+
46
+
47
+ class InitWorkspaceRequest(BaseModel):
48
+ """Request for initializing a workspace."""
49
+
50
+ repo_path: str = Field(..., min_length=1, description="Path to the repository")
51
+ tech_stack: Optional[str] = Field(None, description="Tech stack description")
52
+ detect: bool = Field(False, description="Auto-detect tech stack from project files")
53
+
54
+
55
+ class UpdateWorkspaceRequest(BaseModel):
56
+ """Request for updating workspace."""
57
+
58
+ tech_stack: Optional[str] = Field(None, description="New tech stack description")
59
+
60
+
61
+ class WorkspaceRegistryResponse(BaseModel):
62
+ """A single registered workspace from the server-side registry (issue #601)."""
63
+
64
+ id: str
65
+ repo_path: str
66
+ name: Optional[str] = None
67
+ tech_stack: Optional[str] = None
68
+ created_at: Optional[str] = None
69
+ last_opened_at: Optional[str] = None
70
+ path_exists: bool = Field(
71
+ ..., description="Whether repo_path still exists on disk (computed at list time)."
72
+ )
73
+
74
+
75
+ class WorkspaceListResponse(BaseModel):
76
+ """Response for listing registered workspaces."""
77
+
78
+ workspaces: List[WorkspaceRegistryResponse]
79
+
80
+
81
+ # ============================================================================
82
+ # Helper Functions
83
+ # ============================================================================
84
+
85
+
86
+ def _workspace_to_response(workspace: Workspace) -> WorkspaceResponse:
87
+ """Convert a Workspace to a WorkspaceResponse."""
88
+ return WorkspaceResponse(
89
+ id=workspace.id,
90
+ repo_path=str(workspace.repo_path),
91
+ state_dir=str(workspace.state_dir),
92
+ tech_stack=workspace.tech_stack,
93
+ created_at=workspace.created_at.isoformat(),
94
+ )
95
+
96
+
97
+ def _detect_tech_stack(repo_path: Path) -> Optional[str]:
98
+ """Auto-detect tech stack from project files.
99
+
100
+ Looks for common project files and infers the tech stack.
101
+ """
102
+ tech_parts = []
103
+
104
+ # Python detection
105
+ pyproject = repo_path / "pyproject.toml"
106
+ if pyproject.exists():
107
+ content = pyproject.read_text()
108
+ if "uv" in content or "[tool.uv]" in content:
109
+ tech_parts.append("Python with uv")
110
+ elif "poetry" in content:
111
+ tech_parts.append("Python with poetry")
112
+ else:
113
+ tech_parts.append("Python")
114
+
115
+ if "pytest" in content:
116
+ tech_parts.append("pytest")
117
+ if "ruff" in content:
118
+ tech_parts.append("ruff for linting")
119
+ if "fastapi" in content.lower():
120
+ tech_parts.append("FastAPI")
121
+
122
+ # Node.js detection
123
+ package_json = repo_path / "package.json"
124
+ if package_json.exists():
125
+ content = package_json.read_text()
126
+ if "next" in content:
127
+ tech_parts.append("Next.js")
128
+ elif "react" in content:
129
+ tech_parts.append("React")
130
+ if "typescript" in content:
131
+ tech_parts.append("TypeScript")
132
+
133
+ # Rust detection
134
+ cargo_toml = repo_path / "Cargo.toml"
135
+ if cargo_toml.exists():
136
+ tech_parts.append("Rust with cargo")
137
+
138
+ # Go detection
139
+ go_mod = repo_path / "go.mod"
140
+ if go_mod.exists():
141
+ tech_parts.append("Go")
142
+
143
+ return ", ".join(tech_parts) if tech_parts else None
144
+
145
+
146
+ def _get_registry(request: Request):
147
+ """Return the WorkspaceRegistryRepository, or None if unavailable.
148
+
149
+ Registry writes are best-effort: when the control-plane DB isn't attached
150
+ (e.g. lightweight tests that mount only this router), registry tracking is
151
+ silently skipped so core workspace operations never break.
152
+ """
153
+ db = getattr(request.app.state, "db", None)
154
+ if db is None:
155
+ return None
156
+ return getattr(db, "workspace_registry", None)
157
+
158
+
159
+ def _register_workspace(request: Request, workspace: Workspace) -> None:
160
+ """Best-effort upsert of a workspace into the server-side registry."""
161
+ registry = _get_registry(request)
162
+ if registry is None:
163
+ return
164
+ try:
165
+ registry.upsert(
166
+ repo_path=str(workspace.repo_path),
167
+ name=workspace.repo_path.name,
168
+ tech_stack=workspace.tech_stack,
169
+ owner_user_id=None, # Until auth is enforced on v2 routers.
170
+ )
171
+ except Exception as e: # noqa: BLE001 - tracking must never break the request
172
+ logger.warning("Failed to register workspace in registry: %s", e)
173
+
174
+
175
+ # ============================================================================
176
+ # Endpoints
177
+ # ============================================================================
178
+
179
+
180
+ @router.get("", response_model=WorkspaceListResponse)
181
+ @rate_limit_standard()
182
+ async def list_workspaces(request: Request) -> WorkspaceListResponse:
183
+ """List all registered workspaces (issue #601).
184
+
185
+ Returns server-side registry entries ordered by recency, each annotated with
186
+ a computed ``path_exists`` so clients can flag stale projects.
187
+
188
+ When the control-plane DB / registry is unavailable, responds 503 rather than
189
+ an empty 200 — clients treat a successful response as authoritative and would
190
+ otherwise wipe their local fallback list. A genuinely empty registry still
191
+ returns 200 with ``[]``.
192
+ """
193
+ registry = _get_registry(request)
194
+ if registry is None:
195
+ raise HTTPException(
196
+ status_code=503,
197
+ detail=api_error(
198
+ "Registry unavailable",
199
+ ErrorCodes.EXECUTION_FAILED,
200
+ "Workspace registry is not available on this server.",
201
+ ),
202
+ )
203
+
204
+ entries = registry.list_all()
205
+ # path_exists is a per-entry blocking stat() inside an async handler. The
206
+ # registry is recency-scoped and small in practice, so the event-loop cost is
207
+ # negligible; revisit (e.g. run_in_executor) only if the list grows large.
208
+ workspaces = [
209
+ WorkspaceRegistryResponse(
210
+ id=entry["id"],
211
+ repo_path=entry["repo_path"],
212
+ name=entry.get("name"),
213
+ tech_stack=entry.get("tech_stack"),
214
+ created_at=entry.get("created_at"),
215
+ last_opened_at=entry.get("last_opened_at"),
216
+ path_exists=Path(entry["repo_path"]).exists(),
217
+ )
218
+ for entry in entries
219
+ ]
220
+ return WorkspaceListResponse(workspaces=workspaces)
221
+
222
+
223
+ @router.post("", response_model=WorkspaceResponse, status_code=201)
224
+ @rate_limit_standard()
225
+ async def init_workspace(
226
+ request: Request,
227
+ body: InitWorkspaceRequest,
228
+ ) -> WorkspaceResponse:
229
+ """Initialize a new workspace for a repository.
230
+
231
+ Creates a .codeframe/ directory with state storage and configuration.
232
+ This is idempotent - safe to call multiple times on the same repo.
233
+
234
+ Args:
235
+ request: HTTP request for rate limiting
236
+ body: Workspace initialization request
237
+
238
+ Returns:
239
+ Created or existing workspace
240
+
241
+ Raises:
242
+ HTTPException:
243
+ - 400: Invalid path or conflicting options
244
+ - 404: Repository path not found
245
+ """
246
+ try:
247
+ repo_path = Path(body.repo_path).resolve()
248
+
249
+ # Validate path exists
250
+ if not repo_path.exists():
251
+ raise HTTPException(
252
+ status_code=404,
253
+ detail=api_error(
254
+ "Path not found",
255
+ ErrorCodes.NOT_FOUND,
256
+ f"Repository path does not exist: {repo_path}",
257
+ ),
258
+ )
259
+
260
+ if not repo_path.is_dir():
261
+ raise HTTPException(
262
+ status_code=400,
263
+ detail=api_error(
264
+ "Invalid path",
265
+ ErrorCodes.VALIDATION_ERROR,
266
+ "Path must be a directory",
267
+ ),
268
+ )
269
+
270
+ # Determine tech stack
271
+ tech_stack = body.tech_stack
272
+ if body.detect and not tech_stack:
273
+ tech_stack = _detect_tech_stack(repo_path)
274
+
275
+ # Check if workspace already exists
276
+ already_existed = ws.workspace_exists(repo_path)
277
+
278
+ if already_existed:
279
+ workspace = ws.get_workspace(repo_path)
280
+ # Update tech stack if provided for existing workspace
281
+ if tech_stack:
282
+ workspace = ws.update_workspace_tech_stack(repo_path, tech_stack)
283
+ else:
284
+ workspace = ws.create_or_load_workspace(repo_path, tech_stack=tech_stack)
285
+
286
+ # Register (or refresh) the workspace in the server-side registry (#601).
287
+ _register_workspace(request, workspace)
288
+
289
+ return _workspace_to_response(workspace)
290
+
291
+ except HTTPException:
292
+ raise
293
+ except Exception as e:
294
+ logger.error(f"Failed to initialize workspace: {e}", exc_info=True)
295
+ raise HTTPException(
296
+ status_code=500,
297
+ detail=api_error("Initialization failed", ErrorCodes.EXECUTION_FAILED, str(e)),
298
+ )
299
+
300
+
301
+ @router.get("/current", response_model=WorkspaceResponse)
302
+ @rate_limit_standard()
303
+ async def get_current_workspace(
304
+ request: Request,
305
+ workspace: Workspace = Depends(get_v2_workspace),
306
+ ) -> WorkspaceResponse:
307
+ """Get information about the current workspace.
308
+
309
+ The workspace is resolved from the workspace_path query parameter
310
+ or the server's default workspace.
311
+
312
+ Args:
313
+ workspace: v2 Workspace (resolved by dependency)
314
+
315
+ Returns:
316
+ Workspace information
317
+ """
318
+ # Track access recency in the registry (#601). Auto-register workspaces that
319
+ # were opened directly by path so they become server-tracked. Best-effort.
320
+ registry = _get_registry(request)
321
+ if registry is not None:
322
+ try:
323
+ entry = registry.get_by_path(str(workspace.repo_path))
324
+ if entry is not None:
325
+ registry.update_last_opened(entry["id"])
326
+ else:
327
+ _register_workspace(request, workspace)
328
+ except Exception as e: # noqa: BLE001 - tracking must never break the request
329
+ logger.warning("Failed to track workspace access: %s", e)
330
+
331
+ return _workspace_to_response(workspace)
332
+
333
+
334
+ @router.patch("/current", response_model=WorkspaceResponse)
335
+ @rate_limit_standard()
336
+ async def update_current_workspace(
337
+ request: Request,
338
+ body: UpdateWorkspaceRequest,
339
+ workspace: Workspace = Depends(get_v2_workspace),
340
+ ) -> WorkspaceResponse:
341
+ """Update the current workspace.
342
+
343
+ Currently supports updating:
344
+ - tech_stack: Natural language description of the project's technology
345
+
346
+ Args:
347
+ request: HTTP request for rate limiting
348
+ body: Update request
349
+ workspace: v2 Workspace (resolved by dependency)
350
+
351
+ Returns:
352
+ Updated workspace
353
+
354
+ Raises:
355
+ HTTPException: 500 if update fails
356
+ """
357
+ try:
358
+ updated = workspace
359
+
360
+ if body.tech_stack is not None:
361
+ updated = ws.update_workspace_tech_stack(workspace.repo_path, body.tech_stack)
362
+ # Keep the registry's cached metadata (tech_stack) in sync (#601).
363
+ _register_workspace(request, updated)
364
+
365
+ return _workspace_to_response(updated)
366
+
367
+ except Exception as e:
368
+ logger.error(f"Failed to update workspace: {e}", exc_info=True)
369
+ raise HTTPException(
370
+ status_code=500,
371
+ detail=api_error("Update failed", ErrorCodes.EXECUTION_FAILED, str(e)),
372
+ )
373
+
374
+
375
+ # ============================================================================
376
+ # Workspace Config (issue #556)
377
+ #
378
+ # Persists per-workspace UI-configurable settings (root path display, default
379
+ # branch, tech-stack auto-detect toggle + manual override) to
380
+ # .codeframe/workspace_config.json.
381
+ # ============================================================================
382
+
383
+
384
+ class WorkspaceConfigResponse(BaseModel):
385
+ workspace_root: str = Field(
386
+ ..., description="Display-only. The server resolves the active workspace from the workspace_path query parameter."
387
+ )
388
+ default_branch: str
389
+ auto_detect_tech_stack: bool
390
+ tech_stack_override: Optional[str] = None
391
+
392
+
393
+ class UpdateWorkspaceConfigRequest(BaseModel):
394
+ workspace_root: str = Field(
395
+ ...,
396
+ min_length=1,
397
+ description="Display-only — editing does not relocate the workspace.",
398
+ )
399
+ default_branch: str = Field(..., min_length=1)
400
+ auto_detect_tech_stack: bool
401
+ tech_stack_override: Optional[str] = None
402
+
403
+
404
+ def _workspace_config_path(workspace: Workspace) -> Path:
405
+ return workspace.state_dir / WORKSPACE_CONFIG_FILENAME
406
+
407
+
408
+ def _default_workspace_config(workspace: Workspace) -> dict:
409
+ return {
410
+ "workspace_root": str(workspace.repo_path),
411
+ "default_branch": "main",
412
+ "auto_detect_tech_stack": True,
413
+ "tech_stack_override": None,
414
+ }
415
+
416
+
417
+ @router.get("/config", response_model=WorkspaceConfigResponse)
418
+ @rate_limit_standard()
419
+ async def get_workspace_config(
420
+ request: Request,
421
+ workspace: Workspace = Depends(get_v2_workspace),
422
+ ) -> WorkspaceConfigResponse:
423
+ """Load workspace configuration for this workspace.
424
+
425
+ Returns defaults sourced from the Workspace itself if no config file exists.
426
+ """
427
+ path = _workspace_config_path(workspace)
428
+ if path.exists():
429
+ try:
430
+ data = json.loads(path.read_text())
431
+ # workspace_root is display-only — always source it from the live
432
+ # workspace so a stored value can't drift from reality.
433
+ data["workspace_root"] = str(workspace.repo_path)
434
+ return WorkspaceConfigResponse(**data)
435
+ except (OSError, json.JSONDecodeError, ValueError, ValidationError) as e:
436
+ logger.warning(
437
+ "Invalid workspace_config.json — falling back to defaults: %s", e
438
+ )
439
+ return WorkspaceConfigResponse(**_default_workspace_config(workspace))
440
+
441
+
442
+ @router.put("/config", response_model=WorkspaceConfigResponse)
443
+ @rate_limit_standard()
444
+ async def update_workspace_config(
445
+ request: Request,
446
+ body: UpdateWorkspaceConfigRequest,
447
+ workspace: Workspace = Depends(get_v2_workspace),
448
+ ) -> WorkspaceConfigResponse:
449
+ """Persist workspace configuration to .codeframe/workspace_config.json.
450
+
451
+ Note: `workspace_root` is informational/display-only. The server resolves
452
+ the active workspace path from the `workspace_path` query parameter or
453
+ its default — editing this field does not relocate the workspace. The
454
+ value is replaced on write so PUT/GET stay consistent.
455
+ """
456
+ payload = body.model_dump(exclude={"workspace_root"})
457
+ payload["workspace_root"] = str(workspace.repo_path)
458
+ atomic_write_json(_workspace_config_path(workspace), payload)
459
+ return WorkspaceConfigResponse(**payload)
460
+
461
+
462
+ @router.get("/exists")
463
+ @rate_limit_standard()
464
+ async def check_workspace_exists(
465
+ request: Request,
466
+ repo_path: str = Query(..., description="Path to check for workspace"),
467
+ ) -> dict:
468
+ """Check if a workspace exists at a given path.
469
+
470
+ Args:
471
+ request: HTTP request for rate limiting
472
+ repo_path: Path to check
473
+
474
+ Returns:
475
+ Whether workspace exists and path info
476
+ """
477
+ path = Path(repo_path).resolve()
478
+
479
+ return {
480
+ "exists": ws.workspace_exists(path),
481
+ "path": str(path),
482
+ "state_dir": str(path / ".codeframe") if ws.workspace_exists(path) else None,
483
+ }
484
+
485
+
486
+ @router.delete("/{workspace_id}", status_code=204)
487
+ @rate_limit_standard()
488
+ async def deregister_workspace(
489
+ request: Request,
490
+ workspace_id: str,
491
+ ) -> Response:
492
+ """Deregister a workspace from the server-side registry (issue #601).
493
+
494
+ Removes only the registry entry — it never deletes the ``.codeframe/``
495
+ directory or any on-disk state.
496
+
497
+ Returns:
498
+ 204 No Content on success.
499
+
500
+ Raises:
501
+ HTTPException:
502
+ - 404: workspace_id not found in the registry
503
+ - 503: registry/control-plane DB unavailable
504
+ """
505
+ registry = _get_registry(request)
506
+ if registry is None:
507
+ raise HTTPException(
508
+ status_code=503,
509
+ detail=api_error(
510
+ "Registry unavailable",
511
+ ErrorCodes.EXECUTION_FAILED,
512
+ "Workspace registry is not available on this server.",
513
+ ),
514
+ )
515
+
516
+ deleted = registry.delete(workspace_id)
517
+ if not deleted:
518
+ raise HTTPException(
519
+ status_code=404,
520
+ detail=api_error(
521
+ "Workspace not found",
522
+ ErrorCodes.NOT_FOUND,
523
+ f"No registered workspace with id: {workspace_id}",
524
+ ),
525
+ )
526
+
527
+ return Response(status_code=204)