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.
@@ -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
+ ]