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
activegraph/cli/main.py
ADDED
|
@@ -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())
|