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,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()