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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- 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)
|