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.
- purra_sqlite/__init__.py +289 -0
- purra_sqlite/codec.py +63 -0
- purra_sqlite/journal.py +231 -0
- purra_sqlite-0.5.0.dist-info/METADATA +160 -0
- purra_sqlite-0.5.0.dist-info/RECORD +8 -0
- purra_sqlite-0.5.0.dist-info/WHEEL +5 -0
- purra_sqlite-0.5.0.dist-info/licenses/LICENSE +21 -0
- purra_sqlite-0.5.0.dist-info/top_level.txt +1 -0
purra_sqlite/__init__.py
ADDED
|
@@ -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))
|
purra_sqlite/journal.py
ADDED
|
@@ -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,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
|