delimit-cli 4.4.0 → 4.5.1

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.
@@ -0,0 +1,412 @@
1
+ """SQLite draft registry — LED-1129 Phase 1 PR-2.
2
+
3
+ Two-table shape (per the deliberation):
4
+
5
+ drafts — durable state machine. One row per draft_id.
6
+ Columns mirror SignedDraft + status + lifecycle timestamps.
7
+ attempts — execution history. One row per execute attempt against a
8
+ draft_id. Forensics for failures + replay-detection.
9
+
10
+ State transitions are enforced atomically via SQLite transactions. The
11
+ schema layer (schema.py) owns the cryptography; this layer owns the
12
+ durable state. The executor (Phase 2) consumes from this layer.
13
+
14
+ Crash semantics: a row at status='executing' after a process restart
15
+ surfaces for human reconciliation — we do NOT auto-retry. That's the
16
+ at-most-once contract.
17
+
18
+ Concurrency: SQLite WAL mode + UPDATE ... WHERE status=? gives us atomic
19
+ state transitions without explicit file locking. Multiple readers OK;
20
+ the executor takes a row by transitioning approved→executing in a single
21
+ UPDATE that returns rowcount.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import sqlite3
28
+ import time
29
+ from contextlib import contextmanager
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any, Dict, Iterator, List, Optional
33
+
34
+ from ai.inbox_drafts.schema import (
35
+ DEFAULT_TTL_SECONDS,
36
+ DraftStatus,
37
+ SignedDraft,
38
+ )
39
+
40
+ DEFAULT_DB_PATH = Path.home() / ".delimit" / "drafts.db"
41
+
42
+ # Schema version for the SQLite tables themselves. Distinct from the
43
+ # draft schema_version (the JSON contract) — this one tracks DB migrations.
44
+ DB_SCHEMA_VERSION = 1
45
+
46
+
47
+ def _resolve_db_path(db_path: Optional[Path]) -> Path:
48
+ """Resolve db_path arg, reading module default at call time.
49
+
50
+ Reading at call time (rather than as a default-arg) lets tests
51
+ monkeypatch `ai.inbox_drafts.registry.DEFAULT_DB_PATH` and have the
52
+ change propagate. With default-arg capture, the value is bound at
53
+ function-definition time and monkeypatching is invisible.
54
+ """
55
+ if db_path is not None:
56
+ return db_path
57
+ import ai.inbox_drafts.registry as _self
58
+ return _self.DEFAULT_DB_PATH
59
+
60
+
61
+ # ── Migrations ────────────────────────────────────────────────────────
62
+
63
+
64
+ _MIGRATIONS = [
65
+ # v1: initial schema
66
+ """
67
+ CREATE TABLE IF NOT EXISTS drafts (
68
+ draft_id TEXT PRIMARY KEY,
69
+ draft_kind TEXT NOT NULL,
70
+ target_json TEXT NOT NULL,
71
+ payload_json TEXT NOT NULL,
72
+ issued_at INTEGER NOT NULL,
73
+ key_version INTEGER NOT NULL,
74
+ schema_version TEXT NOT NULL,
75
+ content_hash TEXT NOT NULL,
76
+ signature TEXT NOT NULL,
77
+ status TEXT NOT NULL,
78
+ led_ref TEXT,
79
+ approval_subject TEXT,
80
+ executed_url TEXT,
81
+ last_error TEXT,
82
+ created_at INTEGER NOT NULL,
83
+ updated_at INTEGER NOT NULL,
84
+ completed_at INTEGER
85
+ );
86
+
87
+ CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts(status);
88
+ CREATE INDEX IF NOT EXISTS idx_drafts_issued_at ON drafts(issued_at);
89
+ CREATE INDEX IF NOT EXISTS idx_drafts_led_ref ON drafts(led_ref);
90
+
91
+ CREATE TABLE IF NOT EXISTS attempts (
92
+ attempt_id INTEGER PRIMARY KEY AUTOINCREMENT,
93
+ draft_id TEXT NOT NULL,
94
+ kind TEXT NOT NULL, -- "verify" | "execute"
95
+ outcome TEXT NOT NULL, -- "ok" | "failed" | "skipped"
96
+ reason TEXT,
97
+ executed_url TEXT,
98
+ attempted_at INTEGER NOT NULL,
99
+ FOREIGN KEY (draft_id) REFERENCES drafts(draft_id)
100
+ );
101
+
102
+ CREATE INDEX IF NOT EXISTS idx_attempts_draft_id ON attempts(draft_id);
103
+
104
+ CREATE TABLE IF NOT EXISTS db_meta (
105
+ key TEXT PRIMARY KEY,
106
+ value TEXT NOT NULL
107
+ );
108
+ """,
109
+ ]
110
+
111
+
112
+ def _open(db_path: Path) -> sqlite3.Connection:
113
+ db_path.parent.mkdir(parents=True, exist_ok=True)
114
+ conn = sqlite3.connect(str(db_path), isolation_level=None, timeout=10.0)
115
+ conn.row_factory = sqlite3.Row
116
+ # WAL gives us cleaner concurrent-reader semantics than the default
117
+ # rollback journal. busy_timeout makes blocked writers wait briefly
118
+ # instead of immediately raising — keeps the executor's poll loop
119
+ # robust against transient daemon writes.
120
+ conn.execute("PRAGMA journal_mode=WAL")
121
+ conn.execute("PRAGMA busy_timeout=5000")
122
+ conn.execute("PRAGMA foreign_keys=ON")
123
+ return conn
124
+
125
+
126
+ def migrate(db_path: Optional[Path] = None) -> int:
127
+ """Apply pending migrations. Returns the resulting DB schema version.
128
+
129
+ Idempotent — running again on an up-to-date DB is a no-op.
130
+ """
131
+ db_path = _resolve_db_path(db_path)
132
+ conn = _open(db_path)
133
+ try:
134
+ # Bootstrap meta table so we can read the version.
135
+ conn.executescript(
136
+ "CREATE TABLE IF NOT EXISTS db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);"
137
+ )
138
+ cur = conn.execute("SELECT value FROM db_meta WHERE key = 'db_schema_version'")
139
+ row = cur.fetchone()
140
+ current = int(row["value"]) if row else 0
141
+ for i, sql in enumerate(_MIGRATIONS, start=1):
142
+ if i > current:
143
+ conn.executescript(sql)
144
+ conn.execute(
145
+ "INSERT OR REPLACE INTO db_meta (key, value) VALUES (?, ?)",
146
+ ("db_schema_version", str(i)),
147
+ )
148
+ return DB_SCHEMA_VERSION
149
+ finally:
150
+ conn.close()
151
+
152
+
153
+ # ── DAO ───────────────────────────────────────────────────────────────
154
+
155
+
156
+ @dataclass
157
+ class DraftRow:
158
+ draft_id: str
159
+ draft_kind: str
160
+ target: Dict[str, Any]
161
+ payload: Any
162
+ issued_at: int
163
+ key_version: int
164
+ schema_version: str
165
+ content_hash: str
166
+ signature: str
167
+ status: str
168
+ led_ref: Optional[str]
169
+ approval_subject: Optional[str]
170
+ executed_url: Optional[str]
171
+ last_error: Optional[str]
172
+ created_at: int
173
+ updated_at: int
174
+ completed_at: Optional[int]
175
+
176
+ @classmethod
177
+ def from_sqlite_row(cls, row: sqlite3.Row) -> "DraftRow":
178
+ return cls(
179
+ draft_id=row["draft_id"],
180
+ draft_kind=row["draft_kind"],
181
+ target=json.loads(row["target_json"]),
182
+ payload=json.loads(row["payload_json"]),
183
+ issued_at=row["issued_at"],
184
+ key_version=row["key_version"],
185
+ schema_version=row["schema_version"],
186
+ content_hash=row["content_hash"],
187
+ signature=row["signature"],
188
+ status=row["status"],
189
+ led_ref=row["led_ref"],
190
+ approval_subject=row["approval_subject"],
191
+ executed_url=row["executed_url"],
192
+ last_error=row["last_error"],
193
+ created_at=row["created_at"],
194
+ updated_at=row["updated_at"],
195
+ completed_at=row["completed_at"],
196
+ )
197
+
198
+ def to_signed_dict(self) -> Dict[str, Any]:
199
+ """Return only the fields that are part of the HMAC scope.
200
+
201
+ Used by the executor to re-verify the signature before acting.
202
+ """
203
+ return {
204
+ "draft_id": self.draft_id,
205
+ "draft_kind": self.draft_kind,
206
+ "target": self.target,
207
+ "payload": self.payload,
208
+ "issued_at": self.issued_at,
209
+ "key_version": self.key_version,
210
+ "schema_version": self.schema_version,
211
+ "content_hash": self.content_hash,
212
+ "signature": self.signature,
213
+ }
214
+
215
+
216
+ @contextmanager
217
+ def connection(db_path: Optional[Path] = None) -> Iterator[sqlite3.Connection]:
218
+ """Context-managed connection. Ensures migrations are applied first."""
219
+ db_path = _resolve_db_path(db_path)
220
+ migrate(db_path)
221
+ conn = _open(db_path)
222
+ try:
223
+ yield conn
224
+ finally:
225
+ conn.close()
226
+
227
+
228
+ def insert_draft(
229
+ signed: SignedDraft,
230
+ *,
231
+ led_ref: Optional[str] = None,
232
+ db_path: Optional[Path] = None,
233
+ ) -> None:
234
+ """Insert a freshly-signed draft in PENDING state.
235
+
236
+ Raises sqlite3.IntegrityError if the draft_id already exists — by
237
+ construction (ULID) this only happens on real ID collision (~impossible)
238
+ or replay attempt with the same id, both of which we want to refuse.
239
+ """
240
+ now = int(time.time())
241
+ with connection(db_path) as conn:
242
+ conn.execute(
243
+ """
244
+ INSERT INTO drafts (
245
+ draft_id, draft_kind, target_json, payload_json, issued_at,
246
+ key_version, schema_version, content_hash, signature, status,
247
+ led_ref, created_at, updated_at
248
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
249
+ """,
250
+ (
251
+ signed.draft_id,
252
+ signed.draft_kind,
253
+ json.dumps(signed.target, sort_keys=True),
254
+ json.dumps(signed.payload, sort_keys=True),
255
+ signed.issued_at,
256
+ signed.key_version,
257
+ signed.schema_version,
258
+ signed.content_hash,
259
+ signed.signature,
260
+ DraftStatus.PENDING.value,
261
+ led_ref,
262
+ now,
263
+ now,
264
+ ),
265
+ )
266
+
267
+
268
+ def get_draft(draft_id: str, db_path: Optional[Path] = None) -> Optional[DraftRow]:
269
+ with connection(db_path) as conn:
270
+ row = conn.execute(
271
+ "SELECT * FROM drafts WHERE draft_id = ?",
272
+ (draft_id,),
273
+ ).fetchone()
274
+ return DraftRow.from_sqlite_row(row) if row else None
275
+
276
+
277
+ def find_draft_by_led_ref(led_ref: str, db_path: Optional[Path] = None) -> List[DraftRow]:
278
+ """Return drafts associated with a given LED reference.
279
+
280
+ Used by the executor when matching founder Ship-it replies whose
281
+ subject line carries an [LED-XXXX] tag.
282
+ """
283
+ with connection(db_path) as conn:
284
+ rows = conn.execute(
285
+ "SELECT * FROM drafts WHERE led_ref = ? ORDER BY created_at DESC",
286
+ (led_ref,),
287
+ ).fetchall()
288
+ return [DraftRow.from_sqlite_row(r) for r in rows]
289
+
290
+
291
+ def transition(
292
+ draft_id: str,
293
+ *,
294
+ expected: str,
295
+ new: str,
296
+ db_path: Optional[Path] = None,
297
+ approval_subject: Optional[str] = None,
298
+ executed_url: Optional[str] = None,
299
+ last_error: Optional[str] = None,
300
+ completed: bool = False,
301
+ ) -> bool:
302
+ """Atomically move a draft from `expected` → `new`.
303
+
304
+ Returns True iff the transition occurred (the row was in `expected`
305
+ state at the moment of the UPDATE). Returns False otherwise — the
306
+ caller did not win the race or the row is in a different state.
307
+
308
+ This is the at-most-once primitive: the executor calls
309
+ transition(approved → executing) before any side effect; the
310
+ rowcount tells it whether it owns the action.
311
+ """
312
+ now = int(time.time())
313
+ with connection(db_path) as conn:
314
+ cur = conn.execute(
315
+ """
316
+ UPDATE drafts SET
317
+ status = ?,
318
+ approval_subject = COALESCE(?, approval_subject),
319
+ executed_url = COALESCE(?, executed_url),
320
+ last_error = COALESCE(?, last_error),
321
+ completed_at = CASE WHEN ? = 1 THEN ? ELSE completed_at END,
322
+ updated_at = ?
323
+ WHERE draft_id = ? AND status = ?
324
+ """,
325
+ (
326
+ new,
327
+ approval_subject,
328
+ executed_url,
329
+ last_error,
330
+ 1 if completed else 0,
331
+ now,
332
+ now,
333
+ draft_id,
334
+ expected,
335
+ ),
336
+ )
337
+ return cur.rowcount == 1
338
+
339
+
340
+ def expire_pending(
341
+ db_path: Optional[Path] = None,
342
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
343
+ ) -> int:
344
+ """Mark pending drafts older than TTL as EXPIRED.
345
+
346
+ Returns the count expired. Idempotent.
347
+ """
348
+ now = int(time.time())
349
+ cutoff = now - ttl_seconds
350
+ with connection(db_path) as conn:
351
+ cur = conn.execute(
352
+ """
353
+ UPDATE drafts SET status = ?, updated_at = ?
354
+ WHERE status = ? AND issued_at < ?
355
+ """,
356
+ (DraftStatus.EXPIRED.value, now, DraftStatus.PENDING.value, cutoff),
357
+ )
358
+ return cur.rowcount
359
+
360
+
361
+ def record_attempt(
362
+ draft_id: str,
363
+ *,
364
+ kind: str,
365
+ outcome: str,
366
+ reason: Optional[str] = None,
367
+ executed_url: Optional[str] = None,
368
+ db_path: Optional[Path] = None,
369
+ ) -> int:
370
+ """Append an attempt row. Returns the new attempt_id.
371
+
372
+ `kind`: "verify" | "execute"
373
+ `outcome`: "ok" | "failed" | "skipped"
374
+ """
375
+ now = int(time.time())
376
+ with connection(db_path) as conn:
377
+ cur = conn.execute(
378
+ """
379
+ INSERT INTO attempts (draft_id, kind, outcome, reason, executed_url, attempted_at)
380
+ VALUES (?, ?, ?, ?, ?, ?)
381
+ """,
382
+ (draft_id, kind, outcome, reason, executed_url, now),
383
+ )
384
+ return cur.lastrowid
385
+
386
+
387
+ def list_attempts(draft_id: str, db_path: Optional[Path] = None) -> List[Dict[str, Any]]:
388
+ with connection(db_path) as conn:
389
+ rows = conn.execute(
390
+ "SELECT * FROM attempts WHERE draft_id = ? ORDER BY attempt_id ASC",
391
+ (draft_id,),
392
+ ).fetchall()
393
+ return [dict(r) for r in rows]
394
+
395
+
396
+ def list_drafts(
397
+ status: Optional[str] = None,
398
+ limit: int = 50,
399
+ db_path: Optional[Path] = None,
400
+ ) -> List[DraftRow]:
401
+ with connection(db_path) as conn:
402
+ if status:
403
+ rows = conn.execute(
404
+ "SELECT * FROM drafts WHERE status = ? ORDER BY created_at DESC LIMIT ?",
405
+ (status, limit),
406
+ ).fetchall()
407
+ else:
408
+ rows = conn.execute(
409
+ "SELECT * FROM drafts ORDER BY created_at DESC LIMIT ?",
410
+ (limit,),
411
+ ).fetchall()
412
+ return [DraftRow.from_sqlite_row(r) for r in rows]