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,1088 @@
1
+ """One atomic Session transaction.
2
+
3
+ The callback sees table reads only before its first table write, and every document acquisition is
4
+ staged. Settlement validates ownership and terminal-task retirement before storage admission;
5
+ adoption swaps committed document values in memory only after storage succeeds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from dataclasses import dataclass
12
+ from typing import Any, Literal, Protocol, cast
13
+
14
+ from ..context import Context
15
+ from ..delta import Op
16
+ from ..documents import (
17
+ AnyDocToken,
18
+ DocDefinition,
19
+ address_id,
20
+ check_record_scope,
21
+ check_record_version,
22
+ document_create,
23
+ materialize_document_value,
24
+ resolve_address,
25
+ )
26
+ from ..entries import Entry
27
+ from ..errors import ReadAfterWrite
28
+ from ..ids import (
29
+ ROOT_CONVERSATION_ID,
30
+ ConversationId,
31
+ DocumentId,
32
+ EntryId,
33
+ Seq,
34
+ SubmissionId,
35
+ TaskId,
36
+ )
37
+ from ..json import JsonObject, JsonValue, copy_json
38
+ from ..types import (
39
+ ConversationOwnership,
40
+ ConversationQuery,
41
+ ConversationRecord,
42
+ Cursor,
43
+ DocumentAddress,
44
+ DocumentCommitChange,
45
+ DocumentCopySource,
46
+ DocumentCreate,
47
+ DocumentRecord,
48
+ EntryDraft,
49
+ EntryQuery,
50
+ EntryRecord,
51
+ EntryWithHead,
52
+ Page,
53
+ Storage,
54
+ StorageWrite,
55
+ SubmissionCreate,
56
+ SubmissionRecord,
57
+ SubmissionSettlement,
58
+ TaskOptions,
59
+ TaskQuery,
60
+ TaskRecord,
61
+ entry_record,
62
+ )
63
+ from .drafts import DocumentDraft, PreparedDraft
64
+ from .forks import prepare_fork_document_copies
65
+
66
+ AnyTaskRecord = TaskRecord
67
+ _INTERNAL_SCAN_PAGE_SIZE = 256
68
+
69
+
70
+ @dataclass(slots=True)
71
+ class LoadedDocument:
72
+ """One committed document incarnation owned by the Session tracker cache."""
73
+
74
+ address_id: str
75
+ record: DocumentRecord
76
+ stored_version: int
77
+ value_version: int
78
+ deltas_since_base: int
79
+ value: JsonObject
80
+
81
+
82
+ class TransactionHost(Protocol):
83
+ """Session services used by a transaction while it holds the mutation line."""
84
+
85
+ storage: Storage
86
+
87
+ def now(self) -> int: ...
88
+
89
+ def cached(self, address_id: str) -> LoadedDocument | None: ...
90
+
91
+ async def load(
92
+ self,
93
+ definition: DocDefinition,
94
+ address_id: str,
95
+ address: DocumentAddress,
96
+ context: Context,
97
+ ) -> LoadedDocument | None: ...
98
+
99
+ def install(self, document: LoadedDocument) -> None: ...
100
+
101
+ def evict(self, address_id: str, record_id: DocumentId) -> None: ...
102
+
103
+ async def conversation_created(self, tx: Transaction, record: ConversationRecord) -> None: ...
104
+
105
+
106
+ @dataclass(slots=True)
107
+ class TransactionTask:
108
+ committed_read: asyncio.Future[AnyTaskRecord | None] | None = None
109
+ write: tuple[Literal["create", "replace"], AnyTaskRecord] | None = None
110
+ publication_conversation_id: int | None = None
111
+
112
+
113
+ @dataclass(frozen=True, slots=True)
114
+ class TransactionScope:
115
+ conversation_id: ConversationId | None = None
116
+ task_id: TaskId | None = None
117
+
118
+
119
+ @dataclass(slots=True)
120
+ class _LoadedTarget:
121
+ document: LoadedDocument
122
+
123
+
124
+ @dataclass(slots=True)
125
+ class _CreatedTarget:
126
+ record: DocumentCreate
127
+ version: int
128
+ value: JsonObject
129
+
130
+
131
+ @dataclass(slots=True)
132
+ class _ForkCopyTarget:
133
+ record: DocumentCreate
134
+ source: DocumentCopySource
135
+
136
+
137
+ @dataclass(slots=True)
138
+ class _RetireOnlyTarget:
139
+ record: DocumentRecord
140
+
141
+
142
+ DocumentTarget = _LoadedTarget | _CreatedTarget | _ForkCopyTarget | _RetireOnlyTarget
143
+
144
+
145
+ @dataclass(slots=True)
146
+ class DocumentEntry:
147
+ address_id: str
148
+ address: DocumentAddress
149
+ definition: DocDefinition | None = None
150
+ draft: DocumentDraft | None = None
151
+ acquisition: asyncio.Future[DocumentDraft] | None = None
152
+ target: DocumentTarget | None = None
153
+ retire_on_commit: bool = False
154
+ prepared: PreparedDraft | None = None
155
+
156
+
157
+ @dataclass(slots=True)
158
+ class _PlanChange:
159
+ loaded: LoadedDocument | None
160
+ definition: DocDefinition | None
161
+ version: int
162
+ value: JsonObject
163
+ ops: list[Op]
164
+
165
+
166
+ @dataclass(slots=True)
167
+ class DocumentPlan:
168
+ address_id: str
169
+ record: DocumentCreate | DocumentRecord
170
+ retire: bool = False
171
+ content: StorageWrite | None = None
172
+ change: _PlanChange | None = None
173
+ conversation_id: int | None = None
174
+
175
+
176
+ SubmissionChange = SubmissionSettlement | dict[str, Any]
177
+
178
+
179
+ def _apply_submission_change(current: SubmissionRecord, change: SubmissionChange) -> SubmissionRecord:
180
+ if current["status"] in ("done", "unanswered"):
181
+ return current
182
+ if change.get("status") == "placed":
183
+ if current["status"] != "queued":
184
+ raise ValueError(f"Submission {current['id']} is not queued")
185
+ status = "placed" if current["type"] == "input" else "done"
186
+ return cast(SubmissionRecord, {**current, "status": status, "entry": change["entry"]})
187
+ if change.get("status") == "done" and current["status"] != "placed":
188
+ raise ValueError(f"Submission {current['id']} is not a placed input")
189
+ return cast(SubmissionRecord, {**current, **change})
190
+
191
+
192
+ class Transaction:
193
+ """Transaction for one Session commit callback."""
194
+
195
+ def __init__(
196
+ self,
197
+ host: TransactionHost,
198
+ context: Context,
199
+ scope: TransactionScope | None = None,
200
+ ) -> None:
201
+ self._host = host
202
+ self._context = context
203
+ self._scope = scope or TransactionScope()
204
+ self._pending: set[asyncio.Future[Any]] = set()
205
+ self._sealed = False
206
+ self._has_table_write = False
207
+ self._writes: list[StorageWrite] = []
208
+ self._created_conversation_ids: set[int] = set()
209
+ self._fork_source_conversation_ids: set[int] = set()
210
+ self._fork_source_document_ids: set[int] = set()
211
+ self._tasks: dict[TaskId, TransactionTask] = {}
212
+ self._submissions: dict[SubmissionId, SubmissionRecord] = {}
213
+ self._submission_changes: list[tuple[SubmissionId, SubmissionChange]] = []
214
+ self._plans: list[DocumentPlan] = []
215
+ self._documents: list[DocumentEntry] = []
216
+ self._latest_document_by_address: dict[str, DocumentEntry] = {}
217
+
218
+ # ── Table reads ────────────────────────────────────────────────────────
219
+
220
+ async def conversation(self, id: ConversationId) -> ConversationRecord | None:
221
+ return await self._read("conversation", lambda: self._host.storage.conversation(id, self._context))
222
+
223
+ async def entry(self, first: EntryId | Entry[Any], second: EntryId | None = None) -> EntryRecord | None:
224
+ kind = None if isinstance(first, int) else first.kind
225
+ id = first if isinstance(first, int) else second
226
+ assert id is not None
227
+
228
+ async def read() -> EntryRecord | None:
229
+ stored = await self._host.storage.entry(id, self._context)
230
+ if stored is None:
231
+ return None
232
+ if kind is not None and stored["entry"].get("kind") != kind:
233
+ return None
234
+ return stored["entry"]
235
+
236
+ return await self._read("entry", read)
237
+
238
+ async def task(self, id: TaskId) -> AnyTaskRecord | None:
239
+ return await self._read("task", lambda: self._committed_task(id))
240
+
241
+ async def scan_conversations(
242
+ self,
243
+ query: ConversationQuery,
244
+ limit: int,
245
+ cursor: Cursor | None = None,
246
+ ) -> Page[ConversationRecord, Cursor]:
247
+ return await self._read(
248
+ "scan_conversations",
249
+ lambda: self._host.storage.scan_conversations(query, limit, cursor, self._context),
250
+ )
251
+
252
+ async def scan_entries(
253
+ self,
254
+ query: EntryQuery,
255
+ limit: int,
256
+ cursor: Cursor | None = None,
257
+ ) -> Page[EntryRecord, Cursor]:
258
+ return await self._read(
259
+ "scan_entries",
260
+ lambda: self._host.storage.scan_entries(query, limit, cursor, self._context),
261
+ )
262
+
263
+ async def latest_head_marker(self, conversation_id: ConversationId) -> EntryWithHead | None:
264
+ return await self._read(
265
+ "latest_head_marker",
266
+ lambda: self._host.storage.find_latest_head_marker(conversation_id, None, self._context),
267
+ )
268
+
269
+ async def scan_tasks(
270
+ self,
271
+ query: TaskQuery,
272
+ limit: int,
273
+ cursor: Cursor | None = None,
274
+ ) -> Page[TaskRecord, Cursor]:
275
+ return await self._read(
276
+ "scan_tasks",
277
+ lambda: self._host.storage.scan_tasks(query, limit, cursor, self._context),
278
+ )
279
+
280
+ async def submission(self, id: SubmissionId) -> SubmissionRecord | None:
281
+ return await self._read("submission", lambda: self._host.storage.submission(id, self._context))
282
+
283
+ async def submission_by_request(
284
+ self,
285
+ conversation_id: ConversationId,
286
+ request_id: str,
287
+ ) -> SubmissionRecord | None:
288
+ return await self._read(
289
+ "submission_by_request",
290
+ lambda: self._host.storage.submission_by_request(conversation_id, request_id, self._context),
291
+ )
292
+
293
+ # ── Table writes ───────────────────────────────────────────────────────
294
+
295
+ async def create_conversation(self, options: dict[str, Any]) -> ConversationRecord:
296
+ ownership = cast(ConversationOwnership, options["ownership"])
297
+ return await self._write(lambda: self._stage_conversation(None, ownership))
298
+
299
+ async def create_root_conversation(self) -> ConversationRecord:
300
+ return await self._write(
301
+ lambda: self._stage_conversation(None, {"kind": "ownerless"}, ROOT_CONVERSATION_ID)
302
+ )
303
+
304
+ async def fork_conversation(
305
+ self,
306
+ parent_conversation_id: ConversationId,
307
+ at: EntryId,
308
+ options: dict[str, Any],
309
+ ) -> ConversationRecord:
310
+ ownership = cast(ConversationOwnership, options["ownership"])
311
+ return await self._write(
312
+ lambda: self._stage_conversation(
313
+ {"conversationId": parent_conversation_id, "at": at},
314
+ ownership,
315
+ )
316
+ )
317
+
318
+ async def _stage_conversation(
319
+ self,
320
+ parent: dict[str, Any] | None,
321
+ ownership: ConversationOwnership,
322
+ reserved_id: ConversationId | None = None,
323
+ ) -> ConversationRecord:
324
+ owner_task_id = ownership["taskId"] if ownership["kind"] == "task" else None
325
+ id = (
326
+ reserved_id
327
+ if reserved_id is not None
328
+ else cast(ConversationId, await self._host.storage.mint_id())
329
+ )
330
+ self._assert_open()
331
+ owner: dict[str, Any] | None = None
332
+ if owner_task_id is not None:
333
+ task = await self._current_task(owner_task_id)
334
+ self._assert_open()
335
+ if task is None:
336
+ raise ValueError(f"Conversation owner task {owner_task_id} does not exist")
337
+ owner = {"conversationId": task["conversationId"], "taskId": owner_task_id}
338
+ record: dict[str, Any] = {"id": id}
339
+ if parent is not None:
340
+ record["parent"] = parent
341
+ if owner is not None:
342
+ record["owner"] = owner
343
+ copies = (
344
+ []
345
+ if parent is None
346
+ else await prepare_fork_document_copies(
347
+ self._host.storage,
348
+ parent["conversationId"],
349
+ parent["at"],
350
+ id,
351
+ self._context,
352
+ )
353
+ )
354
+ self._assert_open()
355
+ for copy in copies:
356
+ self._fork_source_document_ids.add(copy.source["id"])
357
+ entry = DocumentEntry(
358
+ address_id=address_id(copy.record),
359
+ address={"kind": copy.record["kind"], "scope": copy.record["scope"]},
360
+ target=_ForkCopyTarget(copy.record, copy.source),
361
+ )
362
+ if "key" in copy.record:
363
+ entry.address["key"] = copy.record["key"]
364
+ self._documents.append(entry)
365
+ self._latest_document_by_address[entry.address_id] = entry
366
+ if parent is not None:
367
+ self._fork_source_conversation_ids.add(parent["conversationId"])
368
+ self._created_conversation_ids.add(id)
369
+ self._writes.append(cast(StorageWrite, {"type": "conversation", "value": record}))
370
+ await self._host.conversation_created(self, cast(ConversationRecord, record))
371
+ self._assert_open()
372
+ return cast(ConversationRecord, record)
373
+
374
+ async def append_entry(
375
+ self,
376
+ first: ConversationId | Entry[Any],
377
+ second: ConversationId | EntryDraft,
378
+ third: EntryDraft | None = None,
379
+ ) -> EntryRecord:
380
+ if isinstance(first, int):
381
+ conversation_id = first
382
+ value = cast(EntryDraft, second)
383
+ else:
384
+ conversation_id = cast(ConversationId, second)
385
+ value = cast(EntryDraft, {**(third or {}), "kind": first.kind})
386
+ return await self._write(lambda: self._append_entry(conversation_id, value))
387
+
388
+ async def _append_entry(self, conversation_id: ConversationId, value: EntryDraft) -> EntryRecord:
389
+ await self._require_conversation(conversation_id)
390
+ self._assert_open()
391
+ id = cast(EntryId, await self._host.storage.mint_id())
392
+ self._assert_open()
393
+ record = entry_record(id, conversation_id, value, by_task_id=self._scope.task_id)
394
+ self._writes.append({"type": "entry", "value": record})
395
+ return record
396
+
397
+ async def create_task(self, task: Any, input: JsonValue, options: TaskOptions) -> TaskId:
398
+ return await self._write(lambda: self._create_task(task, input, options))
399
+
400
+ async def _create_task(self, task: Any, input: JsonValue, options: TaskOptions) -> TaskId:
401
+ ownership = options["ownership"]
402
+ owner: AnyTaskRecord | None = None
403
+ if ownership["kind"] == "task":
404
+ owner = await self._current_task(ownership["taskId"])
405
+ self._assert_open()
406
+ if owner is None:
407
+ raise ValueError(f"Task owner {ownership['taskId']} does not exist")
408
+ if options.get("background") is True:
409
+ raise TypeError("A child task cannot be background")
410
+ if (
411
+ options.get("conversationId") is not None
412
+ and options["conversationId"] != owner["conversationId"]
413
+ ):
414
+ raise ValueError(
415
+ f"A child task lives in its owner's conversation {owner['conversationId']}"
416
+ )
417
+ conversation_id = (
418
+ (owner["conversationId"] if owner is not None else None)
419
+ or options.get("conversationId")
420
+ or self._scope.conversation_id
421
+ )
422
+ if conversation_id is None:
423
+ raise TypeError("Tx.createTask() requires options.conversationId")
424
+ await self._require_conversation(conversation_id)
425
+ self._assert_open()
426
+ definition = task.definition
427
+ checkpoint = copy_json(definition.initial(input))
428
+ id = cast(TaskId, await self._host.storage.mint_id())
429
+ self._assert_open()
430
+ record: dict[str, Any] = {
431
+ "id": id,
432
+ "conversationId": conversation_id,
433
+ "kind": definition.name,
434
+ "version": definition.version,
435
+ "input": copy_json(input),
436
+ "background": options.get("background", False),
437
+ "abortRequested": False,
438
+ "state": {"status": "pending", "checkpoint": checkpoint},
439
+ }
440
+ if owner is not None:
441
+ record["owner"] = owner["id"]
442
+ if options.get("abandonOnRestart") is True:
443
+ record["abandonOnRestart"] = True
444
+ self._task_entry(id).write = ("create", cast(AnyTaskRecord, copy_json(record)))
445
+ return id
446
+
447
+ async def create_submission(self, create: SubmissionCreate) -> SubmissionRecord:
448
+ return await self._write(lambda: self._create_submission(create))
449
+
450
+ async def _create_submission(self, create: SubmissionCreate) -> SubmissionRecord:
451
+ await self._require_conversation(create["conversationId"])
452
+ self._assert_open()
453
+ id = cast(SubmissionId, await self._host.storage.mint_id())
454
+ self._assert_open()
455
+ record = cast(SubmissionRecord, copy_json({**create, "id": id}))
456
+ self._submissions[id] = record
457
+ return record
458
+
459
+ def settle_submission(self, id: SubmissionId, settlement: SubmissionSettlement) -> None:
460
+ self._assert_open()
461
+ self._has_table_write = True
462
+ self._submission_changes.append((id, cast(SubmissionChange, copy_json(settlement))))
463
+
464
+ def place_submission(self, id: SubmissionId, entry: EntryId) -> None:
465
+ self._assert_open()
466
+ self._has_table_write = True
467
+ self._submission_changes.append((id, {"status": "placed", "entry": entry}))
468
+
469
+ def set_task(self, value: AnyTaskRecord) -> None:
470
+ self._assert_open()
471
+ self._has_table_write = True
472
+ task = self._task_entry(value["id"])
473
+ candidate = task.write[1] if task.write is not None else None
474
+ if candidate is not None and candidate["state"]["status"] == "terminal":
475
+ raise ValueError(f"Task {value['id']} already has a terminal candidate")
476
+ if candidate is not None and candidate["conversationId"] != value["conversationId"]:
477
+ raise ValueError(f"Task {value['id']} cannot change conversations")
478
+ kind = "create" if task.write is not None and task.write[0] == "create" else "replace"
479
+ task.write = (kind, cast(AnyTaskRecord, copy_json(self._stamp_times(value, candidate))))
480
+
481
+ def staged_tasks(self) -> list[AnyTaskRecord]:
482
+ return [task.write[1] for task in self._tasks.values() if task.write is not None]
483
+
484
+ def staged_conversations(self) -> list[ConversationRecord]:
485
+ return [
486
+ write["value"]
487
+ for write in self._writes
488
+ if write["type"] == "conversation"
489
+ ]
490
+
491
+ # ── Documents ──────────────────────────────────────────────────────────
492
+
493
+ async def doc(self, token: AnyDocToken, *args: Any) -> DocumentDraft:
494
+ self._assert_open()
495
+ definition = token.definition
496
+ resolved = resolve_address(definition, args)
497
+ self._assert_task_documents_open(resolved.address)
498
+ latest = self._latest_document_by_address.get(resolved.id)
499
+ if latest is not None and not latest.retire_on_commit:
500
+ if latest.draft is not None:
501
+ return latest.draft
502
+ if latest.acquisition is not None:
503
+ return await latest.acquisition
504
+ if isinstance(latest.target, _ForkCopyTarget):
505
+ latest.acquisition = asyncio.ensure_future(
506
+ self._track(self._acquire_fork_copy(latest, definition, latest.target))
507
+ )
508
+ return await latest.acquisition
509
+ seed = copy_json(args[resolved.next_argument]) if definition.family else None
510
+ entry = DocumentEntry(address_id=resolved.id, address=resolved.address, definition=definition)
511
+ self._documents.append(entry)
512
+ self._latest_document_by_address[entry.address_id] = entry
513
+ entry.acquisition = asyncio.ensure_future(
514
+ self._track(self._acquire(entry, seed, latest is not None and latest.retire_on_commit))
515
+ )
516
+ return await entry.acquisition
517
+
518
+ async def retrieve_doc_draft(self, entry: DocumentEntry) -> DocumentDraft:
519
+ if entry.draft is not None:
520
+ return entry.draft
521
+ if entry.acquisition is not None:
522
+ return await entry.acquisition
523
+ target = entry.target
524
+ if isinstance(target, _CreatedTarget):
525
+ entry.draft = DocumentDraft(target.value)
526
+ return entry.draft
527
+ if isinstance(target, _LoadedTarget):
528
+ entry.draft = DocumentDraft(target.document.value)
529
+ return entry.draft
530
+ raise RuntimeError("Document target has no draft")
531
+
532
+ async def retire_doc(self, token: AnyDocToken, *args: Any) -> None:
533
+ self._assert_open()
534
+ definition = token.definition
535
+ resolved = resolve_address(definition, args)
536
+ latest = self._latest_document_by_address.get(resolved.id)
537
+ if latest is not None and latest.retire_on_commit:
538
+ return
539
+ if latest is not None and isinstance(latest.target, _ForkCopyTarget):
540
+ latest.retire_on_commit = True
541
+ return
542
+ if latest is not None and (latest.draft is not None or latest.acquisition is not None):
543
+ latest.retire_on_commit = True
544
+ await self.retrieve_doc_draft(latest)
545
+ return
546
+ entry = DocumentEntry(
547
+ address_id=resolved.id,
548
+ address=resolved.address,
549
+ definition=definition,
550
+ retire_on_commit=True,
551
+ )
552
+ self._documents.append(entry)
553
+ self._latest_document_by_address[entry.address_id] = entry
554
+ await self._track(self._find_retirement(entry))
555
+
556
+ async def _acquire(
557
+ self,
558
+ entry: DocumentEntry,
559
+ seed: JsonValue | None,
560
+ skip_load: bool,
561
+ ) -> DocumentDraft:
562
+ definition = entry.definition
563
+ assert definition is not None
564
+ loaded = (
565
+ None
566
+ if skip_load
567
+ else await self._host.load(definition, entry.address_id, entry.address, self._context)
568
+ )
569
+ self._assert_open()
570
+ if loaded is not None:
571
+ check_record_scope(definition, loaded.record)
572
+ check_record_version(definition, loaded.record, loaded.stored_version)
573
+ entry.target = _LoadedTarget(loaded)
574
+ entry.draft = DocumentDraft(loaded.value)
575
+ return entry.draft
576
+ scope = entry.address["scope"]
577
+ if scope["kind"] == "conversation":
578
+ await self._require_conversation(scope["conversationId"])
579
+ elif scope["kind"] == "task":
580
+ task = await self._current_task(scope["taskId"])
581
+ if task is None:
582
+ raise ValueError(f"Task {scope['taskId']} does not exist")
583
+ if task["state"]["status"] == "terminal":
584
+ raise ValueError(f"Task {scope['taskId']} is terminal")
585
+ self._assert_open()
586
+ value = copy_json(
587
+ definition.initial(seed) if definition.family else definition.initial()
588
+ )
589
+ if not isinstance(value, dict):
590
+ raise TypeError(f"Document {definition.kind} initial() must return an object")
591
+ id = cast(DocumentId, await self._host.storage.mint_id())
592
+ self._assert_open()
593
+ record = document_create(definition, entry.address, id)
594
+ entry.target = _CreatedTarget(record, definition.version, value)
595
+ entry.draft = DocumentDraft(value)
596
+ return entry.draft
597
+
598
+ async def _acquire_fork_copy(
599
+ self,
600
+ entry: DocumentEntry,
601
+ definition: DocDefinition,
602
+ target: _ForkCopyTarget,
603
+ ) -> DocumentDraft:
604
+ stored = await self._host.storage.document(target.source["id"], target.source["at"], self._context)
605
+ self._assert_open()
606
+ if stored is None:
607
+ raise ValueError(f"Fork source document {target.source['id']} cannot be read")
608
+ record = stored["record"]
609
+ if (
610
+ record["scope"]["kind"] != "conversation"
611
+ or record["kind"] != target.record["kind"]
612
+ or record.get("key") != target.record.get("key")
613
+ or record.get("history") != target.record.get("history")
614
+ or record.get("fork") != target.record.get("fork")
615
+ ):
616
+ raise ValueError(f"Fork source document {target.source['id']} does not match the copied record")
617
+ value = materialize_document_value(definition, target.record, stored["version"], stored["value"])
618
+ entry.definition = definition
619
+ entry.target = _CreatedTarget(target.record, definition.version, value)
620
+ entry.draft = DocumentDraft(value)
621
+ return entry.draft
622
+
623
+ async def _find_retirement(self, entry: DocumentEntry) -> None:
624
+ cached = self._host.cached(entry.address_id)
625
+ record = (
626
+ cached.record
627
+ if cached is not None
628
+ else await self._host.storage.find_document(entry.address, "current", self._context)
629
+ )
630
+ self._assert_open()
631
+ if record is None:
632
+ return
633
+ assert entry.definition is not None
634
+ check_record_scope(entry.definition, record)
635
+ entry.target = _RetireOnlyTarget(record)
636
+
637
+ # ── Settlement ─────────────────────────────────────────────────────────
638
+
639
+ async def settle_failure(self) -> None:
640
+ self._sealed = True
641
+ for document in self._documents:
642
+ if document.draft is not None:
643
+ document.draft._seal()
644
+ await asyncio.gather(*list(self._pending), return_exceptions=True)
645
+
646
+ async def settle_success(self) -> list[StorageWrite]:
647
+ self._sealed = True
648
+ if self._pending:
649
+ for document in self._documents:
650
+ if document.draft is not None:
651
+ document.draft._seal()
652
+ await asyncio.gather(*list(self._pending), return_exceptions=True)
653
+ raise RuntimeError("Session commit callback settled before its pending Tx operations")
654
+ try:
655
+ for document in self._documents:
656
+ if document.draft is not None:
657
+ document.prepared = document.draft._prepare()
658
+ return await self._assemble()
659
+ except Exception:
660
+ for document in self._documents:
661
+ if document.draft is not None:
662
+ document.draft._seal()
663
+ raise
664
+
665
+ def discard(self) -> None:
666
+ for document in self._documents:
667
+ if document.draft is not None:
668
+ document.draft._seal()
669
+
670
+ def adopt(self, seq: Seq) -> list[DocumentCommitChange]:
671
+ publications: list[DocumentCommitChange] = []
672
+ for plan in self._plans:
673
+ committed = "createdAt" in plan.record
674
+ record: DocumentRecord = (
675
+ cast(DocumentRecord, plan.record)
676
+ if committed
677
+ else cast(DocumentRecord, {**plan.record, "createdAt": seq})
678
+ )
679
+ if plan.retire:
680
+ record = cast(DocumentRecord, {**record, "retiredAt": seq})
681
+ change = plan.change
682
+ if plan.change is not None:
683
+ loaded = plan.change.loaded
684
+ if loaded is None:
685
+ if not plan.retire:
686
+ self._host.install(
687
+ LoadedDocument(
688
+ address_id=plan.address_id,
689
+ record=record,
690
+ stored_version=plan.change.version,
691
+ value_version=plan.change.version,
692
+ deltas_since_base=0,
693
+ value=plan.change.value,
694
+ )
695
+ )
696
+ else:
697
+ if plan.content is not None:
698
+ loaded.value = plan.change.value
699
+ loaded.stored_version = plan.change.version
700
+ if plan.content["type"] == "document.change":
701
+ content = plan.content["content"]
702
+ if content["kind"] == "base":
703
+ loaded.deltas_since_base = 0
704
+ else:
705
+ loaded.deltas_since_base += 1
706
+ if plan.retire:
707
+ if committed:
708
+ self._host.evict(plan.address_id, record["id"])
709
+ publications.append(
710
+ {
711
+ "type": "document",
712
+ "record": record,
713
+ "conversationId": plan.conversation_id,
714
+ "version": None,
715
+ "value": None,
716
+ "ops": [],
717
+ }
718
+ )
719
+ elif plan.content is not None and plan.content["type"] == "document.copy":
720
+ publications.append(
721
+ {
722
+ "type": "document.copy",
723
+ "record": record,
724
+ "conversationId": plan.conversation_id,
725
+ "source": plan.content["source"],
726
+ }
727
+ )
728
+ elif change is not None and (change.loaded is None or plan.content is not None):
729
+ publications.append(
730
+ {
731
+ "type": "document",
732
+ "record": record,
733
+ "conversationId": plan.conversation_id,
734
+ "version": change.version,
735
+ "value": change.value,
736
+ "ops": [] if change.loaded is None else change.ops,
737
+ }
738
+ )
739
+ return publications
740
+
741
+ async def _assemble(self) -> list[StorageWrite]:
742
+ storage = self._host.storage
743
+ for document in self._documents:
744
+ plan = self._plan_document(document)
745
+ if plan is not None:
746
+ self._plans.append(plan)
747
+ self._reject_fork_source_writes(self._plans)
748
+ await self._validate_owners()
749
+ for id, task in self._tasks.items():
750
+ if task.write is None or task.write[0] != "replace":
751
+ continue
752
+ committed = await self._committed_task(id)
753
+ if committed is None:
754
+ raise ValueError(f"Task {id} does not exist")
755
+ if committed["state"]["status"] == "terminal":
756
+ raise ValueError(f"Task {id} is already terminal")
757
+ if committed["conversationId"] != task.write[1]["conversationId"]:
758
+ raise ValueError(f"Task {id} cannot change conversations")
759
+
760
+ terminal_task_ids: set[int] = set()
761
+ for task in self._tasks.values():
762
+ if task.write is not None and task.write[1]["state"]["status"] == "terminal":
763
+ terminal_task_ids.add(task.write[1]["id"])
764
+ if terminal_task_ids:
765
+ retiring: set[int] = set()
766
+ for plan in self._plans:
767
+ scope = plan.record["scope"]
768
+ if scope["kind"] != "task" or scope["taskId"] not in terminal_task_ids:
769
+ continue
770
+ plan.retire = True
771
+ retiring.add(plan.record["id"])
772
+ for task_id in terminal_task_ids:
773
+ candidate = self._tasks.get(task_id)
774
+ if candidate is not None and candidate.write is not None and candidate.write[0] == "create":
775
+ continue
776
+ cursor: Cursor | None = None
777
+ while True:
778
+ page = await storage.scan_documents(
779
+ {"scope": {"kind": "task", "taskId": task_id}, "at": "current"},
780
+ _INTERNAL_SCAN_PAGE_SIZE,
781
+ cursor,
782
+ self._context,
783
+ )
784
+ for record in page["items"]:
785
+ if record["id"] in retiring:
786
+ continue
787
+ self._plans.append(
788
+ DocumentPlan(address_id=address_id(record), record=record, retire=True)
789
+ )
790
+ retiring.add(record["id"])
791
+ cursor = page.get("next")
792
+ if cursor is None:
793
+ break
794
+
795
+ for plan in self._plans:
796
+ if not self._publishes(plan):
797
+ continue
798
+ scope = plan.record["scope"]
799
+ if scope["kind"] == "conversation":
800
+ plan.conversation_id = scope["conversationId"]
801
+ continue
802
+ if scope["kind"] != "task":
803
+ continue
804
+ task = self._task_entry(scope["taskId"])
805
+ if task.publication_conversation_id is None:
806
+ current = await self._current_task(scope["taskId"])
807
+ if current is not None:
808
+ task.publication_conversation_id = current["conversationId"]
809
+ plan.conversation_id = task.publication_conversation_id
810
+
811
+ for id, change in self._submission_changes:
812
+ current = self._submissions.get(id)
813
+ if current is None:
814
+ current = await storage.submission(id, self._context)
815
+ if current is None:
816
+ raise ValueError(f"Submission {id} does not exist")
817
+ next_record = _apply_submission_change(current, change)
818
+ if next_record is not current:
819
+ self._submissions[id] = next_record
820
+
821
+ writes = self._writes
822
+ for value in self._submissions.values():
823
+ writes.append({"type": "submission", "value": value})
824
+ for task in self._tasks.values():
825
+ if task.write is not None:
826
+ writes.append({"type": "task", "value": task.write[1]})
827
+ for plan in self._plans:
828
+ change = plan.change
829
+ if (
830
+ plan.content is not None
831
+ and plan.content["type"] == "document.change"
832
+ and plan.content["content"]["kind"] == "delta"
833
+ and change is not None
834
+ and change.loaded is not None
835
+ and change.definition is not None
836
+ and change.definition.checkpoint_when is not None
837
+ ):
838
+ info = {"deltasSinceBase": change.loaded.deltas_since_base}
839
+ if change.definition.checkpoint_when(
840
+ cast(JsonObject, change.value), change.ops, info
841
+ ):
842
+ plan.content = cast(
843
+ StorageWrite,
844
+ {
845
+ **plan.content,
846
+ "content": {
847
+ "version": change.version,
848
+ "kind": "base",
849
+ "value": change.value,
850
+ },
851
+ },
852
+ )
853
+ if plan.content is not None:
854
+ writes.append(plan.content)
855
+ if plan.retire:
856
+ writes.append({"type": "document.retire", "id": plan.record["id"]})
857
+ return writes
858
+
859
+ # ── Helpers ────────────────────────────────────────────────────────────
860
+
861
+ async def _validate_owners(self) -> None:
862
+ owners: list[tuple[str, int]] = []
863
+ for write in self._writes:
864
+ if write["type"] != "conversation":
865
+ continue
866
+ owner = write["value"].get("owner")
867
+ if owner is not None:
868
+ owners.append(("Conversation owner task", owner["taskId"]))
869
+ for task in self._tasks.values():
870
+ if task.write is None or task.write[0] != "create":
871
+ continue
872
+ owner = task.write[1].get("owner")
873
+ if owner is not None:
874
+ owners.append(("Task owner", owner))
875
+ for what, task_id in owners:
876
+ task = await self._current_task(cast(TaskId, task_id))
877
+ if task is None:
878
+ raise ValueError(f"{what} {task_id} does not exist")
879
+ status = task["state"]["status"]
880
+ if status in ("terminal", "completing"):
881
+ raise ValueError(f"{what} {task_id} is {status}")
882
+ if task["abortRequested"]:
883
+ raise ValueError(f"{what} {task_id} is abort-marked")
884
+
885
+ def _reject_fork_source_writes(self, plans: list[DocumentPlan]) -> None:
886
+ for plan in plans:
887
+ if plan.content is None and not plan.retire:
888
+ continue
889
+ record = plan.record
890
+ if record["id"] in self._fork_source_document_ids:
891
+ raise ValueError(
892
+ f"Cannot change fork source document {record['id']} in the fork transaction"
893
+ )
894
+ scope = record["scope"]
895
+ if (
896
+ scope["kind"] == "conversation"
897
+ and scope["conversationId"] in self._fork_source_conversation_ids
898
+ and record["scope"]["kind"] == "conversation"
899
+ and record.get("fork") == "current"
900
+ ):
901
+ raise ValueError(
902
+ f"Cannot fork conversation {scope['conversationId']} "
903
+ "while changing its current-policy documents"
904
+ )
905
+
906
+ def _plan_document(self, document: DocumentEntry) -> DocumentPlan | None:
907
+ target = document.target
908
+ if target is None:
909
+ return None
910
+ if isinstance(target, _RetireOnlyTarget):
911
+ return DocumentPlan(
912
+ address_id=document.address_id,
913
+ record=target.record,
914
+ retire=document.retire_on_commit,
915
+ )
916
+ if isinstance(target, _ForkCopyTarget):
917
+ return DocumentPlan(
918
+ address_id=document.address_id,
919
+ record=target.record,
920
+ retire=document.retire_on_commit,
921
+ content=cast(
922
+ StorageWrite,
923
+ {"type": "document.copy", "record": target.record, "source": target.source},
924
+ ),
925
+ )
926
+ prepared = document.prepared
927
+ assert prepared is not None
928
+ if isinstance(target, _CreatedTarget):
929
+ return DocumentPlan(
930
+ address_id=document.address_id,
931
+ record=target.record,
932
+ retire=document.retire_on_commit,
933
+ content=cast(
934
+ StorageWrite,
935
+ {
936
+ "type": "document.create",
937
+ "record": target.record,
938
+ "content": {"version": target.version, "kind": "base", "value": prepared.value},
939
+ },
940
+ ),
941
+ change=_PlanChange(
942
+ loaded=None,
943
+ definition=document.definition,
944
+ version=target.version,
945
+ value=prepared.value,
946
+ ops=[],
947
+ ),
948
+ )
949
+ loaded = target.document
950
+ definition = document.definition
951
+ assert definition is not None
952
+ ops = prepared.ops
953
+ value = prepared.value
954
+ if loaded.stored_version < definition.version:
955
+ content = cast(
956
+ StorageWrite,
957
+ {
958
+ "type": "document.change",
959
+ "id": loaded.record["id"],
960
+ "content": {"version": definition.version, "kind": "base", "value": value},
961
+ },
962
+ )
963
+ elif ops:
964
+ content = cast(
965
+ StorageWrite,
966
+ {
967
+ "type": "document.change",
968
+ "id": loaded.record["id"],
969
+ "content": {"version": definition.version, "kind": "delta", "ops": ops},
970
+ },
971
+ )
972
+ else:
973
+ content = None
974
+ return DocumentPlan(
975
+ address_id=document.address_id,
976
+ record=loaded.record,
977
+ retire=document.retire_on_commit,
978
+ content=content,
979
+ change=_PlanChange(
980
+ loaded=loaded,
981
+ definition=definition,
982
+ version=definition.version,
983
+ value=value,
984
+ ops=ops,
985
+ ),
986
+ )
987
+
988
+ @staticmethod
989
+ def _publishes(plan: DocumentPlan) -> bool:
990
+ return plan.retire or plan.change is None or plan.change.loaded is None or plan.content is not None
991
+
992
+ def _assert_open(self) -> None:
993
+ if self._sealed:
994
+ raise RuntimeError("Transaction has settled")
995
+
996
+ def _assert_task_documents_open(self, address: DocumentAddress) -> None:
997
+ scope = address["scope"]
998
+ if scope["kind"] != "task":
999
+ return
1000
+ task = self._tasks.get(scope["taskId"])
1001
+ if task is not None and task.write is not None and task.write[1]["state"]["status"] == "terminal":
1002
+ raise ValueError(f"Task {scope['taskId']} is terminal")
1003
+
1004
+ def _track(self, awaitable: Any) -> asyncio.Future[Any]:
1005
+ task = asyncio.ensure_future(awaitable)
1006
+ self._pending.add(task)
1007
+
1008
+ def settle(_: Any) -> None:
1009
+ self._pending.discard(task)
1010
+
1011
+ task.add_done_callback(settle)
1012
+ return task
1013
+
1014
+ async def _read(self, method: str, read: Any) -> Any:
1015
+ self._assert_open()
1016
+ if self._has_table_write:
1017
+ raise ReadAfterWrite(method)
1018
+ return await self._track(read())
1019
+
1020
+ async def _write(self, write: Any) -> Any:
1021
+ self._assert_open()
1022
+ self._has_table_write = True
1023
+ return await self._track(write())
1024
+
1025
+ async def _require_conversation(self, id: ConversationId) -> None:
1026
+ if id in self._created_conversation_ids:
1027
+ return
1028
+ if await self._host.storage.conversation(id, self._context) is None:
1029
+ raise ValueError(f"Conversation {id} does not exist")
1030
+
1031
+ def _stamp_times(self, value: AnyTaskRecord, candidate: AnyTaskRecord | None) -> AnyTaskRecord:
1032
+ status = value["state"]["status"]
1033
+
1034
+ def stamp_enabled(enabled: bool) -> int | None:
1035
+ return self._host.now() if enabled else None
1036
+
1037
+ def first_set(*values: int | None) -> int | None:
1038
+ return next((item for item in values if item is not None), None)
1039
+
1040
+ started_at = first_set(
1041
+ (candidate or {}).get("startedAt"),
1042
+ value.get("startedAt"),
1043
+ stamp_enabled(status == "running"),
1044
+ )
1045
+ ended_at = first_set(
1046
+ (candidate or {}).get("endedAt"),
1047
+ value.get("endedAt"),
1048
+ stamp_enabled(status == "terminal"),
1049
+ )
1050
+ if started_at == value.get("startedAt") and ended_at == value.get("endedAt"):
1051
+ return value
1052
+ result = dict(value)
1053
+ if started_at is None:
1054
+ result.pop("startedAt", None)
1055
+ else:
1056
+ result["startedAt"] = started_at
1057
+ if ended_at is None:
1058
+ result.pop("endedAt", None)
1059
+ else:
1060
+ result["endedAt"] = ended_at
1061
+ return cast(AnyTaskRecord, result)
1062
+
1063
+ def _task_entry(self, id: TaskId) -> TransactionTask:
1064
+ return self._tasks.setdefault(id, TransactionTask())
1065
+
1066
+ async def _current_task(self, id: TaskId) -> AnyTaskRecord | None:
1067
+ task = self._tasks.get(id)
1068
+ if task is not None and task.write is not None:
1069
+ return task.write[1]
1070
+ return await self._committed_task(id)
1071
+
1072
+ async def _committed_task(self, id: TaskId) -> AnyTaskRecord | None:
1073
+ task = self._task_entry(id)
1074
+ if task.committed_read is None:
1075
+ task.committed_read = asyncio.ensure_future(
1076
+ self._host.storage.task(id, self._context)
1077
+ )
1078
+ return await task.committed_read
1079
+
1080
+
1081
+ __all__ = [
1082
+ "DocumentEntry",
1083
+ "DocumentPlan",
1084
+ "LoadedDocument",
1085
+ "Transaction",
1086
+ "TransactionHost",
1087
+ "TransactionScope",
1088
+ ]