activegraph 1.0.0rc2__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.
Files changed (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,206 @@
1
+ """Event-count scheduler for `activate_after`. CONTRACT v0.7 #13.
2
+
3
+ The decision: v0.7's `activate_after` is event-count only, never
4
+ wall-clock. Wall-clock would require a clock-source abstraction and
5
+ break determinism under replay. Event-count is deterministic across
6
+ replay, composes with the tick model, and the escape hatch ("user
7
+ drives `runtime.tick()` and injects timer.fired events") is the v1+
8
+ extension point if anyone needs wall-clock.
9
+
10
+ How it works:
11
+
12
+ * When a triggering event matches a behavior with `activate_after=N`,
13
+ the runtime calls `scheduler.schedule(...)` instead of invoking
14
+ the behavior directly. A `behavior.scheduled` event is emitted.
15
+
16
+ * The scheduler tracks each pending invocation with a fire-at-event
17
+ counter (= current_event_count + N).
18
+
19
+ * After every non-lifecycle event the runtime processes, it calls
20
+ `scheduler.due(graph)` which returns the pending entries whose
21
+ fire counters have been reached.
22
+
23
+ * For each due entry, the runtime RE-CHECKS the `where=` clause
24
+ against the latest graph state before invoking. If it no longer
25
+ holds, the invocation is silently skipped (no extra event). If it
26
+ holds, the behavior fires normally.
27
+
28
+ Parsing `activate_after`:
29
+ * int → number of events
30
+ * str "N" or "N events" / "N event" → N events
31
+ * anything else → ValueError at registration time
32
+
33
+ Anything wall-clock (e.g. "5 minutes") is intentionally rejected with
34
+ a clear message pointing at CONTRACT v0.7 #13.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from dataclasses import dataclass, field
41
+ from typing import Any, Optional
42
+
43
+
44
+ @dataclass
45
+ class ScheduledEntry:
46
+ behavior_name: str
47
+ behavior_index: int # registry index — preserves CONTRACT #10 order
48
+ triggering_event_id: str
49
+ fire_at_event_count: int
50
+ where_recheck_path: Optional[str] # behavior's `where=` payload path is kept
51
+ scheduled_event_id: str # the behavior.scheduled event id
52
+
53
+
54
+ @dataclass
55
+ class DelayedQueue:
56
+ """Pending `activate_after` invocations. FIFO within the same fire tick."""
57
+
58
+ entries: list[ScheduledEntry] = field(default_factory=list)
59
+
60
+ def push(self, entry: ScheduledEntry) -> None:
61
+ self.entries.append(entry)
62
+
63
+ def pop_due(self, current_event_count: int) -> list[ScheduledEntry]:
64
+ due: list[ScheduledEntry] = []
65
+ kept: list[ScheduledEntry] = []
66
+ for e in self.entries:
67
+ if e.fire_at_event_count <= current_event_count:
68
+ due.append(e)
69
+ else:
70
+ kept.append(e)
71
+ self.entries = kept
72
+ return due
73
+
74
+ def __len__(self) -> int:
75
+ return len(self.entries)
76
+
77
+
78
+ _DURATION_RE = re.compile(
79
+ r"^\s*(\d+)\s*(events?)?\s*$", re.IGNORECASE
80
+ )
81
+ _WALL_CLOCK_WORDS = {
82
+ "second", "seconds", "ms", "millisecond", "milliseconds",
83
+ "minute", "minutes", "min", "mins",
84
+ "hour", "hours", "day", "days", "week", "weeks",
85
+ }
86
+
87
+
88
+ from activegraph.errors import RegistrationError as _RegistrationError
89
+
90
+
91
+ class InvalidActivateAfter(_RegistrationError, ValueError):
92
+ """``activate_after=`` on a @behavior / @llm_behavior decorator was
93
+ passed an unparseable or out-of-range value.
94
+
95
+ Multi-inherits :class:`ValueError` for back-compat with user code
96
+ catching the builtin around behavior registration.
97
+ """
98
+
99
+ _doc_slug = "invalid-activate-after"
100
+
101
+ def __init__(self, *, spec: Any, kind: str, hint: str) -> None:
102
+ self.spec = spec
103
+ self.kind = kind
104
+ self.hint = hint
105
+ from activegraph.errors import RegistrationError as _R
106
+ _R.__init__(
107
+ self,
108
+ f"activate_after={spec!r} is invalid ({kind})",
109
+ what_failed=(
110
+ f"A behavior decorator was given `activate_after={spec!r}`, "
111
+ f"which the scheduler refused as {kind}."
112
+ ),
113
+ why=(
114
+ "`activate_after` schedules a behavior to fire N events after "
115
+ "its triggering event. The runtime evaluates the schedule "
116
+ "against the event log (not wall-clock) so replay produces "
117
+ "identical timing — wall-clock units are intentionally out "
118
+ "of scope (CONTRACT v0.7 #13). An unparseable value would "
119
+ "make scheduling silently wrong, which would corrupt the "
120
+ "audit trail at every replay."
121
+ ),
122
+ how_to_fix=hint,
123
+ context={"spec": repr(spec), "kind": kind},
124
+ )
125
+
126
+
127
+ def parse_activate_after(spec: Any) -> int:
128
+ """Parse `activate_after=` into an integer event count.
129
+
130
+ Accepts:
131
+ - int N (N >= 1)
132
+ - str "N", "N event", "N events"
133
+
134
+ Rejects:
135
+ - 0 or negative
136
+ - any wall-clock unit (seconds, minutes, ...): wall-clock is
137
+ intentionally out of scope for v0.7 (see CONTRACT v0.7 #13).
138
+ """
139
+ if isinstance(spec, bool):
140
+ # bool is an int in Python — guard against accidental True/False.
141
+ raise InvalidActivateAfter(
142
+ spec=spec,
143
+ kind="bool not int",
144
+ hint=(
145
+ "Pass an integer event count instead:\n"
146
+ " activate_after=5\n"
147
+ "or a string with the 'events' unit:\n"
148
+ " activate_after='5 events'"
149
+ ),
150
+ )
151
+ if isinstance(spec, int):
152
+ n = spec
153
+ elif isinstance(spec, str):
154
+ s = spec.strip().lower()
155
+ # Detect wall-clock units and reject with a CONTRACT-pointing message.
156
+ for word in _WALL_CLOCK_WORDS:
157
+ if word in s.split():
158
+ raise InvalidActivateAfter(
159
+ spec=spec,
160
+ kind="wall-clock unit",
161
+ hint=(
162
+ f"Wall-clock units (seconds/minutes/hours) are not "
163
+ f"supported. Express the delay as an event count:\n"
164
+ f" activate_after=5\n"
165
+ f" activate_after='5 events'\n"
166
+ f"\n"
167
+ f"If you genuinely need wall-clock scheduling, file "
168
+ f"an issue — the v1+ contract leaves room for it "
169
+ f"behind a separate primitive (see CONTRACT v0.7 #23)."
170
+ ),
171
+ )
172
+ m = _DURATION_RE.match(s)
173
+ if not m:
174
+ raise InvalidActivateAfter(
175
+ spec=spec,
176
+ kind="unparseable string",
177
+ hint=(
178
+ "Use one of:\n"
179
+ " activate_after=5\n"
180
+ " activate_after='5'\n"
181
+ " activate_after='5 events'\n"
182
+ " activate_after='5 event'"
183
+ ),
184
+ )
185
+ n = int(m.group(1))
186
+ else:
187
+ raise InvalidActivateAfter(
188
+ spec=spec,
189
+ kind=f"type {type(spec).__name__}",
190
+ hint=(
191
+ "Pass an int or a string. Example:\n"
192
+ " activate_after=5\n"
193
+ " activate_after='5 events'"
194
+ ),
195
+ )
196
+ if n < 1:
197
+ raise InvalidActivateAfter(
198
+ spec=spec,
199
+ kind="must be >= 1",
200
+ hint=(
201
+ "`activate_after` schedules N events after the trigger. "
202
+ "Zero or negative N has no defined meaning. The minimum "
203
+ "is 1 (fire on the very next event after the trigger)."
204
+ ),
205
+ )
206
+ return n
@@ -0,0 +1,65 @@
1
+ """Construct a View for a behavior invocation. CONTRACT #11."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from activegraph.behaviors.base import Behavior, RelationBehavior
8
+ from activegraph.core.event import Event
9
+ from activegraph.core.graph import Graph
10
+ from activegraph.core.view import View
11
+
12
+
13
+ DEFAULT_RECENT_EVENTS = 50
14
+
15
+
16
+ def build_view(
17
+ behavior: Behavior | RelationBehavior, event: Event, graph: Graph
18
+ ) -> View:
19
+ spec = behavior.view_spec
20
+ if not spec:
21
+ return View(
22
+ objects=graph.all_objects(),
23
+ relations=graph.all_relations(),
24
+ events=graph.events[-DEFAULT_RECENT_EVENTS:],
25
+ )
26
+
27
+ around_path = spec.get("around")
28
+ depth = spec.get("depth", 1)
29
+ include_types: Optional[list[str]] = spec.get("include_types")
30
+ recent_events: int = spec.get("recent_events", DEFAULT_RECENT_EVENTS)
31
+
32
+ if around_path:
33
+ center_id = _resolve_event_path(around_path, event)
34
+ objs, rels = graph.neighborhood(center_id, depth=depth) if center_id else ([], [])
35
+ else:
36
+ objs = graph.all_objects()
37
+ rels = graph.all_relations()
38
+
39
+ if include_types:
40
+ type_set = set(include_types)
41
+ objs = [o for o in objs if o.type in type_set]
42
+
43
+ return View(
44
+ objects=objs,
45
+ relations=rels,
46
+ events=graph.events[-recent_events:] if recent_events > 0 else [],
47
+ )
48
+
49
+
50
+ def _resolve_event_path(expr: str, event: Event) -> Any:
51
+ """Resolve `event.payload.object.id` against an Event."""
52
+ parts = expr.split(".")
53
+ if not parts or parts[0] != "event":
54
+ return None
55
+ cur: Any = event
56
+ for p in parts[1:]:
57
+ if isinstance(cur, dict):
58
+ cur = cur.get(p)
59
+ elif hasattr(cur, p):
60
+ cur = getattr(cur, p)
61
+ else:
62
+ return None
63
+ if cur is None:
64
+ return None
65
+ return cur
@@ -0,0 +1,41 @@
1
+ """Pluggable event-log stores. CONTRACT v0.5 #2, v0.8 #1–#2.
2
+
3
+ Implementations:
4
+ - InMemoryEventStore (v0.5): volatile, used by tests and ephemeral runs.
5
+ - SQLiteEventStore (v0.5): durable single-file. Default for solo work.
6
+ - PostgresEventStore (v0.8): shared-state, multi-process. Opt-in dep.
7
+
8
+ The EventStore protocol lives in ``store.base``. Custom backends conform
9
+ to that protocol; nothing in the runtime imports concrete stores directly.
10
+ The ``open_store(url, run_id)`` entry point picks the right driver from a
11
+ connection URL (sqlite:///... or postgres://...).
12
+ """
13
+
14
+ from activegraph.store.base import EventStore, RunRecord, replay_into
15
+ from activegraph.store.errors import (
16
+ CorruptedEventPayloadError,
17
+ DuplicateEventError,
18
+ EventNotFoundError,
19
+ SchemaVersionMismatch,
20
+ )
21
+ from activegraph.store.memory import InMemoryEventStore
22
+ from activegraph.store.serde import NonSerializableEventError
23
+ from activegraph.store.sqlite import SQLiteEventStore
24
+ from activegraph.store.url import InvalidStoreURL, StoreURL, open_store, parse_store_url
25
+
26
+ __all__ = [
27
+ "CorruptedEventPayloadError",
28
+ "DuplicateEventError",
29
+ "EventNotFoundError",
30
+ "EventStore",
31
+ "InMemoryEventStore",
32
+ "InvalidStoreURL",
33
+ "NonSerializableEventError",
34
+ "RunRecord",
35
+ "SQLiteEventStore",
36
+ "SchemaVersionMismatch",
37
+ "StoreURL",
38
+ "open_store",
39
+ "parse_store_url",
40
+ "replay_into",
41
+ ]
@@ -0,0 +1,66 @@
1
+ """EventStore interface + run metadata. CONTRACT v0.5 #2 and #6.
2
+
3
+ An EventStore is a per-run, append-only view onto an event log. Multiple
4
+ runs may share a backing file (SQLite); the EventStore instance is scoped
5
+ to one `run_id` and only sees that run's events.
6
+
7
+ Methods are deliberately minimal — append, iterate, count, lookup,
8
+ truncate-after. No queries, no indexes beyond what the backend ships. This
9
+ is an event log, not a database.
10
+
11
+ The accompanying `RunRecord` is the canonical row in the `runs` table:
12
+ parent linkage for forks, an optional label, and the original goal/frame.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import Iterable, Iterator, Optional, Protocol
19
+
20
+ from activegraph.core.event import Event
21
+
22
+
23
+ @dataclass
24
+ class RunRecord:
25
+ run_id: str
26
+ parent_run_id: Optional[str]
27
+ forked_at_event_id: Optional[str]
28
+ label: Optional[str]
29
+ created_at: str
30
+ goal: Optional[str]
31
+ frame_id: Optional[str]
32
+
33
+
34
+ class EventStore(Protocol):
35
+ """Append-only per-run event log. CONTRACT v0.5 #2."""
36
+
37
+ run_id: str
38
+
39
+ def append(self, event: Event) -> None: ...
40
+
41
+ def iter_events(
42
+ self,
43
+ after: Optional[str] = None,
44
+ until: Optional[str] = None,
45
+ ) -> Iterator[Event]: ...
46
+
47
+ def get_event(self, event_id: str) -> Optional[Event]: ...
48
+
49
+ def count(self) -> int: ...
50
+
51
+ def truncate_after(self, event_id: str) -> None: ...
52
+
53
+ def close(self) -> None: ...
54
+
55
+
56
+ def replay_into(graph, events: Iterable[Event]) -> int:
57
+ """Apply a stream of events to a Graph without firing listeners.
58
+
59
+ The single replay entry point — used by `Runtime.load` and `Runtime.fork`.
60
+ Returns the number of events replayed.
61
+ """
62
+ n = 0
63
+ for ev in events:
64
+ graph._replay_event(ev) # noqa: SLF001 — internal seam by design
65
+ n += 1
66
+ return n
@@ -0,0 +1,161 @@
1
+ """EventStore conformance suite. CONTRACT v0.8 #18.
2
+
3
+ A reusable pytest-compatible base class that exercises any EventStore
4
+ implementation against the protocol. Concrete subclasses override
5
+ ``make_store(run_id)`` and ``cleanup()``; the tests run identically.
6
+
7
+ The InMemory, SQLite, and (with testcontainers) Postgres stores all run
8
+ through this suite. Any future store implementation gets free coverage
9
+ by subclassing.
10
+
11
+ The suite intentionally avoids pytest fixtures and yields plain
12
+ ``unittest.TestCase``-style methods, so it works under any test runner.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from abc import ABC, abstractmethod
18
+ from typing import Any
19
+
20
+ import pytest
21
+
22
+ from activegraph.core.event import Event
23
+
24
+
25
+ class EventStoreConformance(ABC):
26
+ """Mix into a pytest test class to inherit the full suite.
27
+
28
+ Subclasses MUST implement:
29
+
30
+ def make_store(self, run_id: str) -> EventStore: ...
31
+ def cleanup(self) -> None: ...
32
+
33
+ Tests are method names starting with ``test_``. They are picked up
34
+ by pytest automatically when the concrete subclass is collected.
35
+
36
+ The base class is abstract (no ``test_`` methods callable on it)
37
+ by virtue of ``make_store`` raising on the abstract class. pytest
38
+ will still try to collect it; the ``__test__ = False`` attribute
39
+ prevents that.
40
+ """
41
+
42
+ __test__ = False # do not collect the base; subclasses override.
43
+
44
+ @abstractmethod
45
+ def make_store(self, run_id: str) -> Any:
46
+ """Return a fresh EventStore for ``run_id``. Called per test."""
47
+
48
+ def cleanup(self) -> None:
49
+ """Tear down any resources after a test. Default: no-op."""
50
+
51
+ # ---- helpers ----
52
+
53
+ def _ev(self, eid: str, type_: str = "object.created", payload: dict | None = None) -> Event:
54
+ return Event(
55
+ id=eid,
56
+ type=type_,
57
+ payload=payload or {"k": "v"},
58
+ actor="test",
59
+ frame_id=None,
60
+ caused_by=None,
61
+ timestamp="2026-01-01T00:00:00Z",
62
+ )
63
+
64
+ # ---- the suite ----
65
+
66
+ def test_append_then_iter_in_order(self) -> None:
67
+ try:
68
+ store = self.make_store("run_conformance_1")
69
+ for i in range(5):
70
+ store.append(self._ev(f"evt_{i}"))
71
+ ids = [e.id for e in store.iter_events()]
72
+ assert ids == [f"evt_{i}" for i in range(5)]
73
+ finally:
74
+ self.cleanup()
75
+
76
+ def test_count(self) -> None:
77
+ try:
78
+ store = self.make_store("run_conformance_2")
79
+ assert store.count() == 0
80
+ store.append(self._ev("evt_a"))
81
+ store.append(self._ev("evt_b"))
82
+ assert store.count() == 2
83
+ finally:
84
+ self.cleanup()
85
+
86
+ def test_get_event_known_and_unknown(self) -> None:
87
+ try:
88
+ store = self.make_store("run_conformance_3")
89
+ store.append(self._ev("evt_known"))
90
+ got = store.get_event("evt_known")
91
+ assert got is not None
92
+ assert got.id == "evt_known"
93
+ assert store.get_event("evt_missing") is None
94
+ finally:
95
+ self.cleanup()
96
+
97
+ def test_iter_after_skips_inclusive_boundary(self) -> None:
98
+ try:
99
+ store = self.make_store("run_conformance_4")
100
+ for i in range(4):
101
+ store.append(self._ev(f"evt_{i}"))
102
+ tail = [e.id for e in store.iter_events(after="evt_1")]
103
+ assert tail == ["evt_2", "evt_3"]
104
+ finally:
105
+ self.cleanup()
106
+
107
+ def test_iter_until_includes_boundary(self) -> None:
108
+ try:
109
+ store = self.make_store("run_conformance_5")
110
+ for i in range(4):
111
+ store.append(self._ev(f"evt_{i}"))
112
+ head = [e.id for e in store.iter_events(until="evt_2")]
113
+ assert head == ["evt_0", "evt_1", "evt_2"]
114
+ finally:
115
+ self.cleanup()
116
+
117
+ def test_truncate_after_drops_tail(self) -> None:
118
+ try:
119
+ store = self.make_store("run_conformance_6")
120
+ for i in range(5):
121
+ store.append(self._ev(f"evt_{i}"))
122
+ store.truncate_after("evt_2")
123
+ ids = [e.id for e in store.iter_events()]
124
+ assert ids == ["evt_0", "evt_1", "evt_2"]
125
+ finally:
126
+ self.cleanup()
127
+
128
+ def test_payload_round_trip_preserves_structure(self) -> None:
129
+ try:
130
+ store = self.make_store("run_conformance_7")
131
+ payload = {
132
+ "nested": {"k": [1, 2, {"a": "b"}]},
133
+ "unicode": "café — 🚀",
134
+ "empty": [],
135
+ "null_in_value": None,
136
+ }
137
+ store.append(self._ev("evt_payload", payload=payload))
138
+ got = store.get_event("evt_payload")
139
+ assert got is not None
140
+ assert got.payload == payload
141
+ finally:
142
+ self.cleanup()
143
+
144
+ def test_duplicate_id_in_same_run_is_rejected(self) -> None:
145
+ try:
146
+ store = self.make_store("run_conformance_8")
147
+ store.append(self._ev("evt_dup"))
148
+ with pytest.raises(Exception):
149
+ store.append(self._ev("evt_dup"))
150
+ finally:
151
+ self.cleanup()
152
+
153
+ def test_close_is_idempotent(self) -> None:
154
+ try:
155
+ store = self.make_store("run_conformance_9")
156
+ store.append(self._ev("evt_a"))
157
+ store.close()
158
+ # Closing twice should not raise.
159
+ store.close()
160
+ finally:
161
+ self.cleanup()
@@ -0,0 +1,84 @@
1
+ """Storage-layer error leaves. v1.0 PR-C — StorageError category.
2
+
3
+ The classes here are the v1.0-format leaves under
4
+ :class:`activegraph.errors.StorageError`. The two pre-v1.0 storage
5
+ errors (:class:`NonSerializableEventError` in ``serde.py`` and
6
+ :class:`InvalidStoreURL` in ``url.py``) stay in their topic modules
7
+ and are re-parented in this PR; everything new lives here.
8
+
9
+ Multi-inheritance with Python builtins (KeyError, ValueError) is used
10
+ where existing user code conventionally catches the builtin —
11
+ ``except KeyError`` around a store lookup, ``except ValueError`` around
12
+ event insertion. Preserves the catch site.
13
+
14
+ DB-driver errors (sqlite3.OperationalError, psycopg.OperationalError)
15
+ are NOT wrapped in this PR. The failure modes are driver-specific and
16
+ the recovery prose varies enough per mode (WAL contention, auth, host
17
+ unreachable, db missing, conn dropped) that a dedicated DB-error PR
18
+ will cover them with the right per-mode "Why:" and "How to fix:"
19
+ prose. Flagged in CONTRACT v1.0 PR-C section, not silently dropped.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from activegraph.errors import StorageError
25
+
26
+
27
+ class SchemaVersionMismatch(StorageError):
28
+ """The store's recorded ``schema_version`` doesn't match what this
29
+ activegraph build expects.
30
+
31
+ Fires on store open. The store file is intact; it was just written
32
+ by a different (older or newer) activegraph build. Recovery is
33
+ one of three things: upgrade activegraph, downgrade the store via
34
+ migration, or migrate the run to a fresh store with the current
35
+ build.
36
+ """
37
+
38
+ _doc_slug = "schema-version-mismatch"
39
+
40
+
41
+ class EventNotFoundError(StorageError, KeyError):
42
+ """An event id wasn't found in the run's event log.
43
+
44
+ Multi-inherits :class:`KeyError` so user code that does
45
+ ``except KeyError`` around store lookups keeps working. Fires from
46
+ every ``store.get_event(event_id)`` and from the fork primitive
47
+ when ``--at-event`` names a missing id.
48
+ """
49
+
50
+ _doc_slug = "event-not-found-error"
51
+
52
+
53
+ class DuplicateEventError(StorageError, ValueError):
54
+ """Two events with the same id were appended to the same run.
55
+
56
+ Multi-inherits :class:`ValueError` for back-compat with user code
57
+ catching ValueError around appends. Fires only on programmer error:
58
+ the runtime's id generator is monotonic so duplicates shouldn't
59
+ arise in normal use. Common cause: hand-constructing events with
60
+ fixed ids in a test fixture.
61
+ """
62
+
63
+ _doc_slug = "duplicate-event-error"
64
+
65
+
66
+ class CorruptedEventPayloadError(StorageError):
67
+ """A stored event payload couldn't be decoded as JSON.
68
+
69
+ Fires at load-time when a row's payload column contains invalid
70
+ JSON. Distinct from :class:`NonSerializableEventError`, which fires
71
+ at emit-time when a Python value can't be encoded to JSON.
72
+ Corruption-on-load means the bytes on disk don't parse — a
73
+ different failure mode requiring a different recovery.
74
+ """
75
+
76
+ _doc_slug = "corrupted-event-payload-error"
77
+
78
+
79
+ __all__ = [
80
+ "SchemaVersionMismatch",
81
+ "EventNotFoundError",
82
+ "DuplicateEventError",
83
+ "CorruptedEventPayloadError",
84
+ ]