agentos-ledger-client 0.1.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.
- agentos_ledger_client/__init__.py +69 -0
- agentos_ledger_client/blobs.py +359 -0
- agentos_ledger_client/bootstrap.py +61 -0
- agentos_ledger_client/chain.py +98 -0
- agentos_ledger_client/client.py +245 -0
- agentos_ledger_client/integration.py +117 -0
- agentos_ledger_client/journal.py +245 -0
- agentos_ledger_client/py.typed +0 -0
- agentos_ledger_client/schema.py +297 -0
- agentos_ledger_client/schema.sql +112 -0
- agentos_ledger_client/testing.py +124 -0
- agentos_ledger_client/tests/__init__.py +30 -0
- agentos_ledger_client/tests/conftest.py +91 -0
- agentos_ledger_client/tests/test_a_bare_database_becomes_a_ledger.py +294 -0
- agentos_ledger_client/tests/test_a_placeholder_dsn_is_not_a_database.py +125 -0
- agentos_ledger_client/tests/test_blobs.py +178 -0
- agentos_ledger_client/tests/test_chain.py +67 -0
- agentos_ledger_client/tests/test_client.py +44 -0
- agentos_ledger_client/tests/test_dsn_for_survives_a_unix_socket.py +68 -0
- agentos_ledger_client/tests/test_journal.py +137 -0
- agentos_ledger_client/tests/test_schema.py +17 -0
- agentos_ledger_client/types.py +107 -0
- agentos_ledger_client-0.1.0.dist-info/METADATA +156 -0
- agentos_ledger_client-0.1.0.dist-info/RECORD +26 -0
- agentos_ledger_client-0.1.0.dist-info/WHEEL +5 -0
- agentos_ledger_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""AgentOS State Ledger Client Package.
|
|
2
|
+
|
|
3
|
+
Lightweight client and content-addressed blob storage for the AgentOS State Ledger.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from agentos_ledger_client.blobs import (
|
|
8
|
+
INLINE_THRESHOLD_BYTES,
|
|
9
|
+
STORAGE_GCS,
|
|
10
|
+
STORAGE_INLINE,
|
|
11
|
+
BlobRef,
|
|
12
|
+
get_blob_ref,
|
|
13
|
+
get_blob_ref_by_sha256,
|
|
14
|
+
read_blob,
|
|
15
|
+
read_blob_by_sha256,
|
|
16
|
+
store_blob,
|
|
17
|
+
)
|
|
18
|
+
from agentos_ledger_client.bootstrap import init_schema, schema_sql
|
|
19
|
+
from agentos_ledger_client.chain import (
|
|
20
|
+
GENESIS,
|
|
21
|
+
GLOBAL_CHAIN_LOCK_ID,
|
|
22
|
+
chain_hash,
|
|
23
|
+
envelope_digest,
|
|
24
|
+
new_event_id,
|
|
25
|
+
payload_json,
|
|
26
|
+
)
|
|
27
|
+
from agentos_ledger_client.client import LedgerClient
|
|
28
|
+
from agentos_ledger_client.journal import (
|
|
29
|
+
append_event,
|
|
30
|
+
generate_event_id,
|
|
31
|
+
register_post_append_hook,
|
|
32
|
+
register_pre_append_hook,
|
|
33
|
+
)
|
|
34
|
+
from agentos_ledger_client.types import AppendResult, ChainHead, LedgerEvent
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"init_schema",
|
|
40
|
+
"schema_sql",
|
|
41
|
+
# Chain & Lock
|
|
42
|
+
"GENESIS",
|
|
43
|
+
"GLOBAL_CHAIN_LOCK_ID",
|
|
44
|
+
"chain_hash",
|
|
45
|
+
"envelope_digest",
|
|
46
|
+
"new_event_id",
|
|
47
|
+
"payload_json",
|
|
48
|
+
# Journal
|
|
49
|
+
"append_event",
|
|
50
|
+
"generate_event_id",
|
|
51
|
+
"register_pre_append_hook",
|
|
52
|
+
"register_post_append_hook",
|
|
53
|
+
# Blobs
|
|
54
|
+
"BlobRef",
|
|
55
|
+
"store_blob",
|
|
56
|
+
"read_blob",
|
|
57
|
+
"get_blob_ref",
|
|
58
|
+
"get_blob_ref_by_sha256",
|
|
59
|
+
"read_blob_by_sha256",
|
|
60
|
+
"INLINE_THRESHOLD_BYTES",
|
|
61
|
+
"STORAGE_INLINE",
|
|
62
|
+
"STORAGE_GCS",
|
|
63
|
+
# Client
|
|
64
|
+
"LedgerClient",
|
|
65
|
+
# Types
|
|
66
|
+
"AppendResult",
|
|
67
|
+
"ChainHead",
|
|
68
|
+
"LedgerEvent",
|
|
69
|
+
]
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Content-addressed binary blob storage for the AgentOS State Ledger.
|
|
2
|
+
|
|
3
|
+
Provides storage and retrieval of arbitrary binary payloads with automatic tiering:
|
|
4
|
+
- Payloads <= 1MB: stored inline as BYTEA in public.ledger_blobs
|
|
5
|
+
- Payloads > 1MB: uploaded to Google Cloud Storage (GCS) and referenced by URI
|
|
6
|
+
|
|
7
|
+
All payloads are content-addressed by SHA-256 hex digest, providing automatic
|
|
8
|
+
deduplication and tamper-evident provenance tracking across the lifecycle.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any, Callable, Final
|
|
17
|
+
from uuid import UUID
|
|
18
|
+
|
|
19
|
+
from agentos_ledger_client.integration import current
|
|
20
|
+
from agentos_ledger_client.types import BlobRef
|
|
21
|
+
|
|
22
|
+
INLINE_THRESHOLD_BYTES: Final[int] = 1024 * 1024 # 1 MB threshold: <=1MB inline, >1MB GCS
|
|
23
|
+
STORAGE_INLINE: Final[str] = "inline"
|
|
24
|
+
STORAGE_GCS: Final[str] = "gcs"
|
|
25
|
+
|
|
26
|
+
_gcs_client: Any = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resource_bucket_name() -> str:
|
|
30
|
+
"""Shared bucket for ledger blobs."""
|
|
31
|
+
# NO HARDCODED FALLBACK, and no `except` around the lookup.
|
|
32
|
+
#
|
|
33
|
+
# This used to end `os.environ.get("GCS_RESOURCE_BUCKET",
|
|
34
|
+
# "pwh-agent-resources")` inside a bare `except Exception`, so a
|
|
35
|
+
# CONFIGURATION FAILURE did not raise -- it silently redirected every
|
|
36
|
+
# ledger blob to a hardcoded bucket. A misconfigured deployment wrote
|
|
37
|
+
# somewhere nobody was looking and said nothing. The name also broke
|
|
38
|
+
# the no-`pwh` convention and the OSS rule on hardcoded project names.
|
|
39
|
+
#
|
|
40
|
+
# FROM THE HOST, NOT FROM AN IMPORT. `integration.configure()` is
|
|
41
|
+
# how pwt_agent_os hands its settings in; a consumer supplies its own
|
|
42
|
+
# or none. See integration.py for why importing `app.*` here was
|
|
43
|
+
# worse than useless.
|
|
44
|
+
name = current().bucket_name or os.environ.get("GCS_RESOURCE_BUCKET", "")
|
|
45
|
+
if not name:
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
"no GCS bucket configured for ledger blobs: set "
|
|
48
|
+
"GCS_RESOURCE_BUCKET. Blobs of 1MB or less are stored inline "
|
|
49
|
+
"and need no bucket at all, so this is only reached by a "
|
|
50
|
+
"payload over the inline threshold.")
|
|
51
|
+
return name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_gcs_client() -> Any:
|
|
55
|
+
"""Get or initialize GCS client if google-cloud-storage is installed."""
|
|
56
|
+
factory = current().gcs_client
|
|
57
|
+
if factory is not None:
|
|
58
|
+
return factory()
|
|
59
|
+
try:
|
|
60
|
+
from google.cloud import storage as gcs
|
|
61
|
+
global _gcs_client
|
|
62
|
+
if _gcs_client is None:
|
|
63
|
+
_gcs_client = gcs.Client()
|
|
64
|
+
return _gcs_client
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
raise RuntimeError(
|
|
67
|
+
"GCS client is not available; install google-cloud-storage or provide upload_fn/download_fn"
|
|
68
|
+
) from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _upload_to_gcs(
|
|
72
|
+
bucket_name: str,
|
|
73
|
+
blob_path: str,
|
|
74
|
+
data: bytes,
|
|
75
|
+
content_type: str,
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Upload payload to GCS in threadpool executor and return gs:// URI."""
|
|
78
|
+
client = get_gcs_client()
|
|
79
|
+
bucket = client.bucket(bucket_name)
|
|
80
|
+
blob = bucket.blob(blob_path)
|
|
81
|
+
loop = asyncio.get_running_loop()
|
|
82
|
+
await loop.run_in_executor(
|
|
83
|
+
None,
|
|
84
|
+
lambda: blob.upload_from_string(
|
|
85
|
+
data, content_type=content_type or "application/octet-stream"
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
return f"gs://{bucket_name}/{blob_path}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def download_gcs_uri_bytes(gcs_uri: str) -> bytes:
|
|
92
|
+
"""Download raw bytes from an arbitrary gs:// URI."""
|
|
93
|
+
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
|
94
|
+
raise ValueError(f"not a GCS URI: {gcs_uri!r}")
|
|
95
|
+
without_scheme = gcs_uri[5:]
|
|
96
|
+
bucket_name, _, blob_path = without_scheme.partition("/")
|
|
97
|
+
if not blob_path:
|
|
98
|
+
raise ValueError(f"missing blob path in URI: {gcs_uri!r}")
|
|
99
|
+
|
|
100
|
+
host = current().download
|
|
101
|
+
if host is not None:
|
|
102
|
+
return await host(gcs_uri)
|
|
103
|
+
|
|
104
|
+
def _download() -> bytes:
|
|
105
|
+
client = get_gcs_client()
|
|
106
|
+
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
|
|
107
|
+
|
|
108
|
+
return await asyncio.get_running_loop().run_in_executor(None, _download)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def store_blob(
|
|
112
|
+
conn: Any,
|
|
113
|
+
data: bytes,
|
|
114
|
+
content_type: str = "application/octet-stream",
|
|
115
|
+
*,
|
|
116
|
+
upload_fn: Callable[..., Any] | None = None,
|
|
117
|
+
) -> BlobRef:
|
|
118
|
+
"""Store content-addressed binary data, selecting inline or GCS storage by size.
|
|
119
|
+
|
|
120
|
+
Deduplicates writes based on SHA-256 hash: if the blob already exists,
|
|
121
|
+
returns the existing BlobRef without re-uploading or duplicating records.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
conn: asyncpg connection or compatible database interface.
|
|
125
|
+
data: Raw payload bytes to store.
|
|
126
|
+
content_type: MIME content type of payload.
|
|
127
|
+
upload_fn: Optional callable for GCS upload (for test patching/custom backends).
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
BlobRef referencing the persisted payload.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
TypeError: If data is not bytes or bytearray.
|
|
134
|
+
"""
|
|
135
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
136
|
+
raise TypeError(f"data must be bytes-like, got {type(data).__name__}")
|
|
137
|
+
payload = bytes(data)
|
|
138
|
+
size_bytes = len(payload)
|
|
139
|
+
sha256_hex = hashlib.sha256(payload).hexdigest()
|
|
140
|
+
effective_content_type = content_type or "application/octet-stream"
|
|
141
|
+
|
|
142
|
+
# Deduplication check: return existing record if already present
|
|
143
|
+
existing = await conn.fetchrow(
|
|
144
|
+
"""
|
|
145
|
+
SELECT blob_id, sha256, size_bytes, content_type, storage_backend, gcs_uri, created_at
|
|
146
|
+
FROM public.ledger_blobs
|
|
147
|
+
WHERE sha256 = $1
|
|
148
|
+
""",
|
|
149
|
+
sha256_hex,
|
|
150
|
+
)
|
|
151
|
+
if existing is not None:
|
|
152
|
+
return BlobRef(
|
|
153
|
+
blob_id=existing["blob_id"],
|
|
154
|
+
sha256=existing["sha256"],
|
|
155
|
+
size_bytes=existing["size_bytes"],
|
|
156
|
+
content_type=existing["content_type"],
|
|
157
|
+
storage_backend=existing["storage_backend"],
|
|
158
|
+
created_at=existing["created_at"],
|
|
159
|
+
gcs_uri=existing["gcs_uri"],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Determine storage tier: inline if <= 1MB, GCS if > 1MB
|
|
163
|
+
if size_bytes <= INLINE_THRESHOLD_BYTES:
|
|
164
|
+
storage_backend = STORAGE_INLINE
|
|
165
|
+
gcs_uri = None
|
|
166
|
+
bytea_data = payload
|
|
167
|
+
else:
|
|
168
|
+
storage_backend = STORAGE_GCS
|
|
169
|
+
bytea_data = None
|
|
170
|
+
# THE BUCKET IS ALWAYS RESOLVED, even when the caller supplies
|
|
171
|
+
# its own `upload_fn`.
|
|
172
|
+
#
|
|
173
|
+
# I briefly skipped it in that case, reasoning that an uploader
|
|
174
|
+
# which returns its own URI does not need one. That is true of a
|
|
175
|
+
# test passing a mock and false of the host: `app/ledger/blobs.py`
|
|
176
|
+
# ALWAYS passes `upload_fn=` (so a patched `_upload_to_gcs` takes
|
|
177
|
+
# effect), so "caller supplied an uploader" is not a signal about
|
|
178
|
+
# the caller at all, and the optimisation silently handed every
|
|
179
|
+
# production upload an empty bucket name.
|
|
180
|
+
#
|
|
181
|
+
# The uploader receives the bucket as an argument and may ignore
|
|
182
|
+
# it; what it must not do is receive the wrong one.
|
|
183
|
+
bucket_name = resource_bucket_name()
|
|
184
|
+
uploader = upload_fn or _upload_to_gcs
|
|
185
|
+
blob_path = f"blobs/{sha256_hex}"
|
|
186
|
+
gcs_uri = await uploader(
|
|
187
|
+
bucket_name=bucket_name,
|
|
188
|
+
blob_path=blob_path,
|
|
189
|
+
data=payload,
|
|
190
|
+
content_type=effective_content_type,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
row = await conn.fetchrow(
|
|
194
|
+
"""
|
|
195
|
+
INSERT INTO public.ledger_blobs (
|
|
196
|
+
sha256, size_bytes, content_type, storage_backend, data, gcs_uri
|
|
197
|
+
)
|
|
198
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
199
|
+
ON CONFLICT (sha256) DO UPDATE
|
|
200
|
+
SET sha256 = EXCLUDED.sha256
|
|
201
|
+
RETURNING blob_id, sha256, size_bytes, content_type, storage_backend, gcs_uri, created_at
|
|
202
|
+
""",
|
|
203
|
+
sha256_hex,
|
|
204
|
+
size_bytes,
|
|
205
|
+
effective_content_type,
|
|
206
|
+
storage_backend,
|
|
207
|
+
bytea_data,
|
|
208
|
+
gcs_uri,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return BlobRef(
|
|
212
|
+
blob_id=row["blob_id"],
|
|
213
|
+
sha256=row["sha256"],
|
|
214
|
+
size_bytes=row["size_bytes"],
|
|
215
|
+
content_type=row["content_type"],
|
|
216
|
+
storage_backend=row["storage_backend"],
|
|
217
|
+
created_at=row["created_at"],
|
|
218
|
+
gcs_uri=row["gcs_uri"],
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def read_blob(
|
|
223
|
+
conn: Any,
|
|
224
|
+
blob_id: UUID | str,
|
|
225
|
+
*,
|
|
226
|
+
download_fn: Callable[..., Any] | None = None,
|
|
227
|
+
) -> bytes:
|
|
228
|
+
"""Read binary data for a content-addressed blob by its UUID.
|
|
229
|
+
|
|
230
|
+
Fetches from inline BYTEA or GCS depending on storage_backend, and verifies
|
|
231
|
+
payload integrity against the stored SHA-256 hash.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
conn: asyncpg connection or compatible database interface.
|
|
235
|
+
blob_id: UUID of blob to read.
|
|
236
|
+
download_fn: Optional callable for GCS download.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Raw bytes of the retrieved blob.
|
|
240
|
+
|
|
241
|
+
Raises:
|
|
242
|
+
KeyError: If blob_id not found in ledger_blobs.
|
|
243
|
+
ValueError: If SHA-256 integrity check fails, payload missing, or backend unknown.
|
|
244
|
+
"""
|
|
245
|
+
if isinstance(blob_id, str):
|
|
246
|
+
blob_id = UUID(blob_id)
|
|
247
|
+
|
|
248
|
+
row = await conn.fetchrow(
|
|
249
|
+
"""
|
|
250
|
+
SELECT blob_id, sha256, size_bytes, content_type, storage_backend, data, gcs_uri
|
|
251
|
+
FROM public.ledger_blobs
|
|
252
|
+
WHERE blob_id = $1
|
|
253
|
+
""",
|
|
254
|
+
blob_id,
|
|
255
|
+
)
|
|
256
|
+
if row is None:
|
|
257
|
+
raise KeyError(f"Blob not found: {blob_id}")
|
|
258
|
+
|
|
259
|
+
backend = row["storage_backend"]
|
|
260
|
+
if backend == STORAGE_INLINE:
|
|
261
|
+
raw_data = row["data"]
|
|
262
|
+
if raw_data is None:
|
|
263
|
+
raise ValueError(f"Corrupted inline blob {blob_id}: data payload is NULL")
|
|
264
|
+
payload = bytes(raw_data)
|
|
265
|
+
elif backend == STORAGE_GCS:
|
|
266
|
+
gcs_uri = row["gcs_uri"]
|
|
267
|
+
if not gcs_uri:
|
|
268
|
+
raise ValueError(f"Corrupted GCS blob {blob_id}: gcs_uri is NULL")
|
|
269
|
+
downloader = download_fn or download_gcs_uri_bytes
|
|
270
|
+
payload = await downloader(gcs_uri)
|
|
271
|
+
else:
|
|
272
|
+
raise ValueError(f"Unsupported storage backend: {backend!r}")
|
|
273
|
+
|
|
274
|
+
actual_sha256 = hashlib.sha256(payload).hexdigest()
|
|
275
|
+
if actual_sha256 != row["sha256"]:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"Blob integrity check failed for {blob_id}: "
|
|
278
|
+
f"expected SHA-256 {row['sha256']}, got {actual_sha256}"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return payload
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async def get_blob_ref(conn: Any, blob_id: UUID | str) -> BlobRef | None:
|
|
285
|
+
"""Fetch BlobRef metadata by blob_id without loading payload data."""
|
|
286
|
+
if isinstance(blob_id, str):
|
|
287
|
+
blob_id = UUID(blob_id)
|
|
288
|
+
row = await conn.fetchrow(
|
|
289
|
+
"""
|
|
290
|
+
SELECT blob_id, sha256, size_bytes, content_type, storage_backend, gcs_uri, created_at
|
|
291
|
+
FROM public.ledger_blobs
|
|
292
|
+
WHERE blob_id = $1
|
|
293
|
+
""",
|
|
294
|
+
blob_id,
|
|
295
|
+
)
|
|
296
|
+
if row is None:
|
|
297
|
+
return None
|
|
298
|
+
return BlobRef(
|
|
299
|
+
blob_id=row["blob_id"],
|
|
300
|
+
sha256=row["sha256"],
|
|
301
|
+
size_bytes=row["size_bytes"],
|
|
302
|
+
content_type=row["content_type"],
|
|
303
|
+
storage_backend=row["storage_backend"],
|
|
304
|
+
created_at=row["created_at"],
|
|
305
|
+
gcs_uri=row["gcs_uri"],
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def get_blob_ref_by_sha256(conn: Any, sha256: str) -> BlobRef | None:
|
|
310
|
+
"""Fetch BlobRef metadata by sha256 without loading payload data."""
|
|
311
|
+
row = await conn.fetchrow(
|
|
312
|
+
"""
|
|
313
|
+
SELECT blob_id, sha256, size_bytes, content_type, storage_backend, gcs_uri, created_at
|
|
314
|
+
FROM public.ledger_blobs
|
|
315
|
+
WHERE sha256 = $1
|
|
316
|
+
""",
|
|
317
|
+
sha256,
|
|
318
|
+
)
|
|
319
|
+
if row is None:
|
|
320
|
+
return None
|
|
321
|
+
return BlobRef(
|
|
322
|
+
blob_id=row["blob_id"],
|
|
323
|
+
sha256=row["sha256"],
|
|
324
|
+
size_bytes=row["size_bytes"],
|
|
325
|
+
content_type=row["content_type"],
|
|
326
|
+
storage_backend=row["storage_backend"],
|
|
327
|
+
created_at=row["created_at"],
|
|
328
|
+
gcs_uri=row["gcs_uri"],
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def read_blob_by_sha256(
|
|
333
|
+
conn: Any,
|
|
334
|
+
sha256: str,
|
|
335
|
+
*,
|
|
336
|
+
download_fn: Callable[..., Any] | None = None,
|
|
337
|
+
) -> bytes:
|
|
338
|
+
"""Read binary data for a content-addressed blob by its SHA-256 hash."""
|
|
339
|
+
ref = await get_blob_ref_by_sha256(conn, sha256)
|
|
340
|
+
if ref is None:
|
|
341
|
+
raise KeyError(f"Blob with sha256 {sha256!r} not found")
|
|
342
|
+
return await read_blob(conn, ref.blob_id, download_fn=download_fn)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
__all__ = [
|
|
346
|
+
"BlobRef",
|
|
347
|
+
"store_blob",
|
|
348
|
+
"read_blob",
|
|
349
|
+
"get_blob_ref",
|
|
350
|
+
"get_blob_ref_by_sha256",
|
|
351
|
+
"read_blob_by_sha256",
|
|
352
|
+
"INLINE_THRESHOLD_BYTES",
|
|
353
|
+
"STORAGE_INLINE",
|
|
354
|
+
"STORAGE_GCS",
|
|
355
|
+
"resource_bucket_name",
|
|
356
|
+
"get_gcs_client",
|
|
357
|
+
"_upload_to_gcs",
|
|
358
|
+
"download_gcs_uri_bytes",
|
|
359
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Create the ledger's tables in a database that has none.
|
|
2
|
+
|
|
3
|
+
THE GAP THIS CLOSES. The client reads and writes `ledger_events`,
|
|
4
|
+
`chain_head` and `public.ledger_blobs`, and shipped no way to create any
|
|
5
|
+
of them. Inside pwt_agent_os that was invisible -- the migration runner
|
|
6
|
+
had already built them -- but it made the package uninstallable in the
|
|
7
|
+
only sense that matters: a second repository could `pip install` it and
|
|
8
|
+
then do nothing with it.
|
|
9
|
+
|
|
10
|
+
IDEMPOTENT, because a test fixture calls it on every run and a consumer
|
|
11
|
+
may call it on every boot. Every statement in `schema.sql` carries
|
|
12
|
+
`IF NOT EXISTS`.
|
|
13
|
+
|
|
14
|
+
NOT A MIGRATION RUNNER. This creates the schema when it is absent; it
|
|
15
|
+
does not version it, diff it or alter it. A consumer that needs those
|
|
16
|
+
owns them, exactly as pwt_agent_os does with `migrations/`. If the
|
|
17
|
+
shipped schema and the control panel's migrations ever disagree,
|
|
18
|
+
`tests/test_the_shipped_schema_matches_the_migrations.py` fails the
|
|
19
|
+
build rather than letting the difference reach a chain.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
# INSIDE the package, not at the distribution root, because only files
|
|
27
|
+
# under the package directory are installed as package data -- at the root
|
|
28
|
+
# it would exist in the repository and be missing from every wheel. Read at
|
|
29
|
+
# call time rather than import time: nothing that merely imports the client
|
|
30
|
+
# should touch disk.
|
|
31
|
+
#
|
|
32
|
+
# `schema.sql` and `schema.py` sit side by side and mean different things:
|
|
33
|
+
# the .py is the event-type VOCABULARY (`task.created`, `rlagt.sim.tick`),
|
|
34
|
+
# the .sql is the TABLES those events are written to.
|
|
35
|
+
_SCHEMA = Path(__file__).resolve().parent / "schema.sql"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def schema_sql() -> str:
|
|
39
|
+
"""The DDL this client needs, as text.
|
|
40
|
+
|
|
41
|
+
Exposed separately from `init_schema` because a consumer with its own
|
|
42
|
+
migration runner wants the statements, not the execution -- it can
|
|
43
|
+
paste them into a numbered migration and keep one source of truth for
|
|
44
|
+
how its database is built.
|
|
45
|
+
"""
|
|
46
|
+
return _SCHEMA.read_text(encoding="utf-8")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def init_schema(conn: Any) -> None:
|
|
50
|
+
"""Create the ledger tables on `conn` if they are absent.
|
|
51
|
+
|
|
52
|
+
`conn` is anything with an awaitable `execute` -- an asyncpg
|
|
53
|
+
connection in practice. Runs the whole file in one call so the
|
|
54
|
+
CREATEs and their indexes arrive together; asyncpg's simple-query
|
|
55
|
+
path accepts multiple statements.
|
|
56
|
+
|
|
57
|
+
Deliberately NOT wrapped in a transaction here: a caller that wants
|
|
58
|
+
atomicity owns the transaction, and one that is bootstrapping a
|
|
59
|
+
throwaway test database does not care.
|
|
60
|
+
"""
|
|
61
|
+
await conn.execute(schema_sql())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The canonical hash chain and advisory lock specification for AgentOS State Ledger.
|
|
2
|
+
|
|
3
|
+
Every ledger writer must use the exact same lock ID and chain formula.
|
|
4
|
+
Two writers taking different lock IDs will silently fork the chain, and
|
|
5
|
+
divergent hash implementations will falsely indicate tampering.
|
|
6
|
+
|
|
7
|
+
WHY NOT A SQL FUNCTION, which is the first idea and would be neater:
|
|
8
|
+
Postgres cannot reproduce Python's serialisation. `json.dumps(sort_keys=
|
|
9
|
+
True)` orders keys lexicographically; `jsonb` orders them by LENGTH first
|
|
10
|
+
and then bytewise, so `{"b":1,"aa":2}` serialises in a different order in
|
|
11
|
+
each and hashes differently. Every event ever written used the Python
|
|
12
|
+
ordering, so THE PYTHON ORDERING IS THE FORMAT.
|
|
13
|
+
|
|
14
|
+
WHAT THE CHAIN HASH DOES NOT COVER: the actor. Not the actor, not the
|
|
15
|
+
event type, not the timestamp. That is a real weakness for a ledger whose
|
|
16
|
+
purpose is attribution, and it is deliberately NOT changed here --
|
|
17
|
+
thousands of events verify under this rule today and re-defining it
|
|
18
|
+
silently would invalidate all of them. `envelope_digest` below is the
|
|
19
|
+
forward path: computed and stored alongside, verified when present, so
|
|
20
|
+
the chain gains cover over the envelope without a flag day.
|
|
21
|
+
|
|
22
|
+
BOTH NOTES WERE CARRIED HERE ON PURPOSE. They were in
|
|
23
|
+
`kernel/ledger/chain.py` and lost when this module was extracted. In a
|
|
24
|
+
package meant to be read by other repositories the next reader is
|
|
25
|
+
further from that history, not closer -- which makes this the worst
|
|
26
|
+
place to drop the two comments that stop someone "tidying" the hash into
|
|
27
|
+
a SQL function or extending it to cover the actor.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import uuid
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
GENESIS = "GENESIS"
|
|
37
|
+
|
|
38
|
+
# Canonical advisory lock ID for the ledger chain.
|
|
39
|
+
# 0x4C454447 is "LEDG" in ASCII.
|
|
40
|
+
GLOBAL_CHAIN_LOCK_ID = 0x4C454447
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def payload_json(payload: Any) -> str:
|
|
44
|
+
"""The exact serialized representation of payload bytes in the hash chain.
|
|
45
|
+
|
|
46
|
+
sort_keys=True ensures key-order independence.
|
|
47
|
+
default=str ensures dates/UUIDs/decimals serialize rather than raising.
|
|
48
|
+
"""
|
|
49
|
+
return json.dumps(payload or {}, sort_keys=True, default=str)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def chain_hash(previous_hash: str, payload: Any) -> str:
|
|
53
|
+
"""SHA-256 over previous_hash concatenated with serialized payload."""
|
|
54
|
+
return hashlib.sha256(
|
|
55
|
+
f"{previous_hash}{payload_json(payload)}".encode()
|
|
56
|
+
).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def envelope_digest(
|
|
60
|
+
*,
|
|
61
|
+
event_type: str,
|
|
62
|
+
actor_id: Any,
|
|
63
|
+
actor_type: str,
|
|
64
|
+
on_behalf_of: Any = None,
|
|
65
|
+
payload: Any = None,
|
|
66
|
+
) -> str:
|
|
67
|
+
"""Attribution digest binding envelope metadata and payload together.
|
|
68
|
+
|
|
69
|
+
Uses unit separator (\x1f) delimiter between envelope fields.
|
|
70
|
+
"""
|
|
71
|
+
parts = [
|
|
72
|
+
str(event_type or ""),
|
|
73
|
+
str(actor_id or ""),
|
|
74
|
+
str(actor_type or ""),
|
|
75
|
+
str(on_behalf_of or ""),
|
|
76
|
+
payload_json(payload),
|
|
77
|
+
]
|
|
78
|
+
return hashlib.sha256("\x1f".join(parts).encode()).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def new_event_id() -> uuid.UUID:
|
|
82
|
+
"""Generate a time-ordered UUID v7 if available, otherwise UUID v4."""
|
|
83
|
+
try:
|
|
84
|
+
from uuid_utils import uuid7 as _real_uuid7
|
|
85
|
+
return uuid.UUID(int=_real_uuid7().int)
|
|
86
|
+
except ImportError:
|
|
87
|
+
factory = getattr(uuid, "uuid7", None)
|
|
88
|
+
return factory() if factory else uuid.uuid4()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = [
|
|
92
|
+
"GENESIS",
|
|
93
|
+
"GLOBAL_CHAIN_LOCK_ID",
|
|
94
|
+
"payload_json",
|
|
95
|
+
"chain_hash",
|
|
96
|
+
"envelope_digest",
|
|
97
|
+
"new_event_id",
|
|
98
|
+
]
|