codeframe-ai 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
codeframe/core/replay.py
ADDED
|
@@ -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
|
+
)
|