purra-sqlite 0.5.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.
@@ -0,0 +1,289 @@
1
+ """SQLite transactions around PurrA's canonical storage state machines."""
2
+ import asyncio
3
+ import os
4
+ from inspect import signature
5
+ from contextlib import asynccontextmanager
6
+ from dataclasses import replace
7
+ import sqlite3
8
+ import time
9
+ from uuid import uuid4
10
+
11
+ from purra.adapters.memory import InMemoryAgentAdapters
12
+ from purra.contracts import ToolHandlerResult, RunExecutionLease, RunStatus
13
+ from purra.execution.ownership import execution_owner, execution_claim
14
+ from purra.errors import ContractViolationError
15
+ from .codec import dumps, loads
16
+ from .journal import JOURNAL_FIELDS, STORAGE_VERSION, OutputJournal
17
+
18
+
19
+ _READ_METHODS = {
20
+ "runs": frozenset({"get"}),
21
+ "outputs": frozenset({"list_events", "list_root_events", "load_validated_result"}),
22
+ }
23
+ _INDEPENDENT_PORTS = frozenset({"run_tree", "artifacts", "artifact_claims", "artifact_maintenance", "long_tasks"})
24
+
25
+
26
+ def _journal_run(arguments):
27
+ for name in ("run_id", "root_run_id"):
28
+ if isinstance(arguments.get(name), str):
29
+ return arguments[name]
30
+ for name in ("draft", "spec"):
31
+ run_id = getattr(arguments.get(name), "run_id", None)
32
+ if isinstance(run_id, str):
33
+ return run_id
34
+ drafts = arguments.get("drafts")
35
+ if isinstance(drafts, (tuple, list)) and drafts:
36
+ ids = {getattr(draft, "run_id", None) for draft in drafts}
37
+ if len(ids) == 1 and isinstance(next(iter(ids)), str):
38
+ return next(iter(ids))
39
+ return None
40
+
41
+
42
+ class _Port:
43
+ def __init__(self, store, name):
44
+ self.store, self.name = store, name
45
+ template = getattr(InMemoryAgentAdapters(), name)
46
+ self._methods = {key for key in dir(template) if not key.startswith("_") and callable(getattr(template, key))}
47
+ self._signatures = {key: signature(getattr(template, key)) for key in self._methods} if name in ("runs", "outputs") else {}
48
+
49
+ def __getattr__(self, method):
50
+ if method not in self._methods: raise AttributeError(method)
51
+ async def call(*args, **kwargs):
52
+ if self.name == "outputs" and method in ("list_events", "list_root_events"):
53
+ return await getattr(self.store, "_" + method)(*args, **kwargs)
54
+ read_only = method in _READ_METHODS.get(self.name, ())
55
+ arguments = self._signatures[method].bind(*args, **kwargs).arguments if method in self._signatures else {}
56
+ journal_run_id = _journal_run(arguments)
57
+ stream_id = arguments.get("output_stream_id")
58
+ async with self.store._transaction(read_only=read_only, with_journal=self.name not in _INDEPENDENT_PORTS, journal_run_id=journal_run_id, journal_stream_id=stream_id, lazy_journal=not read_only) as adapters:
59
+ if self.name in ("runs", "outputs") and method not in ("get", "list_events", "list_root_events", "load_validated_result"):
60
+ first = args[0] if args else None
61
+ stream = adapters.runs._state.streams.get(stream_id) if isinstance(stream_id, str) else None
62
+ run_id = journal_run_id or (stream.spec.run_id if stream is not None else None)
63
+ if run_id is None:
64
+ run_id = first if isinstance(first, str) else getattr(first, "run_id", None)
65
+ if run_id:
66
+ self.store._guard(run_id)
67
+ return await getattr(getattr(adapters, self.name), method)(*args, **kwargs)
68
+ return call
69
+
70
+
71
+ class _Publisher:
72
+ def __init__(self, store): self.store = store
73
+
74
+ async def publish_committed(self, event):
75
+ rows = await self.store.outputs.list_events(event.run_id, after_sequence=event.sequence - 1, limit=1)
76
+ if not rows or rows[0] != event:
77
+ raise ValueError("only persisted output can be published")
78
+
79
+ async def wait_for_sequence(self, run_id, *, after_sequence):
80
+ while not await self.store.outputs.list_events(run_id, after_sequence=after_sequence, limit=1):
81
+ await asyncio.sleep(0.05)
82
+
83
+
84
+ class _Idempotency:
85
+ def __init__(self, store): self.store = store
86
+
87
+ async def execute_once(self, run_id, tool_call, operation):
88
+ key = (run_id, tool_call.id)
89
+ async with self.store._transaction(with_journal=False) as adapters:
90
+ state = adapters.runs._state
91
+ receipt = state.tool_receipts.get(key)
92
+ if receipt is not None:
93
+ if receipt[0] != tool_call: raise ValueError("tool_idempotency_conflict")
94
+ return replace(receipt[1], from_cache=True)
95
+ claimed = self.store._claims.get(key)
96
+ if claimed is not None:
97
+ raise ContractViolationError("Reconcile the previous tool attempt before retrying", code="tool_effect_unknown")
98
+ self.store._claims[key] = tool_call
99
+ # External work is never performed while holding a SQLite transaction.
100
+ result = await operation()
101
+ if not isinstance(result, ToolHandlerResult): raise TypeError("invalid tool result")
102
+ async with self.store._transaction(with_journal=False) as adapters:
103
+ adapters.runs._state.tool_receipts[key] = (tool_call, result)
104
+ del self.store._claims[key]
105
+ return result
106
+
107
+
108
+ class SqliteAgentAdapters:
109
+ """Scoped, restartable Run/output, tree, Artifact and Long Task adapters.
110
+
111
+ All state-machine mutations commit atomically. Each namespace is intended for
112
+ a bounded local project; use separate scopes for independent projects.
113
+ """
114
+ def __init__(self, path, *, scope, busy_timeout=5):
115
+ if not isinstance(scope, str) or not scope.strip(): raise ValueError("scope is required")
116
+ self.scope, self.busy_timeout = scope, busy_timeout
117
+ if os.fspath(path) != ":memory:":
118
+ try: os.close(os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
119
+ except FileExistsError: pass
120
+ self._lock = asyncio.Lock()
121
+ self._db = sqlite3.connect(path, timeout=0, isolation_level=None)
122
+ self._db.execute("PRAGMA journal_mode=WAL")
123
+ self._db.execute("PRAGMA synchronous=FULL")
124
+ self._db.execute("PRAGMA foreign_keys=ON")
125
+ self._db.execute("CREATE TABLE IF NOT EXISTS purra_state (scope TEXT NOT NULL, sdk TEXT NOT NULL, version INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(scope,sdk))")
126
+ self._journal = OutputJournal(self._db, scope)
127
+ self._claims = {}
128
+ self._leases = {}
129
+ self.extra = {}
130
+ for name in ("runs", "outputs", "run_tree", "artifacts", "artifact_claims", "artifact_maintenance", "long_tasks"):
131
+ setattr(self, name, _Port(self, name))
132
+ self.publisher = _Publisher(self)
133
+ self.idempotency = _Idempotency(self)
134
+ self.leases = _Leases(self)
135
+
136
+ def _guard(self, run_id):
137
+ lease = self._leases.get(run_id)
138
+ owner = execution_owner.get()
139
+ claim = execution_claim.get()
140
+ if lease is not None and owner is not None and (
141
+ lease.owner_id != owner or (lease.expires_at_ms or 0) <= int(time.time() * 1000)
142
+ or (claim is not None and claim[0] == run_id and claim[2] != lease.attempt)
143
+ ):
144
+ raise ContractViolationError("Execution lease was lost", code="run_lease_lost")
145
+
146
+ def transaction(self):
147
+ return self._transaction()
148
+
149
+ @asynccontextmanager
150
+ async def _connection(self, *, read_only=False):
151
+ async with self._lock:
152
+ deadline = time.monotonic() + self.busy_timeout
153
+ while True:
154
+ try:
155
+ self._db.execute("BEGIN" if read_only else "BEGIN IMMEDIATE")
156
+ break
157
+ except sqlite3.OperationalError as error:
158
+ if "locked" not in str(error) or time.monotonic() >= deadline: raise
159
+ await asyncio.sleep(0.01)
160
+ try:
161
+ yield
162
+ self._db.execute("COMMIT")
163
+ except BaseException:
164
+ self._db.execute("ROLLBACK")
165
+ raise
166
+
167
+ @asynccontextmanager
168
+ async def _transaction(self, *, read_only=False, with_journal=True, journal_run_id=None, journal_stream_id=None, lazy_journal=False):
169
+ async with self._connection(read_only=read_only):
170
+ adapters = InMemoryAgentAdapters()
171
+ groups = {
172
+ "run": (adapters.runs._state, {"lock", "changed", "tool_inflight", "run_tree_authority"}),
173
+ "tree": (adapters.run_tree, {"_lock", "_clock_ms"}),
174
+ "artifact": (adapters.artifacts, {"_lock", "_clock_ms", "_run_is_available"}),
175
+ "task": (adapters.long_tasks, {"_lock", "_clock_ms"}),
176
+ }
177
+ row = self._db.execute("SELECT version,body FROM purra_state WHERE scope=? AND sdk='python'", (self.scope,)).fetchone()
178
+ if row:
179
+ if row[0] != STORAGE_VERSION: raise ValueError("unsupported SQLite storage version")
180
+ saved = loads(row[1])
181
+ for name, (obj, excluded) in groups.items():
182
+ for key, value in saved[name].items():
183
+ if key in excluded or key not in vars(obj) or (name == "run" and key in JOURNAL_FIELDS): raise ValueError("invalid storage field")
184
+ setattr(obj, key, value)
185
+ self._claims = saved["claims"]
186
+ self._leases = saved.get("leases", {})
187
+ self.extra = saved.get("extra", {})
188
+ else:
189
+ self._claims = {}
190
+ self._leases = {}
191
+ self.extra = {}
192
+ if with_journal:
193
+ stream = adapters.runs._state.streams.get(journal_stream_id) if isinstance(journal_stream_id, str) else None
194
+ if journal_run_id is None and stream is not None:
195
+ journal_run_id = stream.spec.run_id
196
+ record = adapters.runs._state.runs.get(journal_run_id)
197
+ root_run_id = (record.params.root_run_id or journal_run_id) if record is not None else None
198
+ self._journal.restore(adapters.runs._state, root_run_id=root_run_id, lazy=lazy_journal)
199
+ prior_sequences = dict(adapters.runs._state.sequences)
200
+ prior_runs = set(adapters.runs._state.runs)
201
+ yield adapters
202
+ if not read_only:
203
+ saved = {name: {k: v for k, v in vars(obj).items() if k not in excluded and not (name == "run" and k in JOURNAL_FIELDS)}
204
+ for name, (obj, excluded) in groups.items()}
205
+ saved["claims"] = self._claims
206
+ saved["leases"] = self._leases
207
+ saved["extra"] = self.extra
208
+ # Snapshot writes scale with project history.
209
+ body = dumps(saved)
210
+ if row is None or row[1] != body:
211
+ self._db.execute("INSERT INTO purra_state VALUES(?, 'python', 3, ?) ON CONFLICT(scope,sdk) DO UPDATE SET version=excluded.version,body=excluded.body", (self.scope, body))
212
+ if with_journal:
213
+ self._journal.append(adapters.runs._state, prior_sequences, prior_runs)
214
+
215
+ async def _list_events(self, run_id, *, after_sequence, limit=200):
216
+ async with self._connection(read_only=True):
217
+ return self._journal.read(run_id, after_sequence, limit)
218
+
219
+ async def _list_root_events(self, root_run_id, *, after_root_sequence, limit=200):
220
+ async with self._connection(read_only=True):
221
+ return self._journal.read(root_run_id, after_root_sequence, limit, root=True)
222
+
223
+ async def reconcile_tool(self, run_id, tool_call, *, result=None, not_executed=False):
224
+ if (result is None) == (not_executed is False): raise ValueError("supply result or proof of non-execution")
225
+ async with self._transaction(with_journal=False) as adapters:
226
+ key = (run_id, tool_call.id)
227
+ if self._claims.get(key) != tool_call: raise ValueError("tool_claim_conflict")
228
+ if result is not None:
229
+ if not isinstance(result, ToolHandlerResult): raise TypeError("invalid tool result")
230
+ adapters.runs._state.tool_receipts[key] = (tool_call, result)
231
+ del self._claims[key]
232
+
233
+ async def list_running(self):
234
+ async with self._transaction(read_only=True, with_journal=False) as adapters:
235
+ return tuple(key for key, run in adapters.runs._state.runs.items() if run.status.value == "running")
236
+
237
+ def close(self):
238
+ if self._lock.locked(): raise RuntimeError("storage transaction is active")
239
+ self._db.close()
240
+
241
+
242
+ __all__ = ["SqliteAgentAdapters"]
243
+
244
+
245
+ class _Leases:
246
+ def __init__(self, store): self.store = store
247
+
248
+ async def get(self, run_id):
249
+ async with self.store._transaction(read_only=True, with_journal=False) as adapters:
250
+ run = adapters.runs._state.runs.get(run_id)
251
+ if run is None: return None
252
+ return replace(self.store._leases.get(run_id, RunExecutionLease(run_id, run.status)), status=run.status)
253
+
254
+ async def claim(self, run_id, owner_id, *, lease_duration_ms):
255
+ if not owner_id or lease_duration_ms <= 0: raise ValueError("invalid lease")
256
+ async with self.store.transaction() as adapters:
257
+ now = int(time.time() * 1000)
258
+ run = adapters.runs._state.runs[run_id]
259
+ old = self.store._leases.get(run_id, RunExecutionLease(run_id, run.status))
260
+ if run.status is not RunStatus.RUNNING or old.cancellation_requested_at_ms is not None: return False
261
+ if old.owner_id is not None and (old.expires_at_ms or 0) > now: return False
262
+ if run.execution_checkpoint is not None and len(run.model_attempt_ids) != run.checkpoint_attempt_count:
263
+ raise ContractViolationError("The last model/tool attempt needs reconciliation", code="run_recovery_requires_reconciliation")
264
+ self.store._leases[run_id] = replace(old, owner_id=owner_id, expires_at_ms=now + lease_duration_ms, heartbeat_at_ms=now, attempt=old.attempt + 1)
265
+ return True
266
+
267
+ async def renew(self, run_id, owner_id, *, lease_duration_ms):
268
+ async with self.store._transaction(with_journal=False):
269
+ now = int(time.time() * 1000)
270
+ old = self.store._leases.get(run_id)
271
+ if old is None or old.owner_id != owner_id or (old.expires_at_ms or 0) <= now: return False
272
+ self.store._leases[run_id] = replace(old, expires_at_ms=now + lease_duration_ms, heartbeat_at_ms=now)
273
+ return True
274
+
275
+ async def release(self, run_id, owner_id):
276
+ async with self.store._transaction(with_journal=False):
277
+ old = self.store._leases.get(run_id)
278
+ if old is None or old.owner_id != owner_id: return False
279
+ self.store._leases[run_id] = replace(old, owner_id=None, expires_at_ms=None)
280
+ return True
281
+
282
+ async def request_cancellation(self, run_id):
283
+ async with self.store._transaction(with_journal=False) as adapters:
284
+ run = adapters.runs._state.runs.get(run_id)
285
+ if run is None or run.status is not RunStatus.RUNNING: return False
286
+ old = self.store._leases.get(run_id, RunExecutionLease(run_id, run.status))
287
+ if old.cancellation_requested_at_ms is not None: return False
288
+ self.store._leases[run_id] = replace(old, cancellation_requested_at_ms=int(time.time() * 1000))
289
+ return True
purra_sqlite/codec.py ADDED
@@ -0,0 +1,63 @@
1
+ """Data-only encoding of the version-pinned Core storage records."""
2
+ from collections.abc import Mapping, Sequence
3
+ from dataclasses import fields, is_dataclass
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ import json
7
+ import math
8
+ import sys
9
+
10
+
11
+ def _types():
12
+ # Only already imported Core record classes are allowed; data cannot import code.
13
+ return {f"{value.__module__}.{value.__qualname__}": value
14
+ for name, module in tuple(sys.modules.items()) if name.startswith("purra.")
15
+ for value in tuple(vars(module).values())
16
+ if isinstance(value, type) and value.__module__.startswith("purra.")
17
+ and (is_dataclass(value) or issubclass(value, Enum))}
18
+
19
+
20
+ def encode(value):
21
+ if isinstance(value, Enum):
22
+ return ["enum", f"{type(value).__module__}.{type(value).__qualname__}", value.value]
23
+ if value is None or type(value) in (str, int, bool):
24
+ return ["value", value]
25
+ if type(value) is float and math.isfinite(value):
26
+ return ["value", value]
27
+ if isinstance(value, datetime):
28
+ return ["datetime", value.isoformat()]
29
+ if is_dataclass(value) and not isinstance(value, type):
30
+ return ["record", f"{type(value).__module__}.{type(value).__qualname__}",
31
+ {f.name: encode(getattr(value, f.name)) for f in fields(value) if f.init}]
32
+ if isinstance(value, Mapping):
33
+ return ["map", [[encode(k), encode(v)] for k, v in value.items()]]
34
+ if isinstance(value, (list, tuple, set, frozenset)):
35
+ return [type(value).__name__, [encode(v) for v in value]]
36
+ if isinstance(value, Sequence):
37
+ return ["list", [encode(v) for v in value]]
38
+ raise TypeError(f"Unsupported durable value: {type(value).__name__}")
39
+
40
+
41
+ def dumps(value):
42
+ return json.dumps(encode(value), ensure_ascii=False, allow_nan=False, separators=(",", ":"))
43
+
44
+
45
+ def loads(text):
46
+ return next(load_many((text,)))
47
+
48
+
49
+ def load_many(texts):
50
+ registry = None
51
+ def decode(row):
52
+ kind = row[0]
53
+ if kind == "value": return row[1]
54
+ if kind == "datetime": return datetime.fromisoformat(row[1])
55
+ if kind == "enum": return registry[row[1]](row[2])
56
+ if kind == "record": return registry[row[1]](**{k: decode(v) for k, v in row[2].items()})
57
+ if kind == "map": return {decode(k): decode(v) for k, v in row[1]}
58
+ constructors = {"list": list, "tuple": tuple, "set": set, "frozenset": frozenset}
59
+ return constructors[kind](decode(v) for v in row[1])
60
+ for text in texts:
61
+ if registry is None:
62
+ registry = _types()
63
+ yield decode(json.loads(text))
@@ -0,0 +1,231 @@
1
+ """Indexed canonical output journal, committed with the execution snapshot."""
2
+
3
+ import operator
4
+ from collections.abc import Sequence
5
+ from itertools import tee
6
+
7
+ from purra.errors import ContractViolationError
8
+ from purra.output.contracts import AgentOutputEvent
9
+ from .codec import dumps, load_many, loads
10
+
11
+ STORAGE_VERSION = 3
12
+ JOURNAL_FIELDS = {"output_events", "root_output_events", "events_by_source_key"}
13
+
14
+
15
+ def _validate_row(event, row):
16
+ if (not isinstance(event, AgentOutputEvent)
17
+ or (event.run_id, event.root_run_id, event.sequence, event.root_sequence) != row[:4]):
18
+ raise ValueError("invalid output journal identity or sequence")
19
+ return event
20
+
21
+
22
+ def _load_rows(rows):
23
+ metadata, bodies = tee(rows)
24
+ for row, event in zip(metadata, load_many(row[4] for row in bodies), strict=True):
25
+ yield _validate_row(event, row)
26
+
27
+
28
+ class _BufferedEvents(Sequence):
29
+ """Transaction-local history with an append buffer and deferred evidence reads."""
30
+
31
+ def __init__(self, count, load):
32
+ self.count, self.load = count, load
33
+ self.pending = []
34
+ self.history = None
35
+
36
+ def __len__(self):
37
+ return self.count + len(self.pending)
38
+
39
+ def _history(self):
40
+ if self.history is None:
41
+ self.history = tuple(self.load())
42
+ if len(self.history) != self.count:
43
+ raise ValueError("incomplete output journal")
44
+ return self.history
45
+
46
+ def __iter__(self):
47
+ yield from self._history()
48
+ yield from self.pending
49
+
50
+ def __getitem__(self, index):
51
+ if isinstance(index, slice):
52
+ start, stop, step = index.indices(len(self))
53
+ if step > 0 and start >= self.count:
54
+ return self.pending[start - self.count:stop - self.count:step]
55
+ return tuple(self)[index]
56
+ index = operator.index(index)
57
+ if index < 0:
58
+ index += len(self)
59
+ if index < 0 or index >= len(self):
60
+ raise IndexError(index)
61
+ return self.pending[index - self.count] if index >= self.count else self._history()[index]
62
+
63
+ def append(self, event):
64
+ self.pending.append(event)
65
+
66
+
67
+ class _SourceEvents(dict):
68
+ def __init__(self, journal):
69
+ super().__init__()
70
+ self.journal = journal
71
+
72
+ def __missing__(self, key):
73
+ event = self._load(key)
74
+ if event is None:
75
+ raise KeyError(key)
76
+ return event
77
+
78
+ def _load(self, key):
79
+ row = self.journal.db.execute(
80
+ "SELECT run_id,root_run_id,sequence,root_sequence,body FROM purra_output_events WHERE scope=? AND sdk='python' AND json_extract(body, '$[2].source_event_key[1]')=?",
81
+ (self.journal.scope, key),
82
+ ).fetchone()
83
+ if row is None:
84
+ return None
85
+ event = _validate_row(loads(row[4]), row)
86
+ if event.source_event_key != key:
87
+ raise ValueError("invalid output journal source key")
88
+ self[key] = event
89
+ return event
90
+
91
+ def get(self, key, default=None):
92
+ if key in self:
93
+ return self[key]
94
+ event = self._load(key)
95
+ return default if event is None else event
96
+
97
+
98
+ class OutputJournal:
99
+ def __init__(self, db, scope):
100
+ self.db, self.scope = db, scope
101
+ db.execute("""CREATE TABLE IF NOT EXISTS purra_journal_runs (
102
+ scope TEXT NOT NULL, sdk TEXT NOT NULL, run_id TEXT NOT NULL,
103
+ root_run_id TEXT NOT NULL, PRIMARY KEY(scope,sdk,run_id),
104
+ FOREIGN KEY(scope,sdk) REFERENCES purra_state(scope,sdk) ON DELETE CASCADE)""")
105
+ db.execute("""CREATE TABLE IF NOT EXISTS purra_output_events (
106
+ scope TEXT NOT NULL, sdk TEXT NOT NULL, run_id TEXT NOT NULL,
107
+ sequence INTEGER NOT NULL, root_run_id TEXT NOT NULL,
108
+ root_sequence INTEGER NOT NULL, body TEXT NOT NULL,
109
+ PRIMARY KEY(scope,sdk,run_id,sequence),
110
+ UNIQUE(scope,sdk,root_run_id,root_sequence),
111
+ FOREIGN KEY(scope,sdk,run_id) REFERENCES purra_journal_runs(scope,sdk,run_id)
112
+ ON DELETE CASCADE)""")
113
+ db.execute("""CREATE UNIQUE INDEX IF NOT EXISTS purra_python_output_source
114
+ ON purra_output_events(scope, json_extract(body, '$[2].source_event_key[1]'))
115
+ WHERE sdk='python'""")
116
+ db.execute("""CREATE INDEX IF NOT EXISTS purra_output_sequence_cover
117
+ ON purra_output_events(scope,sdk,root_run_id,run_id,sequence,root_sequence)""")
118
+ db.execute("""CREATE INDEX IF NOT EXISTS purra_journal_roots
119
+ ON purra_journal_runs(scope,sdk,root_run_id,run_id)""")
120
+
121
+ def restore(self, state, *, root_run_id=None, lazy=False):
122
+ if lazy and root_run_id is not None:
123
+ self._restore_deferred(state, root_run_id)
124
+ return
125
+ if root_run_id is not None:
126
+ state.events_by_source_key = _SourceEvents(self)
127
+ rows = self.db.execute(
128
+ "SELECT run_id,root_run_id,sequence,root_sequence,body FROM purra_output_events WHERE scope=? AND sdk='python'"
129
+ + (" AND root_run_id=?" if root_run_id is not None else "")
130
+ + " ORDER BY root_run_id,root_sequence",
131
+ (self.scope, root_run_id) if root_run_id is not None else (self.scope,),
132
+ )
133
+ for event in _load_rows(rows):
134
+ if event.run_id not in state.runs:
135
+ raise ValueError("output journal references a missing Run")
136
+ if event.sequence != len(state.output_events.get(event.run_id, ())) + 1 or event.root_sequence != len(state.root_output_events.get(event.root_run_id, ())) + 1:
137
+ raise ValueError("invalid output journal sequence")
138
+ state.output_events.setdefault(event.run_id, []).append(event)
139
+ state.root_output_events.setdefault(event.root_run_id, []).append(event)
140
+ state.events_by_source_key[event.source_event_key] = event
141
+ if any(len(state.output_events.get(run_id, ())) != sequence
142
+ for run_id, sequence in state.sequences.items()
143
+ if root_run_id is None or (state.runs[run_id].params.root_run_id or run_id) == root_run_id):
144
+ raise ValueError("incomplete output journal")
145
+
146
+ def _restore_deferred(self, state, root_run_id):
147
+ # Check sequence completeness using indexed columns before accepting writes.
148
+ # Event bodies remain in SQLite until a Core rule asks for history evidence.
149
+ rows = self.db.execute(
150
+ "SELECT run_id,COUNT(*),MIN(sequence),MAX(sequence),MIN(root_sequence),MAX(root_sequence) "
151
+ "FROM purra_output_events WHERE scope=? AND sdk='python' AND root_run_id=? GROUP BY run_id",
152
+ (self.scope, root_run_id),
153
+ ).fetchall()
154
+ counts = {}
155
+ for run_id, count, first, last, _, _ in rows:
156
+ record = state.runs.get(run_id)
157
+ if record is None or (record.params.root_run_id or run_id) != root_run_id:
158
+ raise ValueError("output journal references a missing Run or wrong Root")
159
+ if first != 1 or last != count:
160
+ raise ValueError("invalid output journal sequence")
161
+ counts[run_id] = count
162
+ total = sum(counts.values())
163
+ if (total != state.root_sequences.get(root_run_id, 0)
164
+ or (rows and (min(row[4] for row in rows) != 1 or max(row[5] for row in rows) != total))):
165
+ raise ValueError("incomplete output journal")
166
+ for run_id, record in state.runs.items():
167
+ if (record.params.root_run_id or run_id) != root_run_id:
168
+ continue
169
+ count = counts.get(run_id, 0)
170
+ if count != state.sequences.get(run_id, 0):
171
+ raise ValueError("incomplete output journal")
172
+ state.output_events[run_id] = _BufferedEvents(
173
+ count, lambda run_id=run_id: self._load_history(run_id, root_run_id),
174
+ )
175
+ state.root_output_events[root_run_id] = _BufferedEvents(
176
+ total, lambda: self._load_history(root_run_id, root_run_id, root=True),
177
+ )
178
+ state.events_by_source_key = _SourceEvents(self)
179
+
180
+ def _load_history(self, run_id, root_run_id, *, root=False):
181
+ identity, sequence = ("root_run_id", "root_sequence") if root else ("run_id", "sequence")
182
+ rows = self.db.execute(
183
+ f"SELECT run_id,root_run_id,sequence,root_sequence,body FROM purra_output_events WHERE scope=? AND sdk='python' AND {identity}=? ORDER BY {sequence}",
184
+ (self.scope, run_id),
185
+ )
186
+ for expected, event in enumerate(_load_rows(rows), 1):
187
+ if (event.root_run_id != root_run_id or (not root and event.run_id != run_id)
188
+ or (event.root_sequence if root else event.sequence) != expected):
189
+ raise ValueError("invalid output journal sequence")
190
+ yield event
191
+
192
+ def append(self, state, prior_sequences, prior_runs):
193
+ for run_id in state.runs.keys() - prior_runs:
194
+ record = state.runs[run_id]
195
+ self.db.execute(
196
+ "INSERT INTO purra_journal_runs VALUES (?, 'python', ?, ?)",
197
+ (self.scope, run_id, record.params.root_run_id or run_id),
198
+ )
199
+ for run_id, events in state.output_events.items():
200
+ self.db.executemany(
201
+ "INSERT INTO purra_output_events VALUES (?, 'python', ?, ?, ?, ?, ?)",
202
+ ((self.scope, run_id, event.sequence, event.root_run_id,
203
+ event.root_sequence, dumps(event))
204
+ for event in events[prior_sequences.get(run_id, 0):]),
205
+ )
206
+
207
+ def read(self, run_id, after, limit, *, root=False):
208
+ if after < 0:
209
+ raise ValueError("after root sequence must be non-negative" if root else "after sequence must be non-negative")
210
+ if limit <= 0:
211
+ raise ValueError("limit must be positive")
212
+ limit = operator.index(limit)
213
+ row = self.db.execute(
214
+ "SELECT version FROM purra_state WHERE scope=? AND sdk='python'", (self.scope,),
215
+ ).fetchone()
216
+ if row and row[0] != STORAGE_VERSION:
217
+ raise ValueError("unsupported SQLite storage version")
218
+ run = self.db.execute(
219
+ "SELECT root_run_id FROM purra_journal_runs WHERE scope=? AND sdk='python' AND run_id=?",
220
+ (self.scope, run_id),
221
+ ).fetchone() if row else None
222
+ if run is None:
223
+ raise ContractViolationError(f"run {run_id!r} does not exist", code="run_not_found")
224
+ if root and run[0] != run_id:
225
+ raise ContractViolationError("Root journal query requires a Root Run", code="run_scope_conflict")
226
+ identity, sequence = ("root_run_id", "root_sequence") if root else ("run_id", "sequence")
227
+ rows = self.db.execute(
228
+ f"SELECT run_id,root_run_id,sequence,root_sequence,body FROM purra_output_events WHERE scope=? AND sdk='python' AND {identity}=? AND {sequence}>? ORDER BY {sequence} LIMIT ?",
229
+ (self.scope, run_id, min(after, 2**63 - 1), min(limit, 2**63 - 1)),
230
+ )
231
+ return tuple(_load_rows(rows))
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: purra-sqlite
3
+ Version: 0.5.0
4
+ Summary: SQLite persistence for PurrA
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: purra==0.5.0
10
+ Dynamic: license-file
11
+
12
+ # purra-sqlite · Python
13
+
14
+ English | [简体中文](README.zh-CN.md)
15
+
16
+ SQLite persistence for PurrA Runs, events, operations, budgets, checkpoints, and
17
+ tool receipts. Uses Python's standard library and requires Python 3.11+.
18
+
19
+ ## Install
20
+
21
+ From the repository root:
22
+
23
+ ```sh
24
+ python -m pip install . ./integrations/sqlite/python
25
+ ```
26
+
27
+ ## Configure
28
+
29
+ Given your model `gateway` and Agent `preset`:
30
+
31
+ ```python
32
+ from purra.api import AgentCore
33
+ from purra_sqlite import SqliteAgentAdapters
34
+
35
+ storage = SqliteAgentAdapters("agent.db", scope="user-1/project-1")
36
+ core = AgentCore(
37
+ model_gateway=gateway,
38
+ preset=preset,
39
+ run_repository=storage.runs,
40
+ output_repository=storage.outputs,
41
+ output_publisher=storage.publisher,
42
+ execution_lease_store=storage.leases,
43
+ )
44
+ ```
45
+
46
+ Select `scope` from the application's authenticated user/project binding.
47
+ The bundle also exposes `idempotency`, `run_tree`, `artifacts`,
48
+ and `long_tasks` for the corresponding Core ports.
49
+
50
+ ## Recovery
51
+
52
+ Use `storage.list_running()` to find interrupted Runs and
53
+ `core.resume(run_id, request, options=...)` to resume an eligible checkpoint.
54
+ Restore the original Agent configuration. Execution leases prevent concurrent owners.
55
+
56
+ An interrupted external tool call may already have taken effect. Use
57
+ `storage.reconcile_tool(...)` with its result or evidence that it did not execute
58
+ before retrying. For persisted questions and answers, use
59
+ [SqliteClarification](../../interaction/python/README.md).
60
+
61
+ ## Storage and shutdown
62
+
63
+ Canonical output events are appended as rows with Run and Root sequence indexes.
64
+ Event additions and the execution snapshot commit in one transaction. Output
65
+ pagination and subscription polling neither load the execution snapshot nor
66
+ acquire a writer lock. Run queries, lease lookup and `list_running()` remain
67
+ read-only. Tool receipts, lease renewal/release, cancellation requests, Agent
68
+ tree, Artifact and Long Task repository operations skip journal hydration and
69
+ flushing. Writes with an identifiable Run or output stream validate the Root
70
+ tree's sequence counts in SQL and buffer new events without decoding its history.
71
+ Core rules that inspect history (including planning projections and terminal
72
+ operation settlement) load the required Run's original events on demand.
73
+ Shared budgets still use all sibling Run counters; event-key replay uses indexed
74
+ lookups. Run reads retain complete Root journal hydration.
75
+ Cross-Root event keys use an index; Python SQLite requires `json_extract`, and
76
+ opening an existing v3 database creates this index on first use. Lease acquisition,
77
+ public `transaction()` and operations without an identifiable Run still validate
78
+ the full scope. Execution snapshots retain Run history,
79
+ checkpoints and receipts and are still loaded and saved at scope granularity.
80
+ This adapter therefore still suits bounded local workloads.
81
+ Storage v3 rejects v1/v2 data without automatic migration; existing databases
82
+ cannot be resumed directly.
83
+ Python and TypeScript execution snapshots are not interchangeable.
84
+ Both SDKs defer history loading for Run-scoped writes. SQL sequence checks scan the
85
+ selected Root's covering index without fetching event body rows or sorting by
86
+ Run. Root headers also have a covering index. Existing v3 databases build these
87
+ indexes on opening; this takes time and disk space, and inserts maintain them.
88
+ Metadata snapshots remain scope-sized, so these writes are
89
+ not constant-cost. Event bodies are validated when read; lease acquisition and
90
+ public transactions continue to decode the full journal.
91
+ Body Run/Root ids and sequence values must match their SQL columns on every
92
+ event read, including indexed replay and pagination. Inconsistent rows raise
93
+ `ValueError` and roll back the current transaction; they are not automatically
94
+ repaired. Unread event bodies remain deferred.
95
+
96
+ From the repository root, measure empty output polling and tail pagination with
97
+ 100, 1,000 and 5,000 historical events:
98
+
99
+ ```sh
100
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/benchmark_reads.py
101
+ ```
102
+
103
+ This temporary-database benchmark reports warm median read latency, not
104
+ concurrent throughput or real-model end-to-end performance.
105
+
106
+ Measure tool receipt writes at the same journal sizes, including both claim and
107
+ result-commit transactions:
108
+
109
+ ```sh
110
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/benchmark_writes.py
111
+ ```
112
+
113
+ The tool callback is local and has no external side effect; this excludes real
114
+ business-tool and model latency.
115
+
116
+ Measure active Run event writes beside a growing unrelated Root:
117
+
118
+ ```sh
119
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/benchmark_run_writes.py
120
+ ```
121
+
122
+ This measures isolation from other Roots, not scaling within a single growing Root.
123
+
124
+ Measure appends, model-attempt reservations and checkpoint commits within the same
125
+ growing Root (two warmups and ten measured writes per operation):
126
+
127
+ ```sh
128
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/benchmark_execution_writes.py
129
+ ```
130
+
131
+ The history consists of private domain events. This excludes planning evidence
132
+ replay, concurrent throughput and real Provider latency.
133
+ Add `--profile` to report journal preparation, execution-state encoding/decoding
134
+ and the remaining transaction time separately. Phase medians are calculated
135
+ independently and need not sum to the total median.
136
+
137
+ The application owns database access, backups, and retention. Checkpoints contain
138
+ private model data. Call `await core.close()` before `storage.close()`.
139
+
140
+ ## Opt-in closeout verification
141
+
142
+ After building TypeScript Core and SQLite, run both SDKs through separate writer
143
+ processes, transaction termination, tool-receipt reconciliation and checkpoint
144
+ reopening. The default fixture has 20 Roots, 60 Runs, 20,000 events and 64 KiB
145
+ checkpoint messages per Root; all databases and effect markers are temporary.
146
+
147
+ ```sh
148
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/verify_load.py --output /tmp/purra-load.json
149
+ ```
150
+
151
+ `scripts/verify_provider.py` additionally runs a synthetic lookup task against a
152
+ user-selected DeepSeek configuration in a PurrTypos settings database. It requires
153
+ network access and consumes real API tokens. Supply `--config-db`, `--config-id`
154
+ and `--output`; add `.:integrations/openai/python/src` to `PYTHONPATH` and install
155
+ the OpenAI SDK. Credentials are read in memory, never written to the report.
156
+ Its explicit test transport maps `max_completion_tokens` to `max_tokens`, disables
157
+ thinking, drops OpenAI-only options, and maps `developer` messages to `system`.
158
+ This does not certify unmodified OpenAI transport compatibility with DeepSeek.
159
+ The eager reference is current code with deferred hydration disabled, not a
160
+ historical release. One paired run is functional evidence, not a latency SLA.
@@ -0,0 +1,8 @@
1
+ purra_sqlite/__init__.py,sha256=URe_F4wov4l-psxWF3qhnu7D1LNx-5eOLwGq2CDMH7I,15336
2
+ purra_sqlite/codec.py,sha256=brnPcjmddLNOj3AtfSvuw_8Ozcs1CDA7eIjA6bhF2uw,2608
3
+ purra_sqlite/journal.py,sha256=dFuVS-CdhMQ57z-Zg7i33yd0-VXC2jalw8dEAptcvbw,11046
4
+ purra_sqlite-0.5.0.dist-info/licenses/LICENSE,sha256=WDxSqXCQAO9tUJEUbz6KLAOUqPg28NjIiLOxRn9XySI,1065
5
+ purra_sqlite-0.5.0.dist-info/METADATA,sha256=EVO7Jc7us4Ygxw5YwZZ2X88sslUqwBC21Lxrt2HQQY0,7181
6
+ purra_sqlite-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ purra_sqlite-0.5.0.dist-info/top_level.txt,sha256=XR4yWQgf1X7cFattGsdSmMbefT-3LUFDx7o6Xk4c9sM,13
8
+ purra_sqlite-0.5.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lybrands
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ purra_sqlite