riffdb-langgraph 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.
- riffdb_langgraph/__init__.py +3 -0
- riffdb_langgraph/__main__.py +234 -0
- riffdb_langgraph/config.py +38 -0
- riffdb_langgraph/generated/client.py +2341 -0
- riffdb_langgraph/py.typed +0 -0
- riffdb_langgraph/saver.py +1832 -0
- riffdb_langgraph-0.2.0.dist-info/METADATA +72 -0
- riffdb_langgraph-0.2.0.dist-info/RECORD +9 -0
- riffdb_langgraph-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Disposable real-driver conformance and StateGraph smoke runner used by ``riffdb dev --exec``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import operator
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import AsyncGenerator
|
|
11
|
+
from typing import Annotated, Any, TypedDict
|
|
12
|
+
|
|
13
|
+
from langgraph.checkpoint.base import empty_checkpoint
|
|
14
|
+
from langgraph.checkpoint.conformance import checkpointer_test, validate
|
|
15
|
+
from langgraph.checkpoint.serde.types import TASKS
|
|
16
|
+
from langgraph.graph import END, START, StateGraph
|
|
17
|
+
from langgraph.types import Send
|
|
18
|
+
|
|
19
|
+
from riffdb_langgraph.saver import RiffDBCheckpointSaver
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _AgentState(TypedDict):
|
|
23
|
+
messages: Annotated[list[str], operator.add]
|
|
24
|
+
items: list[str]
|
|
25
|
+
processed: Annotated[list[str], operator.add]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _plan_node(state: _AgentState) -> dict[str, Any]:
|
|
29
|
+
return {
|
|
30
|
+
"messages": [f"planned:{len(state.get('items', []))}"],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _fanout_edges(state: _AgentState) -> list[Send]:
|
|
35
|
+
return [Send("worker", {"item": item}) for item in state.get("items", [])]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _worker_node(state: Any) -> dict[str, Any]:
|
|
39
|
+
item = str(state["item"])
|
|
40
|
+
return {
|
|
41
|
+
"processed": [item.upper()],
|
|
42
|
+
"messages": [f"worker:{item}"],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _finalize_node(state: _AgentState) -> dict[str, Any]:
|
|
47
|
+
joined = ",".join(sorted(state.get("processed", [])))
|
|
48
|
+
return {
|
|
49
|
+
"messages": [f"done:{joined}"],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_graph(saver: RiffDBCheckpointSaver) -> Any:
|
|
54
|
+
builder: StateGraph[Any, Any, Any, Any] = StateGraph(_AgentState)
|
|
55
|
+
builder.add_node("plan", _plan_node)
|
|
56
|
+
builder.add_node("worker", _worker_node)
|
|
57
|
+
builder.add_node("finalize", _finalize_node)
|
|
58
|
+
builder.add_edge(START, "plan")
|
|
59
|
+
builder.add_conditional_edges("plan", _fanout_edges, ["worker"])
|
|
60
|
+
builder.add_edge("worker", "finalize")
|
|
61
|
+
builder.add_edge("finalize", END)
|
|
62
|
+
return builder.compile(checkpointer=saver)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run_sync_smoke(uri: str) -> dict[str, Any]:
|
|
66
|
+
with RiffDBCheckpointSaver(uri, scope="sync-smoke") as saver:
|
|
67
|
+
graph = _build_graph(saver)
|
|
68
|
+
thread_id = "sync-thread-1"
|
|
69
|
+
config: Any = {"configurable": {"thread_id": thread_id}}
|
|
70
|
+
out = graph.invoke(
|
|
71
|
+
{"messages": ["init"], "items": ["alpha", "beta"], "processed": []},
|
|
72
|
+
config=config,
|
|
73
|
+
)
|
|
74
|
+
assert out["processed"] == ["ALPHA", "BETA"]
|
|
75
|
+
assert out["messages"][0] == "init"
|
|
76
|
+
assert out["messages"][-1] == "done:ALPHA,BETA"
|
|
77
|
+
|
|
78
|
+
snapshot = graph.get_state(config)
|
|
79
|
+
assert snapshot.values["processed"] == ["ALPHA", "BETA"]
|
|
80
|
+
|
|
81
|
+
history = list(graph.get_state_history(config))
|
|
82
|
+
assert len(history) >= 3
|
|
83
|
+
|
|
84
|
+
# Fork/update state and verify time-travel
|
|
85
|
+
graph.update_state(config, {"messages": ["manual-note"]})
|
|
86
|
+
updated = graph.get_state(config)
|
|
87
|
+
assert updated.values["messages"][-1] == "manual-note"
|
|
88
|
+
|
|
89
|
+
# Copy thread and prune sync
|
|
90
|
+
saver.copy_thread(thread_id, "sync-thread-1-copy")
|
|
91
|
+
copied_tuple = saver.get_tuple({"configurable": {"thread_id": "sync-thread-1-copy"}})
|
|
92
|
+
assert copied_tuple is not None
|
|
93
|
+
assert copied_tuple.checkpoint["channel_values"]["messages"][-1] == "manual-note"
|
|
94
|
+
|
|
95
|
+
saver.prune(["sync-thread-1-copy"], strategy="keep_latest")
|
|
96
|
+
pruned_list = list(saver.list({"configurable": {"thread_id": "sync-thread-1-copy"}}))
|
|
97
|
+
assert len(pruned_list) == 1
|
|
98
|
+
|
|
99
|
+
saver.delete_thread("sync-thread-1-copy")
|
|
100
|
+
assert saver.get_tuple({"configurable": {"thread_id": "sync-thread-1-copy"}}) is None
|
|
101
|
+
|
|
102
|
+
# Verify v<4 pending_sends migration on get_tuple
|
|
103
|
+
legacy_tid = "sync-legacy-v3"
|
|
104
|
+
parent_cp = empty_checkpoint()
|
|
105
|
+
parent_cp["v"] = 3
|
|
106
|
+
parent_cfg = saver.put(
|
|
107
|
+
{"configurable": {"thread_id": legacy_tid, "checkpoint_ns": ""}},
|
|
108
|
+
parent_cp,
|
|
109
|
+
{"source": "input", "step": 0},
|
|
110
|
+
{},
|
|
111
|
+
)
|
|
112
|
+
saver.put_writes(parent_cfg, [(TASKS, "migrated-send-payload")], task_id="task-legacy")
|
|
113
|
+
child_cp = empty_checkpoint()
|
|
114
|
+
child_cp["v"] = 3
|
|
115
|
+
child_cfg = saver.put(
|
|
116
|
+
parent_cfg,
|
|
117
|
+
child_cp,
|
|
118
|
+
{"source": "loop", "step": 1},
|
|
119
|
+
{},
|
|
120
|
+
)
|
|
121
|
+
migrated_tuple = saver.get_tuple(child_cfg)
|
|
122
|
+
assert migrated_tuple is not None
|
|
123
|
+
assert migrated_tuple.checkpoint["channel_values"].get(TASKS) == ["migrated-send-payload"]
|
|
124
|
+
saver.delete_thread(legacy_tid)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"sync_messages": out["messages"],
|
|
128
|
+
"sync_processed": out["processed"],
|
|
129
|
+
"sync_history_depth": len(history),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def _run_async_smoke(uri: str) -> dict[str, Any]:
|
|
134
|
+
async with RiffDBCheckpointSaver(uri, scope="async-smoke") as saver:
|
|
135
|
+
graph = _build_graph(saver)
|
|
136
|
+
thread_id = "async-thread-1"
|
|
137
|
+
config: Any = {"configurable": {"thread_id": thread_id}}
|
|
138
|
+
out = await graph.ainvoke(
|
|
139
|
+
{"messages": ["start"], "items": ["gamma", "delta"], "processed": []},
|
|
140
|
+
config=config,
|
|
141
|
+
)
|
|
142
|
+
assert out["processed"] == ["GAMMA", "DELTA"]
|
|
143
|
+
assert out["messages"][-1] == "done:DELTA,GAMMA"
|
|
144
|
+
|
|
145
|
+
snapshot = await graph.aget_state(config)
|
|
146
|
+
assert snapshot.values["processed"] == ["GAMMA", "DELTA"]
|
|
147
|
+
|
|
148
|
+
history = [s async for s in graph.aget_state_history(config)]
|
|
149
|
+
assert len(history) >= 3
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"async_messages": out["messages"],
|
|
153
|
+
"async_processed": out["processed"],
|
|
154
|
+
"async_history_depth": len(history),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _run_conformance(uri: str) -> dict[str, Any]:
|
|
159
|
+
suite_counter = 0
|
|
160
|
+
|
|
161
|
+
@checkpointer_test(name="RiffDBCheckpointSaver")
|
|
162
|
+
async def _factory() -> AsyncGenerator[RiffDBCheckpointSaver]:
|
|
163
|
+
nonlocal suite_counter
|
|
164
|
+
suite_counter += 1
|
|
165
|
+
scope = f"conf-{suite_counter}"
|
|
166
|
+
async with RiffDBCheckpointSaver(uri, scope=scope) as saver:
|
|
167
|
+
yield saver
|
|
168
|
+
|
|
169
|
+
report = await validate(_factory)
|
|
170
|
+
if not report.passed_all():
|
|
171
|
+
failures: dict[str, Any] = {}
|
|
172
|
+
for cap, res in report.results.items():
|
|
173
|
+
if not res.passed:
|
|
174
|
+
failures[cap] = {
|
|
175
|
+
"detected": res.detected,
|
|
176
|
+
"passed": res.tests_passed,
|
|
177
|
+
"failed": res.tests_failed,
|
|
178
|
+
"failures": res.failures,
|
|
179
|
+
}
|
|
180
|
+
raise RuntimeError(f"LangGraph checkpointer conformance failed: {json.dumps(failures)}")
|
|
181
|
+
|
|
182
|
+
summary: dict[str, dict[str, Any]] = {}
|
|
183
|
+
total_passed = 0
|
|
184
|
+
for cap, res in sorted(report.results.items()):
|
|
185
|
+
assert res.detected, f"Expected capability {cap} to be detected"
|
|
186
|
+
assert res.passed is True, f"Expected capability {cap} to pass"
|
|
187
|
+
total_passed += res.tests_passed
|
|
188
|
+
summary[cap] = {
|
|
189
|
+
"detected": res.detected,
|
|
190
|
+
"passed": res.passed,
|
|
191
|
+
"tests_passed": res.tests_passed,
|
|
192
|
+
"tests_failed": res.tests_failed,
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
"all_passed": report.passed_all(),
|
|
196
|
+
"total_passed": total_passed,
|
|
197
|
+
"capabilities": summary,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: list[str] | None = None) -> int:
|
|
202
|
+
args = sys.argv[1:] if argv is None else argv
|
|
203
|
+
endpoint = args[0] if len(args) >= 1 else os.environ.get("RIFFDB_ENDPOINT", "")
|
|
204
|
+
cred_file = args[1] if len(args) >= 2 else os.environ.get("RIFFDB_CREDENTIAL_FILE", "")
|
|
205
|
+
database = (
|
|
206
|
+
args[2] if len(args) >= 3 else os.environ.get("RIFFDB_DATABASE", "langgraph-development")
|
|
207
|
+
)
|
|
208
|
+
if not endpoint or not cred_file:
|
|
209
|
+
raise SystemExit(
|
|
210
|
+
"usage: python -m riffdb_langgraph <endpoint> <credential_file> [database]"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
os.environ["RIFFDB_LANGGRAPH_ENDPOINT"] = endpoint
|
|
214
|
+
os.environ["RIFFDB_LANGGRAPH_CREDENTIAL_FILE"] = cred_file
|
|
215
|
+
if os.environ.get("RIFFDB_TENANT") == "":
|
|
216
|
+
os.environ.pop("RIFFDB_TENANT", None)
|
|
217
|
+
|
|
218
|
+
uri = f"riffdb://{database}"
|
|
219
|
+
sync_summary = _run_sync_smoke(uri)
|
|
220
|
+
async_summary = asyncio.run(_run_async_smoke(uri))
|
|
221
|
+
conformance_summary = asyncio.run(_run_conformance(uri))
|
|
222
|
+
|
|
223
|
+
output = {
|
|
224
|
+
"database": database,
|
|
225
|
+
"conformance": conformance_summary,
|
|
226
|
+
"sync_smoke": sync_summary,
|
|
227
|
+
"async_smoke": async_summary,
|
|
228
|
+
}
|
|
229
|
+
print(json.dumps(output, sort_keys=True))
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Strict, secret-free LangGraph checkpointer URI parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidCheckpointerURI(ValueError):
|
|
10
|
+
"""Raised before transport work when a LangGraph RiffDB URI is unsafe or malformed."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class CheckpointerConfig:
|
|
15
|
+
"""The non-secret configuration carried by a LangGraph checkpointer URI."""
|
|
16
|
+
|
|
17
|
+
database: str
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_uri(cls, uri: str) -> CheckpointerConfig:
|
|
21
|
+
parsed = urlsplit(uri)
|
|
22
|
+
if parsed.scheme != "riffdb":
|
|
23
|
+
raise InvalidCheckpointerURI("checkpointer URI must use the riffdb scheme")
|
|
24
|
+
if parsed.username is not None or parsed.password is not None:
|
|
25
|
+
raise InvalidCheckpointerURI("checkpointer URI must not contain credentials")
|
|
26
|
+
if parsed.port is not None:
|
|
27
|
+
raise InvalidCheckpointerURI("checkpointer URI must not contain a port")
|
|
28
|
+
if not parsed.hostname:
|
|
29
|
+
raise InvalidCheckpointerURI("checkpointer URI must name one database")
|
|
30
|
+
if parsed.path not in ("", "/"):
|
|
31
|
+
raise InvalidCheckpointerURI("checkpointer URI must not contain a path")
|
|
32
|
+
if parsed.query:
|
|
33
|
+
raise InvalidCheckpointerURI("checkpointer URI must not contain query parameters")
|
|
34
|
+
if parsed.fragment:
|
|
35
|
+
raise InvalidCheckpointerURI("checkpointer URI must not contain a fragment")
|
|
36
|
+
if parsed.hostname != parsed.netloc:
|
|
37
|
+
raise InvalidCheckpointerURI("checkpointer URI contains unsupported authority syntax")
|
|
38
|
+
return cls(database=parsed.hostname)
|