activegraph 1.0.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.
- activegraph/__init__.py +222 -0
- activegraph/__main__.py +5 -0
- activegraph/behaviors/__init__.py +1 -0
- activegraph/behaviors/base.py +153 -0
- activegraph/behaviors/decorators.py +218 -0
- activegraph/cli/__init__.py +17 -0
- activegraph/cli/main.py +793 -0
- activegraph/cli/quickstart.py +556 -0
- activegraph/core/__init__.py +1 -0
- activegraph/core/clock.py +46 -0
- activegraph/core/event.py +33 -0
- activegraph/core/graph.py +846 -0
- activegraph/core/ids.py +135 -0
- activegraph/core/patch.py +47 -0
- activegraph/core/view.py +59 -0
- activegraph/errors.py +345 -0
- activegraph/frame.py +15 -0
- activegraph/llm/__init__.py +51 -0
- activegraph/llm/anthropic.py +339 -0
- activegraph/llm/cache.py +180 -0
- activegraph/llm/errors.py +257 -0
- activegraph/llm/prompt.py +420 -0
- activegraph/llm/provider.py +66 -0
- activegraph/llm/recorded.py +270 -0
- activegraph/llm/types.py +130 -0
- activegraph/observability/__init__.py +61 -0
- activegraph/observability/logging.py +205 -0
- activegraph/observability/metrics.py +219 -0
- activegraph/observability/migration.py +404 -0
- activegraph/observability/prometheus.py +101 -0
- activegraph/observability/status.py +83 -0
- activegraph/packs/__init__.py +999 -0
- activegraph/packs/diligence/__init__.py +86 -0
- activegraph/packs/diligence/behaviors.py +447 -0
- activegraph/packs/diligence/fixtures/__init__.py +364 -0
- activegraph/packs/diligence/fixtures/companies.py +511 -0
- activegraph/packs/diligence/object_types.py +163 -0
- activegraph/packs/diligence/prompts/document_researcher.md +25 -0
- activegraph/packs/diligence/prompts/memo_synthesizer.md +35 -0
- activegraph/packs/diligence/prompts/question_generator.md +25 -0
- activegraph/packs/diligence/prompts/risk_identifier.md +26 -0
- activegraph/packs/diligence/settings.py +60 -0
- activegraph/packs/diligence/tools.py +124 -0
- activegraph/packs/loader.py +709 -0
- activegraph/packs/scaffold.py +317 -0
- activegraph/policy.py +20 -0
- activegraph/runtime/__init__.py +1 -0
- activegraph/runtime/behavior_graph.py +151 -0
- activegraph/runtime/budget.py +120 -0
- activegraph/runtime/config_errors.py +124 -0
- activegraph/runtime/diff.py +145 -0
- activegraph/runtime/errors.py +216 -0
- activegraph/runtime/exec_errors.py +232 -0
- activegraph/runtime/patterns.py +946 -0
- activegraph/runtime/queue.py +27 -0
- activegraph/runtime/registration_errors.py +291 -0
- activegraph/runtime/registry.py +111 -0
- activegraph/runtime/runtime.py +2441 -0
- activegraph/runtime/scheduler.py +206 -0
- activegraph/runtime/view_builder.py +65 -0
- activegraph/store/__init__.py +41 -0
- activegraph/store/base.py +66 -0
- activegraph/store/conformance.py +161 -0
- activegraph/store/errors.py +84 -0
- activegraph/store/memory.py +118 -0
- activegraph/store/postgres.py +609 -0
- activegraph/store/serde.py +202 -0
- activegraph/store/sqlite.py +446 -0
- activegraph/store/url.py +230 -0
- activegraph/tools/__init__.py +64 -0
- activegraph/tools/base.py +57 -0
- activegraph/tools/cache.py +157 -0
- activegraph/tools/context.py +48 -0
- activegraph/tools/decorators.py +70 -0
- activegraph/tools/errors.py +320 -0
- activegraph/tools/graph_query.py +94 -0
- activegraph/tools/recorded.py +205 -0
- activegraph/tools/web_fetch.py +80 -0
- activegraph/trace/__init__.py +1 -0
- activegraph/trace/causal.py +123 -0
- activegraph/trace/printer.py +495 -0
- activegraph-1.0.0.dist-info/METADATA +282 -0
- activegraph-1.0.0.dist-info/RECORD +86 -0
- activegraph-1.0.0.dist-info/WHEEL +5 -0
- activegraph-1.0.0.dist-info/entry_points.txt +5 -0
- activegraph-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Event JSON serialization. CONTRACT v0.5 #4 — JSON only, human-inspectable.
|
|
2
|
+
|
|
3
|
+
The store persists `event.to_dict()` payloads as JSON. Custom types
|
|
4
|
+
serialize through the strict adapter below: anything we cannot encode is
|
|
5
|
+
raised as `NonSerializableEventError` at `Graph.emit` time, never silently
|
|
6
|
+
dropped or pickled.
|
|
7
|
+
|
|
8
|
+
Decimals serialize to their canonical string form; datetimes serialize to
|
|
9
|
+
ISO 8601. Loading does NOT round-trip these back to Decimal/datetime —
|
|
10
|
+
payload semantics stay flat dicts of JSON primitives. Behaviors that need
|
|
11
|
+
typed values should keep them as strings in the payload.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from datetime import date, datetime
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from activegraph.core.event import Event
|
|
22
|
+
from activegraph.errors import StorageError
|
|
23
|
+
from activegraph.store.errors import CorruptedEventPayloadError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NonSerializableEventError(StorageError, TypeError):
|
|
27
|
+
"""Raised at emit-time when a payload value cannot be JSON-encoded.
|
|
28
|
+
|
|
29
|
+
Multi-inherits :class:`TypeError` so user code that does
|
|
30
|
+
``except TypeError`` around emit/append calls keeps working.
|
|
31
|
+
Distinct from :class:`CorruptedEventPayloadError` — this is the
|
|
32
|
+
encode-side failure (Python value cannot be made into JSON);
|
|
33
|
+
that one is the decode-side failure (JSON bytes cannot be made
|
|
34
|
+
into a Python value).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_doc_slug = "non-serializable-event-error"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _default(o: Any) -> Any:
|
|
41
|
+
if isinstance(o, Decimal):
|
|
42
|
+
return str(o)
|
|
43
|
+
if isinstance(o, (datetime, date)):
|
|
44
|
+
return o.isoformat()
|
|
45
|
+
if isinstance(o, (set, frozenset)):
|
|
46
|
+
# Stable order for snapshot-friendliness.
|
|
47
|
+
return sorted(o)
|
|
48
|
+
raise TypeError(f"object of type {type(o).__name__} is not JSON-serializable")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def encode_payload(payload: dict[str, Any]) -> str:
|
|
52
|
+
"""JSON-encode an event payload or raise NonSerializableEventError."""
|
|
53
|
+
try:
|
|
54
|
+
return json.dumps(payload, default=_default, sort_keys=False, ensure_ascii=False)
|
|
55
|
+
except TypeError as e:
|
|
56
|
+
# Try to identify the offending field by walking the payload.
|
|
57
|
+
offender_path, offender_type = _find_non_serializable(payload)
|
|
58
|
+
raise NonSerializableEventError(
|
|
59
|
+
f"event payload field {offender_path!r} is not JSON-serializable "
|
|
60
|
+
f"(type: {offender_type})",
|
|
61
|
+
what_failed=(
|
|
62
|
+
f"While encoding an event payload for the store, the value at "
|
|
63
|
+
f"{offender_path!r} (type {offender_type}) could not be JSON-"
|
|
64
|
+
f"encoded.\n underlying: {e}"
|
|
65
|
+
),
|
|
66
|
+
why=(
|
|
67
|
+
"The store persists events as JSON so the audit trail is "
|
|
68
|
+
"human-inspectable and round-trips through any JSON-aware "
|
|
69
|
+
"tool. Custom Python types serialize through a strict "
|
|
70
|
+
"adapter (Decimal → string, datetime → ISO 8601, set → "
|
|
71
|
+
"sorted list); anything else is refused at emit-time rather "
|
|
72
|
+
"than silently pickled or dropped, because a silently-"
|
|
73
|
+
"dropped event would corrupt the replay contract."
|
|
74
|
+
),
|
|
75
|
+
how_to_fix=(
|
|
76
|
+
f"Convert {offender_path!r} to a JSON primitive before "
|
|
77
|
+
f"emitting. Common fixes:\n"
|
|
78
|
+
f" - Pydantic model: payload['{offender_path}'] = model.model_dump()\n"
|
|
79
|
+
f" - dataclass: payload['{offender_path}'] = dataclasses.asdict(value)\n"
|
|
80
|
+
f" - custom object: payload['{offender_path}'] = str(value)\n"
|
|
81
|
+
f"\n"
|
|
82
|
+
f"If the type really should serialize, add an adapter clause "
|
|
83
|
+
f"to ``_default`` in activegraph/store/serde.py (Decimal and "
|
|
84
|
+
f"datetime are precedents)."
|
|
85
|
+
),
|
|
86
|
+
context={"path": offender_path, "type": offender_type},
|
|
87
|
+
) from e
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _find_non_serializable(payload: Any, path: str = "") -> tuple[str, str]:
|
|
91
|
+
"""Walk ``payload`` to find the first value that isn't JSON-encodable
|
|
92
|
+
by the strict adapter. Returns ``(path, type_name)``. Falls back to
|
|
93
|
+
``("<unknown>", "<unknown>")`` if the walk completes without finding
|
|
94
|
+
a culprit (shouldn't happen — encode_payload only calls us after
|
|
95
|
+
json.dumps already raised).
|
|
96
|
+
"""
|
|
97
|
+
if isinstance(payload, dict):
|
|
98
|
+
for k, v in payload.items():
|
|
99
|
+
sub = f"{path}.{k}" if path else str(k)
|
|
100
|
+
try:
|
|
101
|
+
json.dumps(v, default=_default)
|
|
102
|
+
except TypeError:
|
|
103
|
+
return _find_non_serializable(v, sub)
|
|
104
|
+
return (path or "<root>", type(payload).__name__)
|
|
105
|
+
if isinstance(payload, list):
|
|
106
|
+
for i, v in enumerate(payload):
|
|
107
|
+
sub = f"{path}[{i}]"
|
|
108
|
+
try:
|
|
109
|
+
json.dumps(v, default=_default)
|
|
110
|
+
except TypeError:
|
|
111
|
+
return _find_non_serializable(v, sub)
|
|
112
|
+
return (path or "<root>", type(payload).__name__)
|
|
113
|
+
return (path or "<root>", type(payload).__name__)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def decode_payload(s: str) -> dict[str, Any]:
|
|
117
|
+
"""JSON-decode a stored event payload, or raise CorruptedEventPayloadError.
|
|
118
|
+
|
|
119
|
+
Wraps :class:`json.JSONDecodeError` so corrupt store contents fail
|
|
120
|
+
with a structured message rather than bubbling the parser exception.
|
|
121
|
+
The store's ``replay_into`` and ``iter_events`` are the entry points;
|
|
122
|
+
corruption is visible at the call site that triggered the load.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
return json.loads(s)
|
|
126
|
+
except json.JSONDecodeError as e:
|
|
127
|
+
preview = s if len(s) <= 64 else s[:60] + " ..."
|
|
128
|
+
raise CorruptedEventPayloadError(
|
|
129
|
+
f"event payload could not be decoded as JSON (at column {e.colno})",
|
|
130
|
+
what_failed=(
|
|
131
|
+
f"While reading a stored event payload, the JSON parser failed "
|
|
132
|
+
f"at line {e.lineno}, column {e.colno}:\n"
|
|
133
|
+
f" {e.msg}\n"
|
|
134
|
+
f" payload preview: {preview!r}"
|
|
135
|
+
),
|
|
136
|
+
why=(
|
|
137
|
+
"The store persists every event payload as JSON. A row that "
|
|
138
|
+
"doesn't parse as JSON means the bytes on disk are corrupted, "
|
|
139
|
+
"the store schema is mismatched (someone wrote a non-JSON "
|
|
140
|
+
"format here), or an out-of-band edit damaged the file. The "
|
|
141
|
+
"framework refuses to silently skip the row — that would "
|
|
142
|
+
"make the replay contract unverifiable, and the next fork or "
|
|
143
|
+
"diff would lie about what happened."
|
|
144
|
+
),
|
|
145
|
+
how_to_fix=(
|
|
146
|
+
"Recover the readable subset of the run by migrating with\n"
|
|
147
|
+
"--skip-corrupted (this skips only the unreadable events and\n"
|
|
148
|
+
"keeps everything else; the destination run is partial but\n"
|
|
149
|
+
"load-bearing events around the corruption are preserved):\n"
|
|
150
|
+
" activegraph migrate --from <src> --to <new-dst> --skip-corrupted\n"
|
|
151
|
+
"The skipped event ids appear in the per-run report.\n"
|
|
152
|
+
"\n"
|
|
153
|
+
"If you need to inspect the corruption first:\n"
|
|
154
|
+
" activegraph inspect <store> --tail 50\n"
|
|
155
|
+
"\n"
|
|
156
|
+
"If you have the original payload elsewhere (a previous run,\n"
|
|
157
|
+
"a backup, a log), open the store directly with sqlite3 / psql\n"
|
|
158
|
+
"and repair the row in place — preferable to skip-and-lose.\n"
|
|
159
|
+
"\n"
|
|
160
|
+
"If the corruption is intrinsic and the run is not worth\n"
|
|
161
|
+
"recovering, re-run from the original goal in a fresh store.\n"
|
|
162
|
+
"The store is append-only; partial corruption does not\n"
|
|
163
|
+
"propagate backward in time."
|
|
164
|
+
),
|
|
165
|
+
context={
|
|
166
|
+
"line": e.lineno,
|
|
167
|
+
"column": e.colno,
|
|
168
|
+
"preview": preview,
|
|
169
|
+
"underlying_msg": e.msg,
|
|
170
|
+
},
|
|
171
|
+
) from e
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def encode_event(event: Event) -> dict[str, Any]:
|
|
175
|
+
"""Encode an Event to a row-ready dict (payload becomes a JSON string)."""
|
|
176
|
+
return {
|
|
177
|
+
"id": event.id,
|
|
178
|
+
"type": event.type,
|
|
179
|
+
"payload": encode_payload(event.payload),
|
|
180
|
+
"actor": event.actor,
|
|
181
|
+
"frame_id": event.frame_id,
|
|
182
|
+
"caused_by": event.caused_by,
|
|
183
|
+
"timestamp": event.timestamp,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def decode_event(row: dict[str, Any]) -> Event:
|
|
188
|
+
"""Decode a stored row back into an Event."""
|
|
189
|
+
return Event(
|
|
190
|
+
id=row["id"],
|
|
191
|
+
type=row["type"],
|
|
192
|
+
payload=decode_payload(row["payload"]),
|
|
193
|
+
actor=row.get("actor"),
|
|
194
|
+
frame_id=row.get("frame_id"),
|
|
195
|
+
caused_by=row.get("caused_by"),
|
|
196
|
+
timestamp=row.get("timestamp", ""),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def validate_event(event: Event) -> None:
|
|
201
|
+
"""Fail-fast: ensure the event can be serialized before we mutate state."""
|
|
202
|
+
encode_payload(event.payload)
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""SQLite-backed EventStore. CONTRACT v0.5 #3 (schema locked).
|
|
2
|
+
|
|
3
|
+
Schema lives in `_SCHEMA` below; any change requires bumping the
|
|
4
|
+
`schema_version` row in the `meta` table.
|
|
5
|
+
|
|
6
|
+
events(seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7
|
+
id TEXT NOT NULL,
|
|
8
|
+
type TEXT NOT NULL,
|
|
9
|
+
actor TEXT,
|
|
10
|
+
payload TEXT NOT NULL, -- JSON
|
|
11
|
+
frame_id TEXT,
|
|
12
|
+
caused_by TEXT,
|
|
13
|
+
timestamp TEXT NOT NULL,
|
|
14
|
+
run_id TEXT NOT NULL,
|
|
15
|
+
UNIQUE(id, run_id))
|
|
16
|
+
|
|
17
|
+
runs(run_id TEXT PRIMARY KEY,
|
|
18
|
+
parent_run_id TEXT,
|
|
19
|
+
forked_at_event_id TEXT,
|
|
20
|
+
label TEXT,
|
|
21
|
+
created_at TEXT NOT NULL,
|
|
22
|
+
goal TEXT,
|
|
23
|
+
frame_id TEXT)
|
|
24
|
+
|
|
25
|
+
meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)
|
|
26
|
+
-- carries schema_version since day one.
|
|
27
|
+
|
|
28
|
+
WAL is enabled on every open. `seq` is the projection ordering authority,
|
|
29
|
+
not `timestamp` — wall clocks can lie; AUTOINCREMENT cannot.
|
|
30
|
+
|
|
31
|
+
NOTE on the UNIQUE constraint (CONTRACT v0.5 diff #3): the locked schema
|
|
32
|
+
said `id TEXT NOT NULL UNIQUE`. That clashes with decision #12 (logical
|
|
33
|
+
IDs are scoped to run_id; a fork preserves the parent's `evt_017`). We
|
|
34
|
+
keep the column shape and intent — IDs are unique within a run — but the
|
|
35
|
+
constraint is `UNIQUE(id, run_id)`. Stored ids are the logical ids; no
|
|
36
|
+
prefixing, no hidden scoping.
|
|
37
|
+
|
|
38
|
+
A SQLiteEventStore is scoped to ONE `run_id`. Other runs in the same file
|
|
39
|
+
are accessed via separate SQLiteEventStore instances pointing at the same
|
|
40
|
+
path. Classmethods below (`list_runs`, `most_recent_run_id`, `fork_run`)
|
|
41
|
+
are file-level helpers.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import sqlite3
|
|
47
|
+
from typing import Any, Iterator, Optional
|
|
48
|
+
|
|
49
|
+
from activegraph.core.event import Event
|
|
50
|
+
from activegraph.store.base import RunRecord
|
|
51
|
+
from activegraph.store.serde import decode_event, encode_event
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SCHEMA_VERSION = "1"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_SCHEMA = [
|
|
58
|
+
"""
|
|
59
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
60
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
61
|
+
id TEXT NOT NULL,
|
|
62
|
+
type TEXT NOT NULL,
|
|
63
|
+
actor TEXT,
|
|
64
|
+
payload TEXT NOT NULL,
|
|
65
|
+
frame_id TEXT,
|
|
66
|
+
caused_by TEXT,
|
|
67
|
+
timestamp TEXT NOT NULL,
|
|
68
|
+
run_id TEXT NOT NULL,
|
|
69
|
+
UNIQUE(id, run_id)
|
|
70
|
+
)
|
|
71
|
+
""",
|
|
72
|
+
"CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, seq)",
|
|
73
|
+
"CREATE INDEX IF NOT EXISTS idx_events_type ON events(type)",
|
|
74
|
+
"""
|
|
75
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
76
|
+
run_id TEXT PRIMARY KEY,
|
|
77
|
+
parent_run_id TEXT,
|
|
78
|
+
forked_at_event_id TEXT,
|
|
79
|
+
label TEXT,
|
|
80
|
+
created_at TEXT NOT NULL,
|
|
81
|
+
goal TEXT,
|
|
82
|
+
frame_id TEXT
|
|
83
|
+
)
|
|
84
|
+
""",
|
|
85
|
+
"""
|
|
86
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
87
|
+
key TEXT PRIMARY KEY,
|
|
88
|
+
value TEXT NOT NULL
|
|
89
|
+
)
|
|
90
|
+
""",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|
95
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
96
|
+
# WAL + synchronous=NORMAL: group-committed fsyncs, no fsync per write.
|
|
97
|
+
# Crash-safe across process crashes; OS crash may lose the last committed
|
|
98
|
+
# transactions but never corrupts the file. Sufficient for an event log
|
|
99
|
+
# and ~25x faster than the default FULL.
|
|
100
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
101
|
+
for stmt in _SCHEMA:
|
|
102
|
+
conn.execute(stmt)
|
|
103
|
+
row = conn.execute(
|
|
104
|
+
"SELECT value FROM meta WHERE key = 'schema_version'"
|
|
105
|
+
).fetchone()
|
|
106
|
+
if row is None:
|
|
107
|
+
conn.execute(
|
|
108
|
+
"INSERT INTO meta(key, value) VALUES ('schema_version', ?)",
|
|
109
|
+
(SCHEMA_VERSION,),
|
|
110
|
+
)
|
|
111
|
+
elif row[0] != SCHEMA_VERSION:
|
|
112
|
+
from activegraph import __version__ as _aw_version
|
|
113
|
+
from activegraph.store.errors import SchemaVersionMismatch
|
|
114
|
+
raise SchemaVersionMismatch(
|
|
115
|
+
f"sqlite store schema_version {row[0]!r} does not match this build's expected {SCHEMA_VERSION!r}",
|
|
116
|
+
what_failed=(
|
|
117
|
+
f"The SQLite store records schema_version={row[0]!r} in its meta table, "
|
|
118
|
+
f"but activegraph {_aw_version} expects schema_version={SCHEMA_VERSION!r}."
|
|
119
|
+
),
|
|
120
|
+
why=(
|
|
121
|
+
"The store file format evolves with the framework. The runtime "
|
|
122
|
+
"refuses to read a store with a different schema_version rather "
|
|
123
|
+
"than risk silent data loss — a newer framework might interpret "
|
|
124
|
+
"columns differently than the writer did, and an older framework "
|
|
125
|
+
"might drop fields it doesn't recognize. Either direction would "
|
|
126
|
+
"corrupt the audit trail."
|
|
127
|
+
),
|
|
128
|
+
how_to_fix=(
|
|
129
|
+
f"One of three actions:\n"
|
|
130
|
+
f" 1. Install the activegraph version that wrote this store\n"
|
|
131
|
+
f" (whichever shipped schema_version={row[0]!r}).\n"
|
|
132
|
+
f" 2. Migrate the run to a store written by this build:\n"
|
|
133
|
+
f" activegraph migrate <src-url> <new-dst-url>\n"
|
|
134
|
+
f" The destination is written with the current schema.\n"
|
|
135
|
+
f" 3. If the store is empty or expendable, delete and re-run.\n"
|
|
136
|
+
f"\n"
|
|
137
|
+
f"Schema version history is documented in CHANGELOG.md."
|
|
138
|
+
),
|
|
139
|
+
context={
|
|
140
|
+
"found_version": row[0],
|
|
141
|
+
"expected_version": SCHEMA_VERSION,
|
|
142
|
+
"activegraph_version": _aw_version,
|
|
143
|
+
"driver": "sqlite",
|
|
144
|
+
},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _row_to_event(row: sqlite3.Row) -> Event:
|
|
149
|
+
return decode_event(
|
|
150
|
+
{
|
|
151
|
+
"id": row["id"],
|
|
152
|
+
"type": row["type"],
|
|
153
|
+
"payload": row["payload"],
|
|
154
|
+
"actor": row["actor"],
|
|
155
|
+
"frame_id": row["frame_id"],
|
|
156
|
+
"caused_by": row["caused_by"],
|
|
157
|
+
"timestamp": row["timestamp"],
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _row_to_run(row: sqlite3.Row) -> RunRecord:
|
|
163
|
+
return RunRecord(
|
|
164
|
+
run_id=row["run_id"],
|
|
165
|
+
parent_run_id=row["parent_run_id"],
|
|
166
|
+
forked_at_event_id=row["forked_at_event_id"],
|
|
167
|
+
label=row["label"],
|
|
168
|
+
created_at=row["created_at"],
|
|
169
|
+
goal=row["goal"],
|
|
170
|
+
frame_id=row["frame_id"],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class SQLiteEventStore:
|
|
175
|
+
"""Per-run view onto a SQLite-backed event log."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, path: str, run_id: str) -> None:
|
|
178
|
+
self.path = path
|
|
179
|
+
self.run_id = run_id
|
|
180
|
+
self._conn = sqlite3.connect(path, isolation_level=None)
|
|
181
|
+
self._conn.row_factory = sqlite3.Row
|
|
182
|
+
_ensure_schema(self._conn)
|
|
183
|
+
|
|
184
|
+
# ---------- EventStore protocol ----------
|
|
185
|
+
|
|
186
|
+
def append(self, event: Event) -> None:
|
|
187
|
+
row = encode_event(event)
|
|
188
|
+
self._conn.execute(
|
|
189
|
+
"""
|
|
190
|
+
INSERT INTO events (id, type, actor, payload, frame_id, caused_by, timestamp, run_id)
|
|
191
|
+
VALUES (:id, :type, :actor, :payload, :frame_id, :caused_by, :timestamp, :run_id)
|
|
192
|
+
""",
|
|
193
|
+
{**row, "run_id": self.run_id},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def iter_events(
|
|
197
|
+
self,
|
|
198
|
+
after: Optional[str] = None,
|
|
199
|
+
until: Optional[str] = None,
|
|
200
|
+
) -> Iterator[Event]:
|
|
201
|
+
clauses = ["run_id = ?"]
|
|
202
|
+
params: list[Any] = [self.run_id]
|
|
203
|
+
if after is not None:
|
|
204
|
+
clauses.append("seq > ?")
|
|
205
|
+
params.append(self._seq_of(after))
|
|
206
|
+
if until is not None:
|
|
207
|
+
clauses.append("seq <= ?")
|
|
208
|
+
params.append(self._seq_of(until))
|
|
209
|
+
sql = "SELECT * FROM events WHERE " + " AND ".join(clauses) + " ORDER BY seq"
|
|
210
|
+
for row in self._conn.execute(sql, params):
|
|
211
|
+
yield _row_to_event(row)
|
|
212
|
+
|
|
213
|
+
def get_event(self, event_id: str) -> Optional[Event]:
|
|
214
|
+
row = self._conn.execute(
|
|
215
|
+
"SELECT * FROM events WHERE id = ? AND run_id = ?",
|
|
216
|
+
(event_id, self.run_id),
|
|
217
|
+
).fetchone()
|
|
218
|
+
return _row_to_event(row) if row else None
|
|
219
|
+
|
|
220
|
+
def count(self) -> int:
|
|
221
|
+
row = self._conn.execute(
|
|
222
|
+
"SELECT COUNT(*) FROM events WHERE run_id = ?", (self.run_id,)
|
|
223
|
+
).fetchone()
|
|
224
|
+
return int(row[0])
|
|
225
|
+
|
|
226
|
+
def truncate_after(self, event_id: str) -> None:
|
|
227
|
+
seq = self._seq_of(event_id)
|
|
228
|
+
self._conn.execute(
|
|
229
|
+
"DELETE FROM events WHERE run_id = ? AND seq > ?",
|
|
230
|
+
(self.run_id, seq),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def close(self) -> None:
|
|
234
|
+
try:
|
|
235
|
+
self._conn.close()
|
|
236
|
+
except sqlite3.ProgrammingError:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
def _seq_of(self, event_id: str) -> int:
|
|
240
|
+
row = self._conn.execute(
|
|
241
|
+
"SELECT seq FROM events WHERE id = ? AND run_id = ?",
|
|
242
|
+
(event_id, self.run_id),
|
|
243
|
+
).fetchone()
|
|
244
|
+
if row is None:
|
|
245
|
+
from activegraph.store.errors import EventNotFoundError
|
|
246
|
+
raise EventNotFoundError(
|
|
247
|
+
f"event {event_id!r} not found in run {self.run_id!r}",
|
|
248
|
+
what_failed=(
|
|
249
|
+
f"The SQLite store has no event with id {event_id!r} in "
|
|
250
|
+
f"run {self.run_id!r}."
|
|
251
|
+
),
|
|
252
|
+
why=(
|
|
253
|
+
"Event ids are the framework's addressing primitive. The "
|
|
254
|
+
"store refuses to return a default for an unknown id — that "
|
|
255
|
+
"would silently corrupt the audit trail and any downstream "
|
|
256
|
+
"fork or replay."
|
|
257
|
+
),
|
|
258
|
+
how_to_fix=(
|
|
259
|
+
"Check the event id against what's actually in the run:\n"
|
|
260
|
+
f" activegraph inspect <store-url> --run-id {self.run_id} --tail 100\n"
|
|
261
|
+
"\n"
|
|
262
|
+
"Common causes: typo in a hand-typed id, referencing an id "
|
|
263
|
+
"from a different run, or a run truncated by an earlier fork."
|
|
264
|
+
),
|
|
265
|
+
context={
|
|
266
|
+
"event_id": event_id,
|
|
267
|
+
"run_id": self.run_id,
|
|
268
|
+
"driver": "sqlite",
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
return int(row["seq"])
|
|
272
|
+
|
|
273
|
+
# ---------- v0.5 helpers (per-run) ----------
|
|
274
|
+
|
|
275
|
+
def get_run(self) -> Optional[RunRecord]:
|
|
276
|
+
row = self._conn.execute(
|
|
277
|
+
"SELECT * FROM runs WHERE run_id = ?", (self.run_id,)
|
|
278
|
+
).fetchone()
|
|
279
|
+
return _row_to_run(row) if row else None
|
|
280
|
+
|
|
281
|
+
def upsert_run(
|
|
282
|
+
self,
|
|
283
|
+
*,
|
|
284
|
+
parent_run_id: Optional[str] = None,
|
|
285
|
+
forked_at_event_id: Optional[str] = None,
|
|
286
|
+
label: Optional[str] = None,
|
|
287
|
+
created_at: str,
|
|
288
|
+
goal: Optional[str] = None,
|
|
289
|
+
frame_id: Optional[str] = None,
|
|
290
|
+
) -> None:
|
|
291
|
+
self._conn.execute(
|
|
292
|
+
"""
|
|
293
|
+
INSERT INTO runs (run_id, parent_run_id, forked_at_event_id, label, created_at, goal, frame_id)
|
|
294
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
295
|
+
ON CONFLICT(run_id) DO UPDATE SET
|
|
296
|
+
parent_run_id = excluded.parent_run_id,
|
|
297
|
+
forked_at_event_id = excluded.forked_at_event_id,
|
|
298
|
+
label = excluded.label,
|
|
299
|
+
goal = COALESCE(excluded.goal, runs.goal),
|
|
300
|
+
frame_id = COALESCE(excluded.frame_id, runs.frame_id)
|
|
301
|
+
""",
|
|
302
|
+
(
|
|
303
|
+
self.run_id,
|
|
304
|
+
parent_run_id,
|
|
305
|
+
forked_at_event_id,
|
|
306
|
+
label,
|
|
307
|
+
created_at,
|
|
308
|
+
goal,
|
|
309
|
+
frame_id,
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# ---------- file-level helpers ----------
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def list_runs(cls, path: str) -> list[RunRecord]:
|
|
317
|
+
conn = sqlite3.connect(path, isolation_level=None)
|
|
318
|
+
conn.row_factory = sqlite3.Row
|
|
319
|
+
_ensure_schema(conn)
|
|
320
|
+
try:
|
|
321
|
+
rows = conn.execute("SELECT * FROM runs ORDER BY created_at").fetchall()
|
|
322
|
+
return [_row_to_run(r) for r in rows]
|
|
323
|
+
finally:
|
|
324
|
+
conn.close()
|
|
325
|
+
|
|
326
|
+
@classmethod
|
|
327
|
+
def most_recent_run_id(cls, path: str) -> Optional[str]:
|
|
328
|
+
conn = sqlite3.connect(path, isolation_level=None)
|
|
329
|
+
conn.row_factory = sqlite3.Row
|
|
330
|
+
_ensure_schema(conn)
|
|
331
|
+
try:
|
|
332
|
+
row = conn.execute(
|
|
333
|
+
"""
|
|
334
|
+
SELECT runs.run_id
|
|
335
|
+
FROM runs
|
|
336
|
+
LEFT JOIN (
|
|
337
|
+
SELECT run_id, MAX(seq) AS last_seq FROM events GROUP BY run_id
|
|
338
|
+
) e ON e.run_id = runs.run_id
|
|
339
|
+
ORDER BY e.last_seq IS NULL, e.last_seq DESC, runs.created_at DESC
|
|
340
|
+
LIMIT 1
|
|
341
|
+
"""
|
|
342
|
+
).fetchone()
|
|
343
|
+
return row["run_id"] if row else None
|
|
344
|
+
finally:
|
|
345
|
+
conn.close()
|
|
346
|
+
|
|
347
|
+
@classmethod
|
|
348
|
+
def fork_run(
|
|
349
|
+
cls,
|
|
350
|
+
path: str,
|
|
351
|
+
*,
|
|
352
|
+
parent_run_id: str,
|
|
353
|
+
new_run_id: str,
|
|
354
|
+
at_event_id: str,
|
|
355
|
+
label: Optional[str],
|
|
356
|
+
created_at: str,
|
|
357
|
+
) -> int:
|
|
358
|
+
"""Copy events from parent_run_id up to and including at_event_id
|
|
359
|
+
into new_run_id (CONTRACT v0.5 #11: copy rows, no row-sharing).
|
|
360
|
+
|
|
361
|
+
Returns the number of events copied.
|
|
362
|
+
"""
|
|
363
|
+
conn = sqlite3.connect(path, isolation_level=None)
|
|
364
|
+
conn.row_factory = sqlite3.Row
|
|
365
|
+
_ensure_schema(conn)
|
|
366
|
+
try:
|
|
367
|
+
cut = conn.execute(
|
|
368
|
+
"SELECT seq FROM events WHERE id = ? AND run_id = ?",
|
|
369
|
+
(at_event_id, parent_run_id),
|
|
370
|
+
).fetchone()
|
|
371
|
+
if cut is None:
|
|
372
|
+
from activegraph.store.errors import EventNotFoundError
|
|
373
|
+
raise EventNotFoundError(
|
|
374
|
+
f"event {at_event_id!r} not found in run {parent_run_id!r}",
|
|
375
|
+
what_failed=(
|
|
376
|
+
f"Cannot fork run {parent_run_id!r} at event "
|
|
377
|
+
f"{at_event_id!r}: that event does not exist in the run."
|
|
378
|
+
),
|
|
379
|
+
why=(
|
|
380
|
+
"Forking takes a parent run and copies events up to and "
|
|
381
|
+
"including --at-event into a new run. The framework "
|
|
382
|
+
"refuses to fork at an unknown event id rather than "
|
|
383
|
+
"guess where the user meant — that would produce a "
|
|
384
|
+
"fork that doesn't share lineage with its claimed parent."
|
|
385
|
+
),
|
|
386
|
+
how_to_fix=(
|
|
387
|
+
f"List the events in the parent run to find a valid "
|
|
388
|
+
f"fork point:\n"
|
|
389
|
+
f" activegraph inspect <store-url> --run-id {parent_run_id} --tail 100\n"
|
|
390
|
+
f"\n"
|
|
391
|
+
f"Then re-issue the fork with a valid event id."
|
|
392
|
+
),
|
|
393
|
+
context={
|
|
394
|
+
"event_id": at_event_id,
|
|
395
|
+
"run_id": parent_run_id,
|
|
396
|
+
"operation": "fork",
|
|
397
|
+
"driver": "sqlite",
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
parent_row = conn.execute(
|
|
401
|
+
"SELECT goal, frame_id FROM runs WHERE run_id = ?", (parent_run_id,)
|
|
402
|
+
).fetchone()
|
|
403
|
+
goal = parent_row["goal"] if parent_row else None
|
|
404
|
+
frame_id = parent_row["frame_id"] if parent_row else None
|
|
405
|
+
conn.execute(
|
|
406
|
+
"""
|
|
407
|
+
INSERT INTO runs (run_id, parent_run_id, forked_at_event_id, label, created_at, goal, frame_id)
|
|
408
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
409
|
+
""",
|
|
410
|
+
(
|
|
411
|
+
new_run_id,
|
|
412
|
+
parent_run_id,
|
|
413
|
+
at_event_id,
|
|
414
|
+
label,
|
|
415
|
+
created_at,
|
|
416
|
+
goal,
|
|
417
|
+
frame_id,
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
# Same logical event ids; UNIQUE(id, run_id) makes that safe.
|
|
421
|
+
rows = conn.execute(
|
|
422
|
+
"SELECT * FROM events WHERE run_id = ? AND seq <= ? ORDER BY seq",
|
|
423
|
+
(parent_run_id, cut["seq"]),
|
|
424
|
+
).fetchall()
|
|
425
|
+
n = 0
|
|
426
|
+
for r in rows:
|
|
427
|
+
conn.execute(
|
|
428
|
+
"""
|
|
429
|
+
INSERT INTO events (id, type, actor, payload, frame_id, caused_by, timestamp, run_id)
|
|
430
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
431
|
+
""",
|
|
432
|
+
(
|
|
433
|
+
r["id"],
|
|
434
|
+
r["type"],
|
|
435
|
+
r["actor"],
|
|
436
|
+
r["payload"],
|
|
437
|
+
r["frame_id"],
|
|
438
|
+
r["caused_by"],
|
|
439
|
+
r["timestamp"],
|
|
440
|
+
new_run_id,
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
n += 1
|
|
444
|
+
return n
|
|
445
|
+
finally:
|
|
446
|
+
conn.close()
|