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,958 @@
1
+ """Portable JSONL implementation of the storage contract, translated from v1.3.0.
2
+
3
+ Commits are append-only main markers plus document and live-task sidecars. In-memory state is
4
+ published only after the marker append succeeds; reopen replays confirmed records, truncates torn
5
+ or unconfirmed tails, and reclaims records that a later base or retirement made unnecessary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from collections.abc import Callable, Sequence
13
+ from dataclasses import dataclass
14
+ from typing import Any, NotRequired, TypedDict, TypeGuard, cast, overload
15
+
16
+ from ...context import Context
17
+ from ...env import Err, FileError, FileSystem
18
+ from ...errors import StorageRequestError
19
+ from ...ids import (
20
+ ConversationId,
21
+ DocumentId,
22
+ EntryId,
23
+ Seq,
24
+ SubmissionId,
25
+ TaskId,
26
+ id_from_number,
27
+ seq_from_number,
28
+ )
29
+ from ...types import (
30
+ ConversationQuery,
31
+ ConversationRecord,
32
+ Cursor,
33
+ DocumentAddress,
34
+ DocumentCreate,
35
+ DocumentPoint,
36
+ DocumentQuery,
37
+ DocumentRecord,
38
+ EntryLookup,
39
+ EntryQuery,
40
+ EntryRecord,
41
+ EntryWithHead,
42
+ Page,
43
+ StorageWrite,
44
+ StoredDocument,
45
+ SubmissionQuery,
46
+ SubmissionRecord,
47
+ TaskQuery,
48
+ TaskRecord,
49
+ )
50
+ from ..memory import MemoryStorage
51
+
52
+ FORMAT_VERSION = 1
53
+ MAIN_FILE = "main.jsonl"
54
+ RECLAIM_SUFFIX = ".reclaim"
55
+
56
+ _SIDECAR_NAME = re.compile(r"^(?:doc|task)-(?P<id>0|[1-9]\d*)\.jsonl$")
57
+ _RECLAIM_NAME = re.compile(r"^(?:doc|task)-(?:0|[1-9]\d*)\.jsonl\.reclaim$")
58
+ _MAX_SAFE_INTEGER = 9_007_199_254_740_991
59
+ _MAIN_ONLY_WRITES = frozenset({"conversation", "entry", "submission", "document.retire"})
60
+ _MAIN_AND_TASK_WRITES = _MAIN_ONLY_WRITES | {"task"}
61
+
62
+
63
+ class JsonlStorageOptions(TypedDict):
64
+ """Options for opening a JSONL directory."""
65
+
66
+ fsync: NotRequired[bool]
67
+
68
+
69
+ class JsonlCorruptionError(Exception):
70
+ """Committed JSONL state is inconsistent; opening the backend fails."""
71
+
72
+ def __init__(self, message: str, cause: BaseException | None = None) -> None:
73
+ super().__init__(message)
74
+ self.name = "JsonlCorruptionError"
75
+ if cause is not None:
76
+ self.__cause__ = cause
77
+
78
+
79
+ class JsonlStoragePoisonedError(Exception):
80
+ """An append outcome is uncertain, so the open backend must be reopened."""
81
+
82
+ def __init__(self, cause: BaseException) -> None:
83
+ super().__init__("JSONL storage is poisoned and must be reopened")
84
+ self.name = "JsonlStoragePoisonedError"
85
+ self.__cause__ = cause
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class _ParsedLine:
90
+ value: Any
91
+ start: int
92
+
93
+
94
+ @dataclass(frozen=True, slots=True)
95
+ class _ParsedFile:
96
+ path: str
97
+ lines: list[_ParsedLine]
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class _EncodedCommit:
102
+ marker: str
103
+ sidecars: dict[str, str]
104
+
105
+
106
+ def _is_object(value: object) -> TypeGuard[dict[str, Any]]:
107
+ return isinstance(value, dict)
108
+
109
+
110
+ def _is_safe_int(value: object) -> TypeGuard[int]:
111
+ return isinstance(value, int) and not isinstance(value, bool) and abs(value) <= _MAX_SAFE_INTEGER
112
+
113
+
114
+ def _sidecar_file_name(kind: str, id: int) -> str:
115
+ return f"{kind}-{id}.jsonl"
116
+
117
+
118
+ def _is_sidecar_file_name(name: str) -> bool:
119
+ return _SIDECAR_NAME.match(name) is not None
120
+
121
+
122
+ def _sidecar_file_id(name: str) -> int:
123
+ match = _SIDECAR_NAME.match(name)
124
+ assert match is not None
125
+ return int(match.group("id"))
126
+
127
+
128
+ def _is_reclaim_file_name(name: str) -> bool:
129
+ return _RECLAIM_NAME.match(name) is not None
130
+
131
+
132
+ def _is_current_only(record: DocumentCreate | DocumentRecord) -> bool:
133
+ return record["scope"]["kind"] != "conversation" or record.get("history") == "latest"
134
+
135
+
136
+ def _sidecar_key(file: str, seq: int, ordinal: int) -> str:
137
+ return json.dumps([file, seq, ordinal], separators=(",", ":"))
138
+
139
+
140
+ def _json_line(value: object) -> str:
141
+ return json.dumps(value, ensure_ascii=True, separators=(",", ":"), allow_nan=False) + "\n"
142
+
143
+
144
+ def _error_from_file(action: str, error: FileError) -> RuntimeError:
145
+ wrapped = RuntimeError(f"JSONL {action} failed: {error}")
146
+ wrapped.__cause__ = error
147
+ return wrapped
148
+
149
+
150
+ def _parse_json(text: str, description: str) -> object:
151
+ try:
152
+ return json.loads(text)
153
+ except json.JSONDecodeError as error:
154
+ raise JsonlCorruptionError(f"Malformed complete {description}", error) from error
155
+
156
+
157
+ def _validate_main_operation(value: object, description: str) -> dict[str, Any]:
158
+ if not _is_object(value) or not isinstance(value.get("type"), str):
159
+ raise JsonlCorruptionError(f"Invalid write in {description}")
160
+ operation = value
161
+ kind = operation["type"]
162
+ if kind in ("conversation", "entry", "submission"):
163
+ record = operation.get("value")
164
+ if not _is_object(record) or not _is_safe_int(record.get("id")):
165
+ raise JsonlCorruptionError(f"Invalid {kind} write in {description}")
166
+ return operation
167
+ if kind == "task":
168
+ record = operation.get("value")
169
+ if (
170
+ not _is_object(record)
171
+ or not _is_safe_int(record.get("id"))
172
+ or not _is_object(record.get("state"))
173
+ or record["state"].get("status") != "terminal"
174
+ ):
175
+ raise JsonlCorruptionError(f"Invalid terminal task write in {description}")
176
+ return operation
177
+ if kind == "document.retire":
178
+ if not _is_safe_int(operation.get("id")):
179
+ raise JsonlCorruptionError(f"Invalid document retirement in {description}")
180
+ return operation
181
+ if kind == "task.sidecar":
182
+ if (
183
+ not _is_safe_int(operation.get("id"))
184
+ or not _is_safe_int(operation.get("ordinal"))
185
+ or operation["ordinal"] < 0
186
+ ):
187
+ raise JsonlCorruptionError(f"Invalid task sidecar write in {description}")
188
+ return operation
189
+ if kind == "document.create":
190
+ record = operation.get("record")
191
+ if (
192
+ not _is_object(record)
193
+ or not _is_safe_int(record.get("id"))
194
+ or not _is_safe_int(operation.get("ordinal"))
195
+ or operation["ordinal"] < 0
196
+ ):
197
+ raise JsonlCorruptionError(f"Invalid document creation in {description}")
198
+ return operation
199
+ if kind == "document.change":
200
+ if (
201
+ not _is_safe_int(operation.get("id"))
202
+ or not _is_safe_int(operation.get("ordinal"))
203
+ or operation["ordinal"] < 0
204
+ ):
205
+ raise JsonlCorruptionError(f"Invalid document change in {description}")
206
+ return operation
207
+ raise JsonlCorruptionError(f"Unknown write type in {description}")
208
+
209
+
210
+ def _parse_main_marker(text: str, line: int) -> dict[str, Any]:
211
+ description = f"{MAIN_FILE} line {line}"
212
+ value = _parse_json(text, description)
213
+ if (
214
+ not _is_object(value)
215
+ or not _is_safe_int(value.get("format"))
216
+ or value["format"] != FORMAT_VERSION
217
+ or value.get("type") != "commit"
218
+ or not _is_safe_int(value.get("seq"))
219
+ or value["seq"] < 1
220
+ or not isinstance(value.get("writes"), list)
221
+ ):
222
+ raise JsonlCorruptionError(f"Invalid commit marker in {description}")
223
+ return {
224
+ "format": FORMAT_VERSION,
225
+ "type": "commit",
226
+ "seq": seq_from_number(value["seq"]),
227
+ "writes": [_validate_main_operation(write, description) for write in value["writes"]],
228
+ }
229
+
230
+
231
+ def _validate_document_content(value: object, description: str) -> dict[str, Any]:
232
+ if not _is_object(value) or not _is_safe_int(value.get("version")) or value["version"] < 1:
233
+ raise JsonlCorruptionError(f"Invalid document content in {description}")
234
+ if value.get("kind") == "base" and _is_object(value.get("value")):
235
+ return value
236
+ if value.get("kind") == "delta" and isinstance(value.get("ops"), list):
237
+ return value
238
+ raise JsonlCorruptionError(f"Invalid document content in {description}")
239
+
240
+
241
+ def _parse_sidecar_record(text: str, file: str, line: int) -> dict[str, Any]:
242
+ description = f"{file} line {line}"
243
+ value = _parse_json(text, description)
244
+ if (
245
+ not _is_object(value)
246
+ or not _is_safe_int(value.get("format"))
247
+ or value["format"] != FORMAT_VERSION
248
+ or value.get("type") != "record"
249
+ or not _is_safe_int(value.get("seq"))
250
+ or value["seq"] < 1
251
+ or not _is_safe_int(value.get("ordinal"))
252
+ or value["ordinal"] < 0
253
+ or not _is_object(value.get("payload"))
254
+ or not isinstance(value["payload"].get("type"), str)
255
+ ):
256
+ raise JsonlCorruptionError(f"Invalid sidecar record in {description}")
257
+ payload = value["payload"]
258
+ if payload["type"] == "task":
259
+ record = payload.get("value")
260
+ if (
261
+ not _is_object(record)
262
+ or not _is_safe_int(record.get("id"))
263
+ or not _is_object(record.get("state"))
264
+ or record["state"].get("status") == "terminal"
265
+ ):
266
+ raise JsonlCorruptionError(f"Invalid live task record in {description}")
267
+ elif payload["type"] == "document":
268
+ if not _is_safe_int(payload.get("id")):
269
+ raise JsonlCorruptionError(f"Invalid document record in {description}")
270
+ _validate_document_content(payload.get("content"), description)
271
+ else:
272
+ raise JsonlCorruptionError(f"Unknown sidecar record type in {description}")
273
+ return value
274
+
275
+
276
+ class JsonlStorage:
277
+ """Portable JSONL implementation of the storage contract."""
278
+
279
+ def __init__(
280
+ self,
281
+ fs: FileSystem,
282
+ directory: str,
283
+ main_path: str,
284
+ options: JsonlStorageOptions | None,
285
+ ) -> None:
286
+ self._fs = fs
287
+ self._directory = directory
288
+ self._main_path = main_path
289
+ self._fsync = bool(options and options.get("fsync", False))
290
+ self._memory = MemoryStorage()
291
+ self._current_only_documents: set[int] = set()
292
+ self._live_task_sidecars: set[int] = set()
293
+ self._closed = False
294
+ self._poison_error: JsonlStoragePoisonedError | None = None
295
+
296
+ @staticmethod
297
+ async def open(
298
+ directory: str,
299
+ fs: FileSystem,
300
+ context: Context,
301
+ options: JsonlStorageOptions | None = None,
302
+ ) -> JsonlStorage:
303
+ """Open or create a JSONL storage directory using the supplied filesystem."""
304
+
305
+ absolute = await fs.absolute_path(directory, context)
306
+ if isinstance(absolute, Err):
307
+ raise _error_from_file("path resolution", absolute.error)
308
+ created = await fs.create_dir(absolute.value, {"recursive": True}, context)
309
+ if isinstance(created, Err):
310
+ raise _error_from_file("directory creation", created.error)
311
+ main_path = await fs.join_path([absolute.value, MAIN_FILE], context)
312
+ if isinstance(main_path, Err):
313
+ raise _error_from_file("path join", main_path.error)
314
+ storage = JsonlStorage(fs, absolute.value, main_path.value, options)
315
+ await storage._recover(context)
316
+ return storage
317
+
318
+ async def commit(self, writes: Sequence[StorageWrite], context: Context) -> Seq:
319
+ self._assert_usable()
320
+ prepared = self._memory.prepare_commit(writes)
321
+ encoded = self._encode_commit(prepared.seq, prepared.writes)
322
+ reclamations = self._plan_reclamations(prepared.writes, encoded)
323
+ sidecars: list[tuple[str, str, str]] = [
324
+ (file, content, await self._resolve_file(file, context))
325
+ for file, content in encoded.sidecars.items()
326
+ ]
327
+
328
+ for file, content, path in sidecars:
329
+ result = await self._fs.append_file(path, content, context)
330
+ if isinstance(result, Err):
331
+ raise self._poison(_error_from_file(f"append to {file}", result.error))
332
+ if self._fsync:
333
+ for file, _content, path in sidecars:
334
+ result = await self._fs.flush_file(path, context)
335
+ if isinstance(result, Err):
336
+ raise self._poison(_error_from_file(f"flush of {file}", result.error))
337
+ marker = await self._fs.append_file(self._main_path, encoded.marker, context)
338
+ if isinstance(marker, Err):
339
+ raise self._poison(_error_from_file(f"append to {MAIN_FILE}", marker.error))
340
+ seq = prepared.apply()
341
+ self._adopt_sidecar_state(prepared.writes)
342
+ await self._reclaim_sidecars(reclamations, context)
343
+ return seq
344
+
345
+ async def mint_id(self) -> int:
346
+ return await self._store.mint_id()
347
+
348
+ async def conversation(self, id: ConversationId, context: Context) -> ConversationRecord | None:
349
+ return await self._store.conversation(id, context)
350
+
351
+ async def scan_conversations(
352
+ self,
353
+ query: ConversationQuery,
354
+ limit: int,
355
+ cursor: Cursor | None,
356
+ context: Context,
357
+ ) -> Page[ConversationRecord, Cursor]:
358
+ return await self._store.scan_conversations(query, limit, cursor, context)
359
+
360
+ @overload
361
+ async def entry(self, id: EntryId, context: Context, /) -> EntryLookup | None: ...
362
+
363
+ @overload
364
+ async def entry(
365
+ self,
366
+ conversation_id: ConversationId,
367
+ id: EntryId,
368
+ context: Context,
369
+ /,
370
+ ) -> EntryLookup | None: ...
371
+
372
+ async def entry(
373
+ self,
374
+ id_or_conversation_id: EntryId | ConversationId | int,
375
+ id_or_context: EntryId | Context | None = None,
376
+ context: Context | None = None,
377
+ ) -> EntryLookup | None:
378
+ if context is None:
379
+ if not isinstance(id_or_context, Context) and id_or_context is not None:
380
+ raise StorageRequestError("Storage.entry() requires an entry ID")
381
+ return await self._store.entry(id_from_number(id_or_conversation_id), id_or_context)
382
+ if not _is_safe_int(id_or_context):
383
+ raise StorageRequestError("Storage.entry() requires an entry ID")
384
+ return await self._store.entry(
385
+ id_from_number(id_or_conversation_id),
386
+ id_from_number(id_or_context),
387
+ context,
388
+ )
389
+
390
+ async def find_latest_head_marker(
391
+ self,
392
+ conversation_id: ConversationId,
393
+ at_or_before_entry_id: EntryId | None,
394
+ context: Context,
395
+ ) -> EntryWithHead | None:
396
+ return await self._store.find_latest_head_marker(
397
+ conversation_id, at_or_before_entry_id, context
398
+ )
399
+
400
+ async def scan_entries(
401
+ self,
402
+ query: EntryQuery,
403
+ limit: int,
404
+ cursor: Cursor | None,
405
+ context: Context,
406
+ ) -> Page[EntryRecord, Cursor]:
407
+ return await self._store.scan_entries(query, limit, cursor, context)
408
+
409
+ async def task(self, id: TaskId, context: Context) -> TaskRecord | None:
410
+ return await self._store.task(id, context)
411
+
412
+ async def scan_tasks(
413
+ self,
414
+ query: TaskQuery,
415
+ limit: int,
416
+ cursor: Cursor | None,
417
+ context: Context,
418
+ ) -> Page[TaskRecord, Cursor]:
419
+ return await self._store.scan_tasks(query, limit, cursor, context)
420
+
421
+ async def submission(self, id: SubmissionId, context: Context) -> SubmissionRecord | None:
422
+ return await self._store.submission(id, context)
423
+
424
+ async def scan_submissions(
425
+ self,
426
+ query: SubmissionQuery,
427
+ limit: int,
428
+ cursor: Cursor | None,
429
+ context: Context,
430
+ ) -> Page[SubmissionRecord, Cursor]:
431
+ return await self._store.scan_submissions(query, limit, cursor, context)
432
+
433
+ async def submission_by_request(
434
+ self,
435
+ conversation_id: ConversationId,
436
+ request_id: str,
437
+ context: Context,
438
+ ) -> SubmissionRecord | None:
439
+ return await self._store.submission_by_request(conversation_id, request_id, context)
440
+
441
+ async def find_document(
442
+ self,
443
+ address: DocumentAddress,
444
+ at: DocumentPoint,
445
+ context: Context,
446
+ ) -> DocumentRecord | None:
447
+ return await self._store.find_document(address, at, context)
448
+
449
+ async def document(
450
+ self,
451
+ id: DocumentId,
452
+ at: DocumentPoint,
453
+ context: Context,
454
+ ) -> StoredDocument | None:
455
+ return await self._store.document(id, at, context)
456
+
457
+ async def scan_documents(
458
+ self,
459
+ query: DocumentQuery,
460
+ limit: int,
461
+ cursor: Cursor | None,
462
+ context: Context,
463
+ ) -> Page[DocumentRecord, Cursor]:
464
+ return await self._store.scan_documents(query, limit, cursor, context)
465
+
466
+ async def close(self, context: Context) -> None:
467
+ if self._closed:
468
+ return
469
+ self._closed = True
470
+ await self._memory.close(context)
471
+
472
+ # ── Encoding and publication ────────────────────────────────────────────
473
+
474
+ def _encode_commit(self, seq: Seq, writes: Sequence[StorageWrite]) -> _EncodedCommit:
475
+ main_writes: list[dict[str, Any]] = []
476
+ records: dict[str, list[dict[str, Any]]] = {}
477
+ next_ordinal = 0
478
+
479
+ def add_sidecar(file: str, payload: dict[str, Any]) -> int:
480
+ nonlocal next_ordinal
481
+ ordinal = next_ordinal
482
+ next_ordinal += 1
483
+ records.setdefault(file, []).append(
484
+ {
485
+ "format": FORMAT_VERSION,
486
+ "type": "record",
487
+ "seq": seq,
488
+ "ordinal": ordinal,
489
+ "payload": payload,
490
+ }
491
+ )
492
+ return ordinal
493
+
494
+ for write in writes:
495
+ kind = write["type"]
496
+ if kind in _MAIN_ONLY_WRITES:
497
+ main_writes.append(cast(dict[str, Any], write))
498
+ elif kind == "task":
499
+ task = cast(dict[str, Any], write["value"])
500
+ if task["state"]["status"] == "terminal":
501
+ main_writes.append(cast(dict[str, Any], write))
502
+ else:
503
+ ordinal = add_sidecar(
504
+ _sidecar_file_name("task", task["id"]),
505
+ {"type": "task", "value": task},
506
+ )
507
+ main_writes.append({"type": "task.sidecar", "id": task["id"], "ordinal": ordinal})
508
+ elif kind == "document.create":
509
+ record = cast(dict[str, Any], write["record"])
510
+ ordinal = add_sidecar(
511
+ _sidecar_file_name("doc", record["id"]),
512
+ {"type": "document", "id": record["id"], "content": write["content"]},
513
+ )
514
+ main_writes.append(
515
+ {"type": "document.create", "record": record, "ordinal": ordinal}
516
+ )
517
+ elif kind == "document.change":
518
+ id = cast(int, write["id"])
519
+ ordinal = add_sidecar(
520
+ _sidecar_file_name("doc", id),
521
+ {"type": "document", "id": id, "content": write["content"]},
522
+ )
523
+ main_writes.append({"type": "document.change", "id": id, "ordinal": ordinal})
524
+
525
+ sidecars = {
526
+ file: "".join(_json_line(record) for record in file_records)
527
+ for file, file_records in records.items()
528
+ }
529
+ marker = _json_line(
530
+ {"format": FORMAT_VERSION, "type": "commit", "seq": seq, "writes": main_writes}
531
+ )
532
+ return _EncodedCommit(marker=marker, sidecars=sidecars)
533
+
534
+ def _plan_reclamations(
535
+ self,
536
+ writes: Sequence[StorageWrite],
537
+ encoded: _EncodedCommit,
538
+ ) -> dict[str, str]:
539
+ created_current_only: dict[int, None] = {}
540
+ retired_documents: dict[int, None] = {}
541
+ base_documents: dict[int, None] = {}
542
+ final_tasks: dict[int, dict[str, Any]] = {}
543
+ for write in writes:
544
+ kind = write["type"]
545
+ if kind == "document.create":
546
+ record = cast(dict[str, Any], write["record"])
547
+ if _is_current_only(cast(DocumentCreate, record)):
548
+ created_current_only[record["id"]] = None
549
+ elif kind == "document.change":
550
+ if write["content"]["kind"] == "base":
551
+ base_documents[cast(int, write["id"])] = None
552
+ elif kind == "document.retire":
553
+ retired_documents[cast(int, write["id"])] = None
554
+ elif kind == "task":
555
+ task = cast(dict[str, Any], write["value"])
556
+ final_tasks[task["id"]] = task
557
+
558
+ replacements: dict[str, str] = {}
559
+
560
+ def is_current_only_document(id: int) -> bool:
561
+ return id in self._current_only_documents or id in created_current_only
562
+
563
+ for id in retired_documents:
564
+ if is_current_only_document(id):
565
+ replacements[_sidecar_file_name("doc", id)] = ""
566
+ for id in base_documents:
567
+ if not is_current_only_document(id) or id in retired_documents:
568
+ continue
569
+ file = _sidecar_file_name("doc", id)
570
+ content = encoded.sidecars.get(file)
571
+ if content is not None:
572
+ replacements[file] = content
573
+ for id, task in final_tasks.items():
574
+ if task["state"]["status"] == "terminal" and (
575
+ id in self._live_task_sidecars
576
+ or _sidecar_file_name("task", id) in encoded.sidecars
577
+ ):
578
+ replacements[_sidecar_file_name("task", id)] = ""
579
+ return replacements
580
+
581
+ def _adopt_sidecar_state(self, writes: Sequence[StorageWrite]) -> None:
582
+ for write in writes:
583
+ if write["type"] == "document.create":
584
+ record = cast(dict[str, Any], write["record"])
585
+ if _is_current_only(cast(DocumentCreate, record)):
586
+ self._current_only_documents.add(record["id"])
587
+ elif write["type"] == "task":
588
+ task = cast(dict[str, Any], write["value"])
589
+ if task["state"]["status"] == "terminal":
590
+ self._live_task_sidecars.discard(task["id"])
591
+ else:
592
+ self._live_task_sidecars.add(task["id"])
593
+
594
+ async def _reclaim_sidecars(self, replacements: dict[str, str], context: Context) -> None:
595
+ """The marker already published this state, so reclamation is retryable maintenance."""
596
+
597
+ if not replacements:
598
+ return
599
+ if self._fsync:
600
+ flushed = await self._fs.flush_file(self._main_path, context)
601
+ if isinstance(flushed, Err):
602
+ return
603
+ for file, content in replacements.items():
604
+ await self._replace_sidecar(file, content, context)
605
+
606
+ async def _replace_sidecar(self, file: str, content: str, context: Context) -> None:
607
+ path = await self._try_join([self._directory, file], context)
608
+ if path is None:
609
+ return
610
+ if content == "":
611
+ await self._fs.remove(path, {"force": True}, context)
612
+ return
613
+ temporary_path = await self._try_join([self._directory, f"{file}{RECLAIM_SUFFIX}"], context)
614
+ if temporary_path is None:
615
+ return
616
+ written = await self._fs.write_file(temporary_path, content, context)
617
+ if isinstance(written, Err):
618
+ return
619
+ if self._fsync:
620
+ flushed = await self._fs.flush_file(temporary_path, context)
621
+ if isinstance(flushed, Err):
622
+ return
623
+ await self._fs.rename_file(temporary_path, path, context)
624
+
625
+ # ── Recovery ────────────────────────────────────────────────────────────
626
+
627
+ async def _recover(self, context: Context) -> None:
628
+ main = await self._read_lines(
629
+ self._main_path,
630
+ MAIN_FILE,
631
+ context,
632
+ lambda text, line: _parse_main_marker(text, line),
633
+ )
634
+ previous_seq = 0
635
+ for marker in main.lines:
636
+ if marker.value["seq"] <= previous_seq:
637
+ raise JsonlCorruptionError(
638
+ f"Commit sequence does not strictly increase in {MAIN_FILE}"
639
+ )
640
+ previous_seq = marker.value["seq"]
641
+
642
+ listed = await self._fs.list_dir(self._directory, context)
643
+ if isinstance(listed, Err):
644
+ raise _error_from_file("directory listing", listed.error)
645
+ for info in listed.value:
646
+ if info["kind"] == "file" and _is_reclaim_file_name(info["name"]):
647
+ await self._fs.remove(info["path"], {"force": True}, context)
648
+ sidecar_files = sorted(
649
+ info["name"]
650
+ for info in listed.value
651
+ if info["kind"] == "file" and _is_sidecar_file_name(info["name"])
652
+ )
653
+ parsed_files: dict[str, _ParsedFile] = {}
654
+ record_by_key: dict[str, _ParsedLine] = {}
655
+ for file in sidecar_files:
656
+ path = await self._try_join([self._directory, file], context)
657
+ if path is None:
658
+ raise _error_from_file("path join", FileError("invalid", "Invalid path", file))
659
+ parsed = await self._read_lines(
660
+ path,
661
+ file,
662
+ context,
663
+ lambda text, line, file=file: _parse_sidecar_record(text, file, line),
664
+ )
665
+ parsed_files[file] = parsed
666
+ previous: dict[str, Any] | None = None
667
+ for line in parsed.lines:
668
+ value = line.value
669
+ if previous is not None and (
670
+ value["seq"] < previous["seq"]
671
+ or (value["seq"] == previous["seq"] and value["ordinal"] <= previous["ordinal"])
672
+ ):
673
+ raise JsonlCorruptionError(f"Sidecar records are out of order in {file}")
674
+ previous = value
675
+ record_by_key[_sidecar_key(file, value["seq"], value["ordinal"])] = line
676
+
677
+ current_only_documents: dict[int, None] = {}
678
+ retired_documents: dict[int, None] = {}
679
+ final_task_is_live: dict[int, bool] = {}
680
+ for marker_line in main.lines:
681
+ for operation in marker_line.value["writes"]:
682
+ kind = operation["type"]
683
+ if kind == "document.create":
684
+ if _is_current_only(operation["record"]):
685
+ current_only_documents[operation["record"]["id"]] = None
686
+ elif kind == "document.retire":
687
+ retired_documents[operation["id"]] = None
688
+ elif kind == "task":
689
+ final_task_is_live[operation["value"]["id"]] = False
690
+ elif kind == "task.sidecar":
691
+ final_task_is_live[operation["id"]] = True
692
+ retired_current_only = {
693
+ id: None for id in retired_documents if id in current_only_documents
694
+ }
695
+
696
+ latest_bases: dict[int, dict[str, Any]] = {}
697
+ for marker_line in main.lines:
698
+ marker = marker_line.value
699
+ for operation in marker["writes"]:
700
+ kind = operation["type"]
701
+ if kind not in ("document.create", "document.change"):
702
+ continue
703
+ id = operation["record"]["id"] if kind == "document.create" else operation["id"]
704
+ if id not in current_only_documents:
705
+ continue
706
+ record_line = record_by_key.get(
707
+ _sidecar_key(_sidecar_file_name("doc", id), marker["seq"], operation["ordinal"])
708
+ )
709
+ if record_line is None:
710
+ continue
711
+ record = record_line.value
712
+ if (
713
+ record["payload"]["type"] != "document"
714
+ or record["payload"]["id"] != id
715
+ or record["payload"]["content"]["kind"] != "base"
716
+ ):
717
+ continue
718
+ previous = latest_bases.get(id)
719
+ if (
720
+ previous is None
721
+ or record["seq"] > previous["seq"]
722
+ or (record["seq"] == previous["seq"] and record["ordinal"] > previous["ordinal"])
723
+ ):
724
+ latest_bases[id] = record
725
+
726
+ terminal_tasks = {id for id, live in final_task_is_live.items() if not live}
727
+ confirmed: set[str] = set()
728
+ for marker_line in main.lines:
729
+ marker = marker_line.value
730
+ writes: list[dict[str, Any]] = []
731
+ for operation in marker["writes"]:
732
+ kind = operation["type"]
733
+ if kind in _MAIN_AND_TASK_WRITES:
734
+ writes.append(operation)
735
+ elif kind == "task.sidecar":
736
+ optional = operation["id"] in terminal_tasks
737
+ record = self._confirm_record(
738
+ marker,
739
+ operation["ordinal"],
740
+ _sidecar_file_name("task", operation["id"]),
741
+ record_by_key,
742
+ confirmed,
743
+ optional,
744
+ )
745
+ if record is not None:
746
+ if (
747
+ record["payload"]["type"] != "task"
748
+ or record["payload"]["value"]["id"] != operation["id"]
749
+ ):
750
+ raise JsonlCorruptionError(
751
+ f"Confirmed task sidecar data does not match commit {marker['seq']}"
752
+ )
753
+ if not optional:
754
+ writes.append({"type": "task", "value": record["payload"]["value"]})
755
+ else:
756
+ id = (
757
+ operation["record"]["id"]
758
+ if kind == "document.create"
759
+ else operation["id"]
760
+ )
761
+ reclaimed = id in retired_current_only or self._is_before_latest_base(
762
+ latest_bases, id, marker["seq"], operation["ordinal"]
763
+ )
764
+ record = self._confirm_record(
765
+ marker,
766
+ operation["ordinal"],
767
+ _sidecar_file_name("doc", id),
768
+ record_by_key,
769
+ confirmed,
770
+ reclaimed,
771
+ )
772
+ content: dict[str, Any] | None = None
773
+ if record is not None:
774
+ if (
775
+ record["payload"]["type"] != "document"
776
+ or record["payload"]["id"] != id
777
+ ):
778
+ raise JsonlCorruptionError(
779
+ f"Confirmed document sidecar data does not match commit {marker['seq']}"
780
+ )
781
+ content = record["payload"]["content"]
782
+ if kind == "document.create":
783
+ if content is not None and content["kind"] != "base":
784
+ raise JsonlCorruptionError(
785
+ f"Document creation lacks a confirmed base in commit {marker['seq']}"
786
+ )
787
+ writes.append(
788
+ {
789
+ "type": "document.create",
790
+ "record": operation["record"],
791
+ "content": (
792
+ {"kind": "base", "version": 1, "value": {}}
793
+ if reclaimed or content is None
794
+ else content
795
+ ),
796
+ }
797
+ )
798
+ elif not reclaimed and content is not None:
799
+ writes.append(
800
+ {"type": "document.change", "id": id, "content": content}
801
+ )
802
+ try:
803
+ self._memory.prepare_commit(cast(list[StorageWrite], writes), marker["seq"]).apply()
804
+ except Exception as error:
805
+ raise JsonlCorruptionError(
806
+ f"Invalid committed state at sequence {marker['seq']}", error
807
+ ) from error
808
+
809
+ reclamations: dict[str, str] = {}
810
+ for file, parsed in parsed_files.items():
811
+ unconfirmed_at: int | None = None
812
+ for line in parsed.lines:
813
+ key = _sidecar_key(file, line.value["seq"], line.value["ordinal"])
814
+ if key in confirmed:
815
+ if unconfirmed_at is not None:
816
+ raise JsonlCorruptionError(
817
+ f"Confirmed record follows an unconfirmed tail in {file}"
818
+ )
819
+ elif unconfirmed_at is None:
820
+ unconfirmed_at = line.start
821
+ if unconfirmed_at is not None:
822
+ truncated = await self._fs.truncate_file(parsed.path, unconfirmed_at, context)
823
+ if isinstance(truncated, Err):
824
+ raise _error_from_file(f"tail truncation of {file}", truncated.error)
825
+
826
+ numeric_id = _sidecar_file_id(file)
827
+ confirmed_lines = [
828
+ line
829
+ for line in parsed.lines
830
+ if _sidecar_key(file, line.value["seq"], line.value["ordinal"]) in confirmed
831
+ ]
832
+ retained_lines: list[_ParsedLine] | None = None
833
+ if file.startswith("task-") and numeric_id in terminal_tasks:
834
+ retained_lines = []
835
+ elif file.startswith("doc-"):
836
+ document_id = numeric_id
837
+ if document_id in retired_current_only:
838
+ retained_lines = []
839
+ elif document_id in latest_bases:
840
+ retained_lines = [
841
+ line
842
+ for line in confirmed_lines
843
+ if not self._is_before_latest_base(
844
+ latest_bases, document_id, line.value["seq"], line.value["ordinal"]
845
+ )
846
+ ]
847
+ if retained_lines is not None and (
848
+ len(retained_lines) < len(confirmed_lines) or len(retained_lines) == 0
849
+ ):
850
+ reclamations[file] = "".join(_json_line(line.value) for line in retained_lines)
851
+ await self._reclaim_sidecars(reclamations, context)
852
+
853
+ self._current_only_documents.update(current_only_documents)
854
+ for id, live in final_task_is_live.items():
855
+ if live:
856
+ self._live_task_sidecars.add(id)
857
+
858
+ @staticmethod
859
+ def _confirm_record(
860
+ marker: dict[str, Any],
861
+ ordinal: int,
862
+ file: str,
863
+ record_by_key: dict[str, _ParsedLine],
864
+ confirmed: set[str],
865
+ optional: bool,
866
+ ) -> dict[str, Any] | None:
867
+ key = _sidecar_key(file, marker["seq"], ordinal)
868
+ if key in confirmed:
869
+ raise JsonlCorruptionError("Sidecar record is confirmed more than once")
870
+ line = record_by_key.get(key)
871
+ if line is None:
872
+ if optional:
873
+ return None
874
+ raise JsonlCorruptionError(
875
+ f"Missing confirmed sidecar record {file} at sequence {marker['seq']}"
876
+ )
877
+ confirmed.add(key)
878
+ return cast(dict[str, Any], line.value)
879
+
880
+ @staticmethod
881
+ def _is_before_latest_base(
882
+ latest_bases: dict[int, dict[str, Any]],
883
+ id: int,
884
+ seq: int,
885
+ ordinal: int,
886
+ ) -> bool:
887
+ base = latest_bases.get(id)
888
+ return base is not None and (
889
+ seq < base["seq"] or (seq == base["seq"] and ordinal < base["ordinal"])
890
+ )
891
+
892
+ async def _read_lines(
893
+ self,
894
+ path: str,
895
+ name: str,
896
+ context: Context,
897
+ parse: Callable[[str, int], Any],
898
+ ) -> _ParsedFile:
899
+ read = await self._fs.read_binary_file(path, context)
900
+ if isinstance(read, Err):
901
+ if read.error.code == "not_found":
902
+ return _ParsedFile(path, [])
903
+ raise _error_from_file(f"read of {name}", read.error)
904
+ data = read.value
905
+ complete_size = len(data)
906
+ if complete_size > 0 and data[complete_size - 1] != 0x0A:
907
+ complete_size = data.rfind(b"\n") + 1
908
+ truncated = await self._fs.truncate_file(path, complete_size, context)
909
+ if isinstance(truncated, Err):
910
+ raise _error_from_file(f"torn-line truncation of {name}", truncated.error)
911
+ lines: list[_ParsedLine] = []
912
+ start = 0
913
+ line_number = 1
914
+ for raw in data[:complete_size].split(b"\n")[:-1]:
915
+ try:
916
+ text = raw.decode("utf-8")
917
+ except UnicodeDecodeError as error:
918
+ raise JsonlCorruptionError(
919
+ f"Invalid UTF-8 in complete {name} line {line_number}", error
920
+ ) from error
921
+ lines.append(_ParsedLine(parse(text, line_number), start))
922
+ start += len(raw) + 1
923
+ line_number += 1
924
+ return _ParsedFile(path, lines)
925
+
926
+ async def _resolve_file(self, file: str, context: Context) -> str:
927
+ path = await self._fs.join_path([self._directory, file], context)
928
+ if isinstance(path, Err):
929
+ raise _error_from_file("path join", path.error)
930
+ return path.value
931
+
932
+ async def _try_join(self, parts: Sequence[str], context: Context) -> str | None:
933
+ path = await self._fs.join_path(parts, context)
934
+ return None if isinstance(path, Err) else path.value
935
+
936
+ @property
937
+ def _store(self) -> MemoryStorage:
938
+ self._assert_usable()
939
+ return self._memory
940
+
941
+ def _poison(self, cause: Exception) -> JsonlStoragePoisonedError:
942
+ if self._poison_error is None:
943
+ self._poison_error = JsonlStoragePoisonedError(cause)
944
+ return self._poison_error
945
+
946
+ def _assert_usable(self) -> None:
947
+ if self._closed:
948
+ raise RuntimeError("JsonlStorage is closed")
949
+ if self._poison_error is not None:
950
+ raise self._poison_error
951
+
952
+
953
+ __all__ = [
954
+ "JsonlCorruptionError",
955
+ "JsonlStorage",
956
+ "JsonlStorageOptions",
957
+ "JsonlStoragePoisonedError",
958
+ ]