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.
- agent_tracegraph-0.2.0.dist-info/METADATA +380 -0
- agent_tracegraph-0.2.0.dist-info/RECORD +28 -0
- agent_tracegraph-0.2.0.dist-info/WHEEL +4 -0
- agent_tracegraph-0.2.0.dist-info/entry_points.txt +2 -0
- agent_tracegraph-0.2.0.dist-info/licenses/LICENSE +191 -0
- tracegraph/__init__.py +38 -0
- tracegraph/adapters/__init__.py +13 -0
- tracegraph/adapters/base.py +22 -0
- tracegraph/adapters/langgraph_checkpoint.py +804 -0
- tracegraph/adapters/otlp_spans.py +703 -0
- tracegraph/adapters/phoenix_export.py +218 -0
- tracegraph/analysis/__init__.py +55 -0
- tracegraph/analysis/ahu.py +329 -0
- tracegraph/analysis/diagnose.py +676 -0
- tracegraph/analysis/explain.py +46 -0
- tracegraph/analysis/patterns.py +628 -0
- tracegraph/artifact.py +192 -0
- tracegraph/cli.py +1242 -0
- tracegraph/input_validation.py +19 -0
- tracegraph/model.py +225 -0
- tracegraph/normalize.py +248 -0
- tracegraph/py.typed +0 -0
- tracegraph/review_candidates.py +136 -0
- tracegraph/sqlite_snapshot.py +65 -0
- tracegraph/store/__init__.py +22 -0
- tracegraph/store/base.py +43 -0
- tracegraph/store/in_memory.py +94 -0
- tracegraph/store/ladybug.py +505 -0
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()
|