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,322 @@
1
+ """Cost analytics API router for CodeFRAME v2 (issue #557).
2
+
3
+ Aggregates the workspace's `token_usage` table into a daily-bucket summary
4
+ for the /costs page in the web UI. Hosts a single endpoint:
5
+
6
+ GET /api/v2/costs/summary?days=30
7
+
8
+ Returns an empty-state payload (all zeros, zero-filled daily series) when
9
+ no spend data exists or the table isn't present — never 404.
10
+
11
+ The handler opens the workspace SQLite database directly to avoid the
12
+ pre-existing schema conflict between `codeframe/core/workspace.py` and
13
+ `codeframe/platform_store/schema_manager.py` — wiring `TokenRepository`
14
+ to a raw connection skips `Database.initialize()` entirely.
15
+ """
16
+
17
+ import logging
18
+ import sqlite3
19
+ from datetime import datetime, timedelta, timezone
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from fastapi import APIRouter, Depends, Query, Request
23
+ from pydantic import BaseModel
24
+
25
+ from codeframe.core import tasks as tasks_module
26
+ from codeframe.core.workspace import Workspace
27
+ from codeframe.lib.rate_limiter import rate_limit_standard
28
+ from codeframe.platform_store.repositories.token_repository import TokenRepository
29
+ from codeframe.ui.dependencies import get_v2_workspace
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ router = APIRouter(prefix="/api/v2/costs", tags=["metrics"])
34
+
35
+
36
+ class DailyCostPoint(BaseModel):
37
+ """One day of aggregated spend."""
38
+
39
+ date: str # ISO format YYYY-MM-DD
40
+ cost_usd: float
41
+
42
+
43
+ class CostSummaryResponse(BaseModel):
44
+ """Aggregated spend over the requested window."""
45
+
46
+ total_spend_usd: float
47
+ total_tasks: int
48
+ avg_cost_per_task: float
49
+ daily: List[DailyCostPoint]
50
+
51
+
52
+ def _empty_summary(days: int) -> Dict:
53
+ """Build a zero-state response with `days` daily buckets."""
54
+ end_date = datetime.now(timezone.utc).date()
55
+ start_date = end_date - timedelta(days=days - 1)
56
+ daily = [
57
+ {"date": (start_date + timedelta(days=i)).isoformat(), "cost_usd": 0.0}
58
+ for i in range(days)
59
+ ]
60
+ return {
61
+ "total_spend_usd": 0.0,
62
+ "total_tasks": 0,
63
+ "avg_cost_per_task": 0.0,
64
+ "daily": daily,
65
+ }
66
+
67
+
68
+ def _query_costs(db_path: str, days: int) -> Dict:
69
+ """Query the workspace DB via TokenRepository on a raw connection.
70
+
71
+ Returns an empty summary if the DB can't be opened or the table is missing,
72
+ rather than raising — keeps the endpoint safe for fresh workspaces.
73
+
74
+ TODO(schema-conflict): we open the connection directly rather than through
75
+ `Database(...).initialize()` because the v2 workspace schema in
76
+ `codeframe/core/workspace.py` and the global schema in
77
+ `persistence/schema_manager.py` define `blockers` incompatibly, and
78
+ `Database.initialize()` therefore crashes on existing workspace DBs.
79
+ Remove this workaround once the two schemas converge.
80
+ """
81
+ try:
82
+ conn = sqlite3.connect(db_path)
83
+ conn.row_factory = sqlite3.Row
84
+ except sqlite3.Error as e:
85
+ logger.warning("costs: failed to open %s: %s", db_path, e)
86
+ return _empty_summary(days)
87
+
88
+ try:
89
+ try:
90
+ cursor = conn.cursor()
91
+ cursor.execute(
92
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage'"
93
+ )
94
+ if cursor.fetchone() is None:
95
+ return _empty_summary(days)
96
+
97
+ repo = TokenRepository(sync_conn=conn)
98
+ return repo.get_costs_summary(days)
99
+ except sqlite3.Error as e:
100
+ # Locked DB, corrupted schema, etc. — fall back to empty state
101
+ # rather than 500'ing the dashboard.
102
+ logger.warning("costs: query failed on %s: %s", db_path, e)
103
+ return _empty_summary(days)
104
+ finally:
105
+ conn.close()
106
+
107
+
108
+ @router.get("/summary", response_model=CostSummaryResponse)
109
+ @rate_limit_standard()
110
+ async def get_costs_summary(
111
+ request: Request,
112
+ workspace: Workspace = Depends(get_v2_workspace),
113
+ days: int = Query(30, ge=7, le=90, description="Window size in days (7-90)"),
114
+ ):
115
+ """Return total spend, task count, average cost, and a daily series.
116
+
117
+ Reads from the workspace's `token_usage` table. Returns zero-filled
118
+ daily buckets so the client can render a chart without conditionals.
119
+ If the table doesn't exist (no agent has run in this workspace yet),
120
+ returns an empty-state response rather than an error.
121
+ """
122
+ summary = _query_costs(str(workspace.db_path), days)
123
+ return CostSummaryResponse(
124
+ total_spend_usd=summary["total_spend_usd"],
125
+ total_tasks=summary["total_tasks"],
126
+ avg_cost_per_task=summary["avg_cost_per_task"],
127
+ daily=[
128
+ DailyCostPoint(date=d["date"], cost_usd=d["cost_usd"])
129
+ for d in summary["daily"]
130
+ ],
131
+ )
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Per-task and per-agent breakdowns (Issue #558)
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ class TaskCostEntry(BaseModel):
140
+ """One task's aggregated cost with the most-used agent."""
141
+
142
+ task_id: str
143
+ task_title: str
144
+ agent_id: str
145
+ input_tokens: int
146
+ output_tokens: int
147
+ total_cost_usd: float
148
+
149
+
150
+ class TaskCostsResponse(BaseModel):
151
+ """Top-N tasks by cost over the requested window."""
152
+
153
+ tasks: List[TaskCostEntry]
154
+
155
+
156
+ class AgentCostEntry(BaseModel):
157
+ """One agent's aggregated cost over the window."""
158
+
159
+ agent_id: str
160
+ input_tokens: int
161
+ output_tokens: int
162
+ total_cost_usd: float
163
+ call_count: int
164
+
165
+
166
+ class AgentCostsResponse(BaseModel):
167
+ """Per-agent breakdown plus overall token totals."""
168
+
169
+ by_agent: List[AgentCostEntry]
170
+ total_input_tokens: int
171
+ total_output_tokens: int
172
+
173
+
174
+ def _placeholder_task_title(task_id: str) -> str:
175
+ """Title to display when a task referenced by token_usage no longer exists."""
176
+ short = str(task_id)[:8] if task_id else "unknown"
177
+ return f"Unknown task ({short})"
178
+
179
+
180
+ def _open_workspace_conn(db_path: str) -> Optional[sqlite3.Connection]:
181
+ """Open the workspace DB or return None if it cannot be read.
182
+
183
+ Mirrors _query_costs's tolerance for fresh/locked workspaces: callers
184
+ fall back to an empty response rather than 500'ing the dashboard.
185
+ """
186
+ try:
187
+ conn = sqlite3.connect(db_path)
188
+ conn.row_factory = sqlite3.Row
189
+ return conn
190
+ except sqlite3.Error as e:
191
+ logger.warning("costs: failed to open %s: %s", db_path, e)
192
+ return None
193
+
194
+
195
+ def _token_usage_exists(conn: sqlite3.Connection) -> bool:
196
+ cursor = conn.cursor()
197
+ cursor.execute(
198
+ "SELECT name FROM sqlite_master WHERE type='table' AND name='token_usage'"
199
+ )
200
+ return cursor.fetchone() is not None
201
+
202
+
203
+ def _query_top_tasks(
204
+ db_path: str, workspace: Workspace, days: int, limit: int,
205
+ ) -> List[Dict[str, Any]]:
206
+ """Aggregate per-task cost and join titles via workspace.tasks.
207
+
208
+ Returns a list of dicts ready for serialization into ``TaskCostEntry``.
209
+ """
210
+ conn = _open_workspace_conn(db_path)
211
+ if conn is None:
212
+ return []
213
+
214
+ try:
215
+ if not _token_usage_exists(conn):
216
+ return []
217
+ try:
218
+ repo = TokenRepository(sync_conn=conn)
219
+ rows = repo.get_top_tasks_by_cost(days=days, limit=limit)
220
+ except sqlite3.Error as e:
221
+ logger.warning("costs/tasks: query failed on %s: %s", db_path, e)
222
+ return []
223
+ finally:
224
+ conn.close()
225
+
226
+ entries: List[Dict[str, Any]] = []
227
+ for row in rows:
228
+ raw_id = row["task_id"]
229
+ task_id_str = str(raw_id) if raw_id is not None else ""
230
+ title = _placeholder_task_title(task_id_str)
231
+ try:
232
+ task = tasks_module.get(workspace, task_id_str)
233
+ if task is not None:
234
+ title = task.title
235
+ except Exception:
236
+ # Lookup failures are non-fatal — keep the placeholder title.
237
+ logger.debug("costs/tasks: task lookup failed for %s", task_id_str, exc_info=True)
238
+
239
+ entries.append({
240
+ "task_id": task_id_str,
241
+ "task_title": title,
242
+ "agent_id": row["agent_id"],
243
+ "input_tokens": row["input_tokens"],
244
+ "output_tokens": row["output_tokens"],
245
+ "total_cost_usd": row["total_cost_usd"],
246
+ })
247
+
248
+ return entries
249
+
250
+
251
+ def _query_costs_by_agent(db_path: str, days: int) -> Dict[str, Any]:
252
+ """Aggregate per-agent cost over the window."""
253
+ empty = {"by_agent": [], "total_input_tokens": 0, "total_output_tokens": 0}
254
+
255
+ conn = _open_workspace_conn(db_path)
256
+ if conn is None:
257
+ return empty
258
+
259
+ try:
260
+ if not _token_usage_exists(conn):
261
+ return empty
262
+ try:
263
+ repo = TokenRepository(sync_conn=conn)
264
+ return repo.get_costs_by_agent(days=days)
265
+ except sqlite3.Error as e:
266
+ logger.warning("costs/by-agent: query failed on %s: %s", db_path, e)
267
+ return empty
268
+ finally:
269
+ conn.close()
270
+
271
+
272
+ @router.get("/tasks", response_model=TaskCostsResponse)
273
+ @rate_limit_standard()
274
+ async def get_costs_by_task(
275
+ request: Request,
276
+ workspace: Workspace = Depends(get_v2_workspace),
277
+ days: int = Query(30, ge=1, le=365, description="Window size in days (1-365)"),
278
+ limit: int = Query(
279
+ 10,
280
+ ge=1,
281
+ le=1000,
282
+ description=(
283
+ "Max number of tasks to return. Default 10 matches the analytics view; "
284
+ "raise it (e.g. to 1000) when populating a per-task badge map for the "
285
+ "full task board."
286
+ ),
287
+ ),
288
+ ):
289
+ """Return the top ``limit`` tasks by total cost over the requested window.
290
+
291
+ Token usage rows are grouped by ``task_id``; the resulting list is sorted
292
+ by total cost (descending). Rows whose ``task_id`` is NULL are excluded —
293
+ only task-attributable spend counts here.
294
+
295
+ If the workspace has no token usage data yet (or the table doesn't exist),
296
+ returns ``{"tasks": []}`` rather than an error.
297
+ """
298
+ entries = _query_top_tasks(str(workspace.db_path), workspace, days, limit=limit)
299
+ return TaskCostsResponse(
300
+ tasks=[TaskCostEntry(**e) for e in entries],
301
+ )
302
+
303
+
304
+ @router.get("/by-agent", response_model=AgentCostsResponse)
305
+ @rate_limit_standard()
306
+ async def get_costs_by_agent_endpoint(
307
+ request: Request,
308
+ workspace: Workspace = Depends(get_v2_workspace),
309
+ days: int = Query(30, ge=1, le=365, description="Window size in days (1-365)"),
310
+ ):
311
+ """Return per-agent cost breakdown and overall input/output token totals.
312
+
313
+ Token usage rows are grouped by ``agent_id`` and sorted by total cost
314
+ (descending). Rows with NULL ``task_id`` still count toward the agent's
315
+ totals (a non-task call still represents spend).
316
+ """
317
+ summary = _query_costs_by_agent(str(workspace.db_path), days)
318
+ return AgentCostsResponse(
319
+ by_agent=[AgentCostEntry(**a) for a in summary["by_agent"]],
320
+ total_input_tokens=summary["total_input_tokens"],
321
+ total_output_tokens=summary["total_output_tokens"],
322
+ )
@@ -0,0 +1,225 @@
1
+ """V2 Diagnose router - delegates to core/diagnostic_agent module.
2
+
3
+ This module provides v2-style API endpoints for task diagnosis.
4
+ Diagnosis analyzes failed runs to identify root causes and recommendations.
5
+
6
+ Routes:
7
+ POST /api/v2/tasks/{id}/diagnose - Diagnose a failed task
8
+ GET /api/v2/tasks/{id}/diagnose - Get existing diagnostic report
9
+ """
10
+
11
+ import logging
12
+ from typing import Optional
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException, Request
15
+ from pydantic import BaseModel, Field
16
+
17
+ from codeframe.core.workspace import Workspace
18
+ from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard
19
+ from codeframe.core import tasks, runtime
20
+ from codeframe.core.diagnostics import (
21
+ DiagnosticReport,
22
+ get_latest_diagnostic_report,
23
+ )
24
+ from codeframe.core.diagnostic_agent import DiagnosticAgent
25
+ from codeframe.ui.dependencies import get_v2_workspace
26
+ from codeframe.ui.response_models import api_error, ErrorCodes
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ router = APIRouter(prefix="/api/v2/tasks", tags=["diagnose-v2"])
31
+
32
+
33
+ # ============================================================================
34
+ # Request/Response Models
35
+ # ============================================================================
36
+
37
+
38
+ class RecommendationResponse(BaseModel):
39
+ """Response for a diagnostic recommendation."""
40
+
41
+ title: str
42
+ description: str
43
+ action: str # RemediationAction value
44
+ priority: int
45
+ command: Optional[str]
46
+
47
+
48
+ class DiagnosticReportResponse(BaseModel):
49
+ """Response for a diagnostic report."""
50
+
51
+ id: str
52
+ task_id: str
53
+ run_id: str
54
+ failure_category: str
55
+ severity: str
56
+ root_cause: str
57
+ log_summary: str
58
+ error_messages: list[str]
59
+ recommendations: list[RecommendationResponse]
60
+ has_blocker: bool
61
+ analyzed_at: str
62
+
63
+
64
+ class DiagnoseRequest(BaseModel):
65
+ """Request for running diagnosis."""
66
+
67
+ force: bool = Field(False, description="Force re-analysis even if report exists")
68
+
69
+
70
+ # ============================================================================
71
+ # Helper Functions
72
+ # ============================================================================
73
+
74
+
75
+ def _report_to_response(report: DiagnosticReport) -> DiagnosticReportResponse:
76
+ """Convert a DiagnosticReport to a DiagnosticReportResponse."""
77
+ return DiagnosticReportResponse(
78
+ id=report.id,
79
+ task_id=report.task_id,
80
+ run_id=report.run_id,
81
+ failure_category=report.failure_category.value,
82
+ severity=report.severity.value,
83
+ root_cause=report.root_cause,
84
+ log_summary=report.log_summary,
85
+ error_messages=report.error_messages,
86
+ recommendations=[
87
+ RecommendationResponse(
88
+ title=rec.title,
89
+ description=rec.description,
90
+ action=rec.action.value,
91
+ priority=rec.priority,
92
+ command=rec.command,
93
+ )
94
+ for rec in report.recommendations
95
+ ],
96
+ has_blocker=report.has_blocker,
97
+ analyzed_at=report.analyzed_at.isoformat(),
98
+ )
99
+
100
+
101
+ # ============================================================================
102
+ # Endpoints
103
+ # ============================================================================
104
+
105
+
106
+ @router.post("/{task_id}/diagnose", response_model=DiagnosticReportResponse)
107
+ @rate_limit_ai()
108
+ async def diagnose_task(
109
+ request: Request,
110
+ task_id: str,
111
+ body: DiagnoseRequest = None,
112
+ workspace: Workspace = Depends(get_v2_workspace),
113
+ ) -> DiagnosticReportResponse:
114
+ """Diagnose a failed task and generate recommendations.
115
+
116
+ Analyzes run logs to identify the root cause of failure and
117
+ provides actionable recommendations to fix the issue.
118
+
119
+ Args:
120
+ task_id: Task ID to diagnose
121
+ request: Diagnosis options
122
+ workspace: v2 Workspace
123
+
124
+ Returns:
125
+ Diagnostic report with analysis and recommendations
126
+
127
+ Raises:
128
+ HTTPException:
129
+ - 404: Task not found or no failed run
130
+ - 400: Task has no failed runs
131
+ """
132
+ force = body.force if body else False
133
+
134
+ try:
135
+ # Find task
136
+ task = tasks.get(workspace, task_id)
137
+ if not task:
138
+ raise HTTPException(
139
+ status_code=404,
140
+ detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"),
141
+ )
142
+
143
+ # Find the most recent failed run
144
+ runs = runtime.list_runs(workspace, task_id=task.id)
145
+ failed_runs = [r for r in runs if r.status == runtime.RunStatus.FAILED]
146
+
147
+ if not failed_runs:
148
+ raise HTTPException(
149
+ status_code=400,
150
+ detail=api_error(
151
+ "No failed runs",
152
+ ErrorCodes.INVALID_STATE,
153
+ f"Task '{task.title}' has no failed runs to diagnose",
154
+ ),
155
+ )
156
+
157
+ latest_run = failed_runs[0] # Most recent failed run
158
+
159
+ # Check for existing report
160
+ existing_report = get_latest_diagnostic_report(workspace, run_id=latest_run.id)
161
+
162
+ if existing_report and not force:
163
+ return _report_to_response(existing_report)
164
+
165
+ # Run diagnostic analysis
166
+ agent = DiagnosticAgent(workspace)
167
+ report = agent.analyze(task.id, latest_run.id)
168
+
169
+ return _report_to_response(report)
170
+
171
+ except HTTPException:
172
+ raise
173
+ except Exception as e:
174
+ logger.error(f"Failed to diagnose task {task_id}: {e}", exc_info=True)
175
+ raise HTTPException(
176
+ status_code=500,
177
+ detail=api_error("Diagnosis failed", ErrorCodes.EXECUTION_FAILED, str(e)),
178
+ )
179
+
180
+
181
+ @router.get("/{task_id}/diagnose", response_model=DiagnosticReportResponse)
182
+ @rate_limit_standard()
183
+ async def get_diagnostic_report(
184
+ request: Request,
185
+ task_id: str,
186
+ workspace: Workspace = Depends(get_v2_workspace),
187
+ ) -> DiagnosticReportResponse:
188
+ """Get the latest diagnostic report for a task.
189
+
190
+ Returns the most recent diagnostic report without running new analysis.
191
+ Use POST to run a new analysis.
192
+
193
+ Args:
194
+ task_id: Task ID to get report for
195
+ workspace: v2 Workspace
196
+
197
+ Returns:
198
+ Latest diagnostic report
199
+
200
+ Raises:
201
+ HTTPException:
202
+ - 404: Task not found or no diagnostic report exists
203
+ """
204
+ # Find task
205
+ task = tasks.get(workspace, task_id)
206
+ if not task:
207
+ raise HTTPException(
208
+ status_code=404,
209
+ detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"),
210
+ )
211
+
212
+ # Get latest report for this task
213
+ report = get_latest_diagnostic_report(workspace, task_id=task.id)
214
+
215
+ if not report:
216
+ raise HTTPException(
217
+ status_code=404,
218
+ detail=api_error(
219
+ "No diagnostic report",
220
+ ErrorCodes.NOT_FOUND,
221
+ f"No diagnostic report exists for task '{task.title}'. Run POST /diagnose first.",
222
+ ),
223
+ )
224
+
225
+ return _report_to_response(report)