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.
@@ -0,0 +1,65 @@
1
+ """Analyze checkpoints on a private SQLite backup, without initializing the source.
2
+
3
+ Reading a checkpoint database is the one place this tool touches a file format that can
4
+ *execute*. LangGraph's msgpack deserializer revives objects by importing a module and calling
5
+ a name, both taken from the stored payload; by default an unrecognized target only logs a
6
+ warning and is then called. A crafted checkpoint row naming ``os.system`` therefore runs a
7
+ command on the machine doing the analysis — which is the opposite of what a read-only
8
+ forensic tool should do. So this module hands the saver a serializer with an **empty**
9
+ allowlist: framework-registered types still revive, and anything else comes back as inert
10
+ data instead of being invoked.
11
+ """
12
+
13
+ from contextlib import closing
14
+ from pathlib import Path
15
+ import sqlite3
16
+ import tempfile
17
+ import time
18
+
19
+ from tracegraph.adapters.langgraph_checkpoint import LangGraphCheckpointAdapter
20
+ from tracegraph.model import RawTrace
21
+
22
+
23
+ def _inert_serde():
24
+ """A checkpoint deserializer that revives data but never invokes an arbitrary callable.
25
+
26
+ ``allowed_msgpack_modules=()`` keeps the framework's own registered types working — dates,
27
+ sets, `Send` packets, the values the adapter actually reads — while an unregistered
28
+ ``(module, name)`` pair is refused and handed back as its raw arguments. LangGraph offers
29
+ the same lockdown through ``LANGGRAPH_STRICT_MSGPACK``, but an environment variable is not
30
+ a guarantee a library can make about itself.
31
+ """
32
+ from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
33
+
34
+ return JsonPlusSerializer(allowed_msgpack_modules=())
35
+
36
+
37
+ def ingest_snapshot(source: Path, thread: str, *, error_channel: str, timeout: float = 30) -> RawTrace:
38
+ from langgraph.checkpoint.sqlite import SqliteSaver
39
+
40
+ deadline = time.monotonic() + timeout
41
+
42
+ def progress(status: int, remaining: int, total: int) -> None:
43
+ if time.monotonic() >= deadline:
44
+ raise ValueError("SQLite snapshot deadline exceeded")
45
+
46
+ try:
47
+ with tempfile.TemporaryDirectory(prefix="tracegraph-snapshot-") as directory:
48
+ snapshot = Path(directory) / "checkpoints.sqlite"
49
+ src = sqlite3.connect(source.resolve().as_uri() + "?mode=ro", timeout=timeout, uri=True)
50
+ try:
51
+ dest = sqlite3.connect(snapshot)
52
+ try:
53
+ src.backup(dest, pages=256, progress=progress, sleep=0.01)
54
+ finally:
55
+ dest.close()
56
+ finally:
57
+ src.close()
58
+ # Not `from_conn_string`: it does not take a serializer, and the default one
59
+ # will call whatever the payload names. `check_same_thread=False` mirrors what
60
+ # that helper does.
61
+ with closing(sqlite3.connect(str(snapshot), check_same_thread=False)) as conn:
62
+ saver = SqliteSaver(conn, serde=_inert_serde())
63
+ return LangGraphCheckpointAdapter(saver, error_channel=error_channel).ingest(thread)
64
+ except sqlite3.Error as exc:
65
+ raise ValueError("cannot read SQLite checkpoint snapshot") from exc
@@ -0,0 +1,22 @@
1
+ """Pluggable query backends. The pure-Python ``InMemoryStore`` is the default.
2
+
3
+ The optional :class:`LadybugStore` lives behind the ``[cypher]`` extra and is exported here
4
+ **only** when ``ladybug`` is importable, so a default install never pays the import cost (or
5
+ errors on a missing optional dep).
6
+ """
7
+
8
+ from tracegraph.store.base import GraphStore
9
+ from tracegraph.store.in_memory import InMemoryStore
10
+
11
+ __all__ = ["GraphStore", "InMemoryStore"]
12
+
13
+ try:
14
+ from tracegraph.store.ladybug import LadybugStore # noqa: F401
15
+ except ModuleNotFoundError as exc:
16
+ # Swallow ONLY "the optional dep itself is missing"; anything else (a broken ladybug
17
+ # install, a typo in our own imports, a removed internal module) must surface as a
18
+ # real error, not silently hide LadybugStore from the public API.
19
+ if exc.name != "ladybug":
20
+ raise
21
+ else:
22
+ __all__.append("LadybugStore")
@@ -0,0 +1,43 @@
1
+ """The pluggable query backend interface — deliberately small.
2
+
3
+ The store does **not** expose a ``query(cypher)`` method: that would leak Cypher
4
+ upward and make the pure-Python path a second-class citizen. Instead, analyses are
5
+ expressed as *operations* the store implements; each backend supplies its own
6
+ implementation (pure-Python traversal here; compiled Cypher in the optional LadybugDB
7
+ backend for pattern matching).
8
+
9
+ Pattern matching intentionally stays out of the minimal ``GraphStore`` protocol. It is a
10
+ separate backend capability (pure-Python functions for the default path; ``LadybugStore`` adds
11
+ ``find_matches``) so RCA/diff callers do not depend on a Cypher-capable backend.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Protocol, runtime_checkable
18
+
19
+ from tracegraph.model import Edge, NormalizedTrace, Step
20
+
21
+
22
+ @runtime_checkable
23
+ class GraphStore(Protocol):
24
+ def init_schema(self) -> None: ...
25
+
26
+ def upsert_nodes(self, steps: list[Step]) -> None: ...
27
+
28
+ def upsert_edges(self, edges: list[Edge]) -> None:
29
+ """Store ALL edges, including every raw CAUSED_BY (not just the tree parent)."""
30
+ ...
31
+
32
+ def ancestors(self, step_id: str) -> list[Step]:
33
+ """Raw CAUSED_BY traversal from ``step_id`` toward root causes (the RCA primitive)."""
34
+ ...
35
+
36
+ def export_artifact(self, path: str | Path) -> None: ...
37
+
38
+ @classmethod
39
+ def load_artifact(cls, path: str | Path) -> "GraphStore": ...
40
+
41
+ def trace(self) -> NormalizedTrace:
42
+ """Return the loaded trace as a NormalizedTrace (for rendering/diffing)."""
43
+ ...
@@ -0,0 +1,94 @@
1
+ """Pure-Python, in-process graph store — the default backend.
2
+
3
+ Zero dependencies beyond the model, no server, no optional deps. It holds the
4
+ normalized trace in memory and answers the raw-graph traversal that ``explain``/RCA
5
+ need. The portable artifact remains the source of record; this store is rebuilt from
6
+ it on load.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import deque
12
+ from pathlib import Path
13
+
14
+ from tracegraph import artifact
15
+ from tracegraph.model import Edge, EdgeType, NormalizedTrace, RawTrace, Step, Trace
16
+ from tracegraph.normalize import normalize, validate_normalized
17
+
18
+
19
+ class InMemoryStore:
20
+ def __init__(self) -> None:
21
+ self._trace: Trace | None = None
22
+ self._steps: dict[str, Step] = {}
23
+ self._edges: list[Edge] = []
24
+ # adjacency for raw causal edges: effect -> [causes]
25
+ self._caused_by: dict[str, list[str]] = {}
26
+
27
+ # --- construction ---
28
+
29
+ @classmethod
30
+ def from_trace(cls, nt: NormalizedTrace) -> "InMemoryStore":
31
+ """Load an already-normalized trace, validating both edge layers at the boundary."""
32
+ validate_normalized(nt)
33
+ store = cls()
34
+ store.init_schema()
35
+ store._trace = nt.trace.model_copy(deep=True)
36
+ store.upsert_nodes(nt.steps)
37
+ store.upsert_edges(nt.edges)
38
+ return store
39
+
40
+ @classmethod
41
+ def from_raw(cls, raw: RawTrace) -> "InMemoryStore":
42
+ """Normalize a raw adapter output and load it. The intended ingestion path."""
43
+ return cls.from_trace(normalize(raw))
44
+
45
+ @classmethod
46
+ def load_artifact(cls, path: str | Path) -> "InMemoryStore":
47
+ return cls.from_trace(artifact.load(path))
48
+
49
+ # --- GraphStore protocol ---
50
+
51
+ def init_schema(self) -> None: # nothing to do for an in-memory store
52
+ pass
53
+
54
+ def upsert_nodes(self, steps: list[Step]) -> None:
55
+ for s in steps:
56
+ self._steps[s.step_id] = s.model_copy(deep=True)
57
+
58
+ def upsert_edges(self, edges: list[Edge]) -> None:
59
+ self._edges.extend(e.model_copy(deep=True) for e in edges)
60
+ for e in edges:
61
+ if e.type is EdgeType.CAUSED_BY:
62
+ self._caused_by.setdefault(e.src, []).append(e.dst)
63
+
64
+ def ancestors(self, step_id: str) -> list[Step]:
65
+ """All raw causal ancestors of ``step_id``, nearest cause first (BFS over CAUSED_BY)."""
66
+ if step_id not in self._steps:
67
+ raise KeyError(f"unknown step {step_id!r}")
68
+ out: list[Step] = []
69
+ seen: set[str] = {step_id}
70
+ queue: deque[str] = deque(self._caused_by.get(step_id, []))
71
+ while queue:
72
+ cur = queue.popleft()
73
+ if cur in seen:
74
+ continue
75
+ seen.add(cur)
76
+ if cur not in self._steps:
77
+ # A CAUSED_BY edge pointing at an unknown step means a corrupted raw
78
+ # graph. explain/RCA must surface that, not silently drop the cause.
79
+ raise KeyError(f"CAUSED_BY edge points to unknown step {cur!r}")
80
+ out.append(self._steps[cur].model_copy(deep=True))
81
+ queue.extend(self._caused_by.get(cur, []))
82
+ return out
83
+
84
+ def export_artifact(self, path: str | Path) -> None:
85
+ artifact.save_atomic(self.trace(), path)
86
+
87
+ def trace(self) -> NormalizedTrace:
88
+ if self._trace is None:
89
+ raise RuntimeError("store has no trace loaded")
90
+ return NormalizedTrace(
91
+ trace=self._trace,
92
+ steps=list(self._steps.values()),
93
+ edges=list(self._edges),
94
+ ).model_copy(deep=True)