harness-sdk-python 0.20.0__tar.gz → 0.22.0__tar.gz

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.
Files changed (37) hide show
  1. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/PKG-INFO +3 -3
  2. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/pyproject.toml +2 -2
  3. harness_sdk_python-0.22.0/src/harness_sdk/__init__.py +29 -0
  4. harness_sdk_python-0.22.0/src/harness_sdk/checkpoints.py +556 -0
  5. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/src/harness_sdk/run_manager.py +485 -338
  6. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/run_helpers.py +96 -68
  7. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_admit_steer.py +20 -13
  8. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_applied_visibility.py +3 -2
  9. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_batches.py +1 -1
  10. harness_sdk_python-0.22.0/tests/test_checkpoints.py +516 -0
  11. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_durability.py +3 -5
  12. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_fenced_postgres.py +9 -8
  13. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_outcomes.py +6 -23
  14. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_restore.py +1 -13
  15. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_run_crash_sweep.py +77 -55
  16. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_settle.py +10 -4
  17. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_stop_continue.py +4 -4
  18. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_stopping_window.py +118 -14
  19. harness_sdk_python-0.20.0/src/harness_sdk/__init__.py +0 -9
  20. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/.gitignore +0 -0
  21. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/README.md +0 -0
  22. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/examples/__init__.py +0 -0
  23. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/examples/runs_app.py +0 -0
  24. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/src/harness_sdk/fenced_postgres.py +0 -0
  25. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/src/harness_sdk/linear_thread.py +0 -0
  26. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_branch_anchor.py +0 -0
  27. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_dispatching.py +0 -0
  28. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_edit_dispatched.py +0 -0
  29. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_edit_reload.py +0 -0
  30. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_enqueue.py +0 -0
  31. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_input_required.py +0 -0
  32. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_linear_thread.py +0 -0
  33. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_meta.py +0 -0
  34. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_placement.py +0 -0
  35. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_prepare_hooks.py +0 -0
  36. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_rewind_during_run.py +0 -0
  37. {harness_sdk_python-0.20.0 → harness_sdk_python-0.22.0}/tests/test_steer.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: harness-sdk-python
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: RunManager: the harness-sdk runs subsystem for Python Statewire hosts
5
5
  Project-URL: Repository, https://github.com/assistant-ui/harness-sdk
6
6
  License-Expression: MIT
7
7
  Requires-Python: <4.0,>=3.12
8
- Requires-Dist: pinned<0.14,>=0.13.0
9
- Requires-Dist: statewire<0.11,>=0.10.0
8
+ Requires-Dist: pinned<0.15,>=0.14.0
9
+ Requires-Dist: statewire<0.12,>=0.11.0
10
10
  Provides-Extra: deepagents
11
11
  Requires-Dist: deepagents>=0.6.12; extra == 'deepagents'
12
12
  Requires-Dist: langchain-core>=0.3; extra == 'deepagents'
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "harness-sdk-python"
3
- version = "0.20.0"
3
+ version = "0.22.0"
4
4
  description = "RunManager: the harness-sdk runs subsystem for Python Statewire hosts"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.12,<4.0"
8
- dependencies = ["statewire>=0.10.0,<0.11", "pinned>=0.13.0,<0.14"]
8
+ dependencies = ["statewire>=0.11.0,<0.12", "pinned>=0.14.0,<0.15"]
9
9
 
10
10
  [project.optional-dependencies]
11
11
  postgres = ["langgraph-checkpoint-postgres>=2.0.0"]
