delimit-cli 4.3.4 → 4.5.0

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 (46) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +25 -18
  3. package/adapters/codex-security.js +64 -0
  4. package/adapters/codex-skill.js +78 -0
  5. package/adapters/cursor-rules.js +73 -0
  6. package/bin/delimit-setup.js +23 -0
  7. package/gateway/ai/backends/governance_bridge.py +168 -2
  8. package/gateway/ai/backends/memory_bridge.py +218 -3
  9. package/gateway/ai/backends/tools_design.py +563 -83
  10. package/gateway/ai/backends/tools_infra.py +21 -7
  11. package/gateway/ai/backends/tools_real.py +3 -1
  12. package/gateway/ai/content_grounding/__init__.py +98 -0
  13. package/gateway/ai/content_grounding/build.py +350 -0
  14. package/gateway/ai/content_grounding/consume.py +280 -0
  15. package/gateway/ai/content_grounding/features.py +218 -0
  16. package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +9 -0
  17. package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +9 -0
  18. package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +17 -0
  19. package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +17 -0
  20. package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +17 -0
  21. package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +18 -0
  22. package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +18 -0
  23. package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +23 -0
  24. package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +16 -0
  25. package/gateway/ai/content_grounding/schemas/claim.schema.json +40 -0
  26. package/gateway/ai/content_grounding/schemas/event.schema.json +23 -0
  27. package/gateway/ai/content_grounding/schemas.py +276 -0
  28. package/gateway/ai/content_grounding/telemetry.py +221 -0
  29. package/gateway/ai/governance.py +89 -0
  30. package/gateway/ai/hot_reload.py +148 -7
  31. package/gateway/ai/inbox_drafts/__init__.py +61 -0
  32. package/gateway/ai/inbox_drafts/registry.py +412 -0
  33. package/gateway/ai/inbox_drafts/schema.py +374 -0
  34. package/gateway/ai/inbox_executor.py +565 -0
  35. package/gateway/ai/ledger_manager.py +1483 -25
  36. package/gateway/ai/license_core.py +3 -1
  37. package/gateway/ai/mcp_bridge.py +1 -1
  38. package/gateway/ai/reddit_proxy.py +8 -6
  39. package/gateway/ai/server.py +451 -9
  40. package/gateway/ai/supabase_sync.py +47 -7
  41. package/gateway/ai/swarm.py +1 -1
  42. package/gateway/ai/workers/executor.py +1 -1
  43. package/gateway/core/diff_engine_v2.py +45 -10
  44. package/gateway/core/zero_spec/express_extractor.py +1 -1
  45. package/lib/delimit-template.js +5 -0
  46. package/package.json +1 -1
@@ -83,26 +83,151 @@ def _is_function_tool(obj: Any) -> bool:
83
83
  return cls.__module__.startswith("fastmcp.") and cls.__name__ == "FunctionTool"
84
84
 
85
85
 
