langgraph-runtime-pg 0.11.1__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.
- langgraph_runtime_pg/__init__.py +38 -0
- langgraph_runtime_pg/checkpoint.py +83 -0
- langgraph_runtime_pg/database.py +480 -0
- langgraph_runtime_pg/lifespan.py +198 -0
- langgraph_runtime_pg/metrics.py +7 -0
- langgraph_runtime_pg/migrate.py +101 -0
- langgraph_runtime_pg/migrations/__init__.py +1 -0
- langgraph_runtime_pg/migrations/env.py +51 -0
- langgraph_runtime_pg/migrations/script.py.mako +27 -0
- langgraph_runtime_pg/migrations/versions/001_initial_schema.py +331 -0
- langgraph_runtime_pg/migrations/versions/__init__.py +1 -0
- langgraph_runtime_pg/models.py +196 -0
- langgraph_runtime_pg/ops.py +3538 -0
- langgraph_runtime_pg/queue.py +270 -0
- langgraph_runtime_pg/redis_stream.py +894 -0
- langgraph_runtime_pg/retry.py +39 -0
- langgraph_runtime_pg/routes.py +9 -0
- langgraph_runtime_pg/store.py +183 -0
- langgraph_runtime_pg-0.11.1.dist-info/METADATA +47 -0
- langgraph_runtime_pg-0.11.1.dist-info/RECORD +23 -0
- langgraph_runtime_pg-0.11.1.dist-info/WHEEL +4 -0
- langgraph_runtime_pg-0.11.1.dist-info/entry_points.txt +2 -0
- langgraph_runtime_pg-0.11.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,3538 @@
|
|
|
1
|
+
"""SQL-native Postgres ops for assistants, threads, runs, and crons."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import copy
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import AsyncIterator, Sequence
|
|
10
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any, Literal, cast
|
|
13
|
+
from uuid import UUID, uuid4
|
|
14
|
+
|
|
15
|
+
import orjson
|
|
16
|
+
import structlog
|
|
17
|
+
from langgraph.types import StateSnapshot
|
|
18
|
+
from sqlalchemy import delete, func, select as sa_select, text
|
|
19
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
20
|
+
from starlette.exceptions import HTTPException
|
|
21
|
+
|
|
22
|
+
from langgraph_runtime_pg.checkpoint import Checkpointer
|
|
23
|
+
from langgraph_runtime_pg.database import (
|
|
24
|
+
PgConnectionProto,
|
|
25
|
+
assistant_to_dict,
|
|
26
|
+
assistant_version_to_dict,
|
|
27
|
+
connect,
|
|
28
|
+
cron_to_dict,
|
|
29
|
+
get_session_factory,
|
|
30
|
+
run_to_dict,
|
|
31
|
+
thread_to_dict,
|
|
32
|
+
)
|
|
33
|
+
from langgraph_runtime_pg.models import (
|
|
34
|
+
AssistantRow,
|
|
35
|
+
AssistantVersionRow,
|
|
36
|
+
CronRow,
|
|
37
|
+
RetryCounterRow,
|
|
38
|
+
RunRow,
|
|
39
|
+
ThreadRow,
|
|
40
|
+
)
|
|
41
|
+
from langgraph_runtime_pg.redis_stream import (
|
|
42
|
+
ContextQueue,
|
|
43
|
+
Message,
|
|
44
|
+
clear_run_heartbeat,
|
|
45
|
+
get_stream_manager,
|
|
46
|
+
has_run_heartbeat,
|
|
47
|
+
heartbeat_refresh_interval_secs,
|
|
48
|
+
ms_seq_id_gt,
|
|
49
|
+
ms_seq_id_sort_key,
|
|
50
|
+
set_run_heartbeat,
|
|
51
|
+
wake_run_queue,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
55
|
+
|
|
56
|
+
StreamHandler = ContextQueue
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _empty_aiter():
|
|
60
|
+
if False:
|
|
61
|
+
yield
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _escape_like(value: str) -> str:
|
|
65
|
+
"""Escape ``%``, ``_``, and ``\\`` for SQL LIKE / ILIKE with escape='\\\\'."""
|
|
66
|
+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _name_ilike(name: str):
|
|
70
|
+
return AssistantRow.name.ilike(f"%{_escape_like(name)}%", escape="\\")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _patch_interrupt(interrupt: Any) -> dict:
|
|
74
|
+
"""JSONB-safe interrupt dict."""
|
|
75
|
+
from langgraph_api.state import patch_interrupt
|
|
76
|
+
|
|
77
|
+
return cast(dict, patch_interrupt(interrupt))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _thread_status_from_checkpoint(
|
|
81
|
+
checkpoint: dict | None,
|
|
82
|
+
exception: BaseException | None,
|
|
83
|
+
*,
|
|
84
|
+
ignore_user_control: bool = False,
|
|
85
|
+
) -> tuple[str, dict]:
|
|
86
|
+
"""Return ``(base_thread_status, interrupts)`` from a checkpoint snapshot."""
|
|
87
|
+
has_next = bool(checkpoint and checkpoint.get("next"))
|
|
88
|
+
if exception:
|
|
89
|
+
if ignore_user_control:
|
|
90
|
+
from langgraph_api.errors import UserInterrupt, UserRollback
|
|
91
|
+
|
|
92
|
+
if not isinstance(exception, (UserInterrupt, UserRollback)):
|
|
93
|
+
base = "error"
|
|
94
|
+
elif has_next:
|
|
95
|
+
base = "interrupted"
|
|
96
|
+
else:
|
|
97
|
+
base = "idle"
|
|
98
|
+
else:
|
|
99
|
+
base = "error"
|
|
100
|
+
elif has_next:
|
|
101
|
+
base = "interrupted"
|
|
102
|
+
else:
|
|
103
|
+
base = "idle"
|
|
104
|
+
|
|
105
|
+
interrupts: dict = {}
|
|
106
|
+
if checkpoint is not None:
|
|
107
|
+
interrupts = {
|
|
108
|
+
t["id"]: [_patch_interrupt(i) for i in (t.get("interrupts") or [])]
|
|
109
|
+
for t in (checkpoint.get("tasks") or [])
|
|
110
|
+
if t.get("interrupts")
|
|
111
|
+
}
|
|
112
|
+
return base, interrupts
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _test_mode() -> bool:
|
|
116
|
+
return os.environ.get("LG_RUNTIME_PG_TEST", "") == "1"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _snapshot_defaults() -> dict:
|
|
120
|
+
"""Kwargs for StateSnapshot on langgraph versions that require ``interrupts``."""
|
|
121
|
+
if not hasattr(StateSnapshot, "interrupts"):
|
|
122
|
+
return {}
|
|
123
|
+
return {"interrupts": ()}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _empty_state_snapshot() -> StateSnapshot:
|
|
127
|
+
return StateSnapshot(
|
|
128
|
+
values={},
|
|
129
|
+
next=(),
|
|
130
|
+
config=cast(Any, None),
|
|
131
|
+
metadata=None,
|
|
132
|
+
created_at=None,
|
|
133
|
+
parent_config=None,
|
|
134
|
+
tasks=(),
|
|
135
|
+
**_snapshot_defaults(),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _get_checkpointer(*, unpack_hook=None):
|
|
140
|
+
from langgraph_api import config as api_config
|
|
141
|
+
|
|
142
|
+
if getattr(api_config, "USE_CUSTOM_CHECKPOINTER", False):
|
|
143
|
+
from langgraph_api import _checkpointer as api_checkpointer
|
|
144
|
+
|
|
145
|
+
return await api_checkpointer.get_checkpointer()
|
|
146
|
+
return Checkpointer(unpack_hook=unpack_hook)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def _adelete_thread_checkpoints(thread_id: UUID | str) -> None:
|
|
150
|
+
checkpointer = await _get_checkpointer()
|
|
151
|
+
await checkpointer.adelete_thread(str(thread_id))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _acopy_thread_checkpoints(source_thread_id: str, target_thread_id: str) -> None:
|
|
155
|
+
"""Copy checkpoints; fall back to alist/aput when acopy_thread is unimplemented."""
|
|
156
|
+
checkpointer = await _get_checkpointer()
|
|
157
|
+
try:
|
|
158
|
+
await checkpointer.acopy_thread(source_thread_id, target_thread_id)
|
|
159
|
+
return
|
|
160
|
+
except NotImplementedError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
cfg: dict[str, Any] = {"configurable": {"thread_id": source_thread_id}}
|
|
164
|
+
checkpoints = [cp async for cp in checkpointer.alist(cfg)]
|
|
165
|
+
checkpoints.sort(key=lambda x: x.config["configurable"]["checkpoint_id"])
|
|
166
|
+
for cp in checkpoints:
|
|
167
|
+
ns = cp.config["configurable"].get("checkpoint_ns", "")
|
|
168
|
+
new_config: dict[str, Any] = {
|
|
169
|
+
"configurable": {
|
|
170
|
+
"thread_id": target_thread_id,
|
|
171
|
+
"checkpoint_ns": ns,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
parent_config = cp.parent_config
|
|
175
|
+
if parent_config and parent_config.get("configurable"):
|
|
176
|
+
parent_id = parent_config["configurable"].get("checkpoint_id")
|
|
177
|
+
if parent_id is not None:
|
|
178
|
+
new_config["configurable"]["checkpoint_id"] = parent_id
|
|
179
|
+
new_metadata = dict(cp.metadata or {})
|
|
180
|
+
if "thread_id" in new_metadata:
|
|
181
|
+
new_metadata["thread_id"] = target_thread_id
|
|
182
|
+
stored_config = await checkpointer.aput(
|
|
183
|
+
new_config,
|
|
184
|
+
cp.checkpoint,
|
|
185
|
+
new_metadata,
|
|
186
|
+
cp.checkpoint.get("channel_versions", {}),
|
|
187
|
+
)
|
|
188
|
+
if cp.pending_writes:
|
|
189
|
+
writes_by_task: dict[str, list[tuple[str, Any]]] = {}
|
|
190
|
+
for task_id, channel, value in cp.pending_writes:
|
|
191
|
+
writes_by_task.setdefault(task_id, []).append((channel, value))
|
|
192
|
+
for task_id, writes in writes_by_task.items():
|
|
193
|
+
await checkpointer.aput_writes(stored_config, writes, task_id)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Authenticated:
|
|
197
|
+
resource: Literal["threads", "crons", "assistants"] = "threads"
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def _context(
|
|
201
|
+
cls,
|
|
202
|
+
ctx: Any,
|
|
203
|
+
action: str,
|
|
204
|
+
) -> Any:
|
|
205
|
+
if not ctx:
|
|
206
|
+
return None
|
|
207
|
+
from langgraph_sdk import Auth
|
|
208
|
+
|
|
209
|
+
return Auth.types.AuthContext(
|
|
210
|
+
user=ctx.user,
|
|
211
|
+
permissions=ctx.permissions,
|
|
212
|
+
resource=cls.resource,
|
|
213
|
+
action=cast(Any, action),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
async def handle_event(
|
|
218
|
+
cls,
|
|
219
|
+
ctx: Any,
|
|
220
|
+
action: str,
|
|
221
|
+
value: Any,
|
|
222
|
+
) -> Any:
|
|
223
|
+
from langgraph_api.auth.custom import handle_event
|
|
224
|
+
from langgraph_api.utils import get_auth_ctx
|
|
225
|
+
|
|
226
|
+
ctx = ctx or get_auth_ctx()
|
|
227
|
+
if not ctx:
|
|
228
|
+
return None
|
|
229
|
+
return await handle_event(cls._context(ctx, action), value)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _run_stream_mode_matches(event_mode: str, stream_mode: list | str | None) -> bool:
|
|
233
|
+
if not stream_mode:
|
|
234
|
+
return True
|
|
235
|
+
modes = [stream_mode] if isinstance(stream_mode, str) else list(stream_mode)
|
|
236
|
+
if event_mode in modes:
|
|
237
|
+
return True
|
|
238
|
+
if ("messages" in modes or "messages-tuple" in modes) and event_mode.startswith("messages"):
|
|
239
|
+
return True
|
|
240
|
+
if "|" in event_mode:
|
|
241
|
+
base_mode, _, _ = event_mode.partition("|")
|
|
242
|
+
if base_mode in modes:
|
|
243
|
+
return True
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class WrappedHTTPException(Exception):
|
|
248
|
+
def __init__(self, http_exception: HTTPException):
|
|
249
|
+
self.http_exception = http_exception
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _ensure_uuid(id_: str | UUID | None) -> UUID:
|
|
253
|
+
if isinstance(id_, str):
|
|
254
|
+
return UUID(id_)
|
|
255
|
+
if id_ is None:
|
|
256
|
+
return uuid4()
|
|
257
|
+
return id_
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Whitelist sort fields: getattr(Model, "metadata") is SQLAlchemy MetaData, not metadata_.
|
|
261
|
+
_ASSISTANT_SORT_FIELDS = frozenset({"assistant_id", "graph_id", "name", "created_at", "updated_at"})
|
|
262
|
+
_THREAD_SORT_FIELDS = frozenset(
|
|
263
|
+
{"thread_id", "created_at", "updated_at", "state_updated_at", "status"}
|
|
264
|
+
)
|
|
265
|
+
_CRON_SORT_FIELDS = frozenset(
|
|
266
|
+
{
|
|
267
|
+
"cron_id",
|
|
268
|
+
"assistant_id",
|
|
269
|
+
"thread_id",
|
|
270
|
+
"next_run_date",
|
|
271
|
+
"end_time",
|
|
272
|
+
"created_at",
|
|
273
|
+
"updated_at",
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _resolve_sort_field(
|
|
279
|
+
sort_by: str | None,
|
|
280
|
+
allowed: frozenset[str],
|
|
281
|
+
default: str,
|
|
282
|
+
*,
|
|
283
|
+
raise_invalid: bool = False,
|
|
284
|
+
) -> str:
|
|
285
|
+
sb = (sort_by or "").lower() if sort_by else ""
|
|
286
|
+
if sb in allowed:
|
|
287
|
+
return sb
|
|
288
|
+
if sort_by and raise_invalid:
|
|
289
|
+
opts = ", ".join(sorted(allowed))
|
|
290
|
+
raise HTTPException(
|
|
291
|
+
status_code=422,
|
|
292
|
+
detail=f"Invalid sort_by field: '{sort_by}'. Valid options are: {opts}",
|
|
293
|
+
)
|
|
294
|
+
return default
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _row_sort_key(row: Any, attr: str) -> tuple:
|
|
298
|
+
"""Nullable-safe sort key — avoids TypeError mixing datetime with None/str."""
|
|
299
|
+
val = getattr(row, attr, None)
|
|
300
|
+
return (val is None, val)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _thread_search_item(
|
|
304
|
+
row: Any,
|
|
305
|
+
*,
|
|
306
|
+
select: list | None = None,
|
|
307
|
+
extract: dict | None = None,
|
|
308
|
+
) -> dict[str, Any]:
|
|
309
|
+
"""Build a Threads.search result dict."""
|
|
310
|
+
d = thread_to_dict(row)
|
|
311
|
+
d.setdefault("state_updated_at", d.get("updated_at"))
|
|
312
|
+
if select:
|
|
313
|
+
out = {k: v for k, v in d.items() if k in select}
|
|
314
|
+
else:
|
|
315
|
+
out = d
|
|
316
|
+
if extract:
|
|
317
|
+
from langgraph_api.utils.extract import extract_path_value
|
|
318
|
+
|
|
319
|
+
out["extracted"] = {alias: extract_path_value(d, path) for alias, path in extract.items()}
|
|
320
|
+
return out
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _merge_jsonb(*objects: dict | None) -> dict:
|
|
324
|
+
"""Shallow JSONB-style merge (later keys win)."""
|
|
325
|
+
result: dict = {}
|
|
326
|
+
for obj in objects:
|
|
327
|
+
if obj:
|
|
328
|
+
result.update(copy.deepcopy(obj))
|
|
329
|
+
return result
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _plain_metadata_filter(filters: Any | None) -> dict | None:
|
|
333
|
+
"""Return filters pushable as JSONB ``@>``, or None if operators need Python."""
|
|
334
|
+
if not filters:
|
|
335
|
+
return {}
|
|
336
|
+
if not isinstance(filters, dict):
|
|
337
|
+
return None
|
|
338
|
+
if any(str(k).startswith("$") for k in filters):
|
|
339
|
+
return None
|
|
340
|
+
for value in filters.values():
|
|
341
|
+
if isinstance(value, dict) and any(str(k).startswith("$") for k in value):
|
|
342
|
+
return None
|
|
343
|
+
return filters
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _auth_denies(metadata: dict | None, filters: Any | None) -> bool:
|
|
347
|
+
return bool(filters) and not _check_filter_match(metadata or {}, filters)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _check_filter_match(
|
|
351
|
+
metadata: dict,
|
|
352
|
+
filters: Any | None,
|
|
353
|
+
nesting_level: int = 0,
|
|
354
|
+
) -> bool:
|
|
355
|
+
if not filters:
|
|
356
|
+
return True
|
|
357
|
+
if nesting_level > 2:
|
|
358
|
+
raise HTTPException(status_code=500, detail="Too many nested filter operators")
|
|
359
|
+
|
|
360
|
+
if "$or" in filters:
|
|
361
|
+
or_groups = filters["$or"]
|
|
362
|
+
if not any(_check_filter_match(metadata, g, nesting_level + 1) for g in or_groups):
|
|
363
|
+
return False
|
|
364
|
+
remaining = {k: v for k, v in filters.items() if k != "$or"}
|
|
365
|
+
if remaining:
|
|
366
|
+
return _check_filter_match(metadata, remaining, nesting_level + 1)
|
|
367
|
+
return True
|
|
368
|
+
|
|
369
|
+
if "$and" in filters:
|
|
370
|
+
and_groups = filters["$and"]
|
|
371
|
+
if not all(_check_filter_match(metadata, g, nesting_level + 1) for g in and_groups):
|
|
372
|
+
return False
|
|
373
|
+
remaining = {k: v for k, v in filters.items() if k != "$and"}
|
|
374
|
+
if remaining:
|
|
375
|
+
return _check_filter_match(metadata, remaining, nesting_level + 1)
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
for key, value in filters.items():
|
|
379
|
+
if isinstance(value, dict):
|
|
380
|
+
op = next(iter(value))
|
|
381
|
+
filter_value = value[op]
|
|
382
|
+
if op == "$eq":
|
|
383
|
+
if key not in metadata or metadata[key] != filter_value:
|
|
384
|
+
return False
|
|
385
|
+
elif op == "$contains":
|
|
386
|
+
if key not in metadata or not isinstance(metadata[key], list):
|
|
387
|
+
return False
|
|
388
|
+
if isinstance(filter_value, list):
|
|
389
|
+
for el in filter_value:
|
|
390
|
+
if el not in metadata[key]:
|
|
391
|
+
return False
|
|
392
|
+
elif filter_value not in metadata[key]:
|
|
393
|
+
return False
|
|
394
|
+
else:
|
|
395
|
+
if key not in metadata or metadata[key] != value:
|
|
396
|
+
return False
|
|
397
|
+
return True
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _assert_graph_exists(graph_id: str) -> None:
|
|
401
|
+
if _test_mode():
|
|
402
|
+
return
|
|
403
|
+
try:
|
|
404
|
+
from langgraph_api.graph import assert_graph_exists
|
|
405
|
+
|
|
406
|
+
assert_graph_exists(graph_id)
|
|
407
|
+
except ImportError:
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def _aget_assistant(session, assistant_id: UUID) -> AssistantRow | None:
|
|
412
|
+
return await session.get(AssistantRow, assistant_id)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def _aget_thread(session, thread_id: UUID) -> ThreadRow | None:
|
|
416
|
+
return await session.get(ThreadRow, thread_id)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
async def _aget_run(session, run_id: UUID) -> RunRow | None:
|
|
420
|
+
return await session.get(RunRow, run_id)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def _aget_cron(session, cron_id: UUID) -> CronRow | None:
|
|
424
|
+
return await session.get(CronRow, cron_id)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
async def _adelete_retry_counters(session, run_ids: Sequence[UUID]) -> None:
|
|
428
|
+
"""Drop retry_counters for deleted runs (no FK cascade on that table)."""
|
|
429
|
+
if not run_ids:
|
|
430
|
+
return
|
|
431
|
+
await session.execute(delete(RetryCounterRow).where(RetryCounterRow.run_id.in_(run_ids)))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
async def _thread_has_live_worker(session, thread_id: UUID) -> bool:
|
|
435
|
+
"""True if any in-flight/cancelled run still heartbeats."""
|
|
436
|
+
run_ids = list(
|
|
437
|
+
(
|
|
438
|
+
await session.execute(
|
|
439
|
+
sa_select(RunRow.run_id).where(
|
|
440
|
+
RunRow.thread_id == thread_id,
|
|
441
|
+
RunRow.status.in_(("pending", "running", "interrupted")),
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
.scalars()
|
|
446
|
+
.all()
|
|
447
|
+
)
|
|
448
|
+
for rid in run_ids:
|
|
449
|
+
alive = await has_run_heartbeat(rid)
|
|
450
|
+
if alive is not False:
|
|
451
|
+
return True
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
async def _thread_has_inflight_work(session, thread_id: UUID) -> bool:
|
|
456
|
+
"""Pending/running rows, or a cancelled worker that is still heartbeating."""
|
|
457
|
+
pending = int(
|
|
458
|
+
await session.scalar(
|
|
459
|
+
sa_select(func.count())
|
|
460
|
+
.select_from(RunRow)
|
|
461
|
+
.where(
|
|
462
|
+
RunRow.thread_id == thread_id,
|
|
463
|
+
RunRow.status.in_(("pending", "running")),
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
or 0
|
|
467
|
+
)
|
|
468
|
+
if pending:
|
|
469
|
+
return True
|
|
470
|
+
return await _thread_has_live_worker(session, thread_id)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class Assistants(Authenticated):
|
|
474
|
+
resource = "assistants"
|
|
475
|
+
|
|
476
|
+
@staticmethod
|
|
477
|
+
async def search(
|
|
478
|
+
conn: PgConnectionProto,
|
|
479
|
+
*,
|
|
480
|
+
graph_id: str | None = None,
|
|
481
|
+
name: str | None = None,
|
|
482
|
+
metadata: dict | None = None,
|
|
483
|
+
limit: int = 10,
|
|
484
|
+
offset: int = 0,
|
|
485
|
+
sort_by: str | None = None,
|
|
486
|
+
sort_order: str | None = None,
|
|
487
|
+
select: list | None = None,
|
|
488
|
+
ctx: Any = None,
|
|
489
|
+
) -> tuple[AsyncIterator, int | None]:
|
|
490
|
+
from langgraph_sdk import Auth
|
|
491
|
+
|
|
492
|
+
metadata = metadata or {}
|
|
493
|
+
filters = await Assistants.handle_event(
|
|
494
|
+
ctx,
|
|
495
|
+
"search",
|
|
496
|
+
Auth.types.AssistantsSearch(
|
|
497
|
+
graph_id=graph_id, metadata=metadata, limit=limit, offset=offset
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
if graph_id is not None:
|
|
501
|
+
_assert_graph_exists(graph_id)
|
|
502
|
+
|
|
503
|
+
q = sa_select(AssistantRow)
|
|
504
|
+
if graph_id:
|
|
505
|
+
q = q.where(AssistantRow.graph_id == graph_id)
|
|
506
|
+
if name:
|
|
507
|
+
q = q.where(_name_ilike(name))
|
|
508
|
+
if metadata:
|
|
509
|
+
q = q.where(AssistantRow.metadata_.contains(metadata))
|
|
510
|
+
plain = _plain_metadata_filter(filters)
|
|
511
|
+
if plain:
|
|
512
|
+
q = q.where(AssistantRow.metadata_.contains(plain))
|
|
513
|
+
elif filters:
|
|
514
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
515
|
+
rows = [r for r in rows if _check_filter_match(r.metadata_ or {}, filters)]
|
|
516
|
+
sb = _resolve_sort_field(sort_by, _ASSISTANT_SORT_FIELDS, "created_at")
|
|
517
|
+
reverse = not (sort_order and sort_order.upper() == "ASC")
|
|
518
|
+
rows.sort(key=lambda r: _row_sort_key(r, sb), reverse=reverse)
|
|
519
|
+
page = rows[offset : offset + limit]
|
|
520
|
+
cursor = offset + limit if len(rows) > offset + limit else None
|
|
521
|
+
items = []
|
|
522
|
+
for r in page:
|
|
523
|
+
d = assistant_to_dict(r)
|
|
524
|
+
items.append({k: v for k, v in d.items() if k in select} if select else d)
|
|
525
|
+
|
|
526
|
+
async def _iter_filtered():
|
|
527
|
+
for d in items:
|
|
528
|
+
yield d
|
|
529
|
+
|
|
530
|
+
return _iter_filtered(), cursor
|
|
531
|
+
|
|
532
|
+
sb = _resolve_sort_field(sort_by, _ASSISTANT_SORT_FIELDS, "created_at")
|
|
533
|
+
col = getattr(AssistantRow, sb)
|
|
534
|
+
reverse = not (sort_order and sort_order.upper() == "ASC")
|
|
535
|
+
q = q.order_by(col.desc() if reverse else col.asc())
|
|
536
|
+
q = q.offset(offset).limit(limit + 1)
|
|
537
|
+
|
|
538
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
539
|
+
cursor = offset + limit if len(rows) > limit else None
|
|
540
|
+
page = rows[:limit]
|
|
541
|
+
# Materialize dicts while the session is still open (API paginates outside connect()).
|
|
542
|
+
items = []
|
|
543
|
+
for r in page:
|
|
544
|
+
d = assistant_to_dict(r)
|
|
545
|
+
items.append({k: v for k, v in d.items() if k in select} if select else d)
|
|
546
|
+
|
|
547
|
+
async def _iter():
|
|
548
|
+
for d in items:
|
|
549
|
+
yield d
|
|
550
|
+
|
|
551
|
+
return _iter(), cursor
|
|
552
|
+
|
|
553
|
+
@staticmethod
|
|
554
|
+
async def get(
|
|
555
|
+
conn: PgConnectionProto,
|
|
556
|
+
assistant_id: UUID | str,
|
|
557
|
+
ctx: Any = None,
|
|
558
|
+
) -> AsyncIterator:
|
|
559
|
+
from langgraph_sdk import Auth
|
|
560
|
+
|
|
561
|
+
assistant_id = _ensure_uuid(assistant_id)
|
|
562
|
+
filters = await Assistants.handle_event(
|
|
563
|
+
ctx, "read", Auth.types.AssistantsRead(assistant_id=assistant_id)
|
|
564
|
+
)
|
|
565
|
+
# Eagerly load before returning the iterator. langgraph_api often does
|
|
566
|
+
# ``async with connect(): it = await Assistants.get(...)`` then
|
|
567
|
+
# ``await fetchone(it)`` *outside* the connect block. A lazy DB await
|
|
568
|
+
# inside ``_yield`` would re-checkout a pool connection after the
|
|
569
|
+
# session closed and never check it in (GC non-checked-in warning).
|
|
570
|
+
row = await _aget_assistant(conn.session, assistant_id)
|
|
571
|
+
data = None
|
|
572
|
+
if row is not None and (not filters or _check_filter_match(row.metadata_ or {}, filters)):
|
|
573
|
+
data = copy.deepcopy(assistant_to_dict(row))
|
|
574
|
+
|
|
575
|
+
async def _yield():
|
|
576
|
+
if data is not None:
|
|
577
|
+
yield data
|
|
578
|
+
|
|
579
|
+
return _yield()
|
|
580
|
+
|
|
581
|
+
@staticmethod
|
|
582
|
+
async def put(
|
|
583
|
+
conn: PgConnectionProto,
|
|
584
|
+
assistant_id: UUID | str,
|
|
585
|
+
*,
|
|
586
|
+
graph_id: str,
|
|
587
|
+
config: dict | None = None,
|
|
588
|
+
context: dict | None = None,
|
|
589
|
+
metadata: dict | None = None,
|
|
590
|
+
if_exists: str = "raise",
|
|
591
|
+
name: str = "",
|
|
592
|
+
description: str | None = None,
|
|
593
|
+
ctx: Any = None,
|
|
594
|
+
system: bool = False,
|
|
595
|
+
) -> AsyncIterator:
|
|
596
|
+
from langgraph_sdk import Auth
|
|
597
|
+
|
|
598
|
+
assistant_id = _ensure_uuid(assistant_id)
|
|
599
|
+
config = config or {}
|
|
600
|
+
context = context or {}
|
|
601
|
+
metadata = metadata or {}
|
|
602
|
+
filters = await Assistants.handle_event(
|
|
603
|
+
ctx,
|
|
604
|
+
"create",
|
|
605
|
+
Auth.types.AssistantsCreate(
|
|
606
|
+
assistant_id=assistant_id,
|
|
607
|
+
graph_id=graph_id,
|
|
608
|
+
config=config,
|
|
609
|
+
context=context,
|
|
610
|
+
metadata=metadata,
|
|
611
|
+
name=name,
|
|
612
|
+
),
|
|
613
|
+
)
|
|
614
|
+
_assert_graph_exists(graph_id)
|
|
615
|
+
|
|
616
|
+
if config.get("configurable") and context:
|
|
617
|
+
raise HTTPException(
|
|
618
|
+
status_code=400,
|
|
619
|
+
detail="Cannot specify both configurable and context.",
|
|
620
|
+
)
|
|
621
|
+
if config.get("configurable"):
|
|
622
|
+
context = config["configurable"]
|
|
623
|
+
elif context:
|
|
624
|
+
config["configurable"] = context
|
|
625
|
+
|
|
626
|
+
existing = await _aget_assistant(conn.session, assistant_id)
|
|
627
|
+
if existing:
|
|
628
|
+
if filters and not _check_filter_match(existing.metadata_ or {}, filters):
|
|
629
|
+
raise HTTPException(
|
|
630
|
+
status_code=409, detail=f"Assistant {assistant_id} already exists"
|
|
631
|
+
)
|
|
632
|
+
if if_exists == "raise":
|
|
633
|
+
raise HTTPException(
|
|
634
|
+
status_code=409, detail=f"Assistant {assistant_id} already exists"
|
|
635
|
+
)
|
|
636
|
+
if if_exists == "do_nothing":
|
|
637
|
+
# Snapshot while session is open — API may drain after connect().
|
|
638
|
+
data = assistant_to_dict(existing)
|
|
639
|
+
|
|
640
|
+
async def _yield_existing():
|
|
641
|
+
yield data
|
|
642
|
+
|
|
643
|
+
return _yield_existing()
|
|
644
|
+
|
|
645
|
+
now = datetime.now(UTC)
|
|
646
|
+
# ON CONFLICT so concurrent replica startups (system assistants) are safe.
|
|
647
|
+
ins = (
|
|
648
|
+
pg_insert(AssistantRow)
|
|
649
|
+
.values(
|
|
650
|
+
assistant_id=assistant_id,
|
|
651
|
+
graph_id=graph_id,
|
|
652
|
+
config=config,
|
|
653
|
+
context=context,
|
|
654
|
+
metadata_=metadata,
|
|
655
|
+
name=name,
|
|
656
|
+
description=description,
|
|
657
|
+
version=1,
|
|
658
|
+
created_at=now,
|
|
659
|
+
updated_at=now,
|
|
660
|
+
)
|
|
661
|
+
.on_conflict_do_nothing(index_elements=["assistant_id"])
|
|
662
|
+
)
|
|
663
|
+
result = await conn.session.execute(ins)
|
|
664
|
+
if result.rowcount == 0:
|
|
665
|
+
existing = await _aget_assistant(conn.session, assistant_id)
|
|
666
|
+
# Re-check auth: another replica may have inserted first.
|
|
667
|
+
if existing is None or (
|
|
668
|
+
filters and not _check_filter_match(existing.metadata_ or {}, filters)
|
|
669
|
+
):
|
|
670
|
+
raise HTTPException(
|
|
671
|
+
status_code=409, detail=f"Assistant {assistant_id} already exists"
|
|
672
|
+
)
|
|
673
|
+
if if_exists == "raise":
|
|
674
|
+
raise HTTPException(
|
|
675
|
+
status_code=409, detail=f"Assistant {assistant_id} already exists"
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
data = assistant_to_dict(existing)
|
|
679
|
+
|
|
680
|
+
async def _yield_raced():
|
|
681
|
+
yield data
|
|
682
|
+
|
|
683
|
+
return _yield_raced()
|
|
684
|
+
|
|
685
|
+
await conn.session.execute(
|
|
686
|
+
pg_insert(AssistantVersionRow)
|
|
687
|
+
.values(
|
|
688
|
+
assistant_id=assistant_id,
|
|
689
|
+
version=1,
|
|
690
|
+
graph_id=graph_id,
|
|
691
|
+
config=config,
|
|
692
|
+
context=context,
|
|
693
|
+
metadata_=metadata,
|
|
694
|
+
name=name,
|
|
695
|
+
description=description,
|
|
696
|
+
created_at=now,
|
|
697
|
+
)
|
|
698
|
+
.on_conflict_do_nothing(index_elements=["assistant_id", "version"])
|
|
699
|
+
)
|
|
700
|
+
await conn.session.flush()
|
|
701
|
+
row = await _aget_assistant(conn.session, assistant_id)
|
|
702
|
+
data = assistant_to_dict(row)
|
|
703
|
+
|
|
704
|
+
async def _yield_new():
|
|
705
|
+
yield data
|
|
706
|
+
|
|
707
|
+
return _yield_new()
|
|
708
|
+
|
|
709
|
+
@staticmethod
|
|
710
|
+
async def patch(
|
|
711
|
+
conn: PgConnectionProto,
|
|
712
|
+
assistant_id: UUID | str,
|
|
713
|
+
*,
|
|
714
|
+
config: dict | None = None,
|
|
715
|
+
context: dict | None = None,
|
|
716
|
+
graph_id: str | None = None,
|
|
717
|
+
metadata: dict | None = None,
|
|
718
|
+
name: str | None = None,
|
|
719
|
+
description: str | None = None,
|
|
720
|
+
ctx: Any = None,
|
|
721
|
+
) -> AsyncIterator:
|
|
722
|
+
from langgraph_sdk import Auth
|
|
723
|
+
|
|
724
|
+
assistant_id = _ensure_uuid(assistant_id)
|
|
725
|
+
config = config if config is not None else {}
|
|
726
|
+
metadata = metadata if metadata is not None else {}
|
|
727
|
+
filters = await Assistants.handle_event(
|
|
728
|
+
ctx,
|
|
729
|
+
"update",
|
|
730
|
+
Auth.types.AssistantsUpdate(
|
|
731
|
+
assistant_id=assistant_id,
|
|
732
|
+
graph_id=graph_id,
|
|
733
|
+
config=config,
|
|
734
|
+
metadata=metadata,
|
|
735
|
+
),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
if graph_id is not None:
|
|
739
|
+
_assert_graph_exists(graph_id)
|
|
740
|
+
if config.get("configurable") and context:
|
|
741
|
+
raise HTTPException(
|
|
742
|
+
status_code=400, detail="Cannot specify both configurable and context."
|
|
743
|
+
)
|
|
744
|
+
if config.get("configurable"):
|
|
745
|
+
context = config["configurable"]
|
|
746
|
+
elif context:
|
|
747
|
+
config["configurable"] = context
|
|
748
|
+
|
|
749
|
+
# Lock so concurrent patches cannot allocate the same version (PK on assistant_versions).
|
|
750
|
+
assistant = (
|
|
751
|
+
await conn.session.execute(
|
|
752
|
+
sa_select(AssistantRow)
|
|
753
|
+
.where(AssistantRow.assistant_id == assistant_id)
|
|
754
|
+
.with_for_update()
|
|
755
|
+
)
|
|
756
|
+
).scalar_one_or_none()
|
|
757
|
+
if not assistant:
|
|
758
|
+
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found")
|
|
759
|
+
if filters and not _check_filter_match(assistant.metadata_ or {}, filters):
|
|
760
|
+
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found")
|
|
761
|
+
|
|
762
|
+
now = datetime.now(UTC)
|
|
763
|
+
max_ver = await conn.session.scalar(
|
|
764
|
+
sa_select(func.coalesce(func.max(AssistantVersionRow.version), 0)).where(
|
|
765
|
+
AssistantVersionRow.assistant_id == assistant_id
|
|
766
|
+
)
|
|
767
|
+
)
|
|
768
|
+
new_version_num = int(max_ver or 0) + 1
|
|
769
|
+
|
|
770
|
+
new_graph = graph_id if graph_id is not None else assistant.graph_id
|
|
771
|
+
new_config = config if config else assistant.config
|
|
772
|
+
new_context = context if context is not None else (assistant.context or {})
|
|
773
|
+
new_meta = (
|
|
774
|
+
{**(assistant.metadata_ or {}), **metadata} if metadata else (assistant.metadata_ or {})
|
|
775
|
+
)
|
|
776
|
+
new_name = name if name is not None else assistant.name
|
|
777
|
+
new_desc = description if description is not None else assistant.description
|
|
778
|
+
|
|
779
|
+
conn.session.add(
|
|
780
|
+
AssistantVersionRow(
|
|
781
|
+
assistant_id=assistant_id,
|
|
782
|
+
version=new_version_num,
|
|
783
|
+
graph_id=new_graph,
|
|
784
|
+
config=new_config,
|
|
785
|
+
context=new_context,
|
|
786
|
+
metadata_=new_meta,
|
|
787
|
+
name=new_name,
|
|
788
|
+
description=new_desc,
|
|
789
|
+
created_at=now,
|
|
790
|
+
)
|
|
791
|
+
)
|
|
792
|
+
assistant.graph_id = new_graph
|
|
793
|
+
assistant.config = new_config
|
|
794
|
+
assistant.context = new_context
|
|
795
|
+
assistant.metadata_ = new_meta
|
|
796
|
+
assistant.name = new_name
|
|
797
|
+
assistant.description = new_desc
|
|
798
|
+
assistant.updated_at = now
|
|
799
|
+
assistant.version = new_version_num
|
|
800
|
+
await conn.session.flush()
|
|
801
|
+
data = assistant_to_dict(assistant)
|
|
802
|
+
|
|
803
|
+
async def _yield():
|
|
804
|
+
yield data
|
|
805
|
+
|
|
806
|
+
return _yield()
|
|
807
|
+
|
|
808
|
+
@staticmethod
|
|
809
|
+
async def delete(
|
|
810
|
+
conn: PgConnectionProto | None,
|
|
811
|
+
assistant_id: UUID | str,
|
|
812
|
+
ctx: Any = None,
|
|
813
|
+
*,
|
|
814
|
+
delete_threads: bool = False,
|
|
815
|
+
) -> AsyncIterator:
|
|
816
|
+
from langgraph_sdk import Auth
|
|
817
|
+
|
|
818
|
+
async with AsyncExitStack() as stack:
|
|
819
|
+
if conn is None:
|
|
820
|
+
conn = await stack.enter_async_context(connect())
|
|
821
|
+
|
|
822
|
+
assistant_id = _ensure_uuid(assistant_id)
|
|
823
|
+
filters = await Assistants.handle_event(
|
|
824
|
+
ctx, "delete", Auth.types.AssistantsDelete(assistant_id=assistant_id)
|
|
825
|
+
)
|
|
826
|
+
assistant = await _aget_assistant(conn.session, assistant_id)
|
|
827
|
+
if not assistant:
|
|
828
|
+
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found")
|
|
829
|
+
if filters and not _check_filter_match(assistant.metadata_ or {}, filters):
|
|
830
|
+
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found")
|
|
831
|
+
|
|
832
|
+
if delete_threads:
|
|
833
|
+
result = await conn.session.execute(
|
|
834
|
+
sa_select(ThreadRow.thread_id).where(
|
|
835
|
+
ThreadRow.metadata_.contains({"assistant_id": str(assistant_id)})
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
for (thread_id,) in result.all():
|
|
839
|
+
try:
|
|
840
|
+
async for _ in await Threads.delete(conn, thread_id, ctx=ctx):
|
|
841
|
+
pass
|
|
842
|
+
except HTTPException:
|
|
843
|
+
await logger.awarning(
|
|
844
|
+
"Skipping thread deletion during cascade delete",
|
|
845
|
+
thread_id=str(thread_id),
|
|
846
|
+
assistant_id=str(assistant_id),
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
await Runs.cancel(conn, assistant_id=assistant_id, action="interrupt", ctx=ctx)
|
|
850
|
+
|
|
851
|
+
await conn.session.execute(
|
|
852
|
+
delete(AssistantVersionRow).where(AssistantVersionRow.assistant_id == assistant_id)
|
|
853
|
+
)
|
|
854
|
+
await conn.session.execute(delete(CronRow).where(CronRow.assistant_id == assistant_id))
|
|
855
|
+
await conn.session.delete(assistant)
|
|
856
|
+
await conn.session.flush()
|
|
857
|
+
|
|
858
|
+
async def _yield():
|
|
859
|
+
yield assistant_id
|
|
860
|
+
|
|
861
|
+
return _yield()
|
|
862
|
+
|
|
863
|
+
@staticmethod
|
|
864
|
+
async def set_latest(
|
|
865
|
+
conn: PgConnectionProto,
|
|
866
|
+
assistant_id: UUID | str,
|
|
867
|
+
version: int,
|
|
868
|
+
ctx: Any = None,
|
|
869
|
+
) -> AsyncIterator:
|
|
870
|
+
from langgraph_sdk import Auth
|
|
871
|
+
|
|
872
|
+
assistant_id = _ensure_uuid(assistant_id)
|
|
873
|
+
filters = await Assistants.handle_event(
|
|
874
|
+
ctx,
|
|
875
|
+
"update",
|
|
876
|
+
Auth.types.AssistantsUpdate(assistant_id=assistant_id, version=version),
|
|
877
|
+
)
|
|
878
|
+
assistant = await _aget_assistant(conn.session, assistant_id)
|
|
879
|
+
if not assistant:
|
|
880
|
+
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found")
|
|
881
|
+
if filters and not _check_filter_match(assistant.metadata_ or {}, filters):
|
|
882
|
+
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found")
|
|
883
|
+
|
|
884
|
+
version_data = await conn.session.get(AssistantVersionRow, (assistant_id, version))
|
|
885
|
+
if not version_data:
|
|
886
|
+
raise HTTPException(status_code=404, detail=f"Version {version} not found")
|
|
887
|
+
|
|
888
|
+
assistant.graph_id = version_data.graph_id
|
|
889
|
+
assistant.config = version_data.config
|
|
890
|
+
assistant.context = version_data.context
|
|
891
|
+
assistant.metadata_ = version_data.metadata_
|
|
892
|
+
assistant.version = version_data.version
|
|
893
|
+
assistant.updated_at = datetime.now(UTC)
|
|
894
|
+
assistant.name = version_data.name
|
|
895
|
+
assistant.description = version_data.description
|
|
896
|
+
await conn.session.flush()
|
|
897
|
+
data = assistant_to_dict(assistant)
|
|
898
|
+
|
|
899
|
+
async def _yield():
|
|
900
|
+
yield data
|
|
901
|
+
|
|
902
|
+
return _yield()
|
|
903
|
+
|
|
904
|
+
@staticmethod
|
|
905
|
+
async def get_versions(
|
|
906
|
+
conn: PgConnectionProto,
|
|
907
|
+
assistant_id: UUID | str,
|
|
908
|
+
metadata: dict | None = None,
|
|
909
|
+
limit: int = 10,
|
|
910
|
+
offset: int = 0,
|
|
911
|
+
ctx: Any = None,
|
|
912
|
+
) -> AsyncIterator:
|
|
913
|
+
from langgraph_sdk import Auth
|
|
914
|
+
|
|
915
|
+
assistant_id = _ensure_uuid(assistant_id)
|
|
916
|
+
metadata = metadata or {}
|
|
917
|
+
filters = await Assistants.handle_event(
|
|
918
|
+
ctx, "read", Auth.types.AssistantsRead(assistant_id=assistant_id)
|
|
919
|
+
)
|
|
920
|
+
assistant = await _aget_assistant(conn.session, assistant_id)
|
|
921
|
+
if not assistant:
|
|
922
|
+
raise HTTPException(status_code=404, detail=f"Assistant {assistant_id} not found")
|
|
923
|
+
|
|
924
|
+
q = sa_select(AssistantVersionRow).where(AssistantVersionRow.assistant_id == assistant_id)
|
|
925
|
+
if metadata:
|
|
926
|
+
q = q.where(AssistantVersionRow.metadata_.contains(metadata))
|
|
927
|
+
q = q.order_by(AssistantVersionRow.version.desc()).offset(offset).limit(limit)
|
|
928
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
929
|
+
# Materialize while session is open — API drains versions outside connect().
|
|
930
|
+
default_name = assistant.name
|
|
931
|
+
default_description = assistant.description
|
|
932
|
+
items: list[dict] = []
|
|
933
|
+
for r in rows:
|
|
934
|
+
d = assistant_version_to_dict(r)
|
|
935
|
+
if filters and not _check_filter_match(d.get("metadata") or {}, filters):
|
|
936
|
+
continue
|
|
937
|
+
d.setdefault("name", default_name)
|
|
938
|
+
d.setdefault("description", default_description)
|
|
939
|
+
items.append(d)
|
|
940
|
+
|
|
941
|
+
async def _yield():
|
|
942
|
+
for d in items:
|
|
943
|
+
yield d
|
|
944
|
+
|
|
945
|
+
return _yield()
|
|
946
|
+
|
|
947
|
+
@staticmethod
|
|
948
|
+
async def count(
|
|
949
|
+
conn: PgConnectionProto,
|
|
950
|
+
*,
|
|
951
|
+
graph_id: str | None = None,
|
|
952
|
+
name: str | None = None,
|
|
953
|
+
metadata: dict | None = None,
|
|
954
|
+
ctx: Any = None,
|
|
955
|
+
) -> int:
|
|
956
|
+
from langgraph_sdk import Auth
|
|
957
|
+
|
|
958
|
+
metadata = metadata or {}
|
|
959
|
+
filters = await Assistants.handle_event(
|
|
960
|
+
ctx,
|
|
961
|
+
"search",
|
|
962
|
+
Auth.types.AssistantsSearch(graph_id=graph_id, metadata=metadata, limit=0, offset=0),
|
|
963
|
+
)
|
|
964
|
+
if graph_id is not None:
|
|
965
|
+
_assert_graph_exists(graph_id)
|
|
966
|
+
|
|
967
|
+
plain = _plain_metadata_filter(filters)
|
|
968
|
+
if filters and plain is None:
|
|
969
|
+
q = sa_select(AssistantRow)
|
|
970
|
+
if graph_id:
|
|
971
|
+
q = q.where(AssistantRow.graph_id == graph_id)
|
|
972
|
+
if name:
|
|
973
|
+
q = q.where(_name_ilike(name))
|
|
974
|
+
if metadata:
|
|
975
|
+
q = q.where(AssistantRow.metadata_.contains(metadata))
|
|
976
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
977
|
+
return sum(1 for r in rows if _check_filter_match(r.metadata_ or {}, filters))
|
|
978
|
+
|
|
979
|
+
count_q = sa_select(func.count()).select_from(AssistantRow)
|
|
980
|
+
if graph_id:
|
|
981
|
+
count_q = count_q.where(AssistantRow.graph_id == graph_id)
|
|
982
|
+
if name:
|
|
983
|
+
count_q = count_q.where(_name_ilike(name))
|
|
984
|
+
if metadata:
|
|
985
|
+
count_q = count_q.where(AssistantRow.metadata_.contains(metadata))
|
|
986
|
+
if plain:
|
|
987
|
+
count_q = count_q.where(AssistantRow.metadata_.contains(plain))
|
|
988
|
+
return int(await conn.session.scalar(count_q) or 0)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
class Threads(Authenticated):
|
|
992
|
+
resource = "threads"
|
|
993
|
+
|
|
994
|
+
@staticmethod
|
|
995
|
+
async def search(
|
|
996
|
+
conn: PgConnectionProto,
|
|
997
|
+
*,
|
|
998
|
+
ids: list | None = None,
|
|
999
|
+
metadata: dict | None = None,
|
|
1000
|
+
values: dict | None = None,
|
|
1001
|
+
status: str | None = None,
|
|
1002
|
+
limit: int = 10,
|
|
1003
|
+
offset: int = 0,
|
|
1004
|
+
sort_by: str | None = None,
|
|
1005
|
+
sort_order: str | None = None,
|
|
1006
|
+
select: list | None = None,
|
|
1007
|
+
extract: dict | None = None,
|
|
1008
|
+
ctx: Any = None,
|
|
1009
|
+
) -> tuple[AsyncIterator, int | None]:
|
|
1010
|
+
from langgraph_sdk import Auth
|
|
1011
|
+
|
|
1012
|
+
metadata = metadata or {}
|
|
1013
|
+
values = values or {}
|
|
1014
|
+
filters = await Threads.handle_event(
|
|
1015
|
+
ctx,
|
|
1016
|
+
"search",
|
|
1017
|
+
Auth.types.ThreadsSearch(
|
|
1018
|
+
metadata=metadata,
|
|
1019
|
+
values=values,
|
|
1020
|
+
status=cast(Any, status),
|
|
1021
|
+
limit=limit,
|
|
1022
|
+
offset=offset,
|
|
1023
|
+
),
|
|
1024
|
+
)
|
|
1025
|
+
q = sa_select(ThreadRow)
|
|
1026
|
+
if ids:
|
|
1027
|
+
id_set = [_ensure_uuid(i) for i in ids]
|
|
1028
|
+
q = q.where(ThreadRow.thread_id.in_(id_set))
|
|
1029
|
+
if metadata:
|
|
1030
|
+
q = q.where(ThreadRow.metadata_.contains(metadata))
|
|
1031
|
+
if values:
|
|
1032
|
+
q = q.where(ThreadRow.values_.contains(values))
|
|
1033
|
+
if status:
|
|
1034
|
+
q = q.where(ThreadRow.status == status)
|
|
1035
|
+
plain = _plain_metadata_filter(filters)
|
|
1036
|
+
if plain:
|
|
1037
|
+
q = q.where(ThreadRow.metadata_.contains(plain))
|
|
1038
|
+
elif filters:
|
|
1039
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
1040
|
+
rows = [r for r in rows if _check_filter_match(r.metadata_ or {}, filters)]
|
|
1041
|
+
sb = _resolve_sort_field(sort_by, _THREAD_SORT_FIELDS, "updated_at")
|
|
1042
|
+
reverse = not (sort_order and sort_order.upper() == "ASC")
|
|
1043
|
+
rows.sort(key=lambda r: _row_sort_key(r, sb), reverse=reverse)
|
|
1044
|
+
page = rows[offset : offset + limit]
|
|
1045
|
+
cursor = offset + limit if len(rows) > offset + limit else None
|
|
1046
|
+
# Materialize while session is open — API paginates outside connect().
|
|
1047
|
+
items = [_thread_search_item(r, select=select, extract=extract) for r in page]
|
|
1048
|
+
|
|
1049
|
+
async def _iter_filtered():
|
|
1050
|
+
for d in items:
|
|
1051
|
+
yield d
|
|
1052
|
+
|
|
1053
|
+
return _iter_filtered(), cursor
|
|
1054
|
+
|
|
1055
|
+
sb = _resolve_sort_field(sort_by, _THREAD_SORT_FIELDS, "updated_at")
|
|
1056
|
+
col = getattr(ThreadRow, sb)
|
|
1057
|
+
reverse = not (sort_order and sort_order.upper() == "ASC")
|
|
1058
|
+
q = q.order_by(col.desc() if reverse else col.asc())
|
|
1059
|
+
q = q.offset(offset).limit(limit + 1)
|
|
1060
|
+
|
|
1061
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
1062
|
+
cursor = offset + limit if len(rows) > limit else None
|
|
1063
|
+
page = rows[:limit]
|
|
1064
|
+
# Materialize while session is open — API paginates outside connect().
|
|
1065
|
+
items = [_thread_search_item(r, select=select, extract=extract) for r in page]
|
|
1066
|
+
|
|
1067
|
+
async def _iter():
|
|
1068
|
+
for d in items:
|
|
1069
|
+
yield d
|
|
1070
|
+
|
|
1071
|
+
return _iter(), cursor
|
|
1072
|
+
|
|
1073
|
+
@staticmethod
|
|
1074
|
+
async def get(
|
|
1075
|
+
conn: PgConnectionProto,
|
|
1076
|
+
thread_id: UUID | str,
|
|
1077
|
+
ctx: Any = None,
|
|
1078
|
+
include_ttl: bool = False,
|
|
1079
|
+
read_mask_paths: list | None = None,
|
|
1080
|
+
) -> AsyncIterator:
|
|
1081
|
+
from langgraph_sdk import Auth
|
|
1082
|
+
|
|
1083
|
+
thread_id = _ensure_uuid(thread_id)
|
|
1084
|
+
filters = await Threads.handle_event(
|
|
1085
|
+
ctx, "read", Auth.types.ThreadsRead(thread_id=thread_id)
|
|
1086
|
+
)
|
|
1087
|
+
row = await _aget_thread(conn.session, thread_id)
|
|
1088
|
+
if not row or (filters and not _check_filter_match(row.metadata_ or {}, filters)):
|
|
1089
|
+
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
|
1090
|
+
d = thread_to_dict(row)
|
|
1091
|
+
d.setdefault("state_updated_at", d.get("updated_at"))
|
|
1092
|
+
|
|
1093
|
+
async def _yield():
|
|
1094
|
+
yield d
|
|
1095
|
+
|
|
1096
|
+
return _yield()
|
|
1097
|
+
|
|
1098
|
+
@staticmethod
|
|
1099
|
+
async def put(
|
|
1100
|
+
conn: PgConnectionProto,
|
|
1101
|
+
thread_id: UUID | str,
|
|
1102
|
+
*,
|
|
1103
|
+
metadata: dict | None = None,
|
|
1104
|
+
if_exists: str = "raise",
|
|
1105
|
+
ttl: Any = None,
|
|
1106
|
+
ctx: Any = None,
|
|
1107
|
+
) -> AsyncIterator:
|
|
1108
|
+
from langgraph_sdk import Auth
|
|
1109
|
+
|
|
1110
|
+
thread_id = _ensure_uuid(thread_id)
|
|
1111
|
+
metadata = metadata or {}
|
|
1112
|
+
filters = await Threads.handle_event(
|
|
1113
|
+
ctx,
|
|
1114
|
+
"create",
|
|
1115
|
+
Auth.types.ThreadsCreate(
|
|
1116
|
+
thread_id=thread_id,
|
|
1117
|
+
metadata=metadata,
|
|
1118
|
+
if_exists=cast(Any, if_exists),
|
|
1119
|
+
),
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
existing = await _aget_thread(conn.session, thread_id)
|
|
1123
|
+
if existing:
|
|
1124
|
+
if filters and not _check_filter_match(existing.metadata_ or {}, filters):
|
|
1125
|
+
raise HTTPException(status_code=409, detail=f"Thread {thread_id} already exists")
|
|
1126
|
+
if if_exists == "raise":
|
|
1127
|
+
raise HTTPException(status_code=409, detail=f"Thread {thread_id} already exists")
|
|
1128
|
+
if if_exists == "do_nothing":
|
|
1129
|
+
data = thread_to_dict(existing)
|
|
1130
|
+
|
|
1131
|
+
async def _yield_existing():
|
|
1132
|
+
yield data
|
|
1133
|
+
|
|
1134
|
+
return _yield_existing()
|
|
1135
|
+
|
|
1136
|
+
now = datetime.now(UTC)
|
|
1137
|
+
# ON CONFLICT so concurrent replica creates of the same id are safe.
|
|
1138
|
+
ins = (
|
|
1139
|
+
pg_insert(ThreadRow)
|
|
1140
|
+
.values(
|
|
1141
|
+
thread_id=thread_id,
|
|
1142
|
+
created_at=now,
|
|
1143
|
+
updated_at=now,
|
|
1144
|
+
state_updated_at=now,
|
|
1145
|
+
metadata_=copy.deepcopy(metadata),
|
|
1146
|
+
status="idle",
|
|
1147
|
+
config={},
|
|
1148
|
+
values_=None,
|
|
1149
|
+
interrupts={},
|
|
1150
|
+
)
|
|
1151
|
+
.on_conflict_do_nothing(index_elements=["thread_id"])
|
|
1152
|
+
)
|
|
1153
|
+
result = await conn.session.execute(ins)
|
|
1154
|
+
if result.rowcount == 0:
|
|
1155
|
+
existing = await _aget_thread(conn.session, thread_id)
|
|
1156
|
+
# Re-check auth: another replica may have inserted first.
|
|
1157
|
+
if existing is None or (
|
|
1158
|
+
filters and not _check_filter_match(existing.metadata_ or {}, filters)
|
|
1159
|
+
):
|
|
1160
|
+
raise HTTPException(status_code=409, detail=f"Thread {thread_id} already exists")
|
|
1161
|
+
if if_exists == "raise":
|
|
1162
|
+
raise HTTPException(status_code=409, detail=f"Thread {thread_id} already exists")
|
|
1163
|
+
|
|
1164
|
+
data = thread_to_dict(existing)
|
|
1165
|
+
|
|
1166
|
+
async def _yield_raced():
|
|
1167
|
+
yield data
|
|
1168
|
+
|
|
1169
|
+
return _yield_raced()
|
|
1170
|
+
|
|
1171
|
+
await conn.session.flush()
|
|
1172
|
+
row = await _aget_thread(conn.session, thread_id)
|
|
1173
|
+
data = thread_to_dict(row)
|
|
1174
|
+
|
|
1175
|
+
async def _yield():
|
|
1176
|
+
yield data
|
|
1177
|
+
|
|
1178
|
+
return _yield()
|
|
1179
|
+
|
|
1180
|
+
@staticmethod
|
|
1181
|
+
async def patch(
|
|
1182
|
+
conn: PgConnectionProto,
|
|
1183
|
+
thread_id: UUID | str,
|
|
1184
|
+
*,
|
|
1185
|
+
metadata: dict | None = None,
|
|
1186
|
+
ttl: Any = None,
|
|
1187
|
+
ctx: Any = None,
|
|
1188
|
+
read_mask_paths: list | None = None,
|
|
1189
|
+
) -> AsyncIterator:
|
|
1190
|
+
from langgraph_sdk import Auth
|
|
1191
|
+
|
|
1192
|
+
thread_id = _ensure_uuid(thread_id)
|
|
1193
|
+
filters = await Threads.handle_event(
|
|
1194
|
+
ctx,
|
|
1195
|
+
"update",
|
|
1196
|
+
Auth.types.ThreadsUpdate(thread_id=thread_id, metadata=cast(Any, metadata or {})),
|
|
1197
|
+
)
|
|
1198
|
+
row = await _aget_thread(conn.session, thread_id)
|
|
1199
|
+
if row is not None and (not filters or _check_filter_match(row.metadata_ or {}, filters)):
|
|
1200
|
+
if metadata:
|
|
1201
|
+
row.metadata_ = {**(row.metadata_ or {}), **metadata}
|
|
1202
|
+
row.updated_at = datetime.now(UTC)
|
|
1203
|
+
await conn.session.flush()
|
|
1204
|
+
d = thread_to_dict(row)
|
|
1205
|
+
d.setdefault("state_updated_at", d.get("updated_at"))
|
|
1206
|
+
|
|
1207
|
+
async def _yield():
|
|
1208
|
+
yield d
|
|
1209
|
+
|
|
1210
|
+
return _yield()
|
|
1211
|
+
|
|
1212
|
+
return _empty_aiter()
|
|
1213
|
+
|
|
1214
|
+
@staticmethod
|
|
1215
|
+
async def delete(
|
|
1216
|
+
conn: PgConnectionProto,
|
|
1217
|
+
thread_id: UUID | str,
|
|
1218
|
+
ctx: Any = None,
|
|
1219
|
+
) -> AsyncIterator:
|
|
1220
|
+
from langgraph_sdk import Auth
|
|
1221
|
+
|
|
1222
|
+
thread_id = _ensure_uuid(thread_id)
|
|
1223
|
+
filters = await Threads.handle_event(
|
|
1224
|
+
ctx, "delete", Auth.types.ThreadsDelete(thread_id=thread_id)
|
|
1225
|
+
)
|
|
1226
|
+
row = await _aget_thread(conn.session, thread_id)
|
|
1227
|
+
if row is None or (filters and not _check_filter_match(row.metadata_ or {}, filters)):
|
|
1228
|
+
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
|
1229
|
+
|
|
1230
|
+
run_ids = list(
|
|
1231
|
+
(
|
|
1232
|
+
await conn.session.execute(
|
|
1233
|
+
sa_select(RunRow.run_id).where(RunRow.thread_id == thread_id)
|
|
1234
|
+
)
|
|
1235
|
+
)
|
|
1236
|
+
.scalars()
|
|
1237
|
+
.all()
|
|
1238
|
+
)
|
|
1239
|
+
await _adelete_retry_counters(conn.session, run_ids)
|
|
1240
|
+
await conn.session.execute(delete(RunRow).where(RunRow.thread_id == thread_id))
|
|
1241
|
+
await conn.session.execute(delete(CronRow).where(CronRow.thread_id == thread_id))
|
|
1242
|
+
await conn.session.delete(row)
|
|
1243
|
+
await conn.session.flush()
|
|
1244
|
+
# Checkpointer is a separate autocommit pool — delete after ops commit so we never roll back the thread after checkpoints are gone.
|
|
1245
|
+
deleted_id = thread_id
|
|
1246
|
+
|
|
1247
|
+
async def _cleanup_checkpoints() -> None:
|
|
1248
|
+
try:
|
|
1249
|
+
await _adelete_thread_checkpoints(deleted_id)
|
|
1250
|
+
except Exception:
|
|
1251
|
+
logger.exception(
|
|
1252
|
+
"Failed to delete checkpoints for thread %s",
|
|
1253
|
+
deleted_id,
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
conn.schedule_after_commit(_cleanup_checkpoints)
|
|
1257
|
+
|
|
1258
|
+
async def _yield():
|
|
1259
|
+
yield deleted_id
|
|
1260
|
+
|
|
1261
|
+
return _yield()
|
|
1262
|
+
|
|
1263
|
+
@staticmethod
|
|
1264
|
+
async def set_joint_status(
|
|
1265
|
+
conn: PgConnectionProto,
|
|
1266
|
+
thread_id: UUID,
|
|
1267
|
+
run_id: UUID,
|
|
1268
|
+
run_status: str,
|
|
1269
|
+
graph_id: str,
|
|
1270
|
+
checkpoint: dict | None = None,
|
|
1271
|
+
exception: BaseException | None = None,
|
|
1272
|
+
) -> None:
|
|
1273
|
+
"""Atomically update run + thread status; uses a fresh session (worker may close outer conn)."""
|
|
1274
|
+
from langgraph_api.serde import json_dumpb, json_loads
|
|
1275
|
+
|
|
1276
|
+
thread_id = _ensure_uuid(thread_id)
|
|
1277
|
+
run_id = _ensure_uuid(run_id)
|
|
1278
|
+
|
|
1279
|
+
base_thread_status, interrupts = _thread_status_from_checkpoint(
|
|
1280
|
+
checkpoint, exception, ignore_user_control=True
|
|
1281
|
+
)
|
|
1282
|
+
now = datetime.now(UTC)
|
|
1283
|
+
values = (
|
|
1284
|
+
json_loads(json_dumpb(checkpoint.get("values"))) if checkpoint is not None else None
|
|
1285
|
+
)
|
|
1286
|
+
error = json_loads(json_dumpb(exception)) if exception else None
|
|
1287
|
+
|
|
1288
|
+
sf = get_session_factory()
|
|
1289
|
+
async with sf() as session:
|
|
1290
|
+
thread = await _aget_thread(session, thread_id)
|
|
1291
|
+
if thread is None:
|
|
1292
|
+
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
|
1293
|
+
run = await _aget_run(session, run_id)
|
|
1294
|
+
if run is None or run.thread_id != thread_id:
|
|
1295
|
+
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
1296
|
+
|
|
1297
|
+
if run_status == "rollback":
|
|
1298
|
+
await _adelete_retry_counters(session, [run_id])
|
|
1299
|
+
await session.delete(run)
|
|
1300
|
+
await session.flush()
|
|
1301
|
+
else:
|
|
1302
|
+
run.status = run_status
|
|
1303
|
+
run.updated_at = now
|
|
1304
|
+
await session.flush()
|
|
1305
|
+
|
|
1306
|
+
pending = int(
|
|
1307
|
+
await session.scalar(
|
|
1308
|
+
sa_select(func.count())
|
|
1309
|
+
.select_from(RunRow)
|
|
1310
|
+
.where(
|
|
1311
|
+
RunRow.thread_id == thread_id,
|
|
1312
|
+
RunRow.status.in_(("pending", "running")),
|
|
1313
|
+
)
|
|
1314
|
+
)
|
|
1315
|
+
or 0
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
meta = dict(thread.metadata_ or {})
|
|
1319
|
+
meta["graph_id"] = graph_id
|
|
1320
|
+
thread.metadata_ = meta
|
|
1321
|
+
thread.status = "busy" if pending else base_thread_status
|
|
1322
|
+
thread.updated_at = now
|
|
1323
|
+
thread.state_updated_at = now
|
|
1324
|
+
thread.interrupts = interrupts
|
|
1325
|
+
thread.error = error
|
|
1326
|
+
# Always write values (including None) when a checkpoint is present so stale values cannot linger.
|
|
1327
|
+
if checkpoint is not None:
|
|
1328
|
+
thread.values_ = values
|
|
1329
|
+
await session.commit()
|
|
1330
|
+
|
|
1331
|
+
@staticmethod
|
|
1332
|
+
async def set_status(
|
|
1333
|
+
conn: PgConnectionProto,
|
|
1334
|
+
thread_id: UUID | str,
|
|
1335
|
+
checkpoint: dict | None,
|
|
1336
|
+
exception: BaseException | None,
|
|
1337
|
+
) -> None:
|
|
1338
|
+
from langgraph_api.serde import json_dumpb, json_loads
|
|
1339
|
+
|
|
1340
|
+
thread_id = _ensure_uuid(thread_id)
|
|
1341
|
+
thread = await _aget_thread(conn.session, thread_id)
|
|
1342
|
+
if not thread:
|
|
1343
|
+
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
|
1344
|
+
|
|
1345
|
+
status, interrupts = _thread_status_from_checkpoint(checkpoint, exception)
|
|
1346
|
+
pending = await conn.session.scalar(
|
|
1347
|
+
sa_select(func.count())
|
|
1348
|
+
.select_from(RunRow)
|
|
1349
|
+
.where(
|
|
1350
|
+
RunRow.thread_id == thread_id,
|
|
1351
|
+
RunRow.status.in_(("pending", "running")),
|
|
1352
|
+
)
|
|
1353
|
+
)
|
|
1354
|
+
if pending:
|
|
1355
|
+
status = "busy"
|
|
1356
|
+
|
|
1357
|
+
now = datetime.now(UTC)
|
|
1358
|
+
thread.updated_at = now
|
|
1359
|
+
thread.state_updated_at = now
|
|
1360
|
+
thread.status = status
|
|
1361
|
+
if checkpoint is not None:
|
|
1362
|
+
thread.values_ = json_loads(json_dumpb(checkpoint.get("values")))
|
|
1363
|
+
thread.interrupts = interrupts
|
|
1364
|
+
thread.error = json_loads(json_dumpb(exception)) if exception else None
|
|
1365
|
+
await conn.session.flush()
|
|
1366
|
+
|
|
1367
|
+
@staticmethod
|
|
1368
|
+
async def copy(
|
|
1369
|
+
conn: PgConnectionProto,
|
|
1370
|
+
thread_id: UUID | str,
|
|
1371
|
+
ctx: Any = None,
|
|
1372
|
+
) -> AsyncIterator:
|
|
1373
|
+
from langgraph_sdk import Auth
|
|
1374
|
+
|
|
1375
|
+
thread_id = _ensure_uuid(thread_id)
|
|
1376
|
+
read_filters = await Threads.handle_event(
|
|
1377
|
+
ctx, "read", Auth.types.ThreadsRead(thread_id=thread_id)
|
|
1378
|
+
)
|
|
1379
|
+
original = await _aget_thread(conn.session, thread_id)
|
|
1380
|
+
if not original or (
|
|
1381
|
+
read_filters and not _check_filter_match(original.metadata_ or {}, read_filters)
|
|
1382
|
+
):
|
|
1383
|
+
return _empty_aiter()
|
|
1384
|
+
|
|
1385
|
+
new_id = uuid4()
|
|
1386
|
+
meta = copy.deepcopy(original.metadata_ or {})
|
|
1387
|
+
await Threads.handle_event(
|
|
1388
|
+
ctx,
|
|
1389
|
+
"create",
|
|
1390
|
+
Auth.types.ThreadsCreate(thread_id=new_id, metadata=meta, if_exists="raise"),
|
|
1391
|
+
)
|
|
1392
|
+
now = datetime.now(UTC)
|
|
1393
|
+
row = ThreadRow(
|
|
1394
|
+
thread_id=new_id,
|
|
1395
|
+
created_at=now,
|
|
1396
|
+
updated_at=now,
|
|
1397
|
+
state_updated_at=now,
|
|
1398
|
+
metadata_=meta,
|
|
1399
|
+
status="idle",
|
|
1400
|
+
config=copy.deepcopy(original.config or {}),
|
|
1401
|
+
values_=copy.deepcopy(original.values_),
|
|
1402
|
+
interrupts=copy.deepcopy(original.interrupts or {}),
|
|
1403
|
+
)
|
|
1404
|
+
conn.session.add(row)
|
|
1405
|
+
await conn.session.flush()
|
|
1406
|
+
try:
|
|
1407
|
+
await _acopy_thread_checkpoints(str(thread_id), str(new_id))
|
|
1408
|
+
except Exception:
|
|
1409
|
+
# Separate autocommit pool — scrub target checkpoints if copy fails before this conn commits.
|
|
1410
|
+
try:
|
|
1411
|
+
await _adelete_thread_checkpoints(new_id)
|
|
1412
|
+
except Exception:
|
|
1413
|
+
logger.exception(
|
|
1414
|
+
"Failed to scrub checkpoints after copy failure",
|
|
1415
|
+
source=str(thread_id),
|
|
1416
|
+
target=str(new_id),
|
|
1417
|
+
)
|
|
1418
|
+
raise
|
|
1419
|
+
|
|
1420
|
+
# Checkpoints already durable; scrub target on ops txn rollback to avoid orphans.
|
|
1421
|
+
copied_target = new_id
|
|
1422
|
+
|
|
1423
|
+
async def _scrub_orphaned_checkpoints() -> None:
|
|
1424
|
+
try:
|
|
1425
|
+
await _adelete_thread_checkpoints(copied_target)
|
|
1426
|
+
except Exception:
|
|
1427
|
+
logger.exception(
|
|
1428
|
+
"Failed to scrub checkpoints after thread copy rollback",
|
|
1429
|
+
source=str(thread_id),
|
|
1430
|
+
target=str(copied_target),
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
conn.schedule_after_rollback(_scrub_orphaned_checkpoints)
|
|
1434
|
+
data = thread_to_dict(row)
|
|
1435
|
+
|
|
1436
|
+
async def _yield():
|
|
1437
|
+
yield data
|
|
1438
|
+
|
|
1439
|
+
return _yield()
|
|
1440
|
+
|
|
1441
|
+
@staticmethod
|
|
1442
|
+
async def count(
|
|
1443
|
+
conn: PgConnectionProto,
|
|
1444
|
+
*,
|
|
1445
|
+
metadata: dict | None = None,
|
|
1446
|
+
values: dict | None = None,
|
|
1447
|
+
status: str | None = None,
|
|
1448
|
+
ctx: Any = None,
|
|
1449
|
+
) -> int:
|
|
1450
|
+
from langgraph_sdk import Auth
|
|
1451
|
+
|
|
1452
|
+
metadata = metadata or {}
|
|
1453
|
+
values = values or {}
|
|
1454
|
+
filters = await Threads.handle_event(
|
|
1455
|
+
ctx,
|
|
1456
|
+
"search",
|
|
1457
|
+
Auth.types.ThreadsSearch(
|
|
1458
|
+
metadata=metadata,
|
|
1459
|
+
values=values,
|
|
1460
|
+
status=cast(Any, status),
|
|
1461
|
+
limit=0,
|
|
1462
|
+
offset=0,
|
|
1463
|
+
),
|
|
1464
|
+
)
|
|
1465
|
+
plain = _plain_metadata_filter(filters)
|
|
1466
|
+
if filters and plain is None:
|
|
1467
|
+
q = sa_select(ThreadRow)
|
|
1468
|
+
if metadata:
|
|
1469
|
+
q = q.where(ThreadRow.metadata_.contains(metadata))
|
|
1470
|
+
if values:
|
|
1471
|
+
q = q.where(ThreadRow.values_.contains(values))
|
|
1472
|
+
if status:
|
|
1473
|
+
q = q.where(ThreadRow.status == status)
|
|
1474
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
1475
|
+
return sum(1 for r in rows if _check_filter_match(r.metadata_ or {}, filters))
|
|
1476
|
+
|
|
1477
|
+
count_q = sa_select(func.count()).select_from(ThreadRow)
|
|
1478
|
+
if metadata:
|
|
1479
|
+
count_q = count_q.where(ThreadRow.metadata_.contains(metadata))
|
|
1480
|
+
if values:
|
|
1481
|
+
count_q = count_q.where(ThreadRow.values_.contains(values))
|
|
1482
|
+
if status:
|
|
1483
|
+
count_q = count_q.where(ThreadRow.status == status)
|
|
1484
|
+
if plain:
|
|
1485
|
+
count_q = count_q.where(ThreadRow.metadata_.contains(plain))
|
|
1486
|
+
return int(await conn.session.scalar(count_q) or 0)
|
|
1487
|
+
|
|
1488
|
+
@staticmethod
|
|
1489
|
+
async def prune(
|
|
1490
|
+
thread_ids: Sequence[str] | Sequence[UUID],
|
|
1491
|
+
strategy: Literal["delete", "keep_latest"] = "delete",
|
|
1492
|
+
batch_size: int = 100,
|
|
1493
|
+
ctx: Any = None,
|
|
1494
|
+
) -> int:
|
|
1495
|
+
"""Prune threads by ID. ``langgraph_api`` calls this without a conn."""
|
|
1496
|
+
del batch_size # reserved for batched deletes; unused for now
|
|
1497
|
+
if not thread_ids:
|
|
1498
|
+
return 0
|
|
1499
|
+
if strategy == "keep_latest":
|
|
1500
|
+
# AsyncPostgresSaver inherits a stub aprune that raises NotImplementedError.
|
|
1501
|
+
checkpointer = await _get_checkpointer()
|
|
1502
|
+
try:
|
|
1503
|
+
await checkpointer.aprune(list(thread_ids), strategy=strategy)
|
|
1504
|
+
except (NotImplementedError, RuntimeError) as exc:
|
|
1505
|
+
raise HTTPException(
|
|
1506
|
+
status_code=422,
|
|
1507
|
+
detail="keep_latest strategy is not supported by this checkpointer",
|
|
1508
|
+
) from exc
|
|
1509
|
+
return len(thread_ids)
|
|
1510
|
+
|
|
1511
|
+
pruned = 0
|
|
1512
|
+
async with connect() as conn:
|
|
1513
|
+
for tid in thread_ids:
|
|
1514
|
+
try:
|
|
1515
|
+
result = await Threads.delete(conn, tid, ctx=ctx)
|
|
1516
|
+
async for _ in result:
|
|
1517
|
+
pruned += 1
|
|
1518
|
+
except HTTPException:
|
|
1519
|
+
pass
|
|
1520
|
+
return pruned
|
|
1521
|
+
|
|
1522
|
+
@staticmethod
|
|
1523
|
+
async def sweep_ttl(
|
|
1524
|
+
conn: PgConnectionProto,
|
|
1525
|
+
*,
|
|
1526
|
+
limit: int | None = None,
|
|
1527
|
+
batch_size: int = 100,
|
|
1528
|
+
) -> tuple[int, int]:
|
|
1529
|
+
"""TTL sweep — no thread TTL column yet; no-op."""
|
|
1530
|
+
del conn, limit, batch_size
|
|
1531
|
+
return (0, 0)
|
|
1532
|
+
|
|
1533
|
+
class State(Authenticated):
|
|
1534
|
+
resource = "threads"
|
|
1535
|
+
|
|
1536
|
+
@staticmethod
|
|
1537
|
+
async def get(
|
|
1538
|
+
conn: PgConnectionProto,
|
|
1539
|
+
config: dict,
|
|
1540
|
+
subgraphs: bool = False,
|
|
1541
|
+
ctx: Any = None,
|
|
1542
|
+
):
|
|
1543
|
+
from langgraph_api.graph import get_graph
|
|
1544
|
+
from langgraph_api.store import get_store
|
|
1545
|
+
|
|
1546
|
+
checkpointer = await _get_checkpointer()
|
|
1547
|
+
thread_id = _ensure_uuid(config["configurable"]["thread_id"])
|
|
1548
|
+
try:
|
|
1549
|
+
thread = await anext(await Threads.get(conn, thread_id, ctx=ctx))
|
|
1550
|
+
except (HTTPException, StopAsyncIteration):
|
|
1551
|
+
return _empty_state_snapshot()
|
|
1552
|
+
|
|
1553
|
+
metadata = thread.get("metadata", {}) or {}
|
|
1554
|
+
thread_config = cast(dict[str, Any], thread.get("config") or {})
|
|
1555
|
+
thread_config = {
|
|
1556
|
+
**thread_config,
|
|
1557
|
+
"configurable": {
|
|
1558
|
+
**thread_config.get("configurable", {}),
|
|
1559
|
+
**config.get("configurable", {}),
|
|
1560
|
+
},
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
graph_id = metadata.get("graph_id")
|
|
1564
|
+
if not graph_id:
|
|
1565
|
+
result = await conn.session.execute(
|
|
1566
|
+
sa_select(RunRow)
|
|
1567
|
+
.where(RunRow.thread_id == thread_id)
|
|
1568
|
+
.order_by(RunRow.created_at.desc())
|
|
1569
|
+
.limit(1)
|
|
1570
|
+
)
|
|
1571
|
+
run = result.scalar_one_or_none()
|
|
1572
|
+
if run is not None:
|
|
1573
|
+
try:
|
|
1574
|
+
graph_id = (
|
|
1575
|
+
(run.kwargs or {})
|
|
1576
|
+
.get("config", {})
|
|
1577
|
+
.get("configurable", {})
|
|
1578
|
+
.get("graph_id")
|
|
1579
|
+
)
|
|
1580
|
+
except Exception:
|
|
1581
|
+
graph_id = None
|
|
1582
|
+
|
|
1583
|
+
if not graph_id:
|
|
1584
|
+
return _empty_state_snapshot()
|
|
1585
|
+
|
|
1586
|
+
if hasattr(checkpointer, "latest_iter"):
|
|
1587
|
+
checkpointer.latest_iter = await checkpointer.aget(config)
|
|
1588
|
+
async with get_graph(
|
|
1589
|
+
graph_id,
|
|
1590
|
+
thread_config,
|
|
1591
|
+
checkpointer=checkpointer,
|
|
1592
|
+
store=(await get_store()),
|
|
1593
|
+
access_context="threads.read",
|
|
1594
|
+
) as graph:
|
|
1595
|
+
result = await graph.aget_state(config, subgraphs=subgraphs)
|
|
1596
|
+
if (
|
|
1597
|
+
result.metadata is not None
|
|
1598
|
+
and "checkpoint_ns" in result.metadata
|
|
1599
|
+
and result.metadata["checkpoint_ns"] == ""
|
|
1600
|
+
):
|
|
1601
|
+
result.metadata.pop("checkpoint_ns")
|
|
1602
|
+
return result
|
|
1603
|
+
|
|
1604
|
+
@staticmethod
|
|
1605
|
+
async def post(
|
|
1606
|
+
conn: PgConnectionProto,
|
|
1607
|
+
config: dict,
|
|
1608
|
+
values: Any = None,
|
|
1609
|
+
as_node: str | None = None,
|
|
1610
|
+
ctx: Any = None,
|
|
1611
|
+
):
|
|
1612
|
+
from langgraph_api.graph import get_graph
|
|
1613
|
+
from langgraph_api.schema import ThreadUpdateResponse
|
|
1614
|
+
from langgraph_api.serde import json_dumpb
|
|
1615
|
+
from langgraph_api.state import (
|
|
1616
|
+
state_snapshot_to_thread_state,
|
|
1617
|
+
)
|
|
1618
|
+
from langgraph_api.store import get_store
|
|
1619
|
+
from langgraph_api.utils import fetchone
|
|
1620
|
+
from langgraph_sdk import Auth
|
|
1621
|
+
|
|
1622
|
+
thread_id = _ensure_uuid(config["configurable"]["thread_id"])
|
|
1623
|
+
filters = await Threads.handle_event(
|
|
1624
|
+
ctx,
|
|
1625
|
+
"update",
|
|
1626
|
+
Auth.types.ThreadsUpdate(thread_id=thread_id),
|
|
1627
|
+
)
|
|
1628
|
+
checkpointer = await _get_checkpointer()
|
|
1629
|
+
thread = await fetchone(
|
|
1630
|
+
await Threads.get(conn, thread_id, ctx=ctx),
|
|
1631
|
+
not_found_detail=f"Thread {thread_id} not found.",
|
|
1632
|
+
)
|
|
1633
|
+
if filters and not _check_filter_match(thread.get("metadata") or {}, filters):
|
|
1634
|
+
raise HTTPException(status_code=403, detail="Forbidden")
|
|
1635
|
+
|
|
1636
|
+
if await _thread_has_inflight_work(conn.session, thread_id):
|
|
1637
|
+
raise HTTPException(
|
|
1638
|
+
status_code=409,
|
|
1639
|
+
detail=f"Thread {thread_id} has in-flight runs",
|
|
1640
|
+
)
|
|
1641
|
+
|
|
1642
|
+
metadata = thread.get("metadata") or {}
|
|
1643
|
+
thread_config = cast(dict[str, Any], thread.get("config") or {})
|
|
1644
|
+
thread_config = {
|
|
1645
|
+
**thread_config,
|
|
1646
|
+
"configurable": {
|
|
1647
|
+
**thread_config.get("configurable", {}),
|
|
1648
|
+
**config.get("configurable", {}),
|
|
1649
|
+
},
|
|
1650
|
+
}
|
|
1651
|
+
graph_id = metadata.get("graph_id")
|
|
1652
|
+
if not graph_id:
|
|
1653
|
+
raise HTTPException(
|
|
1654
|
+
status_code=400,
|
|
1655
|
+
detail=(
|
|
1656
|
+
f"Thread '{thread_id}' has no assigned graph ID. "
|
|
1657
|
+
"Make a run first or set metadata.graph_id."
|
|
1658
|
+
),
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
config["configurable"].setdefault("graph_id", graph_id)
|
|
1662
|
+
if hasattr(checkpointer, "latest_iter"):
|
|
1663
|
+
checkpointer.latest_iter = await checkpointer.aget(config)
|
|
1664
|
+
async with get_graph(
|
|
1665
|
+
graph_id,
|
|
1666
|
+
thread_config,
|
|
1667
|
+
checkpointer=checkpointer,
|
|
1668
|
+
store=(await get_store()),
|
|
1669
|
+
access_context="threads.update",
|
|
1670
|
+
) as graph:
|
|
1671
|
+
update_config = config.copy()
|
|
1672
|
+
update_config["configurable"] = {
|
|
1673
|
+
**config["configurable"],
|
|
1674
|
+
"checkpoint_ns": config["configurable"].get("checkpoint_ns", ""),
|
|
1675
|
+
}
|
|
1676
|
+
next_config = await graph.aupdate_state(update_config, values, as_node=as_node)
|
|
1677
|
+
state = await Threads.State.get(conn, config, subgraphs=False, ctx=ctx)
|
|
1678
|
+
await Threads.set_status(
|
|
1679
|
+
conn,
|
|
1680
|
+
thread_id,
|
|
1681
|
+
{
|
|
1682
|
+
"next": list(state.next),
|
|
1683
|
+
"values": state.values,
|
|
1684
|
+
"tasks": [
|
|
1685
|
+
{"id": t.id, "interrupts": list(t.interrupts)} for t in state.tasks
|
|
1686
|
+
],
|
|
1687
|
+
},
|
|
1688
|
+
None,
|
|
1689
|
+
)
|
|
1690
|
+
await Threads.Stream.publish(
|
|
1691
|
+
thread_id,
|
|
1692
|
+
"state_update",
|
|
1693
|
+
json_dumpb(
|
|
1694
|
+
{
|
|
1695
|
+
"state": state_snapshot_to_thread_state(state),
|
|
1696
|
+
"thread_id": str(thread_id),
|
|
1697
|
+
}
|
|
1698
|
+
),
|
|
1699
|
+
)
|
|
1700
|
+
return ThreadUpdateResponse(
|
|
1701
|
+
checkpoint=next_config["configurable"],
|
|
1702
|
+
configurable=next_config["configurable"],
|
|
1703
|
+
checkpoint_id=next_config["configurable"]["checkpoint_id"],
|
|
1704
|
+
)
|
|
1705
|
+
|
|
1706
|
+
@staticmethod
|
|
1707
|
+
async def bulk(
|
|
1708
|
+
conn: PgConnectionProto,
|
|
1709
|
+
*,
|
|
1710
|
+
config: dict,
|
|
1711
|
+
supersteps: Sequence[dict],
|
|
1712
|
+
ctx: Any = None,
|
|
1713
|
+
):
|
|
1714
|
+
"""Apply a batch of state updates."""
|
|
1715
|
+
from langgraph.types import StateUpdate
|
|
1716
|
+
from langgraph_api.command import map_cmd
|
|
1717
|
+
from langgraph_api.graph import get_graph
|
|
1718
|
+
from langgraph_api.schema import ThreadUpdateResponse
|
|
1719
|
+
from langgraph_api.serde import json_dumpb
|
|
1720
|
+
from langgraph_api.state import (
|
|
1721
|
+
state_snapshot_to_thread_state,
|
|
1722
|
+
)
|
|
1723
|
+
from langgraph_api.store import get_store
|
|
1724
|
+
from langgraph_api.utils import fetchone
|
|
1725
|
+
from langgraph_sdk import Auth
|
|
1726
|
+
|
|
1727
|
+
thread_id = _ensure_uuid(config["configurable"]["thread_id"])
|
|
1728
|
+
filters = await Threads.handle_event(
|
|
1729
|
+
ctx,
|
|
1730
|
+
"update",
|
|
1731
|
+
Auth.types.ThreadsUpdate(thread_id=thread_id),
|
|
1732
|
+
)
|
|
1733
|
+
thread = await fetchone(
|
|
1734
|
+
await Threads.get(conn, thread_id, ctx=ctx),
|
|
1735
|
+
not_found_detail=f"Thread {thread_id} not found.",
|
|
1736
|
+
)
|
|
1737
|
+
if filters and not _check_filter_match(thread.get("metadata") or {}, filters):
|
|
1738
|
+
raise HTTPException(status_code=403, detail="Forbidden")
|
|
1739
|
+
|
|
1740
|
+
if await _thread_has_inflight_work(conn.session, thread_id):
|
|
1741
|
+
raise HTTPException(
|
|
1742
|
+
status_code=409,
|
|
1743
|
+
detail=f"Thread {thread_id} has in-flight runs",
|
|
1744
|
+
)
|
|
1745
|
+
|
|
1746
|
+
metadata = thread.get("metadata") or {}
|
|
1747
|
+
thread_config = cast(dict[str, Any], thread.get("config") or {})
|
|
1748
|
+
thread_config = {
|
|
1749
|
+
**thread_config,
|
|
1750
|
+
"configurable": {
|
|
1751
|
+
**thread_config.get("configurable", {}),
|
|
1752
|
+
**config.get("configurable", {}),
|
|
1753
|
+
},
|
|
1754
|
+
}
|
|
1755
|
+
graph_id = metadata.get("graph_id")
|
|
1756
|
+
if not graph_id:
|
|
1757
|
+
raise HTTPException(
|
|
1758
|
+
status_code=400,
|
|
1759
|
+
detail=(
|
|
1760
|
+
f"Thread '{thread_id}' has no assigned graph ID. "
|
|
1761
|
+
"Make a run first or set metadata.graph_id."
|
|
1762
|
+
),
|
|
1763
|
+
)
|
|
1764
|
+
|
|
1765
|
+
config["configurable"].setdefault("graph_id", graph_id)
|
|
1766
|
+
config["configurable"].setdefault("checkpoint_ns", "")
|
|
1767
|
+
checkpointer = await _get_checkpointer()
|
|
1768
|
+
async with get_graph(
|
|
1769
|
+
graph_id,
|
|
1770
|
+
thread_config,
|
|
1771
|
+
checkpointer=checkpointer,
|
|
1772
|
+
store=(await get_store()),
|
|
1773
|
+
access_context="threads.update",
|
|
1774
|
+
) as graph:
|
|
1775
|
+
next_config = await graph.abulk_update_state(
|
|
1776
|
+
config,
|
|
1777
|
+
[
|
|
1778
|
+
[
|
|
1779
|
+
StateUpdate(
|
|
1780
|
+
(
|
|
1781
|
+
map_cmd(update.get("command"))
|
|
1782
|
+
if update.get("command")
|
|
1783
|
+
else update.get("values")
|
|
1784
|
+
),
|
|
1785
|
+
update.get("as_node"),
|
|
1786
|
+
)
|
|
1787
|
+
for update in superstep.get("updates", [])
|
|
1788
|
+
]
|
|
1789
|
+
for superstep in supersteps
|
|
1790
|
+
],
|
|
1791
|
+
)
|
|
1792
|
+
state = await Threads.State.get(conn, config, subgraphs=False, ctx=ctx)
|
|
1793
|
+
await Threads.set_status(
|
|
1794
|
+
conn,
|
|
1795
|
+
thread_id,
|
|
1796
|
+
{
|
|
1797
|
+
"next": list(state.next),
|
|
1798
|
+
"values": state.values,
|
|
1799
|
+
"tasks": [
|
|
1800
|
+
{"id": t.id, "interrupts": list(t.interrupts)} for t in state.tasks
|
|
1801
|
+
],
|
|
1802
|
+
},
|
|
1803
|
+
None,
|
|
1804
|
+
)
|
|
1805
|
+
await Threads.Stream.publish(
|
|
1806
|
+
thread_id,
|
|
1807
|
+
"state_update",
|
|
1808
|
+
json_dumpb(
|
|
1809
|
+
{
|
|
1810
|
+
"state": state_snapshot_to_thread_state(state),
|
|
1811
|
+
"thread_id": str(thread_id),
|
|
1812
|
+
}
|
|
1813
|
+
),
|
|
1814
|
+
)
|
|
1815
|
+
return ThreadUpdateResponse(checkpoint=next_config["configurable"])
|
|
1816
|
+
|
|
1817
|
+
@staticmethod
|
|
1818
|
+
async def list(
|
|
1819
|
+
conn: PgConnectionProto,
|
|
1820
|
+
*,
|
|
1821
|
+
config: dict,
|
|
1822
|
+
limit: int = 1,
|
|
1823
|
+
before: Any = None,
|
|
1824
|
+
metadata: dict | None = None,
|
|
1825
|
+
ctx: Any = None,
|
|
1826
|
+
) -> list:
|
|
1827
|
+
from langgraph_api.graph import get_graph
|
|
1828
|
+
from langgraph_api.store import get_store
|
|
1829
|
+
from langgraph_api.utils import fetchone
|
|
1830
|
+
|
|
1831
|
+
thread_id = _ensure_uuid(config["configurable"]["thread_id"])
|
|
1832
|
+
thread = await fetchone(await Threads.get(conn, thread_id, ctx=ctx))
|
|
1833
|
+
thread_metadata = thread.get("metadata") or {}
|
|
1834
|
+
thread_config = cast(dict[str, Any], thread.get("config") or {})
|
|
1835
|
+
thread_config = {
|
|
1836
|
+
**thread_config,
|
|
1837
|
+
"configurable": {
|
|
1838
|
+
**thread_config.get("configurable", {}),
|
|
1839
|
+
**config.get("configurable", {}),
|
|
1840
|
+
},
|
|
1841
|
+
}
|
|
1842
|
+
graph_id = thread_metadata.get("graph_id")
|
|
1843
|
+
if not graph_id:
|
|
1844
|
+
return []
|
|
1845
|
+
|
|
1846
|
+
checkpointer = await _get_checkpointer()
|
|
1847
|
+
async with get_graph(
|
|
1848
|
+
graph_id,
|
|
1849
|
+
thread_config,
|
|
1850
|
+
checkpointer=checkpointer,
|
|
1851
|
+
store=(await get_store()),
|
|
1852
|
+
access_context="threads.read",
|
|
1853
|
+
) as graph:
|
|
1854
|
+
before_param = (
|
|
1855
|
+
{"configurable": {"checkpoint_id": before}}
|
|
1856
|
+
if isinstance(before, str)
|
|
1857
|
+
else before
|
|
1858
|
+
)
|
|
1859
|
+
return [
|
|
1860
|
+
state
|
|
1861
|
+
async for state in graph.aget_state_history(
|
|
1862
|
+
config, limit=limit, filter=metadata, before=before_param
|
|
1863
|
+
)
|
|
1864
|
+
]
|
|
1865
|
+
|
|
1866
|
+
class Stream(Authenticated):
|
|
1867
|
+
resource = "threads"
|
|
1868
|
+
|
|
1869
|
+
@staticmethod
|
|
1870
|
+
async def subscribe(
|
|
1871
|
+
conn: PgConnectionProto,
|
|
1872
|
+
thread_id: UUID,
|
|
1873
|
+
seen_runs: set[UUID],
|
|
1874
|
+
*,
|
|
1875
|
+
after_id: str | None = None,
|
|
1876
|
+
replay: bool = True,
|
|
1877
|
+
):
|
|
1878
|
+
sm = get_stream_manager()
|
|
1879
|
+
queues = []
|
|
1880
|
+
thread_id = _ensure_uuid(thread_id)
|
|
1881
|
+
if thread_id not in seen_runs:
|
|
1882
|
+
q = await sm.add_thread_stream(thread_id)
|
|
1883
|
+
queues.append((thread_id, q))
|
|
1884
|
+
seen_runs.add(thread_id)
|
|
1885
|
+
result = await conn.session.execute(
|
|
1886
|
+
sa_select(RunRow).where(RunRow.thread_id == thread_id)
|
|
1887
|
+
)
|
|
1888
|
+
for run in result.scalars():
|
|
1889
|
+
rid = run.run_id
|
|
1890
|
+
if rid not in seen_runs:
|
|
1891
|
+
q = await sm.add_queue(rid, thread_id, after_id=after_id, replay=replay)
|
|
1892
|
+
queues.append((rid, q))
|
|
1893
|
+
seen_runs.add(rid)
|
|
1894
|
+
return queues
|
|
1895
|
+
|
|
1896
|
+
@staticmethod
|
|
1897
|
+
async def join(
|
|
1898
|
+
thread_id: UUID,
|
|
1899
|
+
*,
|
|
1900
|
+
last_event_id: str | None = None,
|
|
1901
|
+
stream_modes: list | None = None,
|
|
1902
|
+
ctx: Any = None,
|
|
1903
|
+
) -> AsyncIterator[tuple[bytes, bytes, bytes | None]]:
|
|
1904
|
+
async for event, payload, stream_id, _run_id in Threads.Stream.join_event_streaming(
|
|
1905
|
+
thread_id,
|
|
1906
|
+
last_event_id=last_event_id,
|
|
1907
|
+
stream_modes=stream_modes or [],
|
|
1908
|
+
ctx=ctx,
|
|
1909
|
+
):
|
|
1910
|
+
yield event, payload, stream_id
|
|
1911
|
+
|
|
1912
|
+
@staticmethod
|
|
1913
|
+
async def join_event_streaming(
|
|
1914
|
+
thread_id: UUID,
|
|
1915
|
+
*,
|
|
1916
|
+
last_event_id: str | None = None,
|
|
1917
|
+
stream_modes: list | None = None,
|
|
1918
|
+
ctx: Any = None,
|
|
1919
|
+
) -> AsyncIterator[tuple[bytes, bytes, bytes | None, str | None]]:
|
|
1920
|
+
"""Stream thread output (Protocol v3)."""
|
|
1921
|
+
await Threads.Stream.check_thread_stream_auth(thread_id, ctx)
|
|
1922
|
+
from langgraph_api.utils.stream_codec import (
|
|
1923
|
+
decode_stream_message,
|
|
1924
|
+
)
|
|
1925
|
+
|
|
1926
|
+
stream_modes = list(stream_modes or [])
|
|
1927
|
+
|
|
1928
|
+
def should_filter_event(event_name: str, message_bytes: bytes) -> bool:
|
|
1929
|
+
if "run_modes" in stream_modes and event_name != "state_update":
|
|
1930
|
+
return False
|
|
1931
|
+
if "state_update" in stream_modes and event_name == "state_update":
|
|
1932
|
+
return False
|
|
1933
|
+
if "lifecycle" in stream_modes and event_name == "metadata":
|
|
1934
|
+
try:
|
|
1935
|
+
message_data = orjson.loads(message_bytes)
|
|
1936
|
+
if message_data.get("status") == "run_done":
|
|
1937
|
+
return False
|
|
1938
|
+
if "attempt" in message_data and "run_id" in message_data:
|
|
1939
|
+
return False
|
|
1940
|
+
except (orjson.JSONDecodeError, TypeError):
|
|
1941
|
+
pass
|
|
1942
|
+
return True
|
|
1943
|
+
|
|
1944
|
+
stream_manager = get_stream_manager()
|
|
1945
|
+
seen_runs: set[UUID] = set()
|
|
1946
|
+
created_queues: list[tuple[UUID, asyncio.Queue]] = []
|
|
1947
|
+
thread_id = _ensure_uuid(thread_id)
|
|
1948
|
+
# "-" restores from Redis Streams; local replay is empty on a cold replica.
|
|
1949
|
+
from_beginning = last_event_id in ("-", "")
|
|
1950
|
+
resume_cursor = None if last_event_id is None or from_beginning else last_event_id
|
|
1951
|
+
restore_cursor = "-" if from_beginning else resume_cursor
|
|
1952
|
+
emitted_ids: set[str] = set()
|
|
1953
|
+
|
|
1954
|
+
def _accept(message: Message) -> bool:
|
|
1955
|
+
"""True if this frame should be emitted (cursor + dedup)."""
|
|
1956
|
+
if not message.id:
|
|
1957
|
+
return True
|
|
1958
|
+
mid = message.id.decode() if isinstance(message.id, bytes) else str(message.id)
|
|
1959
|
+
if mid in emitted_ids:
|
|
1960
|
+
return False
|
|
1961
|
+
if resume_cursor is not None and not ms_seq_id_gt(mid, resume_cursor):
|
|
1962
|
+
return False
|
|
1963
|
+
emitted_ids.add(mid)
|
|
1964
|
+
return True
|
|
1965
|
+
|
|
1966
|
+
try:
|
|
1967
|
+
await logger.ainfo("Joined thread stream", thread_id=str(thread_id))
|
|
1968
|
+
|
|
1969
|
+
# Dedup restore vs later add_queue replay via emitted_ids.
|
|
1970
|
+
if restore_cursor is not None:
|
|
1971
|
+
store_key = thread_id
|
|
1972
|
+
all_events = []
|
|
1973
|
+
run_ids: set[UUID] = set(
|
|
1974
|
+
stream_manager.message_stores.get(store_key, {}).keys()
|
|
1975
|
+
)
|
|
1976
|
+
async with connect() as conn:
|
|
1977
|
+
result = await conn.session.execute(
|
|
1978
|
+
sa_select(RunRow.run_id).where(RunRow.thread_id == thread_id)
|
|
1979
|
+
)
|
|
1980
|
+
run_ids.update(row[0] for row in result.all())
|
|
1981
|
+
for run_id in run_ids:
|
|
1982
|
+
for message in await stream_manager.restore_messages_async(
|
|
1983
|
+
run_id, thread_id, restore_cursor
|
|
1984
|
+
):
|
|
1985
|
+
all_events.append((message, run_id))
|
|
1986
|
+
all_events.sort(key=lambda x: ms_seq_id_sort_key((x[0].id or b"").decode()))
|
|
1987
|
+
for message, run_id in all_events:
|
|
1988
|
+
if not _accept(message):
|
|
1989
|
+
continue
|
|
1990
|
+
try:
|
|
1991
|
+
decoded = decode_stream_message(message.data, channel=message.topic)
|
|
1992
|
+
except (ValueError, KeyError):
|
|
1993
|
+
continue
|
|
1994
|
+
event_bytes = decoded.event_bytes
|
|
1995
|
+
message_bytes = decoded.message_bytes
|
|
1996
|
+
if event_bytes == b"control" and message_bytes == b"done":
|
|
1997
|
+
event_bytes = b"metadata"
|
|
1998
|
+
message_bytes = orjson.dumps(
|
|
1999
|
+
{"status": "run_done", "run_id": str(run_id)}
|
|
2000
|
+
)
|
|
2001
|
+
if not should_filter_event(event_bytes.decode("utf-8"), message_bytes):
|
|
2002
|
+
yield (
|
|
2003
|
+
event_bytes,
|
|
2004
|
+
message_bytes,
|
|
2005
|
+
message.id,
|
|
2006
|
+
str(run_id),
|
|
2007
|
+
)
|
|
2008
|
+
|
|
2009
|
+
# Do not open connect() every drain tick — dual SSE connections exhaust the pool.
|
|
2010
|
+
last_subscribe_mono = 0.0
|
|
2011
|
+
subscribe_interval = 0.15
|
|
2012
|
+
while True:
|
|
2013
|
+
now_mono = time.monotonic()
|
|
2014
|
+
if now_mono - last_subscribe_mono >= subscribe_interval or not created_queues:
|
|
2015
|
+
async with connect() as conn:
|
|
2016
|
+
new_queue_tuples = await Threads.Stream.subscribe(
|
|
2017
|
+
conn,
|
|
2018
|
+
thread_id,
|
|
2019
|
+
seen_runs,
|
|
2020
|
+
after_id=resume_cursor,
|
|
2021
|
+
)
|
|
2022
|
+
for run_id, queue in new_queue_tuples:
|
|
2023
|
+
created_queues.append((run_id, queue))
|
|
2024
|
+
last_subscribe_mono = now_mono
|
|
2025
|
+
|
|
2026
|
+
drained_any = False
|
|
2027
|
+
for run_id, queue in created_queues:
|
|
2028
|
+
while True:
|
|
2029
|
+
try:
|
|
2030
|
+
message = queue.get_nowait()
|
|
2031
|
+
except asyncio.QueueEmpty:
|
|
2032
|
+
break
|
|
2033
|
+
if not _accept(message):
|
|
2034
|
+
continue
|
|
2035
|
+
try:
|
|
2036
|
+
decoded = decode_stream_message(message.data, channel=message.topic)
|
|
2037
|
+
except (ValueError, KeyError):
|
|
2038
|
+
continue
|
|
2039
|
+
event = decoded.event_bytes
|
|
2040
|
+
event_name = event.decode("utf-8")
|
|
2041
|
+
payload = decoded.message_bytes
|
|
2042
|
+
|
|
2043
|
+
if event == b"control" and payload == b"done":
|
|
2044
|
+
topic = (
|
|
2045
|
+
message.topic.decode()
|
|
2046
|
+
if isinstance(message.topic, bytes)
|
|
2047
|
+
else message.topic
|
|
2048
|
+
)
|
|
2049
|
+
done_run_id = topic.split("run:")[1].split(":")[0]
|
|
2050
|
+
meta_event = b"metadata"
|
|
2051
|
+
meta_payload = orjson.dumps(
|
|
2052
|
+
{"status": "run_done", "run_id": done_run_id}
|
|
2053
|
+
)
|
|
2054
|
+
if not should_filter_event("metadata", meta_payload):
|
|
2055
|
+
yield (
|
|
2056
|
+
meta_event,
|
|
2057
|
+
meta_payload,
|
|
2058
|
+
message.id,
|
|
2059
|
+
done_run_id,
|
|
2060
|
+
)
|
|
2061
|
+
drained_any = True
|
|
2062
|
+
elif not should_filter_event(event_name, payload):
|
|
2063
|
+
yield (
|
|
2064
|
+
event,
|
|
2065
|
+
payload,
|
|
2066
|
+
message.id,
|
|
2067
|
+
str(run_id),
|
|
2068
|
+
)
|
|
2069
|
+
drained_any = True
|
|
2070
|
+
await asyncio.sleep(0)
|
|
2071
|
+
|
|
2072
|
+
if drained_any:
|
|
2073
|
+
await asyncio.sleep(0)
|
|
2074
|
+
else:
|
|
2075
|
+
await asyncio.sleep(0.02)
|
|
2076
|
+
except WrappedHTTPException as e:
|
|
2077
|
+
raise e.http_exception from None
|
|
2078
|
+
except asyncio.CancelledError:
|
|
2079
|
+
await logger.awarning(
|
|
2080
|
+
"Thread stream client disconnected",
|
|
2081
|
+
thread_id=str(thread_id),
|
|
2082
|
+
)
|
|
2083
|
+
raise
|
|
2084
|
+
finally:
|
|
2085
|
+
for key, queue in created_queues:
|
|
2086
|
+
try:
|
|
2087
|
+
if key == thread_id:
|
|
2088
|
+
await stream_manager.remove_thread_stream(thread_id, queue)
|
|
2089
|
+
else:
|
|
2090
|
+
await stream_manager.remove_queue(key, thread_id, queue)
|
|
2091
|
+
except Exception:
|
|
2092
|
+
pass
|
|
2093
|
+
|
|
2094
|
+
@staticmethod
|
|
2095
|
+
async def publish(thread_id: UUID | str, event: str, message: bytes) -> None:
|
|
2096
|
+
from langgraph_api.utils.stream_codec import STREAM_CODEC
|
|
2097
|
+
|
|
2098
|
+
topic = f"thread:{thread_id}:stream".encode()
|
|
2099
|
+
sm = get_stream_manager()
|
|
2100
|
+
payload = STREAM_CODEC.encode(event, message)
|
|
2101
|
+
await sm.put_thread(_ensure_uuid(thread_id), Message(topic=topic, data=payload))
|
|
2102
|
+
|
|
2103
|
+
@staticmethod
|
|
2104
|
+
async def check_thread_stream_auth(
|
|
2105
|
+
thread_id: UUID,
|
|
2106
|
+
ctx: Any = None,
|
|
2107
|
+
) -> None:
|
|
2108
|
+
from langgraph_sdk import Auth
|
|
2109
|
+
|
|
2110
|
+
async with connect() as conn:
|
|
2111
|
+
filters = await Threads.Stream.handle_event(
|
|
2112
|
+
ctx,
|
|
2113
|
+
"read",
|
|
2114
|
+
Auth.types.ThreadsRead(thread_id=thread_id),
|
|
2115
|
+
)
|
|
2116
|
+
if filters:
|
|
2117
|
+
try:
|
|
2118
|
+
thread = await anext(await Threads.get(conn, thread_id, ctx=ctx))
|
|
2119
|
+
except (HTTPException, StopAsyncIteration):
|
|
2120
|
+
thread = None
|
|
2121
|
+
if not thread or not _check_filter_match(thread.get("metadata") or {}, filters):
|
|
2122
|
+
raise HTTPException(status_code=404, detail="Thread not found")
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
class Runs(Authenticated):
|
|
2126
|
+
resource = "threads"
|
|
2127
|
+
|
|
2128
|
+
@staticmethod
|
|
2129
|
+
async def stats(conn: PgConnectionProto) -> dict:
|
|
2130
|
+
pending = int(
|
|
2131
|
+
await conn.session.scalar(
|
|
2132
|
+
sa_select(func.count()).select_from(RunRow).where(RunRow.status == "pending")
|
|
2133
|
+
)
|
|
2134
|
+
or 0
|
|
2135
|
+
)
|
|
2136
|
+
running = int(
|
|
2137
|
+
await conn.session.scalar(
|
|
2138
|
+
sa_select(func.count()).select_from(RunRow).where(RunRow.status == "running")
|
|
2139
|
+
)
|
|
2140
|
+
or 0
|
|
2141
|
+
)
|
|
2142
|
+
return {
|
|
2143
|
+
"n_pending": pending,
|
|
2144
|
+
"n_running": running,
|
|
2145
|
+
"pending_runs_wait_time_max_secs": None,
|
|
2146
|
+
"pending_runs_wait_time_med_secs": None,
|
|
2147
|
+
"pending_unblocked_runs_wait_time_max_secs": None,
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
@staticmethod
|
|
2151
|
+
async def pool_stats() -> dict:
|
|
2152
|
+
"""Connection-pool stats for /info and self-hosted metrics."""
|
|
2153
|
+
return {}
|
|
2154
|
+
|
|
2155
|
+
@staticmethod
|
|
2156
|
+
async def put(
|
|
2157
|
+
conn: PgConnectionProto,
|
|
2158
|
+
assistant_id: UUID | str,
|
|
2159
|
+
kwargs: dict,
|
|
2160
|
+
*,
|
|
2161
|
+
thread_id: UUID | None = None,
|
|
2162
|
+
user_id: str | None = None,
|
|
2163
|
+
run_id: UUID | None = None,
|
|
2164
|
+
status: str | None = "pending",
|
|
2165
|
+
metadata: dict | None = None,
|
|
2166
|
+
prevent_insert_if_inflight: bool = False,
|
|
2167
|
+
multitask_strategy: str = "reject",
|
|
2168
|
+
if_not_exists: str = "reject",
|
|
2169
|
+
after_seconds: int = 0,
|
|
2170
|
+
ctx: Any = None,
|
|
2171
|
+
langsmith_session_name: str | None = None,
|
|
2172
|
+
**_: Any,
|
|
2173
|
+
) -> AsyncIterator:
|
|
2174
|
+
from langgraph_sdk import Auth
|
|
2175
|
+
|
|
2176
|
+
assistant_id = _ensure_uuid(assistant_id)
|
|
2177
|
+
thread_id = _ensure_uuid(thread_id) if thread_id else None
|
|
2178
|
+
run_id = _ensure_uuid(run_id) if run_id else uuid4()
|
|
2179
|
+
metadata = metadata or {}
|
|
2180
|
+
config = kwargs.get("config", {})
|
|
2181
|
+
temporary = bool(kwargs.get("temporary", False))
|
|
2182
|
+
|
|
2183
|
+
filters = await Runs.handle_event(
|
|
2184
|
+
ctx,
|
|
2185
|
+
"create_run",
|
|
2186
|
+
Auth.types.RunsCreate(
|
|
2187
|
+
thread_id=None if temporary else thread_id,
|
|
2188
|
+
assistant_id=assistant_id,
|
|
2189
|
+
run_id=run_id,
|
|
2190
|
+
status=cast(Any, status),
|
|
2191
|
+
metadata=metadata,
|
|
2192
|
+
prevent_insert_if_inflight=prevent_insert_if_inflight,
|
|
2193
|
+
multitask_strategy=cast(Any, multitask_strategy),
|
|
2194
|
+
if_not_exists=cast(Any, if_not_exists),
|
|
2195
|
+
after_seconds=after_seconds,
|
|
2196
|
+
kwargs=kwargs,
|
|
2197
|
+
),
|
|
2198
|
+
)
|
|
2199
|
+
|
|
2200
|
+
assistant = await _aget_assistant(conn.session, assistant_id)
|
|
2201
|
+
if not assistant:
|
|
2202
|
+
return _empty_aiter()
|
|
2203
|
+
if (assistant.metadata_ or {}).get("created_by") != "system":
|
|
2204
|
+
assistant_filters = await Assistants.handle_event(
|
|
2205
|
+
ctx, "read", Auth.types.AssistantsRead(assistant_id=assistant_id)
|
|
2206
|
+
)
|
|
2207
|
+
if _auth_denies(assistant.metadata_, assistant_filters):
|
|
2208
|
+
return _empty_aiter()
|
|
2209
|
+
|
|
2210
|
+
existing_thread = await _aget_thread(conn.session, thread_id) if thread_id else None
|
|
2211
|
+
if existing_thread and _auth_denies(existing_thread.metadata_, filters):
|
|
2212
|
+
return _empty_aiter()
|
|
2213
|
+
|
|
2214
|
+
created_thread = False
|
|
2215
|
+
if not existing_thread and (thread_id is None or if_not_exists == "create"):
|
|
2216
|
+
if thread_id is None:
|
|
2217
|
+
thread_id = uuid4()
|
|
2218
|
+
now = datetime.now(UTC)
|
|
2219
|
+
thread_meta = {
|
|
2220
|
+
"graph_id": assistant.graph_id,
|
|
2221
|
+
"assistant_id": str(assistant_id),
|
|
2222
|
+
**(config.get("metadata") or {}),
|
|
2223
|
+
**metadata,
|
|
2224
|
+
}
|
|
2225
|
+
thread_config = _merge_jsonb(
|
|
2226
|
+
assistant.config or {},
|
|
2227
|
+
config,
|
|
2228
|
+
{
|
|
2229
|
+
"configurable": _merge_jsonb(
|
|
2230
|
+
(assistant.config or {}).get("configurable") or {},
|
|
2231
|
+
)
|
|
2232
|
+
},
|
|
2233
|
+
)
|
|
2234
|
+
ins = (
|
|
2235
|
+
pg_insert(ThreadRow)
|
|
2236
|
+
.values(
|
|
2237
|
+
thread_id=thread_id,
|
|
2238
|
+
status="busy",
|
|
2239
|
+
metadata_=thread_meta,
|
|
2240
|
+
config=thread_config,
|
|
2241
|
+
created_at=now,
|
|
2242
|
+
updated_at=now,
|
|
2243
|
+
state_updated_at=now,
|
|
2244
|
+
values_=None,
|
|
2245
|
+
interrupts={},
|
|
2246
|
+
)
|
|
2247
|
+
.on_conflict_do_nothing(index_elements=["thread_id"])
|
|
2248
|
+
)
|
|
2249
|
+
result = await conn.session.execute(ins)
|
|
2250
|
+
if result.rowcount == 0:
|
|
2251
|
+
existing_thread = await _aget_thread(conn.session, thread_id)
|
|
2252
|
+
else:
|
|
2253
|
+
await conn.session.flush()
|
|
2254
|
+
existing_thread = await _aget_thread(conn.session, thread_id)
|
|
2255
|
+
created_thread = True
|
|
2256
|
+
if existing_thread is None:
|
|
2257
|
+
return _empty_aiter()
|
|
2258
|
+
elif not existing_thread:
|
|
2259
|
+
return _empty_aiter()
|
|
2260
|
+
|
|
2261
|
+
# Lock before inflight check + insert so concurrent reject/prevent_insert serialize; also promote busy after create-conflict.
|
|
2262
|
+
locked_thread = (
|
|
2263
|
+
await conn.session.execute(
|
|
2264
|
+
sa_select(ThreadRow).where(ThreadRow.thread_id == thread_id).with_for_update()
|
|
2265
|
+
)
|
|
2266
|
+
).scalar_one_or_none()
|
|
2267
|
+
if locked_thread is None:
|
|
2268
|
+
return _empty_aiter()
|
|
2269
|
+
existing_thread = locked_thread
|
|
2270
|
+
# Re-check auth after lock/conflict: another principal may own the thread.
|
|
2271
|
+
if not created_thread and _auth_denies(existing_thread.metadata_, filters):
|
|
2272
|
+
return _empty_aiter()
|
|
2273
|
+
|
|
2274
|
+
if not created_thread and existing_thread.status != "busy":
|
|
2275
|
+
existing_thread.status = "busy"
|
|
2276
|
+
existing_thread.metadata_ = _merge_jsonb(
|
|
2277
|
+
existing_thread.metadata_ or {},
|
|
2278
|
+
{
|
|
2279
|
+
"graph_id": assistant.graph_id,
|
|
2280
|
+
"assistant_id": str(assistant_id),
|
|
2281
|
+
},
|
|
2282
|
+
)
|
|
2283
|
+
# Thread configurable from assistant+thread only; caller configurable goes on run kwargs later.
|
|
2284
|
+
existing_thread.config = _merge_jsonb(
|
|
2285
|
+
assistant.config or {},
|
|
2286
|
+
existing_thread.config or {},
|
|
2287
|
+
config,
|
|
2288
|
+
{
|
|
2289
|
+
"configurable": _merge_jsonb(
|
|
2290
|
+
(assistant.config or {}).get("configurable") or {},
|
|
2291
|
+
(existing_thread.config or {}).get("configurable") or {},
|
|
2292
|
+
)
|
|
2293
|
+
},
|
|
2294
|
+
)
|
|
2295
|
+
existing_thread.updated_at = datetime.now(UTC)
|
|
2296
|
+
|
|
2297
|
+
inflight_rows = list(
|
|
2298
|
+
(
|
|
2299
|
+
await conn.session.execute(
|
|
2300
|
+
sa_select(RunRow).where(
|
|
2301
|
+
RunRow.thread_id == thread_id,
|
|
2302
|
+
RunRow.status.in_(("pending", "running")),
|
|
2303
|
+
)
|
|
2304
|
+
)
|
|
2305
|
+
).scalars()
|
|
2306
|
+
)
|
|
2307
|
+
inflight = [run_to_dict(r) for r in inflight_rows]
|
|
2308
|
+
if prevent_insert_if_inflight and inflight:
|
|
2309
|
+
|
|
2310
|
+
async def _inflight():
|
|
2311
|
+
for r in inflight:
|
|
2312
|
+
yield r
|
|
2313
|
+
|
|
2314
|
+
return _inflight()
|
|
2315
|
+
|
|
2316
|
+
# Merge (do not replace) caller configurable — dropping __event_streaming_v2 disables the messages/tools stream path.
|
|
2317
|
+
incoming_configurable = dict(config.get("configurable") or {})
|
|
2318
|
+
thread_configurable = dict(
|
|
2319
|
+
(existing_thread.config or {}).get("configurable") or {} if existing_thread else {}
|
|
2320
|
+
)
|
|
2321
|
+
assistant_configurable = dict((assistant.config or {}).get("configurable") or {})
|
|
2322
|
+
configurable = {
|
|
2323
|
+
**assistant_configurable,
|
|
2324
|
+
**thread_configurable,
|
|
2325
|
+
**incoming_configurable,
|
|
2326
|
+
"run_id": str(run_id),
|
|
2327
|
+
"thread_id": str(thread_id),
|
|
2328
|
+
"graph_id": assistant.graph_id,
|
|
2329
|
+
"assistant_id": str(assistant_id),
|
|
2330
|
+
"user_id": (
|
|
2331
|
+
incoming_configurable.get("user_id")
|
|
2332
|
+
or thread_configurable.get("user_id")
|
|
2333
|
+
or assistant_configurable.get("user_id")
|
|
2334
|
+
or user_id
|
|
2335
|
+
),
|
|
2336
|
+
}
|
|
2337
|
+
merged_metadata = {
|
|
2338
|
+
**(assistant.metadata_ or {}),
|
|
2339
|
+
**((existing_thread.metadata_ or {}) if existing_thread else {}),
|
|
2340
|
+
**(config.get("metadata") or {}),
|
|
2341
|
+
**metadata,
|
|
2342
|
+
"assistant_id": str(assistant_id),
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
now = datetime.now(UTC)
|
|
2346
|
+
effective_status = status or "pending"
|
|
2347
|
+
row = RunRow(
|
|
2348
|
+
run_id=run_id,
|
|
2349
|
+
thread_id=thread_id,
|
|
2350
|
+
assistant_id=assistant_id,
|
|
2351
|
+
metadata_=merged_metadata,
|
|
2352
|
+
status=effective_status,
|
|
2353
|
+
kwargs={
|
|
2354
|
+
**kwargs,
|
|
2355
|
+
"config": {
|
|
2356
|
+
**(assistant.config or {}),
|
|
2357
|
+
**config,
|
|
2358
|
+
"configurable": configurable,
|
|
2359
|
+
"metadata": merged_metadata,
|
|
2360
|
+
},
|
|
2361
|
+
"context": {
|
|
2362
|
+
**(assistant.context or {}),
|
|
2363
|
+
**(kwargs.get("context") or {}),
|
|
2364
|
+
},
|
|
2365
|
+
},
|
|
2366
|
+
multitask_strategy=multitask_strategy,
|
|
2367
|
+
# Postgres clock for created_at so Runs.next is immune to host/container skew.
|
|
2368
|
+
updated_at=now,
|
|
2369
|
+
)
|
|
2370
|
+
conn.session.add(row)
|
|
2371
|
+
await conn.session.flush()
|
|
2372
|
+
await conn.session.execute(
|
|
2373
|
+
text(
|
|
2374
|
+
"UPDATE runs SET created_at = now() + make_interval(secs => :secs) "
|
|
2375
|
+
"WHERE run_id = :run_id"
|
|
2376
|
+
),
|
|
2377
|
+
{"secs": int(after_seconds or 0), "run_id": run_id},
|
|
2378
|
+
)
|
|
2379
|
+
await conn.session.refresh(row)
|
|
2380
|
+
new_run = run_to_dict(row)
|
|
2381
|
+
|
|
2382
|
+
if effective_status == "pending":
|
|
2383
|
+
# Wake after connect() commits so workers cannot claim a run whose thread is still invisible.
|
|
2384
|
+
conn.schedule_after_commit(wake_run_queue)
|
|
2385
|
+
|
|
2386
|
+
async def _yield():
|
|
2387
|
+
yield new_run
|
|
2388
|
+
for r in inflight:
|
|
2389
|
+
yield r
|
|
2390
|
+
|
|
2391
|
+
return _yield()
|
|
2392
|
+
|
|
2393
|
+
@staticmethod
|
|
2394
|
+
async def get(
|
|
2395
|
+
conn: PgConnectionProto,
|
|
2396
|
+
run_id: UUID | str,
|
|
2397
|
+
*,
|
|
2398
|
+
thread_id: UUID | str,
|
|
2399
|
+
ctx: Any = None,
|
|
2400
|
+
) -> AsyncIterator:
|
|
2401
|
+
from langgraph_sdk import Auth
|
|
2402
|
+
|
|
2403
|
+
run_id = _ensure_uuid(run_id)
|
|
2404
|
+
thread_id = _ensure_uuid(thread_id)
|
|
2405
|
+
filters = await Runs.handle_event(ctx, "read", Auth.types.ThreadsRead(thread_id=thread_id))
|
|
2406
|
+
# Eager load — API may drain the iterator after connect() closes.
|
|
2407
|
+
row = await _aget_run(conn.session, run_id)
|
|
2408
|
+
data = None
|
|
2409
|
+
if row is not None and row.thread_id == thread_id:
|
|
2410
|
+
if filters:
|
|
2411
|
+
thread = await _aget_thread(conn.session, thread_id)
|
|
2412
|
+
if thread is not None and not _auth_denies(thread.metadata_, filters):
|
|
2413
|
+
data = run_to_dict(row)
|
|
2414
|
+
else:
|
|
2415
|
+
data = run_to_dict(row)
|
|
2416
|
+
|
|
2417
|
+
async def _yield():
|
|
2418
|
+
if data is not None:
|
|
2419
|
+
yield data
|
|
2420
|
+
|
|
2421
|
+
return _yield()
|
|
2422
|
+
|
|
2423
|
+
@staticmethod
|
|
2424
|
+
async def delete(
|
|
2425
|
+
conn: PgConnectionProto,
|
|
2426
|
+
run_id: UUID | str,
|
|
2427
|
+
*,
|
|
2428
|
+
thread_id: UUID | str,
|
|
2429
|
+
ctx: Any = None,
|
|
2430
|
+
) -> AsyncIterator:
|
|
2431
|
+
from langgraph_sdk import Auth
|
|
2432
|
+
|
|
2433
|
+
run_id = _ensure_uuid(run_id)
|
|
2434
|
+
thread_id = _ensure_uuid(thread_id)
|
|
2435
|
+
filters = await Runs.handle_event(
|
|
2436
|
+
ctx,
|
|
2437
|
+
"delete",
|
|
2438
|
+
Auth.types.ThreadsDelete(run_id=run_id, thread_id=thread_id),
|
|
2439
|
+
)
|
|
2440
|
+
row = await _aget_run(conn.session, run_id)
|
|
2441
|
+
if row is None or row.thread_id != thread_id:
|
|
2442
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
2443
|
+
if filters:
|
|
2444
|
+
thread = await _aget_thread(conn.session, thread_id)
|
|
2445
|
+
if thread is None or _auth_denies(thread.metadata_, filters):
|
|
2446
|
+
raise HTTPException(status_code=404, detail="Run not found")
|
|
2447
|
+
await _adelete_retry_counters(conn.session, [run_id])
|
|
2448
|
+
await conn.session.delete(row)
|
|
2449
|
+
await conn.session.flush()
|
|
2450
|
+
|
|
2451
|
+
async def _yield():
|
|
2452
|
+
yield run_id
|
|
2453
|
+
|
|
2454
|
+
return _yield()
|
|
2455
|
+
|
|
2456
|
+
@staticmethod
|
|
2457
|
+
async def cancel(
|
|
2458
|
+
conn: PgConnectionProto,
|
|
2459
|
+
run_ids: Sequence[UUID | str] | None = None,
|
|
2460
|
+
*,
|
|
2461
|
+
action: str = "interrupt",
|
|
2462
|
+
thread_id: UUID | None = None,
|
|
2463
|
+
status: str | None = None,
|
|
2464
|
+
assistant_id: UUID | None = None,
|
|
2465
|
+
ctx: Any = None,
|
|
2466
|
+
) -> None:
|
|
2467
|
+
from langgraph_sdk import Auth
|
|
2468
|
+
|
|
2469
|
+
# Mutually exclusive filter modes so callers cannot accidentally broaden a cancel.
|
|
2470
|
+
if assistant_id is not None:
|
|
2471
|
+
if thread_id is not None or run_ids is not None or status is not None:
|
|
2472
|
+
raise HTTPException(
|
|
2473
|
+
status_code=422,
|
|
2474
|
+
detail=(
|
|
2475
|
+
"Cannot specify 'thread_id', 'run_ids', or 'status' "
|
|
2476
|
+
"when using 'assistant_id'"
|
|
2477
|
+
),
|
|
2478
|
+
)
|
|
2479
|
+
assistant_id = _ensure_uuid(assistant_id)
|
|
2480
|
+
elif status is not None:
|
|
2481
|
+
if thread_id is not None or run_ids is not None:
|
|
2482
|
+
raise HTTPException(
|
|
2483
|
+
status_code=422,
|
|
2484
|
+
detail="Cannot specify 'thread_id' or 'run_ids' when using 'status'",
|
|
2485
|
+
)
|
|
2486
|
+
else:
|
|
2487
|
+
if thread_id is None or run_ids is None:
|
|
2488
|
+
raise HTTPException(
|
|
2489
|
+
status_code=422,
|
|
2490
|
+
detail=(
|
|
2491
|
+
"Must provide either a status, an assistant_id, "
|
|
2492
|
+
"or both 'thread_id' and 'run_ids'"
|
|
2493
|
+
),
|
|
2494
|
+
)
|
|
2495
|
+
|
|
2496
|
+
if run_ids is not None:
|
|
2497
|
+
run_ids = [_ensure_uuid(rid) for rid in run_ids]
|
|
2498
|
+
if thread_id is not None:
|
|
2499
|
+
thread_id = _ensure_uuid(thread_id)
|
|
2500
|
+
|
|
2501
|
+
filters = await Runs.handle_event(
|
|
2502
|
+
ctx,
|
|
2503
|
+
"update",
|
|
2504
|
+
Auth.types.ThreadsUpdate(
|
|
2505
|
+
thread_id=thread_id, # type: ignore[typeddict-item]
|
|
2506
|
+
action=cast(Any, action),
|
|
2507
|
+
metadata={"run_ids": run_ids, "status": status},
|
|
2508
|
+
),
|
|
2509
|
+
)
|
|
2510
|
+
q = sa_select(RunRow)
|
|
2511
|
+
if assistant_id is not None:
|
|
2512
|
+
q = q.where(
|
|
2513
|
+
RunRow.assistant_id == assistant_id,
|
|
2514
|
+
RunRow.status.in_(("pending", "running")),
|
|
2515
|
+
)
|
|
2516
|
+
elif status is not None:
|
|
2517
|
+
if status == "all":
|
|
2518
|
+
statuses: tuple[str, ...] = ("pending", "running")
|
|
2519
|
+
elif status in ("pending", "running"):
|
|
2520
|
+
statuses = (status,)
|
|
2521
|
+
else:
|
|
2522
|
+
raise HTTPException(
|
|
2523
|
+
status_code=422,
|
|
2524
|
+
detail="Invalid status: must be 'pending', 'running', or 'all'",
|
|
2525
|
+
)
|
|
2526
|
+
q = q.where(RunRow.status.in_(statuses))
|
|
2527
|
+
else:
|
|
2528
|
+
q = q.where(
|
|
2529
|
+
RunRow.run_id.in_(cast(Sequence[UUID], run_ids)),
|
|
2530
|
+
RunRow.thread_id == thread_id,
|
|
2531
|
+
)
|
|
2532
|
+
|
|
2533
|
+
candidates = list((await conn.session.execute(q)).scalars())
|
|
2534
|
+
if filters:
|
|
2535
|
+
allowed: list[RunRow] = []
|
|
2536
|
+
for r in candidates:
|
|
2537
|
+
if r.thread_id is None:
|
|
2538
|
+
continue
|
|
2539
|
+
thread = await _aget_thread(conn.session, r.thread_id)
|
|
2540
|
+
if thread is not None and not _auth_denies(thread.metadata_, filters):
|
|
2541
|
+
allowed.append(r)
|
|
2542
|
+
candidates = allowed
|
|
2543
|
+
if not candidates and assistant_id is None:
|
|
2544
|
+
raise HTTPException(status_code=404, detail="No runs found to cancel")
|
|
2545
|
+
|
|
2546
|
+
sm = get_stream_manager()
|
|
2547
|
+
now = datetime.now(UTC)
|
|
2548
|
+
affected_threads: set[UUID] = set()
|
|
2549
|
+
cancelled_any = False
|
|
2550
|
+
for r in candidates:
|
|
2551
|
+
# Re-lock and re-read so we cannot race Runs.next; SKIP LOCKED claimants wait/skip while we hold FOR UPDATE.
|
|
2552
|
+
locked = (
|
|
2553
|
+
await conn.session.execute(
|
|
2554
|
+
sa_select(RunRow)
|
|
2555
|
+
.where(
|
|
2556
|
+
RunRow.run_id == r.run_id,
|
|
2557
|
+
RunRow.status.in_(("pending", "running")),
|
|
2558
|
+
)
|
|
2559
|
+
.with_for_update()
|
|
2560
|
+
)
|
|
2561
|
+
).scalar_one_or_none()
|
|
2562
|
+
if locked is None:
|
|
2563
|
+
continue
|
|
2564
|
+
cancelled_any = True
|
|
2565
|
+
was_pending = locked.status == "pending"
|
|
2566
|
+
run_thread_id = locked.thread_id
|
|
2567
|
+
control = Message(
|
|
2568
|
+
topic=f"run:{locked.run_id}:control".encode(),
|
|
2569
|
+
data=action.encode(),
|
|
2570
|
+
)
|
|
2571
|
+
await sm.put(locked.run_id, run_thread_id, control)
|
|
2572
|
+
# Delete pending on rollback only with no queues; otherwise interrupt so subscribers see a terminal status.
|
|
2573
|
+
queues = sm.get_queues(locked.run_id, run_thread_id)
|
|
2574
|
+
delete_pending = action == "rollback" and was_pending and not queues
|
|
2575
|
+
if delete_pending:
|
|
2576
|
+
await _adelete_retry_counters(conn.session, [locked.run_id])
|
|
2577
|
+
await conn.session.delete(locked)
|
|
2578
|
+
else:
|
|
2579
|
+
locked.status = "interrupted"
|
|
2580
|
+
locked.updated_at = now
|
|
2581
|
+
if was_pending and not queues:
|
|
2582
|
+
# Clear control keys for pending with no subscribers; keep buffers when queues exist so clients can drain.
|
|
2583
|
+
try:
|
|
2584
|
+
await sm.clear_run_buffers(locked.run_id, run_thread_id, local_grace_secs=0.0)
|
|
2585
|
+
except Exception:
|
|
2586
|
+
logger.debug("clear_run_buffers after pending cancel failed", exc_info=True)
|
|
2587
|
+
if run_thread_id is None:
|
|
2588
|
+
continue
|
|
2589
|
+
if was_pending:
|
|
2590
|
+
affected_threads.add(run_thread_id)
|
|
2591
|
+
else:
|
|
2592
|
+
# Only idle running→interrupted when the worker heartbeat is gone; else set_joint_status races State.post.
|
|
2593
|
+
alive = await has_run_heartbeat(locked.run_id)
|
|
2594
|
+
if alive is False:
|
|
2595
|
+
affected_threads.add(run_thread_id)
|
|
2596
|
+
await conn.session.flush()
|
|
2597
|
+
|
|
2598
|
+
# Clear busy when nothing in-flight remains (pending cancels; running only if heartbeat gone).
|
|
2599
|
+
for tid in affected_threads:
|
|
2600
|
+
pending = int(
|
|
2601
|
+
await conn.session.scalar(
|
|
2602
|
+
sa_select(func.count())
|
|
2603
|
+
.select_from(RunRow)
|
|
2604
|
+
.where(
|
|
2605
|
+
RunRow.thread_id == tid,
|
|
2606
|
+
RunRow.status.in_(("pending", "running")),
|
|
2607
|
+
)
|
|
2608
|
+
)
|
|
2609
|
+
or 0
|
|
2610
|
+
)
|
|
2611
|
+
if pending:
|
|
2612
|
+
continue
|
|
2613
|
+
thread = await _aget_thread(conn.session, tid)
|
|
2614
|
+
if thread is not None and thread.status == "busy":
|
|
2615
|
+
thread.status = "idle"
|
|
2616
|
+
thread.updated_at = now
|
|
2617
|
+
await conn.session.flush()
|
|
2618
|
+
|
|
2619
|
+
# Wake even if thread stays busy so a pending multitask-interrupt run becomes claimable when heartbeat drops.
|
|
2620
|
+
if cancelled_any:
|
|
2621
|
+
try:
|
|
2622
|
+
await wake_run_queue()
|
|
2623
|
+
except Exception:
|
|
2624
|
+
logger.debug("wake_run_queue after cancel failed", exc_info=True)
|
|
2625
|
+
|
|
2626
|
+
@staticmethod
|
|
2627
|
+
async def search(
|
|
2628
|
+
conn: PgConnectionProto,
|
|
2629
|
+
thread_id: UUID | str,
|
|
2630
|
+
*,
|
|
2631
|
+
limit: int = 10,
|
|
2632
|
+
offset: int = 0,
|
|
2633
|
+
status: str | None = None,
|
|
2634
|
+
select: list | None = None,
|
|
2635
|
+
ctx: Any = None,
|
|
2636
|
+
) -> AsyncIterator:
|
|
2637
|
+
from langgraph_sdk import Auth
|
|
2638
|
+
|
|
2639
|
+
thread_id = _ensure_uuid(thread_id)
|
|
2640
|
+
filters = await Runs.handle_event(
|
|
2641
|
+
ctx,
|
|
2642
|
+
"search",
|
|
2643
|
+
Auth.types.ThreadsSearch(thread_id=thread_id, limit=limit, offset=offset),
|
|
2644
|
+
)
|
|
2645
|
+
if filters:
|
|
2646
|
+
thread = await _aget_thread(conn.session, thread_id)
|
|
2647
|
+
if thread is None or _auth_denies(thread.metadata_, filters):
|
|
2648
|
+
return _empty_aiter()
|
|
2649
|
+
|
|
2650
|
+
q = sa_select(RunRow).where(RunRow.thread_id == thread_id)
|
|
2651
|
+
if status is not None:
|
|
2652
|
+
q = q.where(RunRow.status == status)
|
|
2653
|
+
q = q.order_by(RunRow.created_at.desc()).offset(offset).limit(limit)
|
|
2654
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
2655
|
+
# Materialize while session is open — API drains runs outside connect().
|
|
2656
|
+
items: list[dict] = []
|
|
2657
|
+
for r in rows:
|
|
2658
|
+
d = run_to_dict(r)
|
|
2659
|
+
items.append({k: v for k, v in d.items() if k in select} if select else d)
|
|
2660
|
+
|
|
2661
|
+
async def _yield():
|
|
2662
|
+
for d in items:
|
|
2663
|
+
yield d
|
|
2664
|
+
|
|
2665
|
+
return _yield()
|
|
2666
|
+
|
|
2667
|
+
@staticmethod
|
|
2668
|
+
async def set_status(
|
|
2669
|
+
conn: PgConnectionProto,
|
|
2670
|
+
run_id: UUID | str,
|
|
2671
|
+
status: str,
|
|
2672
|
+
) -> dict | None:
|
|
2673
|
+
run_id = _ensure_uuid(run_id)
|
|
2674
|
+
run = await _aget_run(conn.session, run_id)
|
|
2675
|
+
if run:
|
|
2676
|
+
run.status = status
|
|
2677
|
+
run.updated_at = datetime.now(UTC)
|
|
2678
|
+
await conn.session.flush()
|
|
2679
|
+
return run_to_dict(run)
|
|
2680
|
+
return None
|
|
2681
|
+
|
|
2682
|
+
@staticmethod
|
|
2683
|
+
async def next(wait: bool = False, limit: int = 1) -> AsyncIterator:
|
|
2684
|
+
"""Claim pending runs via FOR UPDATE SKIP LOCKED (multi-replica safe)."""
|
|
2685
|
+
from sqlalchemy.exc import IntegrityError
|
|
2686
|
+
|
|
2687
|
+
from langgraph_runtime_pg.database import PgRetryCounter, get_session_factory
|
|
2688
|
+
|
|
2689
|
+
if wait:
|
|
2690
|
+
await asyncio.sleep(0.5)
|
|
2691
|
+
else:
|
|
2692
|
+
await asyncio.sleep(0)
|
|
2693
|
+
|
|
2694
|
+
sf = get_session_factory()
|
|
2695
|
+
claimed = 0
|
|
2696
|
+
conflict_budget = max(limit * 8, 8) # bound retries on a hot thread
|
|
2697
|
+
blocked_threads: list[UUID] = [] # skipped while a cancelled sibling still heartbeats
|
|
2698
|
+
while claimed < limit and conflict_budget > 0:
|
|
2699
|
+
run_id: UUID | None = None
|
|
2700
|
+
claimed_payload: tuple[dict[str, Any], int] | None = None
|
|
2701
|
+
try:
|
|
2702
|
+
async with sf() as session:
|
|
2703
|
+
exclude_sql = ""
|
|
2704
|
+
params: dict[str, Any] = {}
|
|
2705
|
+
if blocked_threads:
|
|
2706
|
+
placeholders = ", ".join(f":bt_{i}" for i in range(len(blocked_threads)))
|
|
2707
|
+
exclude_sql = (
|
|
2708
|
+
f"AND (r.thread_id IS NULL OR r.thread_id NOT IN ({placeholders}))"
|
|
2709
|
+
)
|
|
2710
|
+
params = {f"bt_{i}": t for i, t in enumerate(blocked_threads)}
|
|
2711
|
+
result = await session.execute(
|
|
2712
|
+
text(
|
|
2713
|
+
f"""
|
|
2714
|
+
UPDATE runs SET status = 'running', updated_at = now()
|
|
2715
|
+
WHERE run_id = (
|
|
2716
|
+
SELECT r.run_id FROM runs r
|
|
2717
|
+
WHERE r.status = 'pending'
|
|
2718
|
+
AND r.created_at <= now()
|
|
2719
|
+
AND NOT EXISTS (
|
|
2720
|
+
SELECT 1 FROM runs r2
|
|
2721
|
+
WHERE r2.thread_id IS NOT NULL
|
|
2722
|
+
AND r2.thread_id = r.thread_id
|
|
2723
|
+
AND r2.status = 'running'
|
|
2724
|
+
)
|
|
2725
|
+
{exclude_sql}
|
|
2726
|
+
ORDER BY r.created_at
|
|
2727
|
+
FOR UPDATE SKIP LOCKED
|
|
2728
|
+
LIMIT 1
|
|
2729
|
+
)
|
|
2730
|
+
RETURNING run_id
|
|
2731
|
+
"""
|
|
2732
|
+
),
|
|
2733
|
+
params,
|
|
2734
|
+
)
|
|
2735
|
+
row = result.first()
|
|
2736
|
+
if row is None:
|
|
2737
|
+
break
|
|
2738
|
+
run_id = row[0]
|
|
2739
|
+
run_row = await session.get(RunRow, run_id)
|
|
2740
|
+
if run_row is None:
|
|
2741
|
+
await session.commit()
|
|
2742
|
+
break
|
|
2743
|
+
# Skip claim while a cancelled sibling still heartbeats (fail-closed on Redis None); only check non-terminal siblings to avoid stale-heartbeat starvation.
|
|
2744
|
+
if run_row.thread_id is not None:
|
|
2745
|
+
sibling_ids = list(
|
|
2746
|
+
(
|
|
2747
|
+
await session.execute(
|
|
2748
|
+
sa_select(RunRow.run_id).where(
|
|
2749
|
+
RunRow.thread_id == run_row.thread_id,
|
|
2750
|
+
RunRow.run_id != run_id,
|
|
2751
|
+
RunRow.status.in_(("running", "interrupted")),
|
|
2752
|
+
)
|
|
2753
|
+
)
|
|
2754
|
+
)
|
|
2755
|
+
.scalars()
|
|
2756
|
+
.all()
|
|
2757
|
+
)
|
|
2758
|
+
blocked_by_hb = False
|
|
2759
|
+
for sid in sibling_ids:
|
|
2760
|
+
if await has_run_heartbeat(sid) is not False:
|
|
2761
|
+
blocked_by_hb = True
|
|
2762
|
+
break
|
|
2763
|
+
if blocked_by_hb:
|
|
2764
|
+
run_row.status = "pending"
|
|
2765
|
+
await session.commit()
|
|
2766
|
+
blocked_threads.append(run_row.thread_id)
|
|
2767
|
+
conflict_budget -= 1
|
|
2768
|
+
continue
|
|
2769
|
+
# Same transaction as the claim so a failed commit does not inflate attempts.
|
|
2770
|
+
attempt = await PgRetryCounter(sf).increment(run_id, session=session)
|
|
2771
|
+
run_dict = run_to_dict(run_row)
|
|
2772
|
+
# Heartbeat before commit — otherwise sweep can reclaim and a second replica may double-execute.
|
|
2773
|
+
await set_run_heartbeat(run_id)
|
|
2774
|
+
try:
|
|
2775
|
+
await session.commit()
|
|
2776
|
+
except Exception:
|
|
2777
|
+
await clear_run_heartbeat(run_id)
|
|
2778
|
+
raise
|
|
2779
|
+
claimed_payload = (run_dict, attempt)
|
|
2780
|
+
# Yield after the session closes so a slow consumer does not hold a pool connection.
|
|
2781
|
+
if claimed_payload is None:
|
|
2782
|
+
break
|
|
2783
|
+
claimed += 1
|
|
2784
|
+
yield claimed_payload
|
|
2785
|
+
except IntegrityError:
|
|
2786
|
+
# Lost one-running-per-thread race — leave pending for a later claim.
|
|
2787
|
+
if run_id is not None:
|
|
2788
|
+
await clear_run_heartbeat(run_id)
|
|
2789
|
+
conflict_budget -= 1
|
|
2790
|
+
continue
|
|
2791
|
+
|
|
2792
|
+
@staticmethod
|
|
2793
|
+
@asynccontextmanager
|
|
2794
|
+
async def enter(
|
|
2795
|
+
run_id: UUID,
|
|
2796
|
+
thread_id: UUID | None,
|
|
2797
|
+
loop: asyncio.AbstractEventLoop,
|
|
2798
|
+
resumable: bool,
|
|
2799
|
+
) -> AsyncIterator[Any]:
|
|
2800
|
+
"""Enter a run, heartbeat + cancellation listen, signal done."""
|
|
2801
|
+
from langgraph_api.asyncio import SimpleTaskGroup, ValueEvent
|
|
2802
|
+
from langgraph_api.utils.stream_codec import STREAM_CODEC
|
|
2803
|
+
|
|
2804
|
+
stream_manager = get_stream_manager()
|
|
2805
|
+
control_queue = await stream_manager.add_control_queue(run_id, thread_id)
|
|
2806
|
+
|
|
2807
|
+
async def _heartbeat_loop() -> None:
|
|
2808
|
+
interval = heartbeat_refresh_interval_secs()
|
|
2809
|
+
while True:
|
|
2810
|
+
await set_run_heartbeat(run_id)
|
|
2811
|
+
await asyncio.sleep(interval)
|
|
2812
|
+
|
|
2813
|
+
try:
|
|
2814
|
+
await set_run_heartbeat(run_id)
|
|
2815
|
+
async with SimpleTaskGroup(cancel=True, taskgroup_name="Runs.enter") as tg:
|
|
2816
|
+
done = ValueEvent()
|
|
2817
|
+
tg.create_task(_heartbeat_loop())
|
|
2818
|
+
tg.create_task(listen_for_cancellation(control_queue, run_id, thread_id, done))
|
|
2819
|
+
yield done
|
|
2820
|
+
control_message = Message(topic=f"run:{run_id}:control".encode(), data=b"done")
|
|
2821
|
+
await stream_manager.put(run_id, thread_id, control_message)
|
|
2822
|
+
stream_message = Message(
|
|
2823
|
+
topic=f"run:{run_id}:stream".encode(),
|
|
2824
|
+
data=STREAM_CODEC.encode("control", b"done"),
|
|
2825
|
+
)
|
|
2826
|
+
await stream_manager.put(run_id, thread_id, stream_message, resumable=resumable)
|
|
2827
|
+
await stream_manager.remove_control_queue(run_id, thread_id, control_queue)
|
|
2828
|
+
finally:
|
|
2829
|
+
await clear_run_heartbeat(run_id)
|
|
2830
|
+
try:
|
|
2831
|
+
await stream_manager.clear_run_buffers(run_id, thread_id)
|
|
2832
|
+
except Exception:
|
|
2833
|
+
logger.debug("clear_run_buffers failed", exc_info=True)
|
|
2834
|
+
|
|
2835
|
+
@staticmethod
|
|
2836
|
+
async def sweep() -> list[UUID]:
|
|
2837
|
+
"""Reclaim running runs whose Redis heartbeat has expired."""
|
|
2838
|
+
from langgraph_api import config
|
|
2839
|
+
|
|
2840
|
+
from langgraph_runtime_pg.database import PgRetryCounter, get_session_factory
|
|
2841
|
+
|
|
2842
|
+
sf = get_session_factory()
|
|
2843
|
+
reclaimed: list[UUID] = []
|
|
2844
|
+
max_retries = int(getattr(config, "BG_JOB_MAX_RETRIES", 3))
|
|
2845
|
+
retry_counter = PgRetryCounter(sf)
|
|
2846
|
+
|
|
2847
|
+
async with sf() as session:
|
|
2848
|
+
result = await session.execute(
|
|
2849
|
+
sa_select(RunRow.run_id).where(RunRow.status == "running")
|
|
2850
|
+
)
|
|
2851
|
+
running_ids = [row[0] for row in result.all()]
|
|
2852
|
+
|
|
2853
|
+
for run_id in running_ids:
|
|
2854
|
+
alive = await has_run_heartbeat(run_id)
|
|
2855
|
+
if alive is None:
|
|
2856
|
+
# Redis unavailable — do not reclaim (avoid false positives).
|
|
2857
|
+
continue
|
|
2858
|
+
if alive:
|
|
2859
|
+
continue
|
|
2860
|
+
|
|
2861
|
+
attempts = await retry_counter.get(run_id)
|
|
2862
|
+
# Next claim would bump attempts; if that exceeds max, mark error instead of looping.
|
|
2863
|
+
new_status = "error" if attempts >= max_retries else "pending"
|
|
2864
|
+
async with sf() as session:
|
|
2865
|
+
result = await session.execute(
|
|
2866
|
+
text(
|
|
2867
|
+
"""
|
|
2868
|
+
UPDATE runs
|
|
2869
|
+
SET status = :status, updated_at = now()
|
|
2870
|
+
WHERE run_id = :run_id AND status = 'running'
|
|
2871
|
+
RETURNING run_id, thread_id
|
|
2872
|
+
"""
|
|
2873
|
+
),
|
|
2874
|
+
{"status": new_status, "run_id": run_id},
|
|
2875
|
+
)
|
|
2876
|
+
row = result.first()
|
|
2877
|
+
if row is not None and new_status == "error" and row[1] is not None:
|
|
2878
|
+
# Terminal reclaim — do not leave the thread stuck busy.
|
|
2879
|
+
tid = row[1]
|
|
2880
|
+
pending = int(
|
|
2881
|
+
await session.scalar(
|
|
2882
|
+
sa_select(func.count())
|
|
2883
|
+
.select_from(RunRow)
|
|
2884
|
+
.where(
|
|
2885
|
+
RunRow.thread_id == tid,
|
|
2886
|
+
RunRow.status.in_(("pending", "running")),
|
|
2887
|
+
)
|
|
2888
|
+
)
|
|
2889
|
+
or 0
|
|
2890
|
+
)
|
|
2891
|
+
if pending == 0:
|
|
2892
|
+
thread = await _aget_thread(session, tid)
|
|
2893
|
+
if thread is not None:
|
|
2894
|
+
thread.status = "error"
|
|
2895
|
+
thread.updated_at = datetime.now(UTC)
|
|
2896
|
+
if thread.error is None:
|
|
2897
|
+
thread.error = {
|
|
2898
|
+
"error": "RuntimeError",
|
|
2899
|
+
"message": "Run swept after exceeding max retries",
|
|
2900
|
+
}
|
|
2901
|
+
await session.commit()
|
|
2902
|
+
if row is None:
|
|
2903
|
+
continue
|
|
2904
|
+
await clear_run_heartbeat(run_id)
|
|
2905
|
+
reclaimed.append(run_id)
|
|
2906
|
+
await logger.awarning(
|
|
2907
|
+
"Swept stale run",
|
|
2908
|
+
run_id=str(run_id),
|
|
2909
|
+
new_status=new_status,
|
|
2910
|
+
attempts=attempts,
|
|
2911
|
+
)
|
|
2912
|
+
|
|
2913
|
+
# Idle busy threads left behind when a cancelled worker dies without set_joint_status.
|
|
2914
|
+
idled = False
|
|
2915
|
+
async with sf() as session:
|
|
2916
|
+
busy_threads = list(
|
|
2917
|
+
(await session.execute(sa_select(ThreadRow).where(ThreadRow.status == "busy")))
|
|
2918
|
+
.scalars()
|
|
2919
|
+
.all()
|
|
2920
|
+
)
|
|
2921
|
+
now = datetime.now(UTC)
|
|
2922
|
+
for thread in busy_threads:
|
|
2923
|
+
if await _thread_has_inflight_work(session, thread.thread_id):
|
|
2924
|
+
continue
|
|
2925
|
+
thread.status = "idle"
|
|
2926
|
+
thread.updated_at = now
|
|
2927
|
+
idled = True
|
|
2928
|
+
if idled:
|
|
2929
|
+
await session.commit()
|
|
2930
|
+
|
|
2931
|
+
if reclaimed or idled:
|
|
2932
|
+
try:
|
|
2933
|
+
await wake_run_queue()
|
|
2934
|
+
except Exception:
|
|
2935
|
+
logger.debug("wake_run_queue after sweep failed", exc_info=True)
|
|
2936
|
+
return reclaimed
|
|
2937
|
+
|
|
2938
|
+
class Stream:
|
|
2939
|
+
@staticmethod
|
|
2940
|
+
async def subscribe(run_id: UUID, thread_id: UUID | None = None) -> ContextQueue:
|
|
2941
|
+
sm = get_stream_manager()
|
|
2942
|
+
return cast(ContextQueue, await sm.add_queue(_ensure_uuid(run_id), thread_id))
|
|
2943
|
+
|
|
2944
|
+
@staticmethod
|
|
2945
|
+
async def join(
|
|
2946
|
+
run_id: UUID,
|
|
2947
|
+
*,
|
|
2948
|
+
stream_channel: asyncio.Queue,
|
|
2949
|
+
thread_id: UUID,
|
|
2950
|
+
ignore_404: bool = False,
|
|
2951
|
+
cancel_on_disconnect: bool = False,
|
|
2952
|
+
stream_mode: list | str | None = None,
|
|
2953
|
+
last_event_id: str | None = None,
|
|
2954
|
+
ctx: Any = None,
|
|
2955
|
+
) -> AsyncIterator:
|
|
2956
|
+
"""Stream run output using short-lived DB sessions (avoids pool exhaustion)."""
|
|
2957
|
+
from langgraph_api.asyncio import create_task
|
|
2958
|
+
from langgraph_api.serde import json_dumpb
|
|
2959
|
+
from langgraph_api.utils.stream_codec import (
|
|
2960
|
+
decode_stream_message,
|
|
2961
|
+
)
|
|
2962
|
+
|
|
2963
|
+
queue = stream_channel
|
|
2964
|
+
resume_cursor = (
|
|
2965
|
+
last_event_id
|
|
2966
|
+
if last_event_id is not None and last_event_id not in ("-", "")
|
|
2967
|
+
else None
|
|
2968
|
+
)
|
|
2969
|
+
emitted_ids: set[str] = set()
|
|
2970
|
+
|
|
2971
|
+
def _accept(message: Message) -> bool:
|
|
2972
|
+
if not message.id:
|
|
2973
|
+
return True
|
|
2974
|
+
mid = message.id.decode() if isinstance(message.id, bytes) else str(message.id)
|
|
2975
|
+
if mid in emitted_ids:
|
|
2976
|
+
return False
|
|
2977
|
+
if resume_cursor is not None and not ms_seq_id_gt(mid, resume_cursor):
|
|
2978
|
+
return False
|
|
2979
|
+
emitted_ids.add(mid)
|
|
2980
|
+
return True
|
|
2981
|
+
|
|
2982
|
+
try:
|
|
2983
|
+
try:
|
|
2984
|
+
await Runs.Stream.check_run_stream_auth(run_id, thread_id, ctx)
|
|
2985
|
+
except HTTPException as e:
|
|
2986
|
+
raise WrappedHTTPException(e) from None
|
|
2987
|
+
|
|
2988
|
+
async with connect() as conn:
|
|
2989
|
+
run_iter = await Runs.get(conn, run_id, thread_id=thread_id, ctx=ctx)
|
|
2990
|
+
run = await anext(run_iter, None)
|
|
2991
|
+
|
|
2992
|
+
for message in await get_stream_manager().restore_messages_async(
|
|
2993
|
+
run_id, thread_id, last_event_id
|
|
2994
|
+
):
|
|
2995
|
+
if not _accept(message):
|
|
2996
|
+
continue
|
|
2997
|
+
data, mid = message.data, message.id
|
|
2998
|
+
decoded = decode_stream_message(data, channel=message.topic)
|
|
2999
|
+
mode = decoded.event_bytes.decode("utf-8")
|
|
3000
|
+
payload = decoded.message_bytes
|
|
3001
|
+
if mode == "control":
|
|
3002
|
+
if payload == b"done":
|
|
3003
|
+
return
|
|
3004
|
+
elif _run_stream_mode_matches(mode, stream_mode):
|
|
3005
|
+
yield mode.encode(), payload, mid
|
|
3006
|
+
|
|
3007
|
+
while True:
|
|
3008
|
+
try:
|
|
3009
|
+
message = await asyncio.wait_for(queue.get(), timeout=0.5)
|
|
3010
|
+
if not _accept(message):
|
|
3011
|
+
continue
|
|
3012
|
+
data, mid = message.data, message.id
|
|
3013
|
+
decoded = decode_stream_message(data, channel=message.topic)
|
|
3014
|
+
mode = decoded.event_bytes.decode("utf-8")
|
|
3015
|
+
payload = decoded.message_bytes
|
|
3016
|
+
if mode == "control":
|
|
3017
|
+
if payload == b"done":
|
|
3018
|
+
break
|
|
3019
|
+
elif _run_stream_mode_matches(mode, stream_mode):
|
|
3020
|
+
stream_id = (
|
|
3021
|
+
mid if (run or {}).get("kwargs", {}).get("resumable") else None
|
|
3022
|
+
)
|
|
3023
|
+
yield mode.encode(), payload, stream_id
|
|
3024
|
+
except TimeoutError:
|
|
3025
|
+
async with connect() as conn:
|
|
3026
|
+
run_iter = await Runs.get(conn, run_id, thread_id=thread_id, ctx=ctx)
|
|
3027
|
+
run = await anext(run_iter, None)
|
|
3028
|
+
if ignore_404 and run is None:
|
|
3029
|
+
break
|
|
3030
|
+
elif run is None:
|
|
3031
|
+
yield (
|
|
3032
|
+
b"error",
|
|
3033
|
+
json_dumpb(HTTPException(status_code=404, detail="Run not found")),
|
|
3034
|
+
None,
|
|
3035
|
+
)
|
|
3036
|
+
break
|
|
3037
|
+
elif run["status"] not in ("pending", "running"):
|
|
3038
|
+
break
|
|
3039
|
+
except WrappedHTTPException as e:
|
|
3040
|
+
raise e.http_exception from None
|
|
3041
|
+
except Exception:
|
|
3042
|
+
if cancel_on_disconnect:
|
|
3043
|
+
create_task(cancel_run(thread_id, run_id))
|
|
3044
|
+
raise
|
|
3045
|
+
finally:
|
|
3046
|
+
await get_stream_manager().remove_queue(run_id, thread_id, queue)
|
|
3047
|
+
|
|
3048
|
+
@staticmethod
|
|
3049
|
+
async def check_run_stream_auth(
|
|
3050
|
+
run_id: UUID,
|
|
3051
|
+
thread_id: UUID,
|
|
3052
|
+
ctx: Any = None,
|
|
3053
|
+
) -> None:
|
|
3054
|
+
from langgraph_sdk import Auth
|
|
3055
|
+
|
|
3056
|
+
del run_id # auth is thread-scoped
|
|
3057
|
+
async with connect() as conn:
|
|
3058
|
+
filters = await Runs.handle_event(
|
|
3059
|
+
ctx,
|
|
3060
|
+
"read",
|
|
3061
|
+
Auth.types.ThreadsRead(thread_id=thread_id),
|
|
3062
|
+
)
|
|
3063
|
+
if filters:
|
|
3064
|
+
thread = await _aget_thread(conn.session, thread_id)
|
|
3065
|
+
if thread is None or _auth_denies(thread.metadata_, filters):
|
|
3066
|
+
raise HTTPException(status_code=404, detail="Thread not found")
|
|
3067
|
+
|
|
3068
|
+
# Monotonic time of last custom publish; gives dual SSE consumers a beat before interrupt updates.
|
|
3069
|
+
_last_custom_publish_mono: float = 0.0
|
|
3070
|
+
|
|
3071
|
+
@staticmethod
|
|
3072
|
+
async def publish(
|
|
3073
|
+
run_id: UUID | str,
|
|
3074
|
+
event: str,
|
|
3075
|
+
message: bytes,
|
|
3076
|
+
*,
|
|
3077
|
+
thread_id: UUID | str | None = None,
|
|
3078
|
+
resumable: bool = False,
|
|
3079
|
+
) -> None:
|
|
3080
|
+
from langgraph_api.utils.stream_codec import STREAM_CODEC
|
|
3081
|
+
|
|
3082
|
+
topic = f"run:{run_id}:stream".encode()
|
|
3083
|
+
sm = get_stream_manager()
|
|
3084
|
+
payload = STREAM_CODEC.encode(event, message)
|
|
3085
|
+
mode = event.split("|", 1)[0]
|
|
3086
|
+
|
|
3087
|
+
# Brief yield after custom before interrupt-bearing values/updates so dual SSE does not close extension iterators early.
|
|
3088
|
+
if mode in ("updates", "values"):
|
|
3089
|
+
elapsed = time.monotonic() - Runs.Stream._last_custom_publish_mono
|
|
3090
|
+
if elapsed < 0.008:
|
|
3091
|
+
await asyncio.sleep(0.008 - elapsed)
|
|
3092
|
+
|
|
3093
|
+
await sm.put(run_id, thread_id, Message(topic=topic, data=payload), resumable)
|
|
3094
|
+
if mode == "custom" or mode.startswith("custom:"):
|
|
3095
|
+
Runs.Stream._last_custom_publish_mono = time.monotonic()
|
|
3096
|
+
|
|
3097
|
+
|
|
3098
|
+
class Crons(Authenticated):
|
|
3099
|
+
resource = "crons"
|
|
3100
|
+
|
|
3101
|
+
@staticmethod
|
|
3102
|
+
async def put(
|
|
3103
|
+
conn: PgConnectionProto,
|
|
3104
|
+
*,
|
|
3105
|
+
payload: dict,
|
|
3106
|
+
schedule: str,
|
|
3107
|
+
cron_id: UUID | None = None,
|
|
3108
|
+
thread_id: UUID | None = None,
|
|
3109
|
+
end_time: datetime | None = None,
|
|
3110
|
+
metadata: dict | None = None,
|
|
3111
|
+
enabled: bool = True,
|
|
3112
|
+
timezone: str | None = None,
|
|
3113
|
+
on_run_completed: str | None = None,
|
|
3114
|
+
ctx: Any = None,
|
|
3115
|
+
) -> AsyncIterator:
|
|
3116
|
+
import croniter as croniter_mod
|
|
3117
|
+
from langgraph_api.graph import SYSTEM_ASSISTANT_IDS, get_assistant_id
|
|
3118
|
+
from langgraph_api.utils import get_auth_ctx, next_cron_date, uuid7
|
|
3119
|
+
from langgraph_sdk import Auth
|
|
3120
|
+
|
|
3121
|
+
if not croniter_mod.croniter.is_valid(schedule):
|
|
3122
|
+
raise HTTPException(status_code=422, detail=f"Invalid cron schedule: '{schedule}'")
|
|
3123
|
+
|
|
3124
|
+
ctx = ctx or get_auth_ctx()
|
|
3125
|
+
user_id = ctx.user.identity if ctx is not None else None
|
|
3126
|
+
cron_id = cron_id or uuid7()
|
|
3127
|
+
metadata = metadata or {}
|
|
3128
|
+
payload = dict(payload or {})
|
|
3129
|
+
config = dict(payload.get("config") or {})
|
|
3130
|
+
configurable = dict(config.get("configurable") or {})
|
|
3131
|
+
configurable["cron_id"] = str(cron_id)
|
|
3132
|
+
config["configurable"] = configurable
|
|
3133
|
+
payload["config"] = config
|
|
3134
|
+
assistant_id = _ensure_uuid(get_assistant_id(str(payload.get("assistant_id"))))
|
|
3135
|
+
payload["assistant_id"] = str(assistant_id)
|
|
3136
|
+
thread_uuid = _ensure_uuid(thread_id) if thread_id else None
|
|
3137
|
+
|
|
3138
|
+
filters = await Crons.handle_event(
|
|
3139
|
+
ctx,
|
|
3140
|
+
"create",
|
|
3141
|
+
Auth.types.CronsCreate(
|
|
3142
|
+
payload=payload,
|
|
3143
|
+
schedule=schedule,
|
|
3144
|
+
cron_id=cron_id,
|
|
3145
|
+
thread_id=thread_uuid,
|
|
3146
|
+
end_time=end_time,
|
|
3147
|
+
),
|
|
3148
|
+
)
|
|
3149
|
+
|
|
3150
|
+
assistant = await _aget_assistant(conn.session, assistant_id)
|
|
3151
|
+
if assistant is None:
|
|
3152
|
+
raise HTTPException(status_code=404, detail=f"Assistant '{assistant_id}' not found")
|
|
3153
|
+
if str(assistant_id) not in SYSTEM_ASSISTANT_IDS:
|
|
3154
|
+
assistant_filters = await Assistants.handle_event(
|
|
3155
|
+
ctx, "read", Auth.types.AssistantsRead(assistant_id=assistant_id)
|
|
3156
|
+
)
|
|
3157
|
+
if _auth_denies(assistant.metadata_, assistant_filters):
|
|
3158
|
+
raise HTTPException(status_code=404, detail=f"Assistant '{assistant_id}' not found")
|
|
3159
|
+
if thread_uuid is not None:
|
|
3160
|
+
thread = await _aget_thread(conn.session, thread_uuid)
|
|
3161
|
+
thread_filters = await Threads.handle_event(
|
|
3162
|
+
ctx, "read", Auth.types.ThreadsRead(thread_id=thread_uuid)
|
|
3163
|
+
)
|
|
3164
|
+
if thread is None or _auth_denies(thread.metadata_ if thread else None, thread_filters):
|
|
3165
|
+
raise HTTPException(status_code=404, detail=f"Thread '{thread_uuid}' not found")
|
|
3166
|
+
|
|
3167
|
+
existing = await _aget_cron(conn.session, cron_id)
|
|
3168
|
+
if existing:
|
|
3169
|
+
if _auth_denies(existing.metadata_, filters):
|
|
3170
|
+
raise HTTPException(status_code=404, detail=f"Cron '{cron_id}' not found")
|
|
3171
|
+
|
|
3172
|
+
data = cron_to_dict(existing)
|
|
3173
|
+
|
|
3174
|
+
async def _yield_existing():
|
|
3175
|
+
yield data
|
|
3176
|
+
|
|
3177
|
+
return _yield_existing()
|
|
3178
|
+
|
|
3179
|
+
now = datetime.now(UTC)
|
|
3180
|
+
next_run = next_cron_date(schedule, now, timezone=timezone)
|
|
3181
|
+
row = CronRow(
|
|
3182
|
+
cron_id=cron_id,
|
|
3183
|
+
assistant_id=assistant_id,
|
|
3184
|
+
thread_id=thread_uuid,
|
|
3185
|
+
schedule=schedule,
|
|
3186
|
+
payload=payload,
|
|
3187
|
+
metadata_=metadata,
|
|
3188
|
+
next_run_date=next_run,
|
|
3189
|
+
end_time=end_time,
|
|
3190
|
+
user_id=user_id,
|
|
3191
|
+
timezone=timezone,
|
|
3192
|
+
on_run_completed=on_run_completed,
|
|
3193
|
+
enabled=enabled,
|
|
3194
|
+
created_at=now,
|
|
3195
|
+
updated_at=now,
|
|
3196
|
+
)
|
|
3197
|
+
conn.session.add(row)
|
|
3198
|
+
await conn.session.flush()
|
|
3199
|
+
data = cron_to_dict(row)
|
|
3200
|
+
|
|
3201
|
+
async def _yield():
|
|
3202
|
+
yield data
|
|
3203
|
+
|
|
3204
|
+
return _yield()
|
|
3205
|
+
|
|
3206
|
+
@staticmethod
|
|
3207
|
+
async def update(
|
|
3208
|
+
conn: PgConnectionProto,
|
|
3209
|
+
*,
|
|
3210
|
+
cron_id: UUID,
|
|
3211
|
+
schedule: str | None = None,
|
|
3212
|
+
end_time: datetime | None = None,
|
|
3213
|
+
enabled: bool | None = None,
|
|
3214
|
+
on_run_completed: str | None = None,
|
|
3215
|
+
payload: dict | None = None,
|
|
3216
|
+
metadata: dict | None = None,
|
|
3217
|
+
timezone: str | None = None,
|
|
3218
|
+
ctx: Any = None,
|
|
3219
|
+
) -> AsyncIterator:
|
|
3220
|
+
import croniter as croniter_mod
|
|
3221
|
+
from langgraph_api.utils import next_cron_date
|
|
3222
|
+
from langgraph_sdk import Auth
|
|
3223
|
+
|
|
3224
|
+
has_updates = any(
|
|
3225
|
+
v is not None
|
|
3226
|
+
for v in [
|
|
3227
|
+
schedule,
|
|
3228
|
+
end_time,
|
|
3229
|
+
enabled,
|
|
3230
|
+
on_run_completed,
|
|
3231
|
+
payload,
|
|
3232
|
+
metadata,
|
|
3233
|
+
timezone,
|
|
3234
|
+
]
|
|
3235
|
+
)
|
|
3236
|
+
if not has_updates:
|
|
3237
|
+
raise HTTPException(status_code=400, detail="No fields to update")
|
|
3238
|
+
|
|
3239
|
+
cron_id = _ensure_uuid(cron_id)
|
|
3240
|
+
filters = await Crons.handle_event(
|
|
3241
|
+
ctx,
|
|
3242
|
+
"update",
|
|
3243
|
+
Auth.types.CronsUpdate(cron_id=cron_id, payload=payload, schedule=schedule),
|
|
3244
|
+
)
|
|
3245
|
+
row = await _aget_cron(conn.session, cron_id)
|
|
3246
|
+
if row is None or _auth_denies(row.metadata_, filters):
|
|
3247
|
+
raise HTTPException(status_code=404, detail=f"Cron '{cron_id}' not found")
|
|
3248
|
+
|
|
3249
|
+
if timezone is not None:
|
|
3250
|
+
row.timezone = timezone
|
|
3251
|
+
|
|
3252
|
+
if schedule is not None:
|
|
3253
|
+
if not croniter_mod.croniter.is_valid(schedule):
|
|
3254
|
+
raise HTTPException(status_code=422, detail=f"Invalid cron schedule: '{schedule}'")
|
|
3255
|
+
row.schedule = schedule
|
|
3256
|
+
row.next_run_date = next_cron_date(schedule, datetime.now(UTC), timezone=row.timezone)
|
|
3257
|
+
elif timezone is not None:
|
|
3258
|
+
row.next_run_date = next_cron_date(row.schedule, datetime.now(UTC), timezone=timezone)
|
|
3259
|
+
|
|
3260
|
+
if end_time is not None:
|
|
3261
|
+
row.end_time = end_time
|
|
3262
|
+
if enabled is not None:
|
|
3263
|
+
row.enabled = enabled
|
|
3264
|
+
if on_run_completed is not None:
|
|
3265
|
+
row.on_run_completed = on_run_completed
|
|
3266
|
+
if metadata is not None:
|
|
3267
|
+
row.metadata_ = {**(row.metadata_ or {}), **metadata}
|
|
3268
|
+
if payload is not None:
|
|
3269
|
+
existing_payload = dict(row.payload or {})
|
|
3270
|
+
merged = {**existing_payload, **payload}
|
|
3271
|
+
merged["assistant_id"] = existing_payload.get(
|
|
3272
|
+
"assistant_id", merged.get("assistant_id")
|
|
3273
|
+
)
|
|
3274
|
+
merged_config = dict(merged.get("config") or {})
|
|
3275
|
+
merged_configurable = dict(merged_config.get("configurable") or {})
|
|
3276
|
+
merged_configurable["cron_id"] = str(cron_id)
|
|
3277
|
+
merged_config["configurable"] = merged_configurable
|
|
3278
|
+
merged["config"] = merged_config
|
|
3279
|
+
row.payload = merged
|
|
3280
|
+
|
|
3281
|
+
row.updated_at = datetime.now(UTC)
|
|
3282
|
+
await conn.session.flush()
|
|
3283
|
+
data = cron_to_dict(row)
|
|
3284
|
+
|
|
3285
|
+
async def _yield():
|
|
3286
|
+
yield data
|
|
3287
|
+
|
|
3288
|
+
return _yield()
|
|
3289
|
+
|
|
3290
|
+
@staticmethod
|
|
3291
|
+
async def get(
|
|
3292
|
+
conn: PgConnectionProto,
|
|
3293
|
+
cron_id: UUID | str,
|
|
3294
|
+
ctx: Any = None,
|
|
3295
|
+
) -> AsyncIterator:
|
|
3296
|
+
from langgraph_sdk import Auth
|
|
3297
|
+
|
|
3298
|
+
cron_id = _ensure_uuid(cron_id)
|
|
3299
|
+
filters = await Crons.handle_event(ctx, "read", Auth.types.CronsRead(cron_id=cron_id))
|
|
3300
|
+
# Eager load — API may drain the iterator after connect() closes.
|
|
3301
|
+
row = await _aget_cron(conn.session, cron_id)
|
|
3302
|
+
data = None
|
|
3303
|
+
if row is not None and not _auth_denies(row.metadata_, filters):
|
|
3304
|
+
data = copy.deepcopy(cron_to_dict(row))
|
|
3305
|
+
|
|
3306
|
+
async def _yield():
|
|
3307
|
+
if data is not None:
|
|
3308
|
+
yield data
|
|
3309
|
+
|
|
3310
|
+
return _yield()
|
|
3311
|
+
|
|
3312
|
+
@staticmethod
|
|
3313
|
+
async def delete(
|
|
3314
|
+
conn: PgConnectionProto,
|
|
3315
|
+
cron_id: UUID | str,
|
|
3316
|
+
ctx: Any = None,
|
|
3317
|
+
) -> AsyncIterator:
|
|
3318
|
+
from langgraph_sdk import Auth
|
|
3319
|
+
|
|
3320
|
+
cron_id = _ensure_uuid(cron_id)
|
|
3321
|
+
filters = await Crons.handle_event(ctx, "delete", Auth.types.CronsDelete(cron_id=cron_id))
|
|
3322
|
+
row = await _aget_cron(conn.session, cron_id)
|
|
3323
|
+
deleted = row is not None and not _auth_denies(row.metadata_, filters)
|
|
3324
|
+
if deleted:
|
|
3325
|
+
await conn.session.delete(row)
|
|
3326
|
+
await conn.session.flush()
|
|
3327
|
+
|
|
3328
|
+
async def _yield():
|
|
3329
|
+
if deleted:
|
|
3330
|
+
yield cron_id
|
|
3331
|
+
|
|
3332
|
+
return _yield()
|
|
3333
|
+
|
|
3334
|
+
@staticmethod
|
|
3335
|
+
async def search(
|
|
3336
|
+
conn: PgConnectionProto,
|
|
3337
|
+
*,
|
|
3338
|
+
assistant_id: UUID | None = None,
|
|
3339
|
+
thread_id: UUID | None = None,
|
|
3340
|
+
enabled: bool | None = None,
|
|
3341
|
+
limit: int = 10,
|
|
3342
|
+
offset: int = 0,
|
|
3343
|
+
select: list | None = None,
|
|
3344
|
+
ctx: Any = None,
|
|
3345
|
+
sort_by: str | None = None,
|
|
3346
|
+
sort_order: str | None = None,
|
|
3347
|
+
metadata: dict | None = None,
|
|
3348
|
+
) -> tuple[AsyncIterator, int | None]:
|
|
3349
|
+
from langgraph_sdk import Auth
|
|
3350
|
+
|
|
3351
|
+
filters = await Crons.handle_event(
|
|
3352
|
+
ctx,
|
|
3353
|
+
"search",
|
|
3354
|
+
Auth.types.CronsSearch(
|
|
3355
|
+
assistant_id=assistant_id,
|
|
3356
|
+
thread_id=thread_id,
|
|
3357
|
+
limit=limit,
|
|
3358
|
+
offset=offset,
|
|
3359
|
+
),
|
|
3360
|
+
)
|
|
3361
|
+
q = sa_select(CronRow)
|
|
3362
|
+
if assistant_id is not None:
|
|
3363
|
+
q = q.where(CronRow.assistant_id == _ensure_uuid(assistant_id))
|
|
3364
|
+
if thread_id is not None:
|
|
3365
|
+
q = q.where(CronRow.thread_id == _ensure_uuid(thread_id))
|
|
3366
|
+
if enabled is not None:
|
|
3367
|
+
q = q.where(CronRow.enabled == enabled)
|
|
3368
|
+
if metadata:
|
|
3369
|
+
q = q.where(CronRow.metadata_.contains(metadata))
|
|
3370
|
+
plain = _plain_metadata_filter(filters)
|
|
3371
|
+
if plain:
|
|
3372
|
+
q = q.where(CronRow.metadata_.contains(plain))
|
|
3373
|
+
|
|
3374
|
+
sb = _resolve_sort_field(sort_by, _CRON_SORT_FIELDS, "created_at", raise_invalid=True)
|
|
3375
|
+
col = getattr(CronRow, sb)
|
|
3376
|
+
reverse = not (sort_order and sort_order.upper() == "ASC")
|
|
3377
|
+
q = q.order_by(col.desc() if reverse else col.asc())
|
|
3378
|
+
|
|
3379
|
+
if filters and plain is None:
|
|
3380
|
+
rows = [
|
|
3381
|
+
r
|
|
3382
|
+
for r in (await conn.session.execute(q)).scalars()
|
|
3383
|
+
if not _auth_denies(r.metadata_, filters)
|
|
3384
|
+
]
|
|
3385
|
+
page = rows[offset : offset + limit]
|
|
3386
|
+
cursor = offset + limit if len(rows) > offset + limit else None
|
|
3387
|
+
else:
|
|
3388
|
+
q = q.offset(offset).limit(limit + 1)
|
|
3389
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
3390
|
+
cursor = offset + limit if len(rows) > limit else None
|
|
3391
|
+
page = rows[:limit]
|
|
3392
|
+
|
|
3393
|
+
# Materialize while session is open — API paginates outside connect().
|
|
3394
|
+
items: list[dict] = []
|
|
3395
|
+
for r in page:
|
|
3396
|
+
d = cron_to_dict(r)
|
|
3397
|
+
items.append({k: v for k, v in d.items() if k in select} if select else d)
|
|
3398
|
+
|
|
3399
|
+
async def _iter():
|
|
3400
|
+
for d in items:
|
|
3401
|
+
yield d
|
|
3402
|
+
|
|
3403
|
+
return _iter(), cursor
|
|
3404
|
+
|
|
3405
|
+
@staticmethod
|
|
3406
|
+
async def count(
|
|
3407
|
+
conn: PgConnectionProto,
|
|
3408
|
+
*,
|
|
3409
|
+
assistant_id: UUID | None = None,
|
|
3410
|
+
thread_id: UUID | None = None,
|
|
3411
|
+
ctx: Any = None,
|
|
3412
|
+
metadata: dict | None = None,
|
|
3413
|
+
) -> int:
|
|
3414
|
+
from langgraph_sdk import Auth
|
|
3415
|
+
|
|
3416
|
+
filters = await Crons.handle_event(
|
|
3417
|
+
ctx,
|
|
3418
|
+
"search",
|
|
3419
|
+
Auth.types.CronsSearch(
|
|
3420
|
+
assistant_id=assistant_id, thread_id=thread_id, limit=0, offset=0
|
|
3421
|
+
),
|
|
3422
|
+
)
|
|
3423
|
+
plain = _plain_metadata_filter(filters)
|
|
3424
|
+
if filters and plain is None:
|
|
3425
|
+
q = sa_select(CronRow)
|
|
3426
|
+
if assistant_id is not None:
|
|
3427
|
+
q = q.where(CronRow.assistant_id == _ensure_uuid(assistant_id))
|
|
3428
|
+
if thread_id is not None:
|
|
3429
|
+
q = q.where(CronRow.thread_id == _ensure_uuid(thread_id))
|
|
3430
|
+
if metadata:
|
|
3431
|
+
q = q.where(CronRow.metadata_.contains(metadata))
|
|
3432
|
+
rows = list((await conn.session.execute(q)).scalars())
|
|
3433
|
+
return sum(1 for r in rows if not _auth_denies(r.metadata_, filters))
|
|
3434
|
+
|
|
3435
|
+
count_q = sa_select(func.count()).select_from(CronRow)
|
|
3436
|
+
if assistant_id is not None:
|
|
3437
|
+
count_q = count_q.where(CronRow.assistant_id == _ensure_uuid(assistant_id))
|
|
3438
|
+
if thread_id is not None:
|
|
3439
|
+
count_q = count_q.where(CronRow.thread_id == _ensure_uuid(thread_id))
|
|
3440
|
+
if metadata:
|
|
3441
|
+
count_q = count_q.where(CronRow.metadata_.contains(metadata))
|
|
3442
|
+
if plain:
|
|
3443
|
+
count_q = count_q.where(CronRow.metadata_.contains(plain))
|
|
3444
|
+
return int(await conn.session.scalar(count_q) or 0)
|
|
3445
|
+
|
|
3446
|
+
@staticmethod
|
|
3447
|
+
async def next(conn: PgConnectionProto, ctx: Any = None) -> AsyncIterator:
|
|
3448
|
+
"""Yield due crons, advancing next_run_date under SKIP LOCKED."""
|
|
3449
|
+
from langgraph_api.utils import next_cron_date
|
|
3450
|
+
|
|
3451
|
+
del ctx
|
|
3452
|
+
now = datetime.now(UTC)
|
|
3453
|
+
# Claim due rows; advance next_run_date immediately so concurrent replicas skip them.
|
|
3454
|
+
result = await conn.session.execute(
|
|
3455
|
+
text(
|
|
3456
|
+
"""
|
|
3457
|
+
SELECT cron_id FROM crons
|
|
3458
|
+
WHERE enabled = true
|
|
3459
|
+
AND (end_time IS NULL OR end_time >= :now)
|
|
3460
|
+
AND (next_run_date IS NULL OR next_run_date <= :now)
|
|
3461
|
+
ORDER BY next_run_date ASC NULLS FIRST
|
|
3462
|
+
FOR UPDATE SKIP LOCKED
|
|
3463
|
+
"""
|
|
3464
|
+
),
|
|
3465
|
+
{"now": now},
|
|
3466
|
+
)
|
|
3467
|
+
cron_ids = [row[0] for row in result.all()]
|
|
3468
|
+
for cron_id in cron_ids:
|
|
3469
|
+
row = await _aget_cron(conn.session, cron_id)
|
|
3470
|
+
if row is None:
|
|
3471
|
+
continue
|
|
3472
|
+
payload = cron_to_dict(row)
|
|
3473
|
+
# Advance before yield (commit happens when connect() exits).
|
|
3474
|
+
row.next_run_date = next_cron_date(row.schedule, now, timezone=row.timezone)
|
|
3475
|
+
row.updated_at = datetime.now(UTC)
|
|
3476
|
+
await conn.session.flush()
|
|
3477
|
+
yield {**payload, "now": now}
|
|
3478
|
+
|
|
3479
|
+
@staticmethod
|
|
3480
|
+
async def set_next_run_date(
|
|
3481
|
+
conn: PgConnectionProto,
|
|
3482
|
+
cron_id: UUID,
|
|
3483
|
+
next_run_date: datetime,
|
|
3484
|
+
ctx: Any = None,
|
|
3485
|
+
) -> None:
|
|
3486
|
+
cron_id = _ensure_uuid(cron_id)
|
|
3487
|
+
row = await _aget_cron(conn.session, cron_id)
|
|
3488
|
+
if row is None:
|
|
3489
|
+
return
|
|
3490
|
+
row.next_run_date = next_run_date
|
|
3491
|
+
row.updated_at = datetime.now(UTC)
|
|
3492
|
+
await conn.session.flush()
|
|
3493
|
+
|
|
3494
|
+
|
|
3495
|
+
async def listen_for_cancellation(
|
|
3496
|
+
queue: asyncio.Queue, run_id: UUID, thread_id: UUID | None, done: Any
|
|
3497
|
+
) -> None:
|
|
3498
|
+
"""Listen for cancellation messages and set the done event accordingly."""
|
|
3499
|
+
from langgraph_api.errors import UserInterrupt, UserRollback
|
|
3500
|
+
|
|
3501
|
+
stream_manager = get_stream_manager()
|
|
3502
|
+
|
|
3503
|
+
# Hydrate from Redis so a cancel published before this worker subscribed is still observed.
|
|
3504
|
+
if control_key := await stream_manager.aget_control_key(run_id, thread_id):
|
|
3505
|
+
payload = control_key.data
|
|
3506
|
+
if payload == b"rollback":
|
|
3507
|
+
done.set(UserRollback())
|
|
3508
|
+
elif payload == b"interrupt":
|
|
3509
|
+
done.set(UserInterrupt())
|
|
3510
|
+
|
|
3511
|
+
while not done.is_set():
|
|
3512
|
+
try:
|
|
3513
|
+
# Do not break on timeout — long runs stay quiet until interrupt/rollback/done.
|
|
3514
|
+
message = await asyncio.wait_for(queue.get(), timeout=240)
|
|
3515
|
+
payload = message.data
|
|
3516
|
+
if payload == b"rollback":
|
|
3517
|
+
done.set(UserRollback())
|
|
3518
|
+
elif payload == b"interrupt":
|
|
3519
|
+
done.set(UserInterrupt())
|
|
3520
|
+
elif payload == b"done":
|
|
3521
|
+
done.set()
|
|
3522
|
+
break
|
|
3523
|
+
except TimeoutError:
|
|
3524
|
+
continue
|
|
3525
|
+
|
|
3526
|
+
|
|
3527
|
+
async def cancel_run(thread_id: UUID, run_id: UUID, ctx: Any = None) -> None:
|
|
3528
|
+
async with connect() as conn:
|
|
3529
|
+
await Runs.cancel(conn, [run_id], thread_id=thread_id, ctx=ctx)
|
|
3530
|
+
|
|
3531
|
+
|
|
3532
|
+
__all__ = [
|
|
3533
|
+
"Assistants",
|
|
3534
|
+
"Crons",
|
|
3535
|
+
"Runs",
|
|
3536
|
+
"StreamHandler",
|
|
3537
|
+
"Threads",
|
|
3538
|
+
]
|