gdmcode 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 (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
src/memory/db.py ADDED
@@ -0,0 +1,1119 @@
1
+ """GdmDatabase — SQLite wrapper around .context-memory/gdm.db.
2
+
3
+ Single point of access for all persistent state. Creates the schema on first
4
+ init. Thread-safe via connection-per-call pattern. Use as a context manager
5
+ or call close() explicitly.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import sqlite3
11
+ import threading
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING, Any, Callable
14
+
15
+ from src._internal.constants import _CONTEXT_MEMORY_DIR, _DB_FILENAME
16
+ from src.exceptions import DatabaseError
17
+
18
+ __all__ = ["GdmDatabase"]
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Schema DDL — bump _SCHEMA_VERSION in constants.py when adding tables/columns
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _DDL = """
27
+ PRAGMA journal_mode=WAL;
28
+ PRAGMA foreign_keys=ON;
29
+
30
+ CREATE TABLE IF NOT EXISTS schema_version (
31
+ version INTEGER PRIMARY KEY
32
+ );
33
+
34
+ CREATE TABLE IF NOT EXISTS projects (
35
+ project_id TEXT PRIMARY KEY,
36
+ root_path TEXT UNIQUE NOT NULL,
37
+ name TEXT NOT NULL,
38
+ tech_stack TEXT NOT NULL DEFAULT '[]',
39
+ last_seen TEXT NOT NULL DEFAULT (datetime('now'))
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS sessions (
43
+ session_id TEXT PRIMARY KEY,
44
+ project_id TEXT NOT NULL REFERENCES projects(project_id),
45
+ created_at TEXT NOT NULL,
46
+ updated_at TEXT NOT NULL,
47
+ cost_usd REAL NOT NULL DEFAULT 0.0,
48
+ turn_count INTEGER NOT NULL DEFAULT 0,
49
+ status TEXT NOT NULL DEFAULT 'active'
50
+ CHECK(status IN ('active', 'complete', 'crashed'))
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS memory (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ session_id TEXT NOT NULL REFERENCES sessions(session_id),
56
+ turn_index INTEGER NOT NULL DEFAULT 0,
57
+ role TEXT NOT NULL CHECK(role IN ('user','assistant','tool','system')),
58
+ content TEXT NOT NULL,
59
+ tokens INTEGER NOT NULL DEFAULT 0,
60
+ tool_name TEXT,
61
+ tool_call_id TEXT,
62
+ tool_calls_json TEXT,
63
+ UNIQUE(session_id, turn_index)
64
+ );
65
+ CREATE INDEX IF NOT EXISTS idx_memory_session ON memory(session_id);
66
+
67
+ CREATE TABLE IF NOT EXISTS file_cache (
68
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ project_id TEXT NOT NULL REFERENCES projects(project_id),
70
+ path TEXT NOT NULL,
71
+ mtime REAL NOT NULL,
72
+ summary TEXT,
73
+ last_read_at TEXT NOT NULL,
74
+ UNIQUE(project_id, path)
75
+ );
76
+
77
+ CREATE TABLE IF NOT EXISTS conventions (
78
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
79
+ project_id TEXT NOT NULL REFERENCES projects(project_id),
80
+ key TEXT NOT NULL,
81
+ value TEXT NOT NULL,
82
+ confidence REAL NOT NULL DEFAULT 1.0,
83
+ last_updated TEXT NOT NULL,
84
+ UNIQUE(project_id, key)
85
+ );
86
+
87
+ CREATE TABLE IF NOT EXISTS code_index (
88
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
89
+ project_id TEXT NOT NULL REFERENCES projects(project_id),
90
+ file TEXT NOT NULL,
91
+ symbol TEXT NOT NULL,
92
+ kind TEXT NOT NULL,
93
+ line INTEGER NOT NULL,
94
+ signature TEXT
95
+ );
96
+ CREATE INDEX IF NOT EXISTS idx_code_index_project ON code_index(project_id);
97
+ CREATE INDEX IF NOT EXISTS idx_code_index_symbol ON code_index(symbol);
98
+
99
+ CREATE TABLE IF NOT EXISTS errors (
100
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
101
+ project_id TEXT NOT NULL REFERENCES projects(project_id),
102
+ pattern TEXT NOT NULL,
103
+ fix TEXT NOT NULL,
104
+ seen_count INTEGER NOT NULL DEFAULT 1,
105
+ last_seen TEXT NOT NULL,
106
+ UNIQUE(project_id, pattern)
107
+ );
108
+
109
+ CREATE TABLE IF NOT EXISTS permissions (
110
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
111
+ session_id TEXT REFERENCES sessions(session_id),
112
+ tool TEXT NOT NULL UNIQUE,
113
+ path_glob TEXT,
114
+ decision TEXT NOT NULL CHECK(decision IN ('allow','deny','allow_session','allow_always','deny_session')),
115
+ expires_at TEXT
116
+ );
117
+
118
+ CREATE TABLE IF NOT EXISTS checkpoints (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ session_id TEXT NOT NULL REFERENCES sessions(session_id),
121
+ branch TEXT NOT NULL,
122
+ sha TEXT NOT NULL,
123
+ files_json TEXT NOT NULL DEFAULT '[]',
124
+ cost_usd REAL NOT NULL DEFAULT 0.0,
125
+ ts TEXT NOT NULL
126
+ );
127
+
128
+ CREATE TABLE IF NOT EXISTS tasks (
129
+ task_id TEXT PRIMARY KEY,
130
+ session_id TEXT NOT NULL REFERENCES sessions(session_id),
131
+ title TEXT NOT NULL,
132
+ status TEXT NOT NULL DEFAULT 'pending'
133
+ CHECK(status IN ('pending','in_progress','done','blocked')),
134
+ subtasks TEXT NOT NULL DEFAULT '[]'
135
+ );
136
+
137
+ CREATE TABLE IF NOT EXISTS audit_log (
138
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
139
+ session_id TEXT NOT NULL REFERENCES sessions(session_id),
140
+ ts TEXT NOT NULL,
141
+ tool TEXT NOT NULL,
142
+ args TEXT NOT NULL DEFAULT '{}',
143
+ model TEXT,
144
+ decision TEXT NOT NULL DEFAULT 'allowed'
145
+ );
146
+ CREATE INDEX IF NOT EXISTS idx_audit_session ON audit_log(session_id);
147
+
148
+ CREATE TABLE IF NOT EXISTS btw_queue (
149
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
150
+ session_id TEXT NOT NULL REFERENCES sessions(session_id),
151
+ message TEXT NOT NULL,
152
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
153
+ read_at TEXT
154
+ );
155
+ CREATE INDEX IF NOT EXISTS idx_btw_session ON btw_queue(session_id, read_at);
156
+
157
+ CREATE TABLE IF NOT EXISTS spinner_state (
158
+ key TEXT PRIMARY KEY,
159
+ value TEXT NOT NULL
160
+ );
161
+
162
+ CREATE TABLE IF NOT EXISTS session_events (
163
+ event_id TEXT PRIMARY KEY,
164
+ session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
165
+ turn_index INTEGER NOT NULL,
166
+ checkpoint_id INTEGER REFERENCES checkpoints(id),
167
+ model TEXT NOT NULL,
168
+ provider TEXT NOT NULL,
169
+ tier TEXT NOT NULL,
170
+ input_tokens INTEGER NOT NULL DEFAULT 0,
171
+ output_tokens INTEGER NOT NULL DEFAULT 0,
172
+ cached_tokens INTEGER NOT NULL DEFAULT 0,
173
+ cost_usd REAL NOT NULL DEFAULT 0.0,
174
+ user_message TEXT,
175
+ assistant_text TEXT,
176
+ annotation TEXT,
177
+ ts TEXT NOT NULL DEFAULT (datetime('now')),
178
+ UNIQUE(session_id, turn_index)
179
+ );
180
+ CREATE INDEX IF NOT EXISTS idx_session_events_session ON session_events(session_id, turn_index);
181
+
182
+ CREATE TABLE IF NOT EXISTS tool_call_log (
183
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
184
+ event_id TEXT NOT NULL REFERENCES session_events(event_id) ON DELETE CASCADE,
185
+ call_index INTEGER NOT NULL,
186
+ tool_name TEXT NOT NULL,
187
+ call_id TEXT NOT NULL,
188
+ args_json TEXT NOT NULL DEFAULT '{}',
189
+ result_json TEXT,
190
+ duration_ms INTEGER,
191
+ ok INTEGER NOT NULL DEFAULT 1 CHECK(ok IN (0, 1)),
192
+ error TEXT,
193
+ ts TEXT NOT NULL DEFAULT (datetime('now'))
194
+ );
195
+ CREATE INDEX IF NOT EXISTS idx_tool_call_log_event ON tool_call_log(event_id, call_index);
196
+
197
+ CREATE TABLE IF NOT EXISTS patch_log (
198
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
199
+ event_id TEXT NOT NULL REFERENCES session_events(event_id) ON DELETE CASCADE,
200
+ file_path TEXT NOT NULL,
201
+ patch_text TEXT NOT NULL,
202
+ before_sha TEXT,
203
+ after_sha TEXT,
204
+ lines_added INTEGER NOT NULL DEFAULT 0,
205
+ lines_removed INTEGER NOT NULL DEFAULT 0,
206
+ ts TEXT NOT NULL DEFAULT (datetime('now'))
207
+ );
208
+ CREATE INDEX IF NOT EXISTS idx_patch_log_event ON patch_log(event_id);
209
+ CREATE INDEX IF NOT EXISTS idx_patch_log_file ON patch_log(file_path);
210
+
211
+ CREATE TABLE IF NOT EXISTS cost_events (
212
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
213
+ session_id TEXT NOT NULL REFERENCES sessions(session_id),
214
+ event_id TEXT REFERENCES session_events(event_id),
215
+ ts TEXT NOT NULL DEFAULT (datetime('now')),
216
+ provider TEXT NOT NULL,
217
+ tier TEXT NOT NULL,
218
+ input_tokens INTEGER NOT NULL DEFAULT 0,
219
+ output_tokens INTEGER NOT NULL DEFAULT 0,
220
+ cached_tokens INTEGER NOT NULL DEFAULT 0,
221
+ tool_calls_json TEXT NOT NULL DEFAULT '{}',
222
+ cost_usd REAL NOT NULL DEFAULT 0.0,
223
+ scope_project TEXT,
224
+ scope_team TEXT
225
+ );
226
+ CREATE INDEX IF NOT EXISTS idx_cost_events_session ON cost_events(session_id, ts);
227
+ CREATE INDEX IF NOT EXISTS idx_cost_events_ts ON cost_events(ts);
228
+
229
+ CREATE TABLE IF NOT EXISTS budget_limits (
230
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
231
+ scope_type TEXT NOT NULL CHECK(scope_type IN ('session','project','user-global','team')),
232
+ scope_id TEXT NOT NULL,
233
+ period TEXT NOT NULL CHECK(period IN ('session','daily','monthly','total')),
234
+ limit_usd REAL NOT NULL,
235
+ alert_pct_50 INTEGER NOT NULL DEFAULT 1 CHECK(alert_pct_50 IN (0,1)),
236
+ alert_pct_80 INTEGER NOT NULL DEFAULT 1 CHECK(alert_pct_80 IN (0,1)),
237
+ hard_stop INTEGER NOT NULL DEFAULT 1 CHECK(hard_stop IN (0,1)),
238
+ UNIQUE(scope_type, scope_id, period)
239
+ );
240
+
241
+ CREATE TABLE IF NOT EXISTS budget_usage (
242
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
243
+ scope_type TEXT NOT NULL,
244
+ scope_id TEXT NOT NULL,
245
+ period_start TEXT NOT NULL,
246
+ period_end TEXT NOT NULL,
247
+ spent_usd REAL NOT NULL DEFAULT 0.0,
248
+ last_updated TEXT NOT NULL DEFAULT (datetime('now')),
249
+ UNIQUE(scope_type, scope_id, period_start)
250
+ );
251
+
252
+ CREATE TABLE IF NOT EXISTS daemon_jobs (
253
+ job_id TEXT PRIMARY KEY,
254
+ job_type TEXT NOT NULL,
255
+ payload_json TEXT NOT NULL DEFAULT '{}',
256
+ status TEXT NOT NULL DEFAULT 'pending'
257
+ CHECK(status IN ('pending','claimed','done','failed','dead')),
258
+ retry_count INTEGER NOT NULL DEFAULT 0,
259
+ max_retries INTEGER NOT NULL DEFAULT 3,
260
+ lease_until TEXT,
261
+ result_json TEXT,
262
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
263
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
264
+ error TEXT
265
+ );
266
+ CREATE INDEX IF NOT EXISTS idx_daemon_jobs_status ON daemon_jobs(status, created_at);
267
+
268
+ CREATE TABLE IF NOT EXISTS developer_decisions (
269
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
270
+ session_id TEXT NOT NULL,
271
+ file_context TEXT,
272
+ decision_type TEXT NOT NULL,
273
+ value TEXT NOT NULL,
274
+ turn_index INTEGER,
275
+ created_at TEXT DEFAULT (datetime('now'))
276
+ );
277
+ CREATE INDEX IF NOT EXISTS idx_decisions_file ON developer_decisions (file_context);
278
+
279
+ CREATE TABLE IF NOT EXISTS file_hotspots (
280
+ file_path TEXT NOT NULL,
281
+ project_id TEXT NOT NULL,
282
+ edit_session_count INTEGER DEFAULT 1,
283
+ total_edit_count INTEGER DEFAULT 1,
284
+ last_edited_at TEXT,
285
+ PRIMARY KEY (file_path, project_id)
286
+ );
287
+
288
+ CREATE TABLE IF NOT EXISTS batch_jobs (
289
+ id TEXT PRIMARY KEY,
290
+ provider TEXT NOT NULL,
291
+ batch_id TEXT,
292
+ job_type TEXT NOT NULL,
293
+ status TEXT NOT NULL DEFAULT 'queued',
294
+ request_count INTEGER NOT NULL DEFAULT 0,
295
+ created_at TEXT NOT NULL,
296
+ submitted_at TEXT,
297
+ completed_at TEXT,
298
+ error TEXT,
299
+ result_path TEXT
300
+ );
301
+ """
302
+
303
+
304
+ class GdmDatabase:
305
+ """Manages the SQLite database at `.context-memory/gdm.db`.
306
+
307
+ Usage::
308
+
309
+ db = GdmDatabase(project_root=Path("."))
310
+ with db:
311
+ db.upsert_convention(project_id, "naming", "snake_case", 0.95)
312
+
313
+ Or without context manager::
314
+
315
+ db = GdmDatabase(project_root=Path("."))
316
+ db.spinner_state_mark_seen("2026-04-08", "Gedeoning")
317
+ db.close()
318
+ """
319
+
320
+ def __init__(self, project_root: Path) -> None:
321
+ self._db_path = project_root / _CONTEXT_MEMORY_DIR / _DB_FILENAME
322
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
323
+ self._lock = threading.RLock() # serialise cross-thread access to _conn
324
+ self._conn: sqlite3.Connection = self._connect()
325
+ self._ensure_schema()
326
+
327
+ # ------------------------------------------------------------------
328
+ # Connection management
329
+ # ------------------------------------------------------------------
330
+
331
+ def _connect(self) -> sqlite3.Connection:
332
+ try:
333
+ conn = sqlite3.connect(self._db_path, check_same_thread=False)
334
+ conn.row_factory = sqlite3.Row
335
+ conn.execute("PRAGMA busy_timeout = 2000")
336
+ return conn
337
+ except sqlite3.Error as exc:
338
+ raise DatabaseError(f"Cannot open database at {self._db_path}: {exc}") from exc
339
+
340
+ def _ensure_schema(self) -> None:
341
+ from src.db.migrations import registry # local import avoids circular at module level
342
+ try:
343
+ self._conn.executescript(_DDL)
344
+ registry.apply_pending(self._conn)
345
+ except sqlite3.Error as exc:
346
+ raise DatabaseError(f"Schema init failed: {exc}") from exc
347
+
348
+ def close(self) -> None:
349
+ """Close the database connection."""
350
+ self._conn.close()
351
+
352
+ def __enter__(self) -> GdmDatabase:
353
+ return self
354
+
355
+ def __exit__(self, *_: object) -> None:
356
+ self.close()
357
+
358
+ from contextlib import contextmanager
359
+
360
+ @contextmanager # type: ignore[misc]
361
+ def transaction(self): # type: ignore[return]
362
+ """Context manager for an explicit SQLite transaction.
363
+
364
+ All statements inside the block run in one atomic transaction.
365
+ Rolls back on any exception. Thread-safe: acquires _lock for the
366
+ full duration so concurrent threads cannot interleave statements.
367
+ """
368
+ with self._lock:
369
+ try:
370
+ self._conn.execute("BEGIN")
371
+ yield
372
+ self._conn.execute("COMMIT")
373
+ except Exception:
374
+ try:
375
+ self._conn.execute("ROLLBACK")
376
+ except Exception:
377
+ pass
378
+ raise
379
+
380
+ # ------------------------------------------------------------------
381
+ # Spinner state
382
+ # ------------------------------------------------------------------
383
+
384
+ def spinner_state_get(self, date: str) -> list[str]:
385
+ """Return list of priority verbs already seen on `date`."""
386
+ cur = self._conn.execute(
387
+ "SELECT value FROM spinner_state WHERE key = ?", (f"seen:{date}",)
388
+ )
389
+ row = cur.fetchone()
390
+ if row is None:
391
+ return []
392
+ import json
393
+ return json.loads(row["value"])
394
+
395
+ def spinner_state_mark_seen(self, date: str, verb: str) -> None:
396
+ """Record that `verb` was shown on `date`."""
397
+ import json
398
+ seen = self.spinner_state_get(date)
399
+ if verb not in seen:
400
+ seen.append(verb)
401
+ self._conn.execute(
402
+ "INSERT OR REPLACE INTO spinner_state (key, value) VALUES (?, ?)",
403
+ (f"seen:{date}", json.dumps(seen)),
404
+ )
405
+ self._conn.commit()
406
+
407
+ # ------------------------------------------------------------------
408
+ # Audit log
409
+ # ------------------------------------------------------------------
410
+
411
+ def audit_log_write(
412
+ self,
413
+ session_id: str,
414
+ tool: str,
415
+ args: dict, # type: ignore[type-arg]
416
+ model: str | None = None,
417
+ decision: str = "allowed",
418
+ ) -> None:
419
+ """Append an entry to the audit log."""
420
+ import json
421
+ from datetime import datetime, timezone
422
+ ts = datetime.now(timezone.utc).isoformat()
423
+ try:
424
+ self._conn.execute(
425
+ "INSERT INTO audit_log (session_id, ts, tool, args, model, decision) "
426
+ "VALUES (?, ?, ?, ?, ?, ?)",
427
+ (session_id, ts, tool, json.dumps(args), model, decision),
428
+ )
429
+ self._conn.commit()
430
+ except sqlite3.Error as exc:
431
+ raise DatabaseError(f"audit_log write failed: {exc}") from exc
432
+
433
+ # ------------------------------------------------------------------
434
+ # Conventions
435
+ # ------------------------------------------------------------------
436
+
437
+ def upsert_convention(
438
+ self, project_id: str, key: str, value: str, confidence: float = 1.0
439
+ ) -> None:
440
+ from datetime import datetime, timezone
441
+ ts = datetime.now(timezone.utc).isoformat()
442
+ try:
443
+ self._conn.execute(
444
+ """
445
+ INSERT INTO conventions (project_id, key, value, confidence, last_updated)
446
+ VALUES (?, ?, ?, ?, ?)
447
+ ON CONFLICT(project_id, key) DO UPDATE SET
448
+ value = excluded.value,
449
+ confidence = excluded.confidence,
450
+ last_updated = excluded.last_updated
451
+ """,
452
+ (project_id, key, value, confidence, ts),
453
+ )
454
+ self._conn.commit()
455
+ except sqlite3.Error as exc:
456
+ raise DatabaseError(f"upsert_convention failed: {exc}") from exc
457
+
458
+ def get_conventions(self, project_id: str) -> list[sqlite3.Row]:
459
+ cur = self._conn.execute(
460
+ "SELECT key, value, confidence FROM conventions WHERE project_id = ? "
461
+ "ORDER BY confidence DESC",
462
+ (project_id,),
463
+ )
464
+ return cur.fetchall()
465
+
466
+ # ------------------------------------------------------------------
467
+ # File cache
468
+ # ------------------------------------------------------------------
469
+
470
+ def upsert_file_cache(
471
+ self, project_id: str, path: str, mtime: float, summary: str | None
472
+ ) -> None:
473
+ from datetime import datetime, timezone
474
+ ts = datetime.now(timezone.utc).isoformat()
475
+ try:
476
+ self._conn.execute(
477
+ """
478
+ INSERT INTO file_cache (project_id, path, mtime, summary, last_read_at)
479
+ VALUES (?, ?, ?, ?, ?)
480
+ ON CONFLICT(project_id, path) DO UPDATE SET
481
+ mtime = excluded.mtime,
482
+ summary = excluded.summary,
483
+ last_read_at = excluded.last_read_at
484
+ """,
485
+ (project_id, path, mtime, summary, ts),
486
+ )
487
+ self._conn.commit()
488
+ except sqlite3.Error as exc:
489
+ raise DatabaseError(f"upsert_file_cache failed: {exc}") from exc
490
+
491
+ def get_file_cache(self, project_id: str, path: str) -> sqlite3.Row | None:
492
+ cur = self._conn.execute(
493
+ "SELECT mtime, summary, last_read_at FROM file_cache "
494
+ "WHERE project_id = ? AND path = ?",
495
+ (project_id, path),
496
+ )
497
+ return cur.fetchone()
498
+
499
+ # ------------------------------------------------------------------
500
+ # Memory / transcript persistence (crash recovery)
501
+ # ------------------------------------------------------------------
502
+
503
+ def memory_save_turns(
504
+ self, session_id: str, turns: list[dict] # type: ignore[type-arg]
505
+ ) -> None:
506
+ """Replace all persisted turns for a session (idempotent checkpoint).
507
+
508
+ Enumerates turns to write a stable ``turn_index`` so the UNIQUE
509
+ constraint on ``(session_id, turn_index)`` prevents duplicate rows.
510
+ The DELETE step cleans up any stale rows beyond the current transcript
511
+ length (e.g. after eviction/compression shortened the history).
512
+
513
+ Args:
514
+ session_id: active session identifier
515
+ turns: list of turn dicts with keys role/content/tokens/
516
+ tool_name/tool_call_id/tool_calls
517
+ """
518
+ import json as _json
519
+ try:
520
+ with self.transaction():
521
+ # Remove rows beyond the new transcript length (eviction shrinkage).
522
+ self._conn.execute(
523
+ "DELETE FROM memory WHERE session_id = ? AND turn_index >= ?",
524
+ (session_id, len(turns)),
525
+ )
526
+ for idx, t in enumerate(turns):
527
+ self._conn.execute(
528
+ "INSERT OR REPLACE INTO memory "
529
+ "(session_id, turn_index, role, content, tokens, "
530
+ "tool_name, tool_call_id, tool_calls_json) "
531
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
532
+ (
533
+ session_id,
534
+ idx,
535
+ t["role"],
536
+ t.get("content") or "",
537
+ t.get("tokens", 0),
538
+ t.get("tool_name"),
539
+ t.get("tool_call_id"),
540
+ _json.dumps(t["tool_calls"]) if t.get("tool_calls") else None,
541
+ ),
542
+ )
543
+ except sqlite3.Error as exc:
544
+ raise DatabaseError(f"memory_save_turns failed: {exc}") from exc
545
+
546
+ def memory_load_turns(
547
+ self, session_id: str
548
+ ) -> list[dict]: # type: ignore[type-arg]
549
+ """Load persisted transcript turns for session restore.
550
+
551
+ Returns:
552
+ List of turn dicts ordered by ``turn_index``, ready for
553
+ ``Turn.from_message()`` or ``TranscriptStore.from_turns()``.
554
+ """
555
+ import json as _json
556
+ try:
557
+ cur = self._conn.execute(
558
+ "SELECT role, content, tokens, tool_name, tool_call_id, tool_calls_json "
559
+ "FROM memory WHERE session_id = ? ORDER BY turn_index",
560
+ (session_id,),
561
+ )
562
+ return [
563
+ {
564
+ "role": r["role"],
565
+ "content": r["content"],
566
+ "tokens": r["tokens"],
567
+ "tool_name": r["tool_name"],
568
+ "tool_call_id": r["tool_call_id"],
569
+ "tool_calls": _json.loads(r["tool_calls_json"])
570
+ if r["tool_calls_json"]
571
+ else None,
572
+ }
573
+ for r in cur.fetchall()
574
+ ]
575
+ except sqlite3.Error as exc:
576
+ raise DatabaseError(f"memory_load_turns failed: {exc}") from exc
577
+
578
+ def session_set_status(self, session_id: str, status: str) -> None:
579
+ """Update the lifecycle status of a session.
580
+
581
+ Args:
582
+ session_id: session to update
583
+ status: one of 'active', 'complete', 'crashed'
584
+ """
585
+ try:
586
+ self._conn.execute(
587
+ "UPDATE sessions SET status = ?, updated_at = datetime('now') "
588
+ "WHERE session_id = ?",
589
+ (status, session_id),
590
+ )
591
+ self._conn.commit()
592
+ except sqlite3.Error as exc:
593
+ raise DatabaseError(f"session_set_status failed: {exc}") from exc
594
+
595
+ def list_incomplete_sessions(
596
+ self, limit: int = 10
597
+ ) -> list[dict]: # type: ignore[type-arg]
598
+ """Return active sessions that have at least one turn, most recently updated first.
599
+
600
+ Returns an empty list if the sessions table predates core-002
601
+ migration (no ``status`` column) — callers should handle empty gracefully.
602
+
603
+ Each dict has keys: ``session_id``, ``created_at``, ``updated_at``,
604
+ ``turn_count``, ``cost_usd``, ``status``.
605
+ """
606
+ try:
607
+ with self._lock:
608
+ rows = self._conn.execute(
609
+ "SELECT session_id, created_at, updated_at, turn_count, cost_usd, status "
610
+ "FROM sessions WHERE status = 'active' AND turn_count > 0 "
611
+ "ORDER BY updated_at DESC LIMIT ?",
612
+ (limit,),
613
+ ).fetchall()
614
+ return [dict(r) for r in rows]
615
+ except sqlite3.OperationalError:
616
+ # Pre-migration: sessions table may not have the status column yet.
617
+ return []
618
+ except sqlite3.Error as exc:
619
+ raise DatabaseError(f"list_incomplete_sessions failed: {exc}") from exc
620
+
621
+ # ------------------------------------------------------------------
622
+ # Raw access for advanced queries
623
+ # ------------------------------------------------------------------
624
+
625
+ _MAX_RETRY: int = 5
626
+ _RETRY_BASE_MS: float = 50.0
627
+
628
+ def _execute_with_retry(self, fn: "Callable[[], Any]", sql: str = "") -> "Any":
629
+ """Run fn with exponential backoff on SQLITE_BUSY / 'database is locked'."""
630
+ import time as _time
631
+ for attempt in range(self._MAX_RETRY):
632
+ try:
633
+ return fn()
634
+ except sqlite3.OperationalError as exc:
635
+ if "database is locked" not in str(exc).lower() or attempt == self._MAX_RETRY - 1:
636
+ raise DatabaseError(str(exc) + (f"\nSQL: {sql}" if sql else "")) from exc
637
+ wait = self._RETRY_BASE_MS * (2 ** attempt) / 1000.0
638
+ log.debug("SQLITE_BUSY attempt %d/%d, sleeping %.3fs", attempt + 1, self._MAX_RETRY, wait)
639
+ _time.sleep(wait)
640
+ raise DatabaseError("Unreachable") # mypy guard
641
+
642
+ def execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
643
+ """Execute raw SQL. Use sparingly — prefer typed methods above."""
644
+ def _run() -> sqlite3.Cursor:
645
+ with self._lock:
646
+ cur = self._conn.execute(sql, params)
647
+ self._conn.commit()
648
+ return cur
649
+ return self._execute_with_retry(_run, sql)
650
+
651
+ def execute_one(self, sql: str, params: tuple = ()) -> "sqlite3.Row | None":
652
+ """Execute a SELECT and return at most one row, or None."""
653
+ def _run() -> "sqlite3.Row | None":
654
+ with self._lock:
655
+ cur = self._conn.execute(sql, params)
656
+ return cur.fetchone()
657
+ return self._execute_with_retry(_run, sql)
658
+
659
+ def execute_all(self, sql: str, params: tuple = ()) -> "list[sqlite3.Row]":
660
+ """Execute a SELECT and return all rows."""
661
+ def _run() -> "list[sqlite3.Row]":
662
+ with self._lock:
663
+ cur = self._conn.execute(sql, params)
664
+ return cur.fetchall()
665
+ return self._execute_with_retry(_run, sql)
666
+
667
+ # ------------------------------------------------------------------
668
+ # Event log (session_events / tool_call_log / patch_log)
669
+ # ------------------------------------------------------------------
670
+
671
+ def event_log_begin_turn(
672
+ self,
673
+ session_id: str,
674
+ turn_index: int,
675
+ model: str,
676
+ provider: str,
677
+ tier: str,
678
+ user_message: str | None = None,
679
+ ) -> str:
680
+ """Insert a session_events row for a new turn; returns event_id (UUID)."""
681
+ import uuid as _uuid
682
+ from datetime import datetime, timezone
683
+ event_id = str(_uuid.uuid4())
684
+ ts = datetime.now(timezone.utc).isoformat()
685
+ self.execute(
686
+ """
687
+ INSERT INTO session_events
688
+ (event_id, session_id, turn_index, model, provider, tier, user_message, ts)
689
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
690
+ """,
691
+ (event_id, session_id, turn_index, model, provider, tier, user_message, ts),
692
+ )
693
+ return event_id
694
+
695
+ def event_log_complete_turn(
696
+ self,
697
+ event_id: str,
698
+ assistant_text: str | None,
699
+ input_tokens: int,
700
+ output_tokens: int,
701
+ cached_tokens: int,
702
+ cost_usd: float,
703
+ checkpoint_id: int | None = None,
704
+ ) -> None:
705
+ """Update session_events row with turn completion data."""
706
+ self.execute(
707
+ """
708
+ UPDATE session_events
709
+ SET assistant_text=?, input_tokens=?, output_tokens=?, cached_tokens=?,
710
+ cost_usd=?, checkpoint_id=?
711
+ WHERE event_id=?
712
+ """,
713
+ (assistant_text, input_tokens, output_tokens, cached_tokens,
714
+ cost_usd, checkpoint_id, event_id),
715
+ )
716
+
717
+ def event_log_record_tool_call(
718
+ self,
719
+ event_id: str,
720
+ call_index: int,
721
+ tool_name: str,
722
+ call_id: str,
723
+ args: dict,
724
+ result: dict | None = None,
725
+ duration_ms: int | None = None,
726
+ ok: bool = True,
727
+ error: str | None = None,
728
+ ) -> None:
729
+ """Insert one tool_call_log row."""
730
+ import json as _json
731
+ self.execute(
732
+ """
733
+ INSERT INTO tool_call_log
734
+ (event_id, call_index, tool_name, call_id, args_json,
735
+ result_json, duration_ms, ok, error)
736
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
737
+ """,
738
+ (event_id, call_index, tool_name, call_id,
739
+ _json.dumps(args),
740
+ _json.dumps(result) if result is not None else None,
741
+ duration_ms, int(ok), error),
742
+ )
743
+
744
+ def event_log_record_patch(
745
+ self,
746
+ event_id: str,
747
+ file_path: str,
748
+ patch_text: str,
749
+ before_sha: str | None = None,
750
+ after_sha: str | None = None,
751
+ ) -> None:
752
+ """Insert one patch_log row with line-count computed from unified diff."""
753
+ added = max(0, patch_text.count("\n+") - patch_text.count("\n+++"))
754
+ removed = max(0, patch_text.count("\n-") - patch_text.count("\n---"))
755
+ self.execute(
756
+ """
757
+ INSERT INTO patch_log
758
+ (event_id, file_path, patch_text, before_sha, after_sha,
759
+ lines_added, lines_removed)
760
+ VALUES (?, ?, ?, ?, ?, ?, ?)
761
+ """,
762
+ (event_id, file_path, patch_text, before_sha, after_sha, added, removed),
763
+ )
764
+
765
+ def event_log_load_session(self, session_id: str) -> list[dict]:
766
+ """Load full event log for a session ordered by turn_index.
767
+
768
+ Returns list of dicts with keys: event_id, turn_index, model, provider,
769
+ tier, input_tokens, output_tokens, cached_tokens, cost_usd, user_message,
770
+ assistant_text, ts, checkpoint_id, tool_calls, patches.
771
+ """
772
+ import json as _json
773
+ events = self.execute_all(
774
+ "SELECT * FROM session_events WHERE session_id=? ORDER BY turn_index",
775
+ (session_id,),
776
+ )
777
+ result = []
778
+ for ev in events:
779
+ ev_dict = dict(ev)
780
+ tc_rows = self.execute_all(
781
+ "SELECT * FROM tool_call_log WHERE event_id=? ORDER BY call_index",
782
+ (ev["event_id"],),
783
+ )
784
+ ev_dict["tool_calls"] = [
785
+ {**dict(r),
786
+ "args": _json.loads(r["args_json"]) if r["args_json"] else {},
787
+ "result": _json.loads(r["result_json"]) if r["result_json"] else None}
788
+ for r in tc_rows
789
+ ]
790
+ ev_dict["patches"] = [
791
+ dict(r) for r in self.execute_all(
792
+ "SELECT * FROM patch_log WHERE event_id=? ORDER BY id",
793
+ (ev["event_id"],),
794
+ )
795
+ ]
796
+ result.append(ev_dict)
797
+ return result
798
+
799
+ # ------------------------------------------------------------------
800
+ # Cost events
801
+ # ------------------------------------------------------------------
802
+
803
+ def cost_event_insert(
804
+ self,
805
+ session_id: str,
806
+ provider: str,
807
+ tier: str,
808
+ input_tokens: int,
809
+ output_tokens: int,
810
+ cached_tokens: int,
811
+ cost_usd: float,
812
+ tool_calls_json: str = "{}",
813
+ event_id: str | None = None,
814
+ scope_project: str | None = None,
815
+ scope_team: str | None = None,
816
+ ) -> None:
817
+ """Append one row to the cost_events append-only ledger."""
818
+ from datetime import datetime, timezone
819
+ ts = datetime.now(timezone.utc).isoformat()
820
+ self.execute(
821
+ """
822
+ INSERT INTO cost_events
823
+ (session_id, event_id, ts, provider, tier,
824
+ input_tokens, output_tokens, cached_tokens,
825
+ tool_calls_json, cost_usd, scope_project, scope_team)
826
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
827
+ """,
828
+ (session_id, event_id, ts, provider, tier,
829
+ input_tokens, output_tokens, cached_tokens,
830
+ tool_calls_json, cost_usd, scope_project, scope_team),
831
+ )
832
+
833
+ def cost_monthly_spend(
834
+ self, project_id: str | None = None
835
+ ) -> float:
836
+ """Sum cost_usd for the rolling 30-day window ending now."""
837
+ from datetime import datetime, timedelta, timezone
838
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
839
+ if project_id:
840
+ row = self.execute_one(
841
+ """
842
+ SELECT COALESCE(SUM(ce.cost_usd), 0.0) AS total
843
+ FROM cost_events ce
844
+ JOIN sessions s ON ce.session_id = s.session_id
845
+ WHERE s.project_id = ? AND ce.ts >= ?
846
+ """,
847
+ (project_id, cutoff),
848
+ )
849
+ else:
850
+ row = self.execute_one(
851
+ "SELECT COALESCE(SUM(cost_usd), 0.0) AS total FROM cost_events WHERE ts >= ?",
852
+ (cutoff,),
853
+ )
854
+ return float(row["total"]) if row else 0.0
855
+
856
+ # ------------------------------------------------------------------
857
+ # Daemon job queue
858
+ # ------------------------------------------------------------------
859
+
860
+ def daemon_job_submit(
861
+ self, job_type: str, payload: dict, max_retries: int = 3
862
+ ) -> str:
863
+ """Insert a new pending daemon job; return job_id."""
864
+ import json as _json
865
+ import uuid as _uuid
866
+ job_id = str(_uuid.uuid4())[:8]
867
+ self.execute(
868
+ """
869
+ INSERT INTO daemon_jobs (job_id, job_type, payload_json, max_retries)
870
+ VALUES (?, ?, ?, ?)
871
+ """,
872
+ (job_id, job_type, _json.dumps(payload), max_retries),
873
+ )
874
+ return job_id
875
+
876
+ def daemon_job_claim(self, lease_secs: float = 120.0) -> "sqlite3.Row | None":
877
+ """Atomically claim the oldest pending/expired job. Returns full row or None."""
878
+ from datetime import datetime, timedelta, timezone
879
+ now = datetime.now(timezone.utc)
880
+ expires = (now + timedelta(seconds=lease_secs)).isoformat()
881
+ now_iso = now.isoformat()
882
+ try:
883
+ with self.transaction():
884
+ row = self.execute_one(
885
+ """
886
+ SELECT job_id FROM daemon_jobs
887
+ WHERE status = 'pending'
888
+ OR (status = 'claimed' AND lease_until < ?)
889
+ ORDER BY created_at ASC
890
+ LIMIT 1
891
+ """,
892
+ (now_iso,),
893
+ )
894
+ if row is None:
895
+ return None
896
+ self._conn.execute(
897
+ """
898
+ UPDATE daemon_jobs
899
+ SET status='claimed', lease_until=?, updated_at=?
900
+ WHERE job_id=?
901
+ """,
902
+ (expires, now_iso, row["job_id"]),
903
+ )
904
+ return self.execute_one(
905
+ "SELECT * FROM daemon_jobs WHERE job_id=?", (row["job_id"],)
906
+ )
907
+ except Exception as exc:
908
+ raise DatabaseError(f"daemon_job_claim failed: {exc}") from exc
909
+
910
+ def daemon_job_complete(self, job_id: str, result: dict) -> None:
911
+ """Mark job as done and store result."""
912
+ import json as _json
913
+ from datetime import datetime, timezone
914
+ self.execute(
915
+ "UPDATE daemon_jobs SET status='done', result_json=?, updated_at=? WHERE job_id=?",
916
+ (_json.dumps(result), datetime.now(timezone.utc).isoformat(), job_id),
917
+ )
918
+
919
+ def daemon_job_fail(self, job_id: str, error: str) -> None:
920
+ """Increment retry_count; promote to 'dead' when max_retries exhausted."""
921
+ from datetime import datetime, timezone
922
+ try:
923
+ with self.transaction():
924
+ row = self.execute_one(
925
+ "SELECT retry_count, max_retries FROM daemon_jobs WHERE job_id=?", (job_id,)
926
+ )
927
+ if row is None:
928
+ return
929
+ new_count = row["retry_count"] + 1
930
+ new_status = "dead" if new_count >= row["max_retries"] else "pending"
931
+ self._conn.execute(
932
+ """
933
+ UPDATE daemon_jobs
934
+ SET status=?, retry_count=?, error=?, lease_until=NULL, updated_at=?
935
+ WHERE job_id=?
936
+ """,
937
+ (new_status, new_count, error, datetime.now(timezone.utc).isoformat(), job_id),
938
+ )
939
+ except Exception as exc:
940
+ raise DatabaseError(f"daemon_job_fail failed: {exc}") from exc
941
+
942
+ def daemon_job_result(self, job_id: str) -> "dict | None":
943
+ """Return result dict for a done/dead job, or None if still pending/claimed."""
944
+ import json as _json
945
+ row = self.execute_one(
946
+ "SELECT result_json, status, error FROM daemon_jobs WHERE job_id=?", (job_id,)
947
+ )
948
+ if row is None or row["status"] not in ("done", "dead"):
949
+ return None
950
+ if row["result_json"]:
951
+ return _json.loads(row["result_json"])
952
+ return {"ok": False, "error": row["error"]}
953
+
954
+ def daemon_job_pending_count(self) -> int:
955
+ """Count pending daemon jobs."""
956
+ row = self.execute_one("SELECT COUNT(*) AS n FROM daemon_jobs WHERE status='pending'")
957
+ return int(row["n"]) if row else 0
958
+
959
+ def daemon_job_failed_count(self) -> int:
960
+ """Count dead (permanently failed) daemon jobs."""
961
+ row = self.execute_one("SELECT COUNT(*) AS n FROM daemon_jobs WHERE status='dead'")
962
+ return int(row["n"]) if row else 0
963
+
964
+ def commit(self) -> None:
965
+ self._conn.commit()
966
+
967
+ # ------------------------------------------------------------------
968
+ # BTW (out-of-band) queue
969
+ # ------------------------------------------------------------------
970
+
971
+ def btw_dequeue_pending(self, session_id: str) -> list[dict]: # type: ignore[type-arg]
972
+ """Return all unread BTW messages for *session_id*.
973
+
974
+ Does NOT mark them as read — call :meth:`btw_mark_delivered` after
975
+ injecting them into the agent transcript.
976
+
977
+ Returns:
978
+ List of dicts with keys: ``id``, ``message``, ``created_at``.
979
+ """
980
+ try:
981
+ rows = self.execute_all(
982
+ "SELECT id, message, created_at FROM btw_queue "
983
+ "WHERE session_id = ? AND read_at IS NULL "
984
+ "ORDER BY created_at ASC",
985
+ (session_id,),
986
+ )
987
+ return [dict(r) for r in rows]
988
+ except sqlite3.Error as exc:
989
+ raise DatabaseError(f"btw_dequeue_pending failed: {exc}") from exc
990
+
991
+ def btw_mark_delivered(self, ids: list[int]) -> None:
992
+ """Mark BTW messages as delivered (sets ``read_at`` to now).
993
+
994
+ Args:
995
+ ids: list of ``btw_queue.id`` values to mark.
996
+ """
997
+ if not ids:
998
+ return
999
+ from datetime import datetime, timezone
1000
+ now = datetime.now(timezone.utc).isoformat()
1001
+ placeholders = ",".join("?" * len(ids))
1002
+ try:
1003
+ self.execute(
1004
+ f"UPDATE btw_queue SET read_at = ? WHERE id IN ({placeholders})",
1005
+ (now, *ids),
1006
+ )
1007
+ except sqlite3.Error as exc:
1008
+ raise DatabaseError(f"btw_mark_delivered failed: {exc}") from exc
1009
+
1010
+
1011
+
1012
+ # --- VerificationGraph helpers (v9) ---
1013
+
1014
+ def add_edit_node(self, session_id, turn_index, file_path, patch_ref=None):
1015
+ import uuid as _u; from datetime import datetime, timezone
1016
+ from src.artifacts.verification_graph import EditNode
1017
+ nid = str(_u.uuid4()); ts = datetime.now(timezone.utc).isoformat()
1018
+ self.execute(
1019
+ "INSERT INTO edit_nodes (node_id,session_id,turn_index,file_path,patch_ref,created_at) VALUES (?,?,?,?,?,?)",
1020
+ (nid, session_id, turn_index, str(file_path), patch_ref, ts))
1021
+ return EditNode(node_id=nid, session_id=session_id, turn_index=turn_index,
1022
+ file_path=str(file_path), patch_ref=patch_ref, created_at=ts)
1023
+
1024
+ def add_evidence_node(self, node_id, kind, verdict, detail=None, tool=None, duration_ms=None):
1025
+ import json as _j, uuid as _u; from datetime import datetime, timezone
1026
+ from src.artifacts.verification_graph import EvidenceKind, EvidenceNode, Verdict
1027
+ eid = str(_u.uuid4()); ts = datetime.now(timezone.utc).isoformat()
1028
+ kv = kind.value if isinstance(kind, EvidenceKind) else str(kind)
1029
+ vv = verdict.value if isinstance(verdict, Verdict) else str(verdict)
1030
+ dj = _j.dumps(detail if isinstance(detail, dict) else {})
1031
+ self.execute(
1032
+ "INSERT INTO evidence_nodes (evidence_id,node_id,kind,verdict,detail_json,tool,duration_ms,created_at) VALUES (?,?,?,?,?,?,?,?)",
1033
+ (eid, node_id, kv, vv, dj, tool, duration_ms, ts))
1034
+ return EvidenceNode(evidence_id=eid, node_id=node_id, kind=EvidenceKind(kv),
1035
+ verdict=Verdict(vv), detail=_j.loads(dj),
1036
+ tool=tool, duration_ms=duration_ms, created_at=ts)
1037
+
1038
+ def add_graph_edge(self, from_node_id, to_node_id):
1039
+ self.execute("INSERT OR IGNORE INTO graph_edges (from_node_id,to_node_id) VALUES (?,?)",
1040
+ (from_node_id, to_node_id))
1041
+
1042
+ def get_edit_node(self, node_id):
1043
+ from src.artifacts.verification_graph import EditNode
1044
+ r = self.execute_one("SELECT * FROM edit_nodes WHERE node_id=?", (node_id,))
1045
+ if r is None: return None
1046
+ return EditNode(node_id=r["node_id"], session_id=r["session_id"],
1047
+ turn_index=r["turn_index"], file_path=r["file_path"],
1048
+ patch_ref=r["patch_ref"], created_at=r["created_at"])
1049
+
1050
+ def get_evidence_for_node(self, node_id):
1051
+ import json as _j
1052
+ from src.artifacts.verification_graph import EvidenceKind, EvidenceNode, Verdict
1053
+ rows = self.execute_all(
1054
+ "SELECT * FROM evidence_nodes WHERE node_id=? ORDER BY created_at ASC", (node_id,))
1055
+ return [EvidenceNode(evidence_id=r["evidence_id"], node_id=r["node_id"],
1056
+ kind=EvidenceKind(r["kind"]), verdict=Verdict(r["verdict"]),
1057
+ detail=_j.loads(r["detail_json"]),
1058
+ tool=r["tool"], duration_ms=r["duration_ms"],
1059
+ created_at=r["created_at"]) for r in rows]
1060
+
1061
+ def get_dependents(self, node_id):
1062
+ rows = self.execute_all(
1063
+ "SELECT to_node_id FROM graph_edges WHERE from_node_id=?", (node_id,))
1064
+ return [r["to_node_id"] for r in rows]
1065
+
1066
+ def get_edit_nodes_for_session_file(self, session_id, file_path):
1067
+ from src.artifacts.verification_graph import EditNode
1068
+ rows = self.execute_all(
1069
+ "SELECT * FROM edit_nodes WHERE session_id=? AND file_path=? ORDER BY created_at ASC",
1070
+ (session_id, str(file_path)))
1071
+ return [EditNode(node_id=r["node_id"], session_id=r["session_id"],
1072
+ turn_index=r["turn_index"], file_path=r["file_path"],
1073
+ patch_ref=r["patch_ref"], created_at=r["created_at"]) for r in rows]
1074
+
1075
+ # --- Confidence outcomes (v11 / ide-003) ---
1076
+
1077
+ def confidence_outcome_insert(
1078
+ self,
1079
+ session_id: str,
1080
+ hunk_hash: str,
1081
+ score: int,
1082
+ reasons: list,
1083
+ verified: bool = False,
1084
+ ) -> int:
1085
+ """Insert a confidence outcome record and return its row id."""
1086
+ import json as _j
1087
+ from datetime import datetime, timezone
1088
+
1089
+ now = datetime.now(timezone.utc).isoformat()
1090
+ self.execute(
1091
+ "INSERT INTO confidence_outcomes "
1092
+ "(session_id, hunk_hash, score, reasons_json, verified, created_at) "
1093
+ "VALUES (?, ?, ?, ?, ?, ?)",
1094
+ (session_id, hunk_hash, score, _j.dumps(reasons), int(verified), now),
1095
+ )
1096
+ row = self.execute_one("SELECT last_insert_rowid() AS rid")
1097
+ return int(row["rid"]) if row else -1
1098
+
1099
+ def confidence_outcomes_for_session(self, session_id: str) -> list:
1100
+ """Return all confidence outcomes for *session_id*, newest first."""
1101
+ import json as _j
1102
+
1103
+ rows = self.execute_all(
1104
+ "SELECT id, session_id, hunk_hash, score, reasons_json, verified, created_at "
1105
+ "FROM confidence_outcomes WHERE session_id = ? ORDER BY id DESC",
1106
+ (session_id,),
1107
+ )
1108
+ result = []
1109
+ for r in rows:
1110
+ result.append({
1111
+ "id": r["id"],
1112
+ "session_id": r["session_id"],
1113
+ "hunk_hash": r["hunk_hash"],
1114
+ "score": r["score"],
1115
+ "reasons": _j.loads(r["reasons_json"]),
1116
+ "verified": bool(r["verified"]),
1117
+ "created_at": r["created_at"],
1118
+ })
1119
+ return result