agent-tracegraph 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tracegraph/cli.py ADDED
@@ -0,0 +1,1242 @@
1
+ """``tracegraph`` command line for checkpoint, Phoenix, and OTLP causal analysis.
2
+
3
+ LangGraph checkpoint ingestion is a super-step view; Phoenix/OTLP ingestion is span-level.
4
+ Every source is normalized into the same raw causal DAG plus derived structural tree.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import subprocess
14
+ from dataclasses import dataclass
15
+ from decimal import Decimal
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import typer
21
+ from pydantic import ValidationError
22
+ from rich.console import Console
23
+ from rich.markup import escape
24
+ from rich.tree import Tree
25
+
26
+ from tracegraph import artifact
27
+ from tracegraph.input_validation import loads as load_json
28
+ from tracegraph.analysis import PRESETS
29
+ from tracegraph.analysis.diagnose import AnalysisReport
30
+ from tracegraph.analysis.diagnose import analyze as build_analysis
31
+ from tracegraph.analysis.diagnose import save_atomic as save_analysis_report
32
+ from tracegraph.analysis import Match
33
+ from tracegraph.analysis import diff as tree_diff
34
+ from tracegraph.analysis import explain as explain_chain
35
+ from tracegraph.analysis import search as pattern_search
36
+ from tracegraph import __version__
37
+ from tracegraph.analysis import structure_only
38
+ from tracegraph.model import EdgeType, NormalizedTrace, StepStatus
39
+ from tracegraph.normalize import normalize, validate_normalized
40
+ from tracegraph.review_candidates import build_report as build_candidate_report
41
+ from tracegraph.review_candidates import is_review_exportable
42
+ from tracegraph.review_candidates import save_atomic as save_candidate_report
43
+ from tracegraph.store import InMemoryStore
44
+
45
+ app = typer.Typer(
46
+ help="Causal-graph analysis of LangGraph, Phoenix, and OpenInference traces.",
47
+ no_args_is_help=True,
48
+ add_completion=False,
49
+ )
50
+ phoenix_app = typer.Typer(
51
+ help="Read-only Phoenix CLI integration for body-free diagnosis.",
52
+ no_args_is_help=True,
53
+ )
54
+ app.add_typer(phoenix_app, name="phoenix")
55
+
56
+
57
+ def _version_callback(value: bool) -> None:
58
+ if value:
59
+ # Printed bare: this is the one output a release script parses, and a prefix would
60
+ # make every consumer strip it.
61
+ typer.echo(__version__)
62
+ raise typer.Exit()
63
+
64
+
65
+ @app.callback()
66
+ def _root(
67
+ version: bool = typer.Option(
68
+ None,
69
+ "--version",
70
+ "-V",
71
+ help="Print the installed version and exit.",
72
+ callback=_version_callback,
73
+ is_eager=True,
74
+ ),
75
+ ) -> None:
76
+ """Causal-graph analysis of LangGraph, Phoenix, and OpenInference traces."""
77
+
78
+
79
+ console = Console()
80
+ err_console = Console(stderr=True) # diagnostics (warnings) — keep them off result stdout
81
+
82
+ #: Per-file ceiling for artifact loads. An artifact is a body-free trace (hundreds of
83
+ #: steps, a few MB at most); anything this large is not one, and parsing it would pin
84
+ #: multiples of its size in memory. Override with TRACEGRAPH_MAX_ARTIFACT_BYTES
85
+ #: (0 disables the check) if real artifacts ever approach it.
86
+ _DEFAULT_MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
87
+
88
+
89
+ def _max_artifact_bytes() -> int:
90
+ raw = os.environ.get("TRACEGRAPH_MAX_ARTIFACT_BYTES")
91
+ if raw is None:
92
+ return _DEFAULT_MAX_ARTIFACT_BYTES
93
+ try:
94
+ value = int(raw)
95
+ except ValueError:
96
+ raise typer.BadParameter(
97
+ f"TRACEGRAPH_MAX_ARTIFACT_BYTES must be an integer, got {raw!r}"
98
+ ) from None
99
+ if value < 0:
100
+ raise typer.BadParameter("TRACEGRAPH_MAX_ARTIFACT_BYTES must be >= 0")
101
+ if value > 2**63 - 2:
102
+ # Sanity bound: nothing on disk approaches this, and byte counts beyond it stop
103
+ # being meaningful sizes. The chunked reader never passes the limit to read().
104
+ raise typer.BadParameter(
105
+ f"TRACEGRAPH_MAX_ARTIFACT_BYTES is too large (maximum {2**63 - 2})"
106
+ )
107
+ return value
108
+
109
+ _PX_MIN_VERSION = (1, 0, 4)
110
+ _PX_SCAN_LIMIT = 20
111
+ _PX_TIMEOUT_SECONDS = 60
112
+ _PX_VERSION_RE = re.compile(r"(?<!\d)(\d+)\.(\d+)\.(\d+)(?!\d)")
113
+
114
+
115
+ class QueryBackend(str, Enum):
116
+ MEMORY = "memory"
117
+ LADYBUG = "ladybug"
118
+
119
+
120
+ class _PxError(RuntimeError):
121
+ """An actionable, body-free failure at the external Phoenix CLI boundary."""
122
+
123
+
124
+ # Exceptions that mean "this file isn't a usable tracegraph artifact" (bad path, non-JSON,
125
+ # wrong schema_version, or a structurally-invalid trace) — as opposed to a bug inside
126
+ # tracegraph. We translate these into clean CLI errors instead of dumping a raw traceback.
127
+ # ``json.JSONDecodeError``, ``UnicodeDecodeError`` and pydantic's ``ValidationError`` are all
128
+ # ``ValueError`` subclasses, as are the schema_version and canonical-form checks; ``OSError``
129
+ # covers missing / unreadable / is-a-directory paths. Anything else (e.g. a ``TypeError``)
130
+ # is a real bug and propagates unchanged.
131
+ #
132
+ # Trade-off: validate_normalized()'s canonical re-derivation also raises a bare ``ValueError``,
133
+ # so a non-canonical artifact is treated as bad input. That is correct for the common case (a
134
+ # corrupt or hand-edited file) and — in the rare case it surfaces a normalize() regression —
135
+ # the original message is preserved verbatim (see _load_reason) rather than masked.
136
+ _LOAD_ERRORS = (OSError, ValueError)
137
+
138
+
139
+ def _load_reason(exc: Exception) -> str:
140
+ """A short, single-line reason for a load failure — no traceback, no stack."""
141
+ if isinstance(exc, FileNotFoundError):
142
+ return "file not found"
143
+ if isinstance(exc, IsADirectoryError):
144
+ return "is a directory, not a file"
145
+ if isinstance(exc, PermissionError):
146
+ return "permission denied"
147
+ if isinstance(exc, UnicodeDecodeError):
148
+ return "not UTF-8 text (is this a binary file?)"
149
+ if isinstance(exc, json.JSONDecodeError):
150
+ return "not valid JSON"
151
+ if isinstance(exc, ValidationError):
152
+ return "does not match the artifact schema"
153
+ # schema_version mismatch and validate_normalized failures carry a useful message.
154
+ msg = str(exc).strip()
155
+ return msg.splitlines()[0] if msg else exc.__class__.__name__
156
+
157
+
158
+ #: Chunk size for bounded artifact reads. Fixed-size chunks with a cumulative cap —
159
+ #: never ``read(limit + 1)``, which pre-allocates the whole buffer and fails outright
160
+ #: (MemoryError/OverflowError) for a large configured limit even on a tiny file.
161
+ _READ_CHUNK_BYTES = 8 * 1024 * 1024
162
+
163
+
164
+ def _read_artifact_bytes(path: Path) -> bytearray:
165
+ """Read a whole artifact file, enforcing the byte ceiling on the actual reads.
166
+
167
+ Bounded chunked reads from one open descriptor — not a ``stat()`` pre-check — so a
168
+ file that grows (or lies about its size, e.g. a FIFO) between check and read can
169
+ never pull more than ``limit + 1`` bytes into memory: each read requests at most
170
+ the remaining allowance, and one byte past the limit is enough to reject. The
171
+ result accumulates into a single ``bytearray`` (no final full-size join copy).
172
+ This is the single file-load boundary every CLI path goes through.
173
+ """
174
+ limit = _max_artifact_bytes()
175
+ buf = bytearray()
176
+ with open(path, "rb") as handle:
177
+ while True:
178
+ want = (
179
+ min(_READ_CHUNK_BYTES, limit + 1 - len(buf))
180
+ if limit
181
+ else _READ_CHUNK_BYTES
182
+ )
183
+ chunk = handle.read(want)
184
+ if not chunk:
185
+ break
186
+ buf += chunk
187
+ if limit and len(buf) > limit:
188
+ raise ValueError(
189
+ f"artifact exceeds the {limit}-byte limit "
190
+ "(set TRACEGRAPH_MAX_ARTIFACT_BYTES to raise it, 0 to disable)"
191
+ )
192
+ return buf
193
+
194
+
195
+ def _load_validated(path: Path) -> NormalizedTrace:
196
+ """Load an artifact and validate both edge layers. Propagates the underlying load error."""
197
+ nt = artifact.loads(_read_artifact_bytes(path).decode("utf-8"))
198
+ validate_normalized(nt)
199
+ return nt
200
+
201
+
202
+ def _read_source(source: str) -> str:
203
+ if source == "-":
204
+ return typer.get_text_stream("stdin").read()
205
+ return _read_artifact_bytes(Path(source)).decode("utf-8")
206
+
207
+
208
+ def _load_analysis_source(source: str, trace_id: str | None = None) -> NormalizedTrace:
209
+ """Strictly identify a tracegraph artifact or Phoenix CLI trace export."""
210
+ try:
211
+ text = _read_source(source)
212
+ payload = load_json(text)
213
+ if isinstance(payload, dict) and "schema_version" in payload:
214
+ nt = artifact.from_obj(payload)
215
+ else:
216
+ from tracegraph.adapters import PhoenixExportAdapter
217
+
218
+ adapter = PhoenixExportAdapter(payload)
219
+ ids = adapter.discover()
220
+ selected = trace_id
221
+ if selected is None:
222
+ if len(ids) != 1:
223
+ raise ValueError(
224
+ f"Phoenix export holds {len(ids)} traces; pass --trace. "
225
+ f"Found: {', '.join(ids) or 'none'}"
226
+ )
227
+ selected = ids[0]
228
+ nt = normalize(adapter.ingest(selected))
229
+ validate_normalized(nt)
230
+ return nt
231
+ except (*_LOAD_ERRORS, KeyError) as exc:
232
+ raise typer.BadParameter(f"cannot load analysis input {source}: {_load_reason(exc)}") from exc
233
+
234
+
235
+ def _px_run(command: list[str], *, action: str, timeout: int = _PX_TIMEOUT_SECONDS) -> str:
236
+ """Run one read-only ``px`` command without echoing its potentially-sensitive output."""
237
+ try:
238
+ result = subprocess.run(
239
+ command,
240
+ check=True,
241
+ capture_output=True,
242
+ text=True,
243
+ timeout=timeout,
244
+ )
245
+ except FileNotFoundError as exc:
246
+ raise _PxError(
247
+ "Phoenix CLI `px` was not found; install/configure @arizeai/phoenix-cli first"
248
+ ) from exc
249
+ except subprocess.TimeoutExpired as exc:
250
+ raise _PxError(f"{action} timed out after {timeout} seconds") from exc
251
+ except subprocess.CalledProcessError as exc:
252
+ raise _PxError(
253
+ f"{action} failed with exit {exc.returncode}; verify Phoenix endpoint, authentication, "
254
+ "and project configuration"
255
+ ) from exc
256
+ return result.stdout
257
+
258
+
259
+ def _px_version() -> str:
260
+ text = _px_run(["px", "--version"], action="px version check", timeout=10).strip()
261
+ match = _PX_VERSION_RE.search(text)
262
+ if match is None:
263
+ raise _PxError("could not determine the Phoenix CLI version; install px >= 1.0.4")
264
+ parsed = tuple(int(part) for part in match.groups())
265
+ if parsed < _PX_MIN_VERSION:
266
+ raise _PxError(
267
+ f"Phoenix CLI {match.group(0)} is too old; install px >= 1.0.4 with "
268
+ "`npm install -g @arizeai/phoenix-cli@latest`"
269
+ )
270
+ return match.group(0)
271
+
272
+
273
+ def _px_json(command: list[str], *, action: str) -> Any:
274
+ text = _px_run(command, action=action)
275
+ try:
276
+ return load_json(text)
277
+ except ValueError as exc:
278
+ raise _PxError(f"{action} returned invalid JSON") from exc
279
+
280
+
281
+ def _px_project_args(project: str | None) -> list[str]:
282
+ return ["--project", project] if project else []
283
+
284
+
285
+ def _px_normalize(payload: Any, trace_id: str) -> NormalizedTrace:
286
+ try:
287
+ from tracegraph.adapters import PhoenixExportAdapter
288
+
289
+ adapter = PhoenixExportAdapter(payload)
290
+ nt = normalize(adapter.ingest(trace_id))
291
+ validate_normalized(nt)
292
+ return nt
293
+ except (ValueError, KeyError) as exc:
294
+ raise _PxError(
295
+ f"px returned an unsupported or incomplete trace export: {_load_reason(exc)}"
296
+ ) from exc
297
+
298
+
299
+ def _px_trace(trace_id: str, *, project: str | None = None) -> NormalizedTrace:
300
+ """Read one annotated trace through ``px`` without persisting its raw body."""
301
+ command = [
302
+ "px",
303
+ "trace",
304
+ "get",
305
+ trace_id,
306
+ "--include-annotations",
307
+ "--format",
308
+ "raw",
309
+ "--no-progress",
310
+ *_px_project_args(project),
311
+ ]
312
+ payload = _px_json(command, action="px trace get")
313
+ return _px_normalize(payload, trace_id)
314
+
315
+
316
+ def _px_latest_trace(*, project: str | None = None) -> tuple[NormalizedTrace, bool, int]:
317
+ """Select the newest error, or the newest trace when the scan contains no error."""
318
+ command = [
319
+ "px",
320
+ "trace",
321
+ "list",
322
+ "--limit",
323
+ str(_PX_SCAN_LIMIT),
324
+ "--include-annotations",
325
+ "--format",
326
+ "raw",
327
+ "--no-progress",
328
+ *_px_project_args(project),
329
+ ]
330
+ payload = _px_json(command, action="px trace list")
331
+ if not isinstance(payload, list):
332
+ raise _PxError("px trace list returned an unsupported response; expected a JSON array")
333
+ if not payload:
334
+ raise _PxError("the configured Phoenix project has no traces")
335
+
336
+ try:
337
+ from tracegraph.adapters import PhoenixExportAdapter
338
+
339
+ adapter = PhoenixExportAdapter(payload)
340
+ ids = adapter.discover()
341
+ except ValueError as exc:
342
+ raise _PxError(
343
+ f"px returned an unsupported or incomplete trace list: {_load_reason(exc)}"
344
+ ) from exc
345
+
346
+ statuses: list[str] = []
347
+ for trace in payload:
348
+ # px 1.8.1's buildTrace() derives this aggregate from child span statuses. The
349
+ # report below independently normalizes those spans, so selection and diagnosis
350
+ # deliberately remain separate checks rather than treating the aggregate as evidence.
351
+ status = trace.get("status")
352
+ if status not in {"OK", "ERROR"}:
353
+ raise _PxError(
354
+ "px trace list returned a trace without the required OK/ERROR status"
355
+ )
356
+ statuses.append(status)
357
+
358
+ # The pinned CLI contract test verifies that trace list emits newest-first order.
359
+ error_index = next(
360
+ (
361
+ index for index, status in enumerate(statuses) if status == "ERROR"
362
+ ),
363
+ None,
364
+ )
365
+ selected_index = error_index if error_index is not None else 0
366
+ selected_id = ids[selected_index]
367
+ nt = _px_normalize(payload[selected_index], selected_id)
368
+ return nt, error_index is not None, len(payload)
369
+
370
+
371
+ def _render_analysis(report: AnalysisReport, *, limit: int) -> None:
372
+ status_style = "red" if report.status == StepStatus.ERROR.value else "green"
373
+ console.print(
374
+ f"[bold {status_style}]{escape(report.trace_id)}[/] "
375
+ f"{report.step_count} steps · {report.error_count} error"
376
+ )
377
+
378
+ console.print("\n[bold]What failed — candidates to investigate[/]")
379
+ if not report.primary_failures:
380
+ console.print(" [green]No error step found.[/]")
381
+ for finding in report.primary_failures[:limit]:
382
+ step = finding.step
383
+ console.print(f" [red]✗[/] {escape(step.name or step.kind)} ({step.kind}, step {step.seq})")
384
+ shown = {item.step_id: item.name or item.kind for item in finding.causal_steps}
385
+ shown[step.step_id] = step.name or step.kind
386
+ for edge in finding.causal_edges:
387
+ origin = f" [{edge.origin}]" if edge.origin else ""
388
+ console.print(
389
+ f" {escape(shown.get(edge.cause, edge.cause))} → "
390
+ f"{escape(shown.get(edge.effect, edge.effect))}[dim]{escape(origin)}[/]"
391
+ )
392
+ remaining = len(report.primary_failures) - limit
393
+ if remaining > 0:
394
+ console.print(f" [dim]… {remaining} more failure candidate(s) in JSON report[/]")
395
+ if report.propagated_failures:
396
+ names = ", ".join(escape(item.name or item.kind) for item in report.propagated_failures)
397
+ console.print(f" [dim]propagated/context errors: {names}[/]")
398
+
399
+ console.print("\n[bold]Repeated behavior[/]")
400
+ if report.patterns:
401
+ for finding in report.patterns:
402
+ labels = " → ".join(escape(label or "(unnamed)") for label in finding.labels)
403
+ console.print(f" • {finding.pattern_id}@v{finding.pattern_version}: {labels}")
404
+ else:
405
+ console.print(" [dim]No high-signal failure pattern found.[/]")
406
+
407
+ metrics = report.metrics
408
+ console.print("\n[bold]Telemetry[/]")
409
+ console.print(
410
+ " wall="
411
+ + (f"{metrics.wall_duration_ms:g} ms" if metrics.wall_duration_ms is not None else "unavailable")
412
+ + " · tokens="
413
+ + (str(metrics.total_tokens) if metrics.total_tokens is not None else "unavailable")
414
+ + " · cost="
415
+ + (
416
+ f"{metrics.total_cost} {metrics.cost_currency or ''}".rstrip()
417
+ if metrics.total_cost is not None
418
+ else "unavailable"
419
+ )
420
+ )
421
+ for evaluation in metrics.evaluations:
422
+ console.print(
423
+ f" eval {escape(evaluation.name)}: "
424
+ f"label={escape(evaluation.label or 'unavailable')} "
425
+ f"score={evaluation.score if evaluation.score is not None else 'unavailable'}"
426
+ )
427
+
428
+ if report.decision_evidence:
429
+ console.print("\n[bold]External decision evidence[/]")
430
+ for item in report.decision_evidence:
431
+ console.print(
432
+ f" {escape(item.source)} · verdict={escape(item.verdict)} · "
433
+ f"generation={item.graph_generation} · "
434
+ f"artifact={escape(item.artifact_digest)}"
435
+ )
436
+
437
+ if report.comparison:
438
+ comparison = report.comparison
439
+ console.print("\n[bold]Compared with baseline[/]")
440
+ topology = "identical" if comparison.topology_identical else "changed"
441
+ console.print(f" topology: {topology}")
442
+ for change in comparison.behavior_changes:
443
+ console.print(
444
+ f" • {escape(change.name or change.logical_step_key)}: "
445
+ f"{change.before or 'missing'} → {change.after or 'missing'}"
446
+ )
447
+ deltas = ", ".join(
448
+ f"{key}={value:+}" for key, value in comparison.pattern_count_deltas.items() if value
449
+ )
450
+ if deltas:
451
+ console.print(f" pattern deltas: {deltas}")
452
+ metric_deltas = comparison.metric_deltas
453
+ wall_delta = metric_deltas["wall_duration_ms"]
454
+ token_delta = metric_deltas["total_tokens"]
455
+ cost_delta = metric_deltas["total_cost"]
456
+ wall_text = f"{wall_delta:+g} ms" if wall_delta is not None else "unavailable"
457
+ token_text = f"{token_delta:+d}" if token_delta is not None else "unavailable"
458
+ cost_text = (
459
+ f"{Decimal(str(cost_delta)):+f} {report.metrics.cost_currency}"
460
+ if cost_delta is not None and report.metrics.cost_currency
461
+ else "unavailable"
462
+ )
463
+ console.print(
464
+ f" telemetry delta: wall={wall_text} · tokens={token_text} · cost={cost_text}"
465
+ )
466
+
467
+ console.print("\n[bold]Data fidelity / privacy[/]")
468
+ console.print(
469
+ f" {report.causal_fidelity} · links_preserved={report.links_preserved} · "
470
+ f"privacy={report.privacy_profile}"
471
+ )
472
+ for warning in report.warnings:
473
+ console.print(f" [yellow]⚠ {escape(warning)}[/]")
474
+
475
+
476
+ def _artifact_files(paths: list[Path]) -> list[Path]:
477
+ """Expand files and directories (``*.json``) into artifact paths."""
478
+ files: list[Path] = []
479
+ for path in paths:
480
+ files.extend(sorted(path.glob("*.json")) if path.is_dir() else [path])
481
+ if not files:
482
+ raise typer.BadParameter("no artifact files found")
483
+ return files
484
+
485
+
486
+ @dataclass(frozen=True)
487
+ class _LoadedArtifact:
488
+ path: Path
489
+ trace: NormalizedTrace
490
+ digest: str
491
+
492
+
493
+ def _load_artifact_evidence(path: Path) -> _LoadedArtifact:
494
+ """Load and validate the exact bytes whose digest will identify review evidence."""
495
+ raw = _read_artifact_bytes(path)
496
+ digest = f"sha256:{hashlib.sha256(raw).hexdigest()}"
497
+ nt = artifact.loads(raw.decode("utf-8"))
498
+ del raw # only the digest and the parsed trace are retained per file
499
+ validate_normalized(nt)
500
+ return _LoadedArtifact(path=path, trace=nt, digest=digest)
501
+
502
+
503
+ def _load(path: Path) -> NormalizedTrace:
504
+ """Load + validate an explicitly-named artifact, mapping failures to clean CLI errors."""
505
+ try:
506
+ return _load_validated(path)
507
+ except _LOAD_ERRORS as exc:
508
+ raise typer.BadParameter(f"cannot load artifact {path}: {_load_reason(exc)}") from exc
509
+
510
+
511
+ def _warn_skipped(skipped: list[tuple[Path, str]]) -> None:
512
+ """Emit the stderr warning for directory-discovered files that weren't valid artifacts."""
513
+ if not skipped:
514
+ return
515
+ # escape() the dynamic names/reasons so a filename containing Rich markup (e.g.
516
+ # "weird[x].json") renders literally instead of being mis-parsed as style tags.
517
+ detail = ", ".join(f"{f.name} ({why})" for f, why in skipped)
518
+ err_console.print(
519
+ f"[yellow]⚠ skipped {len(skipped)} non-artifact file(s): {escape(detail)}[/]"
520
+ )
521
+
522
+
523
+ def _load_many_evidence(
524
+ paths: list[Path], *, exclude: Path | None = None
525
+ ) -> list[_LoadedArtifact]:
526
+ """Expand files and directories (``*.json``) into a list of validated traces.
527
+
528
+ Directory expansion is **lenient** and **non-recursive** (top-level ``*.json`` only,
529
+ not subdirectories): a directory of artifacts may legitimately also hold unrelated JSON
530
+ (configs, exports), so a globbed ``*.json`` that isn't a valid artifact is skipped (with
531
+ a warning) rather than crashing the whole query. An **explicitly named** file is
532
+ **strict**: if the user points directly at a file, a load failure is a clean, fatal
533
+ error — they meant that file.
534
+
535
+ Rejects duplicate ``trace_id`` across the loaded set. The portable artifact is a system
536
+ of record keyed by ``trace_id``, so two files claiming the same id are ambiguous — and
537
+ ``query --explain`` looks up the originating trace by id when rendering an ancestor
538
+ chain, so silently keeping the last-loaded copy would attach matches from one artifact
539
+ to a different artifact's causal graph.
540
+ """
541
+ discovered: list[tuple[Path, bool]] = [] # (path, explicitly_named)
542
+ excluded = exclude.resolve() if exclude is not None else None
543
+ for p in paths:
544
+ if p.is_dir():
545
+ discovered.extend(
546
+ (f, False)
547
+ for f in sorted(p.glob("*.json"))
548
+ if excluded is None or f.resolve() != excluded
549
+ )
550
+ else:
551
+ if excluded is not None and p.resolve() == excluded:
552
+ raise typer.BadParameter("--out must not overwrite an input artifact")
553
+ discovered.append((p, True))
554
+ if not discovered:
555
+ raise typer.BadParameter("no artifact files found")
556
+
557
+ loaded: list[_LoadedArtifact] = []
558
+ skipped: list[tuple[Path, str]] = []
559
+ for f, explicit in discovered:
560
+ if explicit:
561
+ try:
562
+ loaded.append(_load_artifact_evidence(f))
563
+ except _LOAD_ERRORS as exc:
564
+ # Flush what we've already skipped before aborting, so strays discovered
565
+ # earlier in the argument list aren't silently dropped by the fatal error.
566
+ _warn_skipped(skipped)
567
+ raise typer.BadParameter(
568
+ f"cannot load artifact {f}: {_load_reason(exc)}"
569
+ ) from exc
570
+ continue
571
+ try:
572
+ loaded.append(_load_artifact_evidence(f))
573
+ except _LOAD_ERRORS as exc:
574
+ skipped.append((f, _load_reason(exc)))
575
+
576
+ _warn_skipped(skipped)
577
+ if not loaded:
578
+ raise typer.BadParameter(
579
+ f"no valid artifacts found ({len(skipped)} discovered file(s) were not "
580
+ "tracegraph artifacts)"
581
+ )
582
+
583
+ seen: dict[str, Path] = {}
584
+ for item in loaded:
585
+ tid = item.trace.trace.trace_id
586
+ if tid in seen:
587
+ raise typer.BadParameter(
588
+ f"duplicate trace_id {tid!r}: {seen[tid]} and {item.path}. Each artifact "
589
+ "must carry a unique trace_id — rename or deduplicate before querying."
590
+ )
591
+ seen[tid] = item.path
592
+ return loaded
593
+
594
+
595
+ def _load_many(paths: list[Path]) -> list[NormalizedTrace]:
596
+ return [item.trace for item in _load_many_evidence(paths)]
597
+
598
+
599
+ def _store_cls(backend: QueryBackend) -> type[Any]:
600
+ if backend is QueryBackend.MEMORY:
601
+ return InMemoryStore
602
+ try:
603
+ from tracegraph.store.ladybug import LadybugStore
604
+ except ModuleNotFoundError as exc:
605
+ if exc.name != "ladybug":
606
+ raise
607
+ raise typer.BadParameter(
608
+ "--backend ladybug requires the optional agent-tracegraph[cypher] dependency"
609
+ ) from exc
610
+ return LadybugStore
611
+
612
+
613
+ def _store_from_artifact(path: Path, backend: QueryBackend) -> Any:
614
+ # _store_cls may raise its own clean BadParameter (missing ladybug) — keep it outside the
615
+ # try so we only translate *load* failures, not backend-selection ones.
616
+ cls = _store_cls(backend)
617
+ try:
618
+ # Bounded read through the common boundary rather than cls.load_artifact(path),
619
+ # which would re-open the file without the size ceiling.
620
+ nt = artifact.loads(_read_artifact_bytes(path).decode("utf-8"))
621
+ return cls.from_trace(nt)
622
+ except _LOAD_ERRORS as exc:
623
+ raise typer.BadParameter(f"cannot load artifact {path}: {_load_reason(exc)}") from exc
624
+
625
+
626
+ def _store_from_trace(nt: NormalizedTrace, backend: QueryBackend) -> Any:
627
+ return _store_cls(backend).from_trace(nt)
628
+
629
+
630
+ def _close_store(store: Any) -> None:
631
+ """Release a backend store's resources (no-op for backends without close())."""
632
+ close = getattr(store, "close", None)
633
+ if close is not None:
634
+ close()
635
+
636
+
637
+ def _close_stores(stores: dict[str, Any]) -> None:
638
+ """Close every store, even when one close raises; re-raise the first failure."""
639
+ first: BaseException | None = None
640
+ for store in stores.values():
641
+ try:
642
+ _close_store(store)
643
+ except BaseException as exc: # noqa: BLE001 - must keep closing the rest
644
+ if first is None:
645
+ first = exc
646
+ stores.clear()
647
+ if first is not None:
648
+ raise first
649
+
650
+
651
+ def _search_with_backend(
652
+ traces: list[NormalizedTrace],
653
+ pattern,
654
+ backend: QueryBackend,
655
+ ) -> list[Match]:
656
+ """Match a pattern across traces.
657
+
658
+ Never returns a live store: each backend store is opened, queried, and closed
659
+ within this call (including on exceptions), so a directory sweep holds at most
660
+ one open backend DB at a time. A caller that needs stores afterwards (``query
661
+ --explain``) rebuilds them lazily for just the traces it renders."""
662
+ if backend is QueryBackend.MEMORY:
663
+ return pattern_search(traces, pattern)
664
+
665
+ matches: list[Match] = []
666
+ fell_back = False
667
+ for nt in traces:
668
+ store = _store_from_trace(nt, backend)
669
+ try:
670
+ steps = nt.steps_by_id()
671
+ for path in store.find_matches(pattern):
672
+ labels = [steps[i].name or steps[i].kind.value for i in path]
673
+ matches.append(
674
+ Match(trace_id=nt.trace.trace_id, step_ids=path, labels=labels)
675
+ )
676
+ fell_back = fell_back or getattr(store, "fell_back_to_python", False)
677
+ finally:
678
+ _close_store(store)
679
+ if fell_back:
680
+ # Honesty signal (off result stdout): the ladybug accelerator couldn't compile this
681
+ # pattern under LadybugDB's 30-hop cap and ran the pure-Python matcher instead. Results
682
+ # are identical — this just tells the user the accelerator deferred.
683
+ err_console.print(
684
+ "[dim]⚠ pattern not Cypher-compilable (unbounded gap); ran the pure-Python "
685
+ "matcher over the raw causal graph — results are identical to --backend ladybug's.[/]"
686
+ )
687
+ return matches
688
+
689
+
690
+ def _label(step) -> str:
691
+ # escape() both producer-controlled strings. Span names and error text routinely contain
692
+ # square brackets ("tool[0]", "[Errno 2] ..."), which Rich reads as markup: it would strip
693
+ # them from the rendered name, so the operator silently sees a *wrong* name or error
694
+ # rather than a mangled one. Every other render path in this module already escapes.
695
+ base = escape(step.name or step.kind.value)
696
+ suffix = " ⚠ lossy-projection" if step.projection_lossy else ""
697
+ if step.status is StepStatus.ERROR:
698
+ return f"[bold red]{step.seq}: {base} ✗ {escape(step.error_msg or 'error')}[/]{suffix}"
699
+ return f"[green]{step.seq}: {base}[/]{suffix}"
700
+
701
+
702
+ def _render_tree(nt: NormalizedTrace) -> None:
703
+ children: dict[str, list[str]] = {}
704
+ for e in nt.edges_of(EdgeType.TREE_PARENT):
705
+ children.setdefault(e.dst, []).append(e.src)
706
+ has_parent = {e.src for e in nt.edges_of(EdgeType.TREE_PARENT)}
707
+ steps = nt.steps_by_id()
708
+ # (seq, step_id) — the canonical tie-break used everywhere else. Sorting this *set* by
709
+ # seq alone left equal-seq steps in hash order, so the rendered tree could differ between
710
+ # runs under PYTHONHASHSEED randomization.
711
+ order = sorted(
712
+ children.keys() | {s.step_id for s in nt.steps}, key=lambda i: (steps[i].seq, i)
713
+ )
714
+
715
+ def add_subtree(root_node: Tree, rid: str) -> None:
716
+ # Iterative DFS: a recursive walk overflows on deep-linear traces (one super-step
717
+ # per node). Push children in reverse-seq order so they render seq-ascending as they
718
+ # pop, and each child's branch is rendered under its own parent.
719
+ stack: list[tuple[Tree, str]] = [(root_node, rid)]
720
+ while stack:
721
+ rich_parent, nid = stack.pop()
722
+ branch = rich_parent.add(_label(steps[nid]))
723
+ for child in sorted(children.get(nid, []), key=lambda i: (steps[i].seq, i), reverse=True):
724
+ stack.append((branch, child))
725
+
726
+ # The header is producer-controlled too: a trace id or source kind containing brackets
727
+ # would raise MarkupError on an otherwise valid artifact, exactly as the step labels did.
728
+ root_tree = Tree(
729
+ f"[bold]{escape(nt.trace.trace_id)}[/] ({escape(nt.trace.source_kind)})"
730
+ )
731
+ for rid in [i for i in order if i not in has_parent]:
732
+ add_subtree(root_tree, rid)
733
+ console.print(root_tree)
734
+
735
+ n_err = sum(1 for s in nt.steps if s.status is StepStatus.ERROR)
736
+ n_tool = sum(1 for s in nt.steps if s.kind.value == "TOOL")
737
+ n_lossy = sum(1 for s in nt.steps if s.projection_lossy)
738
+ console.print(
739
+ f"\n[dim]{len(nt.steps)} steps · {n_tool} tool · "
740
+ f"{n_err} error · {n_lossy} lossy-projection[/]"
741
+ )
742
+
743
+
744
+ @app.command()
745
+ def ingest(
746
+ sqlite: Path = typer.Option(..., help="Path to a LangGraph SqliteSaver DB file."),
747
+ thread: str = typer.Option(..., "--thread", "-t", help="thread_id to ingest."),
748
+ out: Path = typer.Option(None, "--out", "-o", help="Artifact path (default <thread>.json)."),
749
+ error_channel: str = typer.Option("error", help="State channel that signals a step error."),
750
+ ) -> None:
751
+ """Ingest a LangGraph thread's checkpoint history into a portable artifact."""
752
+ from tracegraph.sqlite_snapshot import ingest_snapshot
753
+
754
+ target = out or artifact.default_path(thread)
755
+ _distinct_paths([sqlite], [target])
756
+ try:
757
+ raw = ingest_snapshot(sqlite, thread, error_channel=error_channel)
758
+ for warning in raw.ingest_warnings:
759
+ # Disclosures about the *read*, not the run: they describe evidence this ingest
760
+ # could not interpret, so they belong on stderr next to the command that read it
761
+ # rather than in the artifact, which records the run itself.
762
+ err_console.print(f"[yellow]warning:[/] {escape(warning)}")
763
+ nt = normalize(raw)
764
+ _save_ingested(nt, target, explicit=out is not None)
765
+ except (OSError, ValueError, KeyError) as exc:
766
+ raise typer.BadParameter(f"cannot ingest checkpoint: {_load_reason(exc)}") from exc
767
+ console.print(f"[green]ingested {len(nt.steps)} steps[/] → {target}")
768
+
769
+
770
+ @app.command(name="ingest-otlp")
771
+ def ingest_otlp(
772
+ file: Path = typer.Option(..., "--file", "-f", help="OTLP JSON/JSONL span export (resourceSpans)."),
773
+ trace: str = typer.Option(None, "--trace", "-t", help="traceId to ingest (default: the file's only trace)."),
774
+ out: Path = typer.Option(None, "--out", "-o", help="Artifact path (default <traceId>.json)."),
775
+ ) -> None:
776
+ """Ingest one trace from an OpenInference/OTLP span export into a portable artifact."""
777
+ from tracegraph.adapters import OTLPSpanAdapter
778
+
779
+ try:
780
+ # Bounded read through the common size-limited boundary (from_file would
781
+ # read_text the whole file with no ceiling). KeyError joins the usual load
782
+ # errors: the adapter raises it for an unknown/missing trace id.
783
+ adapter = OTLPSpanAdapter.from_json(_read_artifact_bytes(file).decode("utf-8"))
784
+ if trace is None:
785
+ ids = adapter.discover()
786
+ if len(ids) != 1:
787
+ raise typer.BadParameter(
788
+ f"file holds {len(ids)} traces; pass --trace. Found: {', '.join(ids) or 'none'}"
789
+ )
790
+ trace = ids[0]
791
+ nt = normalize(adapter.ingest(trace))
792
+ except (*_LOAD_ERRORS, KeyError) as exc:
793
+ raise typer.BadParameter(f"cannot ingest OTLP export {file}: {_load_reason(exc)}") from exc
794
+ target = out or artifact.default_path(trace)
795
+ _distinct_paths([file], [target])
796
+ _save_ingested(nt, target, explicit=out is not None)
797
+ console.print(f"[green]ingested {len(nt.steps)} spans[/] → {target}")
798
+
799
+
800
+ @app.command(name="ingest-phoenix")
801
+ def ingest_phoenix(
802
+ file: str = typer.Option(..., "--file", "-f", help="Phoenix CLI trace JSON or '-' for stdin."),
803
+ trace: str = typer.Option(None, "--trace", "-t", help="traceId (required for multi-trace exports)."),
804
+ out: Path = typer.Option(None, "--out", "-o", help="Body-free artifact path."),
805
+ ) -> None:
806
+ """Ingest one Phoenix CLI trace export into a body-free portable artifact."""
807
+ nt = _load_analysis_source(file, trace)
808
+ if nt.trace.source_kind != "phoenix_cli":
809
+ raise typer.BadParameter("--file must contain a Phoenix CLI trace export")
810
+ target = out or artifact.default_path(nt.trace.trace_id)
811
+ _distinct_paths([Path(file)] if file != "-" else [], [target])
812
+ _save_ingested(nt, target, explicit=out is not None)
813
+ console.print(f"[green]ingested {len(nt.steps)} Phoenix spans[/] → {target}")
814
+
815
+
816
+ @app.command(name="analyze")
817
+ def analyze_command(
818
+ source: str = typer.Argument(..., help="Tracegraph artifact, Phoenix export, or '-' for stdin."),
819
+ trace: str = typer.Option(None, "--trace", "-t", help="Trace id for a multi-trace Phoenix input."),
820
+ baseline: str = typer.Option(None, "--baseline", help="Explicit artifact/Phoenix baseline."),
821
+ baseline_trace: str = typer.Option(None, "--baseline-trace", help="Trace id in a multi-trace baseline."),
822
+ json_out: Path = typer.Option(None, "--json-out", help="Write deterministic body-free JSON report."),
823
+ limit: int = typer.Option(3, "--limit", "-l", help="Failure candidates to investigate rendered in the terminal."),
824
+ ) -> None:
825
+ """Automatically diagnose failures, retries, telemetry, and an optional baseline."""
826
+ if limit <= 0:
827
+ raise typer.BadParameter("--limit must be a positive integer")
828
+ _distinct_paths([Path(p) for p in (source, baseline) if p and p != "-"], [json_out] if json_out else [])
829
+ nt = _load_analysis_source(source, trace)
830
+ baseline_nt = _load_analysis_source(baseline, baseline_trace) if baseline else None
831
+ report = build_analysis(nt, baseline=baseline_nt)
832
+ _render_analysis(report, limit=limit)
833
+ if json_out:
834
+ _save_report(report, json_out)
835
+ console.print(f"\n[green]report[/] → {json_out}")
836
+
837
+
838
+ @phoenix_app.command(name="diagnose")
839
+ def phoenix_diagnose(
840
+ trace_id: str | None = typer.Argument(
841
+ None, help="Phoenix trace id (default: latest failed trace)."
842
+ ),
843
+ project: str = typer.Option(None, "--project", help="Phoenix project name or id."),
844
+ baseline: str = typer.Option(None, "--baseline", help="Explicit Phoenix baseline trace id."),
845
+ save_artifact: Path = typer.Option(None, "--save-artifact", help="Save the body-free artifact."),
846
+ json_out: Path = typer.Option(None, "--json-out", help="Write deterministic body-free JSON report."),
847
+ limit: int = typer.Option(3, "--limit", "-l", help="Failure candidates to investigate rendered in the terminal."),
848
+ ) -> None:
849
+ """Diagnose an explicit trace or automatically select a recent failure."""
850
+ if limit <= 0:
851
+ raise typer.BadParameter("--limit must be a positive integer")
852
+ _distinct_paths([], [p for p in (save_artifact, json_out) if p is not None])
853
+ try:
854
+ _px_version()
855
+ if trace_id:
856
+ nt = _px_trace(trace_id, project=project)
857
+ else:
858
+ nt, selected_error, scanned = _px_latest_trace(project=project)
859
+ trace_id = nt.trace.trace_id
860
+ if selected_error:
861
+ console.print(
862
+ f"[cyan]selected latest failed Phoenix trace[/] {escape(trace_id)}"
863
+ )
864
+ else:
865
+ console.print(
866
+ f"[yellow]No failed trace found in the latest {scanned}; "
867
+ f"diagnosing the newest trace[/] {escape(trace_id)}"
868
+ )
869
+ baseline_nt = _px_trace(baseline, project=project) if baseline else None
870
+ except _PxError as exc:
871
+ raise typer.BadParameter(str(exc)) from exc
872
+ report = build_analysis(nt, baseline=baseline_nt)
873
+ _render_analysis(report, limit=limit)
874
+ if save_artifact:
875
+ _save_artifact(nt, save_artifact)
876
+ console.print(f"\n[green]artifact[/] → {save_artifact}")
877
+ if json_out:
878
+ _save_report(report, json_out)
879
+ console.print(f"[green]report[/] → {json_out}")
880
+
881
+
882
+ @phoenix_app.command(name="doctor")
883
+ def phoenix_doctor(
884
+ project: str = typer.Option(None, "--project", help="Phoenix project name or id."),
885
+ ) -> None:
886
+ """Check the read-only Phoenix CLI, connection, authentication, and project access."""
887
+ try:
888
+ version = _px_version()
889
+ projects = _px_json(
890
+ [
891
+ "px",
892
+ "project",
893
+ "list",
894
+ "--limit",
895
+ "1",
896
+ "--format",
897
+ "raw",
898
+ "--no-progress",
899
+ ],
900
+ action="px project list",
901
+ )
902
+ if not isinstance(projects, list):
903
+ raise _PxError("px project list returned an unsupported response; expected a JSON array")
904
+ traces = _px_json(
905
+ [
906
+ "px",
907
+ "trace",
908
+ "list",
909
+ "--limit",
910
+ "1",
911
+ "--format",
912
+ "raw",
913
+ "--no-progress",
914
+ *_px_project_args(project),
915
+ ],
916
+ action="px trace list",
917
+ )
918
+ if not isinstance(traces, list):
919
+ raise _PxError("px trace list returned an unsupported response; expected a JSON array")
920
+ if traces:
921
+ from tracegraph.adapters import PhoenixExportAdapter
922
+
923
+ PhoenixExportAdapter(traces)
924
+ except (ValueError, _PxError) as exc:
925
+ err_console.print(f"[bold red]FAIL[/] {escape(str(exc))}")
926
+ raise typer.Exit(1) from exc
927
+
928
+ target = escape(project or "configured project")
929
+ if not traces:
930
+ console.print(
931
+ f"[bold yellow]WARN[/] px {version} can access {target}, but it has no traces yet."
932
+ )
933
+ console.print(" Instrument and run the agent once, then retry `tracegraph phoenix diagnose`.")
934
+ return
935
+ console.print(f"[bold green]READY[/] px {version} can read {target}.")
936
+ console.print(" Run `tracegraph phoenix diagnose` to inspect the latest failed trace.")
937
+
938
+
939
+ @app.command()
940
+ def inspect(artifact_path: Path = typer.Argument(..., help="Artifact JSON from `ingest`.")) -> None:
941
+ """Render a trace's derived causal tree."""
942
+ _render_tree(_load(artifact_path))
943
+
944
+
945
+ @app.command()
946
+ def validate(
947
+ artifacts: list[Path] = typer.Argument(..., help="Artifact JSON files and/or directories."),
948
+ ) -> None:
949
+ """Validate artifacts against the canonical schema and causal-graph contract."""
950
+ # Resolve the size-limit configuration ONCE, before the per-file loop: a bad
951
+ # TRACEGRAPH_MAX_ARTIFACT_BYTES is a configuration error and must abort the command,
952
+ # not be reported as "INVALID" against every (perfectly valid) file.
953
+ _max_artifact_bytes()
954
+ files = _artifact_files(artifacts)
955
+ failures = 0
956
+ for path in files:
957
+ try:
958
+ nt = _load(path)
959
+ except typer.BadParameter as exc:
960
+ # _load already narrowed to load-shaped failures (_LOAD_ERRORS); anything else
961
+ # is a real tracegraph bug and must propagate, not print as "INVALID".
962
+ failures += 1
963
+ console.print(f"[bold red]INVALID[/] {path}: {exc}")
964
+ continue
965
+ console.print(
966
+ f"[green]OK[/] {escape(str(path))} [dim]{escape(nt.trace.trace_id)} · {len(nt.steps)} steps[/]"
967
+ )
968
+
969
+ if failures:
970
+ console.print(f"\n[bold red]{failures} invalid artifact(s)[/]")
971
+ raise typer.Exit(1)
972
+ console.print(f"\n[green]{len(files)} artifact(s) valid[/]")
973
+
974
+
975
+ @app.command()
976
+ def explain(
977
+ artifact_path: Path = typer.Argument(..., help="Artifact JSON from `ingest`."),
978
+ step_id: str = typer.Argument(..., help="Step id to explain (full or unique suffix)."),
979
+ backend: QueryBackend = typer.Option(
980
+ QueryBackend.MEMORY,
981
+ "--backend",
982
+ case_sensitive=False,
983
+ help="Query backend to use for raw causal traversal.",
984
+ ),
985
+ ) -> None:
986
+ """Trace a step's raw causal chain back toward its root cause."""
987
+ store = _store_from_artifact(artifact_path, backend)
988
+ try:
989
+ steps = store.trace().steps_by_id()
990
+ # An exact full-id match always wins — even when that id is also a *suffix* of a longer
991
+ # namespaced id (e.g. "abc" vs "ns:abc"). Only fall back to suffix matching when the
992
+ # input isn't itself a full step id, so a valid id is never rejected as "ambiguous".
993
+ if step_id in steps:
994
+ chosen = step_id
995
+ else:
996
+ suffix = [sid for sid in steps if sid.endswith(step_id)]
997
+ if len(suffix) != 1:
998
+ raise typer.BadParameter(
999
+ f"{step_id!r} matched {len(suffix)} steps; use a unique id or suffix."
1000
+ )
1001
+ chosen = suffix[0]
1002
+ result = explain_chain(store, chosen, steps=steps)
1003
+ finally:
1004
+ _close_store(store)
1005
+ target = result.target
1006
+ # Unescaped, a name or error containing "[...]" raises MarkupError and kills the command.
1007
+ console.print(f"[bold]{escape(target.name or target.kind.value)}[/] (step {target.seq}) "
1008
+ f"\\[{target.status.value}] {escape(target.error_msg or '')}")
1009
+ if not result.chain:
1010
+ console.print("[dim]no causes (this is a root step)[/]")
1011
+ for s in result.chain:
1012
+ flag = " [yellow]⚠ projection-lossy[/]" if s.projection_lossy else ""
1013
+ console.print(f" ← {escape(s.name or s.source.value)} (step {s.seq}){flag}")
1014
+ if result.is_lossy:
1015
+ console.print(
1016
+ "\n[yellow]Some steps had multiple real causes; the single-parent tree view "
1017
+ "is lossy for them — trust this raw chain, not the tree.[/]"
1018
+ )
1019
+
1020
+
1021
+ @app.command()
1022
+ def diff(
1023
+ a: Path = typer.Argument(..., help="Artifact JSON for run A."),
1024
+ b: Path = typer.Argument(..., help="Artifact JSON for run B."),
1025
+ structure: bool = typer.Option(False, "--structure", help="Compare topology only, ignore labels."),
1026
+ ) -> None:
1027
+ """Structurally diff two runs (rooted-tree isomorphism over the derived tree).
1028
+
1029
+ Exits 0 when isomorphic, 1 when not (so CI can gate on structural regressions).
1030
+ """
1031
+ nt_a, nt_b = _load(a), _load(b)
1032
+ console.print(f"[dim]Derived TREE_PARENT comparison; lossy steps: A={sum(s.projection_lossy for s in nt_a.steps)}, B={sum(s.projection_lossy for s in nt_b.steps)}[/]")
1033
+ label = structure_only if structure else None
1034
+ result = tree_diff(nt_a, nt_b, label) if label else tree_diff(nt_a, nt_b)
1035
+ if result.identical:
1036
+ console.print("[green]IDENTICAL[/] — the two runs are structurally isomorphic.")
1037
+ raise typer.Exit(0)
1038
+ console.print("[bold yellow]NOT IDENTICAL[/]")
1039
+ for line in result.changes:
1040
+ console.print(f" • {line}")
1041
+ console.print("[dim](divergence localization is heuristic; the identical/not verdict is exact)[/]")
1042
+ raise typer.Exit(1)
1043
+
1044
+
1045
+ @app.command()
1046
+ def presets() -> None:
1047
+ """List the named cross-trace query patterns."""
1048
+ for name, pattern in PRESETS.items():
1049
+ # escape() the pattern: its rendering uses [gap …] markers that Rich would otherwise
1050
+ # parse as style tags and silently swallow (the gap connector would vanish).
1051
+ version = f"@v{pattern.pattern_version}"
1052
+ review = " [cyan]review-exportable[/]" if is_review_exportable(pattern) else ""
1053
+ console.print(
1054
+ f"[bold]{name}{version}[/] [dim]{escape(pattern.description)}[/]{review}"
1055
+ )
1056
+ console.print(f" {escape(str(pattern))}")
1057
+
1058
+
1059
+ @app.command()
1060
+ def query(
1061
+ preset: str = typer.Argument(..., help="Preset pattern name (see `tracegraph presets`)."),
1062
+ artifacts: list[Path] = typer.Argument(..., help="Artifact JSON files and/or directories."),
1063
+ limit: int | None = typer.Option(
1064
+ None,
1065
+ "--limit",
1066
+ "-l",
1067
+ help="Cap total matches across all traces (useful when scanning hundreds). "
1068
+ "Match order is unchanged — the cap simply truncates the tail.",
1069
+ ),
1070
+ explain: bool = typer.Option(
1071
+ False,
1072
+ "--explain",
1073
+ help="For each match, render the raw causal ancestor chain of the matched "
1074
+ "effect (the last step in the path) — same shape as `tracegraph explain`.",
1075
+ ),
1076
+ backend: QueryBackend = typer.Option(
1077
+ QueryBackend.MEMORY,
1078
+ "--backend",
1079
+ case_sensitive=False,
1080
+ help="Query backend to use for pattern matching and optional explanations.",
1081
+ ),
1082
+ ) -> None:
1083
+ """Find a causal pattern across one or many traces. Exits 1 if no match is found."""
1084
+ pattern = PRESETS.get(preset)
1085
+ if pattern is None:
1086
+ raise typer.BadParameter(
1087
+ f"unknown preset {preset!r}; available: {', '.join(PRESETS)}"
1088
+ )
1089
+ if limit is not None and limit <= 0:
1090
+ raise typer.BadParameter("--limit must be a positive integer")
1091
+ traces = _load_many(artifacts)
1092
+ matches = _search_with_backend(traces, pattern, backend)
1093
+ # --explain stores are built lazily below, AFTER --limit truncation, so a 10-of-500
1094
+ # cap opens at most 10 backend DBs regardless of how many traces matched.
1095
+ stores: dict[str, Any] = {}
1096
+ try:
1097
+ total = len(matches)
1098
+ truncated = limit is not None and total > limit
1099
+ if truncated:
1100
+ matches = matches[:limit]
1101
+ # escape() the pattern str (its [gap …] markers would be eaten as Rich markup otherwise).
1102
+ console.print(
1103
+ f"[bold]{pattern.pattern_id}@v{pattern.pattern_version}[/]: {escape(str(pattern))} "
1104
+ f"[dim](over {len(traces)} trace(s))[/]\n"
1105
+ )
1106
+ if not matches:
1107
+ console.print("[dim]no matches[/]")
1108
+ raise typer.Exit(1)
1109
+
1110
+ # Lazily build stores only for the traces that actually appear in the (possibly
1111
+ # truncated) match set — --explain over a 10-of-500 cap shouldn't pay 500 store loads.
1112
+ traces_by_id = {nt.trace.trace_id: nt for nt in traces}
1113
+ steps_maps: dict[str, dict] = {}
1114
+ for m in matches:
1115
+ console.print(
1116
+ f"[green]{escape(m.trace_id)}[/]: "
1117
+ + " → ".join(escape(label) for label in m.labels)
1118
+ )
1119
+ if explain:
1120
+ store = stores.get(m.trace_id)
1121
+ if store is None:
1122
+ store = _store_from_trace(traces_by_id[m.trace_id], backend)
1123
+ stores[m.trace_id] = store
1124
+ steps_map = steps_maps.get(m.trace_id)
1125
+ if steps_map is None:
1126
+ steps_map = traces_by_id[m.trace_id].steps_by_id()
1127
+ steps_maps[m.trace_id] = steps_map
1128
+ # The effect (last step in the matched path) is what we trace back from —
1129
+ # the rest of the match is by construction part of its causal chain, but
1130
+ # explain() surfaces every cause including ones the pattern didn't constrain.
1131
+ result = explain_chain(store, m.step_ids[-1], steps=steps_map)
1132
+ if not result.chain:
1133
+ console.print(" [dim]← (no causes — matched effect is a root step)[/]")
1134
+ for s in result.chain:
1135
+ flag = " [yellow]⚠ projection-lossy[/]" if s.projection_lossy else ""
1136
+ # Fallback label matches `tracegraph explain` (s.name or s.source.value)
1137
+ # so the two renderings agree for nameless LangGraph checkpoints.
1138
+ console.print(f" ← {escape(s.name or s.source.value)} (step {s.seq}){flag}")
1139
+ # Causal-honesty signal: when the matched effect (or anything in the chain)
1140
+ # has more than one real cause, the single-parent tree projection dropped
1141
+ # at least one. `tracegraph explain` surfaces this same warning — `query
1142
+ # --explain` must too, or a fan-in effect's lossiness becomes invisible
1143
+ # (target lossiness doesn't show up in the per-step ⚠ flags above, since
1144
+ # we only flag chain ancestors there).
1145
+ if result.is_lossy:
1146
+ console.print(
1147
+ " [yellow]⚠ some steps in this match had multiple real causes — "
1148
+ "trust this raw chain, not the tree.[/]"
1149
+ )
1150
+
1151
+ n_traces = len({m.trace_id for m in matches})
1152
+ suffix = f" — truncated from {total}" if truncated else ""
1153
+ console.print(
1154
+ f"\n[dim]{len(matches)} match(es) across {n_traces} trace(s){suffix}[/]"
1155
+ )
1156
+ finally:
1157
+ _close_stores(stores)
1158
+
1159
+
1160
+ @app.command(name="export-review-candidates")
1161
+ def export_review_candidates(
1162
+ preset: str = typer.Argument(..., help="Review-exportable preset pattern name."),
1163
+ artifacts: list[Path] = typer.Argument(..., help="Artifact JSON files and/or directories."),
1164
+ out: Path = typer.Option(..., "--out", "-o", help="Versioned review-candidate JSON report."),
1165
+ backend: QueryBackend = typer.Option(
1166
+ QueryBackend.MEMORY,
1167
+ "--backend",
1168
+ case_sensitive=False,
1169
+ help="Pattern-matching backend; both backends produce identical candidates.",
1170
+ ),
1171
+ ) -> None:
1172
+ """Export body-free, versioned governance review candidates. Empty matches succeed."""
1173
+ pattern = PRESETS.get(preset)
1174
+ if pattern is None:
1175
+ raise typer.BadParameter(
1176
+ f"unknown preset {preset!r}; available: {', '.join(PRESETS)}"
1177
+ )
1178
+ if not is_review_exportable(pattern):
1179
+ raise typer.BadParameter(f"preset {preset!r} is not review-exportable")
1180
+
1181
+ loaded = _load_many_evidence(artifacts, exclude=out)
1182
+ traces = [item.trace for item in loaded]
1183
+ matches = _search_with_backend(traces, pattern, backend)
1184
+ traces_by_id = {item.trace.trace.trace_id: item.trace for item in loaded}
1185
+ digests_by_id = {item.trace.trace.trace_id: item.digest for item in loaded}
1186
+ try:
1187
+ report = build_candidate_report(pattern, matches, traces_by_id, digests_by_id)
1188
+ save_candidate_report(report, out)
1189
+ except (OSError, ValueError) as exc:
1190
+ raise typer.BadParameter(f"cannot export review candidates: {exc}") from exc
1191
+ console.print(
1192
+ f"[green]exported {len(report.candidates)} review candidate(s)[/] "
1193
+ f"from {len(matches)} match(es) → {out}"
1194
+ )
1195
+
1196
+
1197
+ def main() -> None:
1198
+ app()
1199
+
1200
+
1201
+ def _distinct_paths(inputs: list[Path], outputs: list[Path]) -> None:
1202
+ seen = list(inputs)
1203
+ for output in outputs:
1204
+ for previous in seen:
1205
+ if output.resolve() == previous.resolve() or (
1206
+ output.exists() and previous.exists() and output.samefile(previous)
1207
+ ):
1208
+ raise typer.BadParameter("output must not overwrite an input or another output")
1209
+ seen.append(output)
1210
+
1211
+
1212
+ def _save_report(report: AnalysisReport, target: Path) -> None:
1213
+ """Write a JSON report, turning an unwritable destination into a clean usage error.
1214
+
1215
+ The analysis has already run and been rendered by the time we get here, so an
1216
+ unwrapped OSError would replace a completed result with a raw traceback.
1217
+ """
1218
+ try:
1219
+ save_analysis_report(report, target)
1220
+ except OSError as exc:
1221
+ raise typer.BadParameter(f"cannot write report: {_load_reason(exc)}") from exc
1222
+
1223
+
1224
+ def _save_artifact(nt: NormalizedTrace, target: Path) -> None:
1225
+ """Write an artifact to an explicit destination, with the same clean-error contract."""
1226
+ try:
1227
+ artifact.save_atomic(nt, target)
1228
+ except OSError as exc:
1229
+ raise typer.BadParameter(f"cannot save artifact: {_load_reason(exc)}") from exc
1230
+
1231
+
1232
+ def _save_ingested(nt: NormalizedTrace, target: Path, *, explicit: bool) -> None:
1233
+ try:
1234
+ artifact.save_atomic(nt, target, replace=explicit)
1235
+ except FileExistsError as exc:
1236
+ raise typer.BadParameter("default output exists; pass --out to explicitly replace a file") from exc
1237
+ except OSError as exc:
1238
+ raise typer.BadParameter(f"cannot save artifact: {_load_reason(exc)}") from exc
1239
+
1240
+
1241
+ if __name__ == "__main__":
1242
+ main()