statebus 0.1.1__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.
- statebus/__init__.py +44 -0
- statebus/bus.py +504 -0
- statebus/cli.py +305 -0
- statebus/errors.py +172 -0
- statebus/mcp_server.py +354 -0
- statebus/rest_server.py +312 -0
- statebus/store.py +610 -0
- statebus/validation.py +184 -0
- statebus-0.1.1.dist-info/METADATA +214 -0
- statebus-0.1.1.dist-info/RECORD +14 -0
- statebus-0.1.1.dist-info/WHEEL +5 -0
- statebus-0.1.1.dist-info/entry_points.txt +4 -0
- statebus-0.1.1.dist-info/licenses/LICENSE +21 -0
- statebus-0.1.1.dist-info/top_level.txt +1 -0
statebus/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""statebus — typed, versioned state for multi-agent workflows."""
|
|
2
|
+
|
|
3
|
+
from .bus import SlotView, StateBus, TraceEvent, WriteReceipt
|
|
4
|
+
from .errors import (
|
|
5
|
+
BreakingSchemaChangeError,
|
|
6
|
+
ConflictError,
|
|
7
|
+
InvalidArgumentError,
|
|
8
|
+
InvalidSchemaError,
|
|
9
|
+
NotJsonSerializableError,
|
|
10
|
+
SchemaVersionNotFoundError,
|
|
11
|
+
SlotArchivedError,
|
|
12
|
+
SlotExistsError,
|
|
13
|
+
SlotNameError,
|
|
14
|
+
SlotNotFoundError,
|
|
15
|
+
SlotVersionNotFoundError,
|
|
16
|
+
StateBusError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
ValidationIssue,
|
|
19
|
+
ValueTooLargeError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.1"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"StateBus",
|
|
26
|
+
"WriteReceipt",
|
|
27
|
+
"SlotView",
|
|
28
|
+
"TraceEvent",
|
|
29
|
+
"StateBusError",
|
|
30
|
+
"SlotNotFoundError",
|
|
31
|
+
"SlotExistsError",
|
|
32
|
+
"SlotArchivedError",
|
|
33
|
+
"SlotNameError",
|
|
34
|
+
"SchemaVersionNotFoundError",
|
|
35
|
+
"SlotVersionNotFoundError",
|
|
36
|
+
"NotJsonSerializableError",
|
|
37
|
+
"ValueTooLargeError",
|
|
38
|
+
"ValidationError",
|
|
39
|
+
"ValidationIssue",
|
|
40
|
+
"ConflictError",
|
|
41
|
+
"BreakingSchemaChangeError",
|
|
42
|
+
"InvalidArgumentError",
|
|
43
|
+
"InvalidSchemaError",
|
|
44
|
+
]
|
statebus/bus.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""StateBus — typed, versioned state for multi-agent workflows.
|
|
2
|
+
|
|
3
|
+
Agents read/write named *slots*. Every write is validated against the slot's
|
|
4
|
+
JSON Schema, appended as a new version, and recorded in the handoff trace::
|
|
5
|
+
|
|
6
|
+
from statebus import StateBus
|
|
7
|
+
|
|
8
|
+
bus = StateBus("team.db") # durable; StateBus() is in-memory
|
|
9
|
+
bus.register_slot("customer_record", schema={...}, schema_version="2.1")
|
|
10
|
+
receipt = bus.write("customer_record", {...}, agent="agent_a")
|
|
11
|
+
value = bus.read("customer_record") # plain value
|
|
12
|
+
old = bus.replay("customer_record", version=2) # exact value at v2
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
import functools
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import threading
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import store as store_module
|
|
26
|
+
from .errors import (
|
|
27
|
+
BreakingSchemaChangeError,
|
|
28
|
+
ConflictError,
|
|
29
|
+
InvalidArgumentError,
|
|
30
|
+
NotJsonSerializableError,
|
|
31
|
+
SchemaVersionNotFoundError,
|
|
32
|
+
SlotArchivedError,
|
|
33
|
+
SlotExistsError,
|
|
34
|
+
SlotNameError,
|
|
35
|
+
SlotNotFoundError,
|
|
36
|
+
SlotVersionNotFoundError,
|
|
37
|
+
ValidationError,
|
|
38
|
+
ValueTooLargeError,
|
|
39
|
+
)
|
|
40
|
+
from .store import (
|
|
41
|
+
CasMismatch,
|
|
42
|
+
MemoryStore,
|
|
43
|
+
SlotRecord,
|
|
44
|
+
SQLiteStore,
|
|
45
|
+
Store,
|
|
46
|
+
TraceRecord,
|
|
47
|
+
utc_now,
|
|
48
|
+
)
|
|
49
|
+
from .validation import (
|
|
50
|
+
check_backward_compatible,
|
|
51
|
+
check_schema_valid,
|
|
52
|
+
validate_against_schema,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
_SLOT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.\-]{0,63}$")
|
|
56
|
+
DEFAULT_MAX_VERSIONS = 100
|
|
57
|
+
DEFAULT_MAX_VALUE_BYTES = 1_000_000 # 1 MiB per write
|
|
58
|
+
DEFAULT_MAX_TRACE_EVENTS = 100_000
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _locked(method):
|
|
62
|
+
"""Serialize mutating bus operations in-process (stores handle the rest)."""
|
|
63
|
+
|
|
64
|
+
@functools.wraps(method)
|
|
65
|
+
def wrapper(self, *args, **kwargs):
|
|
66
|
+
with self._lock:
|
|
67
|
+
return method(self, *args, **kwargs)
|
|
68
|
+
|
|
69
|
+
return wrapper
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class WriteReceipt:
|
|
74
|
+
slot: str
|
|
75
|
+
version: int
|
|
76
|
+
schema_version: str
|
|
77
|
+
agent: str
|
|
78
|
+
ts: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class SlotView:
|
|
83
|
+
"""Envelope for a slot's current (or historical) value."""
|
|
84
|
+
|
|
85
|
+
slot: str
|
|
86
|
+
version: int
|
|
87
|
+
value: Any
|
|
88
|
+
agent: str
|
|
89
|
+
schema_version: str
|
|
90
|
+
ts: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class TraceEvent:
|
|
95
|
+
seq: int
|
|
96
|
+
ts: str
|
|
97
|
+
kind: str
|
|
98
|
+
slot: str | None
|
|
99
|
+
agent: str | None
|
|
100
|
+
version: int | None
|
|
101
|
+
schema_version: str | None
|
|
102
|
+
detail: dict
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class StateBus:
|
|
106
|
+
"""Typed, versioned, traced state for multi-agent workflows."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
db_path: str | None = None,
|
|
111
|
+
*,
|
|
112
|
+
max_value_bytes: int = DEFAULT_MAX_VALUE_BYTES,
|
|
113
|
+
trace_reads: bool = False,
|
|
114
|
+
default_agent: str = "unknown",
|
|
115
|
+
) -> None:
|
|
116
|
+
"""
|
|
117
|
+
Args:
|
|
118
|
+
db_path: Path to a SQLite file for durable storage.
|
|
119
|
+
``None`` selects the in-memory backend (nothing is persisted).
|
|
120
|
+
max_value_bytes: Per-write size cap; larger writes are rejected.
|
|
121
|
+
trace_reads: Record read events in the trace by default.
|
|
122
|
+
default_agent: Agent id used when a call does not name one.
|
|
123
|
+
"""
|
|
124
|
+
self._store: Store = SQLiteStore(db_path) if db_path is not None else MemoryStore()
|
|
125
|
+
self._max_value_bytes = max_value_bytes
|
|
126
|
+
self._trace_reads = trace_reads
|
|
127
|
+
self._default_agent = default_agent
|
|
128
|
+
self._lock = threading.RLock()
|
|
129
|
+
|
|
130
|
+
# -- context manager -------------------------------------------------
|
|
131
|
+
def __enter__(self) -> StateBus:
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(self, *exc: Any) -> None:
|
|
135
|
+
self.close()
|
|
136
|
+
|
|
137
|
+
def close(self) -> None:
|
|
138
|
+
self._store.close()
|
|
139
|
+
|
|
140
|
+
# -- helpers ----------------------------------------------------------
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _check_slot_name(name: str) -> None:
|
|
143
|
+
if not isinstance(name, str) or not _SLOT_NAME_RE.match(name):
|
|
144
|
+
raise SlotNameError(name)
|
|
145
|
+
|
|
146
|
+
def _get_slot(self, name: str) -> SlotRecord:
|
|
147
|
+
self._check_slot_name(name)
|
|
148
|
+
record = self._store.get_slot(name)
|
|
149
|
+
if record is None:
|
|
150
|
+
raise SlotNotFoundError(name)
|
|
151
|
+
return record
|
|
152
|
+
|
|
153
|
+
def _schema_for(self, record: SlotRecord, schema_version: str | None) -> tuple[str, dict]:
|
|
154
|
+
wanted = schema_version or record.active_schema_version
|
|
155
|
+
for entry in record.schema_versions:
|
|
156
|
+
if entry["version"] == wanted:
|
|
157
|
+
return wanted, entry["schema"]
|
|
158
|
+
raise SchemaVersionNotFoundError(record.name, wanted)
|
|
159
|
+
|
|
160
|
+
def _serialize(self, value: Any) -> str:
|
|
161
|
+
try:
|
|
162
|
+
# allow_nan=False: NaN/Infinity are not valid JSON and would corrupt
|
|
163
|
+
# non-Python readers (Go, JS agents via REST/MCP).
|
|
164
|
+
text = json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
|
165
|
+
except (TypeError, ValueError) as exc:
|
|
166
|
+
raise NotJsonSerializableError(str(exc)) from exc
|
|
167
|
+
size = len(text.encode("utf-8"))
|
|
168
|
+
if size > self._max_value_bytes:
|
|
169
|
+
raise ValueTooLargeError(size, self._max_value_bytes)
|
|
170
|
+
return text
|
|
171
|
+
|
|
172
|
+
def _trace(
|
|
173
|
+
self,
|
|
174
|
+
kind: str,
|
|
175
|
+
slot: str | None,
|
|
176
|
+
agent: str | None,
|
|
177
|
+
version: int | None = None,
|
|
178
|
+
schema_version: str | None = None,
|
|
179
|
+
detail: dict | None = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
self._store.append_trace(
|
|
182
|
+
TraceRecord(
|
|
183
|
+
seq=0,
|
|
184
|
+
ts=utc_now(),
|
|
185
|
+
kind=kind,
|
|
186
|
+
slot=slot,
|
|
187
|
+
agent=agent,
|
|
188
|
+
version=version,
|
|
189
|
+
schema_version=schema_version,
|
|
190
|
+
detail=detail or {},
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# -- slot lifecycle ----------------------------------------------------
|
|
195
|
+
@_locked
|
|
196
|
+
def register_slot(
|
|
197
|
+
self,
|
|
198
|
+
name: str,
|
|
199
|
+
schema: dict,
|
|
200
|
+
schema_version: str = "1.0",
|
|
201
|
+
*,
|
|
202
|
+
max_versions: int = DEFAULT_MAX_VERSIONS,
|
|
203
|
+
trace_reads: bool | None = None,
|
|
204
|
+
by: str | None = None,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Register a named slot with its first schema version.
|
|
207
|
+
|
|
208
|
+
Raises:
|
|
209
|
+
SlotExistsError: the slot is already registered.
|
|
210
|
+
SlotNameError: the name is invalid.
|
|
211
|
+
"""
|
|
212
|
+
self._check_slot_name(name)
|
|
213
|
+
if not isinstance(schema, (dict, bool)):
|
|
214
|
+
raise TypeError("schema must be a JSON Schema (dict or boolean)")
|
|
215
|
+
check_schema_valid(schema)
|
|
216
|
+
if not schema_version or not isinstance(schema_version, str):
|
|
217
|
+
raise ValueError("schema_version must be a non-empty string")
|
|
218
|
+
record = SlotRecord(
|
|
219
|
+
name=name,
|
|
220
|
+
schema_versions=[
|
|
221
|
+
{
|
|
222
|
+
"version": schema_version,
|
|
223
|
+
"schema": copy.deepcopy(schema),
|
|
224
|
+
"breaking": False,
|
|
225
|
+
"registered_at": utc_now(),
|
|
226
|
+
"registered_by": by or self._default_agent,
|
|
227
|
+
}
|
|
228
|
+
],
|
|
229
|
+
active_schema_version=schema_version,
|
|
230
|
+
max_versions=max_versions,
|
|
231
|
+
trace_reads=self._trace_reads if trace_reads is None else trace_reads,
|
|
232
|
+
archived=False,
|
|
233
|
+
created_at=utc_now(),
|
|
234
|
+
)
|
|
235
|
+
try:
|
|
236
|
+
self._store.register_slot(record)
|
|
237
|
+
except KeyError:
|
|
238
|
+
raise SlotExistsError(name) from None
|
|
239
|
+
self._trace(
|
|
240
|
+
"slot.registered",
|
|
241
|
+
name,
|
|
242
|
+
by or self._default_agent,
|
|
243
|
+
schema_version=schema_version,
|
|
244
|
+
detail={"max_versions": max_versions},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
@_locked
|
|
248
|
+
def register_schema_version(
|
|
249
|
+
self,
|
|
250
|
+
slot: str,
|
|
251
|
+
schema: dict,
|
|
252
|
+
schema_version: str,
|
|
253
|
+
*,
|
|
254
|
+
allow_breaking: bool = False,
|
|
255
|
+
by: str | None = None,
|
|
256
|
+
) -> list[str]:
|
|
257
|
+
"""Register a new schema version for a slot; it becomes the active one.
|
|
258
|
+
|
|
259
|
+
Returns the list of detected breakages (empty when compatible).
|
|
260
|
+
Raises BreakingSchemaChangeError unless ``allow_breaking=True``.
|
|
261
|
+
"""
|
|
262
|
+
record = self._get_slot(slot)
|
|
263
|
+
if record.archived:
|
|
264
|
+
raise SlotArchivedError(slot)
|
|
265
|
+
if not isinstance(schema, (dict, bool)):
|
|
266
|
+
raise TypeError("schema must be a JSON Schema (dict or boolean)")
|
|
267
|
+
check_schema_valid(schema)
|
|
268
|
+
if any(e["version"] == schema_version for e in record.schema_versions):
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"schema version {schema_version!r} already registered for slot {slot!r}"
|
|
271
|
+
)
|
|
272
|
+
old_schema = record.schema_versions[-1]["schema"]
|
|
273
|
+
breakages = check_backward_compatible(old_schema, schema)
|
|
274
|
+
if breakages and not allow_breaking:
|
|
275
|
+
raise BreakingSchemaChangeError(slot, schema_version, breakages)
|
|
276
|
+
self._store.add_schema_version(
|
|
277
|
+
slot, copy.deepcopy(schema), schema_version, bool(breakages), by or self._default_agent
|
|
278
|
+
)
|
|
279
|
+
self._store.set_active_schema_version(slot, schema_version)
|
|
280
|
+
self._trace(
|
|
281
|
+
"slot.schema_version",
|
|
282
|
+
slot,
|
|
283
|
+
by or self._default_agent,
|
|
284
|
+
schema_version=schema_version,
|
|
285
|
+
detail={"breaking": bool(breakages), "breakages": breakages},
|
|
286
|
+
)
|
|
287
|
+
return breakages
|
|
288
|
+
|
|
289
|
+
@_locked
|
|
290
|
+
def archive_slot(self, slot: str, *, by: str | None = None) -> None:
|
|
291
|
+
"""Archive a slot: writes are rejected, history is preserved forever."""
|
|
292
|
+
record = self._get_slot(slot)
|
|
293
|
+
if record.archived:
|
|
294
|
+
return
|
|
295
|
+
self._store.archive_slot(slot)
|
|
296
|
+
self._trace("slot.archived", slot, by or self._default_agent)
|
|
297
|
+
|
|
298
|
+
def slots(self) -> list[dict]:
|
|
299
|
+
"""List all slots with their metadata (no values)."""
|
|
300
|
+
return [
|
|
301
|
+
{
|
|
302
|
+
"name": r.name,
|
|
303
|
+
"active_schema_version": r.active_schema_version,
|
|
304
|
+
"schema_versions": [e["version"] for e in r.schema_versions],
|
|
305
|
+
"max_versions": r.max_versions,
|
|
306
|
+
"archived": r.archived,
|
|
307
|
+
"created_at": r.created_at,
|
|
308
|
+
}
|
|
309
|
+
for r in self._store.list_slots()
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
# -- writes -------------------------------------------------------------
|
|
313
|
+
@_locked
|
|
314
|
+
def write(
|
|
315
|
+
self,
|
|
316
|
+
slot: str,
|
|
317
|
+
value: Any,
|
|
318
|
+
*,
|
|
319
|
+
agent: str | None = None,
|
|
320
|
+
expected_version: int | None = None,
|
|
321
|
+
schema_version: str | None = None,
|
|
322
|
+
) -> WriteReceipt:
|
|
323
|
+
"""Validate ``value`` and append it as a new version of ``slot``.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
expected_version: optimistic-concurrency guard — the write fails
|
|
327
|
+
with :class:`ConflictError` unless this matches the current
|
|
328
|
+
version. Omit for last-write-wins.
|
|
329
|
+
schema_version: validate against this registered schema version
|
|
330
|
+
instead of the active one.
|
|
331
|
+
|
|
332
|
+
Every write path funnels through here — there is no raw-write API.
|
|
333
|
+
"""
|
|
334
|
+
record = self._get_slot(slot)
|
|
335
|
+
if record.archived:
|
|
336
|
+
raise SlotArchivedError(slot)
|
|
337
|
+
if expected_version is not None and (
|
|
338
|
+
isinstance(expected_version, bool)
|
|
339
|
+
or not isinstance(expected_version, int)
|
|
340
|
+
or expected_version < 0
|
|
341
|
+
):
|
|
342
|
+
raise InvalidArgumentError(
|
|
343
|
+
f"expected_version must be a non-negative integer, got {expected_version!r}"
|
|
344
|
+
)
|
|
345
|
+
who = agent or self._default_agent
|
|
346
|
+
version_label, schema = self._schema_for(record, schema_version)
|
|
347
|
+
issues = validate_against_schema(schema, value)
|
|
348
|
+
if issues:
|
|
349
|
+
raise ValidationError(slot, version_label, issues)
|
|
350
|
+
payload = self._serialize(value)
|
|
351
|
+
try:
|
|
352
|
+
rec = self._store.append_version(slot, payload, who, version_label, expected_version)
|
|
353
|
+
except KeyError:
|
|
354
|
+
raise SlotNotFoundError(slot) from None
|
|
355
|
+
except CasMismatch as exc:
|
|
356
|
+
raise ConflictError(
|
|
357
|
+
slot=slot,
|
|
358
|
+
expected_version=exc.expected_version,
|
|
359
|
+
current_version=exc.current_version,
|
|
360
|
+
current_agent=exc.current_agent,
|
|
361
|
+
) from None
|
|
362
|
+
self._trace("write", slot, who, version=rec.version, schema_version=version_label)
|
|
363
|
+
return WriteReceipt(
|
|
364
|
+
slot=slot,
|
|
365
|
+
version=rec.version,
|
|
366
|
+
schema_version=version_label,
|
|
367
|
+
agent=who,
|
|
368
|
+
ts=rec.ts,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# -- reads ----------------------------------------------------------------
|
|
372
|
+
def _view(self, slot: str, rec: store_module.VersionRecord) -> SlotView:
|
|
373
|
+
return SlotView(
|
|
374
|
+
slot=slot,
|
|
375
|
+
version=rec.version,
|
|
376
|
+
value=json.loads(rec.value_json),
|
|
377
|
+
agent=rec.agent,
|
|
378
|
+
schema_version=rec.schema_version,
|
|
379
|
+
ts=rec.ts,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def read(self, slot: str, *, agent: str | None = None, trace: bool | None = None) -> Any:
|
|
383
|
+
"""Return the current value of ``slot`` (plain JSON data)."""
|
|
384
|
+
record = self._get_slot(slot)
|
|
385
|
+
rec = self._store.get_latest(slot)
|
|
386
|
+
if rec is None:
|
|
387
|
+
raise SlotVersionNotFoundError(slot, 0)
|
|
388
|
+
if trace if trace is not None else record.trace_reads:
|
|
389
|
+
self._trace(
|
|
390
|
+
"read",
|
|
391
|
+
slot,
|
|
392
|
+
agent or self._default_agent,
|
|
393
|
+
version=rec.version,
|
|
394
|
+
schema_version=rec.schema_version,
|
|
395
|
+
)
|
|
396
|
+
return json.loads(rec.value_json)
|
|
397
|
+
|
|
398
|
+
def inspect(self, slot: str) -> SlotView:
|
|
399
|
+
"""Return the current value plus its envelope (version, writer, ...)."""
|
|
400
|
+
self._get_slot(slot)
|
|
401
|
+
rec = self._store.get_latest(slot)
|
|
402
|
+
if rec is None:
|
|
403
|
+
raise SlotVersionNotFoundError(slot, 0)
|
|
404
|
+
return self._view(slot, rec)
|
|
405
|
+
|
|
406
|
+
def history(self, slot: str, *, limit: int = 50, offset: int = 0) -> list[SlotView]:
|
|
407
|
+
"""Newest-first version history for a slot."""
|
|
408
|
+
self._get_slot(slot)
|
|
409
|
+
return [self._view(slot, r) for r in self._store.list_versions(slot, limit, offset)]
|
|
410
|
+
|
|
411
|
+
def replay(self, slot: str, version: int) -> Any:
|
|
412
|
+
"""Return the exact value ``slot`` held at ``version``."""
|
|
413
|
+
self._get_slot(slot)
|
|
414
|
+
rec = self._store.get_version(slot, version)
|
|
415
|
+
if rec is None:
|
|
416
|
+
raise SlotVersionNotFoundError(slot, version)
|
|
417
|
+
return json.loads(rec.value_json)
|
|
418
|
+
|
|
419
|
+
def version_count(self, slot: str) -> int:
|
|
420
|
+
self._get_slot(slot)
|
|
421
|
+
return self._store.count_versions(slot)
|
|
422
|
+
|
|
423
|
+
# -- trace -----------------------------------------------------------------
|
|
424
|
+
def trace(
|
|
425
|
+
self,
|
|
426
|
+
*,
|
|
427
|
+
slot: str | None = None,
|
|
428
|
+
agent: str | None = None,
|
|
429
|
+
since: int | None = None,
|
|
430
|
+
kinds: list[str] | None = None,
|
|
431
|
+
limit: int = 100,
|
|
432
|
+
) -> list[TraceEvent]:
|
|
433
|
+
"""Query the handoff trace, newest first.
|
|
434
|
+
|
|
435
|
+
``since`` is a trace sequence number — poll ``trace(since=last_seq)``
|
|
436
|
+
to follow the bus live.
|
|
437
|
+
"""
|
|
438
|
+
if slot is not None:
|
|
439
|
+
self._check_slot_name(slot)
|
|
440
|
+
records = self._store.list_trace(slot, agent, since, kinds, limit)
|
|
441
|
+
return [
|
|
442
|
+
TraceEvent(
|
|
443
|
+
seq=r.seq,
|
|
444
|
+
ts=r.ts,
|
|
445
|
+
kind=r.kind,
|
|
446
|
+
slot=r.slot,
|
|
447
|
+
agent=r.agent,
|
|
448
|
+
version=r.version,
|
|
449
|
+
schema_version=r.schema_version,
|
|
450
|
+
detail=r.detail,
|
|
451
|
+
)
|
|
452
|
+
for r in records
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
# -- retention ---------------------------------------------------------------
|
|
456
|
+
@_locked
|
|
457
|
+
def prune(self, slot: str | None = None, *, by: str | None = None) -> dict[str, int]:
|
|
458
|
+
"""Enforce each slot's ``max_versions`` retention. Returns {slot: pruned}."""
|
|
459
|
+
targets = [slot] if slot else [r.name for r in self._store.list_slots()]
|
|
460
|
+
result: dict[str, int] = {}
|
|
461
|
+
for name in targets:
|
|
462
|
+
record = self._get_slot(name)
|
|
463
|
+
pruned = self._store.prune(name, record.max_versions)
|
|
464
|
+
if pruned:
|
|
465
|
+
self._trace(
|
|
466
|
+
"prune",
|
|
467
|
+
name,
|
|
468
|
+
by or self._default_agent,
|
|
469
|
+
detail={"pruned": pruned, "kept_genesis": True},
|
|
470
|
+
)
|
|
471
|
+
result[name] = pruned
|
|
472
|
+
return result
|
|
473
|
+
|
|
474
|
+
@_locked
|
|
475
|
+
def prune_trace(self, keep_last: int = DEFAULT_MAX_TRACE_EVENTS) -> int:
|
|
476
|
+
"""Bound trace storage: keep only the newest ``keep_last`` trace events.
|
|
477
|
+
|
|
478
|
+
Version retention (:meth:`prune`) does not touch the trace; call this
|
|
479
|
+
(or schedule it) on long-running buses. Returns the number removed.
|
|
480
|
+
"""
|
|
481
|
+
if keep_last < 1:
|
|
482
|
+
raise ValueError("keep_last must be >= 1")
|
|
483
|
+
return self._store.prune_trace(keep_last)
|
|
484
|
+
|
|
485
|
+
def export_slot(self, slot: str) -> dict:
|
|
486
|
+
"""Export a slot's full metadata + version history (backup before pruning)."""
|
|
487
|
+
record = self._get_slot(slot)
|
|
488
|
+
return {
|
|
489
|
+
"slot": record.name,
|
|
490
|
+
"active_schema_version": record.active_schema_version,
|
|
491
|
+
"schema_versions": copy.deepcopy(record.schema_versions),
|
|
492
|
+
"archived": record.archived,
|
|
493
|
+
"exported_at": utc_now(),
|
|
494
|
+
"versions": [
|
|
495
|
+
{
|
|
496
|
+
"version": v.version,
|
|
497
|
+
"value": v.value,
|
|
498
|
+
"agent": v.agent,
|
|
499
|
+
"schema_version": v.schema_version,
|
|
500
|
+
"ts": v.ts,
|
|
501
|
+
}
|
|
502
|
+
for v in self.history(slot, limit=self._store.count_versions(slot))
|
|
503
|
+
],
|
|
504
|
+
}
|