tdd-cli 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.
tddcli/ledger.py ADDED
@@ -0,0 +1,433 @@
1
+ """SQLite ledger. One per repository (R13.3), outside every worktree, never in the repo.
2
+
3
+ Invocations, transitions and events are append-only. Nothing here accepts a phase
4
+ from a caller — phases are written only by the state machine.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import socket
12
+ import sqlite3
13
+ from datetime import datetime, timedelta, timezone
14
+ from pathlib import Path
15
+
16
+ SCHEMA_VERSION = 2
17
+
18
+
19
+ class LedgerVersionError(RuntimeError):
20
+ """The ledger on disk was written by a newer tdd-cli than this one."""
21
+
22
+
23
+ #: Forward migrations, keyed by the version they upgrade *from*. Applied in order
24
+ #: after the idempotent SCHEMA script, which already creates missing tables and
25
+ #: indexes; a migration entry therefore only needs statements SCHEMA cannot express,
26
+ #: such as ALTER TABLE on an existing table. Every released schema version must have
27
+ #: an entry here (empty string when SCHEMA alone suffices), so an old ledger is
28
+ #: upgraded rather than silently run against a shape the code no longer expects.
29
+ MIGRATIONS: dict[int, str] = {
30
+ # v1 -> v2 added the baseline_claim table; CREATE TABLE IF NOT EXISTS covers it.
31
+ 1: "",
32
+ }
33
+
34
+ SCHEMA = """
35
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
36
+
37
+ CREATE TABLE IF NOT EXISTS baseline_claim (
38
+ id INTEGER PRIMARY KEY,
39
+ worktree_path TEXT NOT NULL UNIQUE,
40
+ hostname TEXT NOT NULL,
41
+ pid INTEGER NOT NULL,
42
+ projects_total INTEGER NOT NULL DEFAULT 0,
43
+ projects_done INTEGER NOT NULL DEFAULT 0,
44
+ current_project TEXT,
45
+ started_at TEXT NOT NULL
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS plan_contract (
49
+ id INTEGER PRIMARY KEY,
50
+ plan_path TEXT NOT NULL,
51
+ git_blob_sha TEXT,
52
+ git_commit TEXT,
53
+ status TEXT NOT NULL, -- declared | undeclared
54
+ declared_cycles TEXT NOT NULL, -- json
55
+ annotation_keys TEXT NOT NULL, -- json
56
+ registered_at TEXT NOT NULL
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS run (
60
+ id INTEGER PRIMARY KEY,
61
+ plan_contract_id INTEGER NOT NULL REFERENCES plan_contract(id),
62
+ executor_model TEXT NOT NULL,
63
+ executor_session TEXT,
64
+ executor_source TEXT NOT NULL, -- transcript | human | unknown
65
+ worktree_path TEXT NOT NULL,
66
+ started_at TEXT NOT NULL,
67
+ ended_at TEXT,
68
+ outcome TEXT, -- complete | blocked | abandoned
69
+ allow_dirty INTEGER NOT NULL DEFAULT 0,
70
+ preexisting_dirty TEXT NOT NULL, -- json: excluded from authorship forever (R9.21)
71
+ config_sha TEXT -- tdd.toml as of run start; drift is an event
72
+ );
73
+
74
+ CREATE TABLE IF NOT EXISTS baseline (
75
+ id INTEGER PRIMARY KEY,
76
+ run_id INTEGER NOT NULL REFERENCES run(id),
77
+ project TEXT NOT NULL,
78
+ failing TEXT NOT NULL, -- json
79
+ captured_at TEXT NOT NULL
80
+ );
81
+
82
+ CREATE TABLE IF NOT EXISTS collection_snapshot (
83
+ id INTEGER PRIMARY KEY,
84
+ run_id INTEGER NOT NULL REFERENCES run(id),
85
+ project TEXT NOT NULL,
86
+ tests TEXT NOT NULL, -- json list
87
+ failed_files TEXT NOT NULL, -- json map path -> error
88
+ captured_at TEXT NOT NULL
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS cycle (
92
+ id INTEGER PRIMARY KEY,
93
+ run_id INTEGER NOT NULL REFERENCES run(id),
94
+ ordinal INTEGER NOT NULL,
95
+ kind TEXT NOT NULL, -- standard | pin | contract
96
+ projects TEXT NOT NULL, -- json list
97
+ declared_tests TEXT NOT NULL, -- json list
98
+ target_tests TEXT NOT NULL, -- json list (adopted; may differ, R8.9)
99
+ phase TEXT NOT NULL,
100
+ head_at_open TEXT NOT NULL,
101
+ title TEXT,
102
+ opened_at TEXT NOT NULL,
103
+ closed_at TEXT,
104
+ skip_reason TEXT
105
+ );
106
+
107
+ CREATE TABLE IF NOT EXISTS invocation (
108
+ id INTEGER PRIMARY KEY,
109
+ run_id INTEGER NOT NULL REFERENCES run(id),
110
+ cycle_id INTEGER REFERENCES cycle(id),
111
+ phase_at TEXT NOT NULL,
112
+ project TEXT NOT NULL,
113
+ adapter TEXT NOT NULL,
114
+ target_test TEXT,
115
+ target_outcome TEXT,
116
+ target_failure TEXT,
117
+ total_passed INTEGER NOT NULL DEFAULT 0,
118
+ total_failed INTEGER NOT NULL DEFAULT 0,
119
+ other_failures TEXT NOT NULL, -- json, baseline-subtracted
120
+ duration_ms INTEGER NOT NULL DEFAULT 0,
121
+ retried INTEGER NOT NULL DEFAULT 0,
122
+ tree_hash TEXT,
123
+ started_at TEXT NOT NULL
124
+ );
125
+
126
+ CREATE TABLE IF NOT EXISTS gate_result (
127
+ id INTEGER PRIMARY KEY,
128
+ run_id INTEGER NOT NULL REFERENCES run(id),
129
+ cycle_id INTEGER REFERENCES cycle(id),
130
+ project TEXT NOT NULL,
131
+ kind TEXT NOT NULL, -- lint | typecheck
132
+ ok INTEGER NOT NULL,
133
+ output TEXT,
134
+ at TEXT NOT NULL
135
+ );
136
+
137
+ CREATE TABLE IF NOT EXISTS transition (
138
+ id INTEGER PRIMARY KEY,
139
+ cycle_id INTEGER NOT NULL REFERENCES cycle(id),
140
+ from_phase TEXT NOT NULL,
141
+ to_phase TEXT NOT NULL,
142
+ invocation_id INTEGER REFERENCES invocation(id),
143
+ at TEXT NOT NULL
144
+ );
145
+
146
+ CREATE TABLE IF NOT EXISTS annotation (
147
+ id INTEGER PRIMARY KEY,
148
+ run_id INTEGER NOT NULL REFERENCES run(id),
149
+ cycle_id INTEGER REFERENCES cycle(id),
150
+ key TEXT NOT NULL,
151
+ value TEXT NOT NULL,
152
+ at TEXT NOT NULL
153
+ );
154
+
155
+ CREATE TABLE IF NOT EXISTS integrity_event (
156
+ id INTEGER PRIMARY KEY,
157
+ run_id INTEGER NOT NULL REFERENCES run(id),
158
+ cycle_id INTEGER REFERENCES cycle(id),
159
+ kind TEXT NOT NULL,
160
+ detail TEXT,
161
+ at TEXT NOT NULL
162
+ );
163
+
164
+ CREATE TABLE IF NOT EXISTS blocker (
165
+ id INTEGER PRIMARY KEY,
166
+ run_id INTEGER NOT NULL REFERENCES run(id),
167
+ cycle_id INTEGER REFERENCES cycle(id),
168
+ kind TEXT NOT NULL,
169
+ detail TEXT,
170
+ at TEXT NOT NULL
171
+ );
172
+
173
+ CREATE TABLE IF NOT EXISTS sensitivity_check (
174
+ id INTEGER PRIMARY KEY,
175
+ cycle_id INTEGER NOT NULL REFERENCES cycle(id),
176
+ reference_diff TEXT NOT NULL,
177
+ reference_untracked TEXT NOT NULL,
178
+ mutation_diff TEXT,
179
+ observed_failure TEXT,
180
+ restored_ok INTEGER,
181
+ opened_at TEXT NOT NULL,
182
+ closed_at TEXT
183
+ );
184
+
185
+ CREATE TABLE IF NOT EXISTS commit_record (
186
+ id INTEGER PRIMARY KEY,
187
+ run_id INTEGER NOT NULL REFERENCES run(id),
188
+ cycle_id INTEGER REFERENCES cycle(id),
189
+ phase TEXT NOT NULL,
190
+ sha TEXT NOT NULL,
191
+ message TEXT NOT NULL,
192
+ files TEXT NOT NULL,
193
+ at TEXT NOT NULL
194
+ );
195
+
196
+ CREATE TABLE IF NOT EXISTS artifact_check (
197
+ id INTEGER PRIMARY KEY,
198
+ run_id INTEGER NOT NULL REFERENCES run(id),
199
+ cycle_id INTEGER REFERENCES cycle(id),
200
+ artifact TEXT NOT NULL,
201
+ stale INTEGER NOT NULL,
202
+ regenerated INTEGER NOT NULL DEFAULT 0,
203
+ at TEXT NOT NULL
204
+ );
205
+
206
+ CREATE TABLE IF NOT EXISTS human_intervention (
207
+ id INTEGER PRIMARY KEY,
208
+ run_id INTEGER NOT NULL REFERENCES run(id),
209
+ note TEXT NOT NULL,
210
+ at TEXT NOT NULL
211
+ );
212
+
213
+ CREATE INDEX IF NOT EXISTS idx_cycle_run ON cycle(run_id);
214
+ CREATE INDEX IF NOT EXISTS idx_inv_cycle ON invocation(cycle_id);
215
+ """
216
+
217
+
218
+ def now() -> str:
219
+ return datetime.now(timezone.utc).isoformat()
220
+
221
+
222
+ def ledger_path(repo_path: Path) -> Path:
223
+ base = os.environ.get("TDD_LEDGER_HOME")
224
+ root = Path(base) if base else Path.home() / ".local" / "share" / "tdd-cli"
225
+ slug = str(repo_path).replace(os.sep, "-").strip("-")
226
+ root.mkdir(parents=True, exist_ok=True)
227
+ return root / f"{slug}.sqlite3"
228
+
229
+
230
+ class Ledger:
231
+ def __init__(self, repo_path: Path):
232
+ self.repo_path = repo_path
233
+ self.path = ledger_path(repo_path)
234
+ # A generous busy timeout: two `run start` calls against one worktree open
235
+ # separate connections and both write (claim, then run/baseline rows).
236
+ # SQLite's default 5s timeout can be exceeded while one holds the write lock
237
+ # through a real baseline probe (subprocess pytest/vitest calls), surfacing
238
+ # as `sqlite3.OperationalError: database is locked` instead of the intended
239
+ # `IntegrityError` rejection path.
240
+ self.db = sqlite3.connect(self.path, timeout=30.0)
241
+ self.db.row_factory = sqlite3.Row
242
+ self.db.execute("PRAGMA journal_mode=WAL")
243
+ self.db.execute("PRAGMA foreign_keys=ON")
244
+ stored = self._stored_version()
245
+ if stored is not None and stored > SCHEMA_VERSION:
246
+ self.db.close()
247
+ raise LedgerVersionError(
248
+ f"ledger {self.path} has schema version {stored}, but this tdd-cli"
249
+ f" understands up to {SCHEMA_VERSION} — it was written by a newer"
250
+ " tdd-cli. Upgrade tdd-cli; do not downgrade the ledger."
251
+ )
252
+ self.db.executescript(SCHEMA)
253
+ while stored is not None and stored < SCHEMA_VERSION:
254
+ self.db.executescript(MIGRATIONS[stored])
255
+ stored += 1
256
+ self.db.execute(
257
+ "INSERT INTO meta(key, value) VALUES ('schema_version', ?)"
258
+ " ON CONFLICT(key) DO UPDATE SET value = excluded.value",
259
+ (str(SCHEMA_VERSION),),
260
+ )
261
+ self.db.commit()
262
+
263
+ def _stored_version(self) -> int | None:
264
+ """The schema version already on disk, or None for a fresh database."""
265
+ try:
266
+ row = self.db.execute(
267
+ "SELECT value FROM meta WHERE key = 'schema_version'"
268
+ ).fetchone()
269
+ except sqlite3.OperationalError: # no meta table: fresh database
270
+ return None
271
+ return int(row[0]) if row else None
272
+
273
+ # -- generic helpers -------------------------------------------------
274
+
275
+ def _write(self, sql: str, params: tuple) -> sqlite3.Cursor:
276
+ """Every write goes through here, so none can strand the write lock.
277
+
278
+ Python's sqlite3 module does not roll back a failed statement, so a
279
+ constraint violation — the `baseline_claim.worktree_path` UNIQUE violation
280
+ that *is* the claim's lock, among others — leaves this connection's implicit
281
+ transaction open. An unrolled-back writer holds SQLite's write lock until the
282
+ connection is garbage collected, starving concurrent writers well past any
283
+ reasonable busy timeout. The claim mechanism is designed around failed writes
284
+ being cheap and side-effect-free, so this must hold on every path, not just
285
+ the one that happened to be exercised under load.
286
+ """
287
+ try:
288
+ cur = self.db.execute(sql, params)
289
+ self.db.commit()
290
+ return cur
291
+ except Exception:
292
+ self.db.rollback()
293
+ raise
294
+
295
+ def insert(self, table: str, **cols) -> int:
296
+ keys = ", ".join(cols)
297
+ marks = ", ".join("?" for _ in cols)
298
+ return self._write(
299
+ f"INSERT INTO {table} ({keys}) VALUES ({marks})", tuple(cols.values())
300
+ ).lastrowid
301
+
302
+ def one(self, sql: str, params: tuple = ()) -> sqlite3.Row | None:
303
+ return self.db.execute(sql, params).fetchone()
304
+
305
+ def all(self, sql: str, params: tuple = ()) -> list[sqlite3.Row]:
306
+ return self.db.execute(sql, params).fetchall()
307
+
308
+ def update(self, table: str, row_id: int, **cols) -> None:
309
+ sets = ", ".join(f"{k} = ?" for k in cols)
310
+ self._write(
311
+ f"UPDATE {table} SET {sets} WHERE id = ?", (*cols.values(), row_id)
312
+ )
313
+
314
+ # -- domain queries --------------------------------------------------
315
+
316
+ def active_run(self, worktree: str) -> sqlite3.Row | None:
317
+ return self.one(
318
+ "SELECT * FROM run WHERE worktree_path = ? AND ended_at IS NULL"
319
+ " ORDER BY id DESC LIMIT 1",
320
+ (worktree,),
321
+ )
322
+
323
+ def open_cycle(self, run_id: int) -> sqlite3.Row | None:
324
+ return self.one(
325
+ "SELECT * FROM cycle WHERE run_id = ? AND closed_at IS NULL"
326
+ " ORDER BY ordinal LIMIT 1",
327
+ (run_id,),
328
+ )
329
+
330
+ def cycles(self, run_id: int) -> list[sqlite3.Row]:
331
+ return self.all("SELECT * FROM cycle WHERE run_id = ? ORDER BY ordinal", (run_id,))
332
+
333
+ def baselines(self, run_id: int) -> dict[str, set[str]]:
334
+ rows = self.all("SELECT project, failing FROM baseline WHERE run_id = ?", (run_id,))
335
+ return {r["project"]: set(json.loads(r["failing"])) for r in rows}
336
+
337
+ def collection(self, run_id: int) -> dict[str, set[str]]:
338
+ rows = self.all(
339
+ "SELECT project, tests FROM collection_snapshot WHERE run_id = ?", (run_id,)
340
+ )
341
+ return {r["project"]: set(json.loads(r["tests"])) for r in rows}
342
+
343
+ def invocations(self, cycle_id: int, phase: str | None = None) -> list[sqlite3.Row]:
344
+ if phase:
345
+ return self.all(
346
+ "SELECT * FROM invocation WHERE cycle_id = ? AND phase_at = ? ORDER BY id",
347
+ (cycle_id, phase),
348
+ )
349
+ return self.all("SELECT * FROM invocation WHERE cycle_id = ? ORDER BY id", (cycle_id,))
350
+
351
+ def open_sensitivity(self, cycle_id: int) -> sqlite3.Row | None:
352
+ return self.one(
353
+ "SELECT * FROM sensitivity_check WHERE cycle_id = ? AND closed_at IS NULL"
354
+ " ORDER BY id DESC LIMIT 1",
355
+ (cycle_id,),
356
+ )
357
+
358
+ def completed_sensitivity(self, cycle_id: int) -> sqlite3.Row | None:
359
+ return self.one(
360
+ "SELECT * FROM sensitivity_check WHERE cycle_id = ? AND restored_ok = 1"
361
+ " ORDER BY id DESC LIMIT 1",
362
+ (cycle_id,),
363
+ )
364
+
365
+ def event(self, run_id: int, cycle_id: int | None, kind: str, detail: str = "") -> None:
366
+ self.insert(
367
+ "integrity_event",
368
+ run_id=run_id,
369
+ cycle_id=cycle_id,
370
+ kind=kind,
371
+ detail=detail,
372
+ at=now(),
373
+ )
374
+
375
+ # -- baseline claim ----------------------------------------------------
376
+
377
+ def claim(self, worktree: str, hostname: str, pid: int, projects_total: int) -> int:
378
+ """Insert the claim row. The insert is the lock: `worktree_path`
379
+ carries `UNIQUE`, so a second claim on the same worktree raises
380
+ `sqlite3.IntegrityError` rather than racing a read-then-write check."""
381
+ return self.insert(
382
+ "baseline_claim",
383
+ worktree_path=worktree,
384
+ hostname=hostname,
385
+ pid=pid,
386
+ projects_total=projects_total,
387
+ projects_done=0,
388
+ current_project=None,
389
+ started_at=now(),
390
+ )
391
+
392
+ def release_claim(self, worktree: str) -> None:
393
+ self.db.execute("DELETE FROM baseline_claim WHERE worktree_path = ?", (worktree,))
394
+ self.db.commit()
395
+
396
+ def update_claim(self, worktree: str, projects_done: int, current_project: str) -> None:
397
+ """Counters and progress only — no per-project timing history, that lives in
398
+ the stderr heartbeat lines."""
399
+ self.db.execute(
400
+ "UPDATE baseline_claim SET projects_done = ?, current_project = ?"
401
+ " WHERE worktree_path = ?",
402
+ (projects_done, current_project, worktree),
403
+ )
404
+ self.db.commit()
405
+
406
+ def active_claim(self, worktree: str) -> dict | None:
407
+ """Read-only, per the store's append-only contract — `cmd_progress` and
408
+ `cmd_status` call this as pure observers. Only `cmd_run_start` acts on the
409
+ computed `stale` flag (release + reclaim); nothing here deletes a row."""
410
+ row = self.one("SELECT * FROM baseline_claim WHERE worktree_path = ?", (worktree,))
411
+ if row is None:
412
+ return None
413
+ claim = dict(row)
414
+ if claim["hostname"] == socket.gethostname():
415
+ try:
416
+ os.kill(claim["pid"], 0)
417
+ stale = False
418
+ except ProcessLookupError:
419
+ # Same host, pid no longer running (e.g. a `SIGKILL`ed `run start`).
420
+ stale = True
421
+ except PermissionError:
422
+ # Pid exists but is owned by someone else — alive.
423
+ stale = False
424
+ else:
425
+ # A pid is meaningless from another host, and reused pids would make a
426
+ # host-crossing liveness check actively wrong. Fall back to age: a false
427
+ # "alive" bricks the worktree, a false "dead" reopens the bug (Decisions).
428
+ started = datetime.fromisoformat(claim["started_at"])
429
+ if started.tzinfo is None:
430
+ started = started.replace(tzinfo=timezone.utc)
431
+ stale = datetime.now(timezone.utc) - started > timedelta(minutes=60)
432
+ claim["stale"] = stale
433
+ return claim