thread-archive 0.0.2__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 (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2340 @@
1
+ """JSONL truth-log: the durable source of truth for the archive.
2
+
3
+ The storage model is **JSONL-as-truth + SQLite-as-rebuildable-index**: the JSONL
4
+ directory on disk is the only authoritative store, and ``index.db`` is a pure
5
+ projection of it — ``rm index.db && archive reindex`` reconstructs the whole index
6
+ from the JSONL, and ``cp``/``rsync`` of the directory *is* the backup. Nothing
7
+ durable lives only in SQLite.
8
+
9
+ **One file per thread.** Each conversation (and each topic — topics are threads
10
+ too) is its own ``threads/<id>.jsonl``, the same shape as a Claude Code session
11
+ file: a ``{"type": "thread", ...}`` metadata record (latest wins) followed by one
12
+ ``{"type": "event", ...}`` line per event, in order. A new event appends to that
13
+ one thread's file; you can open, diff, or ``cp`` a single conversation. The two
14
+ cross-thread overlays — ``thread_links`` (the topic graph's edges) and
15
+ ``topic_messages`` (topic evidence) — aren't any one conversation's content, so
16
+ they stay as snapshot files (``thread_links.jsonl`` / ``topic_messages.jsonl``).
17
+
18
+ **Adaptive sharding.** A flat ``threads/`` directory is comfortable to ~16k files;
19
+ past that, tooling (file trees, ``ls``, completion) drags. ``manifest.json`` records
20
+ a ``shard_depth``: 0 = flat (``threads/<id>.jsonl``), 1 = 256 id-bucketed subdirs
21
+ (``threads/<id%256>/<id>.jsonl``, good to a few million), 2 = two levels. The path
22
+ resolver computes a thread's location from the recorded depth, so reads and writes
23
+ always agree; crossing the threshold triggers an auto-rebalance, and once sharded
24
+ every checkpoint re-homes any straggler file. The rebalance is crash-safe by
25
+ construction: the new depth is persisted *before* any file moves, a file whose home
26
+ already exists is merged (appended) rather than overwritten, and the sweep is
27
+ serialized across processes by a flock — see :func:`_maybe_rebalance`. Small
28
+ archives stay flat with zero ceremony.
29
+
30
+ Writes preserve the **JSONL ⊇ SQLite** invariant: a row is staged on the session
31
+ and flushed **and fsynced** to its thread file *before* the COMMIT it belongs to, so
32
+ the projection can never hold a row the truth lacks — the truth append meets the
33
+ same durability bar as SQLite's own WAL commit (plain ``fsync``, the same call
34
+ SQLite issues; neither uses ``F_FULLFSYNC``). Each append batch is framed by a
35
+ drain-intent undo journal, so a power loss mid-batch is rolled back to the
36
+ pre-batch baselines on the next write (see the drain-intent section).
37
+ :func:`reindex` is the recovery
38
+ primitive — it rebuilds the SQLite store from the JSONL directory as a
39
+ **build-and-swap**: the new index is built in a temp file and atomically renamed
40
+ over ``index.db``, so a killed reindex leaves the old index fully intact; a
41
+ corrupted or deleted index is never a data-loss event.
42
+ :func:`rebuild_truth_from_store` is the inverse: it re-emits the whole per-thread
43
+ truth from the current store (used once to migrate an older monolithic
44
+ ``events.jsonl`` into per-thread files).
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import fcntl
50
+ import json
51
+ import logging
52
+ import os
53
+ import shutil
54
+ import sqlite3
55
+ import uuid
56
+ from collections import OrderedDict
57
+ from collections.abc import Generator
58
+ from contextlib import contextmanager
59
+ from datetime import datetime, timezone
60
+ from pathlib import Path
61
+ from typing import TextIO
62
+
63
+ from sqlalchemy import DateTime, event, insert, select
64
+ from sqlalchemy.orm import Session
65
+
66
+ from .._store import (
67
+ Event,
68
+ ImportState,
69
+ KgEvent,
70
+ Thread,
71
+ ThreadLink,
72
+ TopicMessage,
73
+ build_engine,
74
+ get_engine,
75
+ get_session,
76
+ init_db,
77
+ use_engine,
78
+ )
79
+
80
+ logger = logging.getLogger(__name__)
81
+
82
+ # Per-thread files live under truth/threads/; the cross-thread overlays are
83
+ # snapshot files (full-rewritten at checkpoint) since they aren't one thread's
84
+ # content. thread_links / topic_messages FK threads.id, so they load after threads.
85
+ THREADS_SUBDIR = "threads"
86
+ _CROSS_THREAD: dict[str, type] = {"thread_links": ThreadLink, "topic_messages": TopicMessage}
87
+
88
+ # The curatorial event log (see KgEvent): an append-only file of every topic/link/
89
+ # evidence mutation. It is the *source of truth* for the knowledge layer — the
90
+ # thread_links / topic_messages projections are folded from it on reindex. Any
91
+ # legacy _CROSS_THREAD snapshot is treated as a reindex seed the replay reconciles
92
+ # on top (upsert + tombstone), so the two coexist during the snapshot→log transition.
93
+ KG_EVENTS_FILE = "kg_events.jsonl"
94
+
95
+ # Flat until a directory would exceed this many files, then shard by id buckets.
96
+ _FLAT_MAX = int(os.environ.get("THREAD_ARCHIVE_SHARD_FLAT_MAX", "16384"))
97
+ _BUCKET = 256 # children per shard level
98
+
99
+ # Cap on simultaneously-open per-thread append handles (LRU-evicted). A bulk import
100
+ # usually touches one thread at a time, so the working set is tiny; the cap only
101
+ # bounds a pathological fan-out.
102
+ _MAX_OPEN_HANDLES = 256
103
+ _handles: "OrderedDict[str, TextIO]" = OrderedDict()
104
+
105
+
106
+ # ── config ──────────────────────────────────────────────────────────────────
107
+ def log_dir() -> Path:
108
+ """The JSONL truth directory for this archive instance (``<home>/truth``)."""
109
+ from .._config import resolve_paths
110
+
111
+ return resolve_paths().truth_dir
112
+
113
+
114
+ def _now_iso() -> str:
115
+ return datetime.now(timezone.utc).isoformat()
116
+
117
+
118
+ def _fsync_dir(d: Path) -> None:
119
+ """fsync a directory so a just-created (or just-renamed-in) file's dirent is
120
+ durable. Without it, a power loss can drop a fully-fsynced new file from the
121
+ directory while the SQLite commit that depended on it survives — index ⊃ truth,
122
+ the one direction the invariant forbids."""
123
+ try:
124
+ fd = os.open(d, os.O_RDONLY)
125
+ except OSError: # pragma: no cover — directory vanished / unreadable
126
+ return
127
+ try:
128
+ os.fsync(fd)
129
+ except OSError: # pragma: no cover — fs doesn't support dir fsync
130
+ pass
131
+ finally:
132
+ os.close(fd)
133
+
134
+
135
+ # ── manifest (shard depth + last-checkpoint watermark) ───────────────────────
136
+
137
+ # The on-disk truth format version, recorded as ``version`` in manifest.json and
138
+ # specified in docs/format.md. Bump only for a change an existing reader would
139
+ # misinterpret (record shapes, file layout, sharding semantics) — additive
140
+ # optional fields don't count. Code that meets a *newer* version must refuse
141
+ # the archive rather than guess (see :func:`_read_manifest`).
142
+ TRUTH_FORMAT_VERSION = 1
143
+
144
+
145
+ class TruthFormatError(RuntimeError):
146
+ """The truth directory declares a format version newer than this code reads."""
147
+
148
+
149
+ def _manifest_path(d: Path) -> Path:
150
+ return d / "manifest.json"
151
+
152
+
153
+ def _infer_shard_depth(d: Path) -> int:
154
+ """Infer the shard depth from the directory shape — the fallback when the
155
+ manifest is unreadable. A sharded layout is unmistakable: ``threads/`` contains
156
+ two-hex-digit bucket directories (which may themselves contain buckets). Walking
157
+ the first bucket chain recovers the depth, so a corrupt or deleted manifest on a
158
+ sharded archive can never silently reset writers to the flat layout (which would
159
+ grow flat twins of sharded files and let stale metadata shadow fresh records on
160
+ reindex)."""
161
+ depth = 0
162
+ cur = d / THREADS_SUBDIR
163
+ while depth < 4: # bound: depths beyond 2 don't exist, but never loop unbounded
164
+ try:
165
+ bucket = next(
166
+ (
167
+ e for e in os.scandir(cur)
168
+ if e.is_dir() and len(e.name) == 2
169
+ and all(c in "0123456789abcdef" for c in e.name)
170
+ ),
171
+ None,
172
+ )
173
+ except OSError:
174
+ break
175
+ if bucket is None:
176
+ break
177
+ depth += 1
178
+ cur = Path(bucket.path)
179
+ return depth
180
+
181
+
182
+ def _read_manifest(d: Path) -> dict:
183
+ p = _manifest_path(d)
184
+ if p.exists():
185
+ try:
186
+ m = json.loads(p.read_text(encoding="utf-8"))
187
+ except (OSError, ValueError):
188
+ # Corrupt manifest: fall through to defaults, but NEVER default the
189
+ # shard depth on a sharded archive — infer it from the layout below.
190
+ logger.error("truth: manifest.json unreadable — inferring shard depth")
191
+ else:
192
+ declared = m.get("version", TRUTH_FORMAT_VERSION)
193
+ if isinstance(declared, int) and declared > TRUTH_FORMAT_VERSION:
194
+ # A newer layout may have changed sharding or record semantics;
195
+ # reading it with old assumptions risks writing flat twins or
196
+ # shadowing fresh records — refuse instead of guessing.
197
+ raise TruthFormatError(
198
+ f"truth directory {d} declares format version {declared}, but this "
199
+ f"code reads version {TRUTH_FORMAT_VERSION}. Upgrade thread-archive "
200
+ "to a release that supports it."
201
+ )
202
+ return m
203
+ depth = _infer_shard_depth(d)
204
+ if depth:
205
+ logger.warning(
206
+ "truth: no readable manifest; inferred shard_depth=%d from layout", depth
207
+ )
208
+ return {"version": TRUTH_FORMAT_VERSION, "shard_depth": depth, "last_checkpoint_at": None}
209
+
210
+
211
+ def _write_manifest(d: Path, m: dict) -> None:
212
+ d.mkdir(parents=True, exist_ok=True)
213
+ # Per-process tmp name: manifest writers (watcher maintenance, a manual
214
+ # checkpoint, a backup) aren't otherwise serialized, and two processes
215
+ # sharing one tmp path would interleave writes and publish a torn manifest.
216
+ tmp = _manifest_path(d).with_name(f"manifest.json.tmp.{os.getpid()}")
217
+ try:
218
+ with open(tmp, "w", encoding="utf-8") as fh:
219
+ fh.write(json.dumps(m, indent=2))
220
+ fh.flush()
221
+ os.fsync(fh.fileno()) # durable before the rename makes it visible
222
+ os.replace(tmp, _manifest_path(d))
223
+ finally:
224
+ tmp.unlink(missing_ok=True) # no-op after the rename; clears a failed write
225
+ _fsync_dir(d) # the rename itself must survive power loss
226
+
227
+
228
+ # Serializes manifest read-modify-write across processes. The manifest carries
229
+ # keys owned by different writers (shard_depth: checkpoint/rebalance; hashes
230
+ # baseline: verify), and concurrent whole-file replaces would lose whichever
231
+ # writer published first. Leaf lock: mutators run pure, nothing else is
232
+ # acquired while it is held, so it can nest inside any other lock safely.
233
+ MANIFEST_LOCK_FILE = ".manifest.lock"
234
+
235
+
236
+ def _manifest_lock_path() -> Path:
237
+ from .._config import resolve_paths
238
+
239
+ return resolve_paths().home / MANIFEST_LOCK_FILE
240
+
241
+
242
+ def update_manifest(d: Path, mutate) -> dict:
243
+ """Atomically read-modify-write the manifest: ``mutate(m)`` edits the dict in
244
+ place under an exclusive flock, so no concurrent writer's keys are lost to a
245
+ stale read. Returns the manifest as written."""
246
+ path = _manifest_lock_path()
247
+ path.parent.mkdir(parents=True, exist_ok=True)
248
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
249
+ try:
250
+ fcntl.flock(fd, fcntl.LOCK_EX)
251
+ m = _read_manifest(d)
252
+ mutate(m)
253
+ _write_manifest(d, m)
254
+ return m
255
+ finally:
256
+ os.close(fd) # closing the fd releases the flock
257
+
258
+
259
+ def _shard_depth(d: Path) -> int:
260
+ return int(_read_manifest(d).get("shard_depth", 0))
261
+
262
+
263
+ # ── per-thread file paths (sharded by recorded depth) ────────────────────────
264
+ def _thread_relpath(thread_id: int, depth: int) -> Path:
265
+ """Relative path of a thread's file at ``depth``: flat (depth 0), else nested
266
+ id-bucket dirs (``<id%256>/...``). Deterministic — readers and writers compute
267
+ the same location from the manifest's depth."""
268
+ parts: list[str] = []
269
+ x = int(thread_id)
270
+ for _ in range(depth):
271
+ parts.append(f"{x % _BUCKET:02x}")
272
+ x //= _BUCKET
273
+ return Path(THREADS_SUBDIR, *parts, f"{int(thread_id)}.jsonl")
274
+
275
+
276
+ def _thread_file(d: Path, thread_id: int, depth: int) -> Path:
277
+ return d / _thread_relpath(thread_id, depth)
278
+
279
+
280
+ def _max_dir_occupancy(n_threads: int, depth: int) -> int:
281
+ """Worst-case files in a single directory for ``n_threads`` spread over ``depth``
282
+ bucket levels (256 buckets/level, roughly uniform by id)."""
283
+ return -(-n_threads // (_BUCKET ** depth)) # ceil div
284
+
285
+
286
+ def _depth_for(n_threads: int) -> int:
287
+ depth = 0
288
+ while _max_dir_occupancy(n_threads, depth) > _FLAT_MAX:
289
+ depth += 1
290
+ return depth
291
+
292
+
293
+ # ── serialization ───────────────────────────────────────────────────────────
294
+ def _json_default(o: object) -> object:
295
+ if isinstance(o, datetime):
296
+ return o.isoformat()
297
+ if isinstance(o, (bytes, bytearray)):
298
+ return bytes(o).decode("utf-8", "replace")
299
+ return str(o)
300
+
301
+
302
+ def _row_dict(obj: object) -> dict:
303
+ """Every mapped column of an ORM row, by key — captures server-defaulted
304
+ columns (``recorded_at``) populated after flush, so the JSONL is faithful."""
305
+ return {c.key: getattr(obj, c.key) for c in obj.__table__.columns} # type: ignore[attr-defined]
306
+
307
+
308
+ def _datetime_cols(model: type) -> set[str]:
309
+ return {c.key for c in model.__table__.columns if isinstance(c.type, DateTime)} # type: ignore[attr-defined]
310
+
311
+
312
+ def _coerce(model: type, row: dict) -> dict:
313
+ """Reverse :func:`_json_default` for reload: ISO strings → datetime so the
314
+ SQLite DateTime columns round-trip as the live writer wrote them. Keys not
315
+ mapped on the model are dropped, so a truth log written under an older schema
316
+ (columns since removed) still reloads cleanly into the current projection."""
317
+ valid = {c.key for c in model.__table__.columns}
318
+ row = {k: v for k, v in row.items() if k in valid}
319
+ for key in _datetime_cols(model):
320
+ val = row.get(key)
321
+ if isinstance(val, str):
322
+ row[key] = datetime.fromisoformat(val)
323
+ return row
324
+
325
+
326
+ # ── append handles (LRU, one per open thread file) ───────────────────────────
327
+ def _repair_torn_tail(path: Path) -> None:
328
+ """Newline-terminate a file whose last append was torn (a crash mid-write).
329
+
330
+ Without this, the next append glues its record onto the torn fragment and the
331
+ combined line is unparseable — the torn fragment *consumes a valid event*, and
332
+ reindex skips both. Adding the newline first isolates the fragment as its own
333
+ (already unrecoverable) line so every later record stays intact. Same guard
334
+ :func:`_merge_file_into` applies at its boundary."""
335
+ try:
336
+ if path.stat().st_size == 0:
337
+ return
338
+ except FileNotFoundError:
339
+ return
340
+ with open(path, "rb+") as fh:
341
+ fh.seek(-1, os.SEEK_END)
342
+ if fh.read(1) != b"\n":
343
+ fh.write(b"\n")
344
+ fh.flush()
345
+ os.fsync(fh.fileno())
346
+ logger.warning("truth: repaired torn tail (newline-terminated) %s", path)
347
+
348
+
349
+ def _same_inode(fh: TextIO, path: Path) -> bool:
350
+ """True when the cached handle still writes the file at ``path``. A truth
351
+ re-emit (``rebuild_truth_from_store``) atomically replaces thread files, so a
352
+ handle cached across it points at the unlinked old inode — appends through it
353
+ vanish while their SQLite commits survive, the one direction the invariant
354
+ forbids. Two stats per append; the price of never writing to a dead file."""
355
+ try:
356
+ a = os.fstat(fh.fileno())
357
+ b = os.stat(path)
358
+ except (OSError, ValueError):
359
+ return False
360
+ return (a.st_dev, a.st_ino) == (b.st_dev, b.st_ino)
361
+
362
+
363
+ def _handle(path: Path) -> TextIO:
364
+ key = str(path)
365
+ fh = _handles.get(key)
366
+ if fh is not None and not fh.closed and _same_inode(fh, path):
367
+ _handles.move_to_end(key)
368
+ return fh
369
+ if fh is not None and not fh.closed:
370
+ try:
371
+ fh.close() # stale inode — the file was replaced under us
372
+ except (OSError, ValueError): # pragma: no cover
373
+ pass
374
+ path.parent.mkdir(parents=True, exist_ok=True)
375
+ _repair_torn_tail(path) # never append onto a torn fragment
376
+ is_new = not path.exists()
377
+ fh = open(path, "a", encoding="utf-8") # noqa: SIM115 — long-lived, closed in reset()
378
+ if is_new:
379
+ _fsync_dir(path.parent) # the new file's dirent must be as durable as its rows
380
+ _handles[key] = fh
381
+ _handles.move_to_end(key)
382
+ while len(_handles) > _MAX_OPEN_HANDLES:
383
+ _, old = _handles.popitem(last=False)
384
+ try:
385
+ old.close()
386
+ except (OSError, ValueError): # pragma: no cover
387
+ pass
388
+ return fh
389
+
390
+
391
+ def _append_line(path: Path, rec: dict) -> None:
392
+ fh = _handle(path)
393
+ fh.write(json.dumps(rec, default=_json_default, ensure_ascii=False))
394
+ fh.write("\n")
395
+ fh.flush()
396
+
397
+
398
+ def _fsync_handle(path: Path) -> None:
399
+ """fsync a cached append handle so its flushed lines are durable on disk.
400
+
401
+ Callers batch: write every line of a logical unit via :func:`_append_line`
402
+ (flush-only), then fsync each touched file once — one fsync per file per
403
+ commit, not per line, keeps bulk imports fast while closing the power-loss
404
+ window between the OS page cache and the platter.
405
+
406
+ A batch touching more than ``_MAX_OPEN_HANDLES`` files LRU-evicts its early
407
+ handles before this runs; eviction close() flushes to the OS but does not
408
+ fsync, so an evicted file is reopened here and fsynced by fd — the durability
409
+ bar must not quietly drop for bulk batches. An OSError propagates (fail fast,
410
+ same as an fsync failure on a live handle)."""
411
+ fh = _handles.get(str(path))
412
+ if fh is not None and not fh.closed:
413
+ os.fsync(fh.fileno())
414
+ return
415
+ fd = os.open(path, os.O_RDONLY)
416
+ try:
417
+ os.fsync(fd)
418
+ finally:
419
+ os.close(fd)
420
+
421
+
422
+ def reset_handles() -> None:
423
+ """Close cached append handles (tests / shutdown)."""
424
+ for fh in _handles.values():
425
+ try:
426
+ fh.close()
427
+ except (OSError, ValueError): # pragma: no cover — already closed / broken handle
428
+ pass
429
+ _handles.clear()
430
+
431
+
432
+ # ── drain intent (crash framing for append batches) ──────────────────────────
433
+ # The all-or-nothing drain rollback handles *process* failure, but a power loss
434
+ # mid-batch leaves a partial batch in the truth with nothing to distinguish it
435
+ # from committed records. The intent file frames each batch as an undo journal:
436
+ # the drain durably records ``{txn, files: [{path, baseline, ids}]}`` BEFORE its
437
+ # first data write and empties the file after its last data fsync — both inside
438
+ # the truth-write mutex, so a non-empty intent is only ever observable after a
439
+ # crash. Recovery runs on every acquisition of the mutex (and at reindex start):
440
+ # it rolls the framed files back to their baselines, guarded two ways —
441
+ # * the committed-ids check: any of the batch's freshly-inserted ids present in
442
+ # the live index means the COMMIT landed (it was atomic), so a crash *after*
443
+ # COMMIT can never truncate committed truth (index ⊃ truth is the forbidden
444
+ # direction);
445
+ # * the tail check: everything past a file's baseline must be the framed
446
+ # batch's own records (a torn final fragment — the crash itself — is
447
+ # allowed). Anything else means another writer appended after the crash, and
448
+ # the file is left in place rather than risk cutting foreign records.
449
+ DRAIN_INTENT_FILE = ".drain.intent"
450
+
451
+ # Intent record kinds whose ids are fresh inserts in an index table — the basis
452
+ # of the committed-ids check. ``thread`` is deliberately absent: a metadata
453
+ # re-stage carries a thread id that pre-exists in the index whether or not the
454
+ # batch committed, so it can't witness the commit.
455
+ _INTENT_TABLES = {"event": "events", "kg_event": "kg_events"}
456
+
457
+
458
+ def _intent_path() -> Path:
459
+ from .._config import resolve_paths
460
+
461
+ return resolve_paths().home / DRAIN_INTENT_FILE
462
+
463
+
464
+ def _write_intent(files: list[dict]) -> None:
465
+ """Durably frame the batch about to be appended: fsynced BEFORE the first
466
+ data write, so a crash mid-batch always leaves the frame recovery needs to
467
+ identify — and remove — the partial batch."""
468
+ p = _intent_path()
469
+ p.parent.mkdir(parents=True, exist_ok=True)
470
+ existed = p.exists()
471
+ with open(p, "w", encoding="utf-8") as fh:
472
+ json.dump(
473
+ {"txn": uuid.uuid4().hex, "at": _now_iso(), "files": files},
474
+ fh, default=_json_default,
475
+ )
476
+ fh.flush()
477
+ os.fsync(fh.fileno())
478
+ if not existed:
479
+ _fsync_dir(p.parent) # the frame's dirent must be as durable as the frame
480
+
481
+
482
+ def _clear_intent() -> None:
483
+ """Empty the intent after the batch's last data fsync, still under the write
484
+ mutex — an intent must never be visible outside its batch's lock hold. Not
485
+ fsynced: if a crash resurrects a cleared-but-complete intent, the
486
+ committed-ids check in recovery resolves it correctly."""
487
+ try:
488
+ with open(_intent_path(), "w", encoding="utf-8"):
489
+ pass
490
+ except OSError: # pragma: no cover — best-effort; recovery re-resolves it
491
+ logger.exception("truth: could not clear drain intent")
492
+
493
+
494
+ def _intent_committed(files: list[dict]) -> bool | None:
495
+ """Whether the framed batch's COMMIT landed. Any of its freshly-inserted ids
496
+ present in the live index means yes (the COMMIT was atomic — all or none).
497
+ ``None`` = cannot tell (no insert ids in the batch, or no readable index),
498
+ which callers treat as committed: keeping possibly-uncommitted records is the
499
+ safe direction (dedup collapse bounds the damage), truncating possibly-
500
+ committed ones is the forbidden one."""
501
+ by_table: dict[str, list[int]] = {}
502
+ for f in files:
503
+ for kind, rid in f.get("ids") or []:
504
+ table = _INTENT_TABLES.get(kind)
505
+ if table is not None and rid is not None:
506
+ by_table.setdefault(table, []).append(int(rid))
507
+ if not by_table:
508
+ return None
509
+ db = get_engine().url.database
510
+ if not db or db == ":memory:":
511
+ return None
512
+ try:
513
+ conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
514
+ except sqlite3.Error:
515
+ return None
516
+ try:
517
+ for table, ids in by_table.items():
518
+ probe = ids[:32] # one committed id proves the whole batch
519
+ try:
520
+ row = conn.execute(
521
+ f"SELECT 1 FROM {table} WHERE id IN ({','.join('?' * len(probe))}) LIMIT 1", # noqa: S608 — table from _INTENT_TABLES
522
+ probe,
523
+ ).fetchone()
524
+ except sqlite3.Error:
525
+ return None
526
+ if row is not None:
527
+ return True
528
+ return False
529
+ finally:
530
+ conn.close()
531
+
532
+
533
+ def _tail_is_intent_only(path: Path, baseline: int, allowed: set) -> bool:
534
+ """True when everything past ``baseline`` is the framed batch's own records.
535
+ An unparseable FINAL fragment — the torn write of the crash itself — is
536
+ allowed; any other content means a writer appended after the crash and a
537
+ truncate would destroy records that aren't the batch's."""
538
+ try:
539
+ with open(path, "rb") as fh:
540
+ fh.seek(baseline)
541
+ chunks = fh.read().split(b"\n")
542
+ except OSError:
543
+ return False
544
+ for i, raw in enumerate(chunks):
545
+ if not raw.strip():
546
+ continue
547
+ try:
548
+ rec = json.loads(raw)
549
+ except ValueError:
550
+ return i == len(chunks) - 1 # torn final fragment only
551
+ if (rec.get("type", "event"), rec.get("id")) not in allowed:
552
+ return False
553
+ return True
554
+
555
+
556
+ def _recover_crashed_drain() -> None:
557
+ """Resolve a leftover drain intent — the mark of a process that died (power
558
+ loss, SIGKILL) mid-batch. Runs under the truth-write mutex on every
559
+ acquisition: a live drain's intent never outlives its lock hold, so anything
560
+ found here is a dead process's. Uncommitted framed files are rolled back to
561
+ their baselines (the crash-durable twin of the drain's in-process rollback);
562
+ a committed or undecidable batch keeps its records. Never raises — recovery
563
+ is a repair pass, and a bug in it must not take writes down."""
564
+ p = _intent_path()
565
+ try:
566
+ raw = p.read_text(encoding="utf-8")
567
+ except OSError:
568
+ return
569
+ if not raw.strip():
570
+ return
571
+ try:
572
+ intent = json.loads(raw)
573
+ files = list(intent["files"])
574
+ except (ValueError, KeyError, TypeError):
575
+ # A torn intent write: the crash hit before any data append (the intent
576
+ # is durable before the first write), so the truth is untouched.
577
+ logger.warning("truth: discarding torn drain intent (no data was written)")
578
+ _clear_intent()
579
+ return
580
+ try:
581
+ committed = _intent_committed(files)
582
+ if committed is not False:
583
+ logger.warning(
584
+ "truth: drain intent txn=%s survived a crash but its commit %s — keeping its records",
585
+ intent.get("txn"), "landed" if committed else "cannot be determined",
586
+ )
587
+ _clear_intent()
588
+ return
589
+ d = log_dir()
590
+ rolled_back = kept = 0
591
+ for f in files:
592
+ path = d / f["path"]
593
+ baseline = f.get("baseline")
594
+ allowed = {(k, i) for k, i in f.get("ids") or []}
595
+ try:
596
+ size = path.stat().st_size
597
+ except OSError:
598
+ continue # never created (crash before its first append) or already gone
599
+ if baseline is not None and size <= baseline:
600
+ continue # nothing of the batch landed here
601
+ if not _tail_is_intent_only(path, baseline or 0, allowed):
602
+ kept += 1
603
+ logger.error(
604
+ "truth: crashed drain tail of %s holds records outside its intent — left in place",
605
+ path,
606
+ )
607
+ continue
608
+ fh = _handles.pop(str(path), None)
609
+ if fh is not None and not fh.closed:
610
+ fh.close()
611
+ if baseline is None:
612
+ path.unlink(missing_ok=True) # the batch created it — no ghost thread file
613
+ _fsync_dir(path.parent)
614
+ else:
615
+ with open(path, "rb+") as rb:
616
+ rb.truncate(baseline)
617
+ os.fsync(rb.fileno())
618
+ rolled_back += 1
619
+ logger.warning(
620
+ "truth: recovered crashed drain txn=%s — %d file(s) rolled back to baseline%s",
621
+ intent.get("txn"), rolled_back,
622
+ f", {kept} left for inspection" if kept else "",
623
+ )
624
+ except Exception: # pragma: no cover — recovery must never block writes
625
+ logger.exception("truth: crashed-drain recovery failed; intent cleared")
626
+ _clear_intent()
627
+
628
+
629
+ # ── truth-write mutex (append batches are mutually exclusive across processes) ─
630
+ # The reindex lock is *shared* among writers, so two processes can append to the
631
+ # same truth file concurrently — a JSON line larger than one write(2) can interleave
632
+ # with another writer's line (corrupting both), and the drain-failure rollback
633
+ # truncates to a baseline that would chop records another process appended in
634
+ # between. This exclusive flock makes each append batch (a drain, a checkpoint
635
+ # backstop pass) atomic with respect to other writers. Held for milliseconds;
636
+ # writers are few. flock auto-releases on process death.
637
+ TRUTH_WRITE_LOCK_FILE = ".truthwrite.lock"
638
+
639
+
640
+ def _truth_write_lock_path() -> Path:
641
+ from .._config import resolve_paths
642
+
643
+ return resolve_paths().home / TRUTH_WRITE_LOCK_FILE
644
+
645
+
646
+ @contextmanager
647
+ def _truth_write_lock() -> Generator[None, None, None]:
648
+ path = _truth_write_lock_path()
649
+ path.parent.mkdir(parents=True, exist_ok=True)
650
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
651
+ try:
652
+ fcntl.flock(fd, fcntl.LOCK_EX)
653
+ # A leftover intent is a dead process's crashed batch — resolve it before
654
+ # this holder appends (or moves files) on top of the partial tail.
655
+ _recover_crashed_drain()
656
+ yield
657
+ finally:
658
+ os.close(fd) # closing the fd releases the flock
659
+
660
+
661
+ # ── staged writes (durable per-thread append BEFORE the SQLite commit) ───────
662
+ # "JSONL is truth" requires the invariant **JSONL ⊇ SQLite**: anything the
663
+ # projection has must already be in the truth, never the reverse. A write doesn't
664
+ # touch the file directly — it stages (kind, thread_id, row) on the session, and a
665
+ # global ``before_commit`` listener flushes each staged record to its thread file
666
+ # *before* the COMMIT. A raise there aborts the commit (fail fast — never commit a
667
+ # projection we couldn't make durable as truth). If the COMMIT then fails after the
668
+ # flush, the truth holds a row SQLite lacks — the safe direction: reindex rebuilds
669
+ # it, and dedup_key collapses any re-import. ``after_rollback`` discards the buffer.
670
+ _PENDING = "_jsonl_pending"
671
+
672
+
673
+ def _stage(session: Session, kind: str, thread_id: int, row: dict) -> None:
674
+ session.info.setdefault(_PENDING, []).append((kind, int(thread_id), row))
675
+
676
+
677
+ def append_event_row(session: Session, event_row: object) -> None:
678
+ """Stage an event for its thread's truth file. Written only if the session commits."""
679
+ _stage(session, "event", event_row.thread_id, _row_dict(event_row)) # type: ignore[attr-defined]
680
+
681
+
682
+ def record_thread(session: Session, thread: object) -> None:
683
+ """Stage a thread's metadata record for its truth file (written on commit).
684
+
685
+ The metadata-write seam: called at thread creation so each thread file opens
686
+ with its ``{"type": "thread", ...}`` record. A later metadata change can re-stage
687
+ (latest wins on reindex); :func:`checkpoint` is the backstop for updates."""
688
+ _stage(session, "thread", thread.id, _row_dict(thread)) # type: ignore[attr-defined]
689
+
690
+
691
+ def unstage_thread(session: Session, thread_id: int) -> None:
692
+ """Discard any staged truth rows for ``thread_id`` (its metadata record and any
693
+ events) from the session's pending buffer — the complement of the staging seam
694
+ for the discard-an-empty-thread path. A thread row deleted before its commit
695
+ must ALSO drop its staged record: otherwise the drain still writes a
696
+ ``threads/<id>.jsonl`` the projection no longer holds, ``verify`` drifts, and
697
+ the next reindex resurrects the thread as an empty ghost. Cross-thread rows
698
+ (kind ``kg_event``) are untouched."""
699
+ pending = session.info.get(_PENDING)
700
+ if not pending:
701
+ return
702
+ tid = int(thread_id)
703
+ session.info[_PENDING] = [
704
+ (kind, t, row) for kind, t, row in pending
705
+ if not (t == tid and kind in ("thread", "event"))
706
+ ]
707
+
708
+
709
+ def append_kg_event(session: Session, kg_event: object) -> None:
710
+ """Stage a curatorial event for the append-only ``kg_events.jsonl`` truth log.
711
+
712
+ The knowledge-layer write seam (mirrors :func:`append_event_row` for the
713
+ conversation log): a librarian write flushes the ``KgEvent`` (so ``id`` /
714
+ ``recorded_at`` are populated), stages it here, and the row is appended to the
715
+ single ``kg_events.jsonl`` file before the COMMIT it belongs to — keeping the
716
+ JSONL ⊇ SQLite invariant for curation. ``thread_id`` is irrelevant for the
717
+ cross-thread log (it routes to one file, not a per-thread file), so pass 0."""
718
+ _stage(session, "kg_event", 0, _row_dict(kg_event))
719
+
720
+
721
+ def write_events(session: Session, events: list[Event]) -> list[Event]:
722
+ """Insert events into SQLite *and* stage them for their threads' truth files.
723
+
724
+ The single seam so the projection and the truth never diverge: each row is
725
+ flushed to its thread file before the COMMIT it belongs to. ``flush`` populates
726
+ the autoincrement id and the server-default ``recorded_at`` before the row dict
727
+ is snapshotted, so the truth line is faithful. The caller owns the commit."""
728
+ session.add_all(events)
729
+ session.flush()
730
+ for ev in events:
731
+ append_event_row(session, ev)
732
+ return events
733
+
734
+
735
+ @event.listens_for(Session, "before_commit")
736
+ def _drain_before_commit(session: Session) -> None:
737
+ # Flush staged truth rows to their thread files *before* the COMMIT, so a row can
738
+ # never land in SQLite without already being in the truth (a raise here aborts
739
+ # the commit). pop() so a second commit on the same session doesn't re-append.
740
+ # Each touched file is fsynced once after its lines are written: the COMMIT that
741
+ # follows is itself fsynced by SQLite, so without this the projection could
742
+ # survive a power loss that the truth doesn't — the one direction the invariant
743
+ # forbids.
744
+ #
745
+ # The drain is all-or-nothing per commit: each file's pre-drain size is recorded
746
+ # and a failure partway (write error, unserializable row, failed fsync) truncates
747
+ # every touched file back before re-raising. Without the rollback, records 1…N−1
748
+ # of a failed batch would stay in the truth while SQLite rolls back — a later
749
+ # reindex would resurrect the partial batch, and a retried import would re-append
750
+ # the same content under fresh ids (the dedup check reads the rolled-back SQLite),
751
+ # doubling events. The same guarantee against a *power loss* mid-drain comes from
752
+ # the intent frame (see the drain-intent section): the batch's files, baselines
753
+ # and ids are durable before its first data write, so recovery can roll a partial
754
+ # batch back to the same baselines the in-process rollback uses.
755
+ pending = session.info.pop(_PENDING, None)
756
+ if not pending:
757
+ return
758
+ d = log_dir()
759
+ with _truth_write_lock():
760
+ # Depth read under the lock, so a rebalance that bumped it between our
761
+ # staging and this drain can't leave us appending at a stale layout.
762
+ depth = _shard_depth(d)
763
+ staged: list[tuple[Path, dict]] = []
764
+ for kind, thread_id, row in pending:
765
+ path = d / KG_EVENTS_FILE if kind == "kg_event" else _thread_file(d, thread_id, depth)
766
+ staged.append((path, {"type": kind, **row}))
767
+ # Baselines (and open handles) for every touched file BEFORE any append,
768
+ # so the intent frame describes the whole batch up front.
769
+ baselines: dict[Path, int | None] = {} # pre-drain size; None = file didn't exist
770
+ ids_by_path: dict[Path, list] = {}
771
+ for path, rec in staged:
772
+ if path not in baselines:
773
+ existed = path.exists()
774
+ fh = _handle(path) # may create the file, and may newline-repair a torn tail
775
+ fh.flush()
776
+ # Post-repair size, so a rollback keeps the repair in place.
777
+ baselines[path] = path.stat().st_size if existed else None
778
+ ids_by_path.setdefault(path, []).append([rec["type"], rec.get("id")])
779
+ _write_intent([
780
+ {"path": str(path.relative_to(d)), "baseline": size, "ids": ids_by_path[path]}
781
+ for path, size in baselines.items()
782
+ ]) # durable BEFORE the first data write
783
+ try:
784
+ for path, rec in staged:
785
+ _append_line(path, rec)
786
+ for path in baselines:
787
+ _fsync_handle(path)
788
+ except BaseException:
789
+ # Truncate-to-baseline is safe under the write lock: no other writer
790
+ # can have appended to these files since the baseline was taken.
791
+ # Each undo is fsynced: the intent clear below is not, so an
792
+ # un-durable truncate surviving a power loss alongside a durably
793
+ # cleared intent would resurrect the partial batch with no frame
794
+ # left to roll it back.
795
+ for path, size in baselines.items():
796
+ try:
797
+ fh = _handles.pop(str(path), None)
798
+ if fh is not None and not fh.closed:
799
+ fh.close() # drop any buffered partial write with the handle
800
+ if size is None:
801
+ path.unlink(missing_ok=True) # we created it — no ghost thread file
802
+ _fsync_dir(path.parent)
803
+ else:
804
+ with open(path, "rb+") as rb:
805
+ rb.truncate(size)
806
+ os.fsync(rb.fileno())
807
+ except OSError: # pragma: no cover — rollback is best-effort
808
+ logger.exception("truth: could not roll back partial append to %s", path)
809
+ _clear_intent() # the batch is undone; the frame must not outlive it
810
+ raise
811
+ _clear_intent() # inside the lock: an intent is never visible outside its batch
812
+ # Remember what this drain appended (per file: baseline → post-drain size)
813
+ # so a COMMIT failure *after* the drain can compensate (see
814
+ # _undo_drain_on_rollback). Cleared on successful commit.
815
+ session.info[_DRAINED] = {
816
+ path: (size, path.stat().st_size) for path, size in baselines.items()
817
+ }
818
+
819
+
820
+ # A successful drain's footprint, kept on the session until its COMMIT lands:
821
+ # {path: (baseline_size_or_None, post_drain_size)}. The compensation seam for
822
+ # the one atomicity gap the drain leaves open — truth is written and fsynced
823
+ # before the SQLite COMMIT, so a COMMIT that then fails (disk full, a
824
+ # constraint enforced at the final flush, busy timeout) leaves the batch in the
825
+ # truth with no projection row. That is the *safe* direction (JSONL ⊇ SQLite),
826
+ # but it isn't atomic: a later reindex materializes the uncommitted batch.
827
+ _DRAINED = "_jsonl_drained"
828
+
829
+
830
+ @event.listens_for(Session, "after_commit")
831
+ def _forget_drain_on_commit(session: Session) -> None:
832
+ session.info.pop(_DRAINED, None)
833
+
834
+
835
+ @event.listens_for(Session, "after_rollback")
836
+ def _discard_on_rollback(session: Session) -> None:
837
+ session.info.pop(_PENDING, None)
838
+
839
+
840
+ @event.listens_for(Session, "after_transaction_end")
841
+ def _compensate_failed_commit(session: Session, transaction) -> None:
842
+ # The root transaction ending with the drain footprint still present means
843
+ # the COMMIT never landed (a successful one pops it in after_commit, which
844
+ # fires first). after_transaction_end — not after_rollback — is the hook
845
+ # that actually fires on every such path: a commit that raises mid-flight
846
+ # ends via the session context manager's close(), which never emits
847
+ # after_rollback.
848
+ if transaction.parent is None and _DRAINED in session.info:
849
+ _undo_drain(session)
850
+
851
+
852
+ def _undo_drain(session: Session) -> None:
853
+ """Best-effort compensation when a COMMIT failed after its drain succeeded:
854
+ truncate each touched truth file back to its pre-drain baseline, so the
855
+ truth doesn't keep a transaction SQLite rejected (which reindex would
856
+ otherwise resurrect, and which — for metadata records — carries no fresh
857
+ event id for recovery to reconcile against).
858
+
859
+ Only provably-safe undos run: under the exclusive truth-write lock, a file
860
+ is rolled back only if its current size still equals the drain's post-drain
861
+ size — any other writer's append since (sizes differ) leaves the file
862
+ alone, degrading to the documented JSONL ⊇ SQLite behavior. Failure here is
863
+ logged, never raised: the transaction is already over and the leftover
864
+ truth rows are the benign direction (dedup collapses a re-import)."""
865
+ drained: dict[Path, tuple[int | None, int]] | None = session.info.pop(_DRAINED, None)
866
+ if not drained:
867
+ return
868
+ try:
869
+ with _truth_write_lock():
870
+ for path, (baseline, post_size) in drained.items():
871
+ try:
872
+ if not path.exists() or path.stat().st_size != post_size:
873
+ continue # someone else appended (or repaired) — leave it
874
+ fh = _handles.pop(str(path), None)
875
+ if fh is not None and not fh.closed:
876
+ fh.close()
877
+ if baseline is None:
878
+ path.unlink(missing_ok=True) # the drain created it
879
+ _fsync_dir(path.parent)
880
+ else:
881
+ with open(path, "rb+") as rb:
882
+ rb.truncate(baseline)
883
+ os.fsync(rb.fileno())
884
+ except OSError: # pragma: no cover — compensation is best-effort
885
+ logger.exception(
886
+ "truth: could not undo drained batch in %s after rollback", path
887
+ )
888
+ except OSError: # pragma: no cover — lock unavailable; leave the safe direction
889
+ logger.exception("truth: post-rollback drain compensation skipped")
890
+
891
+
892
+ # ── checkpoint (cross-thread snapshots + metadata-update backstop) ───────────
893
+ def _write_snapshot(d: Path, name: str, model: type) -> int:
894
+ """Atomically rewrite ``<name>.jsonl`` from the live table (tmp + rename)."""
895
+ d.mkdir(parents=True, exist_ok=True)
896
+ path = d / f"{name}.jsonl"
897
+ tmp = path.with_suffix(".jsonl.tmp")
898
+ with get_session() as s, open(tmp, "w", encoding="utf-8") as fh:
899
+ n = 0
900
+ for obj in s.execute(select(model)).scalars():
901
+ fh.write(json.dumps(_row_dict(obj), default=_json_default, ensure_ascii=False))
902
+ fh.write("\n")
903
+ n += 1
904
+ fh.flush()
905
+ os.fsync(fh.fileno()) # durable before the rename makes it visible
906
+ os.replace(tmp, path)
907
+ _fsync_dir(d) # the rename itself must survive power loss
908
+ return n
909
+
910
+
911
+ def _checkpoint_changed_threads(d: Path, depth: int, last_iso: str | None) -> int:
912
+ """Backstop for thread *metadata updates*: append a fresh thread record for any
913
+ thread whose ``updated_at`` advanced since the last checkpoint. New threads are
914
+ already recorded at creation (:func:`record_thread`), so the first checkpoint
915
+ (no watermark) writes nothing — it just sets the watermark."""
916
+ if last_iso is None:
917
+ return 0
918
+ last_dt = datetime.fromisoformat(last_iso)
919
+ n = 0
920
+ touched: set[Path] = set()
921
+ with get_session() as s:
922
+ changed = s.execute(select(Thread).where(Thread.updated_at > last_dt)).scalars().all()
923
+ with _truth_write_lock(): # append batches are mutually exclusive across writers
924
+ for t in changed:
925
+ path = _thread_file(d, t.id, depth)
926
+ _append_line(path, {"type": "thread", **_row_dict(t)})
927
+ touched.add(path)
928
+ n += 1
929
+ for path in touched:
930
+ _fsync_handle(path)
931
+ return n
932
+
933
+
934
+ def checkpoint(*, snapshots: bool = True) -> dict:
935
+ """Persist the cross-thread overlays + any thread-metadata updates, and keep the
936
+ shard layout balanced. Per-thread event/metadata records are written inline on
937
+ commit; this is the cadence call (and pre-backup call) that makes the directory
938
+ a complete restore set.
939
+
940
+ ``snapshots=False`` runs **maintenance only** — the manifest watermark, the
941
+ thread-metadata-update backstop, the (small) ``import_state`` snapshot, and
942
+ shard rebalance — and skips the full
943
+ rewrite of the cross-thread overlay files (``thread_links`` / ``topic_messages``).
944
+ That rewrite is O(graph size) and the live-ingest path never changes those tables
945
+ (conversation import only appends events; the overlays are the migration seed plus
946
+ the append-only ``kg_events`` log), so the watcher uses the cheap maintenance form
947
+ on its cadence instead of rewriting tens of MB every few seconds. The full form
948
+ (overlays included) is for explicit pre-backup / pre-reindex / post-curation use."""
949
+ d = log_dir()
950
+ (d / THREADS_SUBDIR).mkdir(parents=True, exist_ok=True)
951
+ m = _read_manifest(d)
952
+ counts: dict = (
953
+ {name: _write_snapshot(d, name, model) for name, model in _CROSS_THREAD.items()}
954
+ if snapshots
955
+ else {}
956
+ )
957
+ # Source-import watermarks: operational state, snapshotted so a reindex of a
958
+ # lost/deleted index (where there is no previous index to carry them from)
959
+ # still restores cursors instead of adopting active sources at EOF. A stale
960
+ # snapshot is safe (import resumes from the older watermark and dedup_key
961
+ # collapses the overlap), but a *fresh* one keeps the regression window at
962
+ # minutes instead of a backup cycle — and the table is small, so unlike the
963
+ # cross-thread overlays it is snapshotted on the maintenance cadence too.
964
+ counts["import_state"] = _write_snapshot(d, "import_state", ImportState)
965
+ # Rebalance BEFORE the thread-metadata backstop, so the backstop appends at the
966
+ # post-rebalance depth and can never manufacture a flat twin of a just-moved file.
967
+ depth = _maybe_rebalance(d, int(m.get("shard_depth", 0)))
968
+ # Re-read the manifest: a concurrent sweep (ours skips when the rebalance lock is
969
+ # held) may have advanced shard_depth — never write a stale depth back over it.
970
+ m = _read_manifest(d)
971
+ depth = max(depth, int(m.get("shard_depth", 0)))
972
+ # The new watermark is captured BEFORE the changed-threads query: a thread
973
+ # updated between the query and a later stamp would fall below the stamp and
974
+ # be missed by every later backstop pass. Captured-first, such an update is
975
+ # re-scanned next pass; the worst case is a harmless re-appended thread record
976
+ # (latest-wins). Updates whose transaction is still uncommitted when the query
977
+ # runs are the primary seam's job — every in-tree metadata writer also stages
978
+ # its record inline (``record_thread``); this pass is only the backstop.
979
+ checkpoint_started_at = _now_iso()
980
+ counts["threads_updated"] = _checkpoint_changed_threads(d, depth, m.get("last_checkpoint_at"))
981
+ # Locked read-modify-write: the backstop pass takes time, and the manifest
982
+ # is shared state (shard depth from a concurrent sweep, a verify run's
983
+ # hashes baseline). Mutating only this checkpoint's own keys under the
984
+ # manifest lock means a foreign key written mid-pass survives.
985
+
986
+ def _stamp(m: dict) -> None:
987
+ m["shard_depth"] = max(depth, int(m.get("shard_depth", 0)))
988
+ m["last_checkpoint_at"] = checkpoint_started_at
989
+
990
+ update_manifest(d, _stamp)
991
+ logger.info("jsonl_log checkpoint(snapshots=%s): %s", snapshots, counts)
992
+ return counts
993
+
994
+
995
+ # ── adaptive rebalance (flat → sharded when a directory gets large) ──────────
996
+ # Serializes the move/merge sweep across processes (the watcher's maintenance pass
997
+ # vs. the daily backup's checkpoint): flock, non-blocking — the loser skips, and
998
+ # whichever checkpoint runs next finishes the job. Deliberately distinct from the
999
+ # reindex lock: the watcher holds THAT one shared around its whole ingest pass, so
1000
+ # an exclusive acquire on it here would self-deadlock.
1001
+ REBALANCE_LOCK_FILE = ".rebalance.lock"
1002
+
1003
+
1004
+ def _rebalance_lock_path() -> Path:
1005
+ from .._config import resolve_paths
1006
+
1007
+ return resolve_paths().home / REBALANCE_LOCK_FILE
1008
+
1009
+
1010
+ @contextmanager
1011
+ def _try_rebalance_lock() -> Generator[bool, None, None]:
1012
+ """Hold the rebalance lock exclusive, non-blocking. Yields False (not holding)
1013
+ when another process is mid-sweep. flock auto-releases on process death — a
1014
+ killed sweep can never wedge the next one."""
1015
+ path = _rebalance_lock_path()
1016
+ path.parent.mkdir(parents=True, exist_ok=True)
1017
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
1018
+ try:
1019
+ try:
1020
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
1021
+ except OSError:
1022
+ yield False
1023
+ return
1024
+ yield True
1025
+ finally:
1026
+ os.close(fd) # closing the fd releases the flock
1027
+
1028
+
1029
+ def _merge_file_into(src: Path, dest: Path) -> None:
1030
+ """Append ``src``'s lines onto ``dest`` (fsynced), then remove ``src`` — the
1031
+ no-clobber move for a file whose home already exists.
1032
+
1033
+ Safe at every boundary: if ``dest`` ends in a torn line (a crash mid-append),
1034
+ a newline is inserted first so the fragment stays its own unparseable line
1035
+ instead of gluing onto ``src``'s first record. Duplicate lines from a crash
1036
+ between copy and unlink are harmless — event lines are id-keyed (reindex's
1037
+ INSERT OR REPLACE collapses them) and thread records are latest-wins. The
1038
+ copy re-checks ``src``'s size before unlinking so lines a racing pre-bump
1039
+ commit appended mid-copy are drained, not dropped."""
1040
+ with open(dest, "ab") as df:
1041
+ if os.stat(dest).st_size > 0:
1042
+ with open(dest, "rb") as ck:
1043
+ ck.seek(-1, os.SEEK_END)
1044
+ if ck.read(1) != b"\n":
1045
+ df.write(b"\n")
1046
+ with open(src, "rb") as sf:
1047
+ while True:
1048
+ shutil.copyfileobj(sf, df)
1049
+ df.flush()
1050
+ if sf.tell() >= os.stat(src).st_size:
1051
+ break # nothing landed on src during the copy
1052
+ os.fsync(df.fileno())
1053
+ os.unlink(src)
1054
+
1055
+
1056
+ def _maybe_rebalance(d: Path, depth: int) -> int:
1057
+ """Keep the shard layout balanced — crash-safe, merge-only, serialized.
1058
+
1059
+ Three properties make a killed or concurrent sweep unable to lose truth:
1060
+
1061
+ * **Manifest first.** Crossing a threshold persists the new ``shard_depth``
1062
+ *before* any file moves. Writers re-read the manifest on every commit, so
1063
+ they immediately compute paths in the final layout — a crash mid-sweep
1064
+ leaves only not-yet-moved files, never a growing flat twin of an
1065
+ already-moved one.
1066
+ * **Merge, never clobber.** A file whose destination already exists (a twin
1067
+ left by a killed sweep, or by a commit that raced the manifest bump) is
1068
+ appended onto it via :func:`_merge_file_into` — recombined, not replaced.
1069
+ * **Straggler sweep.** Once sharded (``depth > 0``), every call re-homes any
1070
+ misplaced file, so an interrupted migration is finished by the next
1071
+ checkpoint rather than waiting on a threshold that will never re-fire.
1072
+
1073
+ The sweep skips (without moving anything) while a reindex build holds the
1074
+ ingest lock exclusive, and runs under its own non-blocking exclusive flock so
1075
+ two checkpointing processes can't interleave moves — the loser returns the
1076
+ depth it was given and its caller re-reads the manifest rather than writing a
1077
+ stale depth back. Returns the effective shard depth."""
1078
+ threads_dir = d / THREADS_SUBDIR
1079
+ if not threads_dir.exists():
1080
+ return depth
1081
+ n = sum(1 for _ in threads_dir.rglob("*.jsonl"))
1082
+ if max(depth, _depth_for(n)) == 0:
1083
+ return 0 # flat and staying flat — nothing can be misplaced
1084
+ with try_shared_ingest_lock() as ingest_ok:
1085
+ if not ingest_ok:
1086
+ return depth # a reindex is reading the truth — don't move it underneath
1087
+ with _try_rebalance_lock() as held:
1088
+ if not held:
1089
+ return depth # another sweep is running; it owns the manifest depth
1090
+ m = _read_manifest(d)
1091
+ depth = max(depth, int(m.get("shard_depth", 0))) # fresh under the lock
1092
+ target = max(depth, _depth_for(n))
1093
+ if target > depth:
1094
+ # Locked RMW, durable BEFORE any file moves (see docstring).
1095
+ update_manifest(d, lambda m: m.__setitem__(
1096
+ "shard_depth", max(target, int(m.get("shard_depth", 0)))
1097
+ ))
1098
+ # List under the lock — a sweep that completed between the count above
1099
+ # and our acquisition has already re-homed what we would move again.
1100
+ misplaced: list[tuple[Path, Path]] = []
1101
+ for path in threads_dir.rglob("*.jsonl"):
1102
+ try:
1103
+ tid = int(path.stem)
1104
+ except ValueError: # pragma: no cover — stray file
1105
+ continue
1106
+ dest = _thread_file(d, tid, target)
1107
+ if dest != path:
1108
+ misplaced.append((path, dest))
1109
+ if not misplaced:
1110
+ return target
1111
+ reset_handles() # our own cached appenders must not span the sweep
1112
+ # The truth-write mutex excludes every drain for the move loop, so no
1113
+ # append can land on a file between its merge-copy and its unlink.
1114
+ with _truth_write_lock():
1115
+ for path, dest in misplaced:
1116
+ dest.parent.mkdir(parents=True, exist_ok=True)
1117
+ if dest.exists():
1118
+ _merge_file_into(path, dest)
1119
+ else:
1120
+ os.replace(path, dest)
1121
+ logger.info(
1122
+ "jsonl_log rebalance: shard_depth %d → %d (%d files re-homed, %d threads)",
1123
+ depth, target, len(misplaced), n,
1124
+ )
1125
+ return target
1126
+
1127
+
1128
+ # ── integrity scan (the primitive behind `archive verify`) ───────────────────
1129
+ def scan_truth_counts(
1130
+ *, event_id_max: int | None = None, thread_id_max: int | None = None,
1131
+ kg_event_id_max: int | None = None, truth_dir: Path | None = None,
1132
+ ) -> dict:
1133
+ """Count threads + event lines across the truth directory — the per-thread
1134
+ files *and* the curatorial log (``kg_events.jsonl``) — tallying any JSON
1135
+ parse errors. The integrity primitive behind ``archive verify``: a clean
1136
+ archive has these match the SQLite projection's thread/event/kg-event counts
1137
+ (the JSONL ⊇ SQLite invariant) with zero parse errors.
1138
+
1139
+ The truth is append-only, so it legitimately accumulates superseded lines the
1140
+ projection collapses: a re-appended line for an id it already holds (a crash
1141
+ between a rebalance merge-copy and its unlink), and a same-content twin under
1142
+ a fresh id (a re-import after the original's commit was lost — same
1143
+ ``dedup_key``). ``events`` counts raw lines; ``events_effective`` counts what
1144
+ the projection materializes — distinct on id, then distinct on ``dedup_key``
1145
+ (falling back to id when NULL). The index is compared against
1146
+ ``events_effective``; the superseded remainder is reported, not drift.
1147
+
1148
+ A thread is counted once per distinct file *stem*, and the collapse runs
1149
+ across every file sharing that stem — so a thread present at two shard
1150
+ depths (a both-layouts backup mirror, a mid-migration crash) counts as one
1151
+ thread and its duplicated lines as superseded, matching what a reindex of
1152
+ that directory materializes.
1153
+
1154
+ ``kg_events`` counts the curatorial log's distinct ids (the file is
1155
+ append-only, so a crash-merge can legitimately duplicate a line; the
1156
+ projection materializes one row per id). Its unparseable lines fold into the
1157
+ same ``parse_errors`` tally and torn-tail/interior split as the per-thread
1158
+ files — the curation truth deserves the same daily scan the conversation
1159
+ truth gets, not a weekly one.
1160
+
1161
+ ``event_id_max`` / ``thread_id_max`` / ``kg_event_id_max`` bound the scan to
1162
+ ids at or below a stable watermark, so a verify racing live ingest (truth
1163
+ lines land *before* their commit; the index keeps growing while the scan
1164
+ reads files) compares the same committed prefix on both sides instead of
1165
+ false-alarming.
1166
+
1167
+ ``truth_dir`` scans an explicit directory instead of the live archive's —
1168
+ the primitive behind the backup-side check (``archive verify --backup``)."""
1169
+ d = truth_dir if truth_dir is not None else log_dir()
1170
+ threads_dir = d / THREADS_SUBDIR
1171
+ n_events = n_effective = parse_errors = 0
1172
+ dup_id_lines = dup_content_lines = 0
1173
+ parse_error_sample: list[str] = []
1174
+ parse_error_locs: list[tuple[str, int]] = []
1175
+ files_by_stem: dict[str, list[Path]] = {}
1176
+ if threads_dir.exists():
1177
+ for path in threads_dir.rglob("*.jsonl"):
1178
+ if thread_id_max is not None:
1179
+ try:
1180
+ if int(path.stem) > thread_id_max:
1181
+ continue
1182
+ except ValueError: # pragma: no cover — stray file
1183
+ pass
1184
+ files_by_stem.setdefault(path.stem, []).append(path)
1185
+ for paths in files_by_stem.values():
1186
+ seen_ids: set = set()
1187
+ seen_keys: set = set()
1188
+ for path in paths:
1189
+ with open(path, encoding="utf-8", errors="replace") as fh:
1190
+ for lineno, line in enumerate(fh, 1):
1191
+ line = line.strip()
1192
+ if not line:
1193
+ continue
1194
+ try:
1195
+ rec = json.loads(line)
1196
+ except ValueError:
1197
+ parse_errors += 1
1198
+ parse_error_locs.append((str(path), lineno))
1199
+ if len(parse_error_sample) < 10:
1200
+ parse_error_sample.append(f"{path}:{lineno}")
1201
+ continue
1202
+ if rec.get("type", "event") != "event":
1203
+ continue
1204
+ ev_id = rec.get("id")
1205
+ if event_id_max is not None and ev_id is not None and ev_id > event_id_max:
1206
+ continue
1207
+ n_events += 1
1208
+ if ev_id in seen_ids:
1209
+ dup_id_lines += 1
1210
+ continue
1211
+ seen_ids.add(ev_id)
1212
+ key = rec.get("dedup_key") or ("id", ev_id)
1213
+ if key in seen_keys:
1214
+ dup_content_lines += 1
1215
+ continue
1216
+ seen_keys.add(key)
1217
+ n_effective += 1
1218
+ # The curatorial log: distinct kg-event ids at or below the watermark, its
1219
+ # parse errors folded into the same tally (and torn/interior split) so a
1220
+ # damaged curation line fails the daily verify, not just the weekly deep one.
1221
+ kg_ids: set = set()
1222
+ kg_path = d / KG_EVENTS_FILE
1223
+ if kg_path.exists():
1224
+ with open(kg_path, encoding="utf-8", errors="replace") as fh:
1225
+ for lineno, line in enumerate(fh, 1):
1226
+ line = line.strip()
1227
+ if not line:
1228
+ continue
1229
+ try:
1230
+ rec = json.loads(line)
1231
+ except ValueError:
1232
+ parse_errors += 1
1233
+ parse_error_locs.append((str(kg_path), lineno))
1234
+ if len(parse_error_sample) < 10:
1235
+ parse_error_sample.append(f"{kg_path}:{lineno}")
1236
+ continue
1237
+ kg_id = rec.get("id")
1238
+ if kg_id is None or (kg_event_id_max is not None and kg_id > kg_event_id_max):
1239
+ continue
1240
+ kg_ids.add(int(kg_id))
1241
+ # Same split reindex reports: a torn tail (the file's final non-empty line —
1242
+ # the residue of a crash mid-append, waiting for `archive repair`) vs. interior
1243
+ # damage (a fragment later appends isolated, or corruption of a formerly-good
1244
+ # line — repair quarantines it and restores any committed event it shadowed).
1245
+ torn, interior = _classify_parse_errors(parse_error_locs)
1246
+ return {
1247
+ "threads": len(files_by_stem),
1248
+ "events": n_events,
1249
+ "events_effective": n_effective,
1250
+ "kg_events": len(kg_ids),
1251
+ "duplicate_id_lines": dup_id_lines,
1252
+ "duplicate_content_lines": dup_content_lines,
1253
+ "parse_errors": parse_errors,
1254
+ "parse_errors_torn_tail": len(torn),
1255
+ "parse_errors_interior": len(interior),
1256
+ "parse_error_sample": parse_error_sample,
1257
+ }
1258
+
1259
+
1260
+ # ── reindex (rebuild the SQLite projection from the JSONL truth) ─────────────
1261
+ def _final_nonempty_lineno(path: Path) -> int | None:
1262
+ """Line number of the file's last non-empty line (None when unreadable/empty).
1263
+ The classifier for parse errors: an unparseable FINAL line is the accepted
1264
+ crash artifact (a torn last append — the same fragment
1265
+ :func:`_repair_torn_tail` newline-isolates); an unparseable line anywhere
1266
+ else is interior corruption."""
1267
+ last = None
1268
+ try:
1269
+ with open(path, encoding="utf-8", errors="replace") as fh:
1270
+ for lineno, line in enumerate(fh, 1):
1271
+ if line.strip():
1272
+ last = lineno
1273
+ except OSError:
1274
+ return None
1275
+ return last
1276
+
1277
+
1278
+ def _classify_parse_errors(
1279
+ errors: list[tuple[str, int]],
1280
+ ) -> tuple[list[tuple[str, int]], list[tuple[str, int]]]:
1281
+ """Split recorded parse errors into ``(torn_tails, interior)``. A torn tail —
1282
+ the file's final non-empty line — is the expected residue of a crash
1283
+ mid-append and never blocks recovery; anything interior means the truth was
1284
+ corrupted some other way and must be looked at, not silently dropped."""
1285
+ torn: list[tuple[str, int]] = []
1286
+ interior: list[tuple[str, int]] = []
1287
+ last_by_path: dict[str, int | None] = {}
1288
+ for path, lineno in errors:
1289
+ if path not in last_by_path:
1290
+ last_by_path[path] = _final_nonempty_lineno(Path(path))
1291
+ (torn if lineno == last_by_path[path] else interior).append((path, lineno))
1292
+ return torn, interior
1293
+
1294
+
1295
+ def _iter_jsonl(path: Path, *, errors: list[tuple[str, int]] | None = None):
1296
+ """Yield parsed records from a JSONL file, skipping unparseable lines.
1297
+
1298
+ A torn line (a crash mid-append) must not kill :func:`reindex` — the recovery
1299
+ primitive has to recover everything parseable, with the same tolerance
1300
+ ``archive verify`` (:func:`scan_truth_counts`) already has. Every skipped line
1301
+ is logged, and recorded on ``errors`` as ``(path, lineno)`` when given, so
1302
+ reindex can report the count instead of silently dropping."""
1303
+ if not path.exists():
1304
+ return
1305
+ with open(path, encoding="utf-8", errors="replace") as fh:
1306
+ for lineno, line in enumerate(fh, 1):
1307
+ line = line.strip()
1308
+ if not line:
1309
+ continue
1310
+ try:
1311
+ yield json.loads(line)
1312
+ except ValueError:
1313
+ logger.warning("truth: skipping unparseable line %s:%d", path, lineno)
1314
+ if errors is not None:
1315
+ errors.append((str(path), lineno))
1316
+
1317
+
1318
+ def _load_table(
1319
+ model: type, path: Path, engine, batch: int = 5000,
1320
+ *, errors: list[tuple[str, int]] | None = None,
1321
+ ) -> int:
1322
+ """Bulk-load a snapshot file (one row per line) into its table."""
1323
+ table = model.__table__ # type: ignore[attr-defined]
1324
+ total = 0
1325
+ buf: list[dict] = []
1326
+
1327
+ def _flush() -> None:
1328
+ nonlocal total
1329
+ if not buf:
1330
+ return
1331
+ with engine.begin() as conn:
1332
+ conn.execute(insert(table).prefix_with("OR REPLACE"), buf)
1333
+ total += len(buf)
1334
+ buf.clear()
1335
+
1336
+ for row in _iter_jsonl(path, errors=errors):
1337
+ buf.append(_coerce(model, row))
1338
+ if len(buf) >= batch:
1339
+ _flush()
1340
+ _flush()
1341
+ return total
1342
+
1343
+
1344
+ def thread_file_load_order(d: Path) -> list[Path]:
1345
+ """Every ``threads/**/*.jsonl`` in the order reindex loads them: path-sorted,
1346
+ then any file at its thread's *canonical* (manifest-depth) location moved
1347
+ last. Loads are last-wins (OR REPLACE by id; latest thread record), so when a
1348
+ thread has files at more than one shard depth — a both-layouts backup mirror,
1349
+ a mid-migration crash — the canonical file's records must win: it is the one
1350
+ live writers append to, so it is at least as fresh as any stale twin. Plain
1351
+ path order would let a stale flat twin load *after* its sharded home
1352
+ (``threads/00/…`` sorts before ``threads/12345.jsonl``) and shadow it."""
1353
+ threads_dir = d / THREADS_SUBDIR
1354
+ if not threads_dir.exists():
1355
+ return []
1356
+ depth = _shard_depth(d)
1357
+
1358
+ def _canonical(path: Path) -> bool:
1359
+ try:
1360
+ return path == _thread_file(d, int(path.stem), depth)
1361
+ except ValueError:
1362
+ return False
1363
+
1364
+ paths = sorted(threads_dir.rglob("*.jsonl"))
1365
+ paths.sort(key=_canonical) # stable: non-canonical first, canonical last
1366
+ return paths
1367
+
1368
+
1369
+ def _load_thread_files(
1370
+ d: Path, engine, batch: int = 5000,
1371
+ *, errors: list[tuple[str, int]] | None = None,
1372
+ ) -> tuple[int, int]:
1373
+ """Load every ``threads/**/<id>.jsonl`` into the threads + events tables.
1374
+
1375
+ Per file: the last ``type:thread`` record is the metadata (latest wins), every
1376
+ ``type:event`` record is an event. Files load in :func:`thread_file_load_order`
1377
+ (canonical-depth file last), so a thread with a stale twin at another shard
1378
+ depth materializes the canonical file's records. A file with events but no
1379
+ thread record (a crash between an import commit and its checkpoint) gets a
1380
+ synthesized minimal thread so its events aren't dropped. FK enforcement is off
1381
+ on the loader, so the interleaved thread/event inserts need no ordering."""
1382
+ thread_buf: list[dict] = []
1383
+ event_buf: list[dict] = []
1384
+ nt = ne = 0
1385
+
1386
+ def _flush() -> None:
1387
+ nonlocal nt, ne
1388
+ if thread_buf:
1389
+ with engine.begin() as conn:
1390
+ conn.execute(insert(Thread.__table__).prefix_with("OR REPLACE"), thread_buf)
1391
+ nt += len(thread_buf)
1392
+ thread_buf.clear()
1393
+ if event_buf:
1394
+ with engine.begin() as conn:
1395
+ conn.execute(insert(Event.__table__).prefix_with("OR REPLACE"), event_buf)
1396
+ ne += len(event_buf)
1397
+ event_buf.clear()
1398
+
1399
+ have_meta: set[int] = set() # tids with a real thread record already loaded
1400
+ for path in thread_file_load_order(d):
1401
+ last_thread: dict | None = None
1402
+ events: list[dict] = []
1403
+ for rec in _iter_jsonl(path, errors=errors):
1404
+ kind = rec.pop("type", "event")
1405
+ if kind == "thread":
1406
+ last_thread = rec
1407
+ else:
1408
+ events.append(rec)
1409
+ if last_thread is None:
1410
+ try:
1411
+ tid = int(path.stem)
1412
+ except ValueError: # pragma: no cover
1413
+ continue
1414
+ if tid in have_meta:
1415
+ # A twin of this thread already supplied its real record; a
1416
+ # synthesized stub loading after it would clobber real metadata.
1417
+ last_thread = None
1418
+ else:
1419
+ last_thread = {"id": tid, "name": f"thread:{tid}"}
1420
+ logger.warning("reindex: %s had no thread record — synthesized minimal", path.name)
1421
+ else:
1422
+ try:
1423
+ have_meta.add(int(last_thread.get("id")))
1424
+ except (TypeError, ValueError): # pragma: no cover — malformed record
1425
+ pass
1426
+ if last_thread is not None:
1427
+ thread_buf.append(_coerce(Thread, last_thread))
1428
+ for ev in events:
1429
+ event_buf.append(_coerce(Event, ev))
1430
+ if len(thread_buf) >= batch or len(event_buf) >= batch:
1431
+ _flush()
1432
+ _flush()
1433
+ return nt, ne
1434
+
1435
+
1436
+ def _carry_import_state(index_path: Path, engine) -> int:
1437
+ """Carry the source-import watermarks from the previous index into the rebuild.
1438
+
1439
+ ``import_state`` is operational state, not truth — the JSONL doesn't contain it,
1440
+ so a plain rebuild would wipe every source cursor. The next poll would then
1441
+ re-adopt each event-bearing source at its current EOF (``adopt_if_unwatermarked``),
1442
+ permanently skipping any source lines appended since its last import — reindexing
1443
+ with live sessions writing would silently lose their tails. The previous live
1444
+ index is the freshest copy of the cursors, so it overlays the (possibly stale)
1445
+ ``import_state.jsonl`` snapshot seed loaded before this. Rows pointing at threads
1446
+ the truth no longer holds are pruned. Returns rows carried."""
1447
+ if not index_path.exists():
1448
+ return 0
1449
+ build_cols = {c.key for c in ImportState.__table__.columns}
1450
+ try:
1451
+ src = sqlite3.connect(f"file:{index_path}?mode=ro", uri=True)
1452
+ except sqlite3.OperationalError: # pragma: no cover — unreadable old index
1453
+ return 0
1454
+ try:
1455
+ try:
1456
+ cur = src.execute("SELECT * FROM import_state")
1457
+ except sqlite3.OperationalError as e:
1458
+ if "no such table" not in str(e): # pragma: no cover — unreadable old index
1459
+ logger.warning("reindex: could not read import_state from old index: %s", e)
1460
+ return 0
1461
+ src_cols = [d[0] for d in cur.description]
1462
+ keep = [i for i, c in enumerate(src_cols) if c in build_cols]
1463
+ cols = [src_cols[i] for i in keep]
1464
+ rows = [tuple(r[i] for i in keep) for r in cur.fetchall()]
1465
+ finally:
1466
+ src.close()
1467
+ if rows:
1468
+ stmt = (
1469
+ f"INSERT OR REPLACE INTO import_state ({', '.join(cols)}) "
1470
+ f"VALUES ({', '.join('?' * len(cols))})"
1471
+ )
1472
+ with engine.begin() as conn:
1473
+ conn.exec_driver_sql(stmt, rows)
1474
+ with engine.begin() as conn:
1475
+ conn.exec_driver_sql(
1476
+ "DELETE FROM import_state WHERE thread_id IS NOT NULL "
1477
+ "AND thread_id NOT IN (SELECT id FROM threads)"
1478
+ )
1479
+ return len(rows)
1480
+
1481
+
1482
+ def _replay_kg_events(
1483
+ d: Path, engine, *, errors: list[tuple[str, int]] | None = None,
1484
+ ) -> int:
1485
+ """Fold the curatorial event log (``kg_events.jsonl``) onto the knowledge projection.
1486
+
1487
+ Loads the log into the ``kg_events`` table and replays each event in ``id`` order
1488
+ through the materializer, mutating ``thread_links`` / ``topic_messages`` on top of
1489
+ whatever legacy snapshot seed was already loaded. The materializer is upsert +
1490
+ tombstone, so a delta that re-touches a seeded row (or deletes one) reconciles
1491
+ cleanly and the replay is idempotent and order-stable. A no-op when the log is
1492
+ absent — a pre-librarian (or purely lexical) archive simply has no curation to fold.
1493
+ Runs through an ORM ``Session`` so the fold can use the materializer, but it never
1494
+ *stages* truth (only :func:`append_kg_event` does), so the before-commit drain is a
1495
+ no-op here and the rebuild can't re-write the log it is reading."""
1496
+ from .._knowledge.materialize import apply_event
1497
+
1498
+ rows = list(_iter_jsonl(d / KG_EVENTS_FILE, errors=errors))
1499
+ for r in rows:
1500
+ r.pop("type", None)
1501
+ rows.sort(key=lambda r: r.get("id") or 0)
1502
+ if not rows:
1503
+ return 0
1504
+ coerced = [_coerce(KgEvent, r) for r in rows]
1505
+ with engine.begin() as conn:
1506
+ conn.execute(insert(KgEvent.__table__).prefix_with("OR REPLACE"), coerced)
1507
+ with Session(engine) as s:
1508
+ for r in coerced:
1509
+ apply_event(s, KgEvent(**r))
1510
+ s.commit()
1511
+ return len(rows)
1512
+
1513
+
1514
+ # ── reindex quiesce lock (shared with the watcher's ingest pass) ─────────────
1515
+ # flock on <home>/.reindex.lock: the watcher holds it SHARED for the duration of
1516
+ # each ingest pass; reindex holds it EXCLUSIVE across build+swap. So a reindex
1517
+ # waits out an in-flight pass, and no event can land in the truth mid-rebuild and
1518
+ # silently miss the new index. flock auto-releases on process death — a killed
1519
+ # holder can never wedge the other side.
1520
+ REINDEX_LOCK_FILE = ".reindex.lock"
1521
+
1522
+
1523
+ def _reindex_lock_path() -> Path:
1524
+ from .._config import resolve_paths
1525
+
1526
+ return resolve_paths().home / REINDEX_LOCK_FILE
1527
+
1528
+
1529
+ @contextmanager
1530
+ def try_shared_ingest_lock() -> Generator[bool, None, None]:
1531
+ """Hold the reindex lock *shared* for one ingest pass, non-blocking.
1532
+
1533
+ Yields True holding the lock, or False (not holding) when a reindex holds it
1534
+ exclusive — the caller skips the pass and ingest resumes on the next one, after
1535
+ the swap. Sources replay from their own import state, so a skipped pass loses
1536
+ nothing."""
1537
+ path = _reindex_lock_path()
1538
+ path.parent.mkdir(parents=True, exist_ok=True)
1539
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
1540
+ try:
1541
+ try:
1542
+ fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
1543
+ except OSError:
1544
+ yield False
1545
+ return
1546
+ yield True
1547
+ finally:
1548
+ os.close(fd) # closing the fd releases the flock
1549
+
1550
+
1551
+ @contextmanager
1552
+ def shared_ingest_lock() -> Generator[None, None, None]:
1553
+ """Hold the reindex lock *shared*, blocking — for one-shot writers.
1554
+
1555
+ The watcher uses the non-blocking :func:`try_shared_ingest_lock` (it can just
1556
+ skip a pass); a one-shot writer — a CLI import, a librarian mutation — has no
1557
+ later pass to retry on, so it waits out an in-flight reindex instead. Every
1558
+ cross-process writer must hold this (or the try- variant) around its truth
1559
+ append **and** the SQLite commit: an unlocked write can append truth after the
1560
+ rebuild's read point and commit into the database inode the swap replaces —
1561
+ present in the truth, silently absent from the new index."""
1562
+ path = _reindex_lock_path()
1563
+ path.parent.mkdir(parents=True, exist_ok=True)
1564
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
1565
+ try:
1566
+ fcntl.flock(fd, fcntl.LOCK_SH)
1567
+ yield
1568
+ finally:
1569
+ os.close(fd) # closing the fd releases the flock
1570
+
1571
+
1572
+ @contextmanager
1573
+ def _hold_reindex_lock() -> Generator[None, None, None]:
1574
+ """Hold the reindex lock *exclusive* for build+swap (blocks until any
1575
+ in-flight ingest pass — a shared holder — finishes, bounded by one pass)."""
1576
+ path = _reindex_lock_path()
1577
+ path.parent.mkdir(parents=True, exist_ok=True)
1578
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
1579
+ try:
1580
+ fcntl.flock(fd, fcntl.LOCK_EX)
1581
+ yield
1582
+ finally:
1583
+ os.close(fd)
1584
+
1585
+
1586
+ def _committed_regression(index_path: Path, tmp_path: Path) -> dict | None:
1587
+ """Committed records the rebuild would lose, diffed against the current index.
1588
+
1589
+ The gate behind fail-closed publication: every event and kg-event id the
1590
+ live index holds must survive into the build — an event may instead survive
1591
+ as a same-content twin (same ``(thread_id, dedup_key)`` under another id;
1592
+ dedup collapse legitimately re-keys those). Threads are checked by id too:
1593
+ an event-less thread lost to a name-conflict ``OR REPLACE`` (or a deleted
1594
+ truth file) has no event row to trip the event diff. Anything else missing
1595
+ means the truth lost committed content (a damaged line, a deleted file) and
1596
+ the swap would make the loss permanent-by-default. Returns ``None`` when
1597
+ there is no readable previous index to diff against (a fresh restore has no
1598
+ baseline)."""
1599
+ if not index_path.exists():
1600
+ return None
1601
+ conn = sqlite3.connect(tmp_path)
1602
+ try:
1603
+ try:
1604
+ conn.execute("ATTACH DATABASE ? AS old", (str(index_path),))
1605
+ lost_threads = conn.execute(
1606
+ "SELECT count(*) FROM old.threads o "
1607
+ "WHERE NOT EXISTS(SELECT 1 FROM main.threads n WHERE n.id = o.id)"
1608
+ ).fetchone()[0]
1609
+ thread_sample = [r[0] for r in conn.execute(
1610
+ "SELECT o.id FROM old.threads o "
1611
+ "WHERE NOT EXISTS(SELECT 1 FROM main.threads n WHERE n.id = o.id) "
1612
+ "ORDER BY o.id LIMIT 10"
1613
+ ).fetchall()] if lost_threads else []
1614
+ lost_events = conn.execute(
1615
+ "SELECT count(*) FROM old.events o "
1616
+ "WHERE NOT EXISTS(SELECT 1 FROM main.events n WHERE n.id = o.id) "
1617
+ "AND (o.dedup_key IS NULL OR NOT EXISTS("
1618
+ " SELECT 1 FROM main.events n "
1619
+ " WHERE n.thread_id = o.thread_id AND n.dedup_key = o.dedup_key))"
1620
+ ).fetchone()[0]
1621
+ sample = [r[0] for r in conn.execute(
1622
+ "SELECT o.id FROM old.events o "
1623
+ "WHERE NOT EXISTS(SELECT 1 FROM main.events n WHERE n.id = o.id) "
1624
+ "AND (o.dedup_key IS NULL OR NOT EXISTS("
1625
+ " SELECT 1 FROM main.events n "
1626
+ " WHERE n.thread_id = o.thread_id AND n.dedup_key = o.dedup_key)) "
1627
+ "ORDER BY o.id LIMIT 10"
1628
+ ).fetchall()] if lost_events else []
1629
+ lost_kg = conn.execute(
1630
+ "SELECT count(*) FROM old.kg_events o "
1631
+ "WHERE NOT EXISTS(SELECT 1 FROM main.kg_events n WHERE n.id = o.id)"
1632
+ ).fetchone()[0]
1633
+ except sqlite3.Error as e:
1634
+ # An unreadable / pre-schema old index is no baseline — the rebuild
1635
+ # IS the recovery. Never let the gate itself block it.
1636
+ logger.warning("reindex: cannot diff against the previous index (%s)", e)
1637
+ return None
1638
+ finally:
1639
+ conn.close()
1640
+ return {
1641
+ "events": int(lost_events), "event_sample": sample,
1642
+ "kg_events": int(lost_kg),
1643
+ "threads": int(lost_threads), "thread_sample": thread_sample,
1644
+ }
1645
+
1646
+
1647
+ def _parsed_equal(a: object, b: object) -> bool:
1648
+ """True when two stored payload texts encode the same object. Raw-text
1649
+ inequality alone must not count — the two stores may serialize one object
1650
+ differently across code versions — so candidates are confirmed by parsed
1651
+ comparison. An unparseable side is a real disagreement (that copy is
1652
+ damaged)."""
1653
+ if a == b:
1654
+ return True
1655
+ try:
1656
+ pa = json.loads(a) if isinstance(a, str) else a
1657
+ pb = json.loads(b) if isinstance(b, str) else b
1658
+ except ValueError:
1659
+ return False
1660
+ return pa == pb
1661
+
1662
+
1663
+ def _content_divergence(index_path: Path, tmp_path: Path) -> dict | None:
1664
+ """Same-id events whose content disagrees between the current index and the
1665
+ rebuild — publication *visibility*, deliberately not a gate.
1666
+
1667
+ The truth is authoritative: on publish the rebuild's content wins, and the
1668
+ projection disagreeing must not block the swap. But nothing mutates a
1669
+ payload after commit, so a same-id disagreement means one copy is damaged —
1670
+ and for an event with no hash-tailed ``dedup_key`` the live index row can be
1671
+ the last good copy of a truth line rotted in place. Cross-store parity
1672
+ (``verify --hashes``) can only see the disagreement while both copies still
1673
+ exist; the moment the swap lands they agree and the rot is laundered.
1674
+ Publication is therefore the last observable moment of the overwrite: count
1675
+ it and name the ids, so an operator (or the agent that just ran ``archive
1676
+ reindex`` as a routine fix) can adjudicate against a backup generation
1677
+ before the next backup run propagates the new content. Returns ``None``
1678
+ with no readable previous index, else ``{"events": n, "sample": [...]}``."""
1679
+ if not index_path.exists():
1680
+ return None
1681
+ conn = sqlite3.connect(tmp_path)
1682
+ try:
1683
+ try:
1684
+ conn.execute("ATTACH DATABASE ? AS old", (str(index_path),))
1685
+ # Raw-text inequality is the cheap SQL prefilter; each candidate is
1686
+ # confirmed in Python (see _parsed_equal). Streamed, not materialized.
1687
+ cur = conn.execute(
1688
+ "SELECT o.id, o.event_type, o.payload, n.event_type, n.payload "
1689
+ "FROM old.events o JOIN main.events n ON n.id = o.id "
1690
+ "WHERE o.payload IS NOT n.payload OR o.event_type IS NOT n.event_type"
1691
+ )
1692
+ diverged = 0
1693
+ sample: list[int] = []
1694
+ for ev_id, o_type, o_payload, n_type, n_payload in cur:
1695
+ if o_type == n_type and _parsed_equal(o_payload, n_payload):
1696
+ continue
1697
+ diverged += 1
1698
+ if len(sample) < 10:
1699
+ sample.append(int(ev_id))
1700
+ except sqlite3.Error as e:
1701
+ # No readable old index — nothing to diverge from; the rebuild IS
1702
+ # the recovery.
1703
+ logger.warning("reindex: cannot content-diff against the previous index (%s)", e)
1704
+ return None
1705
+ finally:
1706
+ conn.close()
1707
+ return {"events": diverged, "sample": sample}
1708
+
1709
+
1710
+ def _build_fk_violations(tmp_path: Path) -> list[tuple]:
1711
+ """``PRAGMA foreign_key_check`` over the whole build — the relational gate
1712
+ behind fail-closed publication. The loader runs FK-OFF with blanket
1713
+ ``INSERT OR REPLACE``, so a load-order accident can delete a parent row
1714
+ while its children survive (``threads.name`` is UNIQUE: two thread records
1715
+ sharing a name make OR REPLACE silently drop one thread and orphan its
1716
+ events). ``quick_check`` is page-level and cannot see that. Every declared
1717
+ FK targets ``threads`` — deliberately-soft references (citations to
1718
+ events) carry no FK, so this gate can never refuse a rebuild over the
1719
+ dangling citations ``verify --deep`` tolerates by design. Returns up to 20
1720
+ ``(table, rowid, parent, fkid)`` rows."""
1721
+ conn = sqlite3.connect(tmp_path)
1722
+ try:
1723
+ return conn.execute("PRAGMA foreign_key_check").fetchmany(20)
1724
+ finally:
1725
+ conn.close()
1726
+
1727
+
1728
+ def _files_for_thread(d: Path, thread_id: int) -> list[Path]:
1729
+ """Every truth file for ``thread_id`` — its canonical-depth file plus any
1730
+ stale twin at another shard depth."""
1731
+ threads_dir = d / THREADS_SUBDIR
1732
+ if not threads_dir.exists():
1733
+ return []
1734
+ return list(threads_dir.rglob(f"{int(thread_id)}.jsonl"))
1735
+
1736
+
1737
+ def _reconcile_collapsed_citations(d: Path, engine) -> dict:
1738
+ """Post-load reconciliation of citation → event references in the build.
1739
+
1740
+ Dedup collapse (OR REPLACE + the ``(thread_id, dedup_key)`` unique index)
1741
+ keeps one row per content identity; when the *discarded* twin's id was cited
1742
+ (``topic_messages.event_id``), the citation would dangle even though the
1743
+ content it cites survived under the other id. The truth still holds the
1744
+ discarded id's line, so its ``dedup_key`` recovers the surviving row: the
1745
+ citation is repointed to it — or dropped when the topic already cites the
1746
+ survivor (same content, same topic, one citation). A citation whose event
1747
+ has no surviving twin is left dangling for ``verify --deep`` to report.
1748
+
1749
+ Citations whose recorded ``thread_id`` disagrees with the cited event's
1750
+ actual thread are aligned to the event — the event row is authoritative and
1751
+ the column is derived (a wrong value came from an unvalidated write or a
1752
+ stale snapshot seed).
1753
+
1754
+ Both repairs are deterministic functions of the truth directory, so
1755
+ re-running them on every reindex lands on the same projection; the truth
1756
+ log itself is never rewritten here. Returns the (nonzero) counts."""
1757
+ with engine.begin() as conn:
1758
+ dangling = conn.exec_driver_sql(
1759
+ "SELECT m.id, m.topic_id, m.event_id, m.thread_id FROM topic_messages m "
1760
+ "WHERE m.archived_at IS NULL "
1761
+ "AND NOT EXISTS(SELECT 1 FROM events e WHERE e.id = m.event_id)"
1762
+ ).fetchall()
1763
+ repointed = dropped = 0
1764
+ if dangling:
1765
+ # dedup_key of each discarded id, recovered from its thread's truth
1766
+ # file(s). dedup_key is thread-scoped (same content in two threads
1767
+ # shares a key), so the survivor lookup stays scoped to the thread.
1768
+ wanted_by_thread: dict[int, set[int]] = {}
1769
+ for _, _, ev_id, tid in dangling:
1770
+ wanted_by_thread.setdefault(int(tid), set()).add(int(ev_id))
1771
+ keys: dict[int, str] = {}
1772
+ for tid, wanted in wanted_by_thread.items():
1773
+ for path in _files_for_thread(d, tid):
1774
+ for rec in _iter_jsonl(path):
1775
+ if (
1776
+ rec.get("type", "event") == "event"
1777
+ and rec.get("id") in wanted and rec.get("dedup_key")
1778
+ ):
1779
+ keys[int(rec["id"])] = rec["dedup_key"]
1780
+ with engine.begin() as conn:
1781
+ for row_id, topic_id, ev_id, tid in dangling:
1782
+ key = keys.get(int(ev_id))
1783
+ if not key:
1784
+ continue
1785
+ survivor = conn.exec_driver_sql(
1786
+ "SELECT id FROM events WHERE thread_id = ? AND dedup_key = ?",
1787
+ (int(tid), key),
1788
+ ).fetchone()
1789
+ if survivor is None:
1790
+ continue
1791
+ already = conn.exec_driver_sql(
1792
+ "SELECT 1 FROM topic_messages WHERE topic_id = ? AND event_id = ?",
1793
+ (int(topic_id), int(survivor[0])),
1794
+ ).fetchone()
1795
+ if already is not None:
1796
+ conn.exec_driver_sql(
1797
+ "DELETE FROM topic_messages WHERE id = ?", (int(row_id),)
1798
+ )
1799
+ dropped += 1
1800
+ else:
1801
+ conn.exec_driver_sql(
1802
+ "UPDATE topic_messages SET event_id = ? WHERE id = ?",
1803
+ (int(survivor[0]), int(row_id)),
1804
+ )
1805
+ repointed += 1
1806
+ with engine.begin() as conn:
1807
+ aligned = conn.exec_driver_sql(
1808
+ "UPDATE topic_messages SET thread_id = "
1809
+ "(SELECT e.thread_id FROM events e WHERE e.id = topic_messages.event_id) "
1810
+ "WHERE EXISTS(SELECT 1 FROM events e WHERE e.id = topic_messages.event_id "
1811
+ "AND e.thread_id != topic_messages.thread_id)"
1812
+ ).rowcount
1813
+ out: dict = {}
1814
+ if repointed:
1815
+ out["citations_repointed"] = repointed
1816
+ if dropped:
1817
+ out["citations_dropped"] = dropped
1818
+ if aligned:
1819
+ out["citations_thread_aligned"] = aligned
1820
+ if out:
1821
+ logger.warning("reindex: citation reconciliation %s", out)
1822
+ return out
1823
+
1824
+
1825
+ def _fold_wal(path: Path) -> None:
1826
+ """Fold any leftover ``-wal`` into the main file and drop the sidecars, so a
1827
+ rename moves one complete, self-contained database."""
1828
+ if Path(f"{path}-wal").exists():
1829
+ conn = sqlite3.connect(path)
1830
+ try:
1831
+ conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
1832
+ finally:
1833
+ conn.close()
1834
+ for suffix in ("-wal", "-shm"):
1835
+ Path(f"{path}{suffix}").unlink(missing_ok=True)
1836
+
1837
+
1838
+ def _unlink_build(tmp_path: Path) -> None:
1839
+ for p in (tmp_path, Path(f"{tmp_path}-wal"), Path(f"{tmp_path}-shm")):
1840
+ p.unlink(missing_ok=True)
1841
+
1842
+
1843
+ def reindex(*, vectors: bool = False, salvage: bool = False) -> dict:
1844
+ """Rebuild the SQLite store from the JSONL truth directory — build-and-swap.
1845
+
1846
+ The recovery primitive: build a complete new index (schema, per-thread files,
1847
+ cross-thread snapshots, kg-event replay, FTS, optionally vectors) in
1848
+ ``index.db.rebuild`` next to the live file, then ``os.replace`` it over
1849
+ ``index.db``. Atomic against a crash: a reindex killed at any point leaves the
1850
+ old index fully intact and the build file is discarded; only the rename — atomic
1851
+ on the same filesystem — publishes the new one. Ingest is quiesced for the
1852
+ duration via the reindex flock (the watcher skips passes while it's held), so no
1853
+ event lands in the truth mid-build and silently misses the new index. In-process
1854
+ readers reconnect on the next ``get_engine()`` call (the live engine's pools are
1855
+ disposed before the swap); *cross-process* readers keep serving the old inode
1856
+ until they reconnect or restart — the accepted stale-read window (see
1857
+ docs/plans/reindex-atomic-swap.md for the follow-up).
1858
+
1859
+ Bulk loading targets the build file through a **FK-OFF Core** loader so
1860
+ dependency-agnostic inserts need no ordering and the conversation truth-log
1861
+ listeners never fire; the kg-event replay runs through a Session on that same
1862
+ engine but stages nothing, so it likewise can't re-write the truth it reads.
1863
+ Loads are **INSERT OR REPLACE** (last-wins). The JSONL is authoritative and
1864
+ replayed as-is — including any dangling reference; integrity was the writer's
1865
+ job.
1866
+
1867
+ **Publication fails closed on committed-content loss.** Unparseable truth
1868
+ lines are skipped and counted (``parse_errors``, split into
1869
+ ``parse_errors_torn_tail`` — the file's final line, a torn last append — and
1870
+ ``parse_errors_interior``): crash artifacts are expected residue (an
1871
+ isolated torn fragment stays an unparseable interior line forever) and must
1872
+ never block the recovery primitive. What must never happen instead is a
1873
+ swap that silently *loses committed records*, so before publishing the
1874
+ build is diffed against the current index (:func:`_committed_regression`):
1875
+ any event, kg-event, or thread the old index holds that the rebuild lacks —
1876
+ by id, and for events with no same-content twin surviving under another id —
1877
+ aborts the swap (the old index stays live) and the error names the loss.
1878
+ The build must also pass ``PRAGMA foreign_key_check``
1879
+ (:func:`_build_fk_violations`) — the FK-OFF OR REPLACE load can orphan
1880
+ children when conflicting parent records collide, and a structurally
1881
+ inconsistent build must not be published. Deliberately-soft references
1882
+ (citations to events) carry no FK, so the gate never blocks the tolerated
1883
+ dangling-citation case; ``salvage=True`` overrides it like the loss gate. A
1884
+ crash fragment was never committed (truth is fsynced before its COMMIT), so
1885
+ it can't trip the gate; a damaged committed line always does. ``salvage=True``
1886
+ is the deliberate override: publish the lossy rebuild anyway. With no
1887
+ readable previous index (the ``rm index.db`` recovery flow) there is no
1888
+ baseline and the gate is skipped — parse errors are still reported. The
1889
+ rebuilt file must also pass ``PRAGMA quick_check`` before the swap (never
1890
+ overridable) — a build corrupted at the page level must not replace a
1891
+ healthy index.
1892
+
1893
+ **Content divergence is reported, never blocked.** A same-id event whose
1894
+ content disagrees between the old index and the build is counted into the
1895
+ result (``content_overwrites`` + sample ids) and logged at publication
1896
+ (:func:`_content_divergence`): the truth is authoritative and its content
1897
+ wins, but nothing mutates a payload after commit, so a disagreement means
1898
+ one copy is damaged — and once the swap lands the two stores agree and
1899
+ cross-store parity can no longer see it. The report is the last observable
1900
+ moment of the overwrite; adjudicate an unexpected one against a backup
1901
+ generation before the next backup run propagates the published content."""
1902
+ d = log_dir()
1903
+
1904
+ engine = get_engine()
1905
+ db_path = engine.url.database
1906
+ if not db_path or db_path == ":memory:":
1907
+ raise RuntimeError("reindex needs a file-backed index (build-and-swap)")
1908
+ index_path = Path(db_path)
1909
+
1910
+ # Pre-flight: the build needs room for a second copy of the index.
1911
+ if index_path.exists():
1912
+ free = shutil.disk_usage(index_path.parent).free
1913
+ need = int(index_path.stat().st_size * 1.2)
1914
+ if free < need:
1915
+ raise RuntimeError(
1916
+ f"reindex: {free / 1e9:.1f} GB free < {need / 1e9:.1f} GB needed "
1917
+ "for the temp build — free disk space first"
1918
+ )
1919
+
1920
+ tmp_path = index_path.with_name(index_path.name + ".rebuild")
1921
+ counts: dict = {}
1922
+ parse_errors: list[tuple[str, int]] = []
1923
+
1924
+ with _hold_reindex_lock():
1925
+ # Entering the truth-write mutex resolves any crashed drain's leftover
1926
+ # intent (a partial batch) before the rebuild reads the files.
1927
+ with _truth_write_lock():
1928
+ pass
1929
+ _unlink_build(tmp_path) # a dead prior build is stale — start clean
1930
+ loader = build_engine(f"sqlite:///{tmp_path}", enforce_fk=False)
1931
+ try:
1932
+ init_db(loader)
1933
+ counts["threads"], counts["events"] = _load_thread_files(d, loader, errors=parse_errors)
1934
+ # The loader counts rows loaded; OR REPLACE + the (thread_id, dedup_key)
1935
+ # unique index collapse superseded lines (re-appended ids, same-content
1936
+ # twins from a lost-commit re-import, a thread's stale twin at another
1937
+ # shard depth), so report what actually survived.
1938
+ with loader.begin() as conn:
1939
+ actual_events = conn.exec_driver_sql("SELECT count(*) FROM events").scalar() or 0
1940
+ actual_threads = conn.exec_driver_sql("SELECT count(*) FROM threads").scalar() or 0
1941
+ if actual_events != counts["events"]:
1942
+ counts["events_collapsed"] = counts["events"] - actual_events
1943
+ counts["events"] = int(actual_events)
1944
+ if actual_threads != counts["threads"]:
1945
+ counts["threads"] = int(actual_threads)
1946
+ for name, model in _CROSS_THREAD.items():
1947
+ counts[name] = _load_table(model, d / f"{name}.jsonl", loader, errors=parse_errors)
1948
+ # Source-import watermarks: seed from the checkpoint snapshot, then
1949
+ # overlay the previous live index's fresher rows (see _carry_import_state).
1950
+ _load_table(ImportState, d / "import_state.jsonl", loader, errors=parse_errors)
1951
+ counts["import_state"] = _carry_import_state(index_path, loader)
1952
+ counts["kg_events"] = _replay_kg_events(d, loader, errors=parse_errors)
1953
+
1954
+ # Fail closed on committed-content loss (see docstring): crash
1955
+ # fragments never block recovery, but a rebuild missing records the
1956
+ # current index holds must not be published over it.
1957
+ torn_tails, interior = _classify_parse_errors(parse_errors)
1958
+ counts["parse_errors_torn_tail"] = len(torn_tails)
1959
+ counts["parse_errors_interior"] = len(interior)
1960
+ if not salvage:
1961
+ lost = _committed_regression(index_path, tmp_path)
1962
+ if lost is not None and (lost["events"] or lost["kg_events"] or lost["threads"]):
1963
+ err_sample = ", ".join(f"{p}:{ln}" for p, ln in interior[:5])
1964
+ raise RuntimeError(
1965
+ f"reindex: the rebuild would lose {lost['events']} committed "
1966
+ f"event(s), {lost['kg_events']} curation event(s) and "
1967
+ f"{lost['threads']} thread(s) the current index holds "
1968
+ f"(event sample: {lost['event_sample']}; thread sample: "
1969
+ f"{lost['thread_sample']}) "
1970
+ "— refusing to publish; the old index was left in place. "
1971
+ + (f"Likely cause: {len(interior)} damaged truth line(s) "
1972
+ f"({err_sample}). " if interior else "")
1973
+ + "Repair the truth (or restore it from backup), or rerun "
1974
+ "with --salvage to publish the lossy rebuild anyway."
1975
+ )
1976
+ # Report-only, salvage or not: same-id content the publish will
1977
+ # overwrite in the index. The truth wins by design — this is the
1978
+ # last observable moment of the overwrite, not a gate (see
1979
+ # _content_divergence).
1980
+ diverged = _content_divergence(index_path, tmp_path)
1981
+ if diverged and diverged["events"]:
1982
+ counts["content_overwrites"] = diverged["events"]
1983
+ counts["content_overwrite_sample"] = diverged["sample"]
1984
+ logger.warning(
1985
+ "reindex: publishing content for %d event id(s) that disagrees "
1986
+ "with the live index (sample: %s) — the truth wins by design; "
1987
+ "if this is unexpected, adjudicate against a backup generation "
1988
+ "before the next backup run propagates it",
1989
+ diverged["events"], diverged["sample"],
1990
+ )
1991
+ counts.update(_reconcile_collapsed_citations(d, loader))
1992
+
1993
+ # FTS + vectors resolve their engine via get_engine(); point them at
1994
+ # the build for the block.
1995
+ with use_engine(loader):
1996
+ from .._retrieval.fts import rebuild_fts
1997
+
1998
+ counts["fts"] = rebuild_fts()
1999
+ # Vectors always survive the rebuild: restore the durable sidecar
2000
+ # cache (space-key-guarded; the hours-long embed runs once, ever)
2001
+ # into the build regardless of the ``vectors`` flag — a plain
2002
+ # reindex must not silently swap away the semantic index.
2003
+ # ``vectors=True`` additionally embeds whatever the cache lacks and
2004
+ # refreshes the sidecar. Degrades to 0 without a sidecar or the
2005
+ # [embeddings] extra — the store stays lexical-only.
2006
+ from .._retrieval import vectors as _vec
2007
+
2008
+ counts["vectors_restored"] = _vec.load_vectors_sidecar(d)
2009
+ # The sidecar may carry vectors for event ids the rebuild
2010
+ # collapsed away (superseded same-content twins) — prune them
2011
+ # so the vector arm never scores rows that can't hydrate.
2012
+ with loader.begin() as conn:
2013
+ has_vec = conn.exec_driver_sql(
2014
+ "SELECT 1 FROM sqlite_master WHERE name='event_vectors'"
2015
+ ).scalar()
2016
+ pruned = conn.exec_driver_sql(
2017
+ "DELETE FROM event_vectors "
2018
+ "WHERE event_id NOT IN (SELECT id FROM events)"
2019
+ ).rowcount if has_vec else 0
2020
+ if pruned:
2021
+ counts["vectors_pruned"] = pruned
2022
+ if vectors:
2023
+ counts["vectors_embedded"] = _vec.index_events_local(rebuild=False)
2024
+ counts["vectors_cached"] = _vec.save_vectors_sidecar(d)
2025
+ except BaseException:
2026
+ loader.dispose()
2027
+ _unlink_build(tmp_path) # the old index was never touched
2028
+ raise
2029
+ loader.dispose()
2030
+
2031
+ # Relational gate on the FINISHED build (after the reconciliation pass
2032
+ # has run its deterministic repairs): the FK-OFF OR REPLACE load can
2033
+ # orphan children when conflicting parent records collide, and a
2034
+ # structurally inconsistent build must not be published. Salvage
2035
+ # overrides, like the loss gate.
2036
+ if not salvage:
2037
+ fk_violations = _build_fk_violations(tmp_path)
2038
+ if fk_violations:
2039
+ _unlink_build(tmp_path)
2040
+ raise RuntimeError(
2041
+ "reindex: the rebuild is relationally inconsistent — "
2042
+ f"foreign_key_check reported {len(fk_violations)} violation(s) "
2043
+ f"(sample: {fk_violations[:5]}) — refusing to publish; the old "
2044
+ "index was left in place. Likely cause: conflicting thread "
2045
+ "records in the truth (OR REPLACE dropped a parent row). "
2046
+ "Repair the truth, or rerun with --salvage to publish anyway."
2047
+ )
2048
+
2049
+ # Page-level gate: a build file corrupted on disk (a bad write during the
2050
+ # hours-long rebuild) must not replace a healthy index. Before the WAL
2051
+ # fold, and read-write: a WAL-mode database refuses a read-only open
2052
+ # once its sidecars are gone.
2053
+ qconn = sqlite3.connect(tmp_path)
2054
+ try:
2055
+ qc = [r[0] for r in qconn.execute("PRAGMA quick_check(10)").fetchall()]
2056
+ finally:
2057
+ qconn.close()
2058
+ if qc != ["ok"]:
2059
+ _unlink_build(tmp_path)
2060
+ raise RuntimeError(
2061
+ f"reindex: rebuilt index failed quick_check ({'; '.join(map(str, qc))}) "
2062
+ "— the old index was left in place"
2063
+ )
2064
+ _fold_wal(tmp_path)
2065
+
2066
+ # Publish: dispose the live engine's pools first (its connections point at
2067
+ # the file being replaced), atomically rename the build over index.db, then
2068
+ # drop the old sidecars — a stale -wal must never be replayed into the new
2069
+ # database. The next get_engine() connection opens the new file.
2070
+ engine.dispose()
2071
+ os.replace(tmp_path, index_path)
2072
+ for suffix in ("-wal", "-shm"):
2073
+ Path(f"{index_path}{suffix}").unlink(missing_ok=True)
2074
+
2075
+ counts["parse_errors"] = len(parse_errors)
2076
+
2077
+ # The topic graph caches a projection per engine; drop it so the next read
2078
+ # rebuilds over the freshly-loaded thread_links.
2079
+ from .._knowledge import reset_cache as _reset_kg
2080
+
2081
+ _reset_kg()
2082
+
2083
+ logger.info("jsonl_log reindex: %s", counts)
2084
+ return counts
2085
+
2086
+
2087
+ # ── truth emit (the single per-thread file writer; no-drift seam) ────────────
2088
+ def emit_thread_file(d: Path, thread_id: int, depth: int, thread_record, event_records) -> int:
2089
+ """Atomically (re)write one ``threads/<id>.jsonl``: an optional ``type:thread``
2090
+ record then the ``type:event`` records, in order. The one place the on-disk
2091
+ per-thread format is produced (used by the store re-emit), so the layout can't
2092
+ drift. Returns the event count written."""
2093
+ path = _thread_file(d, thread_id, depth)
2094
+ path.parent.mkdir(parents=True, exist_ok=True)
2095
+ tmp = path.with_suffix(".jsonl.tmp")
2096
+ n_ev = 0
2097
+ with open(tmp, "w", encoding="utf-8") as fh:
2098
+ if thread_record is not None:
2099
+ fh.write(json.dumps({"type": "thread", **thread_record}, default=_json_default, ensure_ascii=False))
2100
+ fh.write("\n")
2101
+ for ev in event_records:
2102
+ fh.write(json.dumps({"type": "event", **ev}, default=_json_default, ensure_ascii=False))
2103
+ fh.write("\n")
2104
+ n_ev += 1
2105
+ fh.flush()
2106
+ os.fsync(fh.fileno()) # durable before the rename makes it visible
2107
+ os.replace(tmp, path)
2108
+ _fsync_dir(path.parent) # the rename itself must survive power loss
2109
+ return n_ev
2110
+
2111
+
2112
+ def _hash_key_check(payload: object, dedup_key: str) -> bool | None:
2113
+ """True = the payload re-hashes to the content hash embedded in its own
2114
+ ``dedup_key`` (the last ``:``-segment; see
2115
+ ``thread_archive._thread_import.event_builder.compute_dedup_key``); False = mismatch;
2116
+ None = the key carries no hash tail (nothing to validate against)."""
2117
+ import re as _re
2118
+
2119
+ from thread_archive._thread_import.event_builder import compute_content_hash
2120
+
2121
+ if not _re.match(r"^[0-9a-f]{16}$", dedup_key.rsplit(":", 1)[-1]):
2122
+ return None
2123
+ if not isinstance(payload, dict):
2124
+ return False
2125
+ return compute_content_hash(payload) == dedup_key.rsplit(":", 1)[-1]
2126
+
2127
+
2128
+ def _store_rows_failing_key_hash() -> tuple[int, list[str]]:
2129
+ """Store event rows whose payload no longer re-hashes to the content hash
2130
+ embedded in their own ``dedup_key`` — the content half of the pre-flight
2131
+ behind :func:`rebuild_truth_from_store`. The containment check proves the
2132
+ store holds every truth *unit*; this proves the payloads behind those units
2133
+ are self-consistent, so a corrupted index row (rot, a bad in-place write)
2134
+ that kept its id and key can't be promoted over the good truth line by a
2135
+ re-emit. Returns ``(failing, sample)``."""
2136
+ failing = 0
2137
+ sample: list[str] = []
2138
+ with get_session() as s:
2139
+ conn = s.connection().connection # raw sqlite3 — stream, don't materialize
2140
+ for ev_id, tid, key, payload_text in conn.execute(
2141
+ "SELECT id, thread_id, dedup_key, payload FROM events "
2142
+ "WHERE dedup_key IS NOT NULL"
2143
+ ):
2144
+ try:
2145
+ payload = (
2146
+ json.loads(payload_text)
2147
+ if isinstance(payload_text, str) else payload_text
2148
+ )
2149
+ except ValueError:
2150
+ payload = None
2151
+ if _hash_key_check(payload, key) is False:
2152
+ failing += 1
2153
+ if len(sample) < 10:
2154
+ sample.append(f"thread {tid}: event {ev_id}")
2155
+ return failing, sample
2156
+
2157
+
2158
+ def _truth_units_missing_from_store(d: Path) -> tuple[int, list[str], dict[str, int]]:
2159
+ """Content units present in the truth but absent from the store — the
2160
+ pre-flight behind :func:`rebuild_truth_from_store`. A *unit* is an event's
2161
+ content identity: its ``dedup_key`` (thread-scoped), falling back to the
2162
+ event id when the key is NULL — the same collapse ``scan_truth_counts`` and
2163
+ the reindex loader apply. Count parity can hide compensating errors (an
2164
+ index-only event masking a missing one, swapped payloads behind equal
2165
+ totals); containment can't: every effective truth unit must exist in the
2166
+ store, or a re-emit would destroy content the index never had. Walked one
2167
+ thread at a time so memory stays bounded.
2168
+
2169
+ The same walk collects ``dropped_keys``: fields on truth records (event,
2170
+ thread, kg_event) that the running code's models don't map. ``_coerce``
2171
+ drops them on reload — harmless for the projection — but a re-emit rewrites
2172
+ the truth *without* them, which turns that tolerance into permanent loss;
2173
+ the caller refuses on any. Returns ``(missing, sample, dropped_keys)``
2174
+ where ``dropped_keys`` maps ``"<kind>.<field>"`` to its occurrence count."""
2175
+ threads_dir = d / THREADS_SUBDIR
2176
+ files_by_stem: dict[str, list[Path]] = {}
2177
+ if threads_dir.exists():
2178
+ for path in threads_dir.rglob("*.jsonl"):
2179
+ files_by_stem.setdefault(path.stem, []).append(path)
2180
+ valid_keys = {
2181
+ "event": {c.key for c in Event.__table__.columns},
2182
+ "thread": {c.key for c in Thread.__table__.columns},
2183
+ "kg_event": {c.key for c in KgEvent.__table__.columns},
2184
+ }
2185
+ dropped: dict[str, int] = {}
2186
+
2187
+ def _note_unknown(kind: str, rec: dict) -> None:
2188
+ for k in rec.keys() - valid_keys[kind] - {"type"}:
2189
+ dropped[f"{kind}.{k}"] = dropped.get(f"{kind}.{k}", 0) + 1
2190
+
2191
+ missing = 0
2192
+ sample: list[str] = []
2193
+ with get_session() as s:
2194
+ conn = s.connection().connection # raw sqlite3 — stream, don't materialize
2195
+ for stem, paths in files_by_stem.items():
2196
+ try:
2197
+ tid = int(stem)
2198
+ except ValueError: # pragma: no cover — stray file
2199
+ continue
2200
+ units: set = set()
2201
+ for path in paths:
2202
+ for rec in _iter_jsonl(path):
2203
+ kind = rec.get("type", "event")
2204
+ if kind in valid_keys:
2205
+ _note_unknown(kind, rec)
2206
+ if kind != "event" or rec.get("id") is None:
2207
+ continue
2208
+ units.add(rec.get("dedup_key") or ("id", int(rec["id"])))
2209
+ if not units:
2210
+ continue
2211
+ for ev_id, key in conn.execute(
2212
+ "SELECT id, dedup_key FROM events WHERE thread_id = ?", (tid,)
2213
+ ):
2214
+ units.discard(key or ("id", int(ev_id)))
2215
+ missing += len(units)
2216
+ for unit in sorted(map(str, units))[: max(0, 10 - len(sample))]:
2217
+ sample.append(f"thread {tid}: {unit}")
2218
+ if (d / KG_EVENTS_FILE).exists():
2219
+ for rec in _iter_jsonl(d / KG_EVENTS_FILE):
2220
+ _note_unknown("kg_event", rec)
2221
+ return missing, sample, dropped
2222
+
2223
+
2224
+ def rebuild_truth_from_store(*, force: bool = False) -> dict:
2225
+ """Re-emit the entire per-thread truth from the current SQLite store.
2226
+
2227
+ The inverse of :func:`reindex`: for every thread, (re)write ``threads/<id>.jsonl``
2228
+ as its metadata record + ordered events; rewrite the cross-thread snapshots; pick
2229
+ the shard depth for the thread count; set the manifest. Used to migrate an
2230
+ older monolithic ``events.jsonl`` into per-thread files (the old monolith files,
2231
+ if present, are removed), and to finish an index-only repair pass (e.g. the
2232
+ dedup-key backfill) by making the truth match. Idempotent — safe to re-run.
2233
+
2234
+ This is the ONE operation that overwrites truth from the projection — the
2235
+ reverse of the normal flow — so it protects itself: it holds the reindex lock
2236
+ **exclusive** for the duration (no writer can append truth or commit to the
2237
+ index mid-emission; in-tree writers all hold it shared), and it refuses to run
2238
+ unless the store *contains* every effective content unit the truth holds
2239
+ (:func:`_truth_units_missing_from_store` — per-unit containment, not count
2240
+ parity, so a missing event can't hide behind an index-only one). A repair
2241
+ pass that rewrites payloads in place keeps its units (the ``dedup_key``
2242
+ column carries the identity), so the intended use survives the gate.
2243
+
2244
+ Two further pre-flights guard the *content* of what gets written: the truth
2245
+ must carry no fields the running code's models don't map (``_coerce``
2246
+ tolerates them on reload, but a re-emit would drop them from the truth
2247
+ forever — an older binary must not lossily rewrite newer truth), and every
2248
+ store payload with a hash-tailed ``dedup_key`` must still re-hash to it
2249
+ (:func:`_store_rows_failing_key_hash` — a corrupted index row that kept its
2250
+ id and key must not replace the good truth line).
2251
+
2252
+ ``force=True`` overrides the pre-flights only (for a deliberate, understood
2253
+ shrink or drop — e.g. a duplicate-collapse repair, or an in-place payload
2254
+ repair that didn't recompute its keys); it never skips the lock."""
2255
+ d = log_dir()
2256
+ (d / THREADS_SUBDIR).mkdir(parents=True, exist_ok=True)
2257
+
2258
+ with _hold_reindex_lock():
2259
+ # Resolve any crashed drain's leftover intent first: a stale intent
2260
+ # surviving past the re-emit would frame baselines that no longer
2261
+ # describe the (replaced) files. The tail check would refuse to cut
2262
+ # them anyway; resolving here keeps that guard a backstop, not a path.
2263
+ with _truth_write_lock():
2264
+ pass
2265
+ if not force:
2266
+ missing, sample, dropped = _truth_units_missing_from_store(d)
2267
+ if missing:
2268
+ raise RuntimeError(
2269
+ f"rebuild_truth_from_store: the store lacks {missing} event(s) "
2270
+ f"the truth holds (sample: {sample}) — re-emitting would destroy "
2271
+ "truth content the index lacks. Run `archive reindex` first "
2272
+ "(or pass force=True if the shrink is intended)."
2273
+ )
2274
+ if dropped:
2275
+ fields = ", ".join(f"{k} ×{v}" for k, v in sorted(dropped.items()))
2276
+ raise RuntimeError(
2277
+ f"rebuild_truth_from_store: the truth carries field(s) the running "
2278
+ f"code's models don't map ({fields}) — re-emitting would silently "
2279
+ "drop them from the truth forever. Run the code version that wrote "
2280
+ "them (or pass force=True if the drop is intended)."
2281
+ )
2282
+ failing, bad_sample = _store_rows_failing_key_hash()
2283
+ if failing:
2284
+ raise RuntimeError(
2285
+ f"rebuild_truth_from_store: {failing} store payload(s) fail their "
2286
+ f"own dedup-key content hash (sample: {bad_sample}) — re-emitting "
2287
+ "would promote suspect index content over the existing truth. "
2288
+ "Investigate with `archive verify --hashes` and repair the index "
2289
+ "(`archive reindex`) first, or pass force=True if the payloads are "
2290
+ "known-good (an in-place repair that didn't recompute its keys)."
2291
+ )
2292
+ return _rebuild_truth_from_store_locked(d)
2293
+
2294
+
2295
+ def _rebuild_truth_from_store_locked(d: Path) -> dict:
2296
+ with get_session() as s:
2297
+ threads = s.execute(select(Thread).order_by(Thread.id)).scalars().all()
2298
+ depth = _depth_for(len(threads))
2299
+ nt = ne = 0
2300
+ emitted: set[int] = set()
2301
+ for t in threads: # outer list is materialized, so the inner event stream is the only cursor
2302
+ ev_rows = (_row_dict(ev) for ev in s.execute(
2303
+ select(Event).where(Event.thread_id == t.id).order_by(Event.id)
2304
+ ).scalars())
2305
+ ne += emit_thread_file(d, t.id, depth, _row_dict(t), ev_rows)
2306
+ emitted.add(t.id)
2307
+ nt += 1
2308
+
2309
+ for name, model in _CROSS_THREAD.items():
2310
+ _write_snapshot(d, name, model)
2311
+ _write_snapshot(d, "import_state", ImportState) # cursors survive the re-emit too
2312
+
2313
+ # Locked read-modify-write: the re-emit owns the layout keys, not the whole
2314
+ # manifest — a verify run's hashes baseline (or any future key) survives.
2315
+ update_manifest(d, lambda m: m.update(
2316
+ {"version": TRUTH_FORMAT_VERSION, "shard_depth": depth, "last_checkpoint_at": _now_iso()}
2317
+ ))
2318
+
2319
+ # Remove stale copies of re-emitted threads left at another shard depth. Their
2320
+ # content was just fully re-emitted at ``depth``, so an old-layout copy is pure
2321
+ # duplication — and on a later reindex a stale duplicate line would shadow the
2322
+ # fresh (possibly repaired) row for the same event id. Files whose ids the store
2323
+ # does NOT hold are left untouched.
2324
+ for path in list((d / THREADS_SUBDIR).rglob("*.jsonl")):
2325
+ try:
2326
+ tid = int(path.stem)
2327
+ except ValueError: # pragma: no cover — stray file
2328
+ continue
2329
+ if tid in emitted and path != _thread_file(d, tid, depth):
2330
+ path.unlink()
2331
+
2332
+ # Drop the old monolithic files this format replaces.
2333
+ for old in ("events.jsonl", "threads.jsonl"):
2334
+ p = d / old
2335
+ if p.exists():
2336
+ p.unlink()
2337
+
2338
+ result = {"threads": nt, "events": ne, "shard_depth": depth}
2339
+ logger.info("jsonl_log rebuild_truth_from_store: %s", result)
2340
+ return result