codeframe-ai 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )