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,788 @@
1
+ """Execution trace recording and replay for CodeFRAME.
2
+
3
+ Provides data models and CRUD operations for capturing complete
4
+ execution traces (steps, LLM interactions, file operations) and
5
+ replaying them for debugging and learning.
6
+
7
+ This module is headless - no FastAPI or HTTP dependencies.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, timezone
15
+ from typing import Any, Optional
16
+
17
+ from codeframe.core.workspace import Workspace, get_db_connection
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _utc_now() -> datetime:
23
+ return datetime.now(timezone.utc)
24
+
25
+
26
+ # =============================================================================
27
+ # Data Models
28
+ # =============================================================================
29
+
30
+
31
+ @dataclass
32
+ class ExecutionStep:
33
+ """A single step in an execution trace.
34
+
35
+ Each iteration of the ReactAgent loop or verification gate
36
+ is recorded as one step.
37
+ """
38
+
39
+ id: str
40
+ run_id: str
41
+ step_number: int
42
+ step_type: str # "tool_call", "verification", "planning", "gate"
43
+ description: str
44
+ started_at: datetime
45
+ completed_at: Optional[datetime] = None
46
+ status: str = "started" # "started", "completed", "failed"
47
+ input_context: Optional[str] = None
48
+ output_result: Optional[str] = None
49
+ metadata: dict[str, Any] = field(default_factory=dict)
50
+
51
+
52
+ @dataclass
53
+ class LLMInteraction:
54
+ """A single LLM prompt/response pair."""
55
+
56
+ id: str
57
+ run_id: str
58
+ step_id: str
59
+ prompt: str
60
+ response: str
61
+ model: str
62
+ tokens_used: int
63
+ timestamp: datetime
64
+ purpose: str # "execution", "planning", "review", "verification"
65
+
66
+
67
+ @dataclass
68
+ class FileOperation:
69
+ """A file create/edit/delete recorded during execution."""
70
+
71
+ id: str
72
+ run_id: str
73
+ step_id: str
74
+ operation_type: str # "create", "edit", "delete"
75
+ file_path: str
76
+ content_before: Optional[str]
77
+ content_after: Optional[str]
78
+ timestamp: datetime
79
+
80
+
81
+ @dataclass
82
+ class ExecutionTrace:
83
+ """Complete trace of a single run, assembled from the three tables."""
84
+
85
+ run_id: str
86
+ task_id: str
87
+ started_at: datetime
88
+ status: str
89
+ steps: list[ExecutionStep]
90
+ llm_interactions: list[LLMInteraction]
91
+ file_operations: list[FileOperation]
92
+ completed_at: Optional[datetime] = None
93
+
94
+ def summary(self) -> dict[str, Any]:
95
+ unique_files = {op.file_path for op in self.file_operations}
96
+ return {
97
+ "total_steps": len(self.steps),
98
+ "llm_calls": len(self.llm_interactions),
99
+ "total_tokens": sum(i.tokens_used for i in self.llm_interactions),
100
+ "files_modified": len(unique_files),
101
+ }
102
+
103
+
104
+ # =============================================================================
105
+ # ExecutionRecorder — buffered recording for ReactAgent integration
106
+ # =============================================================================
107
+
108
+
109
+ class ExecutionRecorder:
110
+ """Buffered execution trace recorder for ReactAgent.
111
+
112
+ Collects execution steps, LLM interactions, and file operations
113
+ in memory and flushes them to the database periodically or on demand.
114
+
115
+ Args:
116
+ workspace: Target workspace (for DB access).
117
+ run_id: Run identifier to associate all records with.
118
+ flush_interval: Number of records to buffer before auto-flushing.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ workspace: Workspace,
124
+ run_id: str,
125
+ flush_interval: int = 10,
126
+ ) -> None:
127
+ self.workspace = workspace
128
+ self.run_id = run_id
129
+ self._flush_interval = flush_interval
130
+ self._step_buffer: list[ExecutionStep] = []
131
+ self._llm_buffer: list[LLMInteraction] = []
132
+ self._file_op_buffer: list[FileOperation] = []
133
+
134
+ def record_iteration(
135
+ self,
136
+ step_number: int,
137
+ tool_names: list[str],
138
+ llm_response_summary: str,
139
+ ) -> str:
140
+ """Record one iteration of the react loop as an ExecutionStep.
141
+
142
+ Args:
143
+ step_number: 1-based iteration number.
144
+ tool_names: Names of tools called in this iteration.
145
+ llm_response_summary: Short summary of the LLM response.
146
+
147
+ Returns:
148
+ The generated step ID.
149
+ """
150
+ step_id = str(uuid.uuid4())
151
+ now = _utc_now()
152
+ description = (
153
+ f"Tools: {', '.join(tool_names)}" if tool_names else llm_response_summary
154
+ )
155
+ step = ExecutionStep(
156
+ id=step_id,
157
+ run_id=self.run_id,
158
+ step_number=step_number,
159
+ step_type="tool_call",
160
+ description=description,
161
+ started_at=now,
162
+ completed_at=now,
163
+ status="completed",
164
+ output_result=llm_response_summary[:500] if llm_response_summary else None,
165
+ metadata={"tool_names": tool_names},
166
+ )
167
+ self._step_buffer.append(step)
168
+ self._maybe_flush()
169
+ return step_id
170
+
171
+ def record_llm_call(
172
+ self,
173
+ step_id: str,
174
+ prompt_summary: str,
175
+ response_summary: str,
176
+ model: str,
177
+ tokens_used: int,
178
+ purpose: str,
179
+ ) -> str:
180
+ """Record a single LLM prompt/response pair.
181
+
182
+ Args:
183
+ step_id: ID of the parent execution step.
184
+ prompt_summary: Condensed prompt (system summary + last user message).
185
+ response_summary: Content or tool calls summary.
186
+ model: Model identifier.
187
+ tokens_used: Total tokens consumed.
188
+ purpose: Purpose label (execution, planning, etc.).
189
+
190
+ Returns:
191
+ The generated interaction ID.
192
+ """
193
+ interaction_id = str(uuid.uuid4())
194
+ interaction = LLMInteraction(
195
+ id=interaction_id,
196
+ run_id=self.run_id,
197
+ step_id=step_id,
198
+ prompt=prompt_summary[:2000] if prompt_summary else "",
199
+ response=response_summary[:2000] if response_summary else "",
200
+ model=model or "",
201
+ tokens_used=tokens_used,
202
+ timestamp=_utc_now(),
203
+ purpose=purpose,
204
+ )
205
+ self._llm_buffer.append(interaction)
206
+ self._maybe_flush()
207
+ return interaction_id
208
+
209
+ def record_file_operation(
210
+ self,
211
+ step_id: str,
212
+ op_type: str,
213
+ path: str,
214
+ before: Optional[str],
215
+ after: Optional[str],
216
+ ) -> str:
217
+ """Record a file create/edit/delete operation.
218
+
219
+ Args:
220
+ step_id: ID of the parent execution step.
221
+ op_type: Operation type (create, edit, delete).
222
+ path: Relative file path.
223
+ before: Content before the operation (None for create).
224
+ after: Content after the operation (None for delete).
225
+
226
+ Returns:
227
+ The generated file operation ID.
228
+ """
229
+ op_id = str(uuid.uuid4())
230
+ op = FileOperation(
231
+ id=op_id,
232
+ run_id=self.run_id,
233
+ step_id=step_id,
234
+ operation_type=op_type,
235
+ file_path=path,
236
+ content_before=before,
237
+ content_after=after,
238
+ timestamp=_utc_now(),
239
+ )
240
+ self._file_op_buffer.append(op)
241
+ self._maybe_flush()
242
+ return op_id
243
+
244
+ def flush(self) -> None:
245
+ """Write all buffered records to the database."""
246
+ try:
247
+ for step in self._step_buffer:
248
+ save_execution_step(self.workspace, step)
249
+ for interaction in self._llm_buffer:
250
+ save_llm_interaction(self.workspace, interaction)
251
+ for op in self._file_op_buffer:
252
+ save_file_operation(self.workspace, op)
253
+ # Only clear on success — retain data for retry on failure
254
+ self._step_buffer.clear()
255
+ self._llm_buffer.clear()
256
+ self._file_op_buffer.clear()
257
+ except Exception:
258
+ logger.warning("ExecutionRecorder flush failed — data retained for retry", exc_info=True)
259
+
260
+ def _maybe_flush(self) -> None:
261
+ """Auto-flush when buffer reaches threshold."""
262
+ total = len(self._step_buffer) + len(self._llm_buffer) + len(self._file_op_buffer)
263
+ if total >= self._flush_interval:
264
+ self.flush()
265
+
266
+
267
+ # =============================================================================
268
+ # CRUD: ExecutionStep
269
+ # =============================================================================
270
+
271
+
272
+ def save_execution_step(workspace: Workspace, step: ExecutionStep) -> None:
273
+ conn = get_db_connection(workspace)
274
+ try:
275
+ cursor = conn.cursor()
276
+ cursor.execute(
277
+ """
278
+ INSERT OR REPLACE INTO execution_steps
279
+ (id, run_id, step_number, step_type, description, started_at,
280
+ completed_at, status, input_context, output_result, metadata)
281
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
282
+ """,
283
+ (
284
+ step.id,
285
+ step.run_id,
286
+ step.step_number,
287
+ step.step_type,
288
+ step.description,
289
+ step.started_at.isoformat(),
290
+ step.completed_at.isoformat() if step.completed_at else None,
291
+ step.status,
292
+ step.input_context,
293
+ step.output_result,
294
+ json.dumps(step.metadata) if step.metadata else None,
295
+ ),
296
+ )
297
+ conn.commit()
298
+ finally:
299
+ conn.close()
300
+
301
+
302
+ def get_execution_steps(
303
+ workspace: Workspace, run_id: str
304
+ ) -> list[ExecutionStep]:
305
+ conn = get_db_connection(workspace)
306
+ try:
307
+ cursor = conn.cursor()
308
+ cursor.execute(
309
+ """
310
+ SELECT id, run_id, step_number, step_type, description, started_at,
311
+ completed_at, status, input_context, output_result, metadata
312
+ FROM execution_steps
313
+ WHERE run_id = ?
314
+ ORDER BY step_number ASC
315
+ """,
316
+ (run_id,),
317
+ )
318
+ return [_row_to_step(row) for row in cursor.fetchall()]
319
+ finally:
320
+ conn.close()
321
+
322
+
323
+ # =============================================================================
324
+ # CRUD: LLMInteraction
325
+ # =============================================================================
326
+
327
+
328
+ def save_llm_interaction(workspace: Workspace, interaction: LLMInteraction) -> None:
329
+ conn = get_db_connection(workspace)
330
+ try:
331
+ cursor = conn.cursor()
332
+ cursor.execute(
333
+ """
334
+ INSERT OR REPLACE INTO llm_interactions
335
+ (id, run_id, step_id, prompt, response, model, tokens_used,
336
+ timestamp, purpose)
337
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
338
+ """,
339
+ (
340
+ interaction.id,
341
+ interaction.run_id,
342
+ interaction.step_id,
343
+ interaction.prompt,
344
+ interaction.response,
345
+ interaction.model,
346
+ interaction.tokens_used,
347
+ interaction.timestamp.isoformat(),
348
+ interaction.purpose,
349
+ ),
350
+ )
351
+ conn.commit()
352
+ finally:
353
+ conn.close()
354
+
355
+
356
+ def get_llm_interactions(
357
+ workspace: Workspace, run_id: str, step_id: Optional[str] = None
358
+ ) -> list[LLMInteraction]:
359
+ conn = get_db_connection(workspace)
360
+ try:
361
+ cursor = conn.cursor()
362
+ query = """
363
+ SELECT id, run_id, step_id, prompt, response, model, tokens_used,
364
+ timestamp, purpose
365
+ FROM llm_interactions
366
+ WHERE run_id = ?
367
+ """
368
+ params: list = [run_id]
369
+ if step_id:
370
+ query += " AND step_id = ?"
371
+ params.append(step_id)
372
+ query += " ORDER BY timestamp ASC"
373
+ cursor.execute(query, params)
374
+ return [_row_to_llm_interaction(row) for row in cursor.fetchall()]
375
+ finally:
376
+ conn.close()
377
+
378
+
379
+ # =============================================================================
380
+ # CRUD: FileOperation
381
+ # =============================================================================
382
+
383
+
384
+ def save_file_operation(workspace: Workspace, op: FileOperation) -> None:
385
+ conn = get_db_connection(workspace)
386
+ try:
387
+ cursor = conn.cursor()
388
+ cursor.execute(
389
+ """
390
+ INSERT OR REPLACE INTO file_operations
391
+ (id, run_id, step_id, operation_type, file_path,
392
+ content_before, content_after, timestamp)
393
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
394
+ """,
395
+ (
396
+ op.id,
397
+ op.run_id,
398
+ op.step_id,
399
+ op.operation_type,
400
+ op.file_path,
401
+ op.content_before,
402
+ op.content_after,
403
+ op.timestamp.isoformat(),
404
+ ),
405
+ )
406
+ conn.commit()
407
+ finally:
408
+ conn.close()
409
+
410
+
411
+ def get_file_operations(
412
+ workspace: Workspace, run_id: str, step_id: Optional[str] = None
413
+ ) -> list[FileOperation]:
414
+ conn = get_db_connection(workspace)
415
+ try:
416
+ cursor = conn.cursor()
417
+ query = """
418
+ SELECT id, run_id, step_id, operation_type, file_path,
419
+ content_before, content_after, timestamp
420
+ FROM file_operations
421
+ WHERE run_id = ?
422
+ """
423
+ params: list = [run_id]
424
+ if step_id:
425
+ query += " AND step_id = ?"
426
+ params.append(step_id)
427
+ query += " ORDER BY timestamp ASC"
428
+ cursor.execute(query, params)
429
+ return [_row_to_file_operation(row) for row in cursor.fetchall()]
430
+ finally:
431
+ conn.close()
432
+
433
+
434
+ # =============================================================================
435
+ # Trace Loading
436
+ # =============================================================================
437
+
438
+
439
+ def load_execution_trace(workspace: Workspace, run_id: str) -> Optional[ExecutionTrace]:
440
+ """Load a complete execution trace for a run.
441
+
442
+ Assembles steps, LLM interactions, and file operations into
443
+ a single ExecutionTrace object.
444
+
445
+ Returns None if no steps are found for the run.
446
+ """
447
+ steps = get_execution_steps(workspace, run_id)
448
+ if not steps:
449
+ return None
450
+
451
+ llm_interactions = get_llm_interactions(workspace, run_id)
452
+ file_operations = get_file_operations(workspace, run_id)
453
+
454
+ # Get run metadata from the runs table
455
+ conn = get_db_connection(workspace)
456
+ try:
457
+ cursor = conn.cursor()
458
+ cursor.execute(
459
+ "SELECT task_id, status, started_at, completed_at FROM runs WHERE id = ?",
460
+ (run_id,),
461
+ )
462
+ row = cursor.fetchone()
463
+ if not row:
464
+ # Build trace from steps alone (run record may not exist in tests)
465
+ return ExecutionTrace(
466
+ run_id=run_id,
467
+ task_id="unknown",
468
+ started_at=steps[0].started_at,
469
+ status="UNKNOWN",
470
+ steps=steps,
471
+ llm_interactions=llm_interactions,
472
+ file_operations=file_operations,
473
+ )
474
+
475
+ return ExecutionTrace(
476
+ run_id=run_id,
477
+ task_id=row[0],
478
+ started_at=datetime.fromisoformat(row[2]),
479
+ status=row[1],
480
+ steps=steps,
481
+ llm_interactions=llm_interactions,
482
+ file_operations=file_operations,
483
+ completed_at=datetime.fromisoformat(row[3]) if row[3] else None,
484
+ )
485
+ finally:
486
+ conn.close()
487
+
488
+
489
+ def get_step_snapshot(
490
+ workspace: Workspace, run_id: str, step_number: int
491
+ ) -> dict[str, Any]:
492
+ """Reconstruct the file state at a given step.
493
+
494
+ Replays file operations from step 1 through step_number to
495
+ build a dict mapping file_path -> content at that point.
496
+ """
497
+ steps = get_execution_steps(workspace, run_id)
498
+ step_ids = {s.id for s in steps if s.step_number <= step_number}
499
+
500
+ ops = get_file_operations(workspace, run_id)
501
+ relevant_ops = [op for op in ops if op.step_id in step_ids]
502
+
503
+ file_state: dict[str, Optional[str]] = {}
504
+ for op in relevant_ops:
505
+ if op.operation_type == "delete":
506
+ file_state[op.file_path] = None
507
+ else:
508
+ file_state[op.file_path] = op.content_after
509
+
510
+ # Remove deleted files
511
+ return {k: v for k, v in file_state.items() if v is not None}
512
+
513
+
514
+ def compare_steps(
515
+ workspace: Workspace, run_id: str, step_a: int, step_b: int
516
+ ) -> dict[str, dict[str, Optional[str]]]:
517
+ """Compare file state between two steps.
518
+
519
+ Returns a dict of changed files: {file_path: {"before": content_a, "after": content_b}}
520
+ """
521
+ state_a = get_step_snapshot(workspace, run_id, step_a)
522
+ state_b = get_step_snapshot(workspace, run_id, step_b)
523
+
524
+ all_files = set(state_a.keys()) | set(state_b.keys())
525
+ changes = {}
526
+ for f in sorted(all_files):
527
+ before = state_a.get(f)
528
+ after = state_b.get(f)
529
+ if before != after:
530
+ changes[f] = {"before": before, "after": after}
531
+ return changes
532
+
533
+
534
+ # =============================================================================
535
+ # Export Functions
536
+ # =============================================================================
537
+
538
+
539
+ def export_trace_json(trace: ExecutionTrace) -> dict[str, Any]:
540
+ """Export an ExecutionTrace as a JSON-serializable dict.
541
+
542
+ Returns a dict with run metadata, step details, and summary stats.
543
+ """
544
+ # Build a lookup of file operations by step_id
545
+ ops_by_step: dict[str, list[FileOperation]] = {}
546
+ for op in trace.file_operations:
547
+ ops_by_step.setdefault(op.step_id, []).append(op)
548
+
549
+ steps = []
550
+ for step in trace.steps:
551
+ step_ops = ops_by_step.get(step.id, [])
552
+ step_dict: dict[str, Any] = {
553
+ "step_number": step.step_number,
554
+ "step_type": step.step_type,
555
+ "description": step.description,
556
+ "status": step.status,
557
+ "started_at": step.started_at.isoformat(),
558
+ "completed_at": step.completed_at.isoformat() if step.completed_at else None,
559
+ }
560
+ if step_ops:
561
+ step_dict["file_changes"] = [
562
+ {
563
+ "operation": op.operation_type,
564
+ "file_path": op.file_path,
565
+ }
566
+ for op in step_ops
567
+ ]
568
+ steps.append(step_dict)
569
+
570
+ return {
571
+ "run_id": trace.run_id,
572
+ "task_id": trace.task_id,
573
+ "started_at": trace.started_at.isoformat(),
574
+ "completed_at": trace.completed_at.isoformat() if trace.completed_at else None,
575
+ "status": trace.status,
576
+ "steps": steps,
577
+ "summary": trace.summary(),
578
+ }
579
+
580
+
581
+ def export_trace_markdown(trace: ExecutionTrace) -> str:
582
+ """Export an ExecutionTrace as a Markdown report.
583
+
584
+ Produces a human-readable report with header, summary stats,
585
+ and a step-by-step timeline including file changes.
586
+ """
587
+ summary = trace.summary()
588
+
589
+ # Build file operations lookup by step_id
590
+ ops_by_step: dict[str, list[FileOperation]] = {}
591
+ for op in trace.file_operations:
592
+ ops_by_step.setdefault(op.step_id, []).append(op)
593
+
594
+ lines = [
595
+ f"# Execution Trace: {trace.run_id}",
596
+ "",
597
+ f"- **Task**: {trace.task_id}",
598
+ f"- **Status**: {trace.status}",
599
+ f"- **Started**: {trace.started_at.isoformat()}",
600
+ ]
601
+ if trace.completed_at:
602
+ lines.append(f"- **Completed**: {trace.completed_at.isoformat()}")
603
+ lines.append("")
604
+
605
+ lines.append("## Summary")
606
+ lines.append("")
607
+ lines.append("| Metric | Value |")
608
+ lines.append("|--------|-------|")
609
+ lines.append(f"| Total steps | {summary['total_steps']} |")
610
+ lines.append(f"| LLM calls | {summary['llm_calls']} |")
611
+ lines.append(f"| Total tokens | {summary['total_tokens']} |")
612
+ lines.append(f"| Files modified | {summary['files_modified']} |")
613
+ lines.append("")
614
+
615
+ lines.append("## Steps")
616
+ lines.append("")
617
+ for step in trace.steps:
618
+ status_icon = {"completed": "[OK]", "failed": "[FAIL]", "started": "[...]"}.get(
619
+ step.status, f"[{step.status}]"
620
+ )
621
+ lines.append(f"### Step {step.step_number}: {step.description}")
622
+ lines.append(f"- **Type**: {step.step_type}")
623
+ lines.append(f"- **Status**: {status_icon} {step.status}")
624
+
625
+ step_ops = ops_by_step.get(step.id, [])
626
+ if step_ops:
627
+ lines.append("- **File changes**:")
628
+ for op in step_ops:
629
+ lines.append(f" - {op.operation_type}: `{op.file_path}`")
630
+ lines.append("")
631
+
632
+ return "\n".join(lines)
633
+
634
+
635
+ # =============================================================================
636
+ # Interactive Replay Session
637
+ # =============================================================================
638
+
639
+
640
+ class ReplaySession:
641
+ """Manages interactive step-through of an execution trace.
642
+
643
+ Tracks the current position and provides navigation methods.
644
+ Display is delegated to the caller (CLI layer).
645
+ """
646
+
647
+ def __init__(self, trace: ExecutionTrace) -> None:
648
+ self.trace = trace
649
+ self._current_index = 0
650
+
651
+ # Build lookups
652
+ self.ops_by_step: dict[str, list[FileOperation]] = {}
653
+ for op in trace.file_operations:
654
+ self.ops_by_step.setdefault(op.step_id, []).append(op)
655
+
656
+ self.llm_by_step: dict[str, list[LLMInteraction]] = {}
657
+ for llm in trace.llm_interactions:
658
+ self.llm_by_step.setdefault(llm.step_id, []).append(llm)
659
+
660
+ @property
661
+ def current_step(self) -> Optional[ExecutionStep]:
662
+ if 0 <= self._current_index < len(self.trace.steps):
663
+ return self.trace.steps[self._current_index]
664
+ return None
665
+
666
+ @property
667
+ def current_position(self) -> int:
668
+ return self._current_index + 1
669
+
670
+ @property
671
+ def total_steps(self) -> int:
672
+ return len(self.trace.steps)
673
+
674
+ def next(self) -> Optional[ExecutionStep]:
675
+ if self._current_index < len(self.trace.steps) - 1:
676
+ self._current_index += 1
677
+ return self.current_step
678
+
679
+ def previous(self) -> Optional[ExecutionStep]:
680
+ if self._current_index > 0:
681
+ self._current_index -= 1
682
+ return self.current_step
683
+
684
+ def jump(self, step_number: int) -> Optional[ExecutionStep]:
685
+ for i, step in enumerate(self.trace.steps):
686
+ if step.step_number == step_number:
687
+ self._current_index = i
688
+ return step
689
+ return None
690
+
691
+ def get_step_file_ops(self, step: ExecutionStep) -> list[FileOperation]:
692
+ return self.ops_by_step.get(step.id, [])
693
+
694
+ def get_step_llm_calls(self, step: ExecutionStep) -> list[LLMInteraction]:
695
+ return self.llm_by_step.get(step.id, [])
696
+
697
+ def list_steps(self) -> list[ExecutionStep]:
698
+ return list(self.trace.steps)
699
+
700
+
701
+ # =============================================================================
702
+ # Re-run from Step
703
+ # =============================================================================
704
+
705
+
706
+ def prepare_rerun(
707
+ workspace: Workspace,
708
+ run_id: str,
709
+ from_step: int,
710
+ ) -> dict[str, Any]:
711
+ """Prepare state for re-executing from a specific step.
712
+
713
+ Reconstructs the file state at the given step and returns
714
+ metadata needed to create a new run starting from that point.
715
+
716
+ Returns a dict with:
717
+ - file_state: dict of file_path -> content at step N
718
+ - original_run_id: the source run
719
+ - from_step: the step number to resume from
720
+ - remaining_steps: descriptions of steps that follow
721
+ """
722
+ trace = load_execution_trace(workspace, run_id)
723
+ if not trace:
724
+ raise ValueError(f"No trace found for run '{run_id}'")
725
+
726
+ file_state = get_step_snapshot(workspace, run_id, from_step)
727
+
728
+ remaining_steps = [
729
+ {"step_number": s.step_number, "description": s.description}
730
+ for s in trace.steps
731
+ if s.step_number > from_step
732
+ ]
733
+
734
+ return {
735
+ "file_state": file_state,
736
+ "original_run_id": run_id,
737
+ "from_step": from_step,
738
+ "remaining_steps": remaining_steps,
739
+ "task_id": trace.task_id,
740
+ }
741
+
742
+
743
+ # =============================================================================
744
+ # Row Converters
745
+ # =============================================================================
746
+
747
+
748
+ def _row_to_step(row: tuple) -> ExecutionStep:
749
+ return ExecutionStep(
750
+ id=row[0],
751
+ run_id=row[1],
752
+ step_number=row[2],
753
+ step_type=row[3],
754
+ description=row[4],
755
+ started_at=datetime.fromisoformat(row[5]),
756
+ completed_at=datetime.fromisoformat(row[6]) if row[6] else None,
757
+ status=row[7],
758
+ input_context=row[8],
759
+ output_result=row[9],
760
+ metadata=json.loads(row[10]) if row[10] else {},
761
+ )
762
+
763
+
764
+ def _row_to_llm_interaction(row: tuple) -> LLMInteraction:
765
+ return LLMInteraction(
766
+ id=row[0],
767
+ run_id=row[1],
768
+ step_id=row[2],
769
+ prompt=row[3],
770
+ response=row[4],
771
+ model=row[5],
772
+ tokens_used=row[6],
773
+ timestamp=datetime.fromisoformat(row[7]),
774
+ purpose=row[8],
775
+ )
776
+
777
+
778
+ def _row_to_file_operation(row: tuple) -> FileOperation:
779
+ return FileOperation(
780
+ id=row[0],
781
+ run_id=row[1],
782
+ step_id=row[2],
783
+ operation_type=row[3],
784
+ file_path=row[4],
785
+ content_before=row[5],
786
+ content_after=row[6],
787
+ timestamp=datetime.fromisoformat(row[7]),
788
+ )