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 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
+ }