purra-sqlite 0.5.0__tar.gz

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,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,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,149 @@
1
+ # purra-sqlite · Python
2
+
3
+ English | [简体中文](README.zh-CN.md)
4
+
5
+ SQLite persistence for PurrA Runs, events, operations, budgets, checkpoints, and
6
+ tool receipts. Uses Python's standard library and requires Python 3.11+.
7
+
8
+ ## Install
9
+
10
+ From the repository root:
11
+
12
+ ```sh
13
+ python -m pip install . ./integrations/sqlite/python
14
+ ```
15
+
16
+ ## Configure
17
+
18
+ Given your model `gateway` and Agent `preset`:
19
+
20
+ ```python
21
+ from purra.api import AgentCore
22
+ from purra_sqlite import SqliteAgentAdapters
23
+
24
+ storage = SqliteAgentAdapters("agent.db", scope="user-1/project-1")
25
+ core = AgentCore(
26
+ model_gateway=gateway,
27
+ preset=preset,
28
+ run_repository=storage.runs,
29
+ output_repository=storage.outputs,
30
+ output_publisher=storage.publisher,
31
+ execution_lease_store=storage.leases,
32
+ )
33
+ ```
34
+
35
+ Select `scope` from the application's authenticated user/project binding.
36
+ The bundle also exposes `idempotency`, `run_tree`, `artifacts`,
37
+ and `long_tasks` for the corresponding Core ports.
38
+
39
+ ## Recovery
40
+
41
+ Use `storage.list_running()` to find interrupted Runs and
42
+ `core.resume(run_id, request, options=...)` to resume an eligible checkpoint.
43
+ Restore the original Agent configuration. Execution leases prevent concurrent owners.
44
+
45
+ An interrupted external tool call may already have taken effect. Use
46
+ `storage.reconcile_tool(...)` with its result or evidence that it did not execute
47
+ before retrying. For persisted questions and answers, use
48
+ [SqliteClarification](../../interaction/python/README.md).
49
+
50
+ ## Storage and shutdown
51
+
52
+ Canonical output events are appended as rows with Run and Root sequence indexes.
53
+ Event additions and the execution snapshot commit in one transaction. Output
54
+ pagination and subscription polling neither load the execution snapshot nor
55
+ acquire a writer lock. Run queries, lease lookup and `list_running()` remain
56
+ read-only. Tool receipts, lease renewal/release, cancellation requests, Agent
57
+ tree, Artifact and Long Task repository operations skip journal hydration and
58
+ flushing. Writes with an identifiable Run or output stream validate the Root
59
+ tree's sequence counts in SQL and buffer new events without decoding its history.
60
+ Core rules that inspect history (including planning projections and terminal
61
+ operation settlement) load the required Run's original events on demand.
62
+ Shared budgets still use all sibling Run counters; event-key replay uses indexed
63
+ lookups. Run reads retain complete Root journal hydration.
64
+ Cross-Root event keys use an index; Python SQLite requires `json_extract`, and
65
+ opening an existing v3 database creates this index on first use. Lease acquisition,
66
+ public `transaction()` and operations without an identifiable Run still validate
67
+ the full scope. Execution snapshots retain Run history,
68
+ checkpoints and receipts and are still loaded and saved at scope granularity.
69
+ This adapter therefore still suits bounded local workloads.
70
+ Storage v3 rejects v1/v2 data without automatic migration; existing databases
71
+ cannot be resumed directly.
72
+ Python and TypeScript execution snapshots are not interchangeable.
73
+ Both SDKs defer history loading for Run-scoped writes. SQL sequence checks scan the
74
+ selected Root's covering index without fetching event body rows or sorting by
75
+ Run. Root headers also have a covering index. Existing v3 databases build these
76
+ indexes on opening; this takes time and disk space, and inserts maintain them.
77
+ Metadata snapshots remain scope-sized, so these writes are
78
+ not constant-cost. Event bodies are validated when read; lease acquisition and
79
+ public transactions continue to decode the full journal.
80
+ Body Run/Root ids and sequence values must match their SQL columns on every
81
+ event read, including indexed replay and pagination. Inconsistent rows raise
82
+ `ValueError` and roll back the current transaction; they are not automatically
83
+ repaired. Unread event bodies remain deferred.
84
+
85
+ From the repository root, measure empty output polling and tail pagination with
86
+ 100, 1,000 and 5,000 historical events:
87
+
88
+ ```sh
89
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/benchmark_reads.py
90
+ ```
91
+
92
+ This temporary-database benchmark reports warm median read latency, not
93
+ concurrent throughput or real-model end-to-end performance.
94
+
95
+ Measure tool receipt writes at the same journal sizes, including both claim and
96
+ result-commit transactions:
97
+
98
+ ```sh
99
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/benchmark_writes.py
100
+ ```
101
+
102
+ The tool callback is local and has no external side effect; this excludes real
103
+ business-tool and model latency.
104
+
105
+ Measure active Run event writes beside a growing unrelated Root:
106
+
107
+ ```sh
108
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/benchmark_run_writes.py
109
+ ```
110
+
111
+ This measures isolation from other Roots, not scaling within a single growing Root.
112
+
113
+ Measure appends, model-attempt reservations and checkpoint commits within the same
114
+ growing Root (two warmups and ten measured writes per operation):
115
+
116
+ ```sh
117
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/benchmark_execution_writes.py
118
+ ```
119
+
120
+ The history consists of private domain events. This excludes planning evidence
121
+ replay, concurrent throughput and real Provider latency.
122
+ Add `--profile` to report journal preparation, execution-state encoding/decoding
123
+ and the remaining transaction time separately. Phase medians are calculated
124
+ independently and need not sum to the total median.
125
+
126
+ The application owns database access, backups, and retention. Checkpoints contain
127
+ private model data. Call `await core.close()` before `storage.close()`.
128
+
129
+ ## Opt-in closeout verification
130
+
131
+ After building TypeScript Core and SQLite, run both SDKs through separate writer
132
+ processes, transaction termination, tool-receipt reconciliation and checkpoint
133
+ reopening. The default fixture has 20 Roots, 60 Runs, 20,000 events and 64 KiB
134
+ checkpoint messages per Root; all databases and effect markers are temporary.
135
+
136
+ ```sh
137
+ PYTHONPATH=src:integrations/sqlite/python/src .venv/bin/python integrations/sqlite/python/scripts/verify_load.py --output /tmp/purra-load.json
138
+ ```
139
+
140
+ `scripts/verify_provider.py` additionally runs a synthetic lookup task against a
141
+ user-selected DeepSeek configuration in a PurrTypos settings database. It requires
142
+ network access and consumes real API tokens. Supply `--config-db`, `--config-id`
143
+ and `--output`; add `.:integrations/openai/python/src` to `PYTHONPATH` and install
144
+ the OpenAI SDK. Credentials are read in memory, never written to the report.
145
+ Its explicit test transport maps `max_completion_tokens` to `max_tokens`, disables
146
+ thinking, drops OpenAI-only options, and maps `developer` messages to `system`.
147
+ This does not certify unmodified OpenAI transport compatibility with DeepSeek.
148
+ The eager reference is current code with deferred hydration disabled, not a
149
+ historical release. One paired run is functional evidence, not a latency SLA.
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "purra-sqlite"
7
+ version = "0.5.0"
8
+ description = "SQLite persistence for PurrA"
9
+ requires-python = ">=3.11"
10
+ dependencies = ["purra==0.5.0"]
11
+ readme = "README.md"
12
+ license = "MIT"
13
+
14
+ [tool.setuptools.packages.find]
15
+ where = ["src"]
16
+
17
+ [tool.pytest.ini_options]
18
+ pythonpath = ["src", "../../../src"]
19
+ testpaths = ["tests"]
20
+ addopts = "-ra -q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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