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,320 @@
|
|
|
1
|
+
"""V2 Blockers router - delegates to core/blockers module.
|
|
2
|
+
|
|
3
|
+
This module provides v2-style API endpoints for blocker management (human-in-the-loop).
|
|
4
|
+
Blockers represent questions that pause task execution until answered by a human.
|
|
5
|
+
|
|
6
|
+
Routes:
|
|
7
|
+
GET /api/v2/blockers - List blockers with optional filters
|
|
8
|
+
GET /api/v2/blockers/{id} - Get a specific blocker
|
|
9
|
+
POST /api/v2/blockers - Create a new blocker
|
|
10
|
+
POST /api/v2/blockers/{id}/answer - Answer a blocker
|
|
11
|
+
POST /api/v2/blockers/{id}/resolve - Mark blocker as resolved
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from codeframe.core.workspace import Workspace
|
|
21
|
+
from codeframe.lib.rate_limiter import rate_limit_standard
|
|
22
|
+
from codeframe.core import blockers
|
|
23
|
+
from codeframe.core.blockers import BlockerStatus, BlockerOrigin
|
|
24
|
+
from codeframe.ui.dependencies import get_v2_workspace
|
|
25
|
+
from codeframe.ui.response_models import api_error, ErrorCodes
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
router = APIRouter(prefix="/api/v2/blockers", tags=["blockers-v2"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ============================================================================
|
|
33
|
+
# Request/Response Models
|
|
34
|
+
# ============================================================================
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BlockerResponse(BaseModel):
|
|
38
|
+
"""Response for a single blocker."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
workspace_id: str
|
|
42
|
+
task_id: Optional[str]
|
|
43
|
+
question: str
|
|
44
|
+
answer: Optional[str]
|
|
45
|
+
status: str
|
|
46
|
+
created_at: str
|
|
47
|
+
answered_at: Optional[str]
|
|
48
|
+
created_by: BlockerOrigin = BlockerOrigin.HUMAN
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BlockerListResponse(BaseModel):
|
|
52
|
+
"""Response for blocker list."""
|
|
53
|
+
|
|
54
|
+
blockers: list[BlockerResponse]
|
|
55
|
+
total: int
|
|
56
|
+
by_status: dict[str, int]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CreateBlockerRequest(BaseModel):
|
|
60
|
+
"""Request for creating a blocker."""
|
|
61
|
+
|
|
62
|
+
question: str = Field(..., min_length=1, description="The question to ask")
|
|
63
|
+
task_id: Optional[str] = Field(None, description="Optional associated task ID")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AnswerBlockerRequest(BaseModel):
|
|
67
|
+
"""Request for answering a blocker."""
|
|
68
|
+
|
|
69
|
+
answer: str = Field(..., min_length=1, description="The answer text")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ============================================================================
|
|
73
|
+
# Helper Functions
|
|
74
|
+
# ============================================================================
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _blocker_to_response(blocker: blockers.Blocker) -> BlockerResponse:
|
|
78
|
+
"""Convert a Blocker to a BlockerResponse."""
|
|
79
|
+
return BlockerResponse(
|
|
80
|
+
id=blocker.id,
|
|
81
|
+
workspace_id=blocker.workspace_id,
|
|
82
|
+
task_id=blocker.task_id,
|
|
83
|
+
question=blocker.question,
|
|
84
|
+
answer=blocker.answer,
|
|
85
|
+
status=blocker.status.value,
|
|
86
|
+
created_at=blocker.created_at.isoformat(),
|
|
87
|
+
answered_at=blocker.answered_at.isoformat() if blocker.answered_at else None,
|
|
88
|
+
created_by=blocker.created_by.value,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ============================================================================
|
|
93
|
+
# Endpoints
|
|
94
|
+
# ============================================================================
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.get("", response_model=BlockerListResponse)
|
|
98
|
+
@rate_limit_standard()
|
|
99
|
+
async def list_blockers(
|
|
100
|
+
request: Request,
|
|
101
|
+
status: Optional[str] = Query(None, description="Filter by status (OPEN, ANSWERED, RESOLVED)"),
|
|
102
|
+
task_id: Optional[str] = Query(None, description="Filter by task ID"),
|
|
103
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
104
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
105
|
+
) -> BlockerListResponse:
|
|
106
|
+
"""List blockers in the workspace.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
status: Optional status filter
|
|
110
|
+
task_id: Optional task filter
|
|
111
|
+
limit: Maximum blockers to return
|
|
112
|
+
workspace: v2 Workspace
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of blockers with counts by status
|
|
116
|
+
"""
|
|
117
|
+
# Parse status filter
|
|
118
|
+
status_filter = None
|
|
119
|
+
if status:
|
|
120
|
+
try:
|
|
121
|
+
status_filter = BlockerStatus(status.upper())
|
|
122
|
+
except ValueError:
|
|
123
|
+
raise HTTPException(
|
|
124
|
+
status_code=400,
|
|
125
|
+
detail=api_error(
|
|
126
|
+
f"Invalid status: {status}",
|
|
127
|
+
ErrorCodes.VALIDATION_ERROR,
|
|
128
|
+
f"Valid values: {[s.value for s in BlockerStatus]}",
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Get blockers
|
|
133
|
+
blocker_list = blockers.list_all(
|
|
134
|
+
workspace,
|
|
135
|
+
status=status_filter,
|
|
136
|
+
task_id=task_id,
|
|
137
|
+
limit=limit,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Get counts by status
|
|
141
|
+
status_counts = blockers.count_by_status(workspace)
|
|
142
|
+
|
|
143
|
+
return BlockerListResponse(
|
|
144
|
+
blockers=[_blocker_to_response(b) for b in blocker_list],
|
|
145
|
+
total=len(blocker_list),
|
|
146
|
+
by_status=status_counts,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.get("/{blocker_id}", response_model=BlockerResponse)
|
|
151
|
+
@rate_limit_standard()
|
|
152
|
+
async def get_blocker(
|
|
153
|
+
request: Request,
|
|
154
|
+
blocker_id: str,
|
|
155
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
156
|
+
) -> BlockerResponse:
|
|
157
|
+
"""Get a specific blocker by ID.
|
|
158
|
+
|
|
159
|
+
Supports partial ID matching (prefix).
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
blocker_id: Blocker identifier (can be partial)
|
|
163
|
+
workspace: v2 Workspace
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Blocker details
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
HTTPException: 404 if blocker not found, 400 if multiple matches
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
blocker = blockers.get(workspace, blocker_id)
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
# Multiple blockers match partial ID
|
|
175
|
+
raise HTTPException(
|
|
176
|
+
status_code=400,
|
|
177
|
+
detail=api_error("Ambiguous blocker ID", ErrorCodes.VALIDATION_ERROR, str(e)),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if not blocker:
|
|
181
|
+
raise HTTPException(
|
|
182
|
+
status_code=404,
|
|
183
|
+
detail=api_error("Blocker not found", ErrorCodes.NOT_FOUND, f"No blocker with id {blocker_id}"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return _blocker_to_response(blocker)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@router.post("", response_model=BlockerResponse, status_code=201)
|
|
190
|
+
@rate_limit_standard()
|
|
191
|
+
async def create_blocker(
|
|
192
|
+
request: Request,
|
|
193
|
+
body: CreateBlockerRequest,
|
|
194
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
195
|
+
) -> BlockerResponse:
|
|
196
|
+
"""Create a new blocker.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
request: HTTP request for rate limiting
|
|
200
|
+
body: Blocker creation request
|
|
201
|
+
workspace: v2 Workspace
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Created blocker
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
blocker = blockers.create(
|
|
208
|
+
workspace,
|
|
209
|
+
question=body.question,
|
|
210
|
+
task_id=body.task_id,
|
|
211
|
+
)
|
|
212
|
+
return _blocker_to_response(blocker)
|
|
213
|
+
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logger.error(f"Failed to create blocker: {e}", exc_info=True)
|
|
216
|
+
raise HTTPException(
|
|
217
|
+
status_code=500,
|
|
218
|
+
detail=api_error("Failed to create blocker", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@router.post("/{blocker_id}/answer", response_model=BlockerResponse)
|
|
223
|
+
@rate_limit_standard()
|
|
224
|
+
async def answer_blocker(
|
|
225
|
+
request: Request,
|
|
226
|
+
blocker_id: str,
|
|
227
|
+
body: AnswerBlockerRequest,
|
|
228
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
229
|
+
) -> BlockerResponse:
|
|
230
|
+
"""Answer a blocker.
|
|
231
|
+
|
|
232
|
+
Answering a blocker also resets the associated task to READY status,
|
|
233
|
+
so it can be restarted with `cf work start <task-id> --execute`.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
request: HTTP request for rate limiting
|
|
237
|
+
blocker_id: Blocker to answer (can be partial ID)
|
|
238
|
+
body: Answer request
|
|
239
|
+
workspace: v2 Workspace
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Updated blocker
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
HTTPException:
|
|
246
|
+
- 404: Blocker not found
|
|
247
|
+
- 400: Blocker already resolved or ambiguous ID
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
blocker = blockers.answer(workspace, blocker_id, body.answer)
|
|
251
|
+
return _blocker_to_response(blocker)
|
|
252
|
+
|
|
253
|
+
except ValueError as e:
|
|
254
|
+
error_msg = str(e)
|
|
255
|
+
if "not found" in error_msg.lower():
|
|
256
|
+
raise HTTPException(
|
|
257
|
+
status_code=404,
|
|
258
|
+
detail=api_error("Blocker not found", ErrorCodes.NOT_FOUND, error_msg),
|
|
259
|
+
)
|
|
260
|
+
elif "already resolved" in error_msg.lower():
|
|
261
|
+
raise HTTPException(
|
|
262
|
+
status_code=400,
|
|
263
|
+
detail=api_error("Cannot answer", ErrorCodes.INVALID_STATE, error_msg),
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
raise HTTPException(
|
|
267
|
+
status_code=400,
|
|
268
|
+
detail=api_error("Invalid request", ErrorCodes.VALIDATION_ERROR, error_msg),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@router.post("/{blocker_id}/resolve", response_model=BlockerResponse)
|
|
273
|
+
@rate_limit_standard()
|
|
274
|
+
async def resolve_blocker(
|
|
275
|
+
request: Request,
|
|
276
|
+
blocker_id: str,
|
|
277
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
278
|
+
) -> BlockerResponse:
|
|
279
|
+
"""Mark a blocker as resolved.
|
|
280
|
+
|
|
281
|
+
A blocker must be answered before it can be resolved.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
blocker_id: Blocker to resolve (can be partial ID)
|
|
285
|
+
workspace: v2 Workspace
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Updated blocker
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
HTTPException:
|
|
292
|
+
- 404: Blocker not found
|
|
293
|
+
- 400: Blocker not answered yet or already resolved
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
blocker = blockers.resolve(workspace, blocker_id)
|
|
297
|
+
return _blocker_to_response(blocker)
|
|
298
|
+
|
|
299
|
+
except ValueError as e:
|
|
300
|
+
error_msg = str(e)
|
|
301
|
+
if "not found" in error_msg.lower():
|
|
302
|
+
raise HTTPException(
|
|
303
|
+
status_code=404,
|
|
304
|
+
detail=api_error("Blocker not found", ErrorCodes.NOT_FOUND, error_msg),
|
|
305
|
+
)
|
|
306
|
+
elif "must be answered" in error_msg.lower():
|
|
307
|
+
raise HTTPException(
|
|
308
|
+
status_code=400,
|
|
309
|
+
detail=api_error("Cannot resolve", ErrorCodes.INVALID_STATE, error_msg),
|
|
310
|
+
)
|
|
311
|
+
elif "already resolved" in error_msg.lower():
|
|
312
|
+
raise HTTPException(
|
|
313
|
+
status_code=400,
|
|
314
|
+
detail=api_error("Cannot resolve", ErrorCodes.INVALID_STATE, error_msg),
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
raise HTTPException(
|
|
318
|
+
status_code=400,
|
|
319
|
+
detail=api_error("Invalid request", ErrorCodes.VALIDATION_ERROR, error_msg),
|
|
320
|
+
)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""V2 Checkpoint router - delegates to core modules.
|
|
2
|
+
|
|
3
|
+
This module provides v2-style API endpoints for checkpoint management that
|
|
4
|
+
delegate to core/checkpoints.py. It uses the v2 Workspace model.
|
|
5
|
+
|
|
6
|
+
The v1 router (checkpoints.py) remains for backwards compatibility.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from codeframe.core.workspace import Workspace
|
|
16
|
+
from codeframe.lib.rate_limiter import rate_limit_standard
|
|
17
|
+
from codeframe.core import checkpoints
|
|
18
|
+
from codeframe.ui.dependencies import get_v2_workspace
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
router = APIRouter(prefix="/api/v2/checkpoints", tags=["checkpoints-v2"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ============================================================================
|
|
26
|
+
# Request/Response Models
|
|
27
|
+
# ============================================================================
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CreateCheckpointRequest(BaseModel):
|
|
31
|
+
"""Request for creating a checkpoint."""
|
|
32
|
+
|
|
33
|
+
name: str = Field(..., min_length=1, max_length=200)
|
|
34
|
+
include_git_ref: bool = Field(
|
|
35
|
+
default=True,
|
|
36
|
+
description="Whether to capture current git HEAD",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CheckpointResponse(BaseModel):
|
|
41
|
+
"""Response for a single checkpoint."""
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
name: str
|
|
45
|
+
created_at: str
|
|
46
|
+
summary: dict[str, Any]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CheckpointListResponse(BaseModel):
|
|
50
|
+
"""Response for checkpoint list."""
|
|
51
|
+
|
|
52
|
+
checkpoints: list[CheckpointResponse]
|
|
53
|
+
total: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TaskDiffResponse(BaseModel):
|
|
57
|
+
"""Response for a single task diff."""
|
|
58
|
+
|
|
59
|
+
task_id: str
|
|
60
|
+
title: str
|
|
61
|
+
old_status: Optional[str]
|
|
62
|
+
new_status: Optional[str]
|
|
63
|
+
change_type: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CheckpointDiffResponse(BaseModel):
|
|
67
|
+
"""Response for checkpoint diff."""
|
|
68
|
+
|
|
69
|
+
checkpoint_a: dict[str, str]
|
|
70
|
+
checkpoint_b: dict[str, str]
|
|
71
|
+
task_diffs: list[TaskDiffResponse]
|
|
72
|
+
summary: dict[str, int]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ============================================================================
|
|
76
|
+
# Checkpoint Endpoints
|
|
77
|
+
# ============================================================================
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.post("", response_model=CheckpointResponse)
|
|
81
|
+
@rate_limit_standard()
|
|
82
|
+
async def create_checkpoint(
|
|
83
|
+
request: Request,
|
|
84
|
+
body: CreateCheckpointRequest,
|
|
85
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
86
|
+
) -> CheckpointResponse:
|
|
87
|
+
"""Create a new checkpoint.
|
|
88
|
+
|
|
89
|
+
Captures current state of tasks, blockers, and optionally git ref.
|
|
90
|
+
|
|
91
|
+
This is the v2 equivalent of `cf checkpoint create`.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
request: HTTP request for rate limiting
|
|
95
|
+
body: Checkpoint creation request
|
|
96
|
+
workspace: v2 Workspace
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Created checkpoint
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
checkpoint = checkpoints.create(
|
|
103
|
+
workspace,
|
|
104
|
+
name=body.name,
|
|
105
|
+
include_git_ref=body.include_git_ref,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return CheckpointResponse(
|
|
109
|
+
id=checkpoint.id,
|
|
110
|
+
name=checkpoint.name,
|
|
111
|
+
created_at=checkpoint.created_at.isoformat(),
|
|
112
|
+
summary=checkpoint.snapshot.get("summary", {}),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.error(f"Failed to create checkpoint: {e}", exc_info=True)
|
|
117
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@router.get("", response_model=CheckpointListResponse)
|
|
121
|
+
@rate_limit_standard()
|
|
122
|
+
async def list_checkpoints(
|
|
123
|
+
request: Request,
|
|
124
|
+
limit: int = Query(50, ge=1, le=200),
|
|
125
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
126
|
+
) -> CheckpointListResponse:
|
|
127
|
+
"""List all checkpoints.
|
|
128
|
+
|
|
129
|
+
This is the v2 equivalent of `cf checkpoint list`.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
limit: Maximum checkpoints to return
|
|
133
|
+
workspace: v2 Workspace
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of checkpoints (newest first)
|
|
137
|
+
"""
|
|
138
|
+
checkpoint_list = checkpoints.list_all(workspace, limit=limit)
|
|
139
|
+
|
|
140
|
+
return CheckpointListResponse(
|
|
141
|
+
checkpoints=[
|
|
142
|
+
CheckpointResponse(
|
|
143
|
+
id=c.id,
|
|
144
|
+
name=c.name,
|
|
145
|
+
created_at=c.created_at.isoformat(),
|
|
146
|
+
summary=c.snapshot.get("summary", {}),
|
|
147
|
+
)
|
|
148
|
+
for c in checkpoint_list
|
|
149
|
+
],
|
|
150
|
+
total=len(checkpoint_list),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.get("/{checkpoint_id}", response_model=CheckpointResponse)
|
|
155
|
+
@rate_limit_standard()
|
|
156
|
+
async def get_checkpoint(
|
|
157
|
+
request: Request,
|
|
158
|
+
checkpoint_id: str,
|
|
159
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
160
|
+
) -> CheckpointResponse:
|
|
161
|
+
"""Get a specific checkpoint.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
checkpoint_id: Checkpoint ID or name
|
|
165
|
+
workspace: v2 Workspace
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Checkpoint details
|
|
169
|
+
|
|
170
|
+
Raises:
|
|
171
|
+
HTTPException: 404 if checkpoint not found
|
|
172
|
+
"""
|
|
173
|
+
checkpoint = checkpoints.get(workspace, checkpoint_id)
|
|
174
|
+
if not checkpoint:
|
|
175
|
+
raise HTTPException(
|
|
176
|
+
status_code=404,
|
|
177
|
+
detail=f"Checkpoint not found: {checkpoint_id}",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return CheckpointResponse(
|
|
181
|
+
id=checkpoint.id,
|
|
182
|
+
name=checkpoint.name,
|
|
183
|
+
created_at=checkpoint.created_at.isoformat(),
|
|
184
|
+
summary=checkpoint.snapshot.get("summary", {}),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.post("/{checkpoint_id}/restore")
|
|
189
|
+
@rate_limit_standard()
|
|
190
|
+
async def restore_checkpoint(
|
|
191
|
+
request: Request,
|
|
192
|
+
checkpoint_id: str,
|
|
193
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
194
|
+
) -> dict[str, Any]:
|
|
195
|
+
"""Restore state from a checkpoint.
|
|
196
|
+
|
|
197
|
+
Restores task statuses from the checkpoint. Does not modify files.
|
|
198
|
+
|
|
199
|
+
This is the v2 equivalent of `cf checkpoint restore`.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
checkpoint_id: Checkpoint ID or name
|
|
203
|
+
workspace: v2 Workspace
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Restore result
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
HTTPException: 404 if checkpoint not found
|
|
210
|
+
"""
|
|
211
|
+
try:
|
|
212
|
+
checkpoint = checkpoints.restore(workspace, checkpoint_id)
|
|
213
|
+
return {
|
|
214
|
+
"success": True,
|
|
215
|
+
"checkpoint_id": checkpoint.id,
|
|
216
|
+
"checkpoint_name": checkpoint.name,
|
|
217
|
+
"message": f"Restored state from checkpoint '{checkpoint.name}'",
|
|
218
|
+
}
|
|
219
|
+
except ValueError as e:
|
|
220
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.error(f"Failed to restore checkpoint: {e}", exc_info=True)
|
|
223
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@router.delete("/{checkpoint_id}")
|
|
227
|
+
@rate_limit_standard()
|
|
228
|
+
async def delete_checkpoint(
|
|
229
|
+
request: Request,
|
|
230
|
+
checkpoint_id: str,
|
|
231
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
232
|
+
) -> dict[str, Any]:
|
|
233
|
+
"""Delete a checkpoint.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
checkpoint_id: Checkpoint ID or name
|
|
237
|
+
workspace: v2 Workspace
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Delete result
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
HTTPException: 404 if checkpoint not found
|
|
244
|
+
"""
|
|
245
|
+
deleted = checkpoints.delete(workspace, checkpoint_id)
|
|
246
|
+
if not deleted:
|
|
247
|
+
raise HTTPException(
|
|
248
|
+
status_code=404,
|
|
249
|
+
detail=f"Checkpoint not found: {checkpoint_id}",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"success": True,
|
|
254
|
+
"message": f"Deleted checkpoint {checkpoint_id}",
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@router.get("/{checkpoint_id_a}/diff/{checkpoint_id_b}", response_model=CheckpointDiffResponse)
|
|
259
|
+
@rate_limit_standard()
|
|
260
|
+
async def diff_checkpoints(
|
|
261
|
+
request: Request,
|
|
262
|
+
checkpoint_id_a: str,
|
|
263
|
+
checkpoint_id_b: str,
|
|
264
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
265
|
+
) -> CheckpointDiffResponse:
|
|
266
|
+
"""Compare two checkpoints.
|
|
267
|
+
|
|
268
|
+
Shows task status changes between two checkpoints.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
checkpoint_id_a: First checkpoint ID (typically older)
|
|
272
|
+
checkpoint_id_b: Second checkpoint ID (typically newer)
|
|
273
|
+
workspace: v2 Workspace
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Diff result with task changes
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
HTTPException: 404 if either checkpoint not found
|
|
280
|
+
"""
|
|
281
|
+
try:
|
|
282
|
+
diff_result = checkpoints.diff(workspace, checkpoint_id_a, checkpoint_id_b)
|
|
283
|
+
|
|
284
|
+
return CheckpointDiffResponse(
|
|
285
|
+
checkpoint_a={
|
|
286
|
+
"id": diff_result.checkpoint_a_id,
|
|
287
|
+
"name": diff_result.checkpoint_a_name,
|
|
288
|
+
},
|
|
289
|
+
checkpoint_b={
|
|
290
|
+
"id": diff_result.checkpoint_b_id,
|
|
291
|
+
"name": diff_result.checkpoint_b_name,
|
|
292
|
+
},
|
|
293
|
+
task_diffs=[
|
|
294
|
+
TaskDiffResponse(
|
|
295
|
+
task_id=td.task_id,
|
|
296
|
+
title=td.title,
|
|
297
|
+
old_status=td.old_status,
|
|
298
|
+
new_status=td.new_status,
|
|
299
|
+
change_type=td.change_type,
|
|
300
|
+
)
|
|
301
|
+
for td in diff_result.task_diffs
|
|
302
|
+
],
|
|
303
|
+
summary=diff_result.summary,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
except ValueError as e:
|
|
307
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.error(f"Failed to diff checkpoints: {e}", exc_info=True)
|
|
310
|
+
raise HTTPException(status_code=500, detail=str(e))
|