86
+ def _get_tool_dict(mcp: Any) -> Optional[Dict[str, Any]]:
87
+ """Return a name → tool dict view of the live FastMCP registry.
88
+
89
+ Handles three schemas:
90
+ - fastmcp 2.x: `mcp._tool_manager._tools` keys = bare names
91
+ - fastmcp 3.x: `mcp._local_provider._components` keys = "tool:<name>@<scope>"
92
+ - any future: probe `_tools` / `tools` attrs directly
93
+
94
+ For 3.x the returned dict is a *projected view* — the keys are bare tool
95
+ names (so callers can do `name in d` against a tool name), but writes
96
+ through that view propagate to the underlying components dict using the
97
+ correct namespaced key. That keeps the hot-reload code path unchanged
98
+ across fastmcp versions.
99
+
100
+ Returns None if no compatible registry is found.
101
+ """
102
+ # 2.x path
103
+ tm = getattr(mcp, "_tool_manager", None)
104
+ if tm is not None and isinstance(getattr(tm, "_tools", None), dict):
105
+ return tm._tools # type: ignore[return-value]
106
+
107
+ # 3.x path: _local_provider._components is the live registry, but keys
108
+ # are "tool:<name>@<scope>". Wrap with a projected-name view.
109
+ lp = getattr(mcp, "_local_provider", None)
110
+ if lp is not None:
111
+ comps = getattr(lp, "_components", None)
112
+ if isinstance(comps, dict):
113
+ return _LocalProviderToolView(comps)
114
+
115
+ # Unknown schemas — try common attribute names directly
116
+ for attr in ("_tools", "tools"):
117
+ candidate = getattr(mcp, attr, None)
118
+ if isinstance(candidate, dict):
119
+ return candidate
120
+ for mgr_attr in ("_tool_manager", "tool_manager"):
121
+ mgr = getattr(mcp, mgr_attr, None)
122
+ if mgr is None:
123
+ continue
124
+ for inner in ("_tools", "tools"):
125
+ candidate = getattr(mgr, inner, None)
126
+ if isinstance(candidate, dict):
127
+ return candidate
128
+ return None
129
+
130
+
131
+ class _LocalProviderToolView(dict):
132
+ """fastmcp-3.x compatibility shim.
133
+
134
+ The 3.x `_local_provider._components` dict stores tools under keys of
135
+ the form `"tool:<name>@<scope>"`. Hot reload code expects to write
136
+ `d[name] = tool` and read `name in d` against bare tool names.
137
+
138
+ This view sits in front of the components dict and translates between
139
+ the two schemas. Reads find the matching `tool:NAME@*` key, writes
140
+ insert under `tool:NAME@<existing_scope_if_any_else_empty>`.
141
+ """
142
+
143
+ def __init__(self, backing: Dict[str, Any]):
144
+ super().__init__()
145
+ # Don't store the backing in `super()` storage; just keep a reference.
146
+ self._backing = backing
147
+
148
+ @staticmethod
149
+ def _bare_name(key: str) -> str:
150
+ # "tool:foo@scope" -> "foo"; non-tool keys ignored
151
+ if not key.startswith("tool:"):
152
+ return ""
153
+ rest = key[len("tool:"):]
154
+ return rest.split("@", 1)[0]
155
+
156
+ def _find_key(self, name: str) -> Optional[str]:
157
+ """Find the existing components key for a bare tool name."""
158
+ for k in self._backing:
159
+ if self._bare_name(k) == name:
160
+ return k
161
+ return None
162
+
163
+ def __contains__(self, name: object) -> bool: # type: ignore[override]
164
+ return isinstance(name, str) and self._find_key(name) is not None
165
+
166
+ def __getitem__(self, name: str) -> Any:
167
+ k = self._find_key(name)
168
+ if k is None:
169
+ raise KeyError(name)
170
+ return self._backing[k]
171
+
172
+ def __setitem__(self, name: str, value: Any) -> None:
173
+ existing = self._find_key(name)
174
+ if existing is not None:
175
+ # Replace in place — preserves any scope suffix the original used.
176
+ self._backing[existing] = value
177
+ else:
178
+ self._backing[f"tool:{name}@"] = value
179
+
180
+ def __delitem__(self, name: str) -> None:
181
+ k = self._find_key(name)
182
+ if k is None:
183
+ raise KeyError(name)
184
+ del self._backing[k]
185
+
186
+ def __iter__(self):
187
+ for k in self._backing:
188
+ bn = self._bare_name(k)
189
+ if bn:
190
+ yield bn
191
+
192
+ def __len__(self) -> int:
193
+ return sum(1 for k in self._backing if k.startswith("tool:"))
194
+
195
+
86
196
  def register_module_tools(mcp: Any, module: Any) -> List[str]:
87
- """Walk a module's globals and register every FunctionTool against the live mcp.
197
+ """Walk a module's globals and ensure every decorated tool is in the live mcp.
198
+
199
+ Two schemas in play:
200
+
201
+ fastmcp 2.x — `@mcp.tool()` wraps the decorated function as a
202
+ FunctionTool instance and replaces the module global.
203
+ We find them in `vars(module)` via `_is_function_tool`
204
+ and write them into the live registry dict.
205
+
206
+ fastmcp 3.x — `@mcp.tool()` registers the tool with the server at
207
+ decoration time and leaves the module global as a plain
208
+ function. By the time `register_module_tools` is called,
209
+ the registration has ALREADY happened. Our job is just
210
+ to enumerate the resulting tool names.
88
211
 
