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,793 @@
1
+ """activegraph CLI entry point. CONTRACT v0.8 #12–#13.
2
+
3
+ Subcommands: inspect, replay, fork, diff, export-trace, migrate.
4
+
5
+ Each one is a thin wrapper around a library API. The CLI does no
6
+ business logic — it parses arguments, calls into Python, and formats
7
+ output. Programmatic users get the same behavior by importing the
8
+ called functions directly.
9
+
10
+ Exit codes (CONTRACT v0.8 #13):
11
+ 0 success
12
+ 1 generic error
13
+ 2 usage error (click's default — bad arguments, missing options)
14
+ 3 not found (run id does not exist, store path does not exist)
15
+ 4 corruption (schema mismatch, event log inconsistency)
16
+ 5 divergence (replay-strict failure)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json as _json
22
+ import sys
23
+ from typing import Any, Optional
24
+
25
+ # Click is a hard dep in v0.8. Lazy import so import-time failures
26
+ # carry a clear, actionable message rather than the bare ModuleNotFoundError.
27
+ try:
28
+ import click
29
+ except ImportError: # pragma: no cover — exercised only without click
30
+ print(
31
+ "activegraph CLI requires click. Install with `pip install click` "
32
+ "or `pip install activegraph[cli]`.",
33
+ file=sys.stderr,
34
+ )
35
+ raise SystemExit(2)
36
+
37
+
38
+ EXIT_OK = 0
39
+ EXIT_GENERIC_ERROR = 1
40
+ EXIT_USAGE_ERROR = 2
41
+ EXIT_NOT_FOUND = 3
42
+ EXIT_CORRUPTION = 4
43
+ EXIT_DIVERGENCE = 5
44
+
45
+ EXIT_CODES = {
46
+ "ok": EXIT_OK,
47
+ "generic_error": EXIT_GENERIC_ERROR,
48
+ "usage_error": EXIT_USAGE_ERROR,
49
+ "not_found": EXIT_NOT_FOUND,
50
+ "corruption": EXIT_CORRUPTION,
51
+ "divergence": EXIT_DIVERGENCE,
52
+ }
53
+
54
+
55
+ # ---- shared helpers -----------------------------------------------------
56
+
57
+
58
+ def _open_store_or_die(url: str, run_id: str):
59
+ """Open a store at URL+run_id, mapping common errors to exit codes."""
60
+ from activegraph.store import open_store, InvalidStoreURL
61
+
62
+ try:
63
+ return open_store(url, run_id=run_id)
64
+ except InvalidStoreURL as e:
65
+ click.echo(str(e), err=True)
66
+ raise SystemExit(EXIT_USAGE_ERROR)
67
+ except FileNotFoundError as e:
68
+ click.echo(str(e), err=True)
69
+ raise SystemExit(EXIT_NOT_FOUND)
70
+ except RuntimeError as e:
71
+ if "schema_version" in str(e):
72
+ click.echo(str(e), err=True)
73
+ raise SystemExit(EXIT_CORRUPTION)
74
+ click.echo(str(e), err=True)
75
+ raise SystemExit(EXIT_GENERIC_ERROR)
76
+
77
+
78
+ def _most_recent_run_id_or_die(url: str) -> str:
79
+ import sqlite3
80
+
81
+ from activegraph.store.url import InvalidStoreURL, parse_store_url
82
+
83
+ try:
84
+ parsed = parse_store_url(url)
85
+ except InvalidStoreURL as e:
86
+ click.echo(str(e), err=True)
87
+ raise SystemExit(EXIT_USAGE_ERROR)
88
+ try:
89
+ if parsed.scheme == "sqlite":
90
+ from activegraph.store.sqlite import SQLiteEventStore
91
+
92
+ rid = SQLiteEventStore.most_recent_run_id(parsed.sqlite_path or "")
93
+ else:
94
+ from activegraph.store.postgres import PostgresEventStore
95
+
96
+ rid = PostgresEventStore.most_recent_run_id(parsed.raw)
97
+ except (sqlite3.OperationalError, FileNotFoundError) as e:
98
+ click.echo(f"{url}: {e}", err=True)
99
+ raise SystemExit(EXIT_NOT_FOUND)
100
+ except RuntimeError as e:
101
+ click.echo(str(e), err=True)
102
+ raise SystemExit(EXIT_CORRUPTION if "schema_version" in str(e) else EXIT_GENERIC_ERROR)
103
+ if rid is None:
104
+ click.echo(f"no runs found in {url}", err=True)
105
+ raise SystemExit(EXIT_NOT_FOUND)
106
+ return rid
107
+
108
+
109
+ def _list_runs_or_die(url: str):
110
+ from activegraph.store.url import InvalidStoreURL, parse_store_url
111
+
112
+ try:
113
+ parsed = parse_store_url(url)
114
+ except InvalidStoreURL as e:
115
+ click.echo(str(e), err=True)
116
+ raise SystemExit(EXIT_USAGE_ERROR)
117
+ if parsed.scheme == "sqlite":
118
+ from activegraph.store.sqlite import SQLiteEventStore
119
+
120
+ return SQLiteEventStore.list_runs(parsed.sqlite_path or "")
121
+ from activegraph.store.postgres import PostgresEventStore
122
+
123
+ return PostgresEventStore.list_runs(parsed.raw)
124
+
125
+
126
+ # ---- click group --------------------------------------------------------
127
+
128
+
129
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
130
+ @click.version_option(message="activegraph %(version)s")
131
+ def cli() -> None:
132
+ """Inspect, replay, fork, diff, export, and migrate activegraph runs."""
133
+
134
+
135
+ # ---- quickstart ---------------------------------------------------------
136
+ # v1.0-rc1: the onboarding command. Lives in its own module
137
+ # (activegraph/cli/quickstart.py) because the implementation is large
138
+ # enough to deserve isolation and the command surface here is just
139
+ # the registration.
140
+
141
+ from activegraph.cli.quickstart import cmd_quickstart as _cmd_quickstart
142
+
143
+ cli.add_command(_cmd_quickstart)
144
+
145
+
146
+ # ---- pack ---------------------------------------------------------------
147
+
148
+
149
+ @cli.group("pack")
150
+ def cmd_pack() -> None:
151
+ """Pack-related commands: scaffolding, listing installed packs."""
152
+
153
+
154
+ @cmd_pack.command("new")
155
+ @click.argument("name")
156
+ @click.option(
157
+ "-o", "--output-dir", default=".", show_default=True,
158
+ help="Parent directory under which the new pack package is created.",
159
+ )
160
+ def cmd_pack_new(name: str, output_dir: str) -> None:
161
+ """Scaffold a new pack package skeleton (CONTRACT v0.9 #14).
162
+
163
+ Generates: pyproject.toml, the Python package with stubs for
164
+ object types, behaviors, tools, settings, an example prompt, a
165
+ smoke test, and a README.
166
+ """
167
+ from pathlib import Path
168
+
169
+ from activegraph.packs.scaffold import scaffold_pack
170
+
171
+ try:
172
+ root = scaffold_pack(Path(output_dir), name)
173
+ except FileExistsError as e:
174
+ click.echo(str(e), err=True)
175
+ raise SystemExit(EXIT_GENERIC_ERROR)
176
+ except ValueError as e:
177
+ click.echo(str(e), err=True)
178
+ raise SystemExit(EXIT_USAGE_ERROR)
179
+ click.echo(f"created {root}")
180
+ click.echo("next steps:")
181
+ click.echo(f" cd {root}")
182
+ click.echo(" pip install -e .")
183
+ click.echo(" pytest")
184
+
185
+
186
+ @cmd_pack.command("list")
187
+ def cmd_pack_list() -> None:
188
+ """List installed packs discovered via the activegraph.packs entry
189
+ point group (CONTRACT v0.9 #11).
190
+ """
191
+ from activegraph.packs import discover
192
+
193
+ entries = discover()
194
+ if not entries:
195
+ click.echo("no packs installed")
196
+ return
197
+ for entry in entries:
198
+ click.echo(f" {entry.name:24s} {entry.version:10s} {entry.entry_point}")
199
+
200
+
201
+ # ---- inspect ------------------------------------------------------------
202
+
203
+
204
+ @cli.command("inspect")
205
+ @click.argument("url")
206
+ @click.option("--run-id", default=None, help="Run to inspect (default: most recent).")
207
+ @click.option("--tail", default=20, show_default=True, help="Recent events to include.")
208
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
209
+ @click.option(
210
+ "--event",
211
+ "event_id",
212
+ default=None,
213
+ help=(
214
+ "Print one event's full payload by id (e.g. evt_042). Used to "
215
+ "investigate a divergence: every ReplayDivergenceError names "
216
+ "the offending event id."
217
+ ),
218
+ )
219
+ @click.option(
220
+ "--behaviors",
221
+ is_flag=True,
222
+ help=(
223
+ "Print only the registered-behaviors section. Used when "
224
+ "diagnosing a replay length mismatch — compare which behaviors "
225
+ "fire now against which fired in the recorded run."
226
+ ),
227
+ )
228
+ @click.option(
229
+ "--pack-version",
230
+ is_flag=True,
231
+ help=(
232
+ "Print every pack.loaded event in the run — name, version, "
233
+ "prompt content-hash summary. Used to confirm the pack version "
234
+ "the recorded run was using vs. what's installed today."
235
+ ),
236
+ )
237
+ def cmd_inspect(
238
+ url: str,
239
+ run_id: Optional[str],
240
+ tail: int,
241
+ as_json: bool,
242
+ event_id: Optional[str],
243
+ behaviors: bool,
244
+ pack_version: bool,
245
+ ) -> None:
246
+ """Print a status snapshot for a run.
247
+
248
+ With ``--event``, ``--behaviors``, or ``--pack-version``, prints only
249
+ that focused section instead of the full status. The three are
250
+ mutually exclusive — they're selectors, not filters.
251
+ """
252
+ from activegraph.observability.status import status_to_dict
253
+ from activegraph.runtime.runtime import Runtime
254
+
255
+ if sum([bool(event_id), behaviors, pack_version]) > 1:
256
+ click.echo(
257
+ "--event, --behaviors, and --pack-version are mutually exclusive.",
258
+ err=True,
259
+ )
260
+ raise SystemExit(EXIT_USAGE_ERROR)
261
+
262
+ rid = run_id or _most_recent_run_id_or_die(url)
263
+ try:
264
+ # Load without behaviors. Loading replays the event log but
265
+ # registers an empty registry (no behaviors fire on the
266
+ # re-queued events because the loop is never run).
267
+ rt = Runtime.load(url, run_id=rid)
268
+ except FileNotFoundError as e:
269
+ click.echo(str(e), err=True)
270
+ raise SystemExit(EXIT_NOT_FOUND)
271
+
272
+ if event_id:
273
+ _print_event(rt, event_id, as_json)
274
+ return
275
+ if behaviors:
276
+ _print_behaviors(rt, as_json)
277
+ return
278
+ if pack_version:
279
+ _print_pack_versions(rt, as_json)
280
+ return
281
+
282
+ status = rt.status(recent=tail)
283
+
284
+ if as_json:
285
+ click.echo(_json.dumps(status_to_dict(status), default=str))
286
+ return
287
+
288
+ click.echo(f"run_id: {status.run_id}")
289
+ click.echo(f"state: {status.state}")
290
+ click.echo(f"queue_depth: {status.queue_depth}")
291
+ click.echo(f"events_processed: {status.events_processed}")
292
+ if status.frame is not None:
293
+ click.echo(f"frame: {status.frame.id} ({status.frame.name})")
294
+ bud = status.budget
295
+ click.echo("budget:")
296
+ for k, v in bud.used.items():
297
+ lim = bud.limits.get(k)
298
+ click.echo(f" {k:20s} {v} / {lim if lim is not None else 'unlimited'}")
299
+ if bud.exhausted_by:
300
+ click.echo(f" exhausted_by: {bud.exhausted_by}")
301
+ if status.registered_behaviors:
302
+ click.echo("registered behaviors:")
303
+ for b in status.registered_behaviors:
304
+ sub = ",".join(b.subscribed_to) or "(pattern-only)"
305
+ click.echo(f" {b.name:30s} {b.kind:10s} on={sub}")
306
+ click.echo(f"recent events (last {len(status.recent_events)}):")
307
+ for e in status.recent_events:
308
+ click.echo(f" {e.id:18s} {e.type}")
309
+
310
+
311
+ # ---- inspect selector helpers ------------------------------------------
312
+
313
+
314
+ def _print_event(rt, event_id: str, as_json: bool) -> None:
315
+ """v1.0 CLI follow-on: print one event's full payload by id."""
316
+ target = next((e for e in rt.graph.events if e.id == event_id), None)
317
+ if target is None:
318
+ click.echo(f"event {event_id!r} not found in run {rt.run_id}", err=True)
319
+ raise SystemExit(EXIT_NOT_FOUND)
320
+ if as_json:
321
+ click.echo(_json.dumps({
322
+ "id": target.id,
323
+ "type": target.type,
324
+ "actor": target.actor,
325
+ "frame_id": target.frame_id,
326
+ "caused_by": target.caused_by,
327
+ "timestamp": target.timestamp,
328
+ "payload": target.payload,
329
+ }, default=str))
330
+ return
331
+ click.echo(f"event: {target.id}")
332
+ click.echo(f"type: {target.type}")
333
+ click.echo(f"actor: {target.actor or '(none)'}")
334
+ click.echo(f"frame: {target.frame_id or '(none)'}")
335
+ click.echo(f"caused_by: {target.caused_by or '(none)'}")
336
+ click.echo(f"timestamp: {target.timestamp}")
337
+ click.echo("payload:")
338
+ click.echo(_json.dumps(target.payload, indent=2, default=str))
339
+
340
+
341
+ def _print_behaviors(rt, as_json: bool) -> None:
342
+ """v1.0 CLI follow-on: print only the registered-behaviors section."""
343
+ status = rt.status(recent=0)
344
+ if as_json:
345
+ click.echo(_json.dumps([
346
+ {"name": b.name, "kind": b.kind, "subscribed_to": list(b.subscribed_to)}
347
+ for b in status.registered_behaviors
348
+ ]))
349
+ return
350
+ if not status.registered_behaviors:
351
+ click.echo("(no behaviors registered in this run)")
352
+ return
353
+ click.echo("registered behaviors:")
354
+ for b in status.registered_behaviors:
355
+ sub = ",".join(b.subscribed_to) or "(pattern-only)"
356
+ click.echo(f" {b.name:30s} {b.kind:10s} on={sub}")
357
+
358
+
359
+ def _print_pack_versions(rt, as_json: bool) -> None:
360
+ """v1.0 CLI follow-on: print every pack.loaded event in the run.
361
+
362
+ A `pack.loaded` event carries the pack name, version, and the
363
+ declared+content-hash of every prompt the pack ships. v0.9 #13 locked
364
+ this event as the audit trail for which pack version produced which
365
+ artifacts; v0.9 #10 locked the prompt content-hash as the replay
366
+ contract, so the prompt hashes here are what `ReplayDivergenceError`
367
+ compares against during fork/replay.
368
+ """
369
+ loads = [e for e in rt.graph.events if e.type == "pack.loaded"]
370
+ if as_json:
371
+ click.echo(_json.dumps([
372
+ {
373
+ "event_id": e.id,
374
+ "pack": (e.payload or {}).get("name"),
375
+ "version": (e.payload or {}).get("version"),
376
+ "prompts": (e.payload or {}).get("prompts", {}),
377
+ }
378
+ for e in loads
379
+ ]))
380
+ return
381
+ if not loads:
382
+ click.echo("(no packs loaded in this run)")
383
+ return
384
+ click.echo(f"packs loaded ({len(loads)}):")
385
+ for e in loads:
386
+ p = e.payload or {}
387
+ name = p.get("name", "?")
388
+ version = p.get("version", "?")
389
+ prompts = p.get("prompts") or {}
390
+ click.echo(f" {name:24s} {version:14s} ({e.id})")
391
+ for prompt_name, meta in prompts.items():
392
+ if isinstance(meta, dict):
393
+ short = str(meta.get("hash", ""))
394
+ prompt_ver = meta.get("version", "")
395
+ short = short.removeprefix("sha256:")[:12] if short else "?"
396
+ click.echo(
397
+ f" prompt {prompt_name:24s} v{prompt_ver:<8s} hash={short}"
398
+ )
399
+ else:
400
+ short = str(meta)[:12]
401
+ click.echo(f" prompt {prompt_name:24s} hash={short}")
402
+
403
+
404
+ # ---- replay -------------------------------------------------------------
405
+
406
+
407
+ @cli.command("replay")
408
+ @click.argument("url")
409
+ @click.option("--run-id", required=True, help="Run to replay.")
410
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
411
+ def cmd_replay(url: str, run_id: str, as_json: bool) -> None:
412
+ """Rebuild the graph from a run's event log (no behaviors fire)."""
413
+ from activegraph.runtime.runtime import Runtime
414
+
415
+ try:
416
+ rt = Runtime.load(url, run_id=run_id)
417
+ except FileNotFoundError as e:
418
+ click.echo(str(e), err=True)
419
+ raise SystemExit(EXIT_NOT_FOUND)
420
+ except __import__("sqlite3").OperationalError as e:
421
+ click.echo(f"{url}: {e}", err=True)
422
+ raise SystemExit(EXIT_NOT_FOUND)
423
+
424
+ summary = {
425
+ "run_id": run_id,
426
+ "events": len(rt.graph.events),
427
+ "objects": len(rt.graph.all_objects()),
428
+ "relations": len(rt.graph.all_relations()),
429
+ }
430
+ if as_json:
431
+ click.echo(_json.dumps(summary))
432
+ return
433
+ click.echo(f"run_id: {summary['run_id']}")
434
+ click.echo(f"events: {summary['events']}")
435
+ click.echo(f"objects: {summary['objects']}")
436
+ click.echo(f"relations: {summary['relations']}")
437
+
438
+
439
+ # ---- fork ---------------------------------------------------------------
440
+
441
+
442
+ @cli.command("fork")
443
+ @click.argument("url")
444
+ @click.option("--run-id", required=True, help="Parent run to fork from.")
445
+ @click.option("--at-event", required=True, help="Event id to fork at (inclusive).")
446
+ @click.option("--label", default=None, help="Optional label for the new run.")
447
+ @click.option(
448
+ "--to",
449
+ "to_url",
450
+ default=None,
451
+ help="Destination store URL. Defaults to the source store.",
452
+ )
453
+ @click.option(
454
+ "--record",
455
+ is_flag=True,
456
+ help=(
457
+ "Mark this fork as a re-recording. Appends `-recording` to the "
458
+ "label (or sets the label to `recording` if none was given) and "
459
+ "prints follow-on guidance. Use after a ReplayDivergenceError "
460
+ "when the divergence was intentional — fork at the offending "
461
+ "event id, then run the new run without `replay_strict=True` "
462
+ "so the LLM cache and tool cache write-through new entries."
463
+ ),
464
+ )
465
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
466
+ def cmd_fork(
467
+ url: str,
468
+ run_id: str,
469
+ at_event: str,
470
+ label: Optional[str],
471
+ to_url: Optional[str],
472
+ record: bool,
473
+ as_json: bool,
474
+ ) -> None:
475
+ """Create a new run by copying events up to and including --at-event."""
476
+ from activegraph.core.ids import IDGen
477
+ from activegraph.runtime.runtime import _now_iso
478
+ from activegraph.store.url import InvalidStoreURL, parse_store_url
479
+
480
+ if to_url is None:
481
+ to_url = url
482
+ if record:
483
+ # Label suffix is informational; the actual "recording" semantics
484
+ # emerge when the new run is later loaded with replay_strict=False
485
+ # (the default). v1.0 #C3-adjacent — no new runtime capability;
486
+ # this is operator UX over the existing fork primitive.
487
+ label = f"{label}-recording" if label else "recording"
488
+ if to_url != url:
489
+ click.echo(
490
+ "cross-store fork not supported in v0.8. Fork in the source "
491
+ "store, then `activegraph migrate` the new run.",
492
+ err=True,
493
+ )
494
+ raise SystemExit(EXIT_USAGE_ERROR)
495
+
496
+ try:
497
+ parsed = parse_store_url(url)
498
+ except InvalidStoreURL as e:
499
+ click.echo(str(e), err=True)
500
+ raise SystemExit(EXIT_USAGE_ERROR)
501
+
502
+ new_run_id = IDGen().run()
503
+ try:
504
+ if parsed.scheme == "sqlite":
505
+ from activegraph.store.sqlite import SQLiteEventStore
506
+
507
+ n = SQLiteEventStore.fork_run(
508
+ parsed.sqlite_path or "",
509
+ parent_run_id=run_id,
510
+ new_run_id=new_run_id,
511
+ at_event_id=at_event,
512
+ label=label,
513
+ created_at=_now_iso(),
514
+ )
515
+ else:
516
+ from activegraph.store.postgres import PostgresEventStore
517
+
518
+ n = PostgresEventStore.fork_run(
519
+ parsed.raw,
520
+ parent_run_id=run_id,
521
+ new_run_id=new_run_id,
522
+ at_event_id=at_event,
523
+ label=label,
524
+ created_at=_now_iso(),
525
+ )
526
+ except KeyError as e:
527
+ click.echo(str(e), err=True)
528
+ raise SystemExit(EXIT_NOT_FOUND)
529
+
530
+ out = {
531
+ "parent_run_id": run_id,
532
+ "new_run_id": new_run_id,
533
+ "at_event": at_event,
534
+ "label": label,
535
+ "events_copied": n,
536
+ }
537
+ if as_json:
538
+ if record:
539
+ out["recording"] = True
540
+ click.echo(_json.dumps(out))
541
+ return
542
+ click.echo(f"forked {run_id} at {at_event} -> {new_run_id} ({n} events)")
543
+ if record:
544
+ click.echo(
545
+ " recording fork: load this run without replay_strict=True to "
546
+ "accept new LLM/tool cache entries."
547
+ )
548
+
549
+
550
+ # ---- diff ---------------------------------------------------------------
551
+
552
+
553
+ @cli.command("diff")
554
+ @click.argument("url")
555
+ @click.option("--run-a", required=True, help="Left-hand run.")
556
+ @click.option("--run-b", required=True, help="Right-hand run.")
557
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
558
+ def cmd_diff(url: str, run_a: str, run_b: str, as_json: bool) -> None:
559
+ """Structural diff between two runs in the same store."""
560
+ from activegraph.runtime.diff import compute_diff
561
+ from activegraph.runtime.runtime import Runtime
562
+
563
+ try:
564
+ rt_a = Runtime.load(url, run_id=run_a)
565
+ rt_b = Runtime.load(url, run_id=run_b)
566
+ except FileNotFoundError as e:
567
+ click.echo(str(e), err=True)
568
+ raise SystemExit(EXIT_NOT_FOUND)
569
+ except __import__("sqlite3").OperationalError as e:
570
+ click.echo(f"{url}: {e}", err=True)
571
+ raise SystemExit(EXIT_NOT_FOUND)
572
+
573
+ diff = compute_diff(rt_a.graph, rt_b.graph, run_a, run_b)
574
+ summary = {
575
+ "run_a": run_a,
576
+ "run_b": run_b,
577
+ "shared_events": len(diff.shared_events),
578
+ "parent_only_events": len(diff.parent_only_events),
579
+ "fork_only_events": len(diff.fork_only_events),
580
+ "divergent_objects": len(diff.divergent_objects),
581
+ "divergent_relations": len(diff.divergent_relations),
582
+ }
583
+ if as_json:
584
+ click.echo(_json.dumps(summary))
585
+ return
586
+ click.echo(f"diff {run_a} vs {run_b}:")
587
+ for k, v in summary.items():
588
+ if k in ("run_a", "run_b"):
589
+ continue
590
+ click.echo(f" {k:24s} {v}")
591
+ if diff.divergent_objects:
592
+ click.echo("divergent objects:")
593
+ for o in diff.divergent_objects:
594
+ click.echo(f" - {o.summary()}")
595
+ if diff.divergent_relations:
596
+ click.echo("divergent relations:")
597
+ for r in diff.divergent_relations:
598
+ click.echo(f" - {r.summary()}")
599
+
600
+
601
+ # ---- export-trace -------------------------------------------------------
602
+
603
+
604
+ @cli.command("export-trace")
605
+ @click.argument("url")
606
+ @click.option("--run-id", required=True, help="Run to export.")
607
+ @click.option(
608
+ "--format",
609
+ "fmt",
610
+ type=click.Choice(["text", "jsonl"]),
611
+ default="text",
612
+ show_default=True,
613
+ )
614
+ @click.option(
615
+ "-o", "--output",
616
+ "out_path",
617
+ default=None,
618
+ help="Output file (default: stdout).",
619
+ )
620
+ def cmd_export_trace(url: str, run_id: str, fmt: str, out_path: Optional[str]) -> None:
621
+ """Dump a run's event log as text or JSONL."""
622
+ from activegraph.runtime.runtime import Runtime
623
+
624
+ try:
625
+ rt = Runtime.load(url, run_id=run_id)
626
+ except FileNotFoundError as e:
627
+ click.echo(str(e), err=True)
628
+ raise SystemExit(EXIT_NOT_FOUND)
629
+ except __import__("sqlite3").OperationalError as e:
630
+ click.echo(f"{url}: {e}", err=True)
631
+ raise SystemExit(EXIT_NOT_FOUND)
632
+
633
+ if fmt == "jsonl":
634
+ lines = (_json.dumps(e.to_dict()) for e in rt.graph.events)
635
+ if out_path:
636
+ with open(out_path, "w") as f:
637
+ for ln in lines:
638
+ f.write(ln + "\n")
639
+ else:
640
+ for ln in lines:
641
+ click.echo(ln)
642
+ return
643
+
644
+ # text format — use the trace printer
645
+ from activegraph.trace.printer import Trace
646
+
647
+ trace = Trace(rt.graph)
648
+ if out_path:
649
+ with open(out_path, "w") as f:
650
+ trace.print(file=f) if _supports_file_arg(trace.print) else _fallback_text(trace, f)
651
+ else:
652
+ trace.print()
653
+
654
+
655
+ def _supports_file_arg(fn) -> bool:
656
+ import inspect
657
+
658
+ try:
659
+ sig = inspect.signature(fn)
660
+ return "file" in sig.parameters
661
+ except (TypeError, ValueError):
662
+ return False
663
+
664
+
665
+ def _fallback_text(trace, f) -> None:
666
+ """Trace.print writes to stdout; redirect for backward-compat printers."""
667
+ import contextlib
668
+
669
+ with contextlib.redirect_stdout(f):
670
+ trace.print()
671
+
672
+
673
+ # ---- migrate ------------------------------------------------------------
674
+
675
+
676
+ @cli.command("migrate")
677
+ @click.option("--from", "src", required=True, help="Source store URL.")
678
+ @click.option("--to", "dst", required=True, help="Destination store URL.")
679
+ @click.option(
680
+ "--run-id",
681
+ multiple=True,
682
+ help="Migrate only these run(s). Repeat to specify multiple.",
683
+ )
684
+ @click.option(
685
+ "--skip-corrupted",
686
+ is_flag=True,
687
+ help=(
688
+ "Skip events whose payload fails JSON decode instead of failing "
689
+ "the run. The skipped event ids appear in the per-run report's "
690
+ "`skipped_events`. The resulting destination run is PARTIAL — the "
691
+ "operator is on notice. Use this to recover the readable subset "
692
+ "of a run with a corrupted event payload."
693
+ ),
694
+ )
695
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
696
+ def cmd_migrate(
697
+ src: str,
698
+ dst: str,
699
+ run_id: tuple[str, ...],
700
+ skip_corrupted: bool,
701
+ as_json: bool,
702
+ ) -> None:
703
+ """Copy runs from a source store to a destination store.
704
+
705
+ Transaction-per-run: each run is written in a single destination
706
+ transaction. A failure mid-run rolls back that run's destination
707
+ state. Writes are idempotent (ON CONFLICT DO NOTHING) so re-running
708
+ after a failure is safe.
709
+
710
+ With ``--skip-corrupted``, a corrupted-payload event is skipped
711
+ (recorded in the per-run ``skipped_events``) instead of failing
712
+ the whole run. The destination run is partial; the operator is
713
+ on notice.
714
+ """
715
+ from activegraph.observability.migration import migrate
716
+ from activegraph.store.url import InvalidStoreURL, parse_store_url
717
+
718
+ try:
719
+ parse_store_url(src)
720
+ parse_store_url(dst)
721
+ except InvalidStoreURL as e:
722
+ click.echo(str(e), err=True)
723
+ raise SystemExit(EXIT_USAGE_ERROR)
724
+
725
+ only = list(run_id) if run_id else None
726
+ report = migrate(src, dst, only_run_ids=only, skip_corrupted=skip_corrupted)
727
+
728
+ if as_json:
729
+ out = {
730
+ "source_url": report.source_url,
731
+ "dest_url": report.dest_url,
732
+ "runs": [
733
+ {
734
+ "run_id": r.run_id,
735
+ "status": r.status,
736
+ "events_migrated": r.events_migrated,
737
+ **({"error": r.error} if r.error else {}),
738
+ **(
739
+ {"skipped_events": list(r.skipped_events)}
740
+ if r.skipped_events
741
+ else {}
742
+ ),
743
+ }
744
+ for r in report.runs
745
+ ],
746
+ }
747
+ click.echo(_json.dumps(out))
748
+ else:
749
+ click.echo(f"migrate {src} -> {dst}")
750
+ for r in report.runs:
751
+ line = f" {r.status:7s} run={r.run_id} events={r.events_migrated}"
752
+ if r.error:
753
+ line += f" error={r.error}"
754
+ if r.skipped_events:
755
+ line += f" skipped={len(r.skipped_events)}"
756
+ click.echo(line)
757
+ if r.skipped_events:
758
+ for sid in r.skipped_events:
759
+ click.echo(f" skipped (corrupted): {sid}")
760
+ click.echo(
761
+ f"summary: {sum(1 for r in report.runs if r.status == 'ok')} ok, "
762
+ f"{len(report.failures)} failed"
763
+ )
764
+
765
+ if not report.ok:
766
+ raise SystemExit(EXIT_GENERIC_ERROR)
767
+
768
+
769
+ # ---- entrypoint ---------------------------------------------------------
770
+
771
+
772
+ def main(argv: Optional[list[str]] = None) -> int:
773
+ """Programmatic entry point. Returns an exit code rather than raising
774
+ SystemExit when called from tests via CliRunner.
775
+
776
+ The ``[project.scripts]`` shim invokes this; pyproject hooks up
777
+ ``activegraph -> activegraph.cli.main:main``.
778
+ """
779
+ try:
780
+ cli.main(args=argv, standalone_mode=False)
781
+ except SystemExit as e:
782
+ return int(e.code or 0)
783
+ except click.exceptions.UsageError as e:
784
+ e.show()
785
+ return EXIT_USAGE_ERROR
786
+ except click.exceptions.ClickException as e:
787
+ e.show()
788
+ return EXIT_GENERIC_ERROR
789
+ return EXIT_OK
790
+
791
+
792
+ if __name__ == "__main__":
793
+ raise SystemExit(main())