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.
- purra_sqlite-0.5.0/LICENSE +21 -0
- purra_sqlite-0.5.0/PKG-INFO +160 -0
- purra_sqlite-0.5.0/README.md +149 -0
- purra_sqlite-0.5.0/pyproject.toml +20 -0
- purra_sqlite-0.5.0/setup.cfg +4 -0
- purra_sqlite-0.5.0/src/purra_sqlite/__init__.py +289 -0
- purra_sqlite-0.5.0/src/purra_sqlite/codec.py +63 -0
- purra_sqlite-0.5.0/src/purra_sqlite/journal.py +231 -0
- purra_sqlite-0.5.0/src/purra_sqlite.egg-info/PKG-INFO +160 -0
- purra_sqlite-0.5.0/src/purra_sqlite.egg-info/SOURCES.txt +18 -0
- purra_sqlite-0.5.0/src/purra_sqlite.egg-info/dependency_links.txt +1 -0
- purra_sqlite-0.5.0/src/purra_sqlite.egg-info/requires.txt +1 -0
- purra_sqlite-0.5.0/src/purra_sqlite.egg-info/top_level.txt +1 -0
- purra_sqlite-0.5.0/tests/test_covering_indexes.py +49 -0
- purra_sqlite-0.5.0/tests/test_deferred_journal.py +133 -0
- purra_sqlite-0.5.0/tests/test_journal.py +97 -0
- purra_sqlite-0.5.0/tests/test_metadata.py +95 -0
- purra_sqlite-0.5.0/tests/test_root_scope.py +108 -0
- purra_sqlite-0.5.0/tests/test_row_integrity.py +72 -0
- purra_sqlite-0.5.0/tests/test_sqlite.py +131 -0
|
@@ -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,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
|