@@ -0,0 +1,29 @@
1
+ from .checkpoints import (
2
+ CheckpointIndex,
3
+ CheckpointStore,
4
+ CheckpointThread,
5
+ MemoryCheckpointIndex,
6
+ Plan,
7
+ PostgresCheckpointIndex,
8
+ Snapshot,
9
+ checkpoint_thread,
10
+ langgraph_checkpoints,
11
+ )
12
+ from .linear_thread import linear_thread
13
+ from .run_manager import RunManager, RunManagerDescriptor, run_manager
14
+
15
+ __all__ = [
16
+ "CheckpointIndex",
17
+ "CheckpointStore",
18
+ "CheckpointThread",
19
+ "MemoryCheckpointIndex",
20
+ "Plan",
21
+ "PostgresCheckpointIndex",
22
+ "RunManager",
23
+ "RunManagerDescriptor",
24
+ "Snapshot",
25
+ "checkpoint_thread",
26
+ "langgraph_checkpoints",
27
+ "linear_thread",
28
+ "run_manager",
29
+ ]
@@ -0,0 +1,556 @@
1
+ """Checkpoint subsystem: a sidecar branch/message index over an external
2
+ checkpoint store (``get(checkpoint_id)`` only), designed for langgraph.
3
+
4
+ A branch is a resumption point plus its continuation; edits, regenerations,
5
+ reloads, and compactions all create one. ``branch_id`` is the entry's array
6
+ position + 1; the root branch is 0 and the array holds fork entries only.
7
+ Every ingested checkpoint advances ``last_checkpoint_id`` for every branch in
8
+ the state's ``branch_ids`` path, so a branch's pointer is always its
9
+ subtree's most recent checkpoint. Rows and entries are insert-only;
10
+ ``last_checkpoint_id`` is the only update.
11
+ """
12
+
13
+ import asyncio
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Callable, Protocol
16
+
17
+ from .linear_thread import linear_thread
18
+
19
+ _FORK_TRIGGERS = ("message-edit", "message-reload")
20
+ _TIP_TRIGGERS = (
21
+ "message-send",
22
+ "input-resume",
23
+ "error-continue",
24
+ "stop-continue",
25
+ "steer",
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Snapshot:
31
+ """One checkpoint's projection: its id, message list, and lineage channel."""
32
+
33
+ checkpoint_id: str
34
+ messages: list[dict[str, Any]]
35
+ lineage: dict[str, Any] | None = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Plan:
40
+ """A resumption: the checkpoint to resume from, survivors to re-append, and whether a branch forks."""
41
+
42
+ checkpoint_id: str | None
43
+ replay: list[dict[str, Any]]
44
+ fork: bool
45
+
46
+
47
+ class CheckpointStore(Protocol):
48
+ """The whole read seam over the external framework's checkpoint store."""
49
+
50
+ async def get(self, checkpoint_id: str | None) -> Snapshot | None:
51
+ """The snapshot named by ``checkpoint_id``, the latest for None, None when absent."""
52
+ ...
53
+
54
+
55
+ class CheckpointIndex(Protocol):
56
+ """The sidecar index: branch entries per thread row, one row per message."""
57
+
58
+ async def read_thread(
59
+ self, thread_id: str, checkpoint_ns: str
60
+ ) -> list[dict[str, Any]]: ...
61
+
62
+ async def append_branch(
63
+ self,
64
+ thread_id: str,
65
+ checkpoint_ns: str,
66
+ *,
67
+ fork_message_id: str | None,
68
+ last_checkpoint_id: str,
69
+ ) -> int: ...
70
+
71
+ async def bump_tips(
72
+ self,
73
+ thread_id: str,
74
+ checkpoint_ns: str,
75
+ branch_ids: list[int],
76
+ checkpoint_id: str,
77
+ ) -> None: ...
78
+
79
+ async def upsert_messages(self, rows: list[dict[str, Any]]) -> None: ...
80
+
81
+ async def get_message(
82
+ self, thread_id: str, checkpoint_ns: str, message_id: str
83
+ ) -> dict[str, Any] | None: ...
84
+
85
+
86
+ def _check_branch_ids(branch_ids: list[int], count: int) -> None:
87
+ for branch_id in branch_ids:
88
+ if not isinstance(branch_id, int) or not 1 <= branch_id <= count:
89
+ raise KeyError(f"unknown branch_id {branch_id!r} (index holds {count})")
90
+
91
+
92
+ @dataclass
93
+ class MemoryCheckpointIndex:
94
+ """Dict-backed CheckpointIndex for tests."""
95
+
96
+ _threads: dict[tuple[str, str], list[dict[str, Any]]] = field(default_factory=dict)
97
+ _messages: dict[tuple[str, str, str], dict[str, Any]] = field(default_factory=dict)
98
+
99
+ async def read_thread(
100
+ self, thread_id: str, checkpoint_ns: str
101
+ ) -> list[dict[str, Any]]:
102
+ return [dict(e) for e in self._threads.get((thread_id, checkpoint_ns), [])]
103
+
104
+ async def append_branch(
105
+ self,
106
+ thread_id: str,
107
+ checkpoint_ns: str,
108
+ *,
109
+ fork_message_id: str | None,
110
+ last_checkpoint_id: str,
111
+ ) -> int:
112
+ branches = self._threads.setdefault((thread_id, checkpoint_ns), [])
113
+ branches.append(
114
+ {
115
+ "fork_message_id": fork_message_id,
116
+ "last_checkpoint_id": last_checkpoint_id,
117
+ }
118
+ )
119
+ return len(branches)
120
+
121
+ async def bump_tips(
122
+ self,
123
+ thread_id: str,
124
+ checkpoint_ns: str,
125
+ branch_ids: list[int],
126
+ checkpoint_id: str,
127
+ ) -> None:
128
+ branches = self._threads.get((thread_id, checkpoint_ns), [])
129
+ _check_branch_ids(branch_ids, len(branches))
130
+ for branch_id in branch_ids:
131
+ branches[branch_id - 1]["last_checkpoint_id"] = checkpoint_id
132
+
133
+ async def upsert_messages(self, rows: list[dict[str, Any]]) -> None:
134
+ for row in rows:
135
+ key = (row["thread_id"], row["checkpoint_ns"], row["message_id"])
136
+ self._messages.setdefault(
137
+ key,
138
+ {
139
+ "anchor_checkpoint_id": row["anchor_checkpoint_id"],
140
+ "branch_id": row["branch_id"],
141
+ },
142
+ )
143
+
144
+ async def get_message(
145
+ self, thread_id: str, checkpoint_ns: str, message_id: str
146
+ ) -> dict[str, Any] | None:
147
+ row = self._messages.get((thread_id, checkpoint_ns, message_id))
148
+ return dict(row) if row is not None else None
149
+
150
+
151
+ class PostgresCheckpointIndex:
152
+ """psycopg-backed CheckpointIndex; ``setup()`` creates the two tables."""
153
+
154
+ def __init__(self, conn: Any) -> None:
155
+ try:
156
+ import psycopg # noqa: F401
157
+ except ImportError as exc:
158
+ raise ImportError(
159
+ "harness_sdk.checkpoints.PostgresCheckpointIndex requires the "
160
+ "'postgres' extra: pip install 'harness-sdk-python[postgres]'"
161
+ ) from exc
162
+ self._conn = conn
163
+
164
+ async def setup(self) -> None:
165
+ await self._conn.execute(
166
+ "CREATE TABLE IF NOT EXISTS checkpoint_threads ("
167
+ " thread_id TEXT NOT NULL,"
168
+ " checkpoint_ns TEXT NOT NULL,"
169
+ " branches JSONB NOT NULL,"
170
+ " PRIMARY KEY (thread_id, checkpoint_ns))"
171
+ )
172
+ await self._conn.execute(
173
+ "CREATE TABLE IF NOT EXISTS checkpoint_messages ("
174
+ " thread_id TEXT NOT NULL,"
175
+ " checkpoint_ns TEXT NOT NULL,"
176
+ " message_id TEXT NOT NULL,"
177
+ " anchor_checkpoint_id TEXT,"
178
+ " branch_id INTEGER NOT NULL,"
179
+ " PRIMARY KEY (thread_id, checkpoint_ns, message_id))"
180
+ )
181
+
182
+ async def read_thread(
183
+ self, thread_id: str, checkpoint_ns: str
184
+ ) -> list[dict[str, Any]]:
185
+ cur = await self._conn.execute(
186
+ "SELECT branches FROM checkpoint_threads WHERE thread_id = %s AND checkpoint_ns = %s",
187
+ (thread_id, checkpoint_ns),
188
+ )
189
+ row = await cur.fetchone()
190
+ return row[0] if row is not None else []
191
+
192
+ async def append_branch(
193
+ self,
194
+ thread_id: str,
195
+ checkpoint_ns: str,
196
+ *,
197
+ fork_message_id: str | None,
198
+ last_checkpoint_id: str,
199
+ ) -> int:
200
+ from psycopg.types.json import Jsonb
201
+
202
+ entry = {
203
+ "fork_message_id": fork_message_id,
204
+ "last_checkpoint_id": last_checkpoint_id,
205
+ }
206
+ async with self._conn.transaction():
207
+ await self._conn.execute(
208
+ "INSERT INTO checkpoint_threads (thread_id, checkpoint_ns, branches)"
209
+ " VALUES (%s, %s, '[]'::jsonb) ON CONFLICT DO NOTHING",
210
+ (thread_id, checkpoint_ns),
211
+ )
212
+ cur = await self._conn.execute(
213
+ "UPDATE checkpoint_threads SET branches = branches || %s"
214
+ " WHERE thread_id = %s AND checkpoint_ns = %s"
215
+ " RETURNING jsonb_array_length(branches)",
216
+ (Jsonb([entry]), thread_id, checkpoint_ns),
217
+ )
218
+ return (await cur.fetchone())[0]
219
+
220
+ async def bump_tips(
221
+ self,
222
+ thread_id: str,
223
+ checkpoint_ns: str,
224
+ branch_ids: list[int],
225
+ checkpoint_id: str,
226
+ ) -> None:
227
+ cur = await self._conn.execute(
228
+ "SELECT jsonb_array_length(branches) FROM checkpoint_threads"
229
+ " WHERE thread_id = %s AND checkpoint_ns = %s",
230
+ (thread_id, checkpoint_ns),
231
+ )
232
+ row = await cur.fetchone()
233
+ _check_branch_ids(branch_ids, row[0] if row is not None else 0)
234
+ await self._conn.execute(
235
+ "UPDATE checkpoint_threads SET branches = (SELECT jsonb_agg("
236
+ " CASE WHEN idx::int = ANY(%s)"
237
+ " THEN elem || jsonb_build_object('last_checkpoint_id', %s::text)"
238
+ " ELSE elem END)"
239
+ " FROM jsonb_array_elements(branches) WITH ORDINALITY AS t(elem, idx))"
240
+ " WHERE thread_id = %s AND checkpoint_ns = %s",
241
+ (branch_ids, checkpoint_id, thread_id, checkpoint_ns),
242
+ )
243
+
244
+ async def upsert_messages(self, rows: list[dict[str, Any]]) -> None:
245
+ async with self._conn.cursor() as cur:
246
+ await cur.executemany(
247
+ "INSERT INTO checkpoint_messages"
248
+ " (thread_id, checkpoint_ns, message_id, anchor_checkpoint_id, branch_id)"
249
+ " VALUES (%(thread_id)s, %(checkpoint_ns)s, %(message_id)s,"
250
+ " %(anchor_checkpoint_id)s, %(branch_id)s)"
251
+ " ON CONFLICT (thread_id, checkpoint_ns, message_id) DO NOTHING",
252
+ rows,
253
+ )
254
+
255
+ async def get_message(
256
+ self, thread_id: str, checkpoint_ns: str, message_id: str
257
+ ) -> dict[str, Any] | None:
258
+ cur = await self._conn.execute(
259
+ "SELECT anchor_checkpoint_id, branch_id FROM checkpoint_messages"
260
+ " WHERE thread_id = %s AND checkpoint_ns = %s AND message_id = %s",
261
+ (thread_id, checkpoint_ns, message_id),
262
+ )
263
+ row = await cur.fetchone()
264
+ if row is None:
265
+ return None
266
+ return {"anchor_checkpoint_id": row[0], "branch_id": row[1]}
267
+
268
+
269
+ def _message_dict(message: Any) -> dict[str, Any]:
270
+ if not isinstance(message, dict):
271
+ dump = getattr(message, "model_dump", None)
272
+ if dump is None:
273
+ raise TypeError(f"message must be a dict or a BaseMessage, got {message!r}")
274
+ message = dump()
275
+ message_id = message.get("id")
276
+ if not isinstance(message_id, str) or message_id == "":
277
+ raise ValueError(f"message without an id: {message!r}")
278
+ return message
279
+
280
+
281
+ def langgraph_checkpoints(
282
+ saver: Any,
283
+ *,
284
+ thread_id: str,
285
+ checkpoint_ns: str = "",
286
+ messages_key: str = "messages",
287
+ lineage_key: str = "lineage",
288
+ ) -> CheckpointStore:
289
+ """A CheckpointStore over a langgraph BaseCheckpointSaver's ``aget_tuple``."""
290
+
291
+ class _Store:
292
+ async def get(self, checkpoint_id: str | None) -> Snapshot | None:
293
+ configurable: dict[str, Any] = {
294
+ "thread_id": thread_id,
295
+ "checkpoint_ns": checkpoint_ns,
296
+ }
297
+ if checkpoint_id is not None:
298
+ configurable["checkpoint_id"] = checkpoint_id
299
+ saved = await saver.aget_tuple({"configurable": configurable})
300
+ if saved is None:
301
+ return None
302
+ values = saved.checkpoint["channel_values"]
303
+ lineage = values.get(lineage_key)
304
+ if lineage is not None and not isinstance(lineage, dict):
305
+ raise ValueError(f"lineage channel must be an object, got {lineage!r}")
306
+ return Snapshot(
307
+ checkpoint_id=saved.checkpoint["id"],
308
+ messages=[_message_dict(m) for m in values.get(messages_key, [])],
309
+ lineage=lineage,
310
+ )
311
+
312
+ return _Store()
313
+
314
+
315
+ @dataclass
316
+ class CheckpointThread:
317
+ """One thread's current window over the store, with the sidecar index kept in step."""
318
+
319
+ transcript: list[dict[str, Any]]
320
+ branches: list[dict[str, Any]]
321
+ lineage: dict[str, Any] | None
322
+ checkpoint_id: str | None
323
+ _store: CheckpointStore
324
+ _index: CheckpointIndex
325
+ _thread_id: str
326
+ _ns: str
327
+ _role: Callable[[dict[str, Any]], str]
328
+ _path: list[int] = field(default_factory=lambda: [0])
329
+ _pending_fork: dict[str, Any] | None = None
330
+
331
+ def _projection(self):
332
+ return linear_thread(messages=lambda: self.transcript, role=self._role)
333
+
334
+ async def get_message_meta(self, message_id: str | None) -> dict[str, Any] | None:
335
+ return await self._projection().get_message_meta(message_id)
336
+
337
+ async def get_message_child_id(self, parent_id: str) -> str | None:
338
+ return await self._projection().get_message_child_id(parent_id)
339
+
340
+ def sibling_branches(self, message_id: str) -> list[dict[str, Any]]:
341
+ """Branch entries forked at ``message_id``'s parent, as {branch_id, **entry} in allocation order; the transcript in hand is the extra continuation."""
342
+ index = next(
343
+ (i for i, m in enumerate(self.transcript) if m["id"] == message_id), None
344
+ )
345
+ if index is None:
346
+ raise KeyError(f"message {message_id!r} is not in the current window")
347
+ parent_id = self.transcript[index - 1]["id"] if index > 0 else None
348
+ return [
349
+ {"branch_id": i + 1, **entry}
350
+ for i, entry in enumerate(self.branches)
351
+ if entry["fork_message_id"] == parent_id
352
+ ]
353
+
354
+ async def plan(self, trigger: str, *, rollback_to: str | None = None) -> Plan:
355
+ """The resumption for ``trigger``; a fork trigger names its last surviving message via ``rollback_to``."""
356
+ if trigger in _TIP_TRIGGERS:
357
+ if rollback_to is not None:
358
+ raise ValueError(f"rollback_to is invalid for trigger {trigger!r}")
359
+ return Plan(self.checkpoint_id, [], False)
360
+ if trigger not in _FORK_TRIGGERS:
361
+ raise ValueError(f"unknown trigger {trigger!r}")
362
+ if rollback_to is None:
363
+ self._pending_fork = {
364
+ "fork_message_id": None,
365
+ "anchor_checkpoint_id": None,
366
+ "fork_branch": 0,
367
+ }
368
+ return Plan(None, [], True)
369
+ row = await self._index.get_message(self._thread_id, self._ns, rollback_to)
370
+ if row is None:
371
+ raise KeyError(f"message {rollback_to!r} is not indexed")
372
+ anchor = row["anchor_checkpoint_id"]
373
+ index = next(
374
+ (i for i, m in enumerate(self.transcript) if m["id"] == rollback_to), None
375
+ )
376
+ if index is None:
377
+ raise KeyError(f"message {rollback_to!r} is not in the current window")
378
+ survivors: list[dict[str, Any]] = []
379
+ for message in reversed(self.transcript[: index + 1]):
380
+ meta = await self._index.get_message(self._thread_id, self._ns, message["id"])
381
+ if meta is None or meta["anchor_checkpoint_id"] != anchor:
382
+ break
383
+ survivors.append(message)
384
+ survivors.reverse()
385
+ self._pending_fork = {
386
+ "fork_message_id": rollback_to,
387
+ "anchor_checkpoint_id": anchor,
388
+ "fork_branch": row["branch_id"],
389
+ }
390
+ return Plan(anchor, survivors, True)
391
+
392
+ async def ingest(self, snapshot: Snapshot) -> None:
393
+ """Index one new checkpoint: bump the path's tips, upsert the message diff, and append the pending fork's branch entry; idempotent."""
394
+ pending, self._pending_fork = self._pending_fork, None
395
+ path = (
396
+ list(snapshot.lineage["branch_ids"])
397
+ if snapshot.lineage is not None
398
+ else list(self._path)
399
+ )
400
+ anchor = self.checkpoint_id
401
+ if pending is not None:
402
+ # the new world descends from the fork message's branch, not the window's leaf
403
+ if pending["fork_branch"] in path:
404
+ path = path[: path.index(pending["fork_branch"]) + 1]
405
+ branch_id = await self._index.append_branch(
406
+ self._thread_id,
407
+ self._ns,
408
+ fork_message_id=pending["fork_message_id"],
409
+ last_checkpoint_id=snapshot.checkpoint_id,
410
+ )
411
+ self.branches = [
412
+ *self.branches,
413
+ {
414
+ "fork_message_id": pending["fork_message_id"],
415
+ "last_checkpoint_id": snapshot.checkpoint_id,
416
+ },
417
+ ]
418
+ path = [*path, branch_id]
419
+ anchor = pending["anchor_checkpoint_id"]
420
+ tips = [b for b in path if b != 0]
421
+ if tips:
422
+ await self._index.bump_tips(
423
+ self._thread_id, self._ns, tips, snapshot.checkpoint_id
424
+ )
425
+ for tip in tips:
426
+ if tip <= len(self.branches):
427
+ self.branches[tip - 1]["last_checkpoint_id"] = snapshot.checkpoint_id
428
+ known = {m["id"] for m in self.transcript}
429
+ rows = [
430
+ {
431
+ "thread_id": self._thread_id,
432
+ "checkpoint_ns": self._ns,
433
+ "message_id": message["id"],
434
+ "anchor_checkpoint_id": anchor,
435
+ "branch_id": path[-1] if path else 0,
436
+ }
437
+ for message in snapshot.messages
438
+ if message["id"] not in known
439
+ ]
440
+ if rows:
441
+ await self._index.upsert_messages(rows)
442
+ self.transcript = list(snapshot.messages)
443
+ self.checkpoint_id = snapshot.checkpoint_id
444
+ self.lineage = snapshot.lineage
445
+ self._path = path
446
+
447
+ def _load(self, snapshot: Snapshot) -> "CheckpointThread":
448
+ self.transcript = list(snapshot.messages)
449
+ self.checkpoint_id = snapshot.checkpoint_id
450
+ self.lineage = snapshot.lineage
451
+ self._path = (
452
+ list(snapshot.lineage["branch_ids"])
453
+ if snapshot.lineage is not None
454
+ else [0]
455
+ )
456
+ return self
457
+
458
+ def _tip_of(self, branch_id: int) -> str | None:
459
+ if branch_id == 0:
460
+ return None
461
+ _check_branch_ids([branch_id], len(self.branches))
462
+ return self.branches[branch_id - 1]["last_checkpoint_id"]
463
+
464
+ async def _get(self, checkpoint_id: str | None) -> Snapshot:
465
+ snapshot = await self._store.get(checkpoint_id)
466
+ if snapshot is None:
467
+ raise LookupError(f"checkpoint {checkpoint_id!r} is not in the store")
468
+ return snapshot
469
+
470
+ async def switch_branch(self, branch_id: int) -> "CheckpointThread":
471
+ """Load the branch's subtree tip as the current window."""
472
+ return self._load(await self._get(self._tip_of(branch_id)))
473
+
474
+ async def page_up(self) -> "CheckpointThread":
475
+ """Load the parent world's tail checkpoint as the current window."""
476
+ parent = (self.lineage or {}).get("parent_checkpoint_id")
477
+ if parent is None:
478
+ raise ValueError("the current window has no parent world")
479
+ return self._load(await self._get(parent))
480
+
481
+ async def page_down(self) -> "CheckpointThread":
482
+ """Load the newest world forked at the current window's tail message."""
483
+ if not self.transcript:
484
+ raise ValueError("the current window has no tail message")
485
+ tail = self.transcript[-1]["id"]
486
+ candidates = [
487
+ i + 1
488
+ for i, entry in enumerate(self.branches)
489
+ if entry["fork_message_id"] == tail
490
+ ]
491
+ if not candidates:
492
+ raise LookupError(f"no world forks at tail message {tail!r}")
493
+ return self._load(await self._get(self._tip_of(candidates[-1])))
494
+
495
+ async def locate(self, message_id: str) -> "CheckpointThread":
496
+ """Load a window containing ``message_id``: its branch's tip, or a fork's tip below it that took the pointer."""
497
+ row = await self._index.get_message(self._thread_id, self._ns, message_id)
498
+ if row is None:
499
+ raise KeyError(f"message {message_id!r} is not indexed")
500
+ queue = [row["branch_id"]]
501
+ checked: set[int] = set()
502
+ while queue:
503
+ branch_id = queue.pop(0)
504
+ checked.add(branch_id)
505
+ snapshot = await self._get(self._tip_of(branch_id))
506
+ if any(m["id"] == message_id for m in snapshot.messages):
507
+ return self._load(snapshot)
508
+ for i, entry in enumerate(self.branches):
509
+ candidate = i + 1
510
+ if candidate in checked or candidate in queue:
511
+ continue
512
+ fork_id = entry["fork_message_id"]
513
+ if fork_id is None:
514
+ fork_branch = 0
515
+ else:
516
+ fork_row = await self._index.get_message(
517
+ self._thread_id, self._ns, fork_id
518
+ )
519
+ if fork_row is None:
520
+ continue
521
+ fork_branch = fork_row["branch_id"]
522
+ if fork_branch in checked:
523
+ queue.append(candidate)
524
+ raise LookupError(f"no branch tip contains message {message_id!r}")
525
+
526
+
527
+ async def checkpoint_thread(
528
+ *,
529
+ store: CheckpointStore,
530
+ index: CheckpointIndex,
531
+ thread_id: str,
532
+ checkpoint_ns: str = "",
533
+ role: Callable[[dict[str, Any]], str],
534
+ ) -> CheckpointThread:
535
+ """Boot a CheckpointThread: the thread row and the latest snapshot load concurrently."""
536
+ if not isinstance(thread_id, str) or not thread_id:
537
+ raise ValueError(f"thread_id must be a non-empty string, got {thread_id!r}")
538
+ if not callable(role):
539
+ raise TypeError("role must be callable")
540
+ branches, snapshot = await asyncio.gather(
541
+ index.read_thread(thread_id, checkpoint_ns), store.get(None)
542
+ )
543
+ thread = CheckpointThread(
544
+ transcript=[],
545
+ branches=branches,
546
+ lineage=None,
547
+ checkpoint_id=None,
548
+ _store=store,
549
+ _index=index,
550
+ _thread_id=thread_id,
551
+ _ns=checkpoint_ns,
552
+ _role=role,
553
+ )
554
+ if snapshot is not None:
555
+ thread._load(snapshot)
556
+ return thread