89
- Returns the list of tool keys registered. Existing tools with the same
90
- key are *replaced* — that lets edits to a tool's metadata or schema
91
- take effect without a restart.
212
+ Returns the list of tool keys registered.
92
213
  """
93
214
  if mcp is None or module is None:
94
215
  return []
95
216
  registered: List[str] = []
96
217
  try:
97
- tool_manager = getattr(mcp, "_tool_manager", None)
98
- if tool_manager is None or not hasattr(tool_manager, "_tools"):
218
+ tool_dict = _get_tool_dict(mcp)
219
+ if tool_dict is None:
99
220
  return []
221
+
222
+ # 2.x path: explicit FunctionTool instances in the module globals
223
+ any_function_tool_found = False
100
224
  for name, value in list(vars(module).items()):
101
225
  if not _is_function_tool(value):
102
226
  continue
227
+ any_function_tool_found = True
103
228
  try:
104
229
  key = getattr(value, "key", name)
105
- tool_manager._tools[key] = value
230
+ tool_dict[key] = value
106
231
  registered.append(key)
107
232
  except Exception as e:
108
233
  _log({
@@ -111,6 +236,22 @@ def register_module_tools(mcp: Any, module: Any) -> List[str]:
111
236
  "name": name,
112
237
  "error": str(e),
113
238
  })
239
+
240
+ # 3.x fallback: no FunctionTool in module globals; the decorator
241
+ # already registered the tools. Walk module globals for plain
242
+ # functions whose name appears in the registry.
243
+ if not any_function_tool_found:
244
+ for name, value in list(vars(module).items()):
245
+ if name.startswith("_"):
246
+ continue
247
+ if not callable(value):
248
+ continue
249
+ # Skip imports — only count things actually defined in this module
250
+ value_mod = getattr(value, "__module__", "")
251
+ if value_mod and value_mod != module.__name__:
252
+ continue
253
+ if name in tool_dict:
254
+ registered.append(name)
114
255
  except Exception as e: # noqa: BLE001
115
256
  _log({
116
257
  "event": "register_module_tools_failed",
@@ -0,0 +1,61 @@
1
+ """Inbox drafts registry — LED-1129 Phase 1.
2
+
3
+ Foundation for the autonomous-executor that closes the email→action loop.
4
+ Phase 1 (this module): schema, canonicalization, HMAC binding, SQLite registry.
5
+ NO behavior change — drafts get registered + signed; nobody consumes them yet.
6
+ Phase 2 will add the separate-process executor that reads this registry.
7
+
8
+ See docs/inbox_executor_v1.md for the canonicalization + state-machine spec.
9
+ """
10
+
11
+ from ai.inbox_drafts.schema import (
12
+ DEFAULT_TTL_SECONDS,
13
+ HMAC_KEY_PATH,
14
+ DraftKind,
15
+ DraftStatus,
16
+ SignedDraft,
17
+ canonicalize,
18
+ content_hash,
19
+ new_draft_id,
20
+ sign_draft,
21
+ verify_draft,
22
+ )
23
+ from ai.inbox_drafts.registry import (
24
+ DEFAULT_DB_PATH,
25
+ DraftRow,
26
+ expire_pending,
27
+ find_draft_by_led_ref,
28
+ get_draft,
29
+ insert_draft,
30
+ list_attempts,
31
+ list_drafts,
32
+ migrate,
33
+ record_attempt,
34
+ transition,
35
+ )
36
+
37
+ __all__ = [
38
+ # schema
39
+ "DEFAULT_TTL_SECONDS",
40
+ "HMAC_KEY_PATH",
41
+ "DraftKind",
42
+ "DraftStatus",
43
+ "SignedDraft",
44
+ "canonicalize",
45
+ "content_hash",
46
+ "new_draft_id",
47
+ "sign_draft",
48
+ "verify_draft",
49
+ # registry
50
+ "DEFAULT_DB_PATH",
51
+ "DraftRow",
52
+ "expire_pending",
53
+ "find_draft_by_led_ref",
54
+ "get_draft",
55
+ "insert_draft",
56
+ "list_attempts",
57
+ "list_drafts",
58
+ "migrate",
59
+ "record_attempt",
60
+ "transition",
61
+ ]
@@ -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]