langgraph-ledger 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,39 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Public API of langgraph-ledger."""
3
+ from __future__ import annotations
4
+
5
+ from .analysis import analyze_log
6
+ from .callbacks import DshTraceCallbackHandler
7
+ from .checkpointer import TracingCheckpointSaver
8
+ from .dag import RunDAG, build_dag, build_dag_from_file
9
+ from .events import EVENT_KINDS, FORMAT_VERSION
10
+ from .hashing import (canonical_json, checkpoint_label, event_id, sha256_hex,
11
+ tool_call_label)
12
+ from .recorder import (TracePayloadError, TraceRecorder, active_recorder,
13
+ bind_recorder, current_recorder, emit_event, read_log,
14
+ recorder_for)
15
+ from .repair import close_orphaned_run, find_orphaned_runs, repair_all
16
+ from .replay import ReplayError, replay_messages
17
+ from .rollback import find_checkpoint_by_label, fork_thread, time_travel_config
18
+ from .verify import VerifyReport, verify_log, verify_thread
19
+
20
+ __version__ = "0.2.0"
21
+
22
+ __all__ = [
23
+ "__version__",
24
+ # recording
25
+ "TraceRecorder", "TracePayloadError", "bind_recorder", "current_recorder",
26
+ "active_recorder", "emit_event", "read_log", "recorder_for",
27
+ # integration
28
+ "TracingCheckpointSaver", "DshTraceCallbackHandler",
29
+ # labels / hashing
30
+ "canonical_json", "sha256_hex", "event_id", "tool_call_label",
31
+ "checkpoint_label", "EVENT_KINDS", "FORMAT_VERSION",
32
+ # dag / rollback / audit
33
+ "RunDAG", "build_dag", "build_dag_from_file",
34
+ "fork_thread", "time_travel_config", "find_checkpoint_by_label",
35
+ "verify_log", "verify_thread", "VerifyReport", "analyze_log",
36
+ # crash recovery / replay
37
+ "find_orphaned_runs", "close_orphaned_run", "repair_all",
38
+ "replay_messages", "ReplayError",
39
+ ]
@@ -0,0 +1,6 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Enable `python -m langgraph_ledger <command>`."""
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,113 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Failure analysis over a recorded log.
3
+
4
+ Because every tool call carries a content-addressed label, exact-repeat loops
5
+ (the agent calling the same tool with the same input over and over) fall out
6
+ for free. Combined with error events and node timings this gives a compact
7
+ first-stop failure report:
8
+
9
+ python -m langgraph_ledger analyze <log.jsonl>
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from . import events as ev
17
+ from .recorder import read_log
18
+
19
+ __all__ = ["analyze_log"]
20
+
21
+
22
+ def analyze_log(path: str | Path) -> dict[str, Any]:
23
+ """Summarize one thread log: counts, errors, loops, timeline, checkpoints."""
24
+ by_kind: dict[str, int] = {}
25
+ errors: list[dict[str, Any]] = []
26
+ tool_labels: dict[str, dict[str, Any]] = {}
27
+ checkpoints = 0
28
+ interrupted_runs = 0
29
+ models: dict[str, int] = {}
30
+ node_ms: dict[str, int] = {}
31
+ first_ts: str | None = None
32
+ last_ts: str | None = None
33
+ unknown: dict[str, int] = {}
34
+
35
+ prev_call_label: str | None = None
36
+ streaks: list[dict[str, Any]] = []
37
+ streak: dict[str, Any] | None = None
38
+
39
+ for e in read_log(path):
40
+ kind = str(e.get("kind") or "")
41
+ payload = e.get("payload") or {}
42
+ seq = e.get("seq")
43
+ first_ts = first_ts or e.get("ts")
44
+ last_ts = e.get("ts") or last_ts
45
+ if kind in ev.EVENT_KINDS:
46
+ by_kind[kind] = by_kind.get(kind, 0) + 1
47
+ else:
48
+ unknown[kind] = unknown.get(kind, 0) + 1
49
+
50
+ if kind == ev.KIND_TOOL_CALL:
51
+ label = str(payload.get("label") or "")
52
+ name = str(payload.get("name") or "")
53
+ slot = tool_labels.setdefault(label, {"name": name, "count": 0, "seqs": []})
54
+ slot["count"] += 1
55
+ slot["seqs"].append(seq)
56
+ if label == prev_call_label:
57
+ if streak is None:
58
+ streak = {"label": label, "name": name, "start_seq": seq, "length": 2}
59
+ else:
60
+ streak["length"] += 1
61
+ else:
62
+ if streak is not None:
63
+ streaks.append(streak)
64
+ streak = None
65
+ prev_call_label = label
66
+ elif kind == ev.KIND_TOOL_RESULT:
67
+ if not payload.get("ok"):
68
+ errors.append({"seq": seq, "kind": kind,
69
+ "name": payload.get("name"),
70
+ "error": payload.get("error")})
71
+ elif kind == ev.KIND_LLM_CALL and payload.get("error"):
72
+ errors.append({"seq": seq, "kind": kind, "node": payload.get("node"),
73
+ "error": payload.get("error")})
74
+ if kind == ev.KIND_LLM_CALL and payload.get("model"):
75
+ m = str(payload["model"])
76
+ models[m] = models.get(m, 0) + 1
77
+ elif kind == ev.KIND_NODE_END and not payload.get("ok", True):
78
+ errors.append({"seq": seq, "kind": kind, "run_id": payload.get("run_id"),
79
+ "error": payload.get("error")})
80
+ if kind == ev.KIND_NODE_END and payload.get("node"):
81
+ node = str(payload["node"])
82
+ node_ms[node] = node_ms.get(node, 0) + int(payload.get("ms") or 0)
83
+ elif kind == ev.KIND_RUN_END and payload.get("status") == "interrupted":
84
+ interrupted_runs += 1
85
+ elif kind == ev.KIND_ERROR:
86
+ errors.append({"seq": seq, "kind": kind, "where": payload.get("where"),
87
+ "error": payload.get("error")})
88
+ elif kind == ev.KIND_STATE_SNAPSHOT:
89
+ checkpoints += 1
90
+ prev_call_label = None
91
+
92
+ if streak is not None:
93
+ streaks.append(streak)
94
+
95
+ loops = [{"label": k, "name": v["name"], "count": v["count"], "seqs": v["seqs"]}
96
+ for k, v in tool_labels.items() if v["count"] > 1]
97
+
98
+ return {
99
+ "log": str(path),
100
+ "events": sum(by_kind.values()) + sum(unknown.values()),
101
+ "by_kind": by_kind,
102
+ "unknown_kinds": unknown,
103
+ "checkpoints": checkpoints,
104
+ "interrupted_runs": interrupted_runs,
105
+ "models": models,
106
+ "node_time_ms": node_ms,
107
+ "errors": errors,
108
+ "error_count": len(errors),
109
+ "repeated_tool_calls": loops,
110
+ "consecutive_repeat_streaks": streaks,
111
+ "first_ts": first_ts,
112
+ "last_ts": last_ts,
113
+ }
@@ -0,0 +1,221 @@
1
+ # -*- coding: utf-8 -*-
2
+ """LangChain/LangGraph callback handler that feeds the trace recorder.
3
+
4
+ Attach to any LangGraph run:
5
+
6
+ graph.invoke(input, config={**config, "callbacks": [DshTraceCallbackHandler()]})
7
+
8
+ LangChain propagates callbacks into every node, LLM call and tool call, so a
9
+ single handler sees the whole run. Node boundaries are recognized through
10
+ LangGraph's ``langgraph_node`` metadata; LLM/tool activity is paired by run id
11
+ — the tool *result* carries the same content label as its call, not just a
12
+ run-id echo.
13
+
14
+ ``record_full=True`` opts into persisting full prompt/response/input/output
15
+ text alongside the digests (required for replay). Default off: digests only.
16
+
17
+ All emit calls are fail-soft unless the recorder is strict: tracing never
18
+ breaks the run.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from typing import Any
24
+ from uuid import UUID
25
+
26
+ from langchain_core.callbacks import BaseCallbackHandler
27
+
28
+ from . import events as ev
29
+ from .hashing import tool_call_label
30
+ from .recorder import current_recorder
31
+
32
+ __all__ = ["DshTraceCallbackHandler"]
33
+
34
+
35
+ class DshTraceCallbackHandler(BaseCallbackHandler):
36
+ """Emit dsh-style trace events for node / LLM / tool activity."""
37
+
38
+ def __init__(self, recorder: Any = None, *, record_full: bool = False) -> None:
39
+ super().__init__()
40
+ #: Optional explicit recorder; falls back to the context-bound one.
41
+ self.recorder = recorder
42
+ #: Opt-in: persist full text next to digests (replay prerequisite).
43
+ self.record_full = bool(record_full)
44
+ self._starts: dict[str, float] = {}
45
+ self._llm_open: dict[str, dict[str, Any]] = {}
46
+ #: run_id -> {label, name, node} so the result can echo the call's label
47
+ self._tool_open: dict[str, dict[str, Any]] = {}
48
+ #: run_id -> node name, so node/end keeps its identity
49
+ self._node_open: dict[str, str] = {}
50
+ #: run_id -> {"steps": int} for the top-level graph run (run brackets —
51
+ #: the crash-recovery anchor: an unclosed run/start means "died here")
52
+ self._run_open: dict[str, dict[str, Any]] = {}
53
+
54
+ # -- plumbing --------------------------------------------------------------
55
+
56
+ def _rec(self) -> Any:
57
+ return self.recorder if self.recorder is not None else current_recorder()
58
+
59
+ def _emit(self, kind: str, payload: Any) -> bool:
60
+ rec = self._rec()
61
+ return rec.emit(kind, payload) if rec is not None else False
62
+
63
+ @staticmethod
64
+ def _node(metadata: dict | None) -> str:
65
+ return str((metadata or {}).get("langgraph_node") or "")
66
+
67
+ @staticmethod
68
+ def _model_of(serialized: dict | None, metadata: dict | None,
69
+ llm_output: dict | None = None) -> str:
70
+ """Best-effort model identity: langsmith metadata → serialized → llm_output."""
71
+ for source in ((metadata or {}).get("ls_model_name"),
72
+ (serialized or {}).get("name"),
73
+ (serialized or {}).get("id", [None])[-1]
74
+ if isinstance((serialized or {}).get("id"), list) else None,
75
+ (llm_output or {}).get("model_name")):
76
+ if source:
77
+ return str(source)
78
+ return ""
79
+
80
+ def _mark(self, run_id: UUID) -> None:
81
+ self._starts[str(run_id)] = time.monotonic()
82
+
83
+ def _ms_since(self, run_id: UUID) -> int:
84
+ start = self._starts.pop(str(run_id), None)
85
+ return int((time.monotonic() - start) * 1000) if start is not None else 0
86
+
87
+ # -- node boundaries (LangGraph chain runs carrying langgraph_node) ---------
88
+
89
+ def on_chain_start(self, serialized: dict | None, inputs: Any, *,
90
+ run_id: UUID, parent_run_id: UUID | None = None,
91
+ metadata: dict | None = None, **kwargs: Any) -> None:
92
+ node = self._node(metadata)
93
+ if not node:
94
+ if parent_run_id is None:
95
+ # the top-level graph invocation — open a run bracket
96
+ self._mark(run_id)
97
+ self._run_open[str(run_id)] = {"steps": 0}
98
+ self._emit(ev.KIND_RUN_START, ev.run_start_payload(
99
+ graph=str(kwargs.get("name") or "")))
100
+ return
101
+ self._mark(run_id)
102
+ self._node_open[str(run_id)] = node
103
+ self._emit(ev.KIND_NODE_START, ev.node_boundary_payload(node=node, run_id=str(run_id)))
104
+
105
+ def _close_run(self, run_id: UUID, *, status: str, error: str = "") -> None:
106
+ opened = self._run_open.pop(str(run_id), None)
107
+ if opened is None:
108
+ return
109
+ ms = self._ms_since(run_id)
110
+ self._emit(ev.KIND_RUN_END, ev.run_end_payload(
111
+ status=status, error=error, steps=opened["steps"], ms=ms))
112
+
113
+ def _close_node(self, run_id: UUID, *, ok: bool, error: str = "") -> None:
114
+ node = self._node_open.pop(str(run_id), None)
115
+ if node is None:
116
+ return
117
+ ms = self._ms_since(run_id)
118
+ payload = {**ev.node_boundary_payload(node=node, run_id=str(run_id)),
119
+ "ms": ms, "ok": ok}
120
+ if error:
121
+ payload["error"] = error
122
+ self._emit(ev.KIND_NODE_END, payload)
123
+ for opened in self._run_open.values():
124
+ opened["steps"] += 1
125
+
126
+ def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None:
127
+ if str(run_id) in self._run_open:
128
+ self._close_run(run_id, status="completed")
129
+ return
130
+ self._close_node(run_id, ok=True)
131
+
132
+ def on_chain_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
133
+ if str(run_id) in self._run_open:
134
+ self._close_run(run_id, status="error",
135
+ error=f"{type(error).__name__}: {error}")
136
+ return
137
+ self._close_node(run_id, ok=False, error=f"{type(error).__name__}: {error}")
138
+
139
+ # -- LLM calls ----------------------------------------------------------------
140
+
141
+ def on_llm_start(self, serialized: dict | None, prompts: list[str], *,
142
+ run_id: UUID, metadata: dict | None = None, **kwargs: Any) -> None:
143
+ self._mark(run_id)
144
+ self._llm_open[str(run_id)] = {
145
+ "text": "\n".join(prompts),
146
+ "model": self._model_of(serialized, metadata),
147
+ "node": self._node(metadata),
148
+ }
149
+
150
+ def on_chat_model_start(self, serialized: dict | None, messages: Any, *,
151
+ run_id: UUID, metadata: dict | None = None, **kwargs: Any) -> None:
152
+ self._mark(run_id)
153
+ flat = "\n".join(f"{getattr(m, 'type', '?')}: {getattr(m, 'content', m)}"
154
+ for batch in (messages or []) for m in (batch or []))
155
+ self._llm_open[str(run_id)] = {
156
+ "text": flat,
157
+ "model": self._model_of(serialized, metadata),
158
+ "node": self._node(metadata),
159
+ }
160
+
161
+ def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None:
162
+ ms = self._ms_since(run_id)
163
+ info = self._llm_open.pop(str(run_id), {})
164
+ text = ""
165
+ usage = None
166
+ try:
167
+ text = "\n".join(getattr(g, "text", "") or str(getattr(g, "message", ""))
168
+ for gen in (response.generations or [])
169
+ for g in (gen or []))
170
+ usage = (response.llm_output or {}).get("token_usage")
171
+ if not info.get("model"):
172
+ info["model"] = self._model_of(None, None, response.llm_output)
173
+ except AttributeError:
174
+ pass
175
+ self._emit(ev.KIND_LLM_CALL, ev.llm_call_payload(
176
+ node=info.get("node", ""), model=info.get("model", ""),
177
+ prompt=info.get("text", ""), response=text, ms=ms, usage=usage,
178
+ record_full=self.record_full))
179
+
180
+ def on_llm_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
181
+ ms = self._ms_since(run_id)
182
+ info = self._llm_open.pop(str(run_id), {})
183
+ self._emit(ev.KIND_LLM_CALL, ev.llm_call_payload(
184
+ node=info.get("node", ""), model=info.get("model", ""),
185
+ prompt=info.get("text", ""), response="", ms=ms,
186
+ error=f"{type(error).__name__}: {error}",
187
+ record_full=self.record_full))
188
+
189
+ # -- tool calls (hash-labeled; result echoes the call's label) -----------------
190
+
191
+ def on_tool_start(self, serialized: dict | None, input_str: str, *,
192
+ run_id: UUID, parent_run_id: UUID | None = None,
193
+ metadata: dict | None = None, **kwargs: Any) -> None:
194
+ self._mark(run_id)
195
+ name = str((serialized or {}).get("name") or "")
196
+ tool_input = kwargs.get("inputs", input_str)
197
+ canonical_input = tool_input if isinstance(tool_input, (dict, list)) else input_str
198
+ label = tool_call_label(name, canonical_input)
199
+ self._tool_open[str(run_id)] = {"label": label, "name": name,
200
+ "node": self._node(metadata)}
201
+ self._emit(ev.KIND_TOOL_CALL, ev.tool_call_payload(
202
+ label=label, node=self._node(metadata), name=name,
203
+ tool_input=canonical_input, run_id=str(run_id),
204
+ parent_run_id=str(parent_run_id) if parent_run_id else "",
205
+ record_full=self.record_full))
206
+
207
+ def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
208
+ ms = self._ms_since(run_id)
209
+ opened = self._tool_open.pop(str(run_id), {})
210
+ self._emit(ev.KIND_TOOL_RESULT, ev.tool_result_payload(
211
+ label=opened.get("label", ""), name=opened.get("name", ""),
212
+ ok=True, ms=ms, output=output, run_id=str(run_id),
213
+ record_full=self.record_full))
214
+
215
+ def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
216
+ ms = self._ms_since(run_id)
217
+ opened = self._tool_open.pop(str(run_id), {})
218
+ self._emit(ev.KIND_TOOL_RESULT, ev.tool_result_payload(
219
+ label=opened.get("label", ""), name=opened.get("name", ""),
220
+ ok=False, ms=ms, error=f"{type(error).__name__}: {error}",
221
+ run_id=str(run_id), record_full=self.record_full))
@@ -0,0 +1,213 @@
1
+ # -*- coding: utf-8 -*-
2
+ """A drop-in wrapper that adds dsh-grade traceability to ANY LangGraph checkpointer.
3
+
4
+ Wraps an existing ``BaseCheckpointSaver`` (in-memory, SQLite, Postgres, Redis,
5
+ …) and, while passing every call through unchanged:
6
+
7
+ - hashes every checkpoint on write (content-addressed ``cp_*`` label + full
8
+ sha256) and records its parent — this is the checkpoint DAG edge;
9
+ - records pending writes per task with digested values;
10
+ - emits all of it into the hash-chained append-only event log.
11
+
12
+ Behavioral contract of the wrapped saver is preserved exactly: return values,
13
+ exceptions and ordering all pass through. Trace failures are fail-soft and
14
+ never reach the caller.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Any, AsyncIterator, Iterator, Sequence
20
+
21
+ from langgraph.checkpoint.base import (
22
+ BaseCheckpointSaver,
23
+ ChannelProtocol,
24
+ Checkpoint,
25
+ CheckpointMetadata,
26
+ CheckpointTuple,
27
+ RunnableConfig,
28
+ SerializerProtocol,
29
+ )
30
+
31
+ from . import events as ev
32
+ from .hashing import checkpoint_label, sha256_hex
33
+ from .recorder import TraceRecorder, current_recorder, recorder_for
34
+
35
+ __all__ = ["TracingCheckpointSaver", "normalized_checkpoint_digest"]
36
+
37
+
38
+ def normalized_checkpoint_digest(serde: SerializerProtocol,
39
+ checkpoint: Checkpoint) -> tuple[str, str]:
40
+ """(full sha256 hex, cp_* label) over the *roundtrip-normalized* checkpoint.
41
+
42
+ The object handed to ``put`` may contain fields that do not survive
43
+ serialization. Hashing ``dumps(loads(dumps(cp)))`` makes the write-time
44
+ digest equal to the read-time digest — the property ``verify_thread``
45
+ relies on to prove no post-hoc drift.
46
+ """
47
+ try:
48
+ tag1, blob1 = serde.dumps_typed(checkpoint)
49
+ restored = serde.loads_typed((tag1, blob1))
50
+ tag2, blob2 = serde.dumps_typed(restored)
51
+ if isinstance(blob2, str):
52
+ blob2 = blob2.encode("utf-8")
53
+ return sha256_hex(tag2.encode() + b"\x00" + blob2), checkpoint_label(blob2)
54
+ except Exception: # noqa: BLE001 — digest must never break a write
55
+ return "", ""
56
+
57
+
58
+ def _cfg_ids(config: RunnableConfig) -> tuple[str, str, str | None]:
59
+ conf = (config or {}).get("configurable") or {}
60
+ return (str(conf.get("thread_id") or "anonymous"),
61
+ str(conf.get("checkpoint_ns") or ""),
62
+ conf.get("checkpoint_id"))
63
+
64
+
65
+ class TracingCheckpointSaver(BaseCheckpointSaver):
66
+ """Wrap any BaseCheckpointSaver with hash-labeled, hash-chained tracing."""
67
+
68
+ def __init__(self, inner: BaseCheckpointSaver, *,
69
+ trace_root: str | Path,
70
+ recorder: TraceRecorder | None = None,
71
+ enabled: bool = True) -> None:
72
+ super().__init__(serde=inner.serde)
73
+ self.inner = inner
74
+ self.trace_root = Path(trace_root)
75
+ self._default_recorder = recorder
76
+ self.enabled = bool(enabled)
77
+
78
+ # -- recorder resolution ------------------------------------------------------
79
+
80
+ def recorder_for(self, thread_id: str) -> TraceRecorder:
81
+ return recorder_for(self.trace_root, thread_id, enabled=self.enabled)
82
+
83
+ def _rec_for(self, thread_id: str) -> TraceRecorder | None:
84
+ bound = self._default_recorder or current_recorder()
85
+ if bound is not None:
86
+ return bound if bound.enabled else None
87
+ if not self.enabled:
88
+ return None
89
+ return self.recorder_for(thread_id)
90
+
91
+ # -- checkpoint hashing ---------------------------------------------------------
92
+
93
+ def _checkpoint_digest(self, checkpoint: Checkpoint) -> tuple[str, str]:
94
+ """(full sha256 hex, short cp_* label) — roundtrip-normalized."""
95
+ return normalized_checkpoint_digest(self.serde, checkpoint)
96
+
97
+ def _emit_snapshot(self, out_config: RunnableConfig,
98
+ metadata: CheckpointMetadata) -> None:
99
+ """Hash the *stored* form: re-read the just-written checkpoint and digest
100
+ its reconstruction — the same object verify_thread will see later."""
101
+ thread_id, _ns, _parent = _cfg_ids(out_config)
102
+ rec = self._rec_for(thread_id)
103
+ if rec is None:
104
+ return
105
+ tup = self.inner.get_tuple(out_config)
106
+ if tup is None:
107
+ return
108
+ full, label = self._checkpoint_digest(tup.checkpoint)
109
+ md = dict(metadata or {})
110
+ parent_id = (tup.parent_config or {}).get("configurable", {}).get("checkpoint_id")
111
+ rec.emit(ev.KIND_STATE_SNAPSHOT, ev.state_snapshot_payload(
112
+ checkpoint_id=str(tup.checkpoint.get("id") or ""),
113
+ checkpoint_sha256=full, label=label,
114
+ parent_checkpoint_id=str(parent_id) if parent_id else None,
115
+ step=md.get("step"), source=str(md.get("source") or "")))
116
+
117
+ # -- pass-through with tracing ---------------------------------------------------
118
+
119
+ def put(self, config: RunnableConfig, checkpoint: Checkpoint,
120
+ metadata: CheckpointMetadata, new_versions: Any) -> RunnableConfig:
121
+ out = self.inner.put(config, checkpoint, metadata, new_versions)
122
+ try:
123
+ self._emit_snapshot(out, metadata)
124
+ except Exception: # noqa: BLE001 — observation never breaks the run
125
+ pass
126
+ return out
127
+
128
+ def _trace_writes(self, config: RunnableConfig, writes: Sequence[tuple[str, Any]],
129
+ task_id: str) -> None:
130
+ try:
131
+ thread_id, _ns, checkpoint_id = _cfg_ids(config)
132
+ rec = self._rec_for(thread_id)
133
+ if rec is not None:
134
+ digested = []
135
+ for channel, value in writes:
136
+ try:
137
+ _t, blob = self.serde.dumps_typed(value)
138
+ digest = sha256_hex(blob if isinstance(blob, bytes)
139
+ else str(blob).encode("utf-8"))
140
+ size = len(blob)
141
+ except Exception: # noqa: BLE001
142
+ digest, size = "", 0
143
+ digested.append({"channel": str(channel), "sha256": digest,
144
+ "size": int(size)})
145
+ rec.emit(ev.KIND_CHECKPOINT_WRITES, ev.checkpoint_writes_payload(
146
+ checkpoint_id=str(checkpoint_id) if checkpoint_id else None,
147
+ task_id=task_id, writes=digested))
148
+ except Exception: # noqa: BLE001
149
+ pass
150
+
151
+ def put_writes(self, config: RunnableConfig, writes: Sequence[tuple[str, Any]],
152
+ task_id: str, task_path: str = "") -> None:
153
+ self.inner.put_writes(config, writes, task_id, task_path)
154
+ self._trace_writes(config, writes, task_id)
155
+
156
+ # -- pure delegation ---------------------------------------------------------------
157
+
158
+ def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
159
+ return self.inner.get_tuple(config)
160
+
161
+ def list(self, config: RunnableConfig | None, *,
162
+ filter: dict | None = None, before: RunnableConfig | None = None,
163
+ limit: int | None = None) -> Iterator[CheckpointTuple]:
164
+ yield from self.inner.list(config, filter=filter, before=before, limit=limit)
165
+
166
+ def delete_thread(self, thread_id: str) -> None:
167
+ self.inner.delete_thread(thread_id)
168
+
169
+ # -- async: delegate to the inner saver's async path --------------------------------
170
+
171
+ async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
172
+ return await self.inner.aget_tuple(config)
173
+
174
+ async def alist(self, config: RunnableConfig | None, *,
175
+ filter: dict | None = None, before: RunnableConfig | None = None,
176
+ limit: int | None = None) -> AsyncIterator[CheckpointTuple]:
177
+ async for tup in self.inner.alist(config, filter=filter, before=before, limit=limit):
178
+ yield tup
179
+
180
+ async def aput(self, config: RunnableConfig, checkpoint: Checkpoint,
181
+ metadata: CheckpointMetadata, new_versions: Any) -> RunnableConfig:
182
+ out = await self.inner.aput(config, checkpoint, metadata, new_versions)
183
+ try:
184
+ tup = await self.inner.aget_tuple(out)
185
+ if tup is not None:
186
+ thread_id, _ns, _p = _cfg_ids(out)
187
+ rec = self._rec_for(thread_id)
188
+ if rec is not None:
189
+ full, label = self._checkpoint_digest(tup.checkpoint)
190
+ md = dict(metadata or {})
191
+ parent_id = (tup.parent_config or {}).get(
192
+ "configurable", {}).get("checkpoint_id")
193
+ rec.emit(ev.KIND_STATE_SNAPSHOT, ev.state_snapshot_payload(
194
+ checkpoint_id=str(tup.checkpoint.get("id") or ""),
195
+ checkpoint_sha256=full, label=label,
196
+ parent_checkpoint_id=str(parent_id) if parent_id else None,
197
+ step=md.get("step"), source=str(md.get("source") or "")))
198
+ except Exception: # noqa: BLE001
199
+ pass
200
+ return out
201
+
202
+ async def aput_writes(self, config: RunnableConfig, writes: Sequence[tuple[str, Any]],
203
+ task_id: str, task_path: str = "") -> None:
204
+ await self.inner.aput_writes(config, writes, task_id, task_path)
205
+ self._trace_writes(config, writes, task_id)
206
+
207
+ async def adelete_thread(self, thread_id: str) -> None:
208
+ await self.inner.adelete_thread(thread_id)
209
+
210
+ # -- convenience -----------------------------------------------------------------------
211
+
212
+ def register_channel(self, channel: ChannelProtocol, key: str) -> None:
213
+ self.inner.register_channel(channel, key)
@@ -0,0 +1,71 @@
1
+ # -*- coding: utf-8 -*-
2
+ """CLI: verify / analyze / dag / repair / replay a recorded trace log.
3
+
4
+ python -m langgraph_ledger verify <thread.jsonl>
5
+ python -m langgraph_ledger analyze <thread.jsonl>
6
+ python -m langgraph_ledger dag <thread.jsonl> [--mermaid]
7
+ python -m langgraph_ledger repair <thread.jsonl | trace-root/>
8
+ python -m langgraph_ledger replay <thread.jsonl>
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from .analysis import analyze_log
18
+ from .dag import build_dag_from_file
19
+ from .repair import close_orphaned_run, repair_all
20
+ from .replay import replay_messages
21
+ from .verify import verify_log
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ parser = argparse.ArgumentParser(prog="langgraph_ledger",
26
+ description=__doc__)
27
+ sub = parser.add_subparsers(dest="command", required=True)
28
+ for name in ("verify", "analyze", "dag", "replay"):
29
+ p = sub.add_parser(name)
30
+ p.add_argument("log", help="path to a thread .jsonl trace log")
31
+ if name == "dag":
32
+ p.add_argument("--mermaid", action="store_true",
33
+ help="print a Mermaid flowchart instead of JSON")
34
+ p = sub.add_parser("repair", help="close crash-orphaned runs "
35
+ "(a single log or a whole trace-root directory)")
36
+ p.add_argument("target", help="a thread .jsonl log or a trace-root directory")
37
+ args = parser.parse_args(argv)
38
+
39
+ if args.command == "repair":
40
+ target = Path(args.target)
41
+ if target.is_dir():
42
+ summary = repair_all(target)
43
+ elif target.is_file():
44
+ summary = close_orphaned_run(target)
45
+ else:
46
+ print(f"target not found: {target}", file=sys.stderr)
47
+ return 2
48
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
49
+ return 0
50
+
51
+ if not Path(args.log).is_file():
52
+ print(f"log not found: {args.log}", file=sys.stderr)
53
+ return 2
54
+
55
+ if args.command == "verify":
56
+ report = verify_log(args.log)
57
+ print(json.dumps(report, ensure_ascii=False, indent=2))
58
+ return 0 if report else 1
59
+ if args.command == "analyze":
60
+ print(json.dumps(analyze_log(args.log), ensure_ascii=False, indent=2))
61
+ return 0
62
+ if args.command == "replay":
63
+ print(json.dumps(replay_messages(args.log), ensure_ascii=False, indent=2))
64
+ return 0
65
+ dag = build_dag_from_file(args.log)
66
+ print(dag.to_mermaid() if args.mermaid else json.dumps(dag.to_dict(), ensure_ascii=False, indent=2))
67
+ return 0
68
+
69
+
70
+ if __name__ == "__main__":
71
+ raise SystemExit(main())