pi-durable-python 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,889 @@
1
+ """Detached in-memory reference storage.
2
+
3
+ Reads and retained writes are copied intentionally, matching the ownership boundary of
4
+ serialization-backed stores. This is backend conformance, not defensive validation: storage still
5
+ trusts Session-supplied semantics such as ancestry and ownership.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable, Sequence
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, cast, overload
13
+
14
+ from ..context import Context
15
+ from ..delta import Op, apply_immutable_batches
16
+ from ..documents import address_id
17
+ from ..errors import StorageRequestError
18
+ from ..ids import (
19
+ ConversationId,
20
+ DocumentId,
21
+ EntryId,
22
+ Seq,
23
+ SubmissionId,
24
+ TaskId,
25
+ id_from_number,
26
+ seq_from_number,
27
+ )
28
+ from ..json import JsonObject, JsonValue, copy_json, freeze_json
29
+ from ..types import (
30
+ ConversationQuery,
31
+ ConversationRecord,
32
+ Cursor,
33
+ DocumentAddress,
34
+ DocumentContent,
35
+ DocumentCreate,
36
+ DocumentPoint,
37
+ DocumentQuery,
38
+ DocumentRecord,
39
+ EntryLookup,
40
+ EntryQuery,
41
+ EntryRecord,
42
+ EntryWithHead,
43
+ Page,
44
+ ScanOrder,
45
+ StorageWrite,
46
+ StoredDocument,
47
+ SubmissionQuery,
48
+ SubmissionRecord,
49
+ TaskQuery,
50
+ TaskRecord,
51
+ )
52
+ from .scan import next_cursor, scan_start
53
+
54
+ _MAX_SAFE_INTEGER = 9_007_199_254_740_991
55
+ _TASK_STATUSES = ("pending", "running", "waiting", "completing", "terminal")
56
+ _SUBMISSION_STATUSES = ("queued", "placed", "done", "unanswered")
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class DocumentRevision:
61
+ seq: Seq
62
+ content: DocumentContent
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class StoredDocumentState:
67
+ record: DocumentRecord
68
+ revisions: list[DocumentRevision]
69
+
70
+
71
+ @dataclass(slots=True)
72
+ class DocumentAction:
73
+ create: DocumentCreate | None = None
74
+ content: DocumentContent | None = None
75
+ retire: bool = False
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class DocumentAddressIndex:
80
+ ids: list[DocumentId] = field(default_factory=list)
81
+ current_id: DocumentId | None = None
82
+
83
+
84
+ def _is_safe_int(value: object) -> bool:
85
+ return isinstance(value, int) and not isinstance(value, bool) and abs(value) <= _MAX_SAFE_INTEGER
86
+
87
+
88
+ def _cursor_id(cursor: Cursor | None) -> DocumentId | None:
89
+ if cursor is None:
90
+ return None
91
+ after = cursor.get("after")
92
+ if not _is_safe_int(after):
93
+ raise StorageRequestError("Invalid storage cursor")
94
+ return id_from_number(cast(int, after))
95
+
96
+
97
+ def _lower_bound(ids: Sequence[int], target: int) -> int:
98
+ low, high = 0, len(ids)
99
+ while low < high:
100
+ middle = (low + high) // 2
101
+ if ids[middle] < target:
102
+ low = middle + 1
103
+ else:
104
+ high = middle
105
+ return low
106
+
107
+
108
+ def _upper_bound(ids: Sequence[int], target: int) -> int:
109
+ low, high = 0, len(ids)
110
+ while low < high:
111
+ middle = (low + high) // 2
112
+ if ids[middle] <= target:
113
+ low = middle + 1
114
+ else:
115
+ high = middle
116
+ return low
117
+
118
+
119
+ def _insert_sorted(ids: list[Any], id: Any) -> None:
120
+ if not ids or ids[-1] < id:
121
+ ids.append(id)
122
+ else:
123
+ ids.insert(_lower_bound(ids, id), id)
124
+
125
+
126
+ def _remove_sorted(ids: list[Any], id: Any) -> None:
127
+ index = _lower_bound(ids, id)
128
+ if index < len(ids) and ids[index] == id:
129
+ ids.pop(index)
130
+
131
+
132
+ def _insert_map_id(index: dict[Any, list[Any]], key: Any, id: Any) -> None:
133
+ ids = index.setdefault(key, [])
134
+ _insert_sorted(ids, id)
135
+
136
+
137
+ def _scope_key(scope: dict[str, Any]) -> str:
138
+ if scope["kind"] == "session":
139
+ return '["session"]'
140
+ if scope["kind"] == "conversation":
141
+ return f'["conversation",{scope["conversationId"]}]'
142
+ return f'["task",{scope["taskId"]}]'
143
+
144
+
145
+ def _address_key(address: DocumentAddress) -> str:
146
+ return address_id(address)
147
+
148
+
149
+ def _record_address_key(record: DocumentCreate | DocumentRecord) -> str:
150
+ key = {"key": record["key"]} if "key" in record else {}
151
+ return _address_key(cast(DocumentAddress, {"kind": record["kind"], "scope": record["scope"], **key}))
152
+
153
+
154
+ def _is_alive_at(record: DocumentRecord, at: DocumentPoint) -> bool:
155
+ if at == "current":
156
+ return "retiredAt" not in record
157
+ return record["createdAt"] <= at and ("retiredAt" not in record or at < record["retiredAt"])
158
+
159
+
160
+ def _is_current_only(record: DocumentRecord) -> bool:
161
+ return record["scope"]["kind"] != "conversation" or record.get("history") == "latest"
162
+
163
+
164
+ def _scan_indexes(ids: Sequence[int], order: ScanOrder, after: int | None) -> Iterable[int]:
165
+ if order == "ascending":
166
+ first = 0 if after is None else _upper_bound(ids, after)
167
+ yield from range(first, len(ids))
168
+ else:
169
+ end = len(ids) if after is None else _lower_bound(ids, after)
170
+ yield from range(end - 1, -1, -1)
171
+
172
+
173
+ def _page(values: Sequence[Any], limit: int, order: ScanOrder) -> Page[Any, Cursor]:
174
+ if not values:
175
+ return {"items": []}
176
+ items = [copy_json(item) for item in values[:limit]]
177
+ if len(values) <= limit:
178
+ return {"items": items}
179
+ return {"items": items, "next": next_cursor(items[-1]["id"], order)}
180
+
181
+
182
+ @dataclass(slots=True)
183
+ class _State:
184
+ record_types: dict[int, str] = field(default_factory=dict)
185
+ conversations: dict[ConversationId, ConversationRecord] = field(default_factory=dict)
186
+ conversation_ids: list[ConversationId] = field(default_factory=list)
187
+ conversation_ids_by_owner_conversation: dict[ConversationId, list[ConversationId]] = field(
188
+ default_factory=dict
189
+ )
190
+ conversation_ids_by_owner_task: dict[TaskId, list[ConversationId]] = field(default_factory=dict)
191
+ entries: dict[EntryId, EntryRecord] = field(default_factory=dict)
192
+ entry_ids: dict[ConversationId, list[EntryId]] = field(default_factory=dict)
193
+ head_entry_ids: dict[ConversationId, list[EntryId]] = field(default_factory=dict)
194
+ entry_commit_seqs: dict[EntryId, Seq] = field(default_factory=dict)
195
+ tasks: dict[TaskId, TaskRecord] = field(default_factory=dict)
196
+ task_ids: list[TaskId] = field(default_factory=list)
197
+ task_ids_by_status: dict[str, list[TaskId]] = field(
198
+ default_factory=lambda: {status: [] for status in _TASK_STATUSES}
199
+ )
200
+ submissions: dict[SubmissionId, SubmissionRecord] = field(default_factory=dict)
201
+ submission_ids: list[SubmissionId] = field(default_factory=list)
202
+ submission_ids_by_status: dict[str, list[SubmissionId]] = field(
203
+ default_factory=lambda: {status: [] for status in _SUBMISSION_STATUSES}
204
+ )
205
+ submission_ids_by_request: dict[ConversationId, dict[str, SubmissionId]] = field(default_factory=dict)
206
+ documents: dict[DocumentId, StoredDocumentState] = field(default_factory=dict)
207
+ document_addresses: dict[str, DocumentAddressIndex] = field(default_factory=dict)
208
+ document_ids_by_scope: dict[str, list[DocumentId]] = field(default_factory=dict)
209
+
210
+
211
+ class PreparedMemoryCommit:
212
+ """A fully validated, detached state mutation whose application performs no fallible preparation."""
213
+
214
+ __slots__ = ("_apply", "_applied", "seq", "writes")
215
+
216
+ def __init__(
217
+ self,
218
+ seq: Seq,
219
+ writes: Sequence[StorageWrite],
220
+ apply: Any,
221
+ ) -> None:
222
+ self.seq = seq
223
+ self.writes = writes
224
+ self._apply = apply
225
+ self._applied = False
226
+
227
+ def apply(self) -> Seq:
228
+ if not self._applied:
229
+ self._applied = True
230
+ self._apply()
231
+ return self.seq
232
+
233
+
234
+ class MemoryStorage:
235
+ """Detached in-memory reference implementation of :class:`~pi_durable.types.Storage`."""
236
+
237
+ def __init__(self) -> None:
238
+ self._state = _State()
239
+ self._next_id = 2
240
+ self._next_seq = 1
241
+ self._closed = False
242
+
243
+ async def commit(self, writes: Sequence[StorageWrite], context: Context) -> Seq:
244
+ return self.prepare_commit(writes).apply()
245
+
246
+ def prepare_commit(
247
+ self,
248
+ writes: Sequence[StorageWrite],
249
+ seq: Seq | None = None,
250
+ ) -> PreparedMemoryCommit:
251
+ """Validate and detach one commit without changing observable state."""
252
+
253
+ self._assert_open()
254
+ assigned = seq_from_number(self._next_seq) if seq is None else seq
255
+ if not _is_safe_int(assigned) or assigned < self._next_seq:
256
+ raise ValueError(f"Commit sequence {assigned} does not strictly increase")
257
+ detached = cast(
258
+ list[StorageWrite],
259
+ copy_json(
260
+ self._resolve_document_copies(
261
+ [cast(StorageWrite, copy_json(write)) for write in writes]
262
+ )
263
+ ),
264
+ )
265
+ frozen = cast(
266
+ list[StorageWrite],
267
+ freeze_json(detached),
268
+ )
269
+ self._check_global_ids(frozen)
270
+ document_actions = self._prepare_document_actions(frozen)
271
+ self._check_document_actions(document_actions)
272
+
273
+ def apply() -> None:
274
+ self._apply_prepared_commit(frozen, document_actions, assigned)
275
+
276
+ return PreparedMemoryCommit(assigned, frozen, apply)
277
+
278
+ # ── Validation and preparation ─────────────────────────────────────────
279
+
280
+ def _resolve_document_copies(self, writes: list[StorageWrite]) -> list[StorageWrite]:
281
+ if not any(write["type"] == "document.copy" for write in writes):
282
+ return writes
283
+ changed_ids: set[int] = set()
284
+ for write in writes:
285
+ if write["type"] in ("document.create", "document.copy"):
286
+ changed_ids.add(write["record"]["id"])
287
+ elif write["type"] in ("document.change", "document.retire"):
288
+ changed_ids.add(write["id"])
289
+ resolved: list[StorageWrite] = []
290
+ for write in writes:
291
+ if write["type"] != "document.copy":
292
+ resolved.append(write)
293
+ continue
294
+ try:
295
+ source = write["source"]
296
+ if source["id"] in changed_ids:
297
+ raise ValueError(f"Fork source document {source['id']} is changed in the copy batch")
298
+ stored = self._materialize_document(source["id"], source["at"])
299
+ if stored is None:
300
+ raise ValueError(f"Fork source document {source['id']} cannot be read")
301
+ source_record = stored["record"]
302
+ record = write["record"]
303
+ if (
304
+ source_record["scope"]["kind"] != "conversation"
305
+ or record["scope"]["kind"] != "conversation"
306
+ or source_record["kind"] != record["kind"]
307
+ or source_record.get("key") != record.get("key")
308
+ or source_record.get("history") != record.get("history")
309
+ or source_record.get("fork") != record.get("fork")
310
+ ):
311
+ raise ValueError(f"Fork source document {source['id']} does not match the copied record")
312
+ resolved.append(
313
+ cast(
314
+ StorageWrite,
315
+ {
316
+ "type": "document.create",
317
+ "record": record,
318
+ "content": {
319
+ "kind": "base",
320
+ "version": stored["version"],
321
+ "value": stored["value"],
322
+ },
323
+ },
324
+ )
325
+ )
326
+ except Exception as error:
327
+ raise ValueError(f"Document copy {write['record']['id']} was rejected") from error
328
+ return resolved
329
+
330
+ def _check_global_ids(self, writes: Sequence[StorageWrite]) -> None:
331
+ claimed: dict[int, str] = {}
332
+ for write in writes:
333
+ if write["type"] in ("document.change", "document.retire"):
334
+ continue
335
+ if write["type"] in ("document.create", "document.copy"):
336
+ table = "document"
337
+ id = write["record"]["id"]
338
+ else:
339
+ table = write["type"]
340
+ id = write["value"]["id"]
341
+ existing = self._state.record_types.get(id)
342
+ earlier = claimed.get(id)
343
+ if table in ("conversation", "entry", "document"):
344
+ if existing is not None:
345
+ raise ValueError(f"ID {id} already belongs to {existing}")
346
+ if earlier is not None:
347
+ raise ValueError(f"ID {id} is written more than once")
348
+ else:
349
+ if existing is not None and existing != table:
350
+ raise ValueError(f"ID {id} already belongs to {existing}")
351
+ if earlier is not None and earlier != table:
352
+ raise ValueError(f"ID {id} is written as two record types")
353
+ claimed[id] = table
354
+
355
+ def _prepare_document_actions(self, writes: Sequence[StorageWrite]) -> dict[DocumentId, DocumentAction]:
356
+ actions: dict[DocumentId, DocumentAction] = {}
357
+ for write in writes:
358
+ if write["type"] not in ("document.create", "document.change", "document.retire"):
359
+ continue
360
+ id = write["record"]["id"] if write["type"] == "document.create" else write["id"]
361
+ action = actions.setdefault(id, DocumentAction())
362
+ if write["type"] == "document.create":
363
+ if action.create is not None or action.content is not None:
364
+ raise ValueError(f"Document {id} has more than one content command")
365
+ action.create = write["record"]
366
+ action.content = write["content"]
367
+ elif write["type"] == "document.change":
368
+ if action.content is not None:
369
+ raise ValueError(f"Document {id} has more than one content command")
370
+ action.content = write["content"]
371
+ else:
372
+ if action.retire:
373
+ raise ValueError(f"Document {id} is retired more than once")
374
+ action.retire = True
375
+ return actions
376
+
377
+ def _check_document_actions(self, actions: dict[DocumentId, DocumentAction]) -> None:
378
+ live_counts: dict[str, int] = {}
379
+ for id, action in actions.items():
380
+ existing = self._state.documents.get(id)
381
+ if action.create is None and existing is None:
382
+ raise ValueError(f"Unknown document: {id}")
383
+ if action.create is not None and existing is not None:
384
+ raise ValueError(f"Document {id} already exists")
385
+ if existing is not None and "retiredAt" in existing.record:
386
+ raise ValueError(f"Document {id} is retired")
387
+ previous = existing.revisions[-1] if existing is not None and existing.revisions else None
388
+ if action.content is not None and action.content["kind"] == "delta":
389
+ if previous is None:
390
+ raise ValueError(f"Document {id} delta has no base")
391
+ if previous.content.get("version") != action.content["version"]:
392
+ raise ValueError(f"Document {id} version transition requires a base")
393
+ key = _record_address_key(action.create if action.create is not None else existing.record)
394
+ current_id = self._state.document_addresses.get(key, DocumentAddressIndex()).current_id
395
+ live = live_counts.get(key)
396
+ if live is None:
397
+ live = 0 if current_id is None else 1
398
+ if action.retire and current_id == id:
399
+ live -= 1
400
+ if action.create is not None and not action.retire:
401
+ live += 1
402
+ live_counts[key] = live
403
+ for live in live_counts.values():
404
+ if live > 1:
405
+ raise ValueError("Document address already has a current incarnation")
406
+
407
+ # ── Application ─────────────────────────────────────────────────────────
408
+
409
+ def _apply_prepared_commit(
410
+ self,
411
+ writes: Sequence[StorageWrite],
412
+ document_actions: dict[DocumentId, DocumentAction],
413
+ seq: Seq,
414
+ ) -> None:
415
+ state = self._state
416
+ for write in writes:
417
+ item = cast(dict[str, Any], write)
418
+ kind = item["type"]
419
+ if kind == "conversation":
420
+ value = item["value"]
421
+ state.record_types[value["id"]] = "conversation"
422
+ state.conversations[value["id"]] = value
423
+ _insert_sorted(state.conversation_ids, value["id"])
424
+ owner = value.get("owner")
425
+ if owner is not None:
426
+ _insert_map_id(
427
+ state.conversation_ids_by_owner_conversation,
428
+ owner["conversationId"],
429
+ value["id"],
430
+ )
431
+ _insert_map_id(state.conversation_ids_by_owner_task, owner["taskId"], value["id"])
432
+ self._next_id = max(self._next_id, value["id"] + 1)
433
+ elif kind == "entry":
434
+ value = item["value"]
435
+ state.record_types[value["id"]] = "entry"
436
+ state.entries[value["id"]] = value
437
+ state.entry_commit_seqs[value["id"]] = seq
438
+ ids = state.entry_ids.setdefault(value["conversationId"], [])
439
+ _insert_sorted(ids, value["id"])
440
+ if "head" in value:
441
+ heads = state.head_entry_ids.setdefault(value["conversationId"], [])
442
+ _insert_sorted(heads, value["id"])
443
+ self._next_id = max(self._next_id, value["id"] + 1)
444
+ elif kind == "task":
445
+ value = item["value"]
446
+ state.record_types[value["id"]] = "task"
447
+ previous = state.tasks.get(value["id"])
448
+ if previous is None:
449
+ _insert_sorted(state.task_ids, value["id"])
450
+ _insert_sorted(state.task_ids_by_status[value["state"]["status"]], value["id"])
451
+ elif previous["state"]["status"] != value["state"]["status"]:
452
+ _remove_sorted(state.task_ids_by_status[previous["state"]["status"]], value["id"])
453
+ _insert_sorted(state.task_ids_by_status[value["state"]["status"]], value["id"])
454
+ state.tasks[value["id"]] = value
455
+ self._next_id = max(self._next_id, value["id"] + 1)
456
+ elif kind == "submission":
457
+ value = item["value"]
458
+ state.record_types[value["id"]] = "submission"
459
+ previous = state.submissions.get(value["id"])
460
+ if previous is None:
461
+ _insert_sorted(state.submission_ids, value["id"])
462
+ _insert_sorted(state.submission_ids_by_status[value["status"]], value["id"])
463
+ elif previous["status"] != value["status"]:
464
+ _remove_sorted(state.submission_ids_by_status[previous["status"]], value["id"])
465
+ _insert_sorted(state.submission_ids_by_status[value["status"]], value["id"])
466
+ previous_request = previous.get("requestId") if previous is not None else None
467
+ if previous_request is not None:
468
+ requests = state.submission_ids_by_request.get(previous["conversationId"])
469
+ if requests is not None and requests.get(previous_request) == value["id"]:
470
+ requests.pop(previous_request, None)
471
+ if not requests:
472
+ state.submission_ids_by_request.pop(previous["conversationId"], None)
473
+ state.submissions[value["id"]] = value
474
+ request = value.get("requestId")
475
+ if request is not None:
476
+ requests = state.submission_ids_by_request.setdefault(value["conversationId"], {})
477
+ requests[request] = value["id"]
478
+ self._next_id = max(self._next_id, value["id"] + 1)
479
+ elif kind == "document.copy":
480
+ raise ValueError("Prepared document copy was not resolved")
481
+ self._apply_document_actions(document_actions, seq)
482
+ self._next_seq = seq + 1
483
+
484
+ def _apply_document_actions(self, actions: dict[DocumentId, DocumentAction], seq: Seq) -> None:
485
+ state = self._state
486
+ for id, action in actions.items():
487
+ stored = state.documents.get(id)
488
+ if action.create is not None:
489
+ record: DocumentRecord = cast(
490
+ DocumentRecord,
491
+ {
492
+ **action.create,
493
+ "createdAt": seq,
494
+ **({"retiredAt": seq} if action.retire else {}),
495
+ },
496
+ )
497
+ assert action.content is not None
498
+ stored = StoredDocumentState(record, [DocumentRevision(seq, action.content)])
499
+ state.record_types[id] = "document"
500
+ state.documents[id] = stored
501
+ key = _record_address_key(record)
502
+ address = state.document_addresses.setdefault(key, DocumentAddressIndex())
503
+ _insert_sorted(address.ids, id)
504
+ scope_ids = state.document_ids_by_scope.setdefault(_scope_key(record["scope"]), [])
505
+ _insert_sorted(scope_ids, id)
506
+ self._next_id = max(self._next_id, id + 1)
507
+ elif action.content is not None and stored is not None:
508
+ revision = DocumentRevision(seq, action.content)
509
+ if action.content["kind"] == "base" and _is_current_only(stored.record):
510
+ stored.revisions = [revision]
511
+ else:
512
+ stored.revisions.append(revision)
513
+ if action.retire and action.create is None and stored is not None:
514
+ stored.record = cast(DocumentRecord, {**stored.record, "retiredAt": seq})
515
+ if action.retire and stored is not None and _is_current_only(stored.record):
516
+ stored.revisions = []
517
+ if stored is not None and (action.create is not None or action.retire):
518
+ address = state.document_addresses[_record_address_key(stored.record)]
519
+ if action.retire and address.current_id == id:
520
+ address.current_id = None
521
+ if action.create is not None and not action.retire:
522
+ address.current_id = id
523
+
524
+ # ── Reads ───────────────────────────────────────────────────────────────
525
+
526
+ async def mint_id(self) -> int:
527
+ self._assert_open()
528
+ if not _is_safe_int(self._next_id):
529
+ raise ValueError("ID space is exhausted")
530
+ value = self._next_id
531
+ self._next_id += 1
532
+ return value
533
+
534
+ async def conversation(self, id: ConversationId, context: Context) -> ConversationRecord | None:
535
+ self._assert_open()
536
+ value = self._state.conversations.get(id)
537
+ return None if value is None else cast(ConversationRecord, copy_json(value))
538
+
539
+ async def scan_conversations(
540
+ self,
541
+ query: ConversationQuery,
542
+ limit: int,
543
+ cursor: Cursor | None,
544
+ context: Context,
545
+ ) -> Page[ConversationRecord, Cursor]:
546
+ self._assert_open()
547
+ owner_task = query.get("ownerTaskId")
548
+ owner_conversation = query.get("ownerConversationId")
549
+ if owner_task is not None:
550
+ ids: Sequence[ConversationId] = self._state.conversation_ids_by_owner_task.get(owner_task, [])
551
+ elif owner_conversation is not None:
552
+ ids = self._state.conversation_ids_by_owner_conversation.get(owner_conversation, [])
553
+ else:
554
+ ids = self._state.conversation_ids
555
+ start = scan_start(query.get("order"), cursor, "ascending")
556
+ values: list[ConversationRecord] = []
557
+ for index in _scan_indexes(ids, start.order, start.after):
558
+ if len(values) > limit:
559
+ break
560
+ value = self._state.conversations[ids[index]]
561
+ if owner_conversation is not None:
562
+ owner = value.get("owner")
563
+ if owner is None or owner["conversationId"] != owner_conversation:
564
+ continue
565
+ values.append(value)
566
+ return _page(values, limit, start.order)
567
+
568
+ @overload
569
+ async def entry(self, id: EntryId, context: Context, /) -> EntryLookup | None: ...
570
+
571
+ @overload
572
+ async def entry(
573
+ self,
574
+ conversation_id: ConversationId,
575
+ id: EntryId,
576
+ context: Context,
577
+ /,
578
+ ) -> EntryLookup | None: ...
579
+
580
+ async def entry(
581
+ self,
582
+ id_or_conversation_id: EntryId | ConversationId | int,
583
+ id_or_context: EntryId | Context | None = None,
584
+ context: Context | None = None,
585
+ ) -> EntryLookup | None:
586
+ self._assert_open()
587
+ if context is None:
588
+ id = id_from_number(id_or_conversation_id)
589
+ entry = self._state.entries.get(id)
590
+ if entry is None:
591
+ return None
592
+ return {
593
+ "entry": cast(EntryRecord, copy_json(entry)),
594
+ "commitSeq": self._state.entry_commit_seqs[id],
595
+ }
596
+ if not _is_safe_int(id_or_context):
597
+ raise StorageRequestError("Storage.entry() requires an entry ID")
598
+ conversation_id = id_from_number(id_or_conversation_id)
599
+ id = id_from_number(id_or_context)
600
+ entry = next(self._visible_entries(conversation_id, id, id), None)
601
+ if entry is None:
602
+ return None
603
+ return {"entry": cast(EntryRecord, copy_json(entry)), "commitSeq": self._state.entry_commit_seqs[id]}
604
+
605
+ async def find_latest_head_marker(
606
+ self,
607
+ conversation_id: ConversationId,
608
+ at_or_before_entry_id: EntryId | None,
609
+ context: Context,
610
+ ) -> EntryWithHead | None:
611
+ self._assert_open()
612
+ if conversation_id not in self._state.conversations:
613
+ raise StorageRequestError(f"Unknown conversation: {conversation_id}")
614
+ current_id = conversation_id
615
+ upper = _MAX_SAFE_INTEGER if at_or_before_entry_id is None else at_or_before_entry_id
616
+ while True:
617
+ ids = self._state.head_entry_ids.get(current_id, [])
618
+ index = _upper_bound(ids, upper) - 1
619
+ if index >= 0:
620
+ entry = self._state.entries[ids[index]]
621
+ value = dict(entry)
622
+ value["head"] = entry["head"]
623
+ return cast(EntryWithHead, copy_json(value))
624
+ conversation = self._state.conversations[current_id]
625
+ parent = conversation.get("parent")
626
+ if parent is None:
627
+ return None
628
+ upper = min(upper, parent["at"])
629
+ current_id = parent["conversationId"]
630
+
631
+ async def scan_entries(
632
+ self,
633
+ query: EntryQuery,
634
+ limit: int,
635
+ cursor: Cursor | None,
636
+ context: Context,
637
+ ) -> Page[EntryRecord, Cursor]:
638
+ self._assert_open()
639
+ start = scan_start(query.get("order"), cursor, "descending")
640
+ min_entry_id: int | None = query.get("minEntryId")
641
+ max_entry_id: int | None = query.get("maxEntryId")
642
+ if start.after is not None and start.order == "descending":
643
+ max_entry_id = min(max_entry_id if max_entry_id is not None else start.after, start.after - 1)
644
+ if start.after is not None and start.order == "ascending":
645
+ min_entry_id = max(min_entry_id if min_entry_id is not None else start.after, start.after + 1)
646
+ visible: list[EntryRecord] = []
647
+ entries = (
648
+ self._visible_entries(query["conversationId"], min_entry_id, max_entry_id)
649
+ if start.order == "descending"
650
+ else self._visible_entries_ascending(query["conversationId"], min_entry_id, max_entry_id)
651
+ )
652
+ for entry in entries:
653
+ visible.append(entry)
654
+ if len(visible) > limit:
655
+ break
656
+ return _page(visible, limit, start.order)
657
+
658
+ async def task(self, id: TaskId, context: Context) -> TaskRecord | None:
659
+ self._assert_open()
660
+ value = self._state.tasks.get(id)
661
+ return None if value is None else cast(TaskRecord, copy_json(value))
662
+
663
+ async def scan_tasks(
664
+ self,
665
+ query: TaskQuery,
666
+ limit: int,
667
+ cursor: Cursor | None,
668
+ context: Context,
669
+ ) -> Page[TaskRecord, Cursor]:
670
+ self._assert_open()
671
+ start = scan_start(query.get("order"), cursor, "ascending")
672
+ status = query.get("status")
673
+ ids: Sequence[TaskId] = (
674
+ self._state.task_ids if status is None else self._state.task_ids_by_status[status]
675
+ )
676
+ values: list[TaskRecord] = []
677
+ for index in _scan_indexes(ids, start.order, start.after):
678
+ if len(values) > limit:
679
+ break
680
+ value = self._state.tasks[ids[index]]
681
+ if "conversationId" in query and value["conversationId"] != query["conversationId"]:
682
+ continue
683
+ if "kind" in query and value["kind"] != query["kind"]:
684
+ continue
685
+ if "abortRequested" in query and value["abortRequested"] != query["abortRequested"]:
686
+ continue
687
+ if "background" in query and value["background"] != query["background"]:
688
+ continue
689
+ values.append(value)
690
+ return _page(values, limit, start.order)
691
+
692
+ async def submission(self, id: SubmissionId, context: Context) -> SubmissionRecord | None:
693
+ self._assert_open()
694
+ value = self._state.submissions.get(id)
695
+ return None if value is None else cast(SubmissionRecord, copy_json(value))
696
+
697
+ async def scan_submissions(
698
+ self,
699
+ query: SubmissionQuery,
700
+ limit: int,
701
+ cursor: Cursor | None,
702
+ context: Context,
703
+ ) -> Page[SubmissionRecord, Cursor]:
704
+ self._assert_open()
705
+ start = scan_start(query.get("order"), cursor, "ascending")
706
+ status = query.get("status")
707
+ ids: Sequence[SubmissionId] = (
708
+ self._state.submission_ids
709
+ if status is None
710
+ else self._state.submission_ids_by_status[status]
711
+ )
712
+ values: list[SubmissionRecord] = []
713
+ for index in _scan_indexes(ids, start.order, start.after):
714
+ if len(values) > limit:
715
+ break
716
+ value = self._state.submissions[ids[index]]
717
+ if "conversationId" in query and value["conversationId"] != query["conversationId"]:
718
+ continue
719
+ values.append(value)
720
+ return _page(values, limit, start.order)
721
+
722
+ async def submission_by_request(
723
+ self,
724
+ conversation_id: ConversationId,
725
+ request_id: str,
726
+ context: Context,
727
+ ) -> SubmissionRecord | None:
728
+ self._assert_open()
729
+ id = self._state.submission_ids_by_request.get(conversation_id, {}).get(request_id)
730
+ if id is None:
731
+ return None
732
+ return cast(SubmissionRecord, copy_json(self._state.submissions[id]))
733
+
734
+ async def find_document(
735
+ self,
736
+ address: DocumentAddress,
737
+ at: DocumentPoint,
738
+ context: Context,
739
+ ) -> DocumentRecord | None:
740
+ self._assert_open()
741
+ index = self._state.document_addresses.get(_address_key(address))
742
+ if at == "current":
743
+ if index is None or index.current_id is None:
744
+ return None
745
+ return cast(DocumentRecord, copy_json(self._state.documents[index.current_id].record))
746
+ ids = index.ids if index is not None else []
747
+ for id in ids:
748
+ record = self._state.documents[id].record
749
+ if _is_alive_at(record, at):
750
+ return cast(DocumentRecord, copy_json(record))
751
+ return None
752
+
753
+ async def document(self, id: DocumentId, at: DocumentPoint, context: Context) -> StoredDocument | None:
754
+ self._assert_open()
755
+ stored = self._materialize_document(id, at)
756
+ if stored is None:
757
+ return None
758
+ return {
759
+ "record": cast(DocumentRecord, copy_json(stored["record"])),
760
+ "version": stored["version"],
761
+ "value": cast(JsonObject, copy_json(stored["value"])),
762
+ "deltasSinceBase": stored["deltasSinceBase"],
763
+ }
764
+
765
+ async def scan_documents(
766
+ self,
767
+ query: DocumentQuery,
768
+ limit: int,
769
+ cursor: Cursor | None,
770
+ context: Context,
771
+ ) -> Page[DocumentRecord, Cursor]:
772
+ self._assert_open()
773
+ ids = self._state.document_ids_by_scope.get(_scope_key(query["scope"]), [])
774
+ after = _cursor_id(cursor)
775
+ start = 0 if after is None else _upper_bound(ids, after)
776
+ values: list[DocumentRecord] = []
777
+ for index in range(start, len(ids)):
778
+ if len(values) > limit:
779
+ break
780
+ record = self._state.documents[ids[index]].record
781
+ if "kind" in query and record["kind"] != query["kind"]:
782
+ continue
783
+ if _is_alive_at(record, query["at"]):
784
+ values.append(record)
785
+ return _page(values, limit, "ascending")
786
+
787
+ async def close(self, context: Context) -> None:
788
+ self._closed = True
789
+
790
+ # ── Internals ───────────────────────────────────────────────────────────
791
+
792
+ def _materialize_document(self, id: DocumentId, at: DocumentPoint) -> StoredDocument | None:
793
+ stored = self._state.documents.get(id)
794
+ if stored is None:
795
+ return None
796
+ if at != "current" and _is_current_only(stored.record):
797
+ raise StorageRequestError(f"Document {id} does not retain historical content")
798
+ if not _is_alive_at(stored.record, at):
799
+ return None
800
+ revisions = (
801
+ stored.revisions
802
+ if at == "current"
803
+ else [revision for revision in stored.revisions if revision.seq <= at]
804
+ )
805
+ base_index = len(revisions) - 1
806
+ while base_index >= 0 and revisions[base_index].content["kind"] != "base":
807
+ base_index -= 1
808
+ if base_index < 0:
809
+ raise ValueError(f"Document {id} is missing a required base")
810
+ base = revisions[base_index].content
811
+ assert base["kind"] == "base"
812
+ version = base["version"]
813
+ batches: list[Sequence[Op]] = []
814
+ for revision in revisions[base_index + 1:]:
815
+ content = revision.content
816
+ if content["kind"] != "delta" or content["version"] != version:
817
+ raise ValueError(f"Document {id} crosses a stored version boundary without a base")
818
+ batches.append(content["ops"])
819
+ value = apply_immutable_batches(cast(JsonValue, base["value"]), batches)
820
+ return {
821
+ "record": stored.record,
822
+ "version": version,
823
+ "value": cast(JsonObject, value),
824
+ "deltasSinceBase": len(revisions) - base_index - 1,
825
+ }
826
+
827
+ def _visible_entries(
828
+ self,
829
+ conversation_id: ConversationId,
830
+ min_entry_id: int | None = None,
831
+ max_entry_id: int | None = None,
832
+ ) -> Iterable[EntryRecord]:
833
+ if conversation_id not in self._state.conversations:
834
+ raise StorageRequestError(f"Unknown conversation: {conversation_id}")
835
+ minimum = -(2**63) if min_entry_id is None else min_entry_id
836
+ upper = 2**63 - 1 if max_entry_id is None else max_entry_id
837
+ current_id = conversation_id
838
+ while True:
839
+ ids = self._state.entry_ids.get(current_id, [])
840
+ for index in range(_upper_bound(ids, upper) - 1, -1, -1):
841
+ id = ids[index]
842
+ if id < minimum:
843
+ break
844
+ yield self._state.entries[id]
845
+ conversation = self._state.conversations[current_id]
846
+ parent = conversation.get("parent")
847
+ if parent is None:
848
+ break
849
+ upper = min(upper, parent["at"])
850
+ if upper < minimum:
851
+ break
852
+ current_id = parent["conversationId"]
853
+
854
+ def _visible_entries_ascending(
855
+ self,
856
+ conversation_id: ConversationId,
857
+ min_entry_id: int | None = None,
858
+ max_entry_id: int | None = None,
859
+ ) -> Iterable[EntryRecord]:
860
+ if conversation_id not in self._state.conversations:
861
+ raise StorageRequestError(f"Unknown conversation: {conversation_id}")
862
+ minimum = -(2**63) if min_entry_id is None else min_entry_id
863
+ upper = 2**63 - 1 if max_entry_id is None else max_entry_id
864
+ segments: list[tuple[ConversationId, int]] = []
865
+ current_id = conversation_id
866
+ while True:
867
+ segments.append((current_id, upper))
868
+ conversation = self._state.conversations[current_id]
869
+ parent = conversation.get("parent")
870
+ if parent is None:
871
+ break
872
+ upper = min(upper, parent["at"])
873
+ if upper < minimum:
874
+ break
875
+ current_id = parent["conversationId"]
876
+ for segment_id, segment_upper in reversed(segments):
877
+ ids = self._state.entry_ids.get(segment_id, [])
878
+ for index in range(_lower_bound(ids, minimum), len(ids)):
879
+ id = ids[index]
880
+ if id > segment_upper:
881
+ break
882
+ yield self._state.entries[id]
883
+
884
+ def _assert_open(self) -> None:
885
+ if self._closed:
886
+ raise RuntimeError("MemoryStorage is closed")
887
+
888
+
889
+ __all__ = ["MemoryStorage", "PreparedMemoryCommit"]