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.
- package/CHANGELOG.md +96 -0
- package/README.md +25 -18
- package/adapters/codex-security.js +64 -0
- package/adapters/codex-skill.js +78 -0
- package/adapters/cursor-rules.js +73 -0
- package/bin/delimit-setup.js +23 -0
- package/gateway/ai/backends/governance_bridge.py +168 -2
- package/gateway/ai/backends/memory_bridge.py +218 -3
- package/gateway/ai/backends/tools_design.py +563 -83
- package/gateway/ai/backends/tools_infra.py +21 -7
- package/gateway/ai/backends/tools_real.py +3 -1
- package/gateway/ai/content_grounding/__init__.py +98 -0
- package/gateway/ai/content_grounding/build.py +350 -0
- package/gateway/ai/content_grounding/consume.py +280 -0
- package/gateway/ai/content_grounding/features.py +218 -0
- package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +9 -0
- package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +17 -0
- package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +18 -0
- package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +23 -0
- package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +16 -0
- package/gateway/ai/content_grounding/schemas/claim.schema.json +40 -0
- package/gateway/ai/content_grounding/schemas/event.schema.json +23 -0
- package/gateway/ai/content_grounding/schemas.py +276 -0
- package/gateway/ai/content_grounding/telemetry.py +221 -0
- package/gateway/ai/governance.py +89 -0
- package/gateway/ai/hot_reload.py +148 -7
- package/gateway/ai/inbox_drafts/__init__.py +61 -0
- package/gateway/ai/inbox_drafts/registry.py +412 -0
- package/gateway/ai/inbox_drafts/schema.py +374 -0
- package/gateway/ai/inbox_executor.py +565 -0
- package/gateway/ai/ledger_manager.py +1483 -25
- package/gateway/ai/license_core.py +3 -1
- package/gateway/ai/mcp_bridge.py +1 -1
- package/gateway/ai/reddit_proxy.py +8 -6
- package/gateway/ai/server.py +451 -9
- package/gateway/ai/supabase_sync.py +47 -7
- package/gateway/ai/swarm.py +1 -1
- package/gateway/ai/workers/executor.py +1 -1
- package/gateway/core/diff_engine_v2.py +45 -10
- package/gateway/core/zero_spec/express_extractor.py +1 -1
- package/lib/delimit-template.js +5 -0
- package/package.json +1 -1
package/gateway/ai/hot_reload.py
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
98
|
-
if
|
|
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
|
-
|
|
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]
|