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.
Files changed (100) hide show
  1. cortexshift/__init__.py +10 -0
  2. cortexshift/__main__.py +6 -0
  3. cortexshift/adapters/__init__.py +22 -0
  4. cortexshift/adapters/command_runner.py +116 -0
  5. cortexshift/adapters/discovery.py +55 -0
  6. cortexshift/adapters/git/__init__.py +10 -0
  7. cortexshift/adapters/git/inspector.py +321 -0
  8. cortexshift/adapters/git/parser.py +140 -0
  9. cortexshift/adapters/headless_runner.py +92 -0
  10. cortexshift/adapters/process_runner.py +56 -0
  11. cortexshift/adapters/providers/__init__.py +4 -0
  12. cortexshift/adapters/providers/antigravity.py +530 -0
  13. cortexshift/adapters/providers/claude.py +375 -0
  14. cortexshift/adapters/providers/codex.py +434 -0
  15. cortexshift/adapters/sqlite/__init__.py +10 -0
  16. cortexshift/adapters/sqlite/migrations.py +268 -0
  17. cortexshift/adapters/sqlite/store.py +914 -0
  18. cortexshift/adapters/workspace_lease.py +123 -0
  19. cortexshift/application/__init__.py +42 -0
  20. cortexshift/application/checkpoint_builder.py +218 -0
  21. cortexshift/application/checkpoint_service.py +273 -0
  22. cortexshift/application/doctor.py +80 -0
  23. cortexshift/application/handoff_builder.py +281 -0
  24. cortexshift/application/handoff_renderer.py +430 -0
  25. cortexshift/application/handoff_service.py +66 -0
  26. cortexshift/application/init_service.py +86 -0
  27. cortexshift/application/locator.py +48 -0
  28. cortexshift/application/native_session.py +65 -0
  29. cortexshift/application/recovery_service.py +235 -0
  30. cortexshift/application/repository_service.py +146 -0
  31. cortexshift/application/resume_service.py +124 -0
  32. cortexshift/application/run_service.py +270 -0
  33. cortexshift/application/session_launcher.py +183 -0
  34. cortexshift/application/session_service.py +63 -0
  35. cortexshift/application/source_session.py +62 -0
  36. cortexshift/application/status_service.py +73 -0
  37. cortexshift/application/switch_service.py +671 -0
  38. cortexshift/application/task_service.py +201 -0
  39. cortexshift/application/task_workspace.py +152 -0
  40. cortexshift/cli/__init__.py +5 -0
  41. cortexshift/cli/app.py +2477 -0
  42. cortexshift/domain/__init__.py +153 -0
  43. cortexshift/domain/checkpoint.py +174 -0
  44. cortexshift/domain/doctor.py +68 -0
  45. cortexshift/domain/errors.py +277 -0
  46. cortexshift/domain/git.py +102 -0
  47. cortexshift/domain/handoff.py +241 -0
  48. cortexshift/domain/identifiers.py +27 -0
  49. cortexshift/domain/launch.py +58 -0
  50. cortexshift/domain/mcp_binding.py +81 -0
  51. cortexshift/domain/native_session.py +19 -0
  52. cortexshift/domain/project.py +37 -0
  53. cortexshift/domain/provider.py +67 -0
  54. cortexshift/domain/session.py +92 -0
  55. cortexshift/domain/status.py +40 -0
  56. cortexshift/domain/task.py +191 -0
  57. cortexshift/mcp/__init__.py +38 -0
  58. cortexshift/mcp/context.py +165 -0
  59. cortexshift/mcp/facade.py +513 -0
  60. cortexshift/mcp/models.py +178 -0
  61. cortexshift/mcp/resources.py +45 -0
  62. cortexshift/mcp/server.py +52 -0
  63. cortexshift/mcp/tools.py +176 -0
  64. cortexshift/ports/__init__.py +39 -0
  65. cortexshift/ports/checkpoint_store.py +45 -0
  66. cortexshift/ports/command_runner.py +56 -0
  67. cortexshift/ports/discovery.py +41 -0
  68. cortexshift/ports/handoff_delivery.py +91 -0
  69. cortexshift/ports/handoff_store.py +43 -0
  70. cortexshift/ports/headless_runner.py +58 -0
  71. cortexshift/ports/native_session.py +20 -0
  72. cortexshift/ports/process_runner.py +31 -0
  73. cortexshift/ports/provider.py +152 -0
  74. cortexshift/ports/repository.py +44 -0
  75. cortexshift/ports/session_store.py +27 -0
  76. cortexshift/ports/state_store.py +55 -0
  77. cortexshift/ports/workspace_lease.py +39 -0
  78. cortexshift/tui/__init__.py +24 -0
  79. cortexshift/tui/actions.py +58 -0
  80. cortexshift/tui/app.py +1051 -0
  81. cortexshift/tui/coordinator.py +173 -0
  82. cortexshift/tui/cortexshift.tcss +258 -0
  83. cortexshift/tui/facade.py +614 -0
  84. cortexshift/tui/modals.py +594 -0
  85. cortexshift/tui/models.py +503 -0
  86. cortexshift/tui/screens/__init__.py +81 -0
  87. cortexshift/tui/screens/checkpoints.py +188 -0
  88. cortexshift/tui/screens/handoffs.py +180 -0
  89. cortexshift/tui/screens/help.py +117 -0
  90. cortexshift/tui/screens/overview.py +200 -0
  91. cortexshift/tui/screens/providers.py +169 -0
  92. cortexshift/tui/screens/repository.py +143 -0
  93. cortexshift/tui/screens/sessions.py +146 -0
  94. cortexshift/tui/screens/task.py +174 -0
  95. cortexshift/tui/widgets.py +209 -0
  96. cortexshift-0.1.0.dist-info/METADATA +202 -0
  97. cortexshift-0.1.0.dist-info/RECORD +100 -0
  98. cortexshift-0.1.0.dist-info/WHEEL +4 -0
  99. cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
  100. 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
+ )