cortexshift 0.1.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.
- cortexshift/__init__.py +10 -0
- cortexshift/__main__.py +6 -0
- cortexshift/adapters/__init__.py +22 -0
- cortexshift/adapters/command_runner.py +116 -0
- cortexshift/adapters/discovery.py +55 -0
- cortexshift/adapters/git/__init__.py +10 -0
- cortexshift/adapters/git/inspector.py +321 -0
- cortexshift/adapters/git/parser.py +140 -0
- cortexshift/adapters/headless_runner.py +92 -0
- cortexshift/adapters/process_runner.py +56 -0
- cortexshift/adapters/providers/__init__.py +4 -0
- cortexshift/adapters/providers/antigravity.py +530 -0
- cortexshift/adapters/providers/claude.py +375 -0
- cortexshift/adapters/providers/codex.py +434 -0
- cortexshift/adapters/sqlite/__init__.py +10 -0
- cortexshift/adapters/sqlite/migrations.py +268 -0
- cortexshift/adapters/sqlite/store.py +914 -0
- cortexshift/adapters/workspace_lease.py +123 -0
- cortexshift/application/__init__.py +42 -0
- cortexshift/application/checkpoint_builder.py +218 -0
- cortexshift/application/checkpoint_service.py +273 -0
- cortexshift/application/doctor.py +80 -0
- cortexshift/application/handoff_builder.py +281 -0
- cortexshift/application/handoff_renderer.py +430 -0
- cortexshift/application/handoff_service.py +66 -0
- cortexshift/application/init_service.py +86 -0
- cortexshift/application/locator.py +48 -0
- cortexshift/application/native_session.py +65 -0
- cortexshift/application/recovery_service.py +235 -0
- cortexshift/application/repository_service.py +146 -0
- cortexshift/application/resume_service.py +124 -0
- cortexshift/application/run_service.py +270 -0
- cortexshift/application/session_launcher.py +183 -0
- cortexshift/application/session_service.py +63 -0
- cortexshift/application/source_session.py +62 -0
- cortexshift/application/status_service.py +73 -0
- cortexshift/application/switch_service.py +671 -0
- cortexshift/application/task_service.py +201 -0
- cortexshift/application/task_workspace.py +152 -0
- cortexshift/cli/__init__.py +5 -0
- cortexshift/cli/app.py +2477 -0
- cortexshift/domain/__init__.py +153 -0
- cortexshift/domain/checkpoint.py +174 -0
- cortexshift/domain/doctor.py +68 -0
- cortexshift/domain/errors.py +277 -0
- cortexshift/domain/git.py +102 -0
- cortexshift/domain/handoff.py +241 -0
- cortexshift/domain/identifiers.py +27 -0
- cortexshift/domain/launch.py +58 -0
- cortexshift/domain/mcp_binding.py +81 -0
- cortexshift/domain/native_session.py +19 -0
- cortexshift/domain/project.py +37 -0
- cortexshift/domain/provider.py +67 -0
- cortexshift/domain/session.py +92 -0
- cortexshift/domain/status.py +40 -0
- cortexshift/domain/task.py +191 -0
- cortexshift/mcp/__init__.py +38 -0
- cortexshift/mcp/context.py +165 -0
- cortexshift/mcp/facade.py +513 -0
- cortexshift/mcp/models.py +178 -0
- cortexshift/mcp/resources.py +45 -0
- cortexshift/mcp/server.py +52 -0
- cortexshift/mcp/tools.py +176 -0
- cortexshift/ports/__init__.py +39 -0
- cortexshift/ports/checkpoint_store.py +45 -0
- cortexshift/ports/command_runner.py +56 -0
- cortexshift/ports/discovery.py +41 -0
- cortexshift/ports/handoff_delivery.py +91 -0
- cortexshift/ports/handoff_store.py +43 -0
- cortexshift/ports/headless_runner.py +58 -0
- cortexshift/ports/native_session.py +20 -0
- cortexshift/ports/process_runner.py +31 -0
- cortexshift/ports/provider.py +152 -0
- cortexshift/ports/repository.py +44 -0
- cortexshift/ports/session_store.py +27 -0
- cortexshift/ports/state_store.py +55 -0
- cortexshift/ports/workspace_lease.py +39 -0
- cortexshift/tui/__init__.py +24 -0
- cortexshift/tui/actions.py +58 -0
- cortexshift/tui/app.py +1051 -0
- cortexshift/tui/coordinator.py +173 -0
- cortexshift/tui/cortexshift.tcss +258 -0
- cortexshift/tui/facade.py +614 -0
- cortexshift/tui/modals.py +594 -0
- cortexshift/tui/models.py +503 -0
- cortexshift/tui/screens/__init__.py +81 -0
- cortexshift/tui/screens/checkpoints.py +188 -0
- cortexshift/tui/screens/handoffs.py +180 -0
- cortexshift/tui/screens/help.py +117 -0
- cortexshift/tui/screens/overview.py +200 -0
- cortexshift/tui/screens/providers.py +169 -0
- cortexshift/tui/screens/repository.py +143 -0
- cortexshift/tui/screens/sessions.py +146 -0
- cortexshift/tui/screens/task.py +174 -0
- cortexshift/tui/widgets.py +209 -0
- cortexshift-0.1.0.dist-info/METADATA +202 -0
- cortexshift-0.1.0.dist-info/RECORD +100 -0
- cortexshift-0.1.0.dist-info/WHEEL +4 -0
- cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
- cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
"""SQLite implementation of the StateStore persistence port.
|
|
2
|
+
|
|
3
|
+
**Ordering contract.** Every listing that means "newest first" orders by its timestamp
|
|
4
|
+
descending and breaks ties on `rowid` descending: *a newer timestamp wins, and when two
|
|
5
|
+
records carry the same timestamp the later-persisted one wins.* Ascending listings state
|
|
6
|
+
the same rule in the other direction.
|
|
7
|
+
|
|
8
|
+
The tie-break is not cosmetic. `list_sessions` decides which session a handoff is built
|
|
9
|
+
from, which native conversation `resume` reattaches to, and which session a recovery
|
|
10
|
+
checkpoint is bound to; `list_checkpoints` and `list_handoffs` feed the "latest" lookups
|
|
11
|
+
those paths read. Timestamps come from `datetime.now(UTC)`, whose resolution is coarse
|
|
12
|
+
enough on some platforms -- roughly 16 ms on Windows -- that two records written in one
|
|
13
|
+
burst genuinely share an instant. Without a tie-break SQLite is free to return either
|
|
14
|
+
first, so CortexShift could hand off from the wrong session or bind a checkpoint to one.
|
|
15
|
+
|
|
16
|
+
`rowid` is the right tie-break here: every table in this schema is an ordinary rowid
|
|
17
|
+
table, saves are upserts that keep a row's original `rowid`, so it records the order in
|
|
18
|
+
which records were first persisted -- the chronology the timestamps were reaching for.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sqlite3
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from cortexshift.adapters.sqlite.migrations import get_current_schema_version, run_migrations
|
|
28
|
+
from cortexshift.domain.checkpoint import (
|
|
29
|
+
CheckpointKind,
|
|
30
|
+
CheckpointPayload,
|
|
31
|
+
CheckpointRecord,
|
|
32
|
+
)
|
|
33
|
+
from cortexshift.domain.errors import (
|
|
34
|
+
DatabaseStateError,
|
|
35
|
+
StateCorruptionError,
|
|
36
|
+
UnsupportedSchemaVersionError,
|
|
37
|
+
)
|
|
38
|
+
from cortexshift.domain.git import GitSnapshot
|
|
39
|
+
from cortexshift.domain.handoff import (
|
|
40
|
+
HandoffFailureCode,
|
|
41
|
+
HandoffPayload,
|
|
42
|
+
HandoffRecord,
|
|
43
|
+
HandoffStatus,
|
|
44
|
+
)
|
|
45
|
+
from cortexshift.domain.project import Project
|
|
46
|
+
from cortexshift.domain.provider import ProviderId
|
|
47
|
+
from cortexshift.domain.session import Session, SessionExitReason, SessionStatus
|
|
48
|
+
from cortexshift.domain.task import Task, TaskStatus
|
|
49
|
+
from cortexshift.ports.checkpoint_store import CheckpointStore
|
|
50
|
+
from cortexshift.ports.handoff_store import HandoffStore
|
|
51
|
+
from cortexshift.ports.repository import RepositorySnapshotStore
|
|
52
|
+
from cortexshift.ports.session_store import SessionStore
|
|
53
|
+
from cortexshift.ports.state_store import StateStore
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_utc_datetime(iso_str: str) -> datetime:
|
|
57
|
+
"""Parse an ISO 8601 string and ensure it has timezone-aware UTC tzinfo."""
|
|
58
|
+
dt = datetime.fromisoformat(iso_str)
|
|
59
|
+
if dt.tzinfo is None:
|
|
60
|
+
return dt.replace(tzinfo=UTC)
|
|
61
|
+
return dt.astimezone(UTC)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SQLiteStateStore(
|
|
65
|
+
StateStore,
|
|
66
|
+
RepositorySnapshotStore,
|
|
67
|
+
SessionStore,
|
|
68
|
+
HandoffStore,
|
|
69
|
+
CheckpointStore,
|
|
70
|
+
):
|
|
71
|
+
"""SQLite-backed StateStore managing project-local canonical state."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, db_path: Path | str, auto_migrate: bool = True) -> None:
|
|
74
|
+
"""Initialize the SQLite state store at the given database path.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
db_path: Absolute or relative path to the sqlite3 database file.
|
|
78
|
+
auto_migrate: Whether to run pending schema migrations automatically on initialization.
|
|
79
|
+
"""
|
|
80
|
+
self.db_path = Path(db_path).resolve()
|
|
81
|
+
# Ensure parent directory exists
|
|
82
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
self._conn = sqlite3.connect(
|
|
86
|
+
database=str(self.db_path),
|
|
87
|
+
timeout=5.0,
|
|
88
|
+
check_same_thread=False,
|
|
89
|
+
)
|
|
90
|
+
self._conn.row_factory = sqlite3.Row
|
|
91
|
+
self._configure_connection()
|
|
92
|
+
except sqlite3.Error as err:
|
|
93
|
+
msg = f"Failed to open SQLite database at {self.db_path}: {err}"
|
|
94
|
+
raise DatabaseStateError(msg) from err
|
|
95
|
+
|
|
96
|
+
if auto_migrate:
|
|
97
|
+
try:
|
|
98
|
+
self.migrate()
|
|
99
|
+
except BaseException:
|
|
100
|
+
# A failed constructor has no context-manager exit to release the connection.
|
|
101
|
+
self.close()
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
def _configure_connection(self) -> None:
|
|
105
|
+
"""Configure SQLite pragmas for safety and performance."""
|
|
106
|
+
try:
|
|
107
|
+
# Setting journal_mode to WAL requires autocommit mode (no active transaction)
|
|
108
|
+
self._conn.isolation_level = None
|
|
109
|
+
self._conn.execute("PRAGMA journal_mode = WAL;")
|
|
110
|
+
self._conn.execute("PRAGMA foreign_keys = ON;")
|
|
111
|
+
self._conn.execute("PRAGMA busy_timeout = 5000;")
|
|
112
|
+
self._conn.isolation_level = "DEFERRED"
|
|
113
|
+
except sqlite3.Error as err:
|
|
114
|
+
msg = f"Failed to configure SQLite database pragmas: {err}"
|
|
115
|
+
raise DatabaseStateError(msg) from err
|
|
116
|
+
|
|
117
|
+
def migrate(self) -> int:
|
|
118
|
+
"""Run pending schema migrations on the database."""
|
|
119
|
+
try:
|
|
120
|
+
return run_migrations(self._conn)
|
|
121
|
+
except (DatabaseStateError, UnsupportedSchemaVersionError):
|
|
122
|
+
raise
|
|
123
|
+
except sqlite3.Error as err:
|
|
124
|
+
raise DatabaseStateError(f"Migration failed: {err}") from err
|
|
125
|
+
|
|
126
|
+
def get_schema_version(self) -> int:
|
|
127
|
+
"""Inspect and return the current schema version of the database."""
|
|
128
|
+
return get_current_schema_version(self._conn)
|
|
129
|
+
|
|
130
|
+
def close(self) -> None:
|
|
131
|
+
"""Close the underlying SQLite connection."""
|
|
132
|
+
if hasattr(self, "_conn"):
|
|
133
|
+
self._conn.close()
|
|
134
|
+
|
|
135
|
+
def __enter__(self) -> "SQLiteStateStore":
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
139
|
+
self.close()
|
|
140
|
+
|
|
141
|
+
# --- Project Operations ---
|
|
142
|
+
|
|
143
|
+
def save_project(self, project: Project) -> None:
|
|
144
|
+
"""Persist or update a canonical Project record."""
|
|
145
|
+
try:
|
|
146
|
+
with self._conn:
|
|
147
|
+
self._conn.execute(
|
|
148
|
+
"""
|
|
149
|
+
INSERT INTO projects (id, name, repo_path, created_at, metadata)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?)
|
|
151
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
152
|
+
name = excluded.name,
|
|
153
|
+
repo_path = excluded.repo_path,
|
|
154
|
+
metadata = excluded.metadata;
|
|
155
|
+
""",
|
|
156
|
+
(
|
|
157
|
+
project.id,
|
|
158
|
+
project.name,
|
|
159
|
+
str(Path(project.repo_path).resolve()),
|
|
160
|
+
project.created_at.isoformat(),
|
|
161
|
+
json.dumps(project.metadata, ensure_ascii=False),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
# Ensure a runtime row exists for this project
|
|
165
|
+
self._conn.execute(
|
|
166
|
+
"""
|
|
167
|
+
INSERT INTO project_runtime (project_id, active_task_id)
|
|
168
|
+
VALUES (?, NULL)
|
|
169
|
+
ON CONFLICT(project_id) DO NOTHING;
|
|
170
|
+
""",
|
|
171
|
+
(project.id,),
|
|
172
|
+
)
|
|
173
|
+
except sqlite3.Error as err:
|
|
174
|
+
raise DatabaseStateError(f"Failed to save project '{project.id}': {err}") from err
|
|
175
|
+
|
|
176
|
+
def get_project(self, project_id: str) -> Project | None:
|
|
177
|
+
"""Retrieve a Project by its stable identifier."""
|
|
178
|
+
try:
|
|
179
|
+
cursor = self._conn.cursor()
|
|
180
|
+
cursor.execute(
|
|
181
|
+
"SELECT id, name, repo_path, created_at, metadata FROM projects WHERE id = ?;",
|
|
182
|
+
(project_id,),
|
|
183
|
+
)
|
|
184
|
+
row = cursor.fetchone()
|
|
185
|
+
if row is None:
|
|
186
|
+
return None
|
|
187
|
+
return self._row_to_project(row)
|
|
188
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
189
|
+
raise StateCorruptionError(f"Failed to load project '{project_id}': {err}") from err
|
|
190
|
+
|
|
191
|
+
def get_default_project(self) -> Project | None:
|
|
192
|
+
"""Retrieve the canonical project associated with this project-local store."""
|
|
193
|
+
try:
|
|
194
|
+
cursor = self._conn.cursor()
|
|
195
|
+
cursor.execute(
|
|
196
|
+
"SELECT id, name, repo_path, created_at, metadata FROM projects LIMIT 1;"
|
|
197
|
+
)
|
|
198
|
+
row = cursor.fetchone()
|
|
199
|
+
if row is None:
|
|
200
|
+
return None
|
|
201
|
+
return self._row_to_project(row)
|
|
202
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
203
|
+
raise StateCorruptionError(f"Failed to load default project: {err}") from err
|
|
204
|
+
|
|
205
|
+
def _row_to_project(self, row: sqlite3.Row) -> Project:
|
|
206
|
+
"""Convert a database row into a Project domain entity."""
|
|
207
|
+
return Project(
|
|
208
|
+
id=row["id"],
|
|
209
|
+
name=row["name"],
|
|
210
|
+
repo_path=row["repo_path"],
|
|
211
|
+
created_at=_parse_utc_datetime(row["created_at"]),
|
|
212
|
+
metadata=json.loads(row["metadata"]),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# --- Task Operations ---
|
|
216
|
+
|
|
217
|
+
def save_task(self, task: Task) -> None:
|
|
218
|
+
"""Persist or update a canonical Task record."""
|
|
219
|
+
try:
|
|
220
|
+
with self._conn:
|
|
221
|
+
self._conn.execute(
|
|
222
|
+
"""
|
|
223
|
+
INSERT INTO tasks (
|
|
224
|
+
id, project_id, title, objective, requirements, constraints,
|
|
225
|
+
status, completed_items, current_work, remaining_items,
|
|
226
|
+
known_issues, created_at, updated_at, metadata
|
|
227
|
+
)
|
|
228
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
229
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
230
|
+
title = excluded.title,
|
|
231
|
+
objective = excluded.objective,
|
|
232
|
+
requirements = excluded.requirements,
|
|
233
|
+
constraints = excluded.constraints,
|
|
234
|
+
status = excluded.status,
|
|
235
|
+
completed_items = excluded.completed_items,
|
|
236
|
+
current_work = excluded.current_work,
|
|
237
|
+
remaining_items = excluded.remaining_items,
|
|
238
|
+
known_issues = excluded.known_issues,
|
|
239
|
+
updated_at = excluded.updated_at,
|
|
240
|
+
metadata = excluded.metadata;
|
|
241
|
+
""",
|
|
242
|
+
(
|
|
243
|
+
task.id,
|
|
244
|
+
task.project_id,
|
|
245
|
+
task.title,
|
|
246
|
+
task.objective,
|
|
247
|
+
json.dumps(task.requirements, ensure_ascii=False),
|
|
248
|
+
json.dumps(task.constraints, ensure_ascii=False),
|
|
249
|
+
task.status.value,
|
|
250
|
+
json.dumps(task.completed_items, ensure_ascii=False),
|
|
251
|
+
task.current_work,
|
|
252
|
+
json.dumps(task.remaining_items, ensure_ascii=False),
|
|
253
|
+
json.dumps(task.known_issues, ensure_ascii=False),
|
|
254
|
+
task.created_at.isoformat(),
|
|
255
|
+
task.updated_at.isoformat(),
|
|
256
|
+
json.dumps(task.metadata, ensure_ascii=False),
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
except sqlite3.Error as err:
|
|
260
|
+
raise DatabaseStateError(f"Failed to save task '{task.id}': {err}") from err
|
|
261
|
+
|
|
262
|
+
def get_task(self, task_id: str) -> Task | None:
|
|
263
|
+
"""Retrieve a Task by its stable identifier."""
|
|
264
|
+
try:
|
|
265
|
+
cursor = self._conn.cursor()
|
|
266
|
+
cursor.execute(
|
|
267
|
+
"""
|
|
268
|
+
SELECT id, project_id, title, objective, requirements, constraints,
|
|
269
|
+
status, completed_items, current_work, remaining_items,
|
|
270
|
+
known_issues, created_at, updated_at, metadata
|
|
271
|
+
FROM tasks WHERE id = ?;
|
|
272
|
+
""",
|
|
273
|
+
(task_id,),
|
|
274
|
+
)
|
|
275
|
+
row = cursor.fetchone()
|
|
276
|
+
if row is None:
|
|
277
|
+
return None
|
|
278
|
+
return self._row_to_task(row)
|
|
279
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
280
|
+
raise StateCorruptionError(f"Failed to load task '{task_id}': {err}") from err
|
|
281
|
+
|
|
282
|
+
def list_tasks(self, project_id: str) -> list[Task]:
|
|
283
|
+
"""List all tasks associated with a given project ordered by creation time."""
|
|
284
|
+
try:
|
|
285
|
+
cursor = self._conn.cursor()
|
|
286
|
+
cursor.execute(
|
|
287
|
+
"""
|
|
288
|
+
SELECT id, project_id, title, objective, requirements, constraints,
|
|
289
|
+
status, completed_items, current_work, remaining_items,
|
|
290
|
+
known_issues, created_at, updated_at, metadata
|
|
291
|
+
FROM tasks WHERE project_id = ?
|
|
292
|
+
ORDER BY created_at ASC, rowid ASC;
|
|
293
|
+
""",
|
|
294
|
+
(project_id,),
|
|
295
|
+
)
|
|
296
|
+
return [self._row_to_task(row) for row in cursor.fetchall()]
|
|
297
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
298
|
+
msg = f"Failed to list tasks for project '{project_id}': {err}"
|
|
299
|
+
raise StateCorruptionError(msg) from err
|
|
300
|
+
|
|
301
|
+
def _row_to_task(self, row: sqlite3.Row) -> Task:
|
|
302
|
+
"""Convert a database row into a Task domain entity."""
|
|
303
|
+
return Task(
|
|
304
|
+
id=row["id"],
|
|
305
|
+
project_id=row["project_id"],
|
|
306
|
+
title=row["title"],
|
|
307
|
+
objective=row["objective"],
|
|
308
|
+
requirements=json.loads(row["requirements"]),
|
|
309
|
+
constraints=json.loads(row["constraints"]),
|
|
310
|
+
status=TaskStatus(row["status"]),
|
|
311
|
+
completed_items=json.loads(row["completed_items"]),
|
|
312
|
+
current_work=row["current_work"],
|
|
313
|
+
remaining_items=json.loads(row["remaining_items"]),
|
|
314
|
+
known_issues=json.loads(row["known_issues"]),
|
|
315
|
+
created_at=_parse_utc_datetime(row["created_at"]),
|
|
316
|
+
updated_at=_parse_utc_datetime(row["updated_at"]),
|
|
317
|
+
metadata=json.loads(row["metadata"]),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# --- Runtime / Active Task Operations ---
|
|
321
|
+
|
|
322
|
+
def get_active_task_id(self, project_id: str) -> str | None:
|
|
323
|
+
"""Retrieve the identifier of the active task for the project, if any."""
|
|
324
|
+
try:
|
|
325
|
+
cursor = self._conn.cursor()
|
|
326
|
+
cursor.execute(
|
|
327
|
+
"SELECT active_task_id FROM project_runtime WHERE project_id = ?;",
|
|
328
|
+
(project_id,),
|
|
329
|
+
)
|
|
330
|
+
row = cursor.fetchone()
|
|
331
|
+
if row is None or row["active_task_id"] is None:
|
|
332
|
+
return None
|
|
333
|
+
return str(row["active_task_id"])
|
|
334
|
+
except sqlite3.Error as err:
|
|
335
|
+
msg = f"Failed to get active task for project '{project_id}': {err}"
|
|
336
|
+
raise StateCorruptionError(msg) from err
|
|
337
|
+
|
|
338
|
+
def set_active_task_id(self, project_id: str, task_id: str | None) -> None:
|
|
339
|
+
"""Set or clear the active task identifier for the project."""
|
|
340
|
+
try:
|
|
341
|
+
with self._conn:
|
|
342
|
+
self._conn.execute(
|
|
343
|
+
"""
|
|
344
|
+
INSERT INTO project_runtime (project_id, active_task_id)
|
|
345
|
+
VALUES (?, ?)
|
|
346
|
+
ON CONFLICT(project_id) DO UPDATE SET active_task_id = excluded.active_task_id;
|
|
347
|
+
""",
|
|
348
|
+
(project_id, task_id),
|
|
349
|
+
)
|
|
350
|
+
except sqlite3.Error as err:
|
|
351
|
+
raise DatabaseStateError(
|
|
352
|
+
f"Failed to set active task '{task_id}' for project '{project_id}': {err}"
|
|
353
|
+
) from err
|
|
354
|
+
|
|
355
|
+
# --- Git Snapshot Operations ---
|
|
356
|
+
|
|
357
|
+
def save_snapshot(self, snapshot: GitSnapshot) -> None:
|
|
358
|
+
"""Persist a canonical GitSnapshot record."""
|
|
359
|
+
try:
|
|
360
|
+
with self._conn:
|
|
361
|
+
self._conn.execute(
|
|
362
|
+
"""
|
|
363
|
+
INSERT INTO git_snapshots (
|
|
364
|
+
id, project_id, project_root, git_root, git_version,
|
|
365
|
+
branch, head_sha, detached_head, dirty,
|
|
366
|
+
staged_files, modified_files, untracked_files, conflicted_files,
|
|
367
|
+
working_tree_diff_summary, staged_diff_summary,
|
|
368
|
+
captured_at, metadata
|
|
369
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
|
370
|
+
""",
|
|
371
|
+
(
|
|
372
|
+
snapshot.id,
|
|
373
|
+
snapshot.project_id,
|
|
374
|
+
snapshot.project_root,
|
|
375
|
+
snapshot.git_root,
|
|
376
|
+
snapshot.git_version,
|
|
377
|
+
snapshot.branch,
|
|
378
|
+
snapshot.head_sha,
|
|
379
|
+
1 if snapshot.detached_head else 0,
|
|
380
|
+
1 if snapshot.dirty else 0,
|
|
381
|
+
json.dumps(snapshot.staged_files),
|
|
382
|
+
json.dumps(snapshot.modified_files),
|
|
383
|
+
json.dumps(snapshot.untracked_files),
|
|
384
|
+
json.dumps(snapshot.conflicted_files),
|
|
385
|
+
snapshot.working_tree_diff_summary,
|
|
386
|
+
snapshot.staged_diff_summary,
|
|
387
|
+
snapshot.captured_at.isoformat(),
|
|
388
|
+
json.dumps(snapshot.metadata),
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
except sqlite3.IntegrityError as err:
|
|
392
|
+
raise DatabaseStateError(
|
|
393
|
+
f"Failed to persist Git snapshot '{snapshot.id}': {err}"
|
|
394
|
+
) from err
|
|
395
|
+
except sqlite3.Error as err:
|
|
396
|
+
raise DatabaseStateError(f"Failed to save Git snapshot '{snapshot.id}': {err}") from err
|
|
397
|
+
|
|
398
|
+
def get_snapshot(self, snapshot_id: str) -> GitSnapshot | None:
|
|
399
|
+
"""Retrieve a GitSnapshot by its identifier."""
|
|
400
|
+
try:
|
|
401
|
+
cursor = self._conn.cursor()
|
|
402
|
+
cursor.execute(
|
|
403
|
+
"""
|
|
404
|
+
SELECT id, project_id, project_root, git_root, git_version,
|
|
405
|
+
branch, head_sha, detached_head, dirty,
|
|
406
|
+
staged_files, modified_files, untracked_files, conflicted_files,
|
|
407
|
+
working_tree_diff_summary, staged_diff_summary,
|
|
408
|
+
captured_at, metadata
|
|
409
|
+
FROM git_snapshots WHERE id = ?;
|
|
410
|
+
""",
|
|
411
|
+
(snapshot_id,),
|
|
412
|
+
)
|
|
413
|
+
row = cursor.fetchone()
|
|
414
|
+
if row is None:
|
|
415
|
+
return None
|
|
416
|
+
return self._row_to_snapshot(row)
|
|
417
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
418
|
+
msg = f"Failed to retrieve snapshot '{snapshot_id}': {err}"
|
|
419
|
+
raise StateCorruptionError(msg) from err
|
|
420
|
+
|
|
421
|
+
def list_snapshots(self, project_id: str, limit: int = 10) -> list[GitSnapshot]:
|
|
422
|
+
"""List snapshots for a given project, ordered newest first."""
|
|
423
|
+
try:
|
|
424
|
+
cursor = self._conn.cursor()
|
|
425
|
+
cursor.execute(
|
|
426
|
+
"""
|
|
427
|
+
SELECT id, project_id, project_root, git_root, git_version,
|
|
428
|
+
branch, head_sha, detached_head, dirty,
|
|
429
|
+
staged_files, modified_files, untracked_files, conflicted_files,
|
|
430
|
+
working_tree_diff_summary, staged_diff_summary,
|
|
431
|
+
captured_at, metadata
|
|
432
|
+
FROM git_snapshots WHERE project_id = ?
|
|
433
|
+
ORDER BY captured_at DESC, rowid DESC
|
|
434
|
+
LIMIT ?;
|
|
435
|
+
""",
|
|
436
|
+
(project_id, max(1, limit)),
|
|
437
|
+
)
|
|
438
|
+
return [self._row_to_snapshot(row) for row in cursor.fetchall()]
|
|
439
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
440
|
+
msg = f"Failed to list snapshots for project '{project_id}': {err}"
|
|
441
|
+
raise StateCorruptionError(msg) from err
|
|
442
|
+
|
|
443
|
+
def _row_to_snapshot(self, row: sqlite3.Row) -> GitSnapshot:
|
|
444
|
+
"""Convert a database row into a GitSnapshot domain entity."""
|
|
445
|
+
return GitSnapshot(
|
|
446
|
+
id=row["id"],
|
|
447
|
+
project_id=row["project_id"],
|
|
448
|
+
project_root=row["project_root"],
|
|
449
|
+
git_root=row["git_root"],
|
|
450
|
+
git_version=row["git_version"],
|
|
451
|
+
branch=row["branch"],
|
|
452
|
+
head_sha=row["head_sha"],
|
|
453
|
+
detached_head=bool(row["detached_head"]),
|
|
454
|
+
dirty=bool(row["dirty"]),
|
|
455
|
+
staged_files=json.loads(row["staged_files"]),
|
|
456
|
+
modified_files=json.loads(row["modified_files"]),
|
|
457
|
+
untracked_files=json.loads(row["untracked_files"]),
|
|
458
|
+
conflicted_files=json.loads(row["conflicted_files"]),
|
|
459
|
+
working_tree_diff_summary=row["working_tree_diff_summary"],
|
|
460
|
+
staged_diff_summary=row["staged_diff_summary"],
|
|
461
|
+
captured_at=_parse_utc_datetime(row["captured_at"]),
|
|
462
|
+
metadata=json.loads(row["metadata"]),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# --- Session Operations (SessionStore) ---
|
|
466
|
+
|
|
467
|
+
def save_session(self, session: Session) -> None:
|
|
468
|
+
"""Persist or update an agent execution Session."""
|
|
469
|
+
try:
|
|
470
|
+
with self._conn:
|
|
471
|
+
self._conn.execute(
|
|
472
|
+
"""
|
|
473
|
+
INSERT INTO sessions (
|
|
474
|
+
id, task_id, provider_id, native_session_id, status,
|
|
475
|
+
started_at, ended_at, exit_reason, exit_code, metadata,
|
|
476
|
+
resumed_from_session_id, reconciled_at
|
|
477
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
478
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
479
|
+
task_id = excluded.task_id,
|
|
480
|
+
provider_id = excluded.provider_id,
|
|
481
|
+
native_session_id = excluded.native_session_id,
|
|
482
|
+
resumed_from_session_id = excluded.resumed_from_session_id,
|
|
483
|
+
status = excluded.status,
|
|
484
|
+
started_at = excluded.started_at,
|
|
485
|
+
ended_at = excluded.ended_at,
|
|
486
|
+
exit_reason = excluded.exit_reason,
|
|
487
|
+
exit_code = excluded.exit_code,
|
|
488
|
+
metadata = excluded.metadata,
|
|
489
|
+
reconciled_at = excluded.reconciled_at;
|
|
490
|
+
""",
|
|
491
|
+
(
|
|
492
|
+
session.id,
|
|
493
|
+
session.task_id,
|
|
494
|
+
str(session.provider_id),
|
|
495
|
+
session.native_session_id,
|
|
496
|
+
session.status.value,
|
|
497
|
+
session.started_at.isoformat(),
|
|
498
|
+
session.ended_at.isoformat() if session.ended_at else None,
|
|
499
|
+
session.exit_reason.value if session.exit_reason else None,
|
|
500
|
+
session.exit_code,
|
|
501
|
+
json.dumps(session.metadata),
|
|
502
|
+
session.resumed_from_session_id,
|
|
503
|
+
session.reconciled_at.isoformat() if session.reconciled_at else None,
|
|
504
|
+
),
|
|
505
|
+
)
|
|
506
|
+
except sqlite3.IntegrityError as err:
|
|
507
|
+
raise DatabaseStateError(f"Failed to persist session '{session.id}': {err}") from err
|
|
508
|
+
except sqlite3.Error as err:
|
|
509
|
+
raise DatabaseStateError(f"Failed to save session '{session.id}': {err}") from err
|
|
510
|
+
|
|
511
|
+
def get_session(self, session_id: str) -> Session | None:
|
|
512
|
+
"""Retrieve a Session by its unique identifier."""
|
|
513
|
+
try:
|
|
514
|
+
cursor = self._conn.cursor()
|
|
515
|
+
cursor.execute(
|
|
516
|
+
"""
|
|
517
|
+
SELECT id, task_id, provider_id, native_session_id, status,
|
|
518
|
+
started_at, ended_at, exit_reason, exit_code, metadata,
|
|
519
|
+
resumed_from_session_id, reconciled_at
|
|
520
|
+
FROM sessions WHERE id = ?;
|
|
521
|
+
""",
|
|
522
|
+
(session_id,),
|
|
523
|
+
)
|
|
524
|
+
row = cursor.fetchone()
|
|
525
|
+
if row is None:
|
|
526
|
+
return None
|
|
527
|
+
return self._row_to_session(row)
|
|
528
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
529
|
+
msg = f"Failed to retrieve session '{session_id}': {err}"
|
|
530
|
+
raise StateCorruptionError(msg) from err
|
|
531
|
+
|
|
532
|
+
def list_sessions(
|
|
533
|
+
self,
|
|
534
|
+
project_id: str | None = None,
|
|
535
|
+
task_id: str | None = None,
|
|
536
|
+
limit: int | None = 20,
|
|
537
|
+
) -> list[Session]:
|
|
538
|
+
"""List sessions, ordered newest first."""
|
|
539
|
+
try:
|
|
540
|
+
cursor = self._conn.cursor()
|
|
541
|
+
conditions: list[str] = []
|
|
542
|
+
params: list[Any] = []
|
|
543
|
+
|
|
544
|
+
if task_id is not None:
|
|
545
|
+
conditions.append("s.task_id = ?")
|
|
546
|
+
params.append(task_id)
|
|
547
|
+
|
|
548
|
+
if project_id is not None:
|
|
549
|
+
conditions.append("t.project_id = ?")
|
|
550
|
+
params.append(project_id)
|
|
551
|
+
|
|
552
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
553
|
+
query = f"""
|
|
554
|
+
SELECT s.id, s.task_id, s.provider_id, s.native_session_id, s.status,
|
|
555
|
+
s.started_at, s.ended_at, s.exit_reason, s.exit_code, s.metadata,
|
|
556
|
+
s.resumed_from_session_id, s.reconciled_at
|
|
557
|
+
FROM sessions s
|
|
558
|
+
JOIN tasks t ON s.task_id = t.id
|
|
559
|
+
{where_clause}
|
|
560
|
+
ORDER BY s.started_at DESC, s.rowid DESC
|
|
561
|
+
LIMIT ?;
|
|
562
|
+
"""
|
|
563
|
+
params.append(-1 if limit is None else max(1, limit))
|
|
564
|
+
cursor.execute(query, params)
|
|
565
|
+
return [self._row_to_session(row) for row in cursor.fetchall()]
|
|
566
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
567
|
+
msg = f"Failed to list sessions: {err}"
|
|
568
|
+
raise StateCorruptionError(msg) from err
|
|
569
|
+
|
|
570
|
+
def _row_to_session(self, row: sqlite3.Row) -> Session:
|
|
571
|
+
"""Convert a database row into a Session domain entity."""
|
|
572
|
+
return Session(
|
|
573
|
+
id=row["id"],
|
|
574
|
+
task_id=row["task_id"],
|
|
575
|
+
provider_id=ProviderId(row["provider_id"]),
|
|
576
|
+
native_session_id=row["native_session_id"],
|
|
577
|
+
resumed_from_session_id=row["resumed_from_session_id"],
|
|
578
|
+
status=SessionStatus(row["status"]),
|
|
579
|
+
started_at=_parse_utc_datetime(row["started_at"]),
|
|
580
|
+
ended_at=_parse_utc_datetime(row["ended_at"]) if row["ended_at"] else None,
|
|
581
|
+
exit_reason=SessionExitReason(row["exit_reason"]) if row["exit_reason"] else None,
|
|
582
|
+
exit_code=row["exit_code"],
|
|
583
|
+
reconciled_at=(
|
|
584
|
+
_parse_utc_datetime(row["reconciled_at"])
|
|
585
|
+
if ("reconciled_at" in tuple(row.keys()) and row["reconciled_at"])
|
|
586
|
+
else None
|
|
587
|
+
),
|
|
588
|
+
metadata=json.loads(row["metadata"]),
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
# --- Handoff Operations (HandoffStore) ---
|
|
592
|
+
|
|
593
|
+
def save_handoff(self, handoff: HandoffRecord) -> None:
|
|
594
|
+
"""Persist or update a canonical HandoffRecord.
|
|
595
|
+
|
|
596
|
+
The canonical payload is stored as validated JSON text. Rendered provider
|
|
597
|
+
prompts and provider responses are never persisted.
|
|
598
|
+
"""
|
|
599
|
+
try:
|
|
600
|
+
with self._conn:
|
|
601
|
+
self._conn.execute(
|
|
602
|
+
"""
|
|
603
|
+
INSERT INTO handoffs (
|
|
604
|
+
id, protocol_version, project_id, task_id,
|
|
605
|
+
source_session_id, source_provider_id, target_provider_id,
|
|
606
|
+
git_snapshot_id, target_session_id, status, payload,
|
|
607
|
+
created_at, delivered_at, failure_code, metadata,
|
|
608
|
+
source_checkpoint_id
|
|
609
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
610
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
611
|
+
protocol_version = excluded.protocol_version,
|
|
612
|
+
project_id = excluded.project_id,
|
|
613
|
+
task_id = excluded.task_id,
|
|
614
|
+
source_session_id = excluded.source_session_id,
|
|
615
|
+
source_provider_id = excluded.source_provider_id,
|
|
616
|
+
target_provider_id = excluded.target_provider_id,
|
|
617
|
+
git_snapshot_id = excluded.git_snapshot_id,
|
|
618
|
+
target_session_id = excluded.target_session_id,
|
|
619
|
+
status = excluded.status,
|
|
620
|
+
payload = excluded.payload,
|
|
621
|
+
delivered_at = excluded.delivered_at,
|
|
622
|
+
failure_code = excluded.failure_code,
|
|
623
|
+
metadata = excluded.metadata,
|
|
624
|
+
source_checkpoint_id = excluded.source_checkpoint_id;
|
|
625
|
+
""",
|
|
626
|
+
(
|
|
627
|
+
handoff.id,
|
|
628
|
+
handoff.protocol_version,
|
|
629
|
+
handoff.project_id,
|
|
630
|
+
handoff.task_id,
|
|
631
|
+
handoff.source_session_id,
|
|
632
|
+
str(handoff.source_provider_id),
|
|
633
|
+
str(handoff.target_provider_id),
|
|
634
|
+
handoff.git_snapshot_id,
|
|
635
|
+
handoff.target_session_id,
|
|
636
|
+
handoff.status.value,
|
|
637
|
+
handoff.payload.model_dump_json(),
|
|
638
|
+
handoff.created_at.isoformat(),
|
|
639
|
+
handoff.delivered_at.isoformat() if handoff.delivered_at else None,
|
|
640
|
+
handoff.failure_code.value if handoff.failure_code else None,
|
|
641
|
+
json.dumps(handoff.metadata, ensure_ascii=False),
|
|
642
|
+
handoff.source_checkpoint_id,
|
|
643
|
+
),
|
|
644
|
+
)
|
|
645
|
+
except sqlite3.IntegrityError as err:
|
|
646
|
+
raise DatabaseStateError(f"Failed to persist handoff '{handoff.id}': {err}") from err
|
|
647
|
+
except sqlite3.Error as err:
|
|
648
|
+
raise DatabaseStateError(f"Failed to save handoff '{handoff.id}': {err}") from err
|
|
649
|
+
|
|
650
|
+
def update_handoff_delivery(
|
|
651
|
+
self,
|
|
652
|
+
handoff_id: str,
|
|
653
|
+
status: HandoffStatus,
|
|
654
|
+
target_session_id: str | None = None,
|
|
655
|
+
delivered_at: datetime | None = None,
|
|
656
|
+
failure_code: HandoffFailureCode | None = None,
|
|
657
|
+
) -> None:
|
|
658
|
+
"""Update delivery metadata of an existing handoff record."""
|
|
659
|
+
try:
|
|
660
|
+
with self._conn:
|
|
661
|
+
self._conn.execute(
|
|
662
|
+
"""
|
|
663
|
+
UPDATE handoffs SET
|
|
664
|
+
status = ?,
|
|
665
|
+
target_session_id = COALESCE(?, target_session_id),
|
|
666
|
+
delivered_at = ?,
|
|
667
|
+
failure_code = ?
|
|
668
|
+
WHERE id = ?;
|
|
669
|
+
""",
|
|
670
|
+
(
|
|
671
|
+
status.value,
|
|
672
|
+
target_session_id,
|
|
673
|
+
delivered_at.isoformat() if delivered_at else None,
|
|
674
|
+
failure_code.value if failure_code else None,
|
|
675
|
+
handoff_id,
|
|
676
|
+
),
|
|
677
|
+
)
|
|
678
|
+
except sqlite3.Error as err:
|
|
679
|
+
msg = f"Failed to update handoff delivery for '{handoff_id}': {err}"
|
|
680
|
+
raise DatabaseStateError(msg) from err
|
|
681
|
+
|
|
682
|
+
def get_handoff(self, handoff_id: str) -> HandoffRecord | None:
|
|
683
|
+
"""Retrieve a HandoffRecord by its stable identifier."""
|
|
684
|
+
try:
|
|
685
|
+
cursor = self._conn.cursor()
|
|
686
|
+
cursor.execute(
|
|
687
|
+
"""
|
|
688
|
+
SELECT id, protocol_version, project_id, task_id,
|
|
689
|
+
source_session_id, source_provider_id, target_provider_id,
|
|
690
|
+
git_snapshot_id, target_session_id, status, payload,
|
|
691
|
+
created_at, delivered_at, failure_code, metadata,
|
|
692
|
+
source_checkpoint_id
|
|
693
|
+
FROM handoffs WHERE id = ?;
|
|
694
|
+
""",
|
|
695
|
+
(handoff_id,),
|
|
696
|
+
)
|
|
697
|
+
row = cursor.fetchone()
|
|
698
|
+
if row is None:
|
|
699
|
+
return None
|
|
700
|
+
return self._row_to_handoff(row)
|
|
701
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
702
|
+
msg = f"Failed to retrieve handoff '{handoff_id}': {err}"
|
|
703
|
+
raise StateCorruptionError(msg) from err
|
|
704
|
+
|
|
705
|
+
def list_handoffs(
|
|
706
|
+
self,
|
|
707
|
+
project_id: str | None = None,
|
|
708
|
+
task_id: str | None = None,
|
|
709
|
+
limit: int = 20,
|
|
710
|
+
) -> list[HandoffRecord]:
|
|
711
|
+
"""List handoff records, ordered newest first."""
|
|
712
|
+
try:
|
|
713
|
+
cursor = self._conn.cursor()
|
|
714
|
+
conditions: list[str] = []
|
|
715
|
+
params: list[Any] = []
|
|
716
|
+
|
|
717
|
+
if project_id is not None:
|
|
718
|
+
conditions.append("project_id = ?")
|
|
719
|
+
params.append(project_id)
|
|
720
|
+
|
|
721
|
+
if task_id is not None:
|
|
722
|
+
conditions.append("task_id = ?")
|
|
723
|
+
params.append(task_id)
|
|
724
|
+
|
|
725
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
726
|
+
query = f"""
|
|
727
|
+
SELECT id, protocol_version, project_id, task_id,
|
|
728
|
+
source_session_id, source_provider_id, target_provider_id,
|
|
729
|
+
git_snapshot_id, target_session_id, status, payload,
|
|
730
|
+
created_at, delivered_at, failure_code, metadata,
|
|
731
|
+
source_checkpoint_id
|
|
732
|
+
FROM handoffs
|
|
733
|
+
{where_clause}
|
|
734
|
+
ORDER BY created_at DESC, rowid DESC
|
|
735
|
+
LIMIT ?;
|
|
736
|
+
"""
|
|
737
|
+
params.append(max(1, limit))
|
|
738
|
+
cursor.execute(query, params)
|
|
739
|
+
return [self._row_to_handoff(row) for row in cursor.fetchall()]
|
|
740
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
741
|
+
raise StateCorruptionError(f"Failed to list handoffs: {err}") from err
|
|
742
|
+
|
|
743
|
+
def _row_to_handoff(self, row: sqlite3.Row) -> HandoffRecord:
|
|
744
|
+
"""Convert a database row into a HandoffRecord domain entity."""
|
|
745
|
+
return HandoffRecord(
|
|
746
|
+
id=row["id"],
|
|
747
|
+
protocol_version=int(row["protocol_version"]),
|
|
748
|
+
project_id=row["project_id"],
|
|
749
|
+
task_id=row["task_id"],
|
|
750
|
+
source_session_id=row["source_session_id"],
|
|
751
|
+
source_provider_id=ProviderId(row["source_provider_id"]),
|
|
752
|
+
target_provider_id=ProviderId(row["target_provider_id"]),
|
|
753
|
+
source_checkpoint_id=(
|
|
754
|
+
row["source_checkpoint_id"]
|
|
755
|
+
if ("source_checkpoint_id" in tuple(row.keys()) and row["source_checkpoint_id"])
|
|
756
|
+
else None
|
|
757
|
+
),
|
|
758
|
+
git_snapshot_id=row["git_snapshot_id"],
|
|
759
|
+
target_session_id=row["target_session_id"],
|
|
760
|
+
status=HandoffStatus(row["status"]),
|
|
761
|
+
payload=HandoffPayload.model_validate_json(row["payload"]),
|
|
762
|
+
created_at=_parse_utc_datetime(row["created_at"]),
|
|
763
|
+
delivered_at=_parse_utc_datetime(row["delivered_at"]) if row["delivered_at"] else None,
|
|
764
|
+
failure_code=(HandoffFailureCode(row["failure_code"]) if row["failure_code"] else None),
|
|
765
|
+
metadata=json.loads(row["metadata"]),
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# --- Checkpoint Operations (CheckpointStore) ---
|
|
769
|
+
|
|
770
|
+
def save_checkpoint(self, checkpoint: CheckpointRecord) -> None:
|
|
771
|
+
"""Persist a canonical CheckpointRecord.
|
|
772
|
+
|
|
773
|
+
The canonical payload is stored as validated JSON text. Rendered prompts,
|
|
774
|
+
provider responses, transcripts, and full diffs are never persisted.
|
|
775
|
+
"""
|
|
776
|
+
try:
|
|
777
|
+
with self._conn:
|
|
778
|
+
self._conn.execute(
|
|
779
|
+
"""
|
|
780
|
+
INSERT INTO checkpoints (
|
|
781
|
+
id, protocol_version, project_id, task_id, session_id,
|
|
782
|
+
git_snapshot_id, kind, payload, created_at, metadata
|
|
783
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
784
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
785
|
+
protocol_version = excluded.protocol_version,
|
|
786
|
+
project_id = excluded.project_id,
|
|
787
|
+
task_id = excluded.task_id,
|
|
788
|
+
session_id = excluded.session_id,
|
|
789
|
+
git_snapshot_id = excluded.git_snapshot_id,
|
|
790
|
+
kind = excluded.kind,
|
|
791
|
+
payload = excluded.payload,
|
|
792
|
+
created_at = excluded.created_at,
|
|
793
|
+
metadata = excluded.metadata;
|
|
794
|
+
""",
|
|
795
|
+
(
|
|
796
|
+
checkpoint.id,
|
|
797
|
+
checkpoint.protocol_version,
|
|
798
|
+
checkpoint.project_id,
|
|
799
|
+
checkpoint.task_id,
|
|
800
|
+
checkpoint.session_id,
|
|
801
|
+
checkpoint.git_snapshot_id,
|
|
802
|
+
checkpoint.kind.value,
|
|
803
|
+
checkpoint.payload.model_dump_json(),
|
|
804
|
+
checkpoint.created_at.isoformat(),
|
|
805
|
+
json.dumps(checkpoint.metadata, ensure_ascii=False),
|
|
806
|
+
),
|
|
807
|
+
)
|
|
808
|
+
except sqlite3.IntegrityError as err:
|
|
809
|
+
raise DatabaseStateError(
|
|
810
|
+
f"Failed to persist checkpoint '{checkpoint.id}': {err}"
|
|
811
|
+
) from err
|
|
812
|
+
except sqlite3.Error as err:
|
|
813
|
+
raise DatabaseStateError(f"Failed to save checkpoint '{checkpoint.id}': {err}") from err
|
|
814
|
+
|
|
815
|
+
def get_checkpoint(self, checkpoint_id: str) -> CheckpointRecord | None:
|
|
816
|
+
"""Retrieve a CheckpointRecord by its stable identifier."""
|
|
817
|
+
try:
|
|
818
|
+
cursor = self._conn.cursor()
|
|
819
|
+
cursor.execute(
|
|
820
|
+
"""
|
|
821
|
+
SELECT id, protocol_version, project_id, task_id, session_id,
|
|
822
|
+
git_snapshot_id, kind, payload, created_at, metadata
|
|
823
|
+
FROM checkpoints WHERE id = ?;
|
|
824
|
+
""",
|
|
825
|
+
(checkpoint_id,),
|
|
826
|
+
)
|
|
827
|
+
row = cursor.fetchone()
|
|
828
|
+
if row is None:
|
|
829
|
+
return None
|
|
830
|
+
return self._row_to_checkpoint(row)
|
|
831
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
832
|
+
msg = f"Failed to retrieve checkpoint '{checkpoint_id}': {err}"
|
|
833
|
+
raise StateCorruptionError(msg) from err
|
|
834
|
+
|
|
835
|
+
def list_checkpoints(
|
|
836
|
+
self,
|
|
837
|
+
project_id: str | None = None,
|
|
838
|
+
task_id: str | None = None,
|
|
839
|
+
limit: int | None = 20,
|
|
840
|
+
) -> list[CheckpointRecord]:
|
|
841
|
+
"""List checkpoint records, ordered newest first."""
|
|
842
|
+
try:
|
|
843
|
+
cursor = self._conn.cursor()
|
|
844
|
+
conditions: list[str] = []
|
|
845
|
+
params: list[Any] = []
|
|
846
|
+
|
|
847
|
+
if project_id is not None:
|
|
848
|
+
conditions.append("project_id = ?")
|
|
849
|
+
params.append(project_id)
|
|
850
|
+
|
|
851
|
+
if task_id is not None:
|
|
852
|
+
conditions.append("task_id = ?")
|
|
853
|
+
params.append(task_id)
|
|
854
|
+
|
|
855
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
856
|
+
query = f"""
|
|
857
|
+
SELECT id, protocol_version, project_id, task_id, session_id,
|
|
858
|
+
git_snapshot_id, kind, payload, created_at, metadata
|
|
859
|
+
FROM checkpoints
|
|
860
|
+
{where_clause}
|
|
861
|
+
ORDER BY created_at DESC, rowid DESC
|
|
862
|
+
LIMIT ?;
|
|
863
|
+
"""
|
|
864
|
+
params.append(-1 if limit is None else max(1, limit))
|
|
865
|
+
cursor.execute(query, params)
|
|
866
|
+
return [self._row_to_checkpoint(row) for row in cursor.fetchall()]
|
|
867
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
868
|
+
raise StateCorruptionError(f"Failed to list checkpoints: {err}") from err
|
|
869
|
+
|
|
870
|
+
def get_latest_checkpoint(self, task_id: str) -> CheckpointRecord | None:
|
|
871
|
+
"""Retrieve the newest checkpoint record for a given task."""
|
|
872
|
+
results = self.list_checkpoints(task_id=task_id, limit=1)
|
|
873
|
+
return results[0] if results else None
|
|
874
|
+
|
|
875
|
+
def list_task_checkpoint_history(self, task_id: str) -> list[CheckpointRecord]:
|
|
876
|
+
"""List every checkpoint for one task in deterministic chronological order.
|
|
877
|
+
|
|
878
|
+
Ordered oldest first by `created_at ASC, rowid ASC`. The rowid tie-breaker keeps
|
|
879
|
+
the sequence stable when two checkpoints share an identical timestamp, which
|
|
880
|
+
coarse clock granularity makes reachable on Windows. `task_id` is mandatory and
|
|
881
|
+
applied in SQL, so this query can never span tasks.
|
|
882
|
+
"""
|
|
883
|
+
try:
|
|
884
|
+
cursor = self._conn.cursor()
|
|
885
|
+
cursor.execute(
|
|
886
|
+
"""
|
|
887
|
+
SELECT id, protocol_version, project_id, task_id, session_id,
|
|
888
|
+
git_snapshot_id, kind, payload, created_at, metadata
|
|
889
|
+
FROM checkpoints
|
|
890
|
+
WHERE task_id = ?
|
|
891
|
+
ORDER BY created_at ASC, rowid ASC;
|
|
892
|
+
""",
|
|
893
|
+
(task_id,),
|
|
894
|
+
)
|
|
895
|
+
return [self._row_to_checkpoint(row) for row in cursor.fetchall()]
|
|
896
|
+
except (sqlite3.Error, ValueError, json.JSONDecodeError) as err:
|
|
897
|
+
raise StateCorruptionError(
|
|
898
|
+
f"Failed to list checkpoint history for task '{task_id}': {err}"
|
|
899
|
+
) from err
|
|
900
|
+
|
|
901
|
+
def _row_to_checkpoint(self, row: sqlite3.Row) -> CheckpointRecord:
|
|
902
|
+
"""Convert a database row into a CheckpointRecord domain entity."""
|
|
903
|
+
return CheckpointRecord(
|
|
904
|
+
id=row["id"],
|
|
905
|
+
protocol_version=int(row["protocol_version"]),
|
|
906
|
+
project_id=row["project_id"],
|
|
907
|
+
task_id=row["task_id"],
|
|
908
|
+
session_id=row["session_id"],
|
|
909
|
+
git_snapshot_id=row["git_snapshot_id"],
|
|
910
|
+
kind=CheckpointKind(row["kind"]),
|
|
911
|
+
payload=CheckpointPayload.model_validate_json(row["payload"]),
|
|
912
|
+
created_at=_parse_utc_datetime(row["created_at"]),
|
|
913
|
+
metadata=json.loads(row["metadata"]),
|
|
914
|
+
)
|