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.
- 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 +342 -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/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.0rc2.dist-info/METADATA +228 -0
- activegraph-1.0.0rc2.dist-info/RECORD +82 -0
- activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
- activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
- activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""Postgres-backed EventStore. CONTRACT v0.8 #1.
|
|
2
|
+
|
|
3
|
+
Mirrors the SQLite schema with Postgres-native types: BIGSERIAL for
|
|
4
|
+
seq, TIMESTAMPTZ for timestamps, JSONB for payloads. Same UNIQUE
|
|
5
|
+
constraint (id, run_id) so logical event ids stay scoped to runs and
|
|
6
|
+
forks preserve them (v0.5 #12).
|
|
7
|
+
|
|
8
|
+
Schema is the contract; both implementations conform. The EventStore
|
|
9
|
+
protocol surface is identical to SQLiteEventStore.
|
|
10
|
+
|
|
11
|
+
Connection management is the user's job:
|
|
12
|
+
- Pass a URL → store owns one dedicated connection.
|
|
13
|
+
- Pass a psycopg.Connection → store does not own its lifecycle.
|
|
14
|
+
- Pass a psycopg_pool.ConnectionPool → store getconn / putconn per op.
|
|
15
|
+
|
|
16
|
+
psycopg 3.x is required (>=3.1,<4). Install with
|
|
17
|
+
``pip install 'activegraph[postgres]'``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
from typing import Any, Iterator, Optional
|
|
24
|
+
|
|
25
|
+
from activegraph.core.event import Event
|
|
26
|
+
from activegraph.store.base import RunRecord
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
SCHEMA_VERSION = "1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_SCHEMA_STATEMENTS = [
|
|
33
|
+
"""
|
|
34
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
35
|
+
seq BIGSERIAL PRIMARY KEY,
|
|
36
|
+
id TEXT NOT NULL,
|
|
37
|
+
type TEXT NOT NULL,
|
|
38
|
+
actor TEXT,
|
|
39
|
+
payload JSONB NOT NULL,
|
|
40
|
+
frame_id TEXT,
|
|
41
|
+
caused_by TEXT,
|
|
42
|
+
timestamp TIMESTAMPTZ NOT NULL,
|
|
43
|
+
run_id TEXT NOT NULL,
|
|
44
|
+
UNIQUE(id, run_id)
|
|
45
|
+
)
|
|
46
|
+
""",
|
|
47
|
+
"CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, seq)",
|
|
48
|
+
"CREATE INDEX IF NOT EXISTS idx_events_type ON events(type)",
|
|
49
|
+
"""
|
|
50
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
51
|
+
run_id TEXT PRIMARY KEY,
|
|
52
|
+
parent_run_id TEXT,
|
|
53
|
+
forked_at_event_id TEXT,
|
|
54
|
+
label TEXT,
|
|
55
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
56
|
+
goal TEXT,
|
|
57
|
+
frame_id TEXT
|
|
58
|
+
)
|
|
59
|
+
""",
|
|
60
|
+
"""
|
|
61
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
62
|
+
key TEXT PRIMARY KEY,
|
|
63
|
+
value TEXT NOT NULL
|
|
64
|
+
)
|
|
65
|
+
""",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _require_psycopg() -> Any:
|
|
70
|
+
try:
|
|
71
|
+
import psycopg # type: ignore
|
|
72
|
+
except ImportError as e: # pragma: no cover — exercised only without dep
|
|
73
|
+
from activegraph.errors import MissingOptionalDependency
|
|
74
|
+
raise MissingOptionalDependency(
|
|
75
|
+
package="psycopg",
|
|
76
|
+
feature="PostgresEventStore",
|
|
77
|
+
extras="postgres",
|
|
78
|
+
) from e
|
|
79
|
+
return psycopg
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _ConnectionSource:
|
|
83
|
+
"""Adapter over the three accepted connection shapes.
|
|
84
|
+
|
|
85
|
+
Hides whether we're holding a URL, a single connection, or a pool.
|
|
86
|
+
Every public method on PostgresEventStore goes through
|
|
87
|
+
``with self._source.cursor() as cur:`` so the underlying lifecycle
|
|
88
|
+
is uniform.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, target: Any) -> None:
|
|
92
|
+
psycopg = _require_psycopg()
|
|
93
|
+
self._psycopg = psycopg
|
|
94
|
+
self._owned_conn: Any = None # We created it; we close it.
|
|
95
|
+
self._pool: Any = None
|
|
96
|
+
self._conn: Any = None
|
|
97
|
+
|
|
98
|
+
if isinstance(target, str):
|
|
99
|
+
# URL — open a dedicated connection. We own it.
|
|
100
|
+
self._owned_conn = psycopg.connect(target, autocommit=True)
|
|
101
|
+
self._conn = self._owned_conn
|
|
102
|
+
elif hasattr(target, "getconn") and hasattr(target, "putconn"):
|
|
103
|
+
# Looks like a pool.
|
|
104
|
+
self._pool = target
|
|
105
|
+
elif hasattr(target, "cursor"):
|
|
106
|
+
# Looks like a Connection. Don't take ownership.
|
|
107
|
+
self._conn = target
|
|
108
|
+
else:
|
|
109
|
+
from activegraph.runtime.config_errors import InvalidArgumentType
|
|
110
|
+
type_name = type(target).__name__
|
|
111
|
+
target_repr = repr(target)
|
|
112
|
+
if len(target_repr) > 80:
|
|
113
|
+
target_repr = target_repr[:77] + "..."
|
|
114
|
+
raise InvalidArgumentType(
|
|
115
|
+
f"PostgresEventStore target has wrong type (got {type_name})",
|
|
116
|
+
what_failed=(
|
|
117
|
+
f"PostgresEventStore was constructed with a target of "
|
|
118
|
+
f"type {type_name}:\n value: {target_repr}\n"
|
|
119
|
+
f" type: {type_name}\n"
|
|
120
|
+
f"Accepted types are: a `postgres://...` URL string, a "
|
|
121
|
+
f"`psycopg.Connection`, or a `psycopg_pool.ConnectionPool`."
|
|
122
|
+
),
|
|
123
|
+
why=(
|
|
124
|
+
"PostgresEventStore's constructor branches on the "
|
|
125
|
+
"target's type — strings open a fresh connection, "
|
|
126
|
+
"Connections are borrowed without ownership, and "
|
|
127
|
+
"ConnectionPools are checked out per operation. An "
|
|
128
|
+
"unknown type has no defined connection lifecycle, and "
|
|
129
|
+
"a fuzzy match would silently leak connections or "
|
|
130
|
+
"double-close them."
|
|
131
|
+
),
|
|
132
|
+
how_to_fix=(
|
|
133
|
+
"Pass one of:\n"
|
|
134
|
+
" PostgresEventStore('postgres://host/dbname', run_id=...)\n"
|
|
135
|
+
" PostgresEventStore(my_psycopg_connection, run_id=...)\n"
|
|
136
|
+
" PostgresEventStore(my_connection_pool, run_id=...)\n"
|
|
137
|
+
"\n"
|
|
138
|
+
"If you already have a SQLAlchemy engine or another "
|
|
139
|
+
"abstraction, extract a raw psycopg.Connection from it "
|
|
140
|
+
"and pass that."
|
|
141
|
+
),
|
|
142
|
+
context={"type": type_name, "repr": target_repr},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# ---- ctx mgrs ----
|
|
146
|
+
|
|
147
|
+
def cursor(self): # returns a context manager yielding a cursor
|
|
148
|
+
return _CursorCtx(self)
|
|
149
|
+
|
|
150
|
+
def transaction(self):
|
|
151
|
+
return _TxCtx(self)
|
|
152
|
+
|
|
153
|
+
# ---- lifecycle ----
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
if self._owned_conn is not None:
|
|
157
|
+
try:
|
|
158
|
+
self._owned_conn.close()
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
self._owned_conn = None
|
|
162
|
+
self._conn = None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _CursorCtx:
|
|
166
|
+
def __init__(self, src: _ConnectionSource) -> None:
|
|
167
|
+
self._src = src
|
|
168
|
+
self._conn: Any = None
|
|
169
|
+
self._cur: Any = None
|
|
170
|
+
self._from_pool = False
|
|
171
|
+
|
|
172
|
+
def __enter__(self):
|
|
173
|
+
if self._src._pool is not None:
|
|
174
|
+
self._conn = self._src._pool.getconn()
|
|
175
|
+
self._from_pool = True
|
|
176
|
+
# Pool conns are typically autocommit=False by default. Set
|
|
177
|
+
# autocommit per-op so single statements don't sit in an
|
|
178
|
+
# implicit txn. Within transaction() we override.
|
|
179
|
+
self._conn.autocommit = True
|
|
180
|
+
else:
|
|
181
|
+
self._conn = self._src._conn
|
|
182
|
+
self._cur = self._conn.cursor()
|
|
183
|
+
return self._cur
|
|
184
|
+
|
|
185
|
+
def __exit__(self, exc_type, exc, tb):
|
|
186
|
+
try:
|
|
187
|
+
if self._cur is not None:
|
|
188
|
+
self._cur.close()
|
|
189
|
+
finally:
|
|
190
|
+
if self._from_pool and self._conn is not None:
|
|
191
|
+
self._src._pool.putconn(self._conn)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _TxCtx:
|
|
195
|
+
"""Run a block in a single transaction; commit on success, rollback on
|
|
196
|
+
exception. For pool-backed sources this borrows one connection and
|
|
197
|
+
keeps autocommit off for the duration.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(self, src: _ConnectionSource) -> None:
|
|
201
|
+
self._src = src
|
|
202
|
+
self._conn: Any = None
|
|
203
|
+
self._from_pool = False
|
|
204
|
+
|
|
205
|
+
def __enter__(self):
|
|
206
|
+
if self._src._pool is not None:
|
|
207
|
+
self._conn = self._src._pool.getconn()
|
|
208
|
+
self._from_pool = True
|
|
209
|
+
self._conn.autocommit = False
|
|
210
|
+
else:
|
|
211
|
+
self._conn = self._src._conn
|
|
212
|
+
# If the source connection is in autocommit mode, switching is
|
|
213
|
+
# cheap and reversible. Save and restore.
|
|
214
|
+
self._prev_autocommit = getattr(self._conn, "autocommit", True)
|
|
215
|
+
self._conn.autocommit = False
|
|
216
|
+
return self._conn
|
|
217
|
+
|
|
218
|
+
def __exit__(self, exc_type, exc, tb):
|
|
219
|
+
try:
|
|
220
|
+
if exc_type is None:
|
|
221
|
+
self._conn.commit()
|
|
222
|
+
else:
|
|
223
|
+
self._conn.rollback()
|
|
224
|
+
finally:
|
|
225
|
+
if self._from_pool:
|
|
226
|
+
self._conn.autocommit = True # reset before returning
|
|
227
|
+
self._src._pool.putconn(self._conn)
|
|
228
|
+
else:
|
|
229
|
+
self._conn.autocommit = self._prev_autocommit
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _ensure_schema(source: _ConnectionSource) -> None:
|
|
233
|
+
with source.cursor() as cur:
|
|
234
|
+
for stmt in _SCHEMA_STATEMENTS:
|
|
235
|
+
cur.execute(stmt)
|
|
236
|
+
cur.execute("SELECT value FROM meta WHERE key = 'schema_version'")
|
|
237
|
+
row = cur.fetchone()
|
|
238
|
+
if row is None:
|
|
239
|
+
cur.execute(
|
|
240
|
+
"INSERT INTO meta(key, value) VALUES ('schema_version', %s)",
|
|
241
|
+
(SCHEMA_VERSION,),
|
|
242
|
+
)
|
|
243
|
+
elif row[0] != SCHEMA_VERSION:
|
|
244
|
+
from activegraph import __version__ as _aw_version
|
|
245
|
+
from activegraph.store.errors import SchemaVersionMismatch
|
|
246
|
+
raise SchemaVersionMismatch(
|
|
247
|
+
f"postgres store schema_version {row[0]!r} does not match this build's expected {SCHEMA_VERSION!r}",
|
|
248
|
+
what_failed=(
|
|
249
|
+
f"The Postgres store records schema_version={row[0]!r} in its meta "
|
|
250
|
+
f"table, but activegraph {_aw_version} expects "
|
|
251
|
+
f"schema_version={SCHEMA_VERSION!r}."
|
|
252
|
+
),
|
|
253
|
+
why=(
|
|
254
|
+
"The store schema evolves with the framework. The runtime "
|
|
255
|
+
"refuses to read a store with a different schema_version rather "
|
|
256
|
+
"than risk silent data loss — a newer framework might interpret "
|
|
257
|
+
"columns differently than the writer did, and an older framework "
|
|
258
|
+
"might drop fields it doesn't recognize."
|
|
259
|
+
),
|
|
260
|
+
how_to_fix=(
|
|
261
|
+
f"One of three actions:\n"
|
|
262
|
+
f" 1. Install the activegraph version that wrote this store.\n"
|
|
263
|
+
f" 2. Migrate runs to a fresh database written by this build:\n"
|
|
264
|
+
f" activegraph migrate <src-url> <new-dst-url>\n"
|
|
265
|
+
f" 3. If the database is expendable, drop and re-create.\n"
|
|
266
|
+
f"\n"
|
|
267
|
+
f"Schema version history is documented in CHANGELOG.md."
|
|
268
|
+
),
|
|
269
|
+
context={
|
|
270
|
+
"found_version": row[0],
|
|
271
|
+
"expected_version": SCHEMA_VERSION,
|
|
272
|
+
"activegraph_version": _aw_version,
|
|
273
|
+
"driver": "postgres",
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _row_to_event(row: tuple) -> Event:
|
|
279
|
+
# Columns: id, type, actor, payload, frame_id, caused_by, timestamp
|
|
280
|
+
id_, type_, actor, payload, frame_id, caused_by, ts = row
|
|
281
|
+
if isinstance(payload, (bytes, str)):
|
|
282
|
+
payload = json.loads(payload)
|
|
283
|
+
# TIMESTAMPTZ comes back as a datetime; ISO-format for consistency
|
|
284
|
+
# with SQLite's text storage.
|
|
285
|
+
ts_str = ts.isoformat() if hasattr(ts, "isoformat") else str(ts)
|
|
286
|
+
return Event(
|
|
287
|
+
id=id_,
|
|
288
|
+
type=type_,
|
|
289
|
+
payload=payload,
|
|
290
|
+
actor=actor,
|
|
291
|
+
frame_id=frame_id,
|
|
292
|
+
caused_by=caused_by,
|
|
293
|
+
timestamp=ts_str,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _row_to_run(row: tuple) -> RunRecord:
|
|
298
|
+
run_id, parent_run_id, forked_at_event_id, label, created_at, goal, frame_id = row
|
|
299
|
+
return RunRecord(
|
|
300
|
+
run_id=run_id,
|
|
301
|
+
parent_run_id=parent_run_id,
|
|
302
|
+
forked_at_event_id=forked_at_event_id,
|
|
303
|
+
label=label,
|
|
304
|
+
created_at=created_at.isoformat() if hasattr(created_at, "isoformat") else str(created_at),
|
|
305
|
+
goal=goal,
|
|
306
|
+
frame_id=frame_id,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
_EVENT_COLUMNS = "id, type, actor, payload, frame_id, caused_by, timestamp"
|
|
311
|
+
_RUN_COLUMNS = (
|
|
312
|
+
"run_id, parent_run_id, forked_at_event_id, label, created_at, goal, frame_id"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class PostgresEventStore:
|
|
317
|
+
"""Per-run view onto a Postgres-backed event log. CONTRACT v0.8 #1."""
|
|
318
|
+
|
|
319
|
+
def __init__(self, target: Any, run_id: str) -> None:
|
|
320
|
+
self._source = _ConnectionSource(target)
|
|
321
|
+
self.run_id = run_id
|
|
322
|
+
_ensure_schema(self._source)
|
|
323
|
+
|
|
324
|
+
# ---------- EventStore protocol ----------
|
|
325
|
+
|
|
326
|
+
def append(self, event: Event) -> None:
|
|
327
|
+
psycopg = self._source._psycopg
|
|
328
|
+
with self._source.cursor() as cur:
|
|
329
|
+
cur.execute(
|
|
330
|
+
f"""
|
|
331
|
+
INSERT INTO events ({_EVENT_COLUMNS}, run_id)
|
|
332
|
+
VALUES (%s, %s, %s, %s::jsonb, %s, %s, %s, %s)
|
|
333
|
+
""",
|
|
334
|
+
(
|
|
335
|
+
event.id,
|
|
336
|
+
event.type,
|
|
337
|
+
event.actor,
|
|
338
|
+
json.dumps(event.payload),
|
|
339
|
+
event.frame_id,
|
|
340
|
+
event.caused_by,
|
|
341
|
+
event.timestamp,
|
|
342
|
+
self.run_id,
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def iter_events(
|
|
347
|
+
self,
|
|
348
|
+
after: Optional[str] = None,
|
|
349
|
+
until: Optional[str] = None,
|
|
350
|
+
) -> Iterator[Event]:
|
|
351
|
+
clauses = ["run_id = %s"]
|
|
352
|
+
params: list[Any] = [self.run_id]
|
|
353
|
+
if after is not None:
|
|
354
|
+
clauses.append("seq > %s")
|
|
355
|
+
params.append(self._seq_of(after))
|
|
356
|
+
if until is not None:
|
|
357
|
+
clauses.append("seq <= %s")
|
|
358
|
+
params.append(self._seq_of(until))
|
|
359
|
+
sql = (
|
|
360
|
+
f"SELECT {_EVENT_COLUMNS} FROM events WHERE "
|
|
361
|
+
+ " AND ".join(clauses)
|
|
362
|
+
+ " ORDER BY seq"
|
|
363
|
+
)
|
|
364
|
+
# Materialize rather than streaming so the cursor closes promptly
|
|
365
|
+
# under pooled connections. Event logs are bounded.
|
|
366
|
+
with self._source.cursor() as cur:
|
|
367
|
+
cur.execute(sql, params)
|
|
368
|
+
rows = cur.fetchall()
|
|
369
|
+
for row in rows:
|
|
370
|
+
yield _row_to_event(row)
|
|
371
|
+
|
|
372
|
+
def get_event(self, event_id: str) -> Optional[Event]:
|
|
373
|
+
with self._source.cursor() as cur:
|
|
374
|
+
cur.execute(
|
|
375
|
+
f"SELECT {_EVENT_COLUMNS} FROM events WHERE id = %s AND run_id = %s",
|
|
376
|
+
(event_id, self.run_id),
|
|
377
|
+
)
|
|
378
|
+
row = cur.fetchone()
|
|
379
|
+
return _row_to_event(row) if row else None
|
|
380
|
+
|
|
381
|
+
def count(self) -> int:
|
|
382
|
+
with self._source.cursor() as cur:
|
|
383
|
+
cur.execute(
|
|
384
|
+
"SELECT COUNT(*) FROM events WHERE run_id = %s", (self.run_id,)
|
|
385
|
+
)
|
|
386
|
+
row = cur.fetchone()
|
|
387
|
+
return int(row[0])
|
|
388
|
+
|
|
389
|
+
def truncate_after(self, event_id: str) -> None:
|
|
390
|
+
seq = self._seq_of(event_id)
|
|
391
|
+
with self._source.cursor() as cur:
|
|
392
|
+
cur.execute(
|
|
393
|
+
"DELETE FROM events WHERE run_id = %s AND seq > %s",
|
|
394
|
+
(self.run_id, seq),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def close(self) -> None:
|
|
398
|
+
self._source.close()
|
|
399
|
+
|
|
400
|
+
def _seq_of(self, event_id: str) -> int:
|
|
401
|
+
with self._source.cursor() as cur:
|
|
402
|
+
cur.execute(
|
|
403
|
+
"SELECT seq FROM events WHERE id = %s AND run_id = %s",
|
|
404
|
+
(event_id, self.run_id),
|
|
405
|
+
)
|
|
406
|
+
row = cur.fetchone()
|
|
407
|
+
if row is None:
|
|
408
|
+
from activegraph.store.errors import EventNotFoundError
|
|
409
|
+
raise EventNotFoundError(
|
|
410
|
+
f"event {event_id!r} not found in run {self.run_id!r}",
|
|
411
|
+
what_failed=(
|
|
412
|
+
f"The Postgres store has no event with id {event_id!r} in "
|
|
413
|
+
f"run {self.run_id!r}."
|
|
414
|
+
),
|
|
415
|
+
why=(
|
|
416
|
+
"Event ids are the framework's addressing primitive. The "
|
|
417
|
+
"store refuses to return a default for an unknown id — that "
|
|
418
|
+
"would silently corrupt the audit trail and any downstream "
|
|
419
|
+
"fork or replay."
|
|
420
|
+
),
|
|
421
|
+
how_to_fix=(
|
|
422
|
+
f"Check the event id against what's actually in the run:\n"
|
|
423
|
+
f" activegraph inspect <store-url> --run-id {self.run_id} --tail 100\n"
|
|
424
|
+
"\n"
|
|
425
|
+
"Common causes: typo in a hand-typed id, referencing an id "
|
|
426
|
+
"from a different run, or a run truncated by an earlier fork."
|
|
427
|
+
),
|
|
428
|
+
context={
|
|
429
|
+
"event_id": event_id,
|
|
430
|
+
"run_id": self.run_id,
|
|
431
|
+
"driver": "postgres",
|
|
432
|
+
},
|
|
433
|
+
)
|
|
434
|
+
return int(row[0])
|
|
435
|
+
|
|
436
|
+
# ---------- v0.5 helpers (per-run) ----------
|
|
437
|
+
|
|
438
|
+
def get_run(self) -> Optional[RunRecord]:
|
|
439
|
+
with self._source.cursor() as cur:
|
|
440
|
+
cur.execute(
|
|
441
|
+
f"SELECT {_RUN_COLUMNS} FROM runs WHERE run_id = %s",
|
|
442
|
+
(self.run_id,),
|
|
443
|
+
)
|
|
444
|
+
row = cur.fetchone()
|
|
445
|
+
return _row_to_run(row) if row else None
|
|
446
|
+
|
|
447
|
+
def upsert_run(
|
|
448
|
+
self,
|
|
449
|
+
*,
|
|
450
|
+
parent_run_id: Optional[str] = None,
|
|
451
|
+
forked_at_event_id: Optional[str] = None,
|
|
452
|
+
label: Optional[str] = None,
|
|
453
|
+
created_at: str,
|
|
454
|
+
goal: Optional[str] = None,
|
|
455
|
+
frame_id: Optional[str] = None,
|
|
456
|
+
) -> None:
|
|
457
|
+
with self._source.cursor() as cur:
|
|
458
|
+
cur.execute(
|
|
459
|
+
f"""
|
|
460
|
+
INSERT INTO runs ({_RUN_COLUMNS})
|
|
461
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
462
|
+
ON CONFLICT (run_id) DO UPDATE SET
|
|
463
|
+
parent_run_id = EXCLUDED.parent_run_id,
|
|
464
|
+
forked_at_event_id = EXCLUDED.forked_at_event_id,
|
|
465
|
+
label = EXCLUDED.label,
|
|
466
|
+
goal = COALESCE(EXCLUDED.goal, runs.goal),
|
|
467
|
+
frame_id = COALESCE(EXCLUDED.frame_id, runs.frame_id)
|
|
468
|
+
""",
|
|
469
|
+
(
|
|
470
|
+
self.run_id,
|
|
471
|
+
parent_run_id,
|
|
472
|
+
forked_at_event_id,
|
|
473
|
+
label,
|
|
474
|
+
created_at,
|
|
475
|
+
goal,
|
|
476
|
+
frame_id,
|
|
477
|
+
),
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# ---------- url-level helpers ----------
|
|
481
|
+
|
|
482
|
+
@classmethod
|
|
483
|
+
def list_runs(cls, target: Any) -> list[RunRecord]:
|
|
484
|
+
source = _ConnectionSource(target)
|
|
485
|
+
try:
|
|
486
|
+
_ensure_schema(source)
|
|
487
|
+
with source.cursor() as cur:
|
|
488
|
+
cur.execute(
|
|
489
|
+
f"SELECT {_RUN_COLUMNS} FROM runs ORDER BY created_at"
|
|
490
|
+
)
|
|
491
|
+
rows = cur.fetchall()
|
|
492
|
+
return [_row_to_run(r) for r in rows]
|
|
493
|
+
finally:
|
|
494
|
+
source.close()
|
|
495
|
+
|
|
496
|
+
@classmethod
|
|
497
|
+
def most_recent_run_id(cls, target: Any) -> Optional[str]:
|
|
498
|
+
source = _ConnectionSource(target)
|
|
499
|
+
try:
|
|
500
|
+
_ensure_schema(source)
|
|
501
|
+
with source.cursor() as cur:
|
|
502
|
+
cur.execute(
|
|
503
|
+
"""
|
|
504
|
+
SELECT runs.run_id
|
|
505
|
+
FROM runs
|
|
506
|
+
LEFT JOIN (
|
|
507
|
+
SELECT run_id, MAX(seq) AS last_seq
|
|
508
|
+
FROM events GROUP BY run_id
|
|
509
|
+
) e ON e.run_id = runs.run_id
|
|
510
|
+
ORDER BY (e.last_seq IS NULL), e.last_seq DESC,
|
|
511
|
+
runs.created_at DESC
|
|
512
|
+
LIMIT 1
|
|
513
|
+
"""
|
|
514
|
+
)
|
|
515
|
+
row = cur.fetchone()
|
|
516
|
+
return row[0] if row else None
|
|
517
|
+
finally:
|
|
518
|
+
source.close()
|
|
519
|
+
|
|
520
|
+
@classmethod
|
|
521
|
+
def fork_run(
|
|
522
|
+
cls,
|
|
523
|
+
target: Any,
|
|
524
|
+
*,
|
|
525
|
+
parent_run_id: str,
|
|
526
|
+
new_run_id: str,
|
|
527
|
+
at_event_id: str,
|
|
528
|
+
label: Optional[str],
|
|
529
|
+
created_at: str,
|
|
530
|
+
) -> int:
|
|
531
|
+
"""Copy events from parent_run_id up to and including at_event_id
|
|
532
|
+
into new_run_id. CONTRACT v0.5 #11 (rows are copied, not shared).
|
|
533
|
+
Runs in a single transaction; returns the number of events copied.
|
|
534
|
+
"""
|
|
535
|
+
source = _ConnectionSource(target)
|
|
536
|
+
try:
|
|
537
|
+
_ensure_schema(source)
|
|
538
|
+
with source.transaction() as conn:
|
|
539
|
+
with conn.cursor() as cur:
|
|
540
|
+
cur.execute(
|
|
541
|
+
"SELECT seq FROM events WHERE id = %s AND run_id = %s",
|
|
542
|
+
(at_event_id, parent_run_id),
|
|
543
|
+
)
|
|
544
|
+
cut = cur.fetchone()
|
|
545
|
+
if cut is None:
|
|
546
|
+
from activegraph.store.errors import EventNotFoundError
|
|
547
|
+
raise EventNotFoundError(
|
|
548
|
+
f"event {at_event_id!r} not found in run {parent_run_id!r}",
|
|
549
|
+
what_failed=(
|
|
550
|
+
f"Cannot fork run {parent_run_id!r} at event "
|
|
551
|
+
f"{at_event_id!r}: that event does not exist in the run."
|
|
552
|
+
),
|
|
553
|
+
why=(
|
|
554
|
+
"Forking takes a parent run and copies events up to and "
|
|
555
|
+
"including --at-event into a new run. The framework "
|
|
556
|
+
"refuses to fork at an unknown event id rather than "
|
|
557
|
+
"guess where the user meant — that would produce a "
|
|
558
|
+
"fork that doesn't share lineage with its claimed parent."
|
|
559
|
+
),
|
|
560
|
+
how_to_fix=(
|
|
561
|
+
f"List the events in the parent run to find a valid "
|
|
562
|
+
f"fork point:\n"
|
|
563
|
+
f" activegraph inspect <store-url> --run-id {parent_run_id} --tail 100\n"
|
|
564
|
+
f"\n"
|
|
565
|
+
f"Then re-issue the fork with a valid event id."
|
|
566
|
+
),
|
|
567
|
+
context={
|
|
568
|
+
"event_id": at_event_id,
|
|
569
|
+
"run_id": parent_run_id,
|
|
570
|
+
"operation": "fork",
|
|
571
|
+
"driver": "postgres",
|
|
572
|
+
},
|
|
573
|
+
)
|
|
574
|
+
cur.execute(
|
|
575
|
+
"SELECT goal, frame_id FROM runs WHERE run_id = %s",
|
|
576
|
+
(parent_run_id,),
|
|
577
|
+
)
|
|
578
|
+
pr = cur.fetchone()
|
|
579
|
+
goal = pr[0] if pr else None
|
|
580
|
+
frame_id = pr[1] if pr else None
|
|
581
|
+
cur.execute(
|
|
582
|
+
f"""
|
|
583
|
+
INSERT INTO runs ({_RUN_COLUMNS})
|
|
584
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
585
|
+
""",
|
|
586
|
+
(
|
|
587
|
+
new_run_id,
|
|
588
|
+
parent_run_id,
|
|
589
|
+
at_event_id,
|
|
590
|
+
label,
|
|
591
|
+
created_at,
|
|
592
|
+
goal,
|
|
593
|
+
frame_id,
|
|
594
|
+
),
|
|
595
|
+
)
|
|
596
|
+
cur.execute(
|
|
597
|
+
f"""
|
|
598
|
+
INSERT INTO events ({_EVENT_COLUMNS}, run_id)
|
|
599
|
+
SELECT {_EVENT_COLUMNS}, %s
|
|
600
|
+
FROM events
|
|
601
|
+
WHERE run_id = %s AND seq <= %s
|
|
602
|
+
ORDER BY seq
|
|
603
|
+
""",
|
|
604
|
+
(new_run_id, parent_run_id, cut[0]),
|
|
605
|
+
)
|
|
606
|
+
n = cur.rowcount
|
|
607
|
+
return int(n)
|
|
608
|
+
finally:
|
|
609
|
+
source.close()
|