mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-17 21:25:46 +02:00
feat: add delta-channel-dump recovery script as examples to dump from Postgres for deltaChannel rollback (#8109)
Add a standalone recovery tool at `examples/delta-channel-dump/` for operators rolling back from langgraph >= 1.2 / deepagents 0.6.x to an older runtime that does not understand `EXT_DELTA_SNAPSHOT` msgpack blobs. Without this, channels like `messages` can appear empty after rollback because the old reducer does not decode delta snapshots. `dump.py` connects directly to Postgres, walks the checkpoint parent chain (mirroring `aget_delta_channel_history`), decodes msgpack blobs via `ormsgpack` with a hand-rolled EXT 0–7 hook (no langgraph imports), and emits JSON with per-channel `seed` + oldest-to-newest `writes`. The operator reduces and re-applies via `update_state` manually. ## Scope - Postgres checkpointer only - Deps: `psycopg[binary]`, `ormsgpack` (not added to repo `pyproject.toml` — operator installs at runtime) - No reducer application, no DB writes, no AES/custom encryption support (fails loudly on encrypted blob types) ## How verified - E2E against local Postgres: 15 runs of `delta_channel_messages_freq` (`snapshot_frequency=10`) - Asserted `delta_kind == "snapshot"`, `len(seed) == 10`, `len(writes) == 5`, message ids `ai-0`..`ai-14` - Confirmed portability: fresh venv with only `pip install "psycopg[binary]" ormsgpack` produced identical output
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
# delta-channel-dump
|
||||
|
||||
Recover messages (and other channels) from a Postgres-backed LangGraph thread
|
||||
written by **langgraph >= 1.2** (DeltaChannel format) — including **LangGraph
|
||||
Server / langgraph-api** deployments on the Postgres runtime, **deepagents
|
||||
0.6.x**, or any OSS app using `PostgresSaver` — before rolling back to an older
|
||||
runtime such as **deepagents 0.5.x / langgraph < 1.2**.
|
||||
|
||||
langgraph-api uses the same `checkpoints` / `checkpoint_blobs` / `checkpoint_writes`
|
||||
schema as OSS `checkpoint-postgres`; this script reads those tables directly.
|
||||
|
||||
On the older runtime, `add_messages` does not understand the `EXT_DELTA_SNAPSHOT`
|
||||
msgpack ext code and silently returns an empty list for affected channels. This
|
||||
tool reads the raw checkpoint blobs from Postgres and emits JSON you can inspect
|
||||
and re-apply via `update_state` (LangGraph Server SDK) or `graph.update_state`
|
||||
(OSS).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install "psycopg[binary]" ormsgpack
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
export DATABASE_URI=postgres://user:pass@host:5432/dbname
|
||||
python3 dump.py \
|
||||
--thread-id <uuid> \
|
||||
--channel messages \
|
||||
[--channel files ...] \
|
||||
[--checkpoint-id <uuid>] \
|
||||
[--checkpoint-ns ""] \
|
||||
--output recovery.json
|
||||
```
|
||||
|
||||
- `--thread-id` (required): thread UUID
|
||||
- `--channel` (required, repeatable): channel names to recover
|
||||
- `--checkpoint-id` (optional): target checkpoint; defaults to latest
|
||||
- `--checkpoint-ns` (optional): namespace; defaults to `""`
|
||||
- `--database-uri` (optional): Postgres URI; defaults to `DATABASE_URI` env var
|
||||
- `--output` (optional): output file; defaults to stdout
|
||||
|
||||
## Output
|
||||
|
||||
```json
|
||||
{
|
||||
"thread_id": "...",
|
||||
"checkpoint_ns": "",
|
||||
"target_checkpoint_id": "...",
|
||||
"parent_checkpoint_id": "...",
|
||||
"channels": {
|
||||
"messages": {
|
||||
"delta_kind": "snapshot",
|
||||
"seed_checkpoint_id": "...",
|
||||
"seed_version": "...",
|
||||
"seed": [{ "type": "ai", "content": "...", "id": "ai-0" }],
|
||||
"writes": [
|
||||
{
|
||||
"checkpoint_id": "...",
|
||||
"task_id": "...",
|
||||
"idx": 0,
|
||||
"value": [{ "type": "ai", "content": "...", "id": "ai-10" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`delta_kind` is one of:
|
||||
|
||||
- `snapshot` — DeltaChannel snapshot blob (`channel_values[ch] == true`)
|
||||
- `legacy_plain` — pre-DeltaChannel inline or blob value
|
||||
- `no_seed` — walked to root without finding a populated ancestor
|
||||
|
||||
`writes` are ordered oldest-to-newest (the order a reducer would replay them).
|
||||
|
||||
## Reducing back to a single list
|
||||
|
||||
For deepagents-style messages, combine seed and writes, then deduplicate:
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
data = json.load(open("recovery.json"))
|
||||
ch = data["channels"]["messages"]
|
||||
messages = list(ch["seed"] or [])
|
||||
for w in ch["writes"]:
|
||||
messages.extend(w["value"] or [])
|
||||
|
||||
# Dedup by id, keep last; drop RemoveMessage tombstones
|
||||
by_id = {}
|
||||
for m in messages:
|
||||
if isinstance(m, dict) and m.get("type") == "remove":
|
||||
by_id.pop(m.get("id"), None)
|
||||
elif isinstance(m, dict) and m.get("id"):
|
||||
by_id[m["id"]] = m
|
||||
else:
|
||||
by_id[id(m)] = m
|
||||
reduced = list(by_id.values())
|
||||
```
|
||||
|
||||
This approximates `_messages_delta_reducer` semantics; adjust for your graph.
|
||||
|
||||
## Re-applying
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
client = get_client(url="http://localhost:8123")
|
||||
await client.threads.update_state(
|
||||
thread_id,
|
||||
values={"messages": reduced},
|
||||
)
|
||||
```
|
||||
|
||||
Review the recovered JSON before calling `update_state`. This tool is
|
||||
read-only and intentionally does not mutate the database.
|
||||
|
||||
## Scope / non-goals (v1)
|
||||
|
||||
- **Postgres only** — OSS `PostgresSaver` or langgraph-api Postgres runtime; not
|
||||
inmem, gRPC core, Mongo, or Redis checkpointer backends
|
||||
- **No AES decryption** (`LANGGRAPH_AES_KEY`) or custom encryption
|
||||
- **No reducer** — raw seed + writes only
|
||||
- **No automatic `update_state`** — operator applies manually
|
||||
|
||||
## Copying
|
||||
|
||||
`dump.py` is self-contained. Copy it anywhere; only `psycopg[binary]` and
|
||||
`ormsgpack` are required at runtime. No langgraph imports.
|
||||
Executable
+469
@@ -0,0 +1,469 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recover delta-channel state from a Postgres-backed LangGraph thread.
|
||||
|
||||
Works with OSS ``PostgresSaver`` and LangGraph Server / langgraph-api on the
|
||||
Postgres runtime (same checkpoint schema). Use after rolling back from
|
||||
langgraph >= 1.2 / deepagents 0.6.x to an older runtime that does not
|
||||
understand ``EXT_DELTA_SNAPSHOT`` msgpack blobs. The script walks the checkpoint
|
||||
parent chain, decodes msgpack blobs, and emits a JSON dump of per-channel
|
||||
``seed`` plus oldest-to-newest ``writes``. Apply the recovered values manually
|
||||
via ``client.threads.update_state(...)`` (Server) or ``graph.update_state``
|
||||
(OSS).
|
||||
|
||||
Install::
|
||||
|
||||
pip install "psycopg[binary]" ormsgpack
|
||||
|
||||
Run::
|
||||
|
||||
export DATABASE_URI=postgres://...
|
||||
python3 dump.py --thread-id <uuid> --channel messages --output recovery.json
|
||||
|
||||
Scope (v1): Postgres only; no AES/custom encryption; no reducer application.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import ormsgpack
|
||||
import psycopg
|
||||
|
||||
# LangGraph msgpack EXT type codes (langgraph/checkpoint/serde/jsonplus.py).
|
||||
EXT_CONSTRUCTOR_SINGLE_ARG = 0
|
||||
EXT_CONSTRUCTOR_POS_ARGS = 1
|
||||
EXT_CONSTRUCTOR_KW_ARGS = 2
|
||||
EXT_METHOD_SINGLE_ARG = 3
|
||||
EXT_PYDANTIC_V1 = 4
|
||||
EXT_PYDANTIC_V2 = 5
|
||||
EXT_NUMPY_ARRAY = 6
|
||||
EXT_DELTA_SNAPSHOT = 7
|
||||
|
||||
_MSGPACK_OPTION = ormsgpack.OPT_NON_STR_KEYS
|
||||
|
||||
|
||||
def ext_hook(code: int, data: bytes) -> Any:
|
||||
"""Decode LangGraph msgpack EXT payloads to JSON-friendly Python values."""
|
||||
if code == EXT_DELTA_SNAPSHOT:
|
||||
inner = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION)
|
||||
return {"__delta_snapshot__": inner}
|
||||
if code == EXT_CONSTRUCTOR_SINGLE_ARG:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION)
|
||||
if tup[0] == "uuid" and tup[1] == "UUID":
|
||||
hex_ = tup[2]
|
||||
return (
|
||||
f"{hex_[:8]}-{hex_[8:12]}-{hex_[12:16]}-"
|
||||
f"{hex_[16:20]}-{hex_[20:]}"
|
||||
)
|
||||
return tup[2]
|
||||
except Exception:
|
||||
return None
|
||||
if code == EXT_CONSTRUCTOR_POS_ARGS:
|
||||
try:
|
||||
tup = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION)
|
||||
if tup[0] == "langgraph.types" and tup[1] == "Send":
|
||||
args = tup[2]
|
||||
if len(args) == 2:
|
||||
return {"__send__": {"node": args[0], "arg": args[1]}}
|
||||
return {
|
||||
"__send__": {
|
||||
"node": args[0],
|
||||
"arg": args[1],
|
||||
"timeout": args[2],
|
||||
}
|
||||
}
|
||||
return tup[2]
|
||||
except Exception:
|
||||
return None
|
||||
if code in (EXT_CONSTRUCTOR_KW_ARGS, EXT_METHOD_SINGLE_ARG):
|
||||
try:
|
||||
tup = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION)
|
||||
return tup[2]
|
||||
except Exception:
|
||||
return None
|
||||
if code in (EXT_PYDANTIC_V1, EXT_PYDANTIC_V2):
|
||||
try:
|
||||
tup = ormsgpack.unpackb(data, ext_hook=ext_hook, option=_MSGPACK_OPTION)
|
||||
return tup[2]
|
||||
except Exception:
|
||||
return None
|
||||
if code == EXT_NUMPY_ARRAY:
|
||||
try:
|
||||
dtype_str, shape, order, buf = ormsgpack.unpackb(
|
||||
data, ext_hook=ext_hook, option=_MSGPACK_OPTION
|
||||
)
|
||||
return {
|
||||
"__numpy_array__": {
|
||||
"dtype": dtype_str,
|
||||
"shape": shape,
|
||||
"order": order,
|
||||
"data_b64": base64.b64encode(buf).decode("ascii"),
|
||||
}
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def decode_blob(blob_type: str, blob_bytes: bytes | None) -> Any:
|
||||
if blob_type in ("empty", "null") or blob_bytes is None:
|
||||
return None
|
||||
if blob_type == "msgpack":
|
||||
return ormsgpack.unpackb(
|
||||
blob_bytes, ext_hook=ext_hook, option=_MSGPACK_OPTION
|
||||
)
|
||||
if blob_type in ("bytes", "bytearray"):
|
||||
return base64.b64encode(blob_bytes).decode("ascii")
|
||||
raise RuntimeError(
|
||||
f"Unknown blob type {blob_type!r}. "
|
||||
"AES/custom-encrypted deployments are out of scope for v1."
|
||||
)
|
||||
|
||||
|
||||
def delta_unwrap(value: Any) -> Any:
|
||||
if isinstance(value, dict) and "__delta_snapshot__" in value:
|
||||
return value["__delta_snapshot__"]
|
||||
return value
|
||||
|
||||
|
||||
def json_default(obj: Any) -> Any:
|
||||
if isinstance(obj, (bytes, bytearray)):
|
||||
return base64.b64encode(bytes(obj)).decode("ascii")
|
||||
if isinstance(obj, uuid.UUID):
|
||||
return str(obj)
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
def resolve_target_checkpoint_id(
|
||||
conn: psycopg.Connection[Any],
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
checkpoint_id: str | None,
|
||||
) -> str:
|
||||
if checkpoint_id is not None:
|
||||
return checkpoint_id
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT checkpoint_id::text
|
||||
FROM checkpoints
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s
|
||||
ORDER BY checkpoint_id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(thread_id, checkpoint_ns),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise SystemExit(
|
||||
f"No checkpoints found for thread_id={thread_id!r} "
|
||||
f"checkpoint_ns={checkpoint_ns!r}"
|
||||
)
|
||||
return row[0]
|
||||
|
||||
|
||||
def _load_checkpoint(
|
||||
conn: psycopg.Connection[Any],
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
checkpoint_id: str,
|
||||
) -> tuple[dict[str, Any], str | None] | None:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT checkpoint, parent_checkpoint_id::text
|
||||
FROM checkpoints
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s
|
||||
""",
|
||||
(thread_id, checkpoint_ns, checkpoint_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return row[0], row[1]
|
||||
|
||||
|
||||
def _load_seed(
|
||||
conn: psycopg.Connection[Any],
|
||||
*,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
channel: str,
|
||||
checkpoint_id: str,
|
||||
channel_values: dict[str, Any],
|
||||
channel_versions: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
cv = channel_values[channel]
|
||||
version = channel_versions.get(channel)
|
||||
if cv is True:
|
||||
blob_row = conn.execute(
|
||||
"""
|
||||
SELECT type, blob
|
||||
FROM checkpoint_blobs
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s
|
||||
AND channel = %s AND version = %s
|
||||
""",
|
||||
(thread_id, checkpoint_ns, channel, version),
|
||||
).fetchone()
|
||||
seed_value = None
|
||||
if blob_row is not None and blob_row[0] != "empty":
|
||||
seed_value = delta_unwrap(decode_blob(blob_row[0], blob_row[1]))
|
||||
return {
|
||||
"delta_kind": "snapshot",
|
||||
"seed_checkpoint_id": checkpoint_id,
|
||||
"seed_version": version,
|
||||
"seed": seed_value,
|
||||
}
|
||||
if isinstance(cv, (int, float, str, bool)) or cv is None:
|
||||
return {
|
||||
"delta_kind": "legacy_plain",
|
||||
"seed_checkpoint_id": checkpoint_id,
|
||||
"seed_version": version,
|
||||
"seed": cv,
|
||||
}
|
||||
blob_row = conn.execute(
|
||||
"""
|
||||
SELECT type, blob
|
||||
FROM checkpoint_blobs
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s
|
||||
AND channel = %s AND version = %s
|
||||
""",
|
||||
(thread_id, checkpoint_ns, channel, version),
|
||||
).fetchone()
|
||||
seed_value = cv if blob_row is None else decode_blob(blob_row[0], blob_row[1])
|
||||
return {
|
||||
"delta_kind": "legacy_plain",
|
||||
"seed_checkpoint_id": checkpoint_id,
|
||||
"seed_version": version,
|
||||
"seed": seed_value,
|
||||
}
|
||||
|
||||
|
||||
def _load_writes_for_checkpoint(
|
||||
conn: psycopg.Connection[Any],
|
||||
*,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
checkpoint_id: str,
|
||||
channel: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Load writes for one checkpoint, newest-first by ``(task_id, idx)``.
|
||||
|
||||
``walk_channel`` reverses the accumulated flat list before returning;
|
||||
DESC here yields oldest-first within each checkpoint in the final output,
|
||||
matching ``PostgresSaver._build_delta_channels_writes_history``.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT task_id::text, idx, type, blob
|
||||
FROM checkpoint_writes
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s
|
||||
AND checkpoint_id = %s AND channel = %s
|
||||
ORDER BY task_id DESC, idx DESC
|
||||
""",
|
||||
(thread_id, checkpoint_ns, checkpoint_id, channel),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"task_id": task_id,
|
||||
"idx": idx,
|
||||
"value": decode_blob(blob_type, blob_bytes),
|
||||
}
|
||||
for task_id, idx, blob_type, blob_bytes in rows
|
||||
]
|
||||
|
||||
|
||||
def walk_channel(
|
||||
conn: psycopg.Connection[Any],
|
||||
*,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
start_checkpoint_id: str | None,
|
||||
channel: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Walk one channel's parent chain from target.parent backward to seed."""
|
||||
chain_writes_newest_first: list[dict[str, Any]] = []
|
||||
cur = start_checkpoint_id
|
||||
while cur is not None:
|
||||
loaded = _load_checkpoint(conn, thread_id, checkpoint_ns, cur)
|
||||
if loaded is None:
|
||||
break
|
||||
checkpoint_json, parent_id = loaded
|
||||
channel_values = checkpoint_json.get("channel_values") or {}
|
||||
channel_versions = checkpoint_json.get("channel_versions") or {}
|
||||
|
||||
chain_writes_newest_first.extend(
|
||||
_load_writes_for_checkpoint(
|
||||
conn,
|
||||
thread_id=thread_id,
|
||||
checkpoint_ns=checkpoint_ns,
|
||||
checkpoint_id=cur,
|
||||
channel=channel,
|
||||
)
|
||||
)
|
||||
|
||||
if channel in channel_values:
|
||||
seed = _load_seed(
|
||||
conn,
|
||||
thread_id=thread_id,
|
||||
checkpoint_ns=checkpoint_ns,
|
||||
channel=channel,
|
||||
checkpoint_id=cur,
|
||||
channel_values=channel_values,
|
||||
channel_versions=channel_versions,
|
||||
)
|
||||
seed["writes"] = list(reversed(chain_writes_newest_first))
|
||||
return seed
|
||||
|
||||
cur = parent_id
|
||||
|
||||
return {
|
||||
"delta_kind": "no_seed",
|
||||
"seed_checkpoint_id": None,
|
||||
"seed_version": None,
|
||||
"seed": None,
|
||||
"writes": list(reversed(chain_writes_newest_first)),
|
||||
}
|
||||
|
||||
|
||||
def walk_parent_chain(
|
||||
conn: psycopg.Connection[Any],
|
||||
*,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
target_checkpoint_id: str,
|
||||
channels: list[str],
|
||||
) -> tuple[str | None, dict[str, dict[str, Any]]]:
|
||||
"""Walk parent chain and return per-channel seed + writes (oldest-first)."""
|
||||
parent_row = conn.execute(
|
||||
"""
|
||||
SELECT parent_checkpoint_id::text
|
||||
FROM checkpoints
|
||||
WHERE thread_id = %s AND checkpoint_ns = %s AND checkpoint_id = %s
|
||||
""",
|
||||
(thread_id, checkpoint_ns, target_checkpoint_id),
|
||||
).fetchone()
|
||||
if parent_row is None:
|
||||
raise SystemExit(
|
||||
f"Checkpoint {target_checkpoint_id!r} not found for thread {thread_id!r}"
|
||||
)
|
||||
parent_checkpoint_id = parent_row[0]
|
||||
|
||||
result = {
|
||||
ch: walk_channel(
|
||||
conn,
|
||||
thread_id=thread_id,
|
||||
checkpoint_ns=checkpoint_ns,
|
||||
start_checkpoint_id=parent_checkpoint_id,
|
||||
channel=ch,
|
||||
)
|
||||
for ch in channels
|
||||
}
|
||||
return parent_checkpoint_id, result
|
||||
|
||||
|
||||
def build_output(
|
||||
*,
|
||||
thread_id: str,
|
||||
checkpoint_ns: str,
|
||||
target_checkpoint_id: str,
|
||||
parent_checkpoint_id: str | None,
|
||||
channels: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"thread_id": thread_id,
|
||||
"checkpoint_ns": checkpoint_ns,
|
||||
"target_checkpoint_id": target_checkpoint_id,
|
||||
"parent_checkpoint_id": parent_checkpoint_id,
|
||||
"channels": channels,
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Recover delta-channel seed + writes from Postgres checkpoint data."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--thread-id", required=True, help="Thread UUID")
|
||||
parser.add_argument(
|
||||
"--channel",
|
||||
action="append",
|
||||
required=True,
|
||||
dest="channels",
|
||||
help="Channel name (repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-id",
|
||||
default=None,
|
||||
help="Target checkpoint UUID (default: latest for thread)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-ns",
|
||||
default="",
|
||||
help='Checkpoint namespace (default: "")',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--database-uri",
|
||||
default=os.environ.get("DATABASE_URI"),
|
||||
help="Postgres URI (default: DATABASE_URI env var)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="-",
|
||||
help="Output JSON file path (default: stdout)",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
if not args.database_uri:
|
||||
print(
|
||||
"error: --database-uri or DATABASE_URI is required",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
thread_id = str(uuid.UUID(args.thread_id))
|
||||
channels = list(dict.fromkeys(args.channels))
|
||||
|
||||
with psycopg.connect(args.database_uri) as conn:
|
||||
target_checkpoint_id = resolve_target_checkpoint_id(
|
||||
conn,
|
||||
thread_id,
|
||||
args.checkpoint_ns,
|
||||
args.checkpoint_id,
|
||||
)
|
||||
parent_checkpoint_id, channel_data = walk_parent_chain(
|
||||
conn,
|
||||
thread_id=thread_id,
|
||||
checkpoint_ns=args.checkpoint_ns,
|
||||
target_checkpoint_id=target_checkpoint_id,
|
||||
channels=channels,
|
||||
)
|
||||
|
||||
output = build_output(
|
||||
thread_id=thread_id,
|
||||
checkpoint_ns=args.checkpoint_ns,
|
||||
target_checkpoint_id=target_checkpoint_id,
|
||||
parent_checkpoint_id=parent_checkpoint_id,
|
||||
channels=channel_data,
|
||||
)
|
||||
payload = json.dumps(output, indent=2, default=json_default)
|
||||
|
||||
if args.output == "-":
|
||||
print(payload)
|
||||
else:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(payload)
|
||||
f.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user