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
pi_durable/types.py ADDED
@@ -0,0 +1,1297 @@
1
+ """Core durable records, queries, and the Storage protocol.
2
+
3
+ Records are persisted JSON objects with camelCase fields, exactly as in the reference package.
4
+ Python type declarations are ``TypedDict`` values so storage, JSONL, and SQLite carry the same
5
+ shape without a mapping layer. This module deliberately does not use postponed annotations:
6
+ ``TypedDict`` computes ``__required_keys__``/``__optional_keys__`` at class creation, and PEP 563
7
+ string annotations would make every field appear required at runtime.
8
+
9
+ Entry records are the immutable value of the transcript. :func:`entry_draft` validates a draft
10
+ and :func:`entry_record` assigns its identity, resolving ``head: "self"``; both return deep-frozen
11
+ JSON values that cannot be mutated after construction.
12
+ """
13
+
14
+ import asyncio
15
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
16
+ from typing import (
17
+ TYPE_CHECKING,
18
+ Any,
19
+ Generic,
20
+ Literal,
21
+ Never,
22
+ NotRequired,
23
+ Protocol,
24
+ TypeAlias,
25
+ TypedDict,
26
+ TypeVar,
27
+ cast,
28
+ overload,
29
+ runtime_checkable,
30
+ )
31
+
32
+ from .context import Context
33
+ from .ids import (
34
+ ROOT_CONVERSATION_ID,
35
+ ConversationId,
36
+ DocumentId,
37
+ EntryId,
38
+ Id,
39
+ Seq,
40
+ SubmissionId,
41
+ TaskId,
42
+ require_positive_int,
43
+ )
44
+ from .json import JsonObject, JsonValue, copy_json, freeze_json
45
+
46
+ if TYPE_CHECKING:
47
+ from .harness.types import ContextView, TaskRuntime
48
+
49
+ __all__ = [
50
+ "ROOT_CONVERSATION_ID",
51
+ "CheckpointInfo",
52
+ "CommitChange",
53
+ "CommitPublication",
54
+ "ContextEdit",
55
+ "ConversationId",
56
+ "ConversationOwnership",
57
+ "ConversationQuery",
58
+ "ConversationRecord",
59
+ "Cursor",
60
+ "DocumentAddress",
61
+ "DocumentCommitChange",
62
+ "DocumentContent",
63
+ "DocumentCopySource",
64
+ "DocumentCreate",
65
+ "DocumentId",
66
+ "DocumentPoint",
67
+ "DocumentQuery",
68
+ "DocumentReader",
69
+ "DocumentRecord",
70
+ "DocumentObserver",
71
+ "DocumentSemantics",
72
+ "DocumentWatch",
73
+ "EntryDraft",
74
+ "EntryId",
75
+ "EntryQuery",
76
+ "EntryRecord",
77
+ "EntryWithHead",
78
+ "Id",
79
+ "JoinPolicy",
80
+ "JsonObject",
81
+ "JsonValue",
82
+ "Page",
83
+ "PhaseHandler",
84
+ "RunningTask",
85
+ "ScanOrder",
86
+ "Seq",
87
+ "Session",
88
+ "SessionEnd",
89
+ "Storage",
90
+ "StorageWrite",
91
+ "StoredDocument",
92
+ "SubmissionCreate",
93
+ "SubmissionId",
94
+ "SubmissionQuery",
95
+ "SubmissionRecord",
96
+ "SubmissionSettlement",
97
+ "TableCommitChange",
98
+ "TaskId",
99
+ "TaskOptions",
100
+ "TaskOutcome",
101
+ "TaskOutcomeError",
102
+ "TaskOwnership",
103
+ "TaskQuery",
104
+ "TaskRecord",
105
+ "TaskState",
106
+ "Tx",
107
+ "TypedEntry",
108
+ "TypedEntryDraft",
109
+ "WatchEnd",
110
+ "WatchEnded",
111
+ "WatchHandle",
112
+ "entry_draft",
113
+ "entry_record",
114
+ ]
115
+
116
+ MessageRecord: TypeAlias = dict[str, JsonValue]
117
+ """One model-facing message. The pi-ai adapter supplies its concrete shape."""
118
+ Op: TypeAlias = list[Any]
119
+ """One Chord delta operation, represented as its JSON array."""
120
+ JsonValueT_co = TypeVar("JsonValueT_co", covariant=True)
121
+
122
+
123
+ # ── Identity, conversation, and entry records ──────────────────────────────
124
+
125
+
126
+ class ConversationParent(TypedDict):
127
+ conversationId: ConversationId
128
+ at: EntryId
129
+
130
+
131
+ class ConversationOwner(TypedDict):
132
+ conversationId: ConversationId
133
+ taskId: TaskId
134
+
135
+
136
+ class ConversationRecord(TypedDict):
137
+ id: ConversationId
138
+ parent: NotRequired[ConversationParent]
139
+ owner: NotRequired[ConversationOwner]
140
+
141
+
142
+ class OwnerlessConversation(TypedDict):
143
+ kind: Literal["ownerless"]
144
+
145
+
146
+ class TaskOwnedConversation(TypedDict):
147
+ kind: Literal["task"]
148
+ taskId: TaskId
149
+
150
+
151
+ ConversationOwnership: TypeAlias = OwnerlessConversation | TaskOwnedConversation
152
+
153
+
154
+ class ConversationOwnedTask(TypedDict):
155
+ kind: Literal["conversation"]
156
+
157
+
158
+ TaskOwnership: TypeAlias = ConversationOwnedTask | TaskOwnedConversation
159
+
160
+
161
+ class OmitContextEdit(TypedDict):
162
+ target: EntryId
163
+ action: Literal["omit"]
164
+ messages: NotRequired[Never]
165
+
166
+
167
+ class ReplaceContextEdit(TypedDict):
168
+ target: EntryId
169
+ action: Literal["replace"]
170
+ messages: list[MessageRecord]
171
+
172
+
173
+ ContextEdit: TypeAlias = OmitContextEdit | ReplaceContextEdit
174
+
175
+
176
+ class EntryRecord(TypedDict):
177
+ id: EntryId
178
+ conversationId: ConversationId
179
+ kind: str
180
+ model: NotRequired[list[MessageRecord]]
181
+ data: NotRequired[JsonValue]
182
+ head: NotRequired[EntryId]
183
+ edits: NotRequired[list[ContextEdit]]
184
+ byTaskId: NotRequired[TaskId]
185
+
186
+
187
+ class EntryDraft(TypedDict):
188
+ kind: str
189
+ model: NotRequired[list[MessageRecord]]
190
+ data: NotRequired[JsonValue]
191
+ head: NotRequired[EntryId | Literal["self"]]
192
+ edits: NotRequired[list[ContextEdit]]
193
+
194
+
195
+ class EntryWithHead(TypedDict):
196
+ """An entry that is also a head marker, so its ``head`` is the range's lower bound."""
197
+
198
+ id: EntryId
199
+ conversationId: ConversationId
200
+ kind: str
201
+ model: NotRequired[list[MessageRecord]]
202
+ data: NotRequired[JsonValue]
203
+ head: EntryId
204
+ edits: NotRequired[list[ContextEdit]]
205
+ byTaskId: NotRequired[TaskId]
206
+
207
+
208
+ D = TypeVar("D", bound=JsonValue)
209
+
210
+
211
+ class TypedEntry(TypedDict, Generic[D]):
212
+ """An entry record whose ``data`` is narrowed to ``D`` when present."""
213
+
214
+ id: EntryId
215
+ conversationId: ConversationId
216
+ kind: str
217
+ model: NotRequired[list[MessageRecord]]
218
+ data: NotRequired[D]
219
+ head: NotRequired[EntryId]
220
+ edits: NotRequired[list[ContextEdit]]
221
+ byTaskId: NotRequired[TaskId]
222
+
223
+
224
+ class TypedEntryDraft(TypedDict, Generic[D]):
225
+ """Entry draft content of a typed kind; the token supplies ``kind``."""
226
+
227
+ model: NotRequired[list[MessageRecord]]
228
+ data: NotRequired[D]
229
+ head: NotRequired[EntryId | Literal["self"]]
230
+ edits: NotRequired[list[ContextEdit]]
231
+
232
+
233
+ # ── Submission records ──────────────────────────────────────────────────────
234
+
235
+
236
+ class SubmissionRecordBase(TypedDict):
237
+ id: SubmissionId
238
+ conversationId: ConversationId
239
+ requestId: NotRequired[str]
240
+
241
+
242
+ class InputQueuedRecord(SubmissionRecordBase):
243
+ type: Literal["input"]
244
+ status: Literal["queued"]
245
+ entry: NotRequired[Never]
246
+ answer: NotRequired[Never]
247
+ reason: NotRequired[Never]
248
+ detail: NotRequired[Never]
249
+
250
+
251
+ class InputPlacedRecord(SubmissionRecordBase):
252
+ type: Literal["input"]
253
+ status: Literal["placed"]
254
+ entry: EntryId
255
+ answer: NotRequired[Never]
256
+ reason: NotRequired[Never]
257
+ detail: NotRequired[Never]
258
+
259
+
260
+ class InputDoneRecord(SubmissionRecordBase):
261
+ type: Literal["input"]
262
+ status: Literal["done"]
263
+ entry: EntryId
264
+ answer: EntryId
265
+ reason: NotRequired[Never]
266
+ detail: NotRequired[Never]
267
+
268
+
269
+ class InputUnansweredRecord(SubmissionRecordBase):
270
+ type: Literal["input"]
271
+ status: Literal["unanswered"]
272
+ entry: NotRequired[EntryId]
273
+ reason: str
274
+ detail: NotRequired[JsonValue]
275
+ answer: NotRequired[Never]
276
+
277
+
278
+ class WriteQueuedRecord(SubmissionRecordBase):
279
+ type: Literal["write"]
280
+ status: Literal["queued"]
281
+ entry: NotRequired[Never]
282
+ answer: NotRequired[Never]
283
+ reason: NotRequired[Never]
284
+ detail: NotRequired[Never]
285
+
286
+
287
+ class WriteDoneRecord(SubmissionRecordBase):
288
+ type: Literal["write"]
289
+ status: Literal["done"]
290
+ entry: EntryId
291
+ answer: NotRequired[Never]
292
+ reason: NotRequired[Never]
293
+ detail: NotRequired[Never]
294
+
295
+
296
+ class WriteUnansweredRecord(SubmissionRecordBase):
297
+ type: Literal["write"]
298
+ status: Literal["unanswered"]
299
+ reason: str
300
+ detail: NotRequired[JsonValue]
301
+ entry: NotRequired[Never]
302
+ answer: NotRequired[Never]
303
+
304
+
305
+ SubmissionRecord: TypeAlias = (
306
+ InputQueuedRecord
307
+ | InputPlacedRecord
308
+ | InputDoneRecord
309
+ | InputUnansweredRecord
310
+ | WriteQueuedRecord
311
+ | WriteDoneRecord
312
+ | WriteUnansweredRecord
313
+ )
314
+
315
+
316
+ class SubmissionCreateBase(TypedDict):
317
+ conversationId: ConversationId
318
+ requestId: NotRequired[str]
319
+ id: NotRequired[Never]
320
+
321
+
322
+ class InputQueuedCreate(SubmissionCreateBase):
323
+ type: Literal["input"]
324
+ status: Literal["queued"]
325
+ entry: NotRequired[Never]
326
+ answer: NotRequired[Never]
327
+ reason: NotRequired[Never]
328
+ detail: NotRequired[Never]
329
+
330
+
331
+ class InputPlacedCreate(SubmissionCreateBase):
332
+ type: Literal["input"]
333
+ status: Literal["placed"]
334
+ entry: EntryId
335
+ answer: NotRequired[Never]
336
+ reason: NotRequired[Never]
337
+ detail: NotRequired[Never]
338
+
339
+
340
+ class InputDoneCreate(SubmissionCreateBase):
341
+ type: Literal["input"]
342
+ status: Literal["done"]
343
+ entry: EntryId
344
+ answer: EntryId
345
+ reason: NotRequired[Never]
346
+ detail: NotRequired[Never]
347
+
348
+
349
+ class InputUnansweredCreate(SubmissionCreateBase):
350
+ type: Literal["input"]
351
+ status: Literal["unanswered"]
352
+ entry: NotRequired[EntryId]
353
+ reason: str
354
+ detail: NotRequired[JsonValue]
355
+ answer: NotRequired[Never]
356
+
357
+
358
+ class WriteQueuedCreate(SubmissionCreateBase):
359
+ type: Literal["write"]
360
+ status: Literal["queued"]
361
+ entry: NotRequired[Never]
362
+ answer: NotRequired[Never]
363
+ reason: NotRequired[Never]
364
+ detail: NotRequired[Never]
365
+
366
+
367
+ class WriteDoneCreate(SubmissionCreateBase):
368
+ type: Literal["write"]
369
+ status: Literal["done"]
370
+ entry: EntryId
371
+ answer: NotRequired[Never]
372
+ reason: NotRequired[Never]
373
+ detail: NotRequired[Never]
374
+
375
+
376
+ class WriteUnansweredCreate(SubmissionCreateBase):
377
+ type: Literal["write"]
378
+ status: Literal["unanswered"]
379
+ reason: str
380
+ detail: NotRequired[JsonValue]
381
+ entry: NotRequired[Never]
382
+ answer: NotRequired[Never]
383
+
384
+
385
+ SubmissionCreate: TypeAlias = (
386
+ InputQueuedCreate
387
+ | InputPlacedCreate
388
+ | InputDoneCreate
389
+ | InputUnansweredCreate
390
+ | WriteQueuedCreate
391
+ | WriteDoneCreate
392
+ | WriteUnansweredCreate
393
+ )
394
+
395
+
396
+ class DoneSubmissionSettlement(TypedDict):
397
+ status: Literal["done"]
398
+ answer: EntryId
399
+
400
+
401
+ class UnansweredSubmissionSettlement(TypedDict):
402
+ status: Literal["unanswered"]
403
+ reason: str
404
+ detail: NotRequired[JsonValue]
405
+ answer: NotRequired[Never]
406
+
407
+
408
+ SubmissionSettlement: TypeAlias = DoneSubmissionSettlement | UnansweredSubmissionSettlement
409
+
410
+
411
+ # ── Task records ────────────────────────────────────────────────────────────
412
+
413
+ JoinPolicy: TypeAlias = Literal["failFast", "allSettled"]
414
+
415
+
416
+ class TaskOutcomeError(TypedDict):
417
+ message: str
418
+ detail: NotRequired[JsonValue]
419
+
420
+
421
+ R = TypeVar("R")
422
+
423
+
424
+ class TaskOutcomeCompleted(TypedDict, Generic[R]):
425
+ status: Literal["completed"]
426
+ result: R
427
+ error: NotRequired[Never]
428
+ reason: NotRequired[Never]
429
+
430
+
431
+ class TaskOutcomeFailed(TypedDict, Generic[R]):
432
+ status: Literal["failed"]
433
+ error: TaskOutcomeError
434
+ result: NotRequired[R]
435
+ reason: NotRequired[Never]
436
+
437
+
438
+ class TaskOutcomeAborted(TypedDict, Generic[R]):
439
+ status: Literal["aborted"]
440
+ reason: NotRequired[str]
441
+ result: NotRequired[R]
442
+ error: NotRequired[Never]
443
+
444
+
445
+ class TaskOutcomeOrphaned(TypedDict):
446
+ status: Literal["orphaned"]
447
+ reason: str
448
+ result: NotRequired[Never]
449
+ error: NotRequired[Never]
450
+
451
+
452
+ class TaskOutcomeFaulted(TypedDict):
453
+ status: Literal["faulted"]
454
+ error: TaskOutcomeError
455
+ result: NotRequired[Never]
456
+ reason: NotRequired[Never]
457
+
458
+
459
+ TaskOutcome: TypeAlias = (
460
+ TaskOutcomeCompleted[Any]
461
+ | TaskOutcomeFailed[Any]
462
+ | TaskOutcomeAborted[Any]
463
+ | TaskOutcomeOrphaned
464
+ | TaskOutcomeFaulted
465
+ )
466
+
467
+ S = TypeVar("S")
468
+
469
+
470
+ class TaskStatePending(TypedDict, Generic[S]):
471
+ status: Literal["pending"]
472
+ checkpoint: S
473
+ outcome: NotRequired[Never]
474
+
475
+
476
+ class TaskStateRunning(TypedDict, Generic[S]):
477
+ status: Literal["running"]
478
+ checkpoint: S
479
+ outcome: NotRequired[Never]
480
+
481
+
482
+ class TaskStateWaiting(TypedDict, Generic[S]):
483
+ status: Literal["waiting"]
484
+ checkpoint: S
485
+ on: list[TaskId]
486
+ policy: JoinPolicy
487
+ outcome: NotRequired[Never]
488
+
489
+
490
+ class TaskStateCompleting(TypedDict, Generic[S]):
491
+ status: Literal["completing"]
492
+ outcome: TaskOutcome
493
+ checkpoint: NotRequired[Never]
494
+
495
+
496
+ class TaskStateTerminal(TypedDict, Generic[S]):
497
+ status: Literal["terminal"]
498
+ outcome: TaskOutcome
499
+ checkpoint: NotRequired[Never]
500
+
501
+
502
+ TaskState: TypeAlias = (
503
+ TaskStatePending[Any]
504
+ | TaskStateRunning[Any]
505
+ | TaskStateWaiting[Any]
506
+ | TaskStateCompleting[Any]
507
+ | TaskStateTerminal[Any]
508
+ )
509
+ NextTaskState: TypeAlias = TaskStateRunning[Any] | TaskStateWaiting[Any] | TaskStateTerminal[Any]
510
+
511
+
512
+ class TaskRecordBase(TypedDict):
513
+ id: TaskId
514
+ conversationId: ConversationId
515
+ kind: str
516
+ version: int
517
+ input: JsonValue
518
+ owner: NotRequired[TaskId]
519
+ background: bool
520
+ abortRequested: bool
521
+ abortReason: NotRequired[Literal["restart"]]
522
+ abandonOnRestart: NotRequired[Literal[True]]
523
+ startedAt: NotRequired[int]
524
+ endedAt: NotRequired[int]
525
+
526
+
527
+ class LiveTaskRecord(TaskRecordBase):
528
+ state: TaskStatePending[Any] | TaskStateRunning[Any] | TaskStateWaiting[Any]
529
+ memos: NotRequired[dict[str, JsonValue]]
530
+
531
+
532
+ class SettledTaskRecord(TaskRecordBase):
533
+ state: TaskStateCompleting[Any] | TaskStateTerminal[Any]
534
+ memos: NotRequired[Never]
535
+
536
+
537
+ TaskRecord: TypeAlias = LiveTaskRecord | SettledTaskRecord
538
+
539
+
540
+ class RunningTaskRecord(TaskRecordBase):
541
+ state: TaskStateRunning[Any]
542
+ memos: NotRequired[dict[str, JsonValue]]
543
+
544
+
545
+ RunningTask: TypeAlias = RunningTaskRecord
546
+
547
+
548
+ PhaseHandler: TypeAlias = Callable[[RunningTask, "TaskRuntime", Context], Awaitable[None]]
549
+ """Runs one checkpoint phase; it must commit durable progress through ``TaskRuntime.commit()``."""
550
+
551
+
552
+ class TaskOptions(TypedDict):
553
+ ownership: TaskOwnership
554
+ conversationId: NotRequired[ConversationId]
555
+ background: NotRequired[bool]
556
+ abandonOnRestart: NotRequired[bool]
557
+
558
+
559
+ # ── Documents ───────────────────────────────────────────────────────────────
560
+
561
+
562
+ class SessionDocumentSemantics(TypedDict):
563
+ scope: Literal["session"]
564
+
565
+
566
+ class LatestConversationSemantics(TypedDict):
567
+ scope: Literal["conversation"]
568
+ history: Literal["latest"]
569
+ fork: Literal["current", "initial"]
570
+
571
+
572
+ class RewindableConversationSemantics(TypedDict):
573
+ scope: Literal["conversation"]
574
+ history: Literal["rewindable"]
575
+ fork: Literal["asOf", "current", "initial"]
576
+
577
+
578
+ class TaskDocumentSemantics(TypedDict):
579
+ scope: Literal["task"]
580
+
581
+
582
+ DocumentSemantics: TypeAlias = (
583
+ SessionDocumentSemantics
584
+ | LatestConversationSemantics
585
+ | RewindableConversationSemantics
586
+ | TaskDocumentSemantics
587
+ )
588
+
589
+
590
+ class CheckpointInfo(TypedDict):
591
+ deltasSinceBase: int
592
+
593
+
594
+ class SessionDocumentScope(TypedDict):
595
+ kind: Literal["session"]
596
+
597
+
598
+ class ConversationDocumentScope(TypedDict):
599
+ kind: Literal["conversation"]
600
+ conversationId: ConversationId
601
+
602
+
603
+ class TaskDocumentScope(TypedDict):
604
+ kind: Literal["task"]
605
+ taskId: TaskId
606
+
607
+
608
+ DocumentScope: TypeAlias = SessionDocumentScope | ConversationDocumentScope | TaskDocumentScope
609
+
610
+
611
+ class DocumentRecordBase(TypedDict):
612
+ id: DocumentId
613
+ kind: str
614
+ key: NotRequired[str]
615
+ createdAt: Seq
616
+ retiredAt: NotRequired[Seq]
617
+
618
+
619
+ class SessionDocumentRecord(DocumentRecordBase):
620
+ scope: SessionDocumentScope
621
+ history: NotRequired[Never]
622
+ fork: NotRequired[Never]
623
+
624
+
625
+ class LatestConversationDocumentRecord(DocumentRecordBase):
626
+ scope: ConversationDocumentScope
627
+ history: Literal["latest"]
628
+ fork: Literal["current", "initial"]
629
+
630
+
631
+ class RewindableConversationDocumentRecord(DocumentRecordBase):
632
+ scope: ConversationDocumentScope
633
+ history: Literal["rewindable"]
634
+ fork: Literal["asOf", "current", "initial"]
635
+
636
+
637
+ class TaskDocumentRecord(DocumentRecordBase):
638
+ scope: TaskDocumentScope
639
+ history: NotRequired[Never]
640
+ fork: NotRequired[Never]
641
+
642
+
643
+ DocumentRecord: TypeAlias = (
644
+ SessionDocumentRecord
645
+ | LatestConversationDocumentRecord
646
+ | RewindableConversationDocumentRecord
647
+ | TaskDocumentRecord
648
+ )
649
+
650
+
651
+ class DocumentCreateBase(TypedDict):
652
+ id: DocumentId
653
+ kind: str
654
+ key: NotRequired[str]
655
+ createdAt: NotRequired[Never]
656
+ retiredAt: NotRequired[Never]
657
+
658
+
659
+ class SessionDocumentCreate(DocumentCreateBase):
660
+ scope: SessionDocumentScope
661
+ history: NotRequired[Never]
662
+ fork: NotRequired[Never]
663
+
664
+
665
+ class LatestConversationDocumentCreate(DocumentCreateBase):
666
+ scope: ConversationDocumentScope
667
+ history: Literal["latest"]
668
+ fork: Literal["current", "initial"]
669
+
670
+
671
+ class RewindableConversationDocumentCreate(DocumentCreateBase):
672
+ scope: ConversationDocumentScope
673
+ history: Literal["rewindable"]
674
+ fork: Literal["asOf", "current", "initial"]
675
+
676
+
677
+ class TaskDocumentCreate(DocumentCreateBase):
678
+ scope: TaskDocumentScope
679
+ history: NotRequired[Never]
680
+ fork: NotRequired[Never]
681
+
682
+
683
+ DocumentCreate: TypeAlias = (
684
+ SessionDocumentCreate
685
+ | LatestConversationDocumentCreate
686
+ | RewindableConversationDocumentCreate
687
+ | TaskDocumentCreate
688
+ )
689
+
690
+
691
+ class DocumentBaseContent(TypedDict):
692
+ version: int
693
+ kind: Literal["base"]
694
+ value: JsonObject
695
+ ops: NotRequired[Never]
696
+
697
+
698
+ class DocumentDeltaContent(TypedDict):
699
+ version: int
700
+ kind: Literal["delta"]
701
+ ops: list[Op]
702
+ value: NotRequired[Never]
703
+
704
+
705
+ DocumentContent: TypeAlias = DocumentBaseContent | DocumentDeltaContent
706
+ DocumentPoint: TypeAlias = Seq | Literal["current"]
707
+
708
+
709
+ class DocumentCopySource(TypedDict):
710
+ id: DocumentId
711
+ at: DocumentPoint
712
+
713
+
714
+ class StoredDocument(TypedDict):
715
+ record: DocumentRecord
716
+ version: int
717
+ value: JsonObject
718
+ deltasSinceBase: int
719
+
720
+
721
+ class DocumentAddress(TypedDict):
722
+ kind: str
723
+ scope: DocumentScope
724
+ key: NotRequired[str]
725
+
726
+
727
+ class DocumentQuery(TypedDict):
728
+ scope: DocumentScope
729
+ at: DocumentPoint
730
+ kind: NotRequired[str]
731
+
732
+
733
+ # ── Commits and storage writes ──────────────────────────────────────────────
734
+
735
+
736
+ class ConversationWrite(TypedDict):
737
+ type: Literal["conversation"]
738
+ value: ConversationRecord
739
+
740
+
741
+ class EntryWrite(TypedDict):
742
+ type: Literal["entry"]
743
+ value: EntryRecord
744
+
745
+
746
+ class TaskWrite(TypedDict):
747
+ type: Literal["task"]
748
+ value: TaskRecord
749
+
750
+
751
+ class SubmissionWrite(TypedDict):
752
+ type: Literal["submission"]
753
+ value: SubmissionRecord
754
+
755
+
756
+ class DocumentCreateWrite(TypedDict):
757
+ type: Literal["document.create"]
758
+ record: DocumentCreate
759
+ content: DocumentBaseContent
760
+
761
+
762
+ class DocumentCopyWrite(TypedDict):
763
+ type: Literal["document.copy"]
764
+ record: DocumentCreate
765
+ source: DocumentCopySource
766
+
767
+
768
+ class DocumentChangeWrite(TypedDict):
769
+ type: Literal["document.change"]
770
+ id: DocumentId
771
+ content: DocumentContent
772
+
773
+
774
+ class DocumentRetireWrite(TypedDict):
775
+ type: Literal["document.retire"]
776
+ id: DocumentId
777
+
778
+
779
+ StorageWrite: TypeAlias = (
780
+ ConversationWrite
781
+ | EntryWrite
782
+ | TaskWrite
783
+ | SubmissionWrite
784
+ | DocumentCreateWrite
785
+ | DocumentCopyWrite
786
+ | DocumentChangeWrite
787
+ | DocumentRetireWrite
788
+ )
789
+
790
+ TableCommitChange: TypeAlias = ConversationWrite | EntryWrite | TaskWrite | SubmissionWrite
791
+
792
+
793
+ class DocumentCommittedChange(TypedDict):
794
+ type: Literal["document"]
795
+ record: DocumentRecord
796
+ conversationId: ConversationId | None
797
+ version: int | None
798
+ value: JsonObject | None
799
+ ops: list[Op]
800
+
801
+
802
+ class DocumentCopiedChange(TypedDict):
803
+ type: Literal["document.copy"]
804
+ record: DocumentRecord
805
+ conversationId: ConversationId
806
+ source: DocumentCopySource
807
+
808
+
809
+ DocumentCommitChange: TypeAlias = DocumentCommittedChange | DocumentCopiedChange
810
+ CommitChange: TypeAlias = TableCommitChange | DocumentCommitChange
811
+
812
+
813
+ class CommitPublication(TypedDict):
814
+ seq: Seq
815
+ changes: list[CommitChange]
816
+
817
+
818
+ class WatchEnded(TypedDict):
819
+ reason: Literal["stopped", "cancelled", "session_closed", "retired"]
820
+
821
+
822
+ class WatchListenerErrorEnd(TypedDict):
823
+ reason: Literal["listener_error"]
824
+ error: BaseException
825
+
826
+
827
+ class WatchSessionFailedEnd(TypedDict):
828
+ reason: Literal["session_failed"]
829
+ error: object
830
+
831
+
832
+ WatchEnd: TypeAlias = WatchEnded | WatchListenerErrorEnd | WatchSessionFailedEnd
833
+ """Terminal result of one document watch."""
834
+
835
+
836
+ class WatchHandle(Protocol, Generic[JsonValueT_co]):
837
+ """Serialized exact-frame observation of an immutable value with bounded pending delivery."""
838
+
839
+ @property
840
+ def value(self) -> JsonValueT_co:
841
+ """Acquisition revision before start; latest delivered immutable revision afterward."""
842
+ ...
843
+
844
+ @property
845
+ def closed(self) -> asyncio.Future[WatchEnd]:
846
+ """Settle when the watch terminates; an already-running callback remains caller-owned."""
847
+ ...
848
+
849
+ def start(self, listener: Callable[[JsonValueT_co, list[Op], Context], Any]) -> None:
850
+ """Install the sole asynchronous listener. Never invokes it inline."""
851
+ ...
852
+
853
+ def stop(self) -> asyncio.Future[WatchEnd]:
854
+ """Idempotently stop future callbacks and return this watch's terminal result."""
855
+ ...
856
+
857
+
858
+ DocumentWatch: TypeAlias = WatchHandle[JsonObject | None]
859
+ """Serialized exact-frame observation of one document incarnation."""
860
+
861
+
862
+ class DocumentObserver(Protocol):
863
+ """Non-creating document watch acquisition shared by Session and later invocation APIs."""
864
+
865
+ async def watch_doc(self, token: Any, *args: Any) -> DocumentWatch | None: ...
866
+
867
+
868
+ # ── Queries and pages ───────────────────────────────────────────────────────
869
+
870
+ ScanOrder: TypeAlias = Literal["ascending", "descending"]
871
+ Cursor: TypeAlias = dict[str, JsonValue]
872
+
873
+ ItemT = TypeVar("ItemT")
874
+ CursorT = TypeVar("CursorT")
875
+
876
+
877
+ class Page(TypedDict, Generic[ItemT, CursorT]):
878
+ items: list[ItemT]
879
+ next: NotRequired[CursorT]
880
+
881
+
882
+ class ConversationQuery(TypedDict):
883
+ ownerConversationId: NotRequired[ConversationId]
884
+ ownerTaskId: NotRequired[TaskId]
885
+ order: NotRequired[ScanOrder]
886
+
887
+
888
+ class EntryQuery(TypedDict):
889
+ conversationId: ConversationId
890
+ minEntryId: NotRequired[EntryId]
891
+ maxEntryId: NotRequired[EntryId]
892
+ order: NotRequired[ScanOrder]
893
+
894
+
895
+ class TaskQuery(TypedDict):
896
+ conversationId: NotRequired[ConversationId]
897
+ kind: NotRequired[str]
898
+ status: NotRequired[Literal["pending", "running", "waiting", "completing", "terminal"]]
899
+ abortRequested: NotRequired[bool]
900
+ background: NotRequired[bool]
901
+ order: NotRequired[ScanOrder]
902
+
903
+
904
+ class SubmissionQuery(TypedDict):
905
+ conversationId: NotRequired[ConversationId]
906
+ status: NotRequired[Literal["queued", "placed", "done", "unanswered"]]
907
+ order: NotRequired[ScanOrder]
908
+
909
+
910
+ class EntryLookup(TypedDict):
911
+ entry: EntryRecord
912
+ commitSeq: Seq
913
+
914
+
915
+ # ── Storage protocol ────────────────────────────────────────────────────────
916
+
917
+
918
+ @runtime_checkable
919
+ class Storage(Protocol):
920
+ """Atomic persistence boundary for Session records.
921
+
922
+ Storage trusts the owning Session to supply semantically valid records, references, ancestry,
923
+ and transitions. Implementations enforce atomicity, global ID ownership, immutable
924
+ conversation/entry creation, document record consistency, and detached values; the Session
925
+ serializes commits.
926
+
927
+ An error a method throws is final: it fails the Session, which nothing retries, so retry
928
+ transient failures inside the method. Two errors of a read fail only that read: a
929
+ :class:`~pi_durable.errors.StorageRequestError` for an invalid request, and a rejection
930
+ because its caller's context was aborted. Any error from :meth:`commit` or :meth:`mint_id`
931
+ is fatal.
932
+ """
933
+
934
+ async def commit(self, writes: Sequence[StorageWrite], context: Context) -> Seq:
935
+ """Atomically persist one batch and return its sequence."""
936
+
937
+ async def mint_id(self) -> int:
938
+ """Return a fresh ID greater than every ID minted or stored before, also across reopen.
939
+
940
+ The value is unbranded; a caller applies :func:`~pi_durable.ids.id_from_number` at its
941
+ own trusted boundary.
942
+ """
943
+
944
+ async def conversation(self, id: ConversationId, context: Context) -> ConversationRecord | None:
945
+ """Look up one conversation by exact ID."""
946
+
947
+ async def scan_conversations(
948
+ self,
949
+ query: ConversationQuery,
950
+ limit: int,
951
+ cursor: Cursor | None,
952
+ context: Context,
953
+ ) -> Page[ConversationRecord, Cursor]:
954
+ """Scan conversations in ``query['order']`` (default ascending) by ID."""
955
+
956
+ @overload
957
+ async def entry(self, id: EntryId, context: Context) -> EntryLookup | None: ...
958
+
959
+ @overload
960
+ async def entry(
961
+ self,
962
+ conversation_id: ConversationId,
963
+ id: EntryId,
964
+ context: Context,
965
+ ) -> EntryLookup | None: ...
966
+
967
+ async def entry(
968
+ self,
969
+ *args: Any,
970
+ context: Context | None = None,
971
+ **kwargs: Any,
972
+ ) -> EntryLookup | None:
973
+ """Look up one global entry, or one visible through a conversation's ancestry."""
974
+
975
+ async def find_latest_head_marker(
976
+ self,
977
+ conversation_id: ConversationId,
978
+ at_or_before_entry_id: EntryId | None,
979
+ context: Context,
980
+ ) -> EntryWithHead | None:
981
+ """Return the newest visible entry with a ``head`` at or below the optional cutoff."""
982
+
983
+ async def scan_entries(
984
+ self,
985
+ query: EntryQuery,
986
+ limit: int,
987
+ cursor: Cursor | None,
988
+ context: Context,
989
+ ) -> Page[EntryRecord, Cursor]:
990
+ """Scan the inclusive visible range in ``query['order']`` (default descending)."""
991
+
992
+ async def task(self, id: TaskId, context: Context) -> TaskRecord | None:
993
+ """Look up the latest complete record for one task."""
994
+
995
+ async def scan_tasks(
996
+ self,
997
+ query: TaskQuery,
998
+ limit: int,
999
+ cursor: Cursor | None,
1000
+ context: Context,
1001
+ ) -> Page[TaskRecord, Cursor]:
1002
+ """Scan task records matching every supplied filter."""
1003
+
1004
+ async def submission(self, id: SubmissionId, context: Context) -> SubmissionRecord | None:
1005
+ """Look up the latest complete record for one admitted submission."""
1006
+
1007
+ async def scan_submissions(
1008
+ self,
1009
+ query: SubmissionQuery,
1010
+ limit: int,
1011
+ cursor: Cursor | None,
1012
+ context: Context,
1013
+ ) -> Page[SubmissionRecord, Cursor]:
1014
+ """Scan submissions matching every supplied filter."""
1015
+
1016
+ async def submission_by_request(
1017
+ self,
1018
+ conversation_id: ConversationId,
1019
+ request_id: str,
1020
+ context: Context,
1021
+ ) -> SubmissionRecord | None:
1022
+ """Find a submission by its conversation-scoped host deduplication key."""
1023
+
1024
+ async def find_document(
1025
+ self,
1026
+ address: DocumentAddress,
1027
+ at: DocumentPoint,
1028
+ context: Context,
1029
+ ) -> DocumentRecord | None:
1030
+ """Resolve the incarnation occupying one exact logical address at the selected point."""
1031
+
1032
+ async def document(
1033
+ self,
1034
+ id: DocumentId,
1035
+ at: DocumentPoint,
1036
+ context: Context,
1037
+ ) -> StoredDocument | None:
1038
+ """Materialize one specific incarnation by ID at the selected point."""
1039
+
1040
+ async def scan_documents(
1041
+ self,
1042
+ query: DocumentQuery,
1043
+ limit: int,
1044
+ cursor: Cursor | None,
1045
+ context: Context,
1046
+ ) -> Page[DocumentRecord, Cursor]:
1047
+ """Scan incarnations alive in one exact scope at the selected point."""
1048
+
1049
+ def close(self, context: Context) -> Awaitable[None]:
1050
+ """Release backend resources; all later operations must reject."""
1051
+
1052
+
1053
+ # ── Public Session and Tx surfaces ─────────────────────────────────────────
1054
+
1055
+
1056
+ class Tx(Protocol):
1057
+ """Transaction surface available inside one Session commit callback."""
1058
+
1059
+ async def conversation(self, id: ConversationId) -> ConversationRecord | None: ...
1060
+
1061
+ async def entry(self, first: Any, second: Any = None) -> EntryRecord | None: ...
1062
+
1063
+ async def task(self, id: TaskId) -> TaskRecord | None: ...
1064
+
1065
+ async def scan_conversations(
1066
+ self,
1067
+ query: ConversationQuery,
1068
+ limit: int,
1069
+ cursor: Cursor | None = None,
1070
+ ) -> Page[ConversationRecord, Cursor]: ...
1071
+
1072
+ async def scan_entries(
1073
+ self,
1074
+ query: EntryQuery,
1075
+ limit: int,
1076
+ cursor: Cursor | None = None,
1077
+ ) -> Page[EntryRecord, Cursor]: ...
1078
+
1079
+ async def latest_head_marker(self, conversation_id: ConversationId) -> EntryWithHead | None: ...
1080
+
1081
+ async def scan_tasks(
1082
+ self,
1083
+ query: TaskQuery,
1084
+ limit: int,
1085
+ cursor: Cursor | None = None,
1086
+ ) -> Page[TaskRecord, Cursor]: ...
1087
+
1088
+ async def submission_by_request(
1089
+ self, conversation_id: ConversationId, request_id: str
1090
+ ) -> SubmissionRecord | None: ...
1091
+
1092
+ async def create_conversation(self, options: dict[str, Any]) -> ConversationRecord: ...
1093
+
1094
+ async def fork_conversation(
1095
+ self,
1096
+ parent_conversation_id: ConversationId,
1097
+ at: EntryId,
1098
+ options: dict[str, Any],
1099
+ ) -> ConversationRecord: ...
1100
+
1101
+ async def append_entry(self, first: Any, second: Any, third: Any = None) -> EntryRecord: ...
1102
+
1103
+ async def create_task(self, task: Any, input: JsonValue, options: TaskOptions) -> TaskId: ...
1104
+
1105
+ async def create_submission(self, create: SubmissionCreate) -> SubmissionRecord: ...
1106
+
1107
+ def settle_submission(self, id: SubmissionId, settlement: SubmissionSettlement) -> None: ...
1108
+
1109
+ def place_submission(self, id: SubmissionId, entry: EntryId) -> None: ...
1110
+
1111
+ def set_task(self, value: TaskRecord) -> None: ...
1112
+
1113
+ async def doc(self, token: Any, *args: Any) -> Any: ...
1114
+
1115
+ async def retire_doc(self, token: Any, *args: Any) -> None: ...
1116
+
1117
+
1118
+ class DocumentReader(Protocol):
1119
+ """Read side of committed documents: ``Session.snapshot`` and ``snapshotAsOf``."""
1120
+
1121
+ async def snapshot(self, token: Any, *args: Any) -> JsonObject | None: ...
1122
+
1123
+ async def snapshot_as_of(self, token: Any, *args: Any) -> JsonObject | None: ...
1124
+
1125
+
1126
+ class SessionEndClosed(TypedDict):
1127
+ """The Session ended with ``close()``."""
1128
+
1129
+ reason: Literal["closed"]
1130
+
1131
+
1132
+ class SessionEndFailed(TypedDict):
1133
+ """The Session failed and closed itself; ``error`` is the first Storage error."""
1134
+
1135
+ reason: Literal["failed"]
1136
+ error: Any
1137
+
1138
+
1139
+ SessionEnd: TypeAlias = SessionEndClosed | SessionEndFailed
1140
+
1141
+
1142
+ class Session(Protocol):
1143
+ """Owner of one mutation line, its records, and its tracked documents."""
1144
+
1145
+ async def commit(self, change: Any, context: Context) -> Any: ...
1146
+
1147
+ async def close(self, context: Context) -> None: ...
1148
+
1149
+ @property
1150
+ def closed(self) -> asyncio.Future[SessionEnd]: ...
1151
+
1152
+ def subscribe_commits(self, listener: Any) -> Any: ...
1153
+
1154
+ def subscribe_close(self, listener: Any) -> Any: ...
1155
+
1156
+ async def snapshot(self, token: Any, *args: Any) -> JsonObject | None: ...
1157
+
1158
+ async def document_state(self, token: Any, *args: Any) -> Any: ...
1159
+
1160
+ async def watch_doc(self, token: Any, *args: Any) -> Any: ...
1161
+
1162
+ async def snapshot_as_of(self, token: Any, *args: Any) -> JsonObject | None: ...
1163
+
1164
+ # Port addition: the Session-level read that `Conversation.context()` exposes. Upstream keeps
1165
+ # it in the Harness, which does not exist yet.
1166
+ async def context(
1167
+ self,
1168
+ conversation_id: ConversationId,
1169
+ context: Context,
1170
+ at: EntryId | None = None,
1171
+ ) -> "ContextView": ...
1172
+
1173
+
1174
+ # ── Entry construction ─────────────────────────────────────────────────────
1175
+
1176
+ _ENTRY_DRAFT_FIELDS = frozenset({"kind", "model", "data", "head", "edits"})
1177
+ _EDIT_FIELDS = frozenset({"target", "action", "messages"})
1178
+
1179
+
1180
+ def _require_mapping(value: object, label: str) -> Mapping[str, Any]:
1181
+ if not isinstance(value, Mapping):
1182
+ raise TypeError(f"{label} must be a mapping; got {type(value).__name__}")
1183
+ for key in value:
1184
+ if not isinstance(key, str):
1185
+ raise TypeError(f"{label} must have string keys; got {key!r}")
1186
+ return cast(Mapping[str, Any], value)
1187
+
1188
+
1189
+ def _copy_messages(value: object, label: str) -> list[MessageRecord]:
1190
+ if not isinstance(value, list):
1191
+ raise TypeError(f"{label} must be a list of messages")
1192
+ messages: list[MessageRecord] = []
1193
+ for index, item in enumerate(value):
1194
+ message = _require_mapping(item, f"{label}[{index}]")
1195
+ role = message.get("role")
1196
+ if not isinstance(role, str) or not role:
1197
+ raise TypeError(f"{label}[{index}] must have a non-empty string role")
1198
+ messages.append(cast(MessageRecord, copy_json(message)))
1199
+ return messages
1200
+
1201
+
1202
+ def _copy_context_edits(value: object, label: str) -> list[ContextEdit]:
1203
+ if not isinstance(value, list):
1204
+ raise TypeError(f"{label} must be a list of context edits")
1205
+ edits: list[ContextEdit] = []
1206
+ for index, item in enumerate(value):
1207
+ edit = _require_mapping(item, f"{label}[{index}]")
1208
+ unknown = set(edit) - _EDIT_FIELDS
1209
+ if unknown:
1210
+ raise TypeError(f"{label}[{index}] has unknown field {sorted(unknown)[0]!r}")
1211
+ if "target" not in edit:
1212
+ raise TypeError(f"{label}[{index}] is missing target")
1213
+ if "action" not in edit:
1214
+ raise TypeError(f"{label}[{index}] is missing action")
1215
+ target = require_positive_int(edit["target"], f"{label}[{index}] target")
1216
+ action = edit["action"]
1217
+ if action == "omit":
1218
+ if "messages" in edit:
1219
+ raise TypeError(f"{label}[{index}] omit cannot carry messages")
1220
+ edits.append(cast(ContextEdit, freeze_json({"target": target, "action": "omit"})))
1221
+ elif action == "replace":
1222
+ if "messages" not in edit:
1223
+ raise TypeError(f"{label}[{index}] replace requires messages")
1224
+ messages = _copy_messages(edit["messages"], f"{label}[{index}].messages")
1225
+ edits.append(
1226
+ cast(
1227
+ ContextEdit,
1228
+ freeze_json({"target": target, "action": "replace", "messages": messages}),
1229
+ )
1230
+ )
1231
+ else:
1232
+ raise TypeError(f"{label}[{index}].action must be 'omit' or 'replace'")
1233
+ return edits
1234
+
1235
+
1236
+ def _copy_entry_head(value: object, label: str) -> EntryId | Literal["self"]:
1237
+ if value == "self":
1238
+ return "self"
1239
+ return cast(EntryId, require_positive_int(value, label))
1240
+
1241
+
1242
+ def entry_draft(value: Mapping[str, Any]) -> EntryDraft:
1243
+ """Validate and freeze an entry draft.
1244
+
1245
+ Unknown fields, a missing or empty kind, malformed edits, non-JSON data, and non-message model
1246
+ entries are rejected with ``TypeError``. The returned value is a deep-frozen JSON object.
1247
+ """
1248
+
1249
+ draft = _require_mapping(value, "Entry draft")
1250
+ unknown = set(draft) - _ENTRY_DRAFT_FIELDS
1251
+ if unknown:
1252
+ raise TypeError(f"Entry draft has unknown field {sorted(unknown)[0]!r}")
1253
+ kind = draft.get("kind")
1254
+ if not isinstance(kind, str) or not kind:
1255
+ raise TypeError("Entry kind must be a non-empty string")
1256
+ copied: dict[str, JsonValue] = {"kind": kind}
1257
+ if "model" in draft:
1258
+ copied["model"] = cast(JsonValue, _copy_messages(draft["model"], "Entry draft model"))
1259
+ if "data" in draft:
1260
+ copied["data"] = copy_json(draft["data"])
1261
+ if "head" in draft:
1262
+ copied["head"] = cast(JsonValue, _copy_entry_head(draft["head"], "Entry draft head"))
1263
+ if "edits" in draft:
1264
+ copied["edits"] = cast(JsonValue, _copy_context_edits(draft["edits"], "Entry draft edits"))
1265
+ return cast(EntryDraft, freeze_json(copied))
1266
+
1267
+
1268
+ def entry_record(
1269
+ entry_id: EntryId,
1270
+ conversation_id: ConversationId,
1271
+ draft: Mapping[str, Any],
1272
+ *,
1273
+ by_task_id: TaskId | None = None,
1274
+ ) -> EntryRecord:
1275
+ """Build an immutable entry record from a validated draft.
1276
+
1277
+ ``head: "self"`` resolves to the newly assigned entry ID. The record copies by value, so
1278
+ neither the draft nor its model messages can change it afterwards.
1279
+ """
1280
+
1281
+ entry_id = cast(EntryId, require_positive_int(entry_id, "entry ID"))
1282
+ conversation_id = cast(ConversationId, require_positive_int(conversation_id, "conversation ID"))
1283
+ validated = entry_draft(draft)
1284
+ record: dict[str, JsonValue] = {
1285
+ "id": entry_id,
1286
+ "conversationId": conversation_id,
1287
+ "kind": validated["kind"],
1288
+ }
1289
+ for field in ("model", "data", "edits"):
1290
+ if field in validated:
1291
+ record[field] = copy_json(validated[field])
1292
+ if "head" in validated:
1293
+ head = validated["head"]
1294
+ record["head"] = entry_id if head == "self" else head
1295
+ if by_task_id is not None:
1296
+ record["byTaskId"] = require_positive_int(by_task_id, "task ID")
1297
+ return cast(EntryRecord, freeze_json(record))