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,695 @@
|
|
|
1
|
+
"""V2 PRD router - delegates to core/prd module.
|
|
2
|
+
|
|
3
|
+
This module provides v2-style API endpoints for PRD (Product Requirements Document)
|
|
4
|
+
CRUD operations. Discovery/generation is handled by discovery_v2.py - this router
|
|
5
|
+
handles storage, retrieval, and management of PRD documents.
|
|
6
|
+
|
|
7
|
+
Routes:
|
|
8
|
+
GET /api/v2/prd - List PRDs or get latest
|
|
9
|
+
GET /api/v2/prd/{id} - Get a specific PRD
|
|
10
|
+
POST /api/v2/prd - Store a new PRD
|
|
11
|
+
DELETE /api/v2/prd/{id} - Delete a PRD
|
|
12
|
+
GET /api/v2/prd/{id}/versions - Get all versions of a PRD
|
|
13
|
+
POST /api/v2/prd/{id}/versions - Create new version
|
|
14
|
+
GET /api/v2/prd/{id}/diff - Diff two versions
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
from typing import AsyncGenerator, Optional
|
|
22
|
+
|
|
23
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
24
|
+
from fastapi.responses import StreamingResponse
|
|
25
|
+
from pydantic import BaseModel, Field, field_validator
|
|
26
|
+
|
|
27
|
+
from codeframe.core.workspace import Workspace
|
|
28
|
+
from codeframe.lib.rate_limiter import rate_limit_standard
|
|
29
|
+
from codeframe.core import prd
|
|
30
|
+
from codeframe.core.prd import PrdHasDependentTasksError
|
|
31
|
+
from codeframe.ui.dependencies import get_v2_workspace
|
|
32
|
+
from codeframe.ui.response_models import api_error, ErrorCodes
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
router = APIRouter(prefix="/api/v2/prd", tags=["prd-v2"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ============================================================================
|
|
40
|
+
# Request/Response Models
|
|
41
|
+
# ============================================================================
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class PrdResponse(BaseModel):
|
|
45
|
+
"""Response for a single PRD."""
|
|
46
|
+
|
|
47
|
+
id: str
|
|
48
|
+
workspace_id: str
|
|
49
|
+
title: str
|
|
50
|
+
content: str
|
|
51
|
+
metadata: dict
|
|
52
|
+
created_at: str
|
|
53
|
+
version: int
|
|
54
|
+
parent_id: Optional[str]
|
|
55
|
+
change_summary: Optional[str]
|
|
56
|
+
chain_id: Optional[str]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PrdSummaryResponse(BaseModel):
|
|
60
|
+
"""Summary response for PRD list (without full content)."""
|
|
61
|
+
|
|
62
|
+
id: str
|
|
63
|
+
workspace_id: str
|
|
64
|
+
title: str
|
|
65
|
+
created_at: str
|
|
66
|
+
version: int
|
|
67
|
+
chain_id: Optional[str]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class PrdListResponse(BaseModel):
|
|
71
|
+
"""Response for PRD list."""
|
|
72
|
+
|
|
73
|
+
prds: list[PrdSummaryResponse]
|
|
74
|
+
total: int
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CreatePrdRequest(BaseModel):
|
|
78
|
+
"""Request for creating a PRD."""
|
|
79
|
+
|
|
80
|
+
content: str = Field(..., min_length=1, description="PRD content (markdown)")
|
|
81
|
+
title: Optional[str] = Field(None, description="Optional title (extracted from content if not provided)")
|
|
82
|
+
metadata: Optional[dict] = Field(None, description="Optional metadata")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CreateVersionRequest(BaseModel):
|
|
86
|
+
"""Request for creating a new PRD version."""
|
|
87
|
+
|
|
88
|
+
content: str = Field(..., min_length=1, description="New PRD content")
|
|
89
|
+
change_summary: str = Field(..., min_length=1, description="Description of changes")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PrdDiffResponse(BaseModel):
|
|
93
|
+
"""Response for PRD version diff."""
|
|
94
|
+
|
|
95
|
+
version1: int
|
|
96
|
+
version2: int
|
|
97
|
+
diff: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AmbiguityAnswer(BaseModel):
|
|
101
|
+
"""A single answered ambiguity from the stress-test results view (#562)."""
|
|
102
|
+
|
|
103
|
+
label: str = Field(..., min_length=1, description="Short ambiguity label")
|
|
104
|
+
questions: list[str] = Field(
|
|
105
|
+
default_factory=list, description="The unanswered questions"
|
|
106
|
+
)
|
|
107
|
+
answer: str = Field(..., min_length=1, description="The user's answer")
|
|
108
|
+
|
|
109
|
+
@field_validator("answer")
|
|
110
|
+
@classmethod
|
|
111
|
+
def _answer_not_blank(cls, v: str) -> str:
|
|
112
|
+
# min_length alone admits whitespace-only answers from API callers;
|
|
113
|
+
# reject them so a blank string is never treated as resolved input.
|
|
114
|
+
if not v.strip():
|
|
115
|
+
raise ValueError("answer must not be blank")
|
|
116
|
+
return v
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class StressTestRefineRequest(BaseModel):
|
|
120
|
+
"""Request to refine a PRD from resolved stress-test ambiguities (#562).
|
|
121
|
+
|
|
122
|
+
Stateless: the client sends back the answered ambiguities' content (the
|
|
123
|
+
server does not persist stress-test runs), which are folded into the PRD
|
|
124
|
+
and saved as a new version.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
prd_id: str = Field(..., description="ID of the PRD to refine")
|
|
128
|
+
answers: list[AmbiguityAnswer] = Field(
|
|
129
|
+
..., min_length=1, description="Resolved ambiguities to fold into the PRD"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ============================================================================
|
|
134
|
+
# Helper Functions
|
|
135
|
+
# ============================================================================
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _prd_to_response(record: prd.PrdRecord) -> PrdResponse:
|
|
139
|
+
"""Convert a PrdRecord to a PrdResponse."""
|
|
140
|
+
return PrdResponse(
|
|
141
|
+
id=record.id,
|
|
142
|
+
workspace_id=record.workspace_id,
|
|
143
|
+
title=record.title,
|
|
144
|
+
content=record.content,
|
|
145
|
+
metadata=record.metadata,
|
|
146
|
+
created_at=record.created_at.isoformat(),
|
|
147
|
+
version=record.version,
|
|
148
|
+
parent_id=record.parent_id,
|
|
149
|
+
change_summary=record.change_summary,
|
|
150
|
+
chain_id=record.chain_id,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _prd_to_summary(record: prd.PrdRecord) -> PrdSummaryResponse:
|
|
155
|
+
"""Convert a PrdRecord to a PrdSummaryResponse (without content)."""
|
|
156
|
+
return PrdSummaryResponse(
|
|
157
|
+
id=record.id,
|
|
158
|
+
workspace_id=record.workspace_id,
|
|
159
|
+
title=record.title,
|
|
160
|
+
created_at=record.created_at.isoformat(),
|
|
161
|
+
version=record.version,
|
|
162
|
+
chain_id=record.chain_id,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ============================================================================
|
|
167
|
+
# Endpoints
|
|
168
|
+
# ============================================================================
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@router.get("", response_model=PrdListResponse)
|
|
172
|
+
@rate_limit_standard()
|
|
173
|
+
async def list_prds(
|
|
174
|
+
request: Request,
|
|
175
|
+
latest_only: bool = Query(False, description="If true, return only latest version per chain"),
|
|
176
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
177
|
+
) -> PrdListResponse:
|
|
178
|
+
"""List PRDs in the workspace.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
latest_only: If true, return only the latest version of each PRD chain
|
|
182
|
+
workspace: v2 Workspace
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
List of PRD summaries (without full content)
|
|
186
|
+
"""
|
|
187
|
+
if latest_only:
|
|
188
|
+
prd_list = prd.list_chains(workspace)
|
|
189
|
+
else:
|
|
190
|
+
prd_list = prd.list_all(workspace)
|
|
191
|
+
|
|
192
|
+
return PrdListResponse(
|
|
193
|
+
prds=[_prd_to_summary(p) for p in prd_list],
|
|
194
|
+
total=len(prd_list),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@router.get("/latest", response_model=PrdResponse)
|
|
199
|
+
@rate_limit_standard()
|
|
200
|
+
async def get_latest_prd(
|
|
201
|
+
request: Request,
|
|
202
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
203
|
+
) -> PrdResponse:
|
|
204
|
+
"""Get the most recently added PRD.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
workspace: v2 Workspace
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
The latest PRD
|
|
211
|
+
|
|
212
|
+
Raises:
|
|
213
|
+
HTTPException: 404 if no PRD exists
|
|
214
|
+
"""
|
|
215
|
+
record = prd.get_latest(workspace)
|
|
216
|
+
|
|
217
|
+
if not record:
|
|
218
|
+
raise HTTPException(
|
|
219
|
+
status_code=404,
|
|
220
|
+
detail=api_error("No PRD found", ErrorCodes.NOT_FOUND, "No PRD exists in this workspace"),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return _prd_to_response(record)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _sse(event: dict) -> str:
|
|
227
|
+
"""Format a stress-test event dict as an SSE ``data:`` frame."""
|
|
228
|
+
return f"data: {json.dumps(event)}\n\n"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _resolve_llm_provider(workspace: Workspace):
|
|
232
|
+
"""Resolve the LLM provider for PRD stress-test web operations.
|
|
233
|
+
|
|
234
|
+
Follows the documented chain: env var → workspace config
|
|
235
|
+
(``.codeframe/config.yaml``) → default ``anthropic``. (No CLI flag here —
|
|
236
|
+
this is the web surface.) Mirrors ``runtime.py`` and the stress-test stream.
|
|
237
|
+
|
|
238
|
+
Raises:
|
|
239
|
+
ValueError: with a user-facing message when the Anthropic API key is
|
|
240
|
+
missing or the provider cannot be constructed.
|
|
241
|
+
"""
|
|
242
|
+
from codeframe.adapters.llm import get_provider
|
|
243
|
+
from codeframe.core.config import load_environment_config
|
|
244
|
+
|
|
245
|
+
env_cfg = load_environment_config(workspace.repo_path)
|
|
246
|
+
llm_cfg = env_cfg.llm if (env_cfg and env_cfg.llm) else None
|
|
247
|
+
provider_type = (
|
|
248
|
+
os.getenv("CODEFRAME_LLM_PROVIDER")
|
|
249
|
+
or (llm_cfg.provider if llm_cfg else None)
|
|
250
|
+
or "anthropic"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Only the Anthropic provider needs an API key up front; local providers
|
|
254
|
+
# (ollama/vllm/compatible) do not.
|
|
255
|
+
if provider_type == "anthropic" and not os.getenv("ANTHROPIC_API_KEY"):
|
|
256
|
+
raise ValueError("ANTHROPIC_API_KEY environment variable required.")
|
|
257
|
+
|
|
258
|
+
provider_kwargs: dict = {}
|
|
259
|
+
model_override = os.getenv("CODEFRAME_LLM_MODEL") or (
|
|
260
|
+
llm_cfg.model if llm_cfg else None
|
|
261
|
+
)
|
|
262
|
+
base_url_override = (llm_cfg.base_url if llm_cfg else None) or os.getenv(
|
|
263
|
+
"OPENAI_BASE_URL"
|
|
264
|
+
)
|
|
265
|
+
if model_override:
|
|
266
|
+
provider_kwargs["model"] = model_override
|
|
267
|
+
if base_url_override:
|
|
268
|
+
provider_kwargs["base_url"] = base_url_override
|
|
269
|
+
|
|
270
|
+
return get_provider(provider_type, **provider_kwargs)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
async def _stress_test_event_stream(
|
|
274
|
+
workspace: Workspace,
|
|
275
|
+
max_depth: int,
|
|
276
|
+
request: Optional[Request] = None,
|
|
277
|
+
) -> AsyncGenerator[str, None]:
|
|
278
|
+
"""Yield SSE frames for a PRD stress-test.
|
|
279
|
+
|
|
280
|
+
Recoverable problems (missing PRD, missing ``ANTHROPIC_API_KEY``) are
|
|
281
|
+
surfaced as in-stream ``error`` events rather than HTTP errors, so a
|
|
282
|
+
browser ``EventSource`` can display them via its message handler.
|
|
283
|
+
|
|
284
|
+
Stops early if the client disconnects, so an abandoned stream does not keep
|
|
285
|
+
issuing LLM calls — mirroring ``event_stream_generator`` in streaming_v2.
|
|
286
|
+
"""
|
|
287
|
+
from codeframe.core.prd_stress_test import stress_test_prd_stream
|
|
288
|
+
|
|
289
|
+
record = prd.get_latest(workspace)
|
|
290
|
+
if not record:
|
|
291
|
+
yield _sse({
|
|
292
|
+
"type": "error",
|
|
293
|
+
"message": "No PRD found. Add or generate a PRD first.",
|
|
294
|
+
})
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
# Resolve the LLM provider following the documented chain (shared with the
|
|
298
|
+
# refine endpoint). Recoverable problems become in-stream error events so a
|
|
299
|
+
# browser EventSource can display them.
|
|
300
|
+
try:
|
|
301
|
+
provider = _resolve_llm_provider(workspace)
|
|
302
|
+
except ValueError as exc:
|
|
303
|
+
yield _sse({"type": "error", "message": str(exc)})
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
async for event in stress_test_prd_stream(
|
|
307
|
+
record.content, provider, max_depth=max_depth,
|
|
308
|
+
):
|
|
309
|
+
# If the browser has gone away, stop iterating the core generator so its
|
|
310
|
+
# next (blocking, billable) LLM call is never made.
|
|
311
|
+
if request is not None and await request.is_disconnected():
|
|
312
|
+
logger.info("Client disconnected from stress-test stream; aborting")
|
|
313
|
+
break
|
|
314
|
+
yield _sse(event)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@router.get("/stress-test")
|
|
318
|
+
@rate_limit_standard()
|
|
319
|
+
async def stress_test_prd_stream_endpoint(
|
|
320
|
+
request: Request,
|
|
321
|
+
max_depth: int = Query(3, ge=1, le=10, description="Maximum recursion depth"),
|
|
322
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
323
|
+
) -> StreamingResponse:
|
|
324
|
+
"""Stream a PRD stress-test (recursive decomposition) via SSE.
|
|
325
|
+
|
|
326
|
+
Runs the headless ``stress_test_prd_stream`` core generator over the
|
|
327
|
+
latest PRD and emits its progress events as Server-Sent Events. This is
|
|
328
|
+
the web equivalent of ``cf prd stress-test``.
|
|
329
|
+
|
|
330
|
+
Declared as GET (not POST) so it is reachable from a browser
|
|
331
|
+
``EventSource``, matching ``GET /api/v2/tasks/{task_id}/stream``. No custom
|
|
332
|
+
auth headers are required (cookie-based auth via ``withCredentials``).
|
|
333
|
+
|
|
334
|
+
Event payloads (JSON in the SSE ``data:`` field, ``type`` field):
|
|
335
|
+
- ``goals_extracted``: high-level goals parsed from the PRD
|
|
336
|
+
- ``goal_analyzed``: one per top-level goal (classification + running
|
|
337
|
+
ambiguity count)
|
|
338
|
+
- ``complete``: ambiguity count + rendered tech spec / ambiguity report
|
|
339
|
+
- ``error``: no PRD, missing API key, or decomposition failure
|
|
340
|
+
"""
|
|
341
|
+
return StreamingResponse(
|
|
342
|
+
_stress_test_event_stream(workspace, max_depth, request),
|
|
343
|
+
media_type="text/event-stream",
|
|
344
|
+
headers={
|
|
345
|
+
"Cache-Control": "no-cache",
|
|
346
|
+
"Connection": "keep-alive",
|
|
347
|
+
"X-Accel-Buffering": "no",
|
|
348
|
+
},
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# NOTE: registered before the "/{prd_id}" catch-all so FastAPI does not match
|
|
353
|
+
# "stress-test/refine" as a PRD id.
|
|
354
|
+
@router.post("/stress-test/refine", response_model=PrdResponse)
|
|
355
|
+
@rate_limit_standard()
|
|
356
|
+
async def refine_prd_from_stress_test(
|
|
357
|
+
request: Request,
|
|
358
|
+
body: StressTestRefineRequest,
|
|
359
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
360
|
+
) -> PrdResponse:
|
|
361
|
+
"""Refine a PRD by folding in answered stress-test ambiguities (#562).
|
|
362
|
+
|
|
363
|
+
Reconstructs :class:`Ambiguity` objects from the submitted answers, calls
|
|
364
|
+
the headless ``resolve_ambiguities_into_prd`` to rewrite the PRD via the
|
|
365
|
+
LLM, then persists the result as a new PRD version. Returns the new version.
|
|
366
|
+
"""
|
|
367
|
+
from codeframe.core.prd_stress_test import (
|
|
368
|
+
Ambiguity,
|
|
369
|
+
resolve_ambiguities_into_prd,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
record = prd.get_by_id(workspace, body.prd_id)
|
|
373
|
+
if not record:
|
|
374
|
+
raise HTTPException(
|
|
375
|
+
status_code=404,
|
|
376
|
+
detail=api_error(
|
|
377
|
+
"PRD not found", ErrorCodes.NOT_FOUND, f"No PRD with id {body.prd_id}"
|
|
378
|
+
),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
provider = _resolve_llm_provider(workspace)
|
|
383
|
+
except ValueError as exc:
|
|
384
|
+
# The request is well-formed; the server lacks LLM configuration
|
|
385
|
+
# (missing API key or unknown provider) → 503, not 400.
|
|
386
|
+
raise HTTPException(
|
|
387
|
+
status_code=503,
|
|
388
|
+
detail=api_error(
|
|
389
|
+
"LLM provider unavailable",
|
|
390
|
+
ErrorCodes.SERVICE_UNAVAILABLE,
|
|
391
|
+
str(exc),
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# resolve_ambiguities_into_prd only reads label, questions, and
|
|
396
|
+
# resolved_answer, so source_node_title/recommendation are intentionally
|
|
397
|
+
# left empty here (the client does not need to round-trip them).
|
|
398
|
+
ambiguities = [
|
|
399
|
+
Ambiguity(
|
|
400
|
+
id=str(i),
|
|
401
|
+
label=ans.label,
|
|
402
|
+
source_node_title="",
|
|
403
|
+
questions=list(ans.questions),
|
|
404
|
+
recommendation="",
|
|
405
|
+
resolved_answer=ans.answer,
|
|
406
|
+
)
|
|
407
|
+
for i, ans in enumerate(body.answers)
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
# resolve_ambiguities_into_prd makes a synchronous, blocking LLM call;
|
|
412
|
+
# offload it to a thread so it does not stall the event loop (mirrors
|
|
413
|
+
# stress_test_prd_stream's asyncio.to_thread usage).
|
|
414
|
+
refined_content = await asyncio.to_thread(
|
|
415
|
+
resolve_ambiguities_into_prd, record.content, ambiguities, provider
|
|
416
|
+
)
|
|
417
|
+
# resolve_ambiguities_into_prd returns the original content unchanged
|
|
418
|
+
# when the LLM rewrite looks truncated. Surface that as an error rather
|
|
419
|
+
# than recording a no-op duplicate version under a "success" toast.
|
|
420
|
+
if refined_content == record.content:
|
|
421
|
+
raise HTTPException(
|
|
422
|
+
status_code=502,
|
|
423
|
+
detail=api_error(
|
|
424
|
+
"PRD refinement produced no changes",
|
|
425
|
+
ErrorCodes.EXECUTION_FAILED,
|
|
426
|
+
"The model returned no usable changes (its output may have "
|
|
427
|
+
"been truncated). Please try again.",
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
new_record = prd.create_new_version(
|
|
431
|
+
workspace,
|
|
432
|
+
parent_prd_id=body.prd_id,
|
|
433
|
+
new_content=refined_content,
|
|
434
|
+
change_summary="Refined via stress-test ambiguity resolution",
|
|
435
|
+
)
|
|
436
|
+
if not new_record:
|
|
437
|
+
# get_by_id already confirmed the PRD exists, so a None here is a
|
|
438
|
+
# persistence fault, not a missing resource → 500, not 404.
|
|
439
|
+
raise HTTPException(
|
|
440
|
+
status_code=500,
|
|
441
|
+
detail=api_error(
|
|
442
|
+
"Failed to persist new PRD version",
|
|
443
|
+
ErrorCodes.INTERNAL_ERROR,
|
|
444
|
+
f"create_new_version returned no record for {body.prd_id}",
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
return _prd_to_response(new_record)
|
|
448
|
+
except HTTPException:
|
|
449
|
+
raise
|
|
450
|
+
except Exception as e:
|
|
451
|
+
logger.error(f"Failed to refine PRD: {e}", exc_info=True)
|
|
452
|
+
raise HTTPException(
|
|
453
|
+
status_code=500,
|
|
454
|
+
detail=api_error(
|
|
455
|
+
"Failed to refine PRD", ErrorCodes.EXECUTION_FAILED, str(e)
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@router.get("/{prd_id}", response_model=PrdResponse)
|
|
461
|
+
@rate_limit_standard()
|
|
462
|
+
async def get_prd(
|
|
463
|
+
request: Request,
|
|
464
|
+
prd_id: str,
|
|
465
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
466
|
+
) -> PrdResponse:
|
|
467
|
+
"""Get a specific PRD by ID.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
prd_id: PRD identifier
|
|
471
|
+
workspace: v2 Workspace
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
PRD details
|
|
475
|
+
|
|
476
|
+
Raises:
|
|
477
|
+
HTTPException: 404 if PRD not found
|
|
478
|
+
"""
|
|
479
|
+
record = prd.get_by_id(workspace, prd_id)
|
|
480
|
+
|
|
481
|
+
if not record:
|
|
482
|
+
raise HTTPException(
|
|
483
|
+
status_code=404,
|
|
484
|
+
detail=api_error("PRD not found", ErrorCodes.NOT_FOUND, f"No PRD with id {prd_id}"),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
return _prd_to_response(record)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
@router.post("", response_model=PrdResponse, status_code=201)
|
|
491
|
+
@rate_limit_standard()
|
|
492
|
+
async def create_prd(
|
|
493
|
+
request: Request,
|
|
494
|
+
body: CreatePrdRequest,
|
|
495
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
496
|
+
) -> PrdResponse:
|
|
497
|
+
"""Store a new PRD.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
request: HTTP request for rate limiting
|
|
501
|
+
body: PRD creation request
|
|
502
|
+
workspace: v2 Workspace
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
Created PRD
|
|
506
|
+
"""
|
|
507
|
+
try:
|
|
508
|
+
record = prd.store(
|
|
509
|
+
workspace,
|
|
510
|
+
content=body.content,
|
|
511
|
+
title=body.title,
|
|
512
|
+
metadata=body.metadata,
|
|
513
|
+
)
|
|
514
|
+
return _prd_to_response(record)
|
|
515
|
+
|
|
516
|
+
except Exception as e:
|
|
517
|
+
logger.error(f"Failed to create PRD: {e}", exc_info=True)
|
|
518
|
+
raise HTTPException(
|
|
519
|
+
status_code=500,
|
|
520
|
+
detail=api_error("Failed to create PRD", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@router.delete("/{prd_id}")
|
|
525
|
+
@rate_limit_standard()
|
|
526
|
+
async def delete_prd(
|
|
527
|
+
request: Request,
|
|
528
|
+
prd_id: str,
|
|
529
|
+
force: bool = Query(False, description="Force delete even if tasks depend on this PRD"),
|
|
530
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
531
|
+
) -> dict:
|
|
532
|
+
"""Delete a PRD.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
prd_id: PRD identifier to delete
|
|
536
|
+
force: If true, delete even if tasks depend on this PRD
|
|
537
|
+
workspace: v2 Workspace
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
Deletion confirmation
|
|
541
|
+
|
|
542
|
+
Raises:
|
|
543
|
+
HTTPException:
|
|
544
|
+
- 404: PRD not found
|
|
545
|
+
- 409: PRD has dependent tasks and force=false
|
|
546
|
+
"""
|
|
547
|
+
try:
|
|
548
|
+
# Check dependencies unless force=True
|
|
549
|
+
check_deps = not force
|
|
550
|
+
deleted = prd.delete(workspace, prd_id, check_dependencies=check_deps)
|
|
551
|
+
|
|
552
|
+
if not deleted:
|
|
553
|
+
raise HTTPException(
|
|
554
|
+
status_code=404,
|
|
555
|
+
detail=api_error("PRD not found", ErrorCodes.NOT_FOUND, f"No PRD with id {prd_id}"),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
"success": True,
|
|
560
|
+
"message": f"PRD {prd_id[:8]} deleted successfully",
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
except PrdHasDependentTasksError as e:
|
|
564
|
+
raise HTTPException(
|
|
565
|
+
status_code=409,
|
|
566
|
+
detail=api_error(
|
|
567
|
+
"Cannot delete PRD with dependent tasks",
|
|
568
|
+
ErrorCodes.CONFLICT,
|
|
569
|
+
f"{e.task_count} task(s) depend on this PRD. Use force=true to delete anyway.",
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ============================================================================
|
|
575
|
+
# Version Endpoints
|
|
576
|
+
# ============================================================================
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@router.get("/{prd_id}/versions", response_model=list[PrdResponse])
|
|
580
|
+
@rate_limit_standard()
|
|
581
|
+
async def get_prd_versions(
|
|
582
|
+
request: Request,
|
|
583
|
+
prd_id: str,
|
|
584
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
585
|
+
) -> list[PrdResponse]:
|
|
586
|
+
"""Get all versions of a PRD.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
prd_id: ID of any PRD in the version chain
|
|
590
|
+
workspace: v2 Workspace
|
|
591
|
+
|
|
592
|
+
Returns:
|
|
593
|
+
List of all versions, newest first
|
|
594
|
+
|
|
595
|
+
Raises:
|
|
596
|
+
HTTPException: 404 if PRD not found
|
|
597
|
+
"""
|
|
598
|
+
versions = prd.get_versions(workspace, prd_id)
|
|
599
|
+
|
|
600
|
+
if not versions:
|
|
601
|
+
raise HTTPException(
|
|
602
|
+
status_code=404,
|
|
603
|
+
detail=api_error("PRD not found", ErrorCodes.NOT_FOUND, f"No PRD with id {prd_id}"),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
return [_prd_to_response(v) for v in versions]
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
@router.post("/{prd_id}/versions", response_model=PrdResponse, status_code=201)
|
|
610
|
+
@rate_limit_standard()
|
|
611
|
+
async def create_prd_version(
|
|
612
|
+
request: Request,
|
|
613
|
+
prd_id: str,
|
|
614
|
+
body: CreateVersionRequest,
|
|
615
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
616
|
+
) -> PrdResponse:
|
|
617
|
+
"""Create a new version of a PRD.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
prd_id: ID of the parent PRD
|
|
621
|
+
request: Version creation request
|
|
622
|
+
workspace: v2 Workspace
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
Created PRD version
|
|
626
|
+
|
|
627
|
+
Raises:
|
|
628
|
+
HTTPException: 404 if parent PRD not found
|
|
629
|
+
"""
|
|
630
|
+
try:
|
|
631
|
+
record = prd.create_new_version(
|
|
632
|
+
workspace,
|
|
633
|
+
parent_prd_id=prd_id,
|
|
634
|
+
new_content=body.content,
|
|
635
|
+
change_summary=body.change_summary,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
if not record:
|
|
639
|
+
raise HTTPException(
|
|
640
|
+
status_code=404,
|
|
641
|
+
detail=api_error("PRD not found", ErrorCodes.NOT_FOUND, f"No PRD with id {prd_id}"),
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
return _prd_to_response(record)
|
|
645
|
+
|
|
646
|
+
except HTTPException:
|
|
647
|
+
raise
|
|
648
|
+
except Exception as e:
|
|
649
|
+
logger.error(f"Failed to create PRD version: {e}", exc_info=True)
|
|
650
|
+
raise HTTPException(
|
|
651
|
+
status_code=500,
|
|
652
|
+
detail=api_error("Failed to create version", ErrorCodes.EXECUTION_FAILED, str(e)),
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@router.get("/{prd_id}/diff", response_model=PrdDiffResponse)
|
|
657
|
+
@rate_limit_standard()
|
|
658
|
+
async def diff_prd_versions(
|
|
659
|
+
request: Request,
|
|
660
|
+
prd_id: str,
|
|
661
|
+
v1: int = Query(..., ge=1, description="First version number"),
|
|
662
|
+
v2: int = Query(..., ge=1, description="Second version number"),
|
|
663
|
+
workspace: Workspace = Depends(get_v2_workspace),
|
|
664
|
+
) -> PrdDiffResponse:
|
|
665
|
+
"""Generate a diff between two versions of a PRD.
|
|
666
|
+
|
|
667
|
+
Args:
|
|
668
|
+
prd_id: ID of any PRD in the version chain
|
|
669
|
+
v1: First version number
|
|
670
|
+
v2: Second version number
|
|
671
|
+
workspace: v2 Workspace
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
Unified diff string
|
|
675
|
+
|
|
676
|
+
Raises:
|
|
677
|
+
HTTPException: 404 if PRD or version not found
|
|
678
|
+
"""
|
|
679
|
+
diff_result = prd.diff_versions(workspace, prd_id, v1, v2)
|
|
680
|
+
|
|
681
|
+
if diff_result is None:
|
|
682
|
+
raise HTTPException(
|
|
683
|
+
status_code=404,
|
|
684
|
+
detail=api_error(
|
|
685
|
+
"Version not found",
|
|
686
|
+
ErrorCodes.NOT_FOUND,
|
|
687
|
+
f"Could not find version {v1} or {v2} for PRD {prd_id}",
|
|
688
|
+
),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
return PrdDiffResponse(
|
|
692
|
+
version1=v1,
|
|
693
|
+
version2=v2,
|
|
694
|
+
diff=diff_result,
|
|
695
|
+
)
|