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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
thread_archive/_api.py
ADDED
|
@@ -0,0 +1,2235 @@
|
|
|
1
|
+
"""The internal coordination layer for thread-archive.
|
|
2
|
+
|
|
3
|
+
Private, like every underscore-prefixed module — the CLI, the MCP servers, and
|
|
4
|
+
the web viewer are built on top of these functions; nothing outside the package
|
|
5
|
+
may import them. Each call
|
|
6
|
+
opens the archive — resolves the home, ensures its directories, pins it for the
|
|
7
|
+
process, and initializes the SQLite engine + schema — then dispatches into the
|
|
8
|
+
store / importers / search / truth / watch layers. Internal imports are lazy so
|
|
9
|
+
``import thread_archive`` stays light and pulls in no server backends.
|
|
10
|
+
|
|
11
|
+
One archive per process: ``open_archive`` pins ``$THREAD_ARCHIVE_HOME`` so the
|
|
12
|
+
engine (index.db), the truth log (truth/), and search all resolve to the same
|
|
13
|
+
home. To switch archives, call :func:`close` first.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from ._config import ENV_HOME, ArchivePaths, resolve_paths
|
|
24
|
+
|
|
25
|
+
# (st_dev, st_ino) of index.db at the last open_archive — swap detection.
|
|
26
|
+
_index_ident: Optional[tuple[int, int]] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _reconnect_if_swapped(paths: ArchivePaths) -> None:
|
|
30
|
+
"""Dispose pooled connections when ``index.db`` was atomically replaced.
|
|
31
|
+
|
|
32
|
+
``reindex`` publishes by renaming a freshly built database over ``index.db``;
|
|
33
|
+
another process's pooled connections keep the *old inode* open — their reads are
|
|
34
|
+
frozen at the pre-swap state and their commits write a file nothing else can see.
|
|
35
|
+
Every API call passes through :func:`open_archive`, so comparing the file identity
|
|
36
|
+
here makes long-lived processes (the librarian MCP, the web app) converge on the
|
|
37
|
+
new index on their next call."""
|
|
38
|
+
global _index_ident
|
|
39
|
+
try:
|
|
40
|
+
st = os.stat(paths.index_path)
|
|
41
|
+
except OSError:
|
|
42
|
+
_index_ident = None
|
|
43
|
+
return
|
|
44
|
+
ident = (st.st_dev, st.st_ino)
|
|
45
|
+
if _index_ident is not None and ident != _index_ident:
|
|
46
|
+
from ._store import get_engine
|
|
47
|
+
|
|
48
|
+
get_engine().dispose()
|
|
49
|
+
_index_ident = ident
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def open_archive(home: Optional[str] = None) -> ArchivePaths:
|
|
53
|
+
"""Open (and initialize) the archive at ``home`` (env / default if None).
|
|
54
|
+
|
|
55
|
+
Switching homes within a process repoints everything atomically: the engine is
|
|
56
|
+
rebuilt (``init_engine``) and the JSONL truth-log's cached append handles are
|
|
57
|
+
dropped, so a second ``open_archive`` to a different home can't leave SQLite
|
|
58
|
+
writing one store while JSONL appends resolve to another.
|
|
59
|
+
"""
|
|
60
|
+
paths = resolve_paths(home).ensure()
|
|
61
|
+
target = paths.sqlalchemy_url
|
|
62
|
+
from ._store import active_dsn, init_db, init_engine
|
|
63
|
+
|
|
64
|
+
if active_dsn() is not None and active_dsn() != target:
|
|
65
|
+
from ._truth import reset_handles
|
|
66
|
+
|
|
67
|
+
reset_handles() # stale handles point at the previous home's files
|
|
68
|
+
# Pin the home so engine + truth + search resolve consistently for the process.
|
|
69
|
+
os.environ[ENV_HOME] = str(paths.home)
|
|
70
|
+
init_engine(target) # rebuilds when the DSN changed
|
|
71
|
+
_reconnect_if_swapped(paths)
|
|
72
|
+
init_db()
|
|
73
|
+
return paths
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def close() -> None:
|
|
77
|
+
"""Dispose the engine so a different archive can be opened."""
|
|
78
|
+
from ._store import close_engine
|
|
79
|
+
from ._truth import reset_handles
|
|
80
|
+
|
|
81
|
+
reset_handles()
|
|
82
|
+
close_engine()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── operational health records (<home>/health.json) ──────────────────────────
|
|
86
|
+
# When verify / backup last ran and how they went — the staleness signal that
|
|
87
|
+
# tells a dead scheduled job apart from a healthy one. Deliberately OUTSIDE the
|
|
88
|
+
# truth dir: this is install-local operational state, so backups don't mirror it
|
|
89
|
+
# (a restored truth must not claim the source install's health history) and the
|
|
90
|
+
# backup can't dirty the tree it is mirroring.
|
|
91
|
+
def _health_path() -> Path:
|
|
92
|
+
return resolve_paths().home / "health.json"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _read_health() -> dict:
|
|
96
|
+
try:
|
|
97
|
+
return json.loads(_health_path().read_text(encoding="utf-8"))
|
|
98
|
+
except (OSError, ValueError):
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _record_health(key: str, record: dict) -> None:
|
|
103
|
+
"""Set ``key`` to ``record`` (stamped with ``at``) — a locked read-modify-
|
|
104
|
+
write (exclusive flock on ``health.json.lock``, same discipline as the
|
|
105
|
+
manifest's): concurrent completions (a verify racing a backup, the nightly's
|
|
106
|
+
stages) each own different keys, and an unlocked whole-file replace would
|
|
107
|
+
lose whichever writer published first. Advisory data, so a failed write
|
|
108
|
+
logs and never breaks the operation it describes."""
|
|
109
|
+
import fcntl
|
|
110
|
+
from datetime import datetime, timezone
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
p = _health_path()
|
|
114
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
fd = os.open(p.with_name(f"{p.name}.lock"), os.O_RDWR | os.O_CREAT, 0o644)
|
|
116
|
+
try:
|
|
117
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
118
|
+
health = _read_health()
|
|
119
|
+
health[key] = {"at": datetime.now(timezone.utc).isoformat(), **record}
|
|
120
|
+
tmp = p.with_name(f"{p.name}.tmp.{os.getpid()}")
|
|
121
|
+
tmp.write_text(json.dumps(health, indent=2), encoding="utf-8")
|
|
122
|
+
os.replace(tmp, p)
|
|
123
|
+
finally:
|
|
124
|
+
os.close(fd) # closing the fd releases the flock
|
|
125
|
+
except OSError:
|
|
126
|
+
import logging
|
|
127
|
+
|
|
128
|
+
logging.getLogger(__name__).exception("could not record %s in health.json", key)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def search(
|
|
132
|
+
query: str,
|
|
133
|
+
*,
|
|
134
|
+
home: Optional[str] = None,
|
|
135
|
+
limit: int = 20,
|
|
136
|
+
thread_id: Optional[int] = None,
|
|
137
|
+
content_types: Optional[list[str]] = None,
|
|
138
|
+
exclude_content_types: Optional[list[str]] = None,
|
|
139
|
+
since: Optional[str] = None,
|
|
140
|
+
until: Optional[str] = None,
|
|
141
|
+
tool_name: Optional[str] = None,
|
|
142
|
+
source: Optional[list[str]] = None,
|
|
143
|
+
startswith: Optional[str] = None,
|
|
144
|
+
sort: Optional[str] = None,
|
|
145
|
+
output: Optional[str] = None,
|
|
146
|
+
context_lines: int = 2,
|
|
147
|
+
context_events: Optional[str] = None,
|
|
148
|
+
rerank: Optional[bool] = None,
|
|
149
|
+
) -> list[dict]:
|
|
150
|
+
"""Federated search over conversation events (lexical FTS5 + optional semantic
|
|
151
|
+
vectors → RRF fusion → weighted rank → optional cross-encoder re-rank). Returns
|
|
152
|
+
enriched event-hit dicts. ``source`` restricts to threads of the named
|
|
153
|
+
provider(s); ``startswith`` does a structural prefix scan; ``sort='oldest'``
|
|
154
|
+
returns the pool chronologically; ``output`` ('count'/'linkable') and
|
|
155
|
+
``context_lines`` / ``context_events`` shape what each hit carries; ``rerank``
|
|
156
|
+
forces the cross-encoder stage (else auto-gated to conceptual queries when the
|
|
157
|
+
``[embeddings]`` extra is present)."""
|
|
158
|
+
open_archive(home)
|
|
159
|
+
from ._retrieval import search as _search
|
|
160
|
+
|
|
161
|
+
return _search(
|
|
162
|
+
query,
|
|
163
|
+
limit=limit,
|
|
164
|
+
thread_id=thread_id,
|
|
165
|
+
content_types=content_types,
|
|
166
|
+
exclude_content_types=exclude_content_types,
|
|
167
|
+
since=since,
|
|
168
|
+
until=until,
|
|
169
|
+
tool_name=tool_name,
|
|
170
|
+
source=source,
|
|
171
|
+
startswith=startswith,
|
|
172
|
+
sort=sort,
|
|
173
|
+
output=output,
|
|
174
|
+
context_lines=context_lines,
|
|
175
|
+
context_events=context_events,
|
|
176
|
+
rerank=rerank,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def read_thread(
|
|
181
|
+
thread_id: int | str,
|
|
182
|
+
*,
|
|
183
|
+
home: Optional[str] = None,
|
|
184
|
+
limit: int = 200,
|
|
185
|
+
offset: int = 0,
|
|
186
|
+
summary: bool | str = False,
|
|
187
|
+
mode: Optional[str] = None,
|
|
188
|
+
user_only: Optional[bool] = None,
|
|
189
|
+
tool_results: bool = False,
|
|
190
|
+
max_chars: int = 0,
|
|
191
|
+
after_event: Optional[int] = None,
|
|
192
|
+
) -> str:
|
|
193
|
+
"""Reconstruct a conversation thread as a readable transcript.
|
|
194
|
+
|
|
195
|
+
``thread_id`` is the archive's integer thread id or a provider **session id**
|
|
196
|
+
(the uuid/source_id a tool knows the conversation by). ``mode`` picks the view —
|
|
197
|
+
``user`` (default), ``chat``, or ``full`` — and the read is turn-paginated +
|
|
198
|
+
size-budgeted (``max_chars``, default ~48k). ``tool_results`` (default off) adds
|
|
199
|
+
tool output under each call in ``full``. ``summary`` swaps in a summary view:
|
|
200
|
+
``True``/``'toc'`` = compact TOC, ``'short'`` / ``'indexed'`` = the stored thread
|
|
201
|
+
summaries; see :func:`thread_archive._retrieval.read_thread` for the full contract."""
|
|
202
|
+
open_archive(home)
|
|
203
|
+
from ._retrieval import read_thread as _read
|
|
204
|
+
|
|
205
|
+
return _read(
|
|
206
|
+
thread_id,
|
|
207
|
+
limit=limit,
|
|
208
|
+
offset=offset,
|
|
209
|
+
summary=summary,
|
|
210
|
+
mode=mode,
|
|
211
|
+
user_only=user_only,
|
|
212
|
+
tool_results=tool_results,
|
|
213
|
+
max_chars=max_chars,
|
|
214
|
+
after_event=after_event,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def read_thread_structured(
|
|
219
|
+
thread_id: int | str,
|
|
220
|
+
*,
|
|
221
|
+
home: Optional[str] = None,
|
|
222
|
+
include_thinking: bool = True,
|
|
223
|
+
include_tools: bool = True,
|
|
224
|
+
) -> dict:
|
|
225
|
+
"""Reconstruct a thread as structured messages (typed render blocks) for the web
|
|
226
|
+
viewer. ``thread_id`` accepts an integer thread id or a provider session id.
|
|
227
|
+
Returns ``{thread_id, title, source, messages}``; see
|
|
228
|
+
:func:`thread_archive._retrieval.read_thread_structured`."""
|
|
229
|
+
open_archive(home)
|
|
230
|
+
from ._retrieval import read_thread_structured as _read
|
|
231
|
+
|
|
232
|
+
return _read(thread_id, include_thinking=include_thinking, include_tools=include_tools)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def import_path(path, *, home: Optional[str] = None, provider: str = "claude-code", source_id: Optional[str] = None):
|
|
236
|
+
"""Import a transcript (line-stream providers) or scan a DB (cursor/opencode).
|
|
237
|
+
|
|
238
|
+
Each thread's metadata record and events are written to its own truth file as
|
|
239
|
+
the import commits, so the per-thread files are already a complete restore set.
|
|
240
|
+
The maintenance pass afterward just keeps the shard layout balanced and advances
|
|
241
|
+
the manifest watermark — conversation import never changes the cross-thread
|
|
242
|
+
overlays, so it does not rewrite those snapshots.
|
|
243
|
+
"""
|
|
244
|
+
open_archive(home)
|
|
245
|
+
from ._importers import DB_SCANNERS, LINE_STREAM_IMPORTERS
|
|
246
|
+
from ._truth import checkpoint as _checkpoint
|
|
247
|
+
from ._truth import shared_ingest_lock
|
|
248
|
+
|
|
249
|
+
p = Path(path)
|
|
250
|
+
# Held shared across the truth append AND the SQLite commit: an unlocked import
|
|
251
|
+
# racing a reindex can land in the truth after the rebuild's read point and
|
|
252
|
+
# commit into the inode the swap replaces. Blocks (bounded by one rebuild)
|
|
253
|
+
# rather than skipping — a one-shot import has no later pass to retry on.
|
|
254
|
+
with shared_ingest_lock():
|
|
255
|
+
if provider in LINE_STREAM_IMPORTERS:
|
|
256
|
+
result = LINE_STREAM_IMPORTERS[provider](p, source_id or p.stem)
|
|
257
|
+
elif provider in DB_SCANNERS:
|
|
258
|
+
result = DB_SCANNERS[provider](p)
|
|
259
|
+
else:
|
|
260
|
+
raise ValueError(f"unknown provider {provider!r}")
|
|
261
|
+
|
|
262
|
+
_checkpoint(snapshots=False)
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def reindex(*, home: Optional[str] = None, vectors: bool = False, salvage: bool = False) -> dict:
|
|
267
|
+
"""Rebuild index.db (relational + FTS) from the JSONL truth directory.
|
|
268
|
+
|
|
269
|
+
Fails closed when the rebuild would lose committed records the current index
|
|
270
|
+
holds (damaged truth lines, a deleted thread file) — the old index stays live
|
|
271
|
+
and the error names the loss; ``salvage=True`` publishes the lossy rebuild
|
|
272
|
+
anyway. Crash artifacts (torn lines that never committed) never block."""
|
|
273
|
+
open_archive(home)
|
|
274
|
+
from ._truth import reindex as _reindex
|
|
275
|
+
|
|
276
|
+
return _reindex(vectors=vectors, salvage=salvage)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def embed(
|
|
280
|
+
*,
|
|
281
|
+
home: Optional[str] = None,
|
|
282
|
+
rebuild: bool = False,
|
|
283
|
+
max_events: Optional[int] = None,
|
|
284
|
+
newest_first: bool = False,
|
|
285
|
+
) -> dict:
|
|
286
|
+
"""Embed the user/text events that are missing a vector (incremental anti-join).
|
|
287
|
+
|
|
288
|
+
The catch-up counterpart to the watcher's live cohost: ``reindex(vectors=True)``
|
|
289
|
+
rebuilds the whole vector index, this just fills the gap. ``rebuild=True``
|
|
290
|
+
re-embeds everything; ``max_events`` caps one call; ``newest_first`` drains the
|
|
291
|
+
freshest gap first (recent threads become semantically findable soonest). No-op
|
|
292
|
+
without the ``[embeddings]`` extra. Returns ``{'embedded': n}``."""
|
|
293
|
+
open_archive(home)
|
|
294
|
+
from ._retrieval.vectors import index_events_local
|
|
295
|
+
|
|
296
|
+
n = index_events_local(rebuild=rebuild, max_events=max_events, newest_first=newest_first)
|
|
297
|
+
return {"embedded": n}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def checkpoint(*, home: Optional[str] = None) -> dict:
|
|
301
|
+
"""Snapshot the mutable authored tables (threads) to the JSONL truth log."""
|
|
302
|
+
open_archive(home)
|
|
303
|
+
from ._truth import checkpoint as _checkpoint
|
|
304
|
+
|
|
305
|
+
return _checkpoint()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def watch(*, home: Optional[str] = None, interval: float = 5.0, once: bool = False):
|
|
309
|
+
"""Watch local AI-tool stores and import incrementally. Blocks unless ``once``."""
|
|
310
|
+
open_archive(home)
|
|
311
|
+
from ._truth import shared_ingest_lock
|
|
312
|
+
from ._watcher import Watcher
|
|
313
|
+
|
|
314
|
+
watcher = Watcher(home=home, interval=interval)
|
|
315
|
+
if once:
|
|
316
|
+
# run() takes the shared reindex lock per pass; a one-shot poll needs the
|
|
317
|
+
# same coverage (blocking — it has no next pass to retry on).
|
|
318
|
+
with shared_ingest_lock():
|
|
319
|
+
return watcher.poll_once()
|
|
320
|
+
watcher.run()
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# Delete-sync sanity bound: refuse to delete more than this fraction of the
|
|
325
|
+
# destination's files (once past the absolute floor). A mass-wipe of the source —
|
|
326
|
+
# a bug emptying truth/, an accidental rm — must not propagate to the last backup;
|
|
327
|
+
# a legitimate mass-move (shard rebalance) re-homes files, so the copies land
|
|
328
|
+
# first and the stale paths deleted stay a bounded fraction only when the copy
|
|
329
|
+
# half of the mirror actually ran.
|
|
330
|
+
_MIRROR_DELETE_FLOOR = 64
|
|
331
|
+
_MIRROR_DELETE_MAX_FRACTION = 0.25
|
|
332
|
+
|
|
333
|
+
# Destination-side generation snapshots: hardlink copies of the mirror's state,
|
|
334
|
+
# taken before each backup run overwrites it. See _snapshot_generation.
|
|
335
|
+
_GENERATIONS_SUBDIR = ".generations"
|
|
336
|
+
_GEN_KEEP_RECENT = 7
|
|
337
|
+
_GEN_KEEP_MONTHS = 6
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _is_append_only_truth(rel: Path) -> bool:
|
|
341
|
+
"""True for truth files that only ever grow in normal operation: the per-thread
|
|
342
|
+
files and the curatorial event log. The cross-thread overlay snapshots and
|
|
343
|
+
``import_state.jsonl`` are full rewrites and may legitimately shrink."""
|
|
344
|
+
return (
|
|
345
|
+
rel.parts[:1] == ("threads",) or rel.name == "kg_events.jsonl"
|
|
346
|
+
) and rel.suffix == ".jsonl"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _atomic_copy(sp: Path, dp: Path, *, trim_to_newline: bool) -> None:
|
|
350
|
+
"""Copy ``sp`` over ``dp`` with no destructive window: the bytes land in a
|
|
351
|
+
same-directory temp file, fsynced, then an atomic rename publishes them — a
|
|
352
|
+
crash or disk-full mid-copy can never leave ``dp`` partial or destroy its
|
|
353
|
+
previous good copy. ``copy2`` under the hood, so mtime rides along and the
|
|
354
|
+
mirror's unchanged-skip keeps working.
|
|
355
|
+
|
|
356
|
+
``trim_to_newline`` (append-only truth files) drops an unterminated final
|
|
357
|
+
fragment from the copy: the mirror doesn't quiesce writers, so a copy racing
|
|
358
|
+
a live append can catch half a line — trimmed to the last newline, every
|
|
359
|
+
published backup copy is a clean, parseable prefix of its source, and the
|
|
360
|
+
full line arrives with the next run."""
|
|
361
|
+
import shutil
|
|
362
|
+
|
|
363
|
+
tmp = dp.parent / f".{dp.name}.tmp-{os.getpid()}"
|
|
364
|
+
try:
|
|
365
|
+
shutil.copy2(sp, tmp)
|
|
366
|
+
with open(tmp, "rb+") as fh:
|
|
367
|
+
size = fh.seek(0, os.SEEK_END)
|
|
368
|
+
if trim_to_newline and size:
|
|
369
|
+
fh.seek(-1, os.SEEK_END)
|
|
370
|
+
if fh.read(1) != b"\n":
|
|
371
|
+
pos, last_nl = size, -1
|
|
372
|
+
while pos > 0:
|
|
373
|
+
step = min(65536, pos)
|
|
374
|
+
fh.seek(pos - step)
|
|
375
|
+
idx = fh.read(step).rfind(b"\n")
|
|
376
|
+
if idx >= 0:
|
|
377
|
+
last_nl = pos - step + idx
|
|
378
|
+
break
|
|
379
|
+
pos -= step
|
|
380
|
+
fh.truncate(last_nl + 1)
|
|
381
|
+
os.fsync(fh.fileno())
|
|
382
|
+
os.replace(tmp, dp)
|
|
383
|
+
except BaseException:
|
|
384
|
+
tmp.unlink(missing_ok=True)
|
|
385
|
+
raise
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _mirror_dir(
|
|
389
|
+
src: Path, dest: Path, *, delete: bool = False, allow_shrink: bool = False
|
|
390
|
+
) -> dict:
|
|
391
|
+
"""Incrementally mirror ``src`` into ``dest`` (skip files unchanged by size +
|
|
392
|
+
mtime). Each file is published atomically (:func:`_atomic_copy`), preserving
|
|
393
|
+
mtime so a re-run copies only what changed — the truth dir is append-mostly,
|
|
394
|
+
so a periodic backup moves little.
|
|
395
|
+
|
|
396
|
+
``delete=True`` makes it a true mirror: destination files with no source
|
|
397
|
+
counterpart are removed (and emptied directories pruned). Without it the mirror
|
|
398
|
+
is additive, and a shard rebalance — which *moves* thread files to new paths —
|
|
399
|
+
leaves the backup holding both layouts. Deletion is bounded: when the planned
|
|
400
|
+
deletions exceed both the absolute floor and the fraction cap of the
|
|
401
|
+
destination's files, they are skipped (``deletions_skipped``) — the mirror
|
|
402
|
+
stays additive rather than letting a gutted source strip the backup.
|
|
403
|
+
|
|
404
|
+
**Re-homed twins are exempt from that bound.** A rebalance moves every thread
|
|
405
|
+
file at once, so the stale old-layout copies at the destination can vastly
|
|
406
|
+
exceed the cap — and skipping them forever leaves the backup double-sized and
|
|
407
|
+
holding records a restore must not see. A doomed ``threads/**`` file whose
|
|
408
|
+
thread has a file at the source's canonical shard depth — present at *both*
|
|
409
|
+
ends, with the destination's canonical copy at least as large as the stale
|
|
410
|
+
one — is provably superseded (the sweep moves/merges, never truncates), so it
|
|
411
|
+
is deleted (``rehomed_twins_deleted``) regardless of the cap. Everything else
|
|
412
|
+
stays capped.
|
|
413
|
+
|
|
414
|
+
**Shrink guard.** An append-only truth file (``threads/**``, ``kg_events.jsonl``)
|
|
415
|
+
whose source copy is *smaller* than its backup copy means the source lost data —
|
|
416
|
+
truncation, corruption, an accidental overwrite. Copying it would destroy the
|
|
417
|
+
last good copy, so the guard keeps the destination file and counts the skip
|
|
418
|
+
(``shrinks_skipped`` + sample); the trailing size check reports it as divergent
|
|
419
|
+
until it is investigated. ``allow_shrink=True`` is the deliberate override for
|
|
420
|
+
an understood re-emit (``rebuild_truth_from_store`` legitimately rewrites files
|
|
421
|
+
smaller)."""
|
|
422
|
+
from ._truth.jsonl_log import _fsync_dir
|
|
423
|
+
|
|
424
|
+
copied = total = deleted = skipped = shrinks = 0
|
|
425
|
+
shrink_sample: list[str] = []
|
|
426
|
+
synced_dirs: set[Path] = set()
|
|
427
|
+
for sp in src.rglob("*"):
|
|
428
|
+
if sp.is_dir():
|
|
429
|
+
continue
|
|
430
|
+
rel = sp.relative_to(src)
|
|
431
|
+
dp = dest / rel
|
|
432
|
+
if dp.exists():
|
|
433
|
+
ss, ds = sp.stat(), dp.stat()
|
|
434
|
+
if ss.st_size == ds.st_size and int(ss.st_mtime) <= int(ds.st_mtime):
|
|
435
|
+
continue
|
|
436
|
+
if (
|
|
437
|
+
not allow_shrink
|
|
438
|
+
and ss.st_size < ds.st_size
|
|
439
|
+
and _is_append_only_truth(rel)
|
|
440
|
+
):
|
|
441
|
+
shrinks += 1
|
|
442
|
+
if len(shrink_sample) < 10:
|
|
443
|
+
shrink_sample.append(str(rel))
|
|
444
|
+
continue
|
|
445
|
+
dp.parent.mkdir(parents=True, exist_ok=True)
|
|
446
|
+
_atomic_copy(sp, dp, trim_to_newline=_is_append_only_truth(rel))
|
|
447
|
+
copied += 1
|
|
448
|
+
total += dp.stat().st_size
|
|
449
|
+
synced_dirs.add(dp.parent)
|
|
450
|
+
for sd in synced_dirs:
|
|
451
|
+
_fsync_dir(sd) # the renames that published this pass's copies must stick
|
|
452
|
+
twins_deleted = 0
|
|
453
|
+
if delete:
|
|
454
|
+
# The generations subtree is destination-only state (hardlink snapshots
|
|
455
|
+
# of prior mirror runs) — never a deletion candidate, and never counted
|
|
456
|
+
# toward the deletion cap's denominator.
|
|
457
|
+
dest_files = [
|
|
458
|
+
dp for dp in dest.rglob("*")
|
|
459
|
+
if not dp.is_dir() and dp.relative_to(dest).parts[0] != _GENERATIONS_SUBDIR
|
|
460
|
+
]
|
|
461
|
+
doomed = [dp for dp in dest_files if not (src / dp.relative_to(dest)).exists()]
|
|
462
|
+
if doomed:
|
|
463
|
+
twins, doomed = _split_rehomed_twins(src, dest, doomed)
|
|
464
|
+
for dp in twins:
|
|
465
|
+
dp.unlink()
|
|
466
|
+
twins_deleted += 1
|
|
467
|
+
deleted += 1
|
|
468
|
+
limit = max(_MIRROR_DELETE_FLOOR, int(len(dest_files) * _MIRROR_DELETE_MAX_FRACTION))
|
|
469
|
+
if len(doomed) > limit:
|
|
470
|
+
skipped = len(doomed)
|
|
471
|
+
else:
|
|
472
|
+
for dp in doomed:
|
|
473
|
+
dp.unlink()
|
|
474
|
+
deleted += 1
|
|
475
|
+
if deleted:
|
|
476
|
+
for dp in sorted((p for p in dest.rglob("*") if p.is_dir()), reverse=True):
|
|
477
|
+
try:
|
|
478
|
+
dp.rmdir() # only succeeds when empty
|
|
479
|
+
except OSError:
|
|
480
|
+
pass
|
|
481
|
+
return {
|
|
482
|
+
"files_copied": copied,
|
|
483
|
+
"bytes_copied": total,
|
|
484
|
+
"files_deleted": deleted,
|
|
485
|
+
"rehomed_twins_deleted": twins_deleted,
|
|
486
|
+
"deletions_skipped": skipped,
|
|
487
|
+
"shrinks_skipped": shrinks,
|
|
488
|
+
"shrink_sample": shrink_sample,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _split_rehomed_twins(src: Path, dest: Path, doomed: list[Path]) -> tuple[list[Path], list[Path]]:
|
|
493
|
+
"""Partition planned mirror deletions into provably-superseded rebalance
|
|
494
|
+
twins and everything else (see :func:`_mirror_dir`). A twin qualifies only
|
|
495
|
+
when its thread's canonical-depth file exists at the source *and* the
|
|
496
|
+
destination's copy of that canonical file is at least as large as the stale
|
|
497
|
+
one — the rebalance sweep moves/merges whole files, so a genuine re-home can
|
|
498
|
+
never leave the canonical copy smaller."""
|
|
499
|
+
from ._truth.jsonl_log import THREADS_SUBDIR, _shard_depth, _thread_relpath
|
|
500
|
+
|
|
501
|
+
depth = _shard_depth(src)
|
|
502
|
+
twins: list[Path] = []
|
|
503
|
+
rest: list[Path] = []
|
|
504
|
+
for dp in doomed:
|
|
505
|
+
rel = dp.relative_to(dest)
|
|
506
|
+
if rel.parts[0] == THREADS_SUBDIR and dp.suffix == ".jsonl":
|
|
507
|
+
try:
|
|
508
|
+
tid = int(dp.stem)
|
|
509
|
+
except ValueError:
|
|
510
|
+
tid = None
|
|
511
|
+
if tid is not None:
|
|
512
|
+
canonical = _thread_relpath(tid, depth)
|
|
513
|
+
try:
|
|
514
|
+
if (
|
|
515
|
+
canonical != rel
|
|
516
|
+
and (src / canonical).exists()
|
|
517
|
+
and (dest / canonical).stat().st_size >= dp.stat().st_size
|
|
518
|
+
):
|
|
519
|
+
twins.append(dp)
|
|
520
|
+
continue
|
|
521
|
+
except OSError: # canonical dest copy missing/unreadable — stay capped
|
|
522
|
+
pass
|
|
523
|
+
rest.append(dp)
|
|
524
|
+
return twins, rest
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _snapshot_generation(dest: Path) -> dict:
|
|
528
|
+
"""Hardlink-snapshot the mirror's current state into
|
|
529
|
+
``<dest>/.generations/<UTC stamp>/`` — taken *before* the mirror run
|
|
530
|
+
overwrites it, so each generation is the destination as the previous run
|
|
531
|
+
left it. The rolling mirror alone propagates destruction: same-size
|
|
532
|
+
corruption, a mistaken ``--allow-shrink``, or a bad repair overwrites the
|
|
533
|
+
only other copy on the next nightly run. Generations make that recoverable.
|
|
534
|
+
|
|
535
|
+
Hardlinks make a generation nearly free (the truth is append-mostly, and
|
|
536
|
+
the mirror only ever publishes destination files via whole-file rename —
|
|
537
|
+
``_atomic_copy`` — so a snapshot's linked inodes are never mutated by later
|
|
538
|
+
runs; deletions just unlink the mirror's name). One generation per run —
|
|
539
|
+
every overwrite of the mirror has a pre-state snapshot, so a second (bad)
|
|
540
|
+
backup on the same day can't destroy the day's only pre-state. The
|
|
541
|
+
snapshot is built under a dot-tmp name and renamed into place (the gens
|
|
542
|
+
dir fsynced after), so a killed run never leaves a directory that looks
|
|
543
|
+
like a complete generation and a published one survives power loss.
|
|
544
|
+
|
|
545
|
+
Retention coalesces by day, never within one: every generation from the
|
|
546
|
+
newest ``_GEN_KEEP_RECENT`` distinct UTC days is kept (pruning a same-day
|
|
547
|
+
sibling would discard exactly the pre-bad-run state generations exist
|
|
548
|
+
for), plus the newest generation of each distinct month until
|
|
549
|
+
``_GEN_KEEP_MONTHS`` months are covered. Failure to snapshot degrades to
|
|
550
|
+
the pre-generations behavior (reported, never blocks the mirror itself)."""
|
|
551
|
+
import logging
|
|
552
|
+
import shutil
|
|
553
|
+
from datetime import datetime, timezone
|
|
554
|
+
|
|
555
|
+
out: dict = {"generation_created": None, "generations_pruned": 0}
|
|
556
|
+
if not (dest / "manifest.json").exists():
|
|
557
|
+
return out # first run: nothing at the destination to preserve
|
|
558
|
+
gens = dest / _GENERATIONS_SUBDIR
|
|
559
|
+
gens.mkdir(exist_ok=True)
|
|
560
|
+
for stale in gens.glob(".tmp-*"): # a killed snapshot's half-built tree
|
|
561
|
+
shutil.rmtree(stale, ignore_errors=True)
|
|
562
|
+
now = datetime.now(timezone.utc)
|
|
563
|
+
names = sorted((p.name for p in gens.iterdir() if p.is_dir()), reverse=True)
|
|
564
|
+
name = now.strftime("%Y%m%dT%H%M%SZ")
|
|
565
|
+
while name in names: # two runs within a second — still one gen per run
|
|
566
|
+
name += "x"
|
|
567
|
+
tmp = gens / f".tmp-{name}"
|
|
568
|
+
try:
|
|
569
|
+
linked = 0
|
|
570
|
+
for sp in dest.rglob("*"):
|
|
571
|
+
if sp.is_dir():
|
|
572
|
+
continue
|
|
573
|
+
rel = sp.relative_to(dest)
|
|
574
|
+
if rel.parts[0] == _GENERATIONS_SUBDIR:
|
|
575
|
+
continue
|
|
576
|
+
gp = tmp / rel
|
|
577
|
+
gp.parent.mkdir(parents=True, exist_ok=True)
|
|
578
|
+
try:
|
|
579
|
+
os.link(sp, gp)
|
|
580
|
+
except OSError: # filesystem without hardlinks — take the copy cost
|
|
581
|
+
shutil.copy2(sp, gp)
|
|
582
|
+
linked += 1
|
|
583
|
+
os.rename(tmp, gens / name)
|
|
584
|
+
from ._truth.jsonl_log import _fsync_dir
|
|
585
|
+
|
|
586
|
+
_fsync_dir(gens) # the publishing rename itself must survive power loss
|
|
587
|
+
out["generation_created"] = name
|
|
588
|
+
out["generation_files"] = linked
|
|
589
|
+
names.insert(0, name)
|
|
590
|
+
names.sort(reverse=True)
|
|
591
|
+
except OSError as e: # snapshot is protection, not the backup itself
|
|
592
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
593
|
+
out["generation_error"] = str(e)
|
|
594
|
+
logging.getLogger(__name__).exception("backup: generation snapshot failed")
|
|
595
|
+
# Coalesce by day, never within one (see docstring).
|
|
596
|
+
days: list[str] = []
|
|
597
|
+
for n in names: # newest first
|
|
598
|
+
if n[:8] not in days:
|
|
599
|
+
days.append(n[:8])
|
|
600
|
+
keep = {n for n in names if n[:8] in set(days[:_GEN_KEEP_RECENT])}
|
|
601
|
+
months = {n[:6] for n in keep}
|
|
602
|
+
for n in names:
|
|
603
|
+
if n not in keep and n[:6] not in months and len(months) < _GEN_KEEP_MONTHS:
|
|
604
|
+
keep.add(n)
|
|
605
|
+
months.add(n[:6])
|
|
606
|
+
for n in names:
|
|
607
|
+
if n not in keep:
|
|
608
|
+
shutil.rmtree(gens / n, ignore_errors=True)
|
|
609
|
+
out["generations_pruned"] += 1
|
|
610
|
+
out["generations_kept"] = len(keep)
|
|
611
|
+
return out
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def backup(
|
|
615
|
+
dest: str,
|
|
616
|
+
*,
|
|
617
|
+
home: Optional[str] = None,
|
|
618
|
+
allow_shrink: bool = False,
|
|
619
|
+
verify_first: bool = True,
|
|
620
|
+
) -> dict:
|
|
621
|
+
"""Back the archive up by mirroring its JSONL truth directory to ``dest``.
|
|
622
|
+
|
|
623
|
+
A ``cp``/``rsync`` of the truth dir *is* the backup (index.db is rebuildable from
|
|
624
|
+
it), so this checkpoints first — flushing the cross-thread overlays + any thread-
|
|
625
|
+
metadata updates so the on-disk truth is a complete restore set — then mirrors
|
|
626
|
+
``truth/`` into ``dest`` incrementally. Point ``dest`` at a *different disk /
|
|
627
|
+
machine*: for pre-retention history the truth log is the only copy.
|
|
628
|
+
|
|
629
|
+
The source is verified before it is mirrored (``verify_first``): a truth dir
|
|
630
|
+
that fails the shallow integrity check (parse errors, index ⊃ truth drift) is
|
|
631
|
+
still backed up — a flawed copy beats no copy — but *additively*: delete-sync
|
|
632
|
+
is disabled for the run so a sick source can't strip the last good backup, and
|
|
633
|
+
``verify_ok=False`` in the result flags the run for investigation.
|
|
634
|
+
|
|
635
|
+
The mirror holds the rebalance lock so a shard sweep can't move files under it
|
|
636
|
+
(which could otherwise leave a moved file in *neither* layout in the backup for
|
|
637
|
+
a whole cycle), and it finishes with a structural check — per-file size parity
|
|
638
|
+
of every source ``.jsonl`` against its destination copy (``mirror_complete``).
|
|
639
|
+
Live appends between the copy pass and the check make a file *larger* at the
|
|
640
|
+
source; that isn't a mirror failure, so the check tolerates dest ≤ src growth
|
|
641
|
+
on files it copied and only flags missing or divergent copies. Append-only
|
|
642
|
+
truth files are additionally shrink-guarded (see :func:`_mirror_dir`);
|
|
643
|
+
``allow_shrink=True`` overrides after a deliberate truth re-emit.
|
|
644
|
+
|
|
645
|
+
Before the mirror touches anything, the destination's current state is
|
|
646
|
+
preserved as a hardlink generation under ``<dest>/.generations/``
|
|
647
|
+
(:func:`_snapshot_generation`) — the recovery margin for destruction the
|
|
648
|
+
in-run guards can't see. ``archive restore-drill`` proves the mirror (or a
|
|
649
|
+
generation) actually restores.
|
|
650
|
+
"""
|
|
651
|
+
open_archive(home)
|
|
652
|
+
from ._truth import checkpoint as _checkpoint
|
|
653
|
+
from ._truth.jsonl_log import _truth_write_lock, _try_rebalance_lock
|
|
654
|
+
|
|
655
|
+
_checkpoint() # full: overlays + metadata-update backstop → truth is a complete restore set
|
|
656
|
+
# Verify AFTER the checkpoint, so verify_ok describes the tree the mirror
|
|
657
|
+
# actually copies — a verdict on the pre-checkpoint state could bless (or
|
|
658
|
+
# smear) a different truth than the one being backed up.
|
|
659
|
+
verify_ok = True
|
|
660
|
+
if verify_first:
|
|
661
|
+
verify_ok = bool(verify(home=home)["ok"])
|
|
662
|
+
|
|
663
|
+
paths = resolve_paths(home)
|
|
664
|
+
# Refresh the durable vector cache so live-embedded vectors (the watcher's
|
|
665
|
+
# cohost writes them into index.db only) survive an index loss and ride the
|
|
666
|
+
# mirror. No-op when the store holds no vectors — an empty save never
|
|
667
|
+
# replaces a populated sidecar.
|
|
668
|
+
from ._retrieval.vectors import save_vectors_sidecar
|
|
669
|
+
|
|
670
|
+
vectors_cached = save_vectors_sidecar(paths.truth_dir)
|
|
671
|
+
dest_path = Path(dest).expanduser()
|
|
672
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
673
|
+
# A destination on the same filesystem as the truth dir protects against a
|
|
674
|
+
# bad write, not against the disk: one device failure (or a stolen machine)
|
|
675
|
+
# takes source, mirror, and every hardlink generation together. Report-only
|
|
676
|
+
# — a same-disk mirror still beats none — but the flag rides the result and
|
|
677
|
+
# the health record so `archive status` and anything watching health.json
|
|
678
|
+
# can keep saying so until a real second copy exists.
|
|
679
|
+
try:
|
|
680
|
+
same_device = os.stat(dest_path).st_dev == os.stat(paths.truth_dir).st_dev
|
|
681
|
+
except OSError:
|
|
682
|
+
same_device = None
|
|
683
|
+
# Delete-sync only when the source looks like a real, checkpointed truth dir —
|
|
684
|
+
# a mirror of an empty/foreign source must never strip a good backup — and only
|
|
685
|
+
# when it verified clean (a sick source mirrors additively; see docstring).
|
|
686
|
+
delete = (paths.truth_dir / "manifest.json").exists() and verify_ok
|
|
687
|
+
# Preserve the destination's pre-run state as a hardlink generation BEFORE
|
|
688
|
+
# the mirror overwrites it — the recovery margin for anything the guards
|
|
689
|
+
# can't see (same-size corruption, a mistaken --allow-shrink, a bad repair).
|
|
690
|
+
generations = _snapshot_generation(dest_path)
|
|
691
|
+
with _try_rebalance_lock() as held:
|
|
692
|
+
# If a rebalance sweep is mid-flight, mirror additively (no deletions):
|
|
693
|
+
# copies of both layouts are safe; stale-path deletion waits for the next run.
|
|
694
|
+
#
|
|
695
|
+
# The truth-write mutex is held for the whole traversal so the mirror is a
|
|
696
|
+
# drain-consistent snapshot: no append batch can land in (or be rolled back
|
|
697
|
+
# out of) a truth file between the mirror reading one file and the next.
|
|
698
|
+
# Without it the copy can capture a mid-drain partial batch — one thread's
|
|
699
|
+
# file with a transaction's rows and another's without — or a pre-rollback
|
|
700
|
+
# append that the drain then truncates away, which the shrink guard would
|
|
701
|
+
# afterwards pin in the mirror as a divergent file. Writers pause for the
|
|
702
|
+
# traversal; incremental runs copy little, so the pause is brief.
|
|
703
|
+
with _truth_write_lock():
|
|
704
|
+
result = _mirror_dir(
|
|
705
|
+
paths.truth_dir, dest_path, delete=delete and held, allow_shrink=allow_shrink
|
|
706
|
+
)
|
|
707
|
+
result.update(generations)
|
|
708
|
+
|
|
709
|
+
# Structural completeness: every source .jsonl must exist at the destination,
|
|
710
|
+
# at ≥ its size at copy time (append-only files may have grown since).
|
|
711
|
+
missing = divergent = 0
|
|
712
|
+
for sp in paths.truth_dir.rglob("*.jsonl"):
|
|
713
|
+
dp = dest_path / sp.relative_to(paths.truth_dir)
|
|
714
|
+
try:
|
|
715
|
+
ds = dp.stat()
|
|
716
|
+
except OSError:
|
|
717
|
+
missing += 1
|
|
718
|
+
continue
|
|
719
|
+
if ds.st_size > sp.stat().st_size:
|
|
720
|
+
divergent += 1 # dest larger than source: divergent copy, not growth
|
|
721
|
+
result["mirror_complete"] = missing == 0 and divergent == 0
|
|
722
|
+
result["dest_missing_files"] = missing
|
|
723
|
+
result["dest_divergent_files"] = divergent
|
|
724
|
+
|
|
725
|
+
# Record the outcome in the home's health file (surfaced by `archive status`):
|
|
726
|
+
# a backup agent that quietly stops running is indistinguishable from a
|
|
727
|
+
# healthy one by its log files alone — the record's age is the signal.
|
|
728
|
+
_record_health("backup_last", {
|
|
729
|
+
"dest": str(dest_path),
|
|
730
|
+
"ok": bool(verify_ok and result["mirror_complete"] and not result["deletions_skipped"]),
|
|
731
|
+
"verify_ok": verify_ok,
|
|
732
|
+
"mirror_complete": result["mirror_complete"],
|
|
733
|
+
"files_copied": result["files_copied"],
|
|
734
|
+
"same_device": same_device,
|
|
735
|
+
})
|
|
736
|
+
_stamp_heartbeat()
|
|
737
|
+
return {
|
|
738
|
+
"truth_dir": str(paths.truth_dir),
|
|
739
|
+
"dest": str(dest_path),
|
|
740
|
+
"vectors_cached": vectors_cached,
|
|
741
|
+
"verify_ok": verify_ok,
|
|
742
|
+
"same_device": same_device,
|
|
743
|
+
**result,
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def restore_drill(
|
|
748
|
+
dest: str, *, home: Optional[str] = None, keep_home: bool = False
|
|
749
|
+
) -> dict:
|
|
750
|
+
"""Prove the backup actually restores: rebuild a full index from the mirror
|
|
751
|
+
in a throwaway home and check what materialized against the mirror's own scan.
|
|
752
|
+
|
|
753
|
+
``verify --backup`` parses and counts the mirror; this is the missing last
|
|
754
|
+
step — an end-to-end rehearsal of the recovery path (copy the truth dir,
|
|
755
|
+
``reindex`` from it: schema, per-thread load, kg replay, FTS, the vector
|
|
756
|
+
sidecar restore, quick_check). ``ok`` means the mirror parsed clean, the
|
|
757
|
+
rebuilt index materialized exactly the mirror's effective counts, the
|
|
758
|
+
restored archive covers the live one (``coverage`` = rebuilt events / live
|
|
759
|
+
events ≥ 0.98 — the mirror is minutes old when the scheduled drill runs, so
|
|
760
|
+
materially lower means the backup restores to less than the archive it is
|
|
761
|
+
supposed to protect), and a smoke pass (:func:`_drill_smoke`) proved the
|
|
762
|
+
rebuilt archive actually *reads and searches*, not just materializes.
|
|
763
|
+
Heavy (a full index build) — sized for the nightly 04:00 window, where
|
|
764
|
+
``archive nightly`` runs it after every backup.
|
|
765
|
+
|
|
766
|
+
The drill home is a temp directory (``keep_home=True`` keeps it for
|
|
767
|
+
inspection, e.g. to point a reader at the restored index); the live archive
|
|
768
|
+
is reopened before returning, and the outcome lands in ``health.json``
|
|
769
|
+
(``restore_drill_last``) so a drill that stops running looks stale."""
|
|
770
|
+
import shutil
|
|
771
|
+
import tempfile
|
|
772
|
+
import time
|
|
773
|
+
|
|
774
|
+
paths = open_archive(home)
|
|
775
|
+
from ._truth import scan_truth_counts
|
|
776
|
+
|
|
777
|
+
dest_path = Path(dest).expanduser()
|
|
778
|
+
if not (dest_path / "threads").exists():
|
|
779
|
+
return {"dest": str(dest_path), "ok": False, "error": "not a truth mirror"}
|
|
780
|
+
live_home = str(paths.home)
|
|
781
|
+
started = time.monotonic()
|
|
782
|
+
from sqlalchemy import func, select
|
|
783
|
+
|
|
784
|
+
from ._store import Event, get_session
|
|
785
|
+
|
|
786
|
+
with get_session() as s:
|
|
787
|
+
live_events = s.execute(select(func.count()).select_from(Event)).scalar() or 0
|
|
788
|
+
scan = scan_truth_counts(truth_dir=dest_path)
|
|
789
|
+
result: dict = {"dest": str(dest_path), "mirror": scan, "live_events": int(live_events)}
|
|
790
|
+
drill_home = Path(tempfile.mkdtemp(prefix="thread-archive-restore-drill-"))
|
|
791
|
+
try:
|
|
792
|
+
# The generations subtree and any half-published mirror temp files are
|
|
793
|
+
# destination bookkeeping, not truth — the drill restores the mirror.
|
|
794
|
+
shutil.copytree(
|
|
795
|
+
dest_path, drill_home / "truth",
|
|
796
|
+
ignore=shutil.ignore_patterns(_GENERATIONS_SUBDIR, ".*.tmp-*"),
|
|
797
|
+
)
|
|
798
|
+
open_archive(str(drill_home))
|
|
799
|
+
from ._truth import reindex as _reindex
|
|
800
|
+
|
|
801
|
+
try:
|
|
802
|
+
counts = _reindex()
|
|
803
|
+
except RuntimeError as e: # a refused/failed rebuild IS the drill's finding
|
|
804
|
+
result.update({"ok": False, "error": str(e)})
|
|
805
|
+
counts = None
|
|
806
|
+
if counts is not None:
|
|
807
|
+
result["rebuilt"] = counts
|
|
808
|
+
result["coverage"] = round(counts["events"] / (live_events or 1), 6)
|
|
809
|
+
result["smoke"] = _drill_smoke(
|
|
810
|
+
str(drill_home), expect_content=counts["events"] > 0
|
|
811
|
+
)
|
|
812
|
+
result["ok"] = (
|
|
813
|
+
scan["parse_errors"] == 0
|
|
814
|
+
and counts["events"] == scan["events_effective"]
|
|
815
|
+
and counts["threads"] == scan["threads"]
|
|
816
|
+
and result["coverage"] >= 0.98
|
|
817
|
+
and result["smoke"]["ok"]
|
|
818
|
+
)
|
|
819
|
+
finally:
|
|
820
|
+
close()
|
|
821
|
+
if keep_home:
|
|
822
|
+
result["drill_home"] = str(drill_home)
|
|
823
|
+
else:
|
|
824
|
+
shutil.rmtree(drill_home, ignore_errors=True)
|
|
825
|
+
open_archive(live_home)
|
|
826
|
+
result["seconds"] = round(time.monotonic() - started, 1)
|
|
827
|
+
_record_health("restore_drill_last", {
|
|
828
|
+
"dest": str(dest_path),
|
|
829
|
+
"ok": bool(result.get("ok")),
|
|
830
|
+
"events": scan["events_effective"],
|
|
831
|
+
"coverage": result.get("coverage"),
|
|
832
|
+
"seconds": result["seconds"],
|
|
833
|
+
})
|
|
834
|
+
_stamp_heartbeat()
|
|
835
|
+
return result
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
# Age gates for the escalated verify tiers `nightly` folds in on top of its
|
|
839
|
+
# nightly backup + shallow verify + restore drill.
|
|
840
|
+
_DEEP_EVERY_DAYS = 7
|
|
841
|
+
_HASHES_EVERY_DAYS = 30
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _health_is_due(key: str, every_days: float) -> bool:
|
|
845
|
+
"""True when health record ``key`` is missing, unparseable, stale, or was
|
|
846
|
+
not ok — the age gate that replaces weekday/day-of-month schedule math: a
|
|
847
|
+
missed (machine off) or failed escalated pass makes the *next* nightly run
|
|
848
|
+
pick it up, instead of waiting for the calendar to come around again."""
|
|
849
|
+
from datetime import datetime, timezone
|
|
850
|
+
|
|
851
|
+
rec = _read_health().get(key)
|
|
852
|
+
try:
|
|
853
|
+
at = datetime.fromisoformat(rec["at"])
|
|
854
|
+
except (TypeError, KeyError, ValueError):
|
|
855
|
+
return True
|
|
856
|
+
if not rec.get("ok"):
|
|
857
|
+
return True
|
|
858
|
+
if at.tzinfo is None:
|
|
859
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
860
|
+
return (datetime.now(timezone.utc) - at).total_seconds() >= every_days * 86400
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# Each pipeline stage, mapped to the health record that a later, out-of-band run
|
|
864
|
+
# of that same stage writes. This is what lets a stage's failure be *retired* by
|
|
865
|
+
# evidence rather than only by another full nightly.
|
|
866
|
+
_STAGE_RECORD = {
|
|
867
|
+
"backup": "backup_last",
|
|
868
|
+
"verify": "verify_last",
|
|
869
|
+
"restore-drill": "restore_drill_last",
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _parse_at(value: object) -> Optional[float]:
|
|
874
|
+
"""Epoch seconds from a health/heartbeat ``at`` stamp; None if absent or bad."""
|
|
875
|
+
from datetime import datetime, timezone
|
|
876
|
+
|
|
877
|
+
if not isinstance(value, str):
|
|
878
|
+
return None
|
|
879
|
+
try:
|
|
880
|
+
at = datetime.fromisoformat(value)
|
|
881
|
+
except ValueError:
|
|
882
|
+
return None
|
|
883
|
+
if at.tzinfo is None:
|
|
884
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
885
|
+
return at.timestamp()
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _stage_recovered(stage: str, nightly: dict, health: dict) -> bool:
|
|
889
|
+
"""Has ``stage`` — which failed in the last nightly — since been proven good?
|
|
890
|
+
|
|
891
|
+
True only when that stage's own health record is green, **postdates** the
|
|
892
|
+
nightly that failed, and came from a check **at least as strong** as the one
|
|
893
|
+
that failed.
|
|
894
|
+
|
|
895
|
+
Strength is what makes this safe, and it matters for verify alone: the
|
|
896
|
+
nightly escalates verify on age gates (``deep``, ``hashes``, and the mirror
|
|
897
|
+
parse-scan, which rides ``--backup`` exactly when deep is due). A later
|
|
898
|
+
*basic* verify passing says nothing about a deep tier that failed, so
|
|
899
|
+
clearing on it would be a false green — the cheap check laundering the
|
|
900
|
+
expensive red. Requiring ≥ strength is the whole guard; without it this
|
|
901
|
+
function is a bug, not a feature.
|
|
902
|
+
"""
|
|
903
|
+
rec = health.get(_STAGE_RECORD.get(stage, ""))
|
|
904
|
+
if not isinstance(rec, dict) or not rec.get("ok"):
|
|
905
|
+
return False
|
|
906
|
+
when, nightly_at = _parse_at(rec.get("at")), _parse_at(nightly.get("at"))
|
|
907
|
+
if when is None or nightly_at is None or when <= nightly_at:
|
|
908
|
+
return False
|
|
909
|
+
if stage != "verify":
|
|
910
|
+
return True
|
|
911
|
+
# The nightly's verify parse-scans the mirror exactly when its deep tier is
|
|
912
|
+
# due, so `deep` gates the backup-scan requirement as well as its own.
|
|
913
|
+
deep = bool(nightly.get("deep"))
|
|
914
|
+
for tier, required in (("deep", deep), ("hashes", bool(nightly.get("hashes"))),
|
|
915
|
+
("backup", deep)):
|
|
916
|
+
if required and not rec.get(tier):
|
|
917
|
+
return False
|
|
918
|
+
return True
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _pipeline_verdict(health: Optional[dict] = None) -> dict:
|
|
922
|
+
"""The pipeline's state **now** — not merely a transcript of the last nightly.
|
|
923
|
+
|
|
924
|
+
The last nightly's failed stages, minus every stage a later at-least-as-strong
|
|
925
|
+
run has since proven good.
|
|
926
|
+
|
|
927
|
+
Without this, the *only* thing that can retire a fault is another full nightly
|
|
928
|
+
(~1h, restore-drill dominated). An operator who fixes the cause and proves it
|
|
929
|
+
fixed — at a stronger tier than the one that failed — still faces a board
|
|
930
|
+
asserting the archive is unprotected until 04:00 comes around. That is a stale
|
|
931
|
+
alarm on the one signal that says whether Ella's memory is recoverable, and a
|
|
932
|
+
signal that keeps crying after the fire is out is one that stops being read.
|
|
933
|
+
"""
|
|
934
|
+
health = _read_health() if health is None else health
|
|
935
|
+
nightly = health.get("nightly_last") or {}
|
|
936
|
+
failed = [s for s in (nightly.get("failed_stages") or []) if isinstance(s, str)]
|
|
937
|
+
recovered = [s for s in failed if _stage_recovered(s, nightly, health)]
|
|
938
|
+
unresolved = [s for s in failed if s not in recovered]
|
|
939
|
+
return {
|
|
940
|
+
"ran": bool(nightly),
|
|
941
|
+
"ok": not unresolved,
|
|
942
|
+
"failed_stages": unresolved,
|
|
943
|
+
"recovered_stages": recovered,
|
|
944
|
+
"nightly_at": nightly.get("at"),
|
|
945
|
+
"dest": nightly.get("dest"),
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _stamp_heartbeat() -> None:
|
|
950
|
+
"""Publish the pipeline verdict to the family-monitor heartbeat.
|
|
951
|
+
|
|
952
|
+
thread-monitor freshness-checks periodic jobs via ``~/.thread/logs`` (the
|
|
953
|
+
shared heartbeat ground — it never reads sibling products' private stores, so
|
|
954
|
+
health.json alone is invisible to it). This file is therefore the entire
|
|
955
|
+
contract, and it carries the two independent facts the monitor needs:
|
|
956
|
+
|
|
957
|
+
- ``nightly_at`` — when the nightly last *ran*. The monitor anchors its
|
|
958
|
+
staleness check on this field, **not** on the file's mtime, because every
|
|
959
|
+
out-of-band stage run below rewrites the file: mtime would report "a run
|
|
960
|
+
happened" on a box whose nightly job has been dead for a week.
|
|
961
|
+
- ``ok`` / ``failed_stages`` — the verdict with recovered stages retired, so a
|
|
962
|
+
proven out-of-band fix clears the board without waiting out another pipeline.
|
|
963
|
+
|
|
964
|
+
Stamped on every nightly completion whatever the outcome, and again whenever a
|
|
965
|
+
stage is re-run on its own. Fail-soft, and skipped entirely when the dir does
|
|
966
|
+
not exist (an install outside the thread family has no monitor to feed) or when
|
|
967
|
+
no nightly has ever run (the monitor reads an absent heartbeat as "awaiting
|
|
968
|
+
first run" — a lone stage run must not pre-empt that). The env override exists
|
|
969
|
+
so tests never stamp the real box's heartbeat.
|
|
970
|
+
"""
|
|
971
|
+
from datetime import datetime, timezone
|
|
972
|
+
|
|
973
|
+
hb_dir = Path(
|
|
974
|
+
os.environ.get("THREAD_ARCHIVE_HEARTBEAT_DIR")
|
|
975
|
+
or Path.home() / ".thread" / "logs"
|
|
976
|
+
)
|
|
977
|
+
if not hb_dir.is_dir():
|
|
978
|
+
return
|
|
979
|
+
verdict = _pipeline_verdict()
|
|
980
|
+
if not verdict["ran"]:
|
|
981
|
+
return
|
|
982
|
+
try:
|
|
983
|
+
(hb_dir / "archive-nightly.heartbeat").write_text(
|
|
984
|
+
json.dumps({
|
|
985
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
986
|
+
"nightly_at": verdict["nightly_at"],
|
|
987
|
+
"ok": verdict["ok"],
|
|
988
|
+
"failed_stages": verdict["failed_stages"],
|
|
989
|
+
"recovered_stages": verdict["recovered_stages"],
|
|
990
|
+
"dest": verdict["dest"],
|
|
991
|
+
}) + "\n",
|
|
992
|
+
encoding="utf-8",
|
|
993
|
+
)
|
|
994
|
+
except OSError:
|
|
995
|
+
pass
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _notify(url: str, message: str) -> Optional[str]:
|
|
999
|
+
"""POST a notification (lab's ``/api/notify`` shape: ``{title, message}``).
|
|
1000
|
+
Fail-soft — returns an error string instead of raising: health.json and the
|
|
1001
|
+
job log are the durable record; the push is best-effort."""
|
|
1002
|
+
import urllib.request
|
|
1003
|
+
|
|
1004
|
+
body = json.dumps({"title": "thread-archive", "message": message}).encode()
|
|
1005
|
+
req = urllib.request.Request(
|
|
1006
|
+
url, data=body, headers={"Content-Type": "application/json"}
|
|
1007
|
+
)
|
|
1008
|
+
try:
|
|
1009
|
+
with urllib.request.urlopen(req, timeout=5):
|
|
1010
|
+
return None
|
|
1011
|
+
except Exception as e:
|
|
1012
|
+
return f"{type(e).__name__}: {e}"
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def nightly(
|
|
1016
|
+
dest: str,
|
|
1017
|
+
*,
|
|
1018
|
+
home: Optional[str] = None,
|
|
1019
|
+
notify_url: Optional[str] = None,
|
|
1020
|
+
allow_shrink: bool = False,
|
|
1021
|
+
drill: bool = True,
|
|
1022
|
+
) -> dict:
|
|
1023
|
+
"""The scheduled protection pipeline, as one command: ``backup`` → ``verify``
|
|
1024
|
+
(with age-gated escalation) → ``restore_drill`` — every night.
|
|
1025
|
+
|
|
1026
|
+
Replaces a shell chain of the three commands. The differences that matter:
|
|
1027
|
+
|
|
1028
|
+
- **Every stage runs** (no ``&&`` short-circuit): a failed backup must not
|
|
1029
|
+
also cost the night's integrity check and drill — each stage's outcome is
|
|
1030
|
+
recorded separately (its own health.json record plus ``nightly_last``),
|
|
1031
|
+
so an alert can say *which* stage broke while the others' green stays
|
|
1032
|
+
visible.
|
|
1033
|
+
- **Escalation is age-gated, not calendar-gated**: the deep verify (+ the
|
|
1034
|
+
mirror parse-scan) folds in when ``verify_deep_last`` is missing, older
|
|
1035
|
+
than ``_DEEP_EVERY_DAYS``, or failed; ``--hashes`` likewise on
|
|
1036
|
+
``_HASHES_EVERY_DAYS``. A machine that was off on the scheduled day runs
|
|
1037
|
+
the escalated pass on its next nightly instead of a month later.
|
|
1038
|
+
- **The drill is nightly.** The restore path is code and the code changes
|
|
1039
|
+
daily; a restore-path regression must surface the next morning, not up
|
|
1040
|
+
to a month later. ~10 min of nice'd 4 a.m. work at current size.
|
|
1041
|
+
- **Failure notifies** (``notify_url``, lab's ``/api/notify`` shape) with
|
|
1042
|
+
the failed stage names. The "never ran at all" case is the monitor's to
|
|
1043
|
+
catch, from the staleness of the health.json records this writes.
|
|
1044
|
+
|
|
1045
|
+
Returns per-stage results plus ``ok`` / ``failed_stages``.
|
|
1046
|
+
"""
|
|
1047
|
+
open_archive(home)
|
|
1048
|
+
failed: list[str] = []
|
|
1049
|
+
result: dict = {"dest": str(Path(dest).expanduser())}
|
|
1050
|
+
|
|
1051
|
+
try:
|
|
1052
|
+
b = backup(dest, home=home, allow_shrink=allow_shrink)
|
|
1053
|
+
backup_ok = bool(
|
|
1054
|
+
b["verify_ok"] and b["mirror_complete"] and not b["deletions_skipped"]
|
|
1055
|
+
)
|
|
1056
|
+
except Exception as e:
|
|
1057
|
+
b, backup_ok = {"error": f"{type(e).__name__}: {e}"}, False
|
|
1058
|
+
result["backup"] = b
|
|
1059
|
+
if not backup_ok:
|
|
1060
|
+
failed.append("backup")
|
|
1061
|
+
|
|
1062
|
+
deep_due = _health_is_due("verify_deep_last", _DEEP_EVERY_DAYS)
|
|
1063
|
+
hashes_due = _health_is_due("verify_hashes_last", _HASHES_EVERY_DAYS)
|
|
1064
|
+
result["escalations"] = {"deep": deep_due, "hashes": hashes_due}
|
|
1065
|
+
try:
|
|
1066
|
+
v = verify(
|
|
1067
|
+
home=home, deep=deep_due, hashes=hashes_due,
|
|
1068
|
+
backup=str(dest) if deep_due else None,
|
|
1069
|
+
)
|
|
1070
|
+
verify_ok = bool(v["ok"])
|
|
1071
|
+
except Exception as e:
|
|
1072
|
+
v, verify_ok = {"error": f"{type(e).__name__}: {e}"}, False
|
|
1073
|
+
result["verify"] = v
|
|
1074
|
+
if not verify_ok:
|
|
1075
|
+
failed.append("verify")
|
|
1076
|
+
|
|
1077
|
+
if drill:
|
|
1078
|
+
try:
|
|
1079
|
+
d = restore_drill(dest, home=home)
|
|
1080
|
+
drill_ok = bool(d.get("ok"))
|
|
1081
|
+
except Exception as e:
|
|
1082
|
+
d, drill_ok = {"error": f"{type(e).__name__}: {e}"}, False
|
|
1083
|
+
result["drill"] = d
|
|
1084
|
+
if not drill_ok:
|
|
1085
|
+
failed.append("restore-drill")
|
|
1086
|
+
|
|
1087
|
+
result["ok"] = not failed
|
|
1088
|
+
result["failed_stages"] = failed
|
|
1089
|
+
_record_health("nightly_last", {
|
|
1090
|
+
"dest": result["dest"],
|
|
1091
|
+
"ok": result["ok"],
|
|
1092
|
+
"failed_stages": failed,
|
|
1093
|
+
"deep": deep_due,
|
|
1094
|
+
"hashes": hashes_due,
|
|
1095
|
+
"drill": drill,
|
|
1096
|
+
})
|
|
1097
|
+
# Publish the verdict to the family-monitor heartbeat (see _stamp_heartbeat):
|
|
1098
|
+
# stamped on every completion whatever the outcome, and the `nightly_last`
|
|
1099
|
+
# record written just above is what it anchors its freshness field on.
|
|
1100
|
+
_stamp_heartbeat()
|
|
1101
|
+
if failed and notify_url:
|
|
1102
|
+
result["notify_error"] = _notify(
|
|
1103
|
+
notify_url,
|
|
1104
|
+
f"nightly backup pipeline FAILED at: {', '.join(failed)} — "
|
|
1105
|
+
"see `archive status`, health.json, and ~/.thread/archive/logs/backup-*.log",
|
|
1106
|
+
)
|
|
1107
|
+
return result
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _drill_smoke(home: str, *, expect_content: bool) -> dict:
|
|
1111
|
+
"""Exercise the restored archive the way a reader would — the last gap
|
|
1112
|
+
between "the index materialized" and "the archive is usable." Reads the
|
|
1113
|
+
newest indexed thread and searches for a token drawn from the FTS shadow's
|
|
1114
|
+
own stored content (the exact corpus ``event_search`` matches against, so a
|
|
1115
|
+
hit is guaranteed when the search surface works). A rebuilt index whose
|
|
1116
|
+
read path or search surface is broken must fail the drill here, not on the
|
|
1117
|
+
first real restore. Runs against the drill home while it is still open."""
|
|
1118
|
+
import re as _re
|
|
1119
|
+
|
|
1120
|
+
from sqlalchemy import text as _sa_text
|
|
1121
|
+
|
|
1122
|
+
from ._store import get_session as _get_session
|
|
1123
|
+
|
|
1124
|
+
out: dict = {"ok": not expect_content, "read_ok": False, "search_ok": False}
|
|
1125
|
+
if not expect_content:
|
|
1126
|
+
return out
|
|
1127
|
+
try:
|
|
1128
|
+
with _get_session() as s:
|
|
1129
|
+
rows = s.execute(_sa_text(
|
|
1130
|
+
"SELECT thread_id, content FROM events_fts "
|
|
1131
|
+
"WHERE content != '' ORDER BY event_id DESC LIMIT 50"
|
|
1132
|
+
)).fetchall()
|
|
1133
|
+
tid = token = None
|
|
1134
|
+
for thread_id, content in rows:
|
|
1135
|
+
token = next(iter(_re.findall(r"[A-Za-z]{4,}", content or "")), None)
|
|
1136
|
+
if token:
|
|
1137
|
+
tid = thread_id
|
|
1138
|
+
break
|
|
1139
|
+
if tid is None:
|
|
1140
|
+
# No sampleable token (empty FTS surface would already fail the
|
|
1141
|
+
# count checks; all-non-Latin content just isn't sampleable here).
|
|
1142
|
+
out["ok"] = True
|
|
1143
|
+
out["skipped"] = "no sampleable token in the newest FTS rows"
|
|
1144
|
+
return out
|
|
1145
|
+
text = read_thread(tid, home=home, mode="chat", limit=20)
|
|
1146
|
+
out["read_ok"] = bool(text and text.strip())
|
|
1147
|
+
out["token"] = token
|
|
1148
|
+
out["search_ok"] = bool(search(token, home=home, limit=5, rerank=False))
|
|
1149
|
+
out["ok"] = out["read_ok"] and out["search_ok"]
|
|
1150
|
+
except Exception as e: # a crash in the read/search path IS the finding
|
|
1151
|
+
out["error"] = f"{type(e).__name__}: {e}"
|
|
1152
|
+
out["ok"] = False
|
|
1153
|
+
return out
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def verify(
|
|
1157
|
+
*,
|
|
1158
|
+
home: Optional[str] = None,
|
|
1159
|
+
deep: bool = False,
|
|
1160
|
+
hashes: bool = False,
|
|
1161
|
+
backup: Optional[str] = None,
|
|
1162
|
+
) -> dict:
|
|
1163
|
+
"""Integrity check: the JSONL truth parses cleanly and matches the SQLite index.
|
|
1164
|
+
|
|
1165
|
+
Scans every per-thread truth file and compares to the projection's counts.
|
|
1166
|
+
The index is compared against ``events_effective`` — the truth's line count
|
|
1167
|
+
after collapsing superseded lines (re-appended ids, same-content twins), which
|
|
1168
|
+
is exactly what a reindex materializes; the raw line count and the superseded
|
|
1169
|
+
remainder are reported alongside. The curatorial log gets the same daily
|
|
1170
|
+
treatment: ``kg_events.jsonl`` is parse-scanned and its distinct-id count
|
|
1171
|
+
compared to the ``kg_events`` table below a kg watermark. ``ok`` is True only
|
|
1172
|
+
when the effective counts align (events, threads, and kg events), nothing
|
|
1173
|
+
failed to parse, the search surface is in parity (shadow ↔
|
|
1174
|
+
FTS5 row counts match with no orphan rows — silently unsearchable content is
|
|
1175
|
+
loss in effect, so it's checked on this daily cadence too), and the live
|
|
1176
|
+
index carries the full declared schema (:func:`_verify_schema` — ``create_all``
|
|
1177
|
+
never retrofits columns/indexes/constraints onto existing tables, so an
|
|
1178
|
+
under-enforced index must be seen and reindexed). A negative event
|
|
1179
|
+
drift (truth > index) is the *safe* direction — ``archive reindex`` rebuilds
|
|
1180
|
+
the index from truth; a positive drift (index > truth) or any parse error is a
|
|
1181
|
+
real integrity problem. Parse errors split into ``parse_errors_torn_tail``
|
|
1182
|
+
(the residue of a crash mid-append) and ``parse_errors_interior``; either kind
|
|
1183
|
+
is cleared by ``archive repair``, which quarantines the damaged lines and
|
|
1184
|
+
restores any committed content they shadowed from the index.
|
|
1185
|
+
|
|
1186
|
+
**A red verify names its cause and keeps its evidence.** The result carries
|
|
1187
|
+
``failed_components`` (exactly which checks failed — ``ok`` is derived from
|
|
1188
|
+
it), the health record carries the same list, and the *full* result of any
|
|
1189
|
+
failing run is appended to ``<home>/verify-failures.jsonl`` — samples
|
|
1190
|
+
included — so a red observed hours later is diagnosable from what it wrote,
|
|
1191
|
+
not from re-running an expensive pass against a store that has moved on.
|
|
1192
|
+
|
|
1193
|
+
Every run records its outcome (``verify_last``: timestamp, ok, drift, the
|
|
1194
|
+
failed components) in ``<home>/health.json``, surfaced by ``archive
|
|
1195
|
+
status`` — an integrity check that silently stops running must look stale,
|
|
1196
|
+
not healthy. The escalated tiers' records (``verify_deep_last`` /
|
|
1197
|
+
``verify_hashes_last``) carry each tier's *own* verdict, so the nightly age
|
|
1198
|
+
gate re-runs the tier that actually failed rather than both.
|
|
1199
|
+
|
|
1200
|
+
``deep=True`` adds an id-level comparison (both directions, below a stable id
|
|
1201
|
+
watermark so in-flight ingest can't false-alarm), dedup_key parity for ids on
|
|
1202
|
+
both sides, knowledge-layer parity, and dangling-reference checks. Slower — it
|
|
1203
|
+
re-reads the whole truth directory (twice) and queries the index per thread —
|
|
1204
|
+
but it sees what count parity can't: missing content masked by compensating
|
|
1205
|
+
errors, and exactly which events drifted.
|
|
1206
|
+
|
|
1207
|
+
``hashes=True`` adds content-level self-validation on both stores: each
|
|
1208
|
+
event's ``dedup_key`` ends in a hash of its payload's semantic content, so
|
|
1209
|
+
re-hashing the stored payload and comparing detects silent payload corruption
|
|
1210
|
+
(bit rot, a bad write) with no extra state. The hash covers only the payload's
|
|
1211
|
+
*semantic content keys* (``_DEDUP_CONTENT_KEYS``) — corruption in other payload
|
|
1212
|
+
fields (model names, metadata) is invisible to it. Events with no dedup_key
|
|
1213
|
+
at all carry nothing to self-validate against, so the same pass also runs a
|
|
1214
|
+
**cross-store payload parity** check: for every event id present on both
|
|
1215
|
+
sides, the truth line's payload and the index row's payload are canonically
|
|
1216
|
+
hashed and compared (``cross``). Writes flow one way (truth before index;
|
|
1217
|
+
nothing mutates a payload after commit), so the two stores are redundant
|
|
1218
|
+
copies and any disagreement is corruption on one side — this is what makes
|
|
1219
|
+
rot in an *unkeyed* payload detectable at all, instead of parsing clean,
|
|
1220
|
+
passing every count, and being promoted over the good index row by the next
|
|
1221
|
+
reindex. A *new* mismatch (key-hash or cross-store) fails
|
|
1222
|
+
``ok``: each run's counts are persisted in ``manifest.json`` and diffed
|
|
1223
|
+
against the previous run's baseline — an increase (or any mismatch on a
|
|
1224
|
+
baseline-less first run) means content changed underneath its key since the
|
|
1225
|
+
last look, and health must go red until it's seen. The stamped baseline
|
|
1226
|
+
absorbs the count, so an acknowledged (e.g. legitimately-repaired-in-place)
|
|
1227
|
+
mismatch fails exactly one run rather than pinning verify red forever — and
|
|
1228
|
+
the failure ledger keeps its samples either way.
|
|
1229
|
+
CPU-heavy (re-hashes every payload on both stores).
|
|
1230
|
+
|
|
1231
|
+
``backup`` scans a backup mirror of the truth directory with the same
|
|
1232
|
+
parse-and-count pass as the live truth (no watermark bound — the mirror is a
|
|
1233
|
+
point-in-time copy) and reports its counts against the live ones. The backup
|
|
1234
|
+
check fails ``ok`` on parse errors in the mirror, and on a *shrinking
|
|
1235
|
+
mirror*: each run's effective count is recorded in health, and a count lower
|
|
1236
|
+
than the previous run's for the same destination means the mirror lost
|
|
1237
|
+
content between looks (the drill's coverage floor only catches drops ≥2%; a
|
|
1238
|
+
slow leak needs the run-over-run diff). A lower count than the *live* truth
|
|
1239
|
+
is expected staleness (the mirror ages between runs) and is reported as
|
|
1240
|
+
``coverage`` for trending. Combined with ``hashes``, the mirror gets the
|
|
1241
|
+
content-level hash scan too — an unchanged destination file is never
|
|
1242
|
+
re-copied, so rot at rest is otherwise invisible forever. A mirror mismatch
|
|
1243
|
+
count above the previous run's for the same destination (or any mismatch on
|
|
1244
|
+
a first, baseline-less look) fails ``ok`` (``backup_hashes``), with the same
|
|
1245
|
+
fails-once absorption as the live scan.
|
|
1246
|
+
``restore_drill`` is the step beyond this: actually rebuild an index from
|
|
1247
|
+
the mirror.
|
|
1248
|
+
|
|
1249
|
+
The SQLite file itself gets a ``PRAGMA quick_check`` — page-level index
|
|
1250
|
+
corruption is otherwise invisible until a query happens to touch a bad page.
|
|
1251
|
+
On the ``hashes`` cadence this upgrades to the full ``integrity_check``,
|
|
1252
|
+
which also verifies b-tree index content against the tables (the only check
|
|
1253
|
+
that catches a corrupted index silently returning wrong query results).
|
|
1254
|
+
The index is rebuildable, so a failure here means ``archive reindex``, not
|
|
1255
|
+
data loss — but it must be *seen*.
|
|
1256
|
+
|
|
1257
|
+
The shallow comparison is watermark-bounded on both sides too (ids at or
|
|
1258
|
+
below the index maxima captured up front), so verify can run against a live
|
|
1259
|
+
watcher without racing its ingest. An *empty* index gets no bound — a
|
|
1260
|
+
restored-but-not-yet-reindexed archive must show its full drift, not a
|
|
1261
|
+
vacuous OK.
|
|
1262
|
+
"""
|
|
1263
|
+
open_archive(home)
|
|
1264
|
+
from sqlalchemy import func, select
|
|
1265
|
+
|
|
1266
|
+
from ._store import Event, KgEvent, Thread, get_session
|
|
1267
|
+
from ._truth import scan_truth_counts
|
|
1268
|
+
|
|
1269
|
+
with get_session() as s:
|
|
1270
|
+
watermark = s.execute(select(func.max(Event.id))).scalar() or 0
|
|
1271
|
+
thread_watermark = s.execute(select(func.max(Thread.id))).scalar() or 0
|
|
1272
|
+
kg_watermark = s.execute(select(func.max(KgEvent.id))).scalar() or 0
|
|
1273
|
+
truth = scan_truth_counts(
|
|
1274
|
+
event_id_max=watermark or None, thread_id_max=thread_watermark or None,
|
|
1275
|
+
kg_event_id_max=kg_watermark or None,
|
|
1276
|
+
)
|
|
1277
|
+
with get_session() as s:
|
|
1278
|
+
thread_q = select(func.count()).select_from(Thread)
|
|
1279
|
+
if thread_watermark:
|
|
1280
|
+
thread_q = thread_q.where(Thread.id <= thread_watermark)
|
|
1281
|
+
idx_threads = s.execute(thread_q).scalar() or 0
|
|
1282
|
+
event_q = select(func.count()).select_from(Event)
|
|
1283
|
+
if watermark:
|
|
1284
|
+
event_q = event_q.where(Event.id <= watermark)
|
|
1285
|
+
idx_events = s.execute(event_q).scalar() or 0
|
|
1286
|
+
kg_q = select(func.count()).select_from(KgEvent)
|
|
1287
|
+
if kg_watermark:
|
|
1288
|
+
kg_q = kg_q.where(KgEvent.id <= kg_watermark)
|
|
1289
|
+
idx_kg = s.execute(kg_q).scalar() or 0
|
|
1290
|
+
drift_threads = int(idx_threads) - truth["threads"]
|
|
1291
|
+
drift_events = int(idx_events) - truth["events_effective"]
|
|
1292
|
+
drift_kg = int(idx_kg) - truth["kg_events"]
|
|
1293
|
+
# Self-check of the index file. The daily form is quick_check (reads every
|
|
1294
|
+
# page but skips index-content verification — a torn page still shows); the
|
|
1295
|
+
# ``hashes`` cadence upgrades to the full integrity_check, which also
|
|
1296
|
+
# verifies b-tree index content against the tables — the only check that
|
|
1297
|
+
# catches a corrupted index silently returning wrong query results.
|
|
1298
|
+
#
|
|
1299
|
+
# The pragma runs on its own just-opened connection, never a pooled one.
|
|
1300
|
+
# FTS5's integrity check consults per-connection segment-structure state,
|
|
1301
|
+
# and a connection that has lived across another process's writes (the
|
|
1302
|
+
# watcher rewrites ``event_search`` segments continuously) can spuriously
|
|
1303
|
+
# report "malformed inverted index" for a healthy index — the same pragma
|
|
1304
|
+
# on a fresh connection to the same file passes. A private connection
|
|
1305
|
+
# checks the same committed bytes without that hazard; it reads the file
|
|
1306
|
+
# the engine is actually bound to, so it judges the same database every
|
|
1307
|
+
# other component of this verify ran against.
|
|
1308
|
+
check_pragma = "integrity_check(10)" if hashes else "quick_check(10)"
|
|
1309
|
+
import sqlite3
|
|
1310
|
+
|
|
1311
|
+
from ._store import get_engine
|
|
1312
|
+
|
|
1313
|
+
index_file = get_engine().url.database
|
|
1314
|
+
qconn = sqlite3.connect(index_file, timeout=5.0)
|
|
1315
|
+
try:
|
|
1316
|
+
# Some page-level damage comes back as result rows, some as a raised
|
|
1317
|
+
# DatabaseError — both are the finding, not a crash: the check must
|
|
1318
|
+
# fail closed with the message, or verify dies on exactly the state
|
|
1319
|
+
# it exists to report. OperationalError (locked / can't open) stays
|
|
1320
|
+
# an error: environmental trouble must not impersonate corruption.
|
|
1321
|
+
try:
|
|
1322
|
+
qc_rows = qconn.execute(f"PRAGMA {check_pragma}").fetchall()
|
|
1323
|
+
except sqlite3.OperationalError:
|
|
1324
|
+
raise
|
|
1325
|
+
except sqlite3.DatabaseError as exc:
|
|
1326
|
+
qc_rows = [(str(exc),)]
|
|
1327
|
+
finally:
|
|
1328
|
+
qconn.close()
|
|
1329
|
+
quick_check = "ok" if [r[0] for r in qc_rows] == ["ok"] else "; ".join(
|
|
1330
|
+
str(r[0]) for r in qc_rows
|
|
1331
|
+
)
|
|
1332
|
+
# Search-surface parity, on the daily cadence (deep re-checks it with more
|
|
1333
|
+
# detail): the FTS shadow and the FTS5 table commit in the same transaction
|
|
1334
|
+
# as their events, so below the watermark the two row counts must match and
|
|
1335
|
+
# no shadow row may point at a missing event. Both are index-internal drift
|
|
1336
|
+
# — a reindex rebuilds them — but silently unsearchable content is loss in
|
|
1337
|
+
# effect, so it must be *seen* daily, not only on the deep cadence.
|
|
1338
|
+
fts_shadow = fts5 = fts_orphans = 0
|
|
1339
|
+
with get_session() as s:
|
|
1340
|
+
conn = s.connection().connection
|
|
1341
|
+
has_fts = conn.execute(
|
|
1342
|
+
"SELECT count(*) FROM sqlite_master WHERE name IN ('events_fts', 'event_search')"
|
|
1343
|
+
).fetchone()[0] == 2
|
|
1344
|
+
if has_fts and watermark:
|
|
1345
|
+
fts_shadow = conn.execute(
|
|
1346
|
+
"SELECT count(*) FROM events_fts WHERE event_id <= ?", (watermark,)
|
|
1347
|
+
).fetchone()[0]
|
|
1348
|
+
fts5 = conn.execute(
|
|
1349
|
+
"SELECT count(*) FROM event_search WHERE event_id <= ?", (watermark,)
|
|
1350
|
+
).fetchone()[0]
|
|
1351
|
+
fts_orphans = conn.execute(
|
|
1352
|
+
"SELECT count(*) FROM events_fts f WHERE f.event_id <= ? "
|
|
1353
|
+
"AND NOT EXISTS(SELECT 1 FROM events e WHERE e.id = f.event_id)",
|
|
1354
|
+
(watermark,),
|
|
1355
|
+
).fetchone()[0]
|
|
1356
|
+
# Declared-schema parity (cheap PRAGMA introspection): a live index that
|
|
1357
|
+
# predates a model change runs under-enforced until a reindex — that gap
|
|
1358
|
+
# must be seen on the daily cadence, not discovered from its consequences.
|
|
1359
|
+
schema = _verify_schema()
|
|
1360
|
+
# ``ok`` is derived from this list at the end — the components and the
|
|
1361
|
+
# verdict cannot disagree, and a red run always names its cause.
|
|
1362
|
+
failed: list[str] = []
|
|
1363
|
+
if drift_threads != 0:
|
|
1364
|
+
failed.append("drift_threads")
|
|
1365
|
+
if drift_events != 0:
|
|
1366
|
+
failed.append("drift_events")
|
|
1367
|
+
if drift_kg != 0:
|
|
1368
|
+
failed.append("drift_kg_events")
|
|
1369
|
+
if truth["parse_errors"]:
|
|
1370
|
+
failed.append("parse_errors")
|
|
1371
|
+
if quick_check != "ok":
|
|
1372
|
+
failed.append("integrity_check" if hashes else "quick_check")
|
|
1373
|
+
if fts_shadow != fts5 or fts_orphans:
|
|
1374
|
+
failed.append("fts_parity")
|
|
1375
|
+
if not schema["ok"]:
|
|
1376
|
+
failed.append("schema")
|
|
1377
|
+
result = {
|
|
1378
|
+
"ok": not failed,
|
|
1379
|
+
"schema": schema,
|
|
1380
|
+
"truth": truth,
|
|
1381
|
+
"index": {
|
|
1382
|
+
"threads": int(idx_threads),
|
|
1383
|
+
"events": int(idx_events),
|
|
1384
|
+
"kg_events": int(idx_kg),
|
|
1385
|
+
"quick_check": quick_check,
|
|
1386
|
+
"check": "integrity_check" if hashes else "quick_check",
|
|
1387
|
+
},
|
|
1388
|
+
"drift": {"threads": drift_threads, "events": drift_events, "kg_events": drift_kg},
|
|
1389
|
+
"fts": {
|
|
1390
|
+
"shadow_rows": int(fts_shadow),
|
|
1391
|
+
"fts5_rows": int(fts5),
|
|
1392
|
+
"orphan_rows": int(fts_orphans),
|
|
1393
|
+
},
|
|
1394
|
+
}
|
|
1395
|
+
if deep:
|
|
1396
|
+
result["deep"] = _verify_deep(watermark)
|
|
1397
|
+
if not result["deep"]["ok"]:
|
|
1398
|
+
failed.append("deep")
|
|
1399
|
+
new_mismatches = False
|
|
1400
|
+
if hashes:
|
|
1401
|
+
result["hashes"] = _verify_hashes(watermark)
|
|
1402
|
+
# Detected corruption must fail health, not just be reported: any *new*
|
|
1403
|
+
# mismatch since the previous baseline (or any mismatch at all on a
|
|
1404
|
+
# first, baseline-less run) fails ``ok``. The baseline this run stamps
|
|
1405
|
+
# absorbs the count, so the failure fires once and the delta signal
|
|
1406
|
+
# stays meaningful — a legitimately-repaired payload's stale hash
|
|
1407
|
+
# doesn't keep verify red forever, but it is *seen* red once (and the
|
|
1408
|
+
# failure ledger below keeps its samples).
|
|
1409
|
+
h, delta = result["hashes"], result["hashes"].get("delta")
|
|
1410
|
+
if delta is not None:
|
|
1411
|
+
new_mismatches = any(v > 0 for v in delta.values())
|
|
1412
|
+
else:
|
|
1413
|
+
new_mismatches = bool(
|
|
1414
|
+
h["truth"]["mismatched"] or h["index"]["mismatched"]
|
|
1415
|
+
or h["cross"]["mismatched"]
|
|
1416
|
+
)
|
|
1417
|
+
result["hashes"]["new_mismatches"] = new_mismatches
|
|
1418
|
+
if new_mismatches:
|
|
1419
|
+
failed.append("hashes")
|
|
1420
|
+
if backup is not None:
|
|
1421
|
+
result["backup"] = _verify_backup(Path(backup).expanduser(), truth)
|
|
1422
|
+
if not result["backup"]["ok"]:
|
|
1423
|
+
failed.append("backup")
|
|
1424
|
+
if hashes and "scan" in result["backup"]:
|
|
1425
|
+
# Content-level rot detection on the mirror too: an unchanged
|
|
1426
|
+
# destination file is never re-copied (size+mtime skip), so silent
|
|
1427
|
+
# corruption at rest would otherwise persist forever while the
|
|
1428
|
+
# parse-and-count scan stays green. No watermark — the mirror is a
|
|
1429
|
+
# point-in-time copy. Same baseline-delta semantics as the live
|
|
1430
|
+
# hashes pass: a mismatch count *above* the previous run's for this
|
|
1431
|
+
# destination (or any mismatch on a baseline-less first run) fails
|
|
1432
|
+
# ``ok``; recording the new count absorbs it, so the failure fires
|
|
1433
|
+
# once and the ledger keeps its samples.
|
|
1434
|
+
dest_path = Path(backup).expanduser()
|
|
1435
|
+
bh = _hash_scan_truth_dir(dest_path, watermark=None)
|
|
1436
|
+
result["backup"]["hashes"] = bh
|
|
1437
|
+
prev = _read_health().get("backup_hashes_last") or {}
|
|
1438
|
+
prev_count = (
|
|
1439
|
+
prev.get("mismatched") if prev.get("dest") == str(dest_path) else None
|
|
1440
|
+
)
|
|
1441
|
+
bh["new_mismatches"] = (
|
|
1442
|
+
bh["mismatched"] > int(prev_count)
|
|
1443
|
+
if prev_count is not None else bool(bh["mismatched"])
|
|
1444
|
+
)
|
|
1445
|
+
if bh["new_mismatches"]:
|
|
1446
|
+
failed.append("backup_hashes")
|
|
1447
|
+
_record_health("backup_hashes_last", {
|
|
1448
|
+
"dest": str(dest_path), "mismatched": bh["mismatched"],
|
|
1449
|
+
})
|
|
1450
|
+
result["ok"] = not failed
|
|
1451
|
+
result["failed_components"] = failed
|
|
1452
|
+
if failed:
|
|
1453
|
+
# Keep the evidence: the counts and samples of a failing run exist only
|
|
1454
|
+
# in this dict, and health records booleans. Without the ledger, a red
|
|
1455
|
+
# observed later is undiagnosable except by re-running the whole pass
|
|
1456
|
+
# against a store that has moved on.
|
|
1457
|
+
ledger = _append_verify_failure(result)
|
|
1458
|
+
if ledger:
|
|
1459
|
+
result["failure_log"] = ledger
|
|
1460
|
+
|
|
1461
|
+
# Record the outcome in the home's health file so `archive status` (and
|
|
1462
|
+
# anything watching it) can see when integrity was last checked and how it
|
|
1463
|
+
# went — a verify that silently stops running is indistinguishable from a
|
|
1464
|
+
# healthy one otherwise. Staleness of the timestamp is the primary signal: a
|
|
1465
|
+
# crash mid-verify leaves the previous record standing, and its age says so.
|
|
1466
|
+
# `deep` / `hashes` / `backup` are the run's TIER, not decoration: they are
|
|
1467
|
+
# what `_stage_recovered` compares against the tier of a verify that failed,
|
|
1468
|
+
# so a basic pass can never retire a deep-tier red. `backup` records whether
|
|
1469
|
+
# the mirror was parse-scanned — the check a bare `archive verify` skips.
|
|
1470
|
+
_record_health("verify_last", {
|
|
1471
|
+
"ok": bool(result["ok"]),
|
|
1472
|
+
"deep": bool(deep),
|
|
1473
|
+
"hashes": bool(hashes),
|
|
1474
|
+
"backup": bool(backup),
|
|
1475
|
+
"drift_events": drift_events,
|
|
1476
|
+
"drift_threads": drift_threads,
|
|
1477
|
+
"parse_errors": truth["parse_errors"],
|
|
1478
|
+
"failed": failed,
|
|
1479
|
+
})
|
|
1480
|
+
# The escalated tiers get their own records: ``verify_last`` is overwritten
|
|
1481
|
+
# by every shallow run, so these are what age-gated schedulers (``nightly``)
|
|
1482
|
+
# read to know when a deep / hashes pass last actually happened. Each
|
|
1483
|
+
# carries its own tier's verdict — a red caused by another component must
|
|
1484
|
+
# not force the expensive tiers to re-run every night.
|
|
1485
|
+
if deep:
|
|
1486
|
+
_record_health("verify_deep_last", {"ok": bool(result["deep"]["ok"])})
|
|
1487
|
+
if hashes:
|
|
1488
|
+
_record_health("verify_hashes_last", {"ok": not new_mismatches})
|
|
1489
|
+
# A verify run on its own is how a failed nightly's verify stage gets retired
|
|
1490
|
+
# — republish the verdict so a proven fix reaches the monitor now, not at 04:00.
|
|
1491
|
+
_stamp_heartbeat()
|
|
1492
|
+
return result
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _append_verify_failure(result: dict) -> Optional[str]:
|
|
1496
|
+
"""Append the full result of a failing verify to ``<home>/verify-failures.jsonl``
|
|
1497
|
+
— the durable evidence a red run leaves behind (health.json holds booleans;
|
|
1498
|
+
the counts and samples live only in the result dict). Append-only and
|
|
1499
|
+
fail-soft: the ledger is advisory, so a write error is logged, never raised.
|
|
1500
|
+
Returns the ledger path, or None when it could not be written."""
|
|
1501
|
+
from datetime import datetime, timezone
|
|
1502
|
+
|
|
1503
|
+
p = resolve_paths().home / "verify-failures.jsonl"
|
|
1504
|
+
try:
|
|
1505
|
+
with open(p, "a", encoding="utf-8") as fh:
|
|
1506
|
+
fh.write(json.dumps(
|
|
1507
|
+
{"at": datetime.now(timezone.utc).isoformat(), **result},
|
|
1508
|
+
default=str,
|
|
1509
|
+
))
|
|
1510
|
+
fh.write("\n")
|
|
1511
|
+
return str(p)
|
|
1512
|
+
except OSError:
|
|
1513
|
+
import logging
|
|
1514
|
+
|
|
1515
|
+
logging.getLogger(__name__).exception("could not append to verify-failures.jsonl")
|
|
1516
|
+
return None
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _verify_schema() -> dict:
|
|
1520
|
+
"""Live index schema vs the declared models — the under-enforcement check.
|
|
1521
|
+
|
|
1522
|
+
Startup schema provisioning is ``create_all`` only: it creates *missing
|
|
1523
|
+
tables* but never retrofits new columns, indexes, or constraints onto
|
|
1524
|
+
existing ones, so a live index created before a model change can silently
|
|
1525
|
+
run under-enforced (e.g. the ``(thread_id, dedup_key)`` unique index absent
|
|
1526
|
+
→ DB-level dedup off) until the next reindex. Introspects the live SQLite
|
|
1527
|
+
schema against ``Base.metadata`` directly — no stored version stamp to
|
|
1528
|
+
drift — and reports missing columns, named indexes, and unique constraints
|
|
1529
|
+
(matched by column set; SQLite realizes them as auto-named unique indexes).
|
|
1530
|
+
Extra live-side objects are ignored: an older/foreign index must still
|
|
1531
|
+
open. Anything missing fails ``verify``'s ``ok`` — the fix is ``archive
|
|
1532
|
+
reindex``, which builds a fresh index with the full declared schema."""
|
|
1533
|
+
from sqlalchemy import UniqueConstraint as _UC
|
|
1534
|
+
|
|
1535
|
+
from ._store import Base, get_session
|
|
1536
|
+
|
|
1537
|
+
missing_tables: list[str] = []
|
|
1538
|
+
missing_columns: list[str] = []
|
|
1539
|
+
missing_indexes: list[str] = []
|
|
1540
|
+
missing_uniques: list[str] = []
|
|
1541
|
+
with get_session() as s:
|
|
1542
|
+
conn = s.connection().connection # raw sqlite3
|
|
1543
|
+
live_tables = {
|
|
1544
|
+
r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
1545
|
+
}
|
|
1546
|
+
for table in Base.metadata.tables.values():
|
|
1547
|
+
if table.name not in live_tables:
|
|
1548
|
+
missing_tables.append(table.name)
|
|
1549
|
+
continue
|
|
1550
|
+
live_cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table.name})")}
|
|
1551
|
+
missing_columns += [
|
|
1552
|
+
f"{table.name}.{c.name}" for c in table.columns if c.name not in live_cols
|
|
1553
|
+
]
|
|
1554
|
+
index_list = conn.execute(f"PRAGMA index_list({table.name})").fetchall()
|
|
1555
|
+
live_index_names = {r[1] for r in index_list}
|
|
1556
|
+
missing_indexes += [
|
|
1557
|
+
str(idx.name) for idx in table.indexes if idx.name not in live_index_names
|
|
1558
|
+
]
|
|
1559
|
+
live_unique_colsets = {
|
|
1560
|
+
frozenset(
|
|
1561
|
+
r[2] for r in conn.execute(f"PRAGMA index_info({row[1]})") if r[2]
|
|
1562
|
+
)
|
|
1563
|
+
for row in index_list
|
|
1564
|
+
if row[2] # unique flag
|
|
1565
|
+
}
|
|
1566
|
+
for uc in table.constraints:
|
|
1567
|
+
if not isinstance(uc, _UC):
|
|
1568
|
+
continue
|
|
1569
|
+
cols = frozenset(c.name for c in uc.columns)
|
|
1570
|
+
if cols not in live_unique_colsets:
|
|
1571
|
+
missing_uniques.append(f"{table.name}({', '.join(sorted(cols))})")
|
|
1572
|
+
return {
|
|
1573
|
+
"ok": not (missing_tables or missing_columns or missing_indexes or missing_uniques),
|
|
1574
|
+
"missing_tables": missing_tables,
|
|
1575
|
+
"missing_columns": missing_columns,
|
|
1576
|
+
"missing_indexes": missing_indexes,
|
|
1577
|
+
"missing_unique_constraints": missing_uniques,
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _verify_backup(dest: Path, live_truth: dict) -> dict:
|
|
1582
|
+
"""Scan a backup mirror of the truth dir and compare it to the live scan.
|
|
1583
|
+
|
|
1584
|
+
The restore-drill primitive: the same parse-and-count pass ``verify`` runs on
|
|
1585
|
+
the live truth, pointed at the mirror. Zero parse errors is the hard
|
|
1586
|
+
requirement (a mirror that doesn't parse doesn't restore); the effective-count
|
|
1587
|
+
ratio against the live truth (``coverage``) quantifies staleness — it should
|
|
1588
|
+
hover near 1.0 and only ever *rise* between backup runs.
|
|
1589
|
+
|
|
1590
|
+
A mirror that *shrank* fails too: each run records the mirror's effective
|
|
1591
|
+
count in health (``backup_scan_last``), and a count below the previous run's
|
|
1592
|
+
for the same destination means the backup lost content between looks —
|
|
1593
|
+
something deleted or truncated mirror files out-of-band. The restore drill's
|
|
1594
|
+
coverage floor only catches drops ≥2% of the archive; the run-over-run diff
|
|
1595
|
+
is what sees a slow leak. Recording the new count absorbs the drop, so —
|
|
1596
|
+
like the hashes baseline — a deliberate shrink (an ``--allow-shrink``
|
|
1597
|
+
re-emit after repair) fails exactly one run instead of pinning verify red."""
|
|
1598
|
+
from ._truth import scan_truth_counts
|
|
1599
|
+
|
|
1600
|
+
if not (dest / "manifest.json").exists() and not (dest / "threads").exists():
|
|
1601
|
+
return {"dest": str(dest), "ok": False, "error": "not a truth mirror"}
|
|
1602
|
+
scan = scan_truth_counts(truth_dir=dest)
|
|
1603
|
+
live_effective = live_truth["events_effective"] or 1
|
|
1604
|
+
prev = _read_health().get("backup_scan_last") or {}
|
|
1605
|
+
prev_effective = (
|
|
1606
|
+
prev.get("events_effective") if prev.get("dest") == str(dest) else None
|
|
1607
|
+
)
|
|
1608
|
+
out = {
|
|
1609
|
+
"dest": str(dest),
|
|
1610
|
+
"ok": scan["parse_errors"] == 0,
|
|
1611
|
+
"scan": scan,
|
|
1612
|
+
"coverage": round(scan["events_effective"] / live_effective, 6),
|
|
1613
|
+
}
|
|
1614
|
+
if prev_effective is not None and scan["events_effective"] < int(prev_effective):
|
|
1615
|
+
out["ok"] = False
|
|
1616
|
+
out["effective_drop"] = {
|
|
1617
|
+
"previous": int(prev_effective),
|
|
1618
|
+
"previous_at": prev.get("at"),
|
|
1619
|
+
"current": scan["events_effective"],
|
|
1620
|
+
}
|
|
1621
|
+
_record_health("backup_scan_last", {
|
|
1622
|
+
"dest": str(dest),
|
|
1623
|
+
"events_effective": scan["events_effective"],
|
|
1624
|
+
})
|
|
1625
|
+
return out
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _hash_key_check(payload: object, dedup_key: str) -> Optional[bool]:
|
|
1629
|
+
"""True = the payload re-hashes to the content hash embedded in its own
|
|
1630
|
+
``dedup_key`` (the last ``:``-segment; see
|
|
1631
|
+
``thread_archive._thread_import.event_builder.compute_dedup_key``); False = mismatch;
|
|
1632
|
+
None = the key carries no hash tail (nothing to validate against)."""
|
|
1633
|
+
from ._truth.jsonl_log import _hash_key_check as _impl
|
|
1634
|
+
|
|
1635
|
+
return _impl(payload, dedup_key)
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def _hash_scan_truth_dir(truth_dir: Path, watermark: Optional[int]) -> dict:
|
|
1639
|
+
"""Re-hash every event payload in a truth directory against its dedup_key's
|
|
1640
|
+
embedded content hash. The scan half of ``verify --hashes``, reusable
|
|
1641
|
+
against a backup mirror (``watermark=None`` — a point-in-time copy has no
|
|
1642
|
+
in-flight ingest to bound out). ``no_key`` counts events with no dedup_key
|
|
1643
|
+
at all: they carry nothing to validate against, so they are invisible to
|
|
1644
|
+
this check — the count keeps that coverage boundary visible."""
|
|
1645
|
+
from ._truth.jsonl_log import THREADS_SUBDIR, _iter_jsonl
|
|
1646
|
+
|
|
1647
|
+
checked = mismatched = skipped = no_key = 0
|
|
1648
|
+
sample: list[int] = []
|
|
1649
|
+
threads_dir = truth_dir / THREADS_SUBDIR
|
|
1650
|
+
if threads_dir.exists():
|
|
1651
|
+
for path in threads_dir.rglob("*.jsonl"):
|
|
1652
|
+
for rec in _iter_jsonl(path):
|
|
1653
|
+
if rec.get("type", "event") != "event":
|
|
1654
|
+
continue
|
|
1655
|
+
ev_id, key = rec.get("id"), rec.get("dedup_key")
|
|
1656
|
+
if ev_id is None or (watermark is not None and ev_id > watermark):
|
|
1657
|
+
continue
|
|
1658
|
+
if not key:
|
|
1659
|
+
no_key += 1
|
|
1660
|
+
continue
|
|
1661
|
+
verdict = _hash_key_check(rec.get("payload"), key)
|
|
1662
|
+
if verdict is None:
|
|
1663
|
+
skipped += 1
|
|
1664
|
+
continue
|
|
1665
|
+
checked += 1
|
|
1666
|
+
if not verdict:
|
|
1667
|
+
mismatched += 1
|
|
1668
|
+
if len(sample) < 10:
|
|
1669
|
+
sample.append(int(ev_id))
|
|
1670
|
+
return {
|
|
1671
|
+
"checked": checked, "mismatched": mismatched,
|
|
1672
|
+
"unhashed_keys": skipped, "no_key": no_key, "mismatch_sample": sample,
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def _payload_fingerprint(event_type: object, payload: object) -> str:
|
|
1677
|
+
"""Canonical content fingerprint of one stored event — the cross-store
|
|
1678
|
+
comparator. Both stores' copies are parsed to Python objects first, so
|
|
1679
|
+
serializer differences (key order, ascii escaping) can't false-positive;
|
|
1680
|
+
timestamps are deliberately excluded (the two stores format them
|
|
1681
|
+
differently)."""
|
|
1682
|
+
import hashlib
|
|
1683
|
+
|
|
1684
|
+
blob = json.dumps([event_type, payload], sort_keys=True, default=str, ensure_ascii=False)
|
|
1685
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def _verify_hashes(watermark: int) -> dict:
|
|
1689
|
+
"""Content-level validation of every stored payload, walking truth and index
|
|
1690
|
+
together one thread at a time.
|
|
1691
|
+
|
|
1692
|
+
Two independent checks per event:
|
|
1693
|
+
|
|
1694
|
+
* **Key-hash self-validation** (``truth`` / ``index``): re-hash the stored
|
|
1695
|
+
payload against the content hash embedded in its own ``dedup_key`` (its
|
|
1696
|
+
last ``:``-segment; see ``thread_archive._thread_import.event_builder.compute_dedup_key``).
|
|
1697
|
+
A mismatch means the payload changed since its key was computed —
|
|
1698
|
+
corruption, or an in-place payload repair that didn't recompute the key.
|
|
1699
|
+
Events with a key whose tail isn't a hash are skipped and counted
|
|
1700
|
+
(``unhashed_keys``); events with no dedup_key at all are counted too
|
|
1701
|
+
(``no_key``).
|
|
1702
|
+
* **Cross-store payload parity** (``cross``): for every id present on both
|
|
1703
|
+
sides, compare a canonical fingerprint of (event_type, payload) between
|
|
1704
|
+
the truth line (last line per id — what a reindex materializes) and the
|
|
1705
|
+
index row. Writes flow one way and nothing mutates payloads after commit,
|
|
1706
|
+
so the stores are redundant copies: disagreement is corruption on one
|
|
1707
|
+
side. This is the only check that can see rot in an *unkeyed* payload —
|
|
1708
|
+
a majority of any archive that predates dedup keys — which would
|
|
1709
|
+
otherwise parse clean, pass every count, and be promoted over the good
|
|
1710
|
+
index row by the next reindex.
|
|
1711
|
+
|
|
1712
|
+
The caller (``verify``) fails ``ok`` when any mismatch count *increased*
|
|
1713
|
+
since the previous baseline (see ``new_mismatches``); the counts themselves
|
|
1714
|
+
are informational."""
|
|
1715
|
+
import json as _json
|
|
1716
|
+
|
|
1717
|
+
from ._store import get_session
|
|
1718
|
+
from ._truth.jsonl_log import (
|
|
1719
|
+
THREADS_SUBDIR,
|
|
1720
|
+
_iter_jsonl,
|
|
1721
|
+
_shard_depth,
|
|
1722
|
+
_thread_file,
|
|
1723
|
+
log_dir,
|
|
1724
|
+
)
|
|
1725
|
+
|
|
1726
|
+
d = log_dir()
|
|
1727
|
+
threads_dir = d / THREADS_SUBDIR
|
|
1728
|
+
depth = _shard_depth(d)
|
|
1729
|
+
|
|
1730
|
+
truth_checked = truth_mismatched = truth_unhashed = truth_no_key = 0
|
|
1731
|
+
truth_sample: list[int] = []
|
|
1732
|
+
index_checked = index_mismatched = index_skipped = 0
|
|
1733
|
+
index_sample: list[int] = []
|
|
1734
|
+
cross_compared = cross_mismatched = 0
|
|
1735
|
+
cross_sample: list[int] = []
|
|
1736
|
+
|
|
1737
|
+
files_by_stem: dict[str, list[Path]] = {}
|
|
1738
|
+
if threads_dir.exists():
|
|
1739
|
+
for path in threads_dir.rglob("*.jsonl"):
|
|
1740
|
+
files_by_stem.setdefault(path.stem, []).append(path)
|
|
1741
|
+
truth_tids: set[int] = set()
|
|
1742
|
+
for stem in files_by_stem:
|
|
1743
|
+
try:
|
|
1744
|
+
truth_tids.add(int(stem))
|
|
1745
|
+
except ValueError: # pragma: no cover — stray file
|
|
1746
|
+
continue
|
|
1747
|
+
|
|
1748
|
+
with get_session() as s:
|
|
1749
|
+
conn = s.connection().connection # raw sqlite3 — stream, don't materialize
|
|
1750
|
+
index_no_key = conn.execute(
|
|
1751
|
+
"SELECT count(*) FROM events WHERE dedup_key IS NULL AND id <= ?",
|
|
1752
|
+
(watermark,),
|
|
1753
|
+
).fetchone()[0]
|
|
1754
|
+
idx_tids = {
|
|
1755
|
+
int(r[0]) for r in conn.execute(
|
|
1756
|
+
"SELECT DISTINCT thread_id FROM events WHERE id <= ?", (watermark,)
|
|
1757
|
+
)
|
|
1758
|
+
}
|
|
1759
|
+
for tid in sorted(idx_tids | truth_tids):
|
|
1760
|
+
# Truth side: every line is key-checked; the last line per id wins
|
|
1761
|
+
# the fingerprint (canonical-depth file last, matching reindex).
|
|
1762
|
+
fingerprints: dict[int, str] = {}
|
|
1763
|
+
paths = sorted(files_by_stem.get(str(tid), []))
|
|
1764
|
+
paths.sort(key=lambda p: p == _thread_file(d, tid, depth))
|
|
1765
|
+
for path in paths:
|
|
1766
|
+
for rec in _iter_jsonl(path):
|
|
1767
|
+
if rec.get("type", "event") != "event":
|
|
1768
|
+
continue
|
|
1769
|
+
ev_id, key = rec.get("id"), rec.get("dedup_key")
|
|
1770
|
+
if ev_id is None or ev_id > watermark:
|
|
1771
|
+
continue
|
|
1772
|
+
payload = rec.get("payload")
|
|
1773
|
+
if not key:
|
|
1774
|
+
truth_no_key += 1
|
|
1775
|
+
else:
|
|
1776
|
+
verdict = _hash_key_check(payload, key)
|
|
1777
|
+
if verdict is None:
|
|
1778
|
+
truth_unhashed += 1
|
|
1779
|
+
else:
|
|
1780
|
+
truth_checked += 1
|
|
1781
|
+
if not verdict:
|
|
1782
|
+
truth_mismatched += 1
|
|
1783
|
+
if len(truth_sample) < 10:
|
|
1784
|
+
truth_sample.append(int(ev_id))
|
|
1785
|
+
fingerprints[int(ev_id)] = _payload_fingerprint(
|
|
1786
|
+
rec.get("event_type"), payload
|
|
1787
|
+
)
|
|
1788
|
+
# Index side of the same thread, plus the cross-store comparison.
|
|
1789
|
+
for ev_id, key, etype, payload_text in conn.execute(
|
|
1790
|
+
"SELECT id, dedup_key, event_type, payload FROM events "
|
|
1791
|
+
"WHERE thread_id = ? AND id <= ?", (tid, watermark)
|
|
1792
|
+
):
|
|
1793
|
+
try:
|
|
1794
|
+
payload = (
|
|
1795
|
+
_json.loads(payload_text)
|
|
1796
|
+
if isinstance(payload_text, str) else payload_text
|
|
1797
|
+
)
|
|
1798
|
+
except ValueError:
|
|
1799
|
+
payload = None
|
|
1800
|
+
if key:
|
|
1801
|
+
verdict = _hash_key_check(payload, key)
|
|
1802
|
+
if verdict is None:
|
|
1803
|
+
index_skipped += 1
|
|
1804
|
+
else:
|
|
1805
|
+
index_checked += 1
|
|
1806
|
+
if not verdict:
|
|
1807
|
+
index_mismatched += 1
|
|
1808
|
+
if len(index_sample) < 10:
|
|
1809
|
+
index_sample.append(int(ev_id))
|
|
1810
|
+
truth_fp = fingerprints.get(int(ev_id))
|
|
1811
|
+
if truth_fp is not None:
|
|
1812
|
+
cross_compared += 1
|
|
1813
|
+
if truth_fp != _payload_fingerprint(etype, payload):
|
|
1814
|
+
cross_mismatched += 1
|
|
1815
|
+
if len(cross_sample) < 10:
|
|
1816
|
+
cross_sample.append(int(ev_id))
|
|
1817
|
+
|
|
1818
|
+
result = {
|
|
1819
|
+
"truth": {
|
|
1820
|
+
"checked": truth_checked, "mismatched": truth_mismatched,
|
|
1821
|
+
"unhashed_keys": truth_unhashed, "no_key": truth_no_key,
|
|
1822
|
+
"mismatch_sample": truth_sample,
|
|
1823
|
+
},
|
|
1824
|
+
"index": {
|
|
1825
|
+
"checked": index_checked, "mismatched": index_mismatched,
|
|
1826
|
+
"unhashed_keys": index_skipped, "no_key": int(index_no_key),
|
|
1827
|
+
"mismatch_sample": index_sample,
|
|
1828
|
+
},
|
|
1829
|
+
"cross": {
|
|
1830
|
+
"compared": cross_compared, "mismatched": cross_mismatched,
|
|
1831
|
+
"mismatch_sample": cross_sample,
|
|
1832
|
+
},
|
|
1833
|
+
}
|
|
1834
|
+
# The signal is the mismatch count *jumping* between runs, so persist this
|
|
1835
|
+
# run's counts in the manifest and surface the previous run's for comparison.
|
|
1836
|
+
# Locked read-modify-write: a checkpoint stamping its own keys concurrently
|
|
1837
|
+
# must not lose this baseline, nor vice versa.
|
|
1838
|
+
from datetime import datetime, timezone
|
|
1839
|
+
|
|
1840
|
+
from ._truth.jsonl_log import update_manifest
|
|
1841
|
+
|
|
1842
|
+
baseline = {
|
|
1843
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
1844
|
+
"truth_mismatched": truth_mismatched,
|
|
1845
|
+
"index_mismatched": index_mismatched,
|
|
1846
|
+
"cross_mismatched": cross_mismatched,
|
|
1847
|
+
}
|
|
1848
|
+
previous: dict = {}
|
|
1849
|
+
|
|
1850
|
+
def _stamp(m: dict) -> None:
|
|
1851
|
+
previous.update(m.get("hashes_baseline") or {})
|
|
1852
|
+
m["hashes_baseline"] = baseline
|
|
1853
|
+
|
|
1854
|
+
update_manifest(log_dir(), _stamp)
|
|
1855
|
+
if previous:
|
|
1856
|
+
result["previous"] = previous
|
|
1857
|
+
result["delta"] = {
|
|
1858
|
+
"truth_mismatched": truth_mismatched - int(previous.get("truth_mismatched", 0)),
|
|
1859
|
+
"index_mismatched": index_mismatched - int(previous.get("index_mismatched", 0)),
|
|
1860
|
+
"cross_mismatched": cross_mismatched - int(previous.get("cross_mismatched", 0)),
|
|
1861
|
+
}
|
|
1862
|
+
return result
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def _verify_deep(watermark: int) -> dict:
|
|
1866
|
+
"""Id-level truth↔index comparison plus knowledge-layer checks.
|
|
1867
|
+
|
|
1868
|
+
Only events with ``id <= watermark`` (committed before the scan began) are
|
|
1869
|
+
compared: the truth line for any such event was written *before* its commit
|
|
1870
|
+
(the staging invariant), so at any later read it must be present — and any
|
|
1871
|
+
index row at or below the watermark must have a truth line. Everything above
|
|
1872
|
+
the watermark is in-flight ingest and skipped.
|
|
1873
|
+
|
|
1874
|
+
Truth-only ids are split into two classes: **superseded** (a same-content twin
|
|
1875
|
+
— equal ``dedup_key`` — exists in the index under another id; the benign
|
|
1876
|
+
residue of a lost-commit re-import, collapsed on reindex) and **missing**
|
|
1877
|
+
(no twin: content the index genuinely lacks — recoverable via reindex).
|
|
1878
|
+
Index-only ids are the forbidden direction and always fail.
|
|
1879
|
+
|
|
1880
|
+
For ids present on both sides, the stored ``dedup_key`` values are compared
|
|
1881
|
+
(``events_key_mismatch``): a mismatch means the two stores disagree on an
|
|
1882
|
+
event's content identity — an index-only mutation the truth never received
|
|
1883
|
+
(a reindex would rewrite it) or corruption on one side. Reported with a
|
|
1884
|
+
sample, and it fails ``ok``.
|
|
1885
|
+
|
|
1886
|
+
The search surface is checked too (both FTS tables are written in the same
|
|
1887
|
+
transaction as their events): orphan shadow rows and a shadow↔FTS5 row-count
|
|
1888
|
+
mismatch fail. Indexable events with no shadow row are re-extracted: one whose
|
|
1889
|
+
payload yields no searchable text legitimately has no row (reported as
|
|
1890
|
+
``empty_extract_events``); one whose extraction yields text today is silently
|
|
1891
|
+
unfindable (``unindexed_events``) and fails — ``archive reindex`` rebuilds the
|
|
1892
|
+
surface.
|
|
1893
|
+
|
|
1894
|
+
Thread *metadata* parity (title/description/summary between the winning truth
|
|
1895
|
+
record and the index row) is report-only: an in-flight metadata commit can
|
|
1896
|
+
legitimately race the scan, but a persistent mismatch means a missed re-stage —
|
|
1897
|
+
and the next reindex would revert the index to the stale truth record.
|
|
1898
|
+
|
|
1899
|
+
The knowledge layer gets the same treatment as events: the kg log and table
|
|
1900
|
+
are id-diffed below a kg watermark captured before any file is read (so a
|
|
1901
|
+
live librarian write can't false-alarm), and ids on both sides are
|
|
1902
|
+
content-compared (``kg.content_mismatch``) — the log is small enough to
|
|
1903
|
+
fingerprint whole, and it is the curation history's only truth.
|
|
1904
|
+
"""
|
|
1905
|
+
import json as _json
|
|
1906
|
+
|
|
1907
|
+
from sqlalchemy import text as sa_text
|
|
1908
|
+
|
|
1909
|
+
from ._store import get_session
|
|
1910
|
+
from ._truth.jsonl_log import (
|
|
1911
|
+
KG_EVENTS_FILE,
|
|
1912
|
+
THREADS_SUBDIR,
|
|
1913
|
+
_iter_jsonl,
|
|
1914
|
+
log_dir,
|
|
1915
|
+
thread_file_load_order,
|
|
1916
|
+
)
|
|
1917
|
+
|
|
1918
|
+
d = log_dir()
|
|
1919
|
+
threads_dir = d / THREADS_SUBDIR
|
|
1920
|
+
|
|
1921
|
+
# The kg watermark, captured before any file is read: the kg id-diff below
|
|
1922
|
+
# is otherwise unbounded, and a librarian write landing mid-scan (its truth
|
|
1923
|
+
# line is durable before its commit, but this pass may read the file first)
|
|
1924
|
+
# would false-alarm ``kg_index_only`` against a perfectly healthy archive.
|
|
1925
|
+
with get_session() as s0:
|
|
1926
|
+
kg_watermark = s0.execute(
|
|
1927
|
+
sa_text("SELECT coalesce(max(id), 0) FROM kg_events")
|
|
1928
|
+
).scalar() or 0
|
|
1929
|
+
|
|
1930
|
+
# Pass 1 — truth ids per thread (≤ watermark), which files hold each thread,
|
|
1931
|
+
# and the winning (last, in reindex's load order — canonical-depth file
|
|
1932
|
+
# last) thread record per id.
|
|
1933
|
+
truth_ids: dict[int, set[int]] = {}
|
|
1934
|
+
thread_files: dict[int, list] = {}
|
|
1935
|
+
truth_meta: dict[int, dict] = {}
|
|
1936
|
+
if threads_dir.exists():
|
|
1937
|
+
for path in thread_file_load_order(d):
|
|
1938
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
1939
|
+
for line in fh:
|
|
1940
|
+
line = line.strip()
|
|
1941
|
+
if not line:
|
|
1942
|
+
continue
|
|
1943
|
+
try:
|
|
1944
|
+
rec = _json.loads(line)
|
|
1945
|
+
except ValueError:
|
|
1946
|
+
continue
|
|
1947
|
+
if rec.get("type", "event") == "thread":
|
|
1948
|
+
if rec.get("id") is not None:
|
|
1949
|
+
truth_meta[int(rec["id"])] = rec
|
|
1950
|
+
continue
|
|
1951
|
+
if rec.get("type", "event") != "event":
|
|
1952
|
+
continue
|
|
1953
|
+
ev_id, tid = rec.get("id"), rec.get("thread_id")
|
|
1954
|
+
if ev_id is None or tid is None or ev_id > watermark:
|
|
1955
|
+
continue
|
|
1956
|
+
truth_ids.setdefault(int(tid), set()).add(int(ev_id))
|
|
1957
|
+
files = thread_files.setdefault(int(tid), [])
|
|
1958
|
+
if path not in files:
|
|
1959
|
+
files.append(path)
|
|
1960
|
+
|
|
1961
|
+
# Pass 2 — per-thread diff against the index.
|
|
1962
|
+
index_only: list[int] = []
|
|
1963
|
+
missing: list[int] = []
|
|
1964
|
+
key_mismatch: list[int] = []
|
|
1965
|
+
superseded = 0
|
|
1966
|
+
with get_session() as s:
|
|
1967
|
+
idx_tids = {
|
|
1968
|
+
r[0] for r in s.execute(sa_text(
|
|
1969
|
+
"SELECT DISTINCT thread_id FROM events WHERE id <= :wm"), {"wm": watermark})
|
|
1970
|
+
}
|
|
1971
|
+
for tid in sorted(set(truth_ids) | idx_tids):
|
|
1972
|
+
rows = s.execute(sa_text(
|
|
1973
|
+
"SELECT id, dedup_key FROM events WHERE thread_id = :t AND id <= :wm"),
|
|
1974
|
+
{"t": tid, "wm": watermark}).all()
|
|
1975
|
+
idx_map = {r[0]: r[1] for r in rows}
|
|
1976
|
+
t_set = truth_ids.get(tid, set())
|
|
1977
|
+
index_only.extend(sorted(set(idx_map) - t_set))
|
|
1978
|
+
if not t_set:
|
|
1979
|
+
continue
|
|
1980
|
+
# Re-read this thread's files for every truth id's dedup_key: the last
|
|
1981
|
+
# line for an id wins, matching what a reindex would materialize.
|
|
1982
|
+
truth_keys: dict[int, str | None] = {}
|
|
1983
|
+
for path in thread_files.get(tid, []):
|
|
1984
|
+
for rec in _iter_jsonl(path):
|
|
1985
|
+
if rec.get("type", "event") == "event" and rec.get("id") in t_set:
|
|
1986
|
+
truth_keys[rec["id"]] = rec.get("dedup_key")
|
|
1987
|
+
idx_keys = {v for v in idx_map.values() if v}
|
|
1988
|
+
for ev_id in sorted(t_set - set(idx_map)):
|
|
1989
|
+
if truth_keys.get(ev_id) and truth_keys[ev_id] in idx_keys:
|
|
1990
|
+
superseded += 1
|
|
1991
|
+
else:
|
|
1992
|
+
missing.append(ev_id)
|
|
1993
|
+
for ev_id in sorted(t_set & set(idx_map)):
|
|
1994
|
+
if truth_keys.get(ev_id) != idx_map[ev_id]:
|
|
1995
|
+
key_mismatch.append(ev_id)
|
|
1996
|
+
|
|
1997
|
+
# Thread-metadata parity: the winning truth record vs the index row, on the
|
|
1998
|
+
# stable text fields. Report-only — the truth line for a metadata update is
|
|
1999
|
+
# staged before its commit, so a commit landing between pass 1's file read
|
|
2000
|
+
# and this query legitimately shows index-newer-than-truth; a *persistent*
|
|
2001
|
+
# mismatch means a re-stage was missed, and the next reindex would silently
|
|
2002
|
+
# revert the index to the stale truth record.
|
|
2003
|
+
meta_mismatch: list[int] = []
|
|
2004
|
+
meta_rows = s.execute(sa_text(
|
|
2005
|
+
"SELECT id, title, description, summary FROM threads")).all()
|
|
2006
|
+
for tid, *idx_fields in meta_rows:
|
|
2007
|
+
rec = truth_meta.get(int(tid))
|
|
2008
|
+
if rec is None:
|
|
2009
|
+
continue # count parity (shallow verify) owns missing records
|
|
2010
|
+
for field, idx_val in zip(("title", "description", "summary"), idx_fields):
|
|
2011
|
+
if (rec.get(field) or None) != (idx_val or None):
|
|
2012
|
+
meta_mismatch.append(int(tid))
|
|
2013
|
+
break
|
|
2014
|
+
|
|
2015
|
+
# Knowledge layer: the kg truth log vs its table, by id — both sides
|
|
2016
|
+
# bounded by the kg watermark captured up front, so a live librarian
|
|
2017
|
+
# write can't false-alarm — plus content parity for ids on both sides
|
|
2018
|
+
# (the kg analogue of the events cross-store check; the log is small
|
|
2019
|
+
# enough to compare whole).
|
|
2020
|
+
kg_recs: dict[int, dict] = {}
|
|
2021
|
+
for rec in _iter_jsonl(d / KG_EVENTS_FILE):
|
|
2022
|
+
rid = rec.get("id")
|
|
2023
|
+
if rid is not None and int(rid) <= kg_watermark:
|
|
2024
|
+
kg_recs[int(rid)] = rec # last line per id wins, like reindex
|
|
2025
|
+
kg_rows = {
|
|
2026
|
+
int(r[0]): tuple(r) for r in s.execute(sa_text(
|
|
2027
|
+
"SELECT id, event_type, entity_type, entity_id, payload "
|
|
2028
|
+
"FROM kg_events WHERE id <= :wm"), {"wm": kg_watermark})
|
|
2029
|
+
}
|
|
2030
|
+
kg_index_only = len(set(kg_rows) - set(kg_recs))
|
|
2031
|
+
kg_truth_only = len(set(kg_recs) - set(kg_rows))
|
|
2032
|
+
kg_content_mismatch = 0
|
|
2033
|
+
for rid in set(kg_recs) & set(kg_rows):
|
|
2034
|
+
rec, row = kg_recs[rid], kg_rows[rid]
|
|
2035
|
+
row_payload = row[4]
|
|
2036
|
+
if isinstance(row_payload, str):
|
|
2037
|
+
try:
|
|
2038
|
+
row_payload = _json.loads(row_payload)
|
|
2039
|
+
except ValueError:
|
|
2040
|
+
row_payload = None
|
|
2041
|
+
truth_fp = _payload_fingerprint(
|
|
2042
|
+
[rec.get("event_type"), rec.get("entity_type"), rec.get("entity_id")],
|
|
2043
|
+
rec.get("payload"),
|
|
2044
|
+
)
|
|
2045
|
+
if truth_fp != _payload_fingerprint([row[1], row[2], row[3]], row_payload):
|
|
2046
|
+
kg_content_mismatch += 1
|
|
2047
|
+
|
|
2048
|
+
# Dangling references.
|
|
2049
|
+
dangling_links = s.execute(sa_text(
|
|
2050
|
+
"SELECT count(*) FROM thread_links l WHERE "
|
|
2051
|
+
"NOT EXISTS(SELECT 1 FROM threads t WHERE t.id = l.source_thread_id) "
|
|
2052
|
+
"OR NOT EXISTS(SELECT 1 FROM threads t WHERE t.id = l.target_thread_id)"
|
|
2053
|
+
)).scalar() or 0
|
|
2054
|
+
dangling_citations = s.execute(sa_text(
|
|
2055
|
+
"SELECT count(*) FROM topic_messages m "
|
|
2056
|
+
"WHERE m.archived_at IS NULL " # tombstoned evidence is history, not a live ref
|
|
2057
|
+
"AND NOT EXISTS(SELECT 1 FROM events e WHERE e.id = m.event_id)"
|
|
2058
|
+
)).scalar() or 0
|
|
2059
|
+
# A live citation whose recorded thread disagrees with the cited event's
|
|
2060
|
+
# actual thread: the column is derived from the event, so disagreement
|
|
2061
|
+
# means an unvalidated write or a stale snapshot seed. Reindex realigns
|
|
2062
|
+
# these (citation reconciliation), so a persistent count means rot.
|
|
2063
|
+
citation_thread_mismatch = s.execute(sa_text(
|
|
2064
|
+
"SELECT count(*) FROM topic_messages m JOIN events e ON e.id = m.event_id "
|
|
2065
|
+
"WHERE m.archived_at IS NULL AND m.thread_id != e.thread_id"
|
|
2066
|
+
)).scalar() or 0
|
|
2067
|
+
dangling_events = s.execute(sa_text(
|
|
2068
|
+
"SELECT count(*) FROM events e "
|
|
2069
|
+
"WHERE NOT EXISTS(SELECT 1 FROM threads t WHERE t.id = e.thread_id)"
|
|
2070
|
+
)).scalar() or 0
|
|
2071
|
+
dup_pairs = s.execute(sa_text(
|
|
2072
|
+
"SELECT count(*) FROM (SELECT 1 FROM events WHERE dedup_key IS NOT NULL "
|
|
2073
|
+
"GROUP BY thread_id, dedup_key HAVING count(*) > 1)"
|
|
2074
|
+
)).scalar() or 0
|
|
2075
|
+
|
|
2076
|
+
# Search-surface parity. The FTS shadow (events_fts) and the FTS5 table
|
|
2077
|
+
# (event_search) are written in the same transaction as their events, so
|
|
2078
|
+
# below the watermark: no shadow row may point at a missing event (orphans),
|
|
2079
|
+
# and the two surfaces must hold the same row count. Coverage — indexable
|
|
2080
|
+
# events with no shadow row — is re-extracted event by event to split the
|
|
2081
|
+
# legitimately-empty (a payload that yields no searchable text has no row
|
|
2082
|
+
# by design) from the genuinely unindexed (extraction yields text today,
|
|
2083
|
+
# so the event is silently unfindable — real drift; `archive reindex`
|
|
2084
|
+
# rebuilds the surface). The gap set is small on a healthy archive, so
|
|
2085
|
+
# re-extracting only it stays cheap where re-extracting the corpus isn't.
|
|
2086
|
+
fts_orphans = fts_shadow_rows = fts5_rows = fts_empty_extract = 0
|
|
2087
|
+
fts_unindexed: list[int] = []
|
|
2088
|
+
has_fts = s.execute(sa_text(
|
|
2089
|
+
"SELECT count(*) FROM sqlite_master WHERE name IN ('events_fts', 'event_search')"
|
|
2090
|
+
)).scalar() == 2
|
|
2091
|
+
if has_fts:
|
|
2092
|
+
fts_orphans = s.execute(sa_text(
|
|
2093
|
+
"SELECT count(*) FROM events_fts f WHERE f.event_id <= :wm "
|
|
2094
|
+
"AND NOT EXISTS(SELECT 1 FROM events e WHERE e.id = f.event_id)"),
|
|
2095
|
+
{"wm": watermark}).scalar() or 0
|
|
2096
|
+
fts_shadow_rows = s.execute(sa_text(
|
|
2097
|
+
"SELECT count(*) FROM events_fts WHERE event_id <= :wm"),
|
|
2098
|
+
{"wm": watermark}).scalar() or 0
|
|
2099
|
+
fts5_rows = s.execute(sa_text(
|
|
2100
|
+
"SELECT count(*) FROM event_search WHERE event_id <= :wm"),
|
|
2101
|
+
{"wm": watermark}).scalar() or 0
|
|
2102
|
+
from ._retrieval._extract import INDEXABLE_EVENT_TYPES, extract_fts_content
|
|
2103
|
+
|
|
2104
|
+
types = ", ".join(f"'{t}'" for t in INDEXABLE_EVENT_TYPES)
|
|
2105
|
+
cur = s.connection().connection.execute( # raw sqlite3 — stream the gap set
|
|
2106
|
+
f"SELECT e.id, e.event_type, e.payload FROM events e " # noqa: S608 — types from INDEXABLE_EVENT_TYPES
|
|
2107
|
+
f"WHERE e.id <= ? AND e.event_type IN ({types}) "
|
|
2108
|
+
"AND NOT EXISTS(SELECT 1 FROM events_fts f WHERE f.event_id = e.id)",
|
|
2109
|
+
(watermark,),
|
|
2110
|
+
)
|
|
2111
|
+
for ev_id, etype, payload_text in cur:
|
|
2112
|
+
try:
|
|
2113
|
+
payload = _json.loads(payload_text) if isinstance(payload_text, str) else payload_text
|
|
2114
|
+
except ValueError:
|
|
2115
|
+
payload = None
|
|
2116
|
+
if isinstance(payload, dict) and extract_fts_content(etype, payload):
|
|
2117
|
+
fts_unindexed.append(int(ev_id))
|
|
2118
|
+
else:
|
|
2119
|
+
fts_empty_extract += 1
|
|
2120
|
+
|
|
2121
|
+
ok = (
|
|
2122
|
+
not index_only and not missing and not key_mismatch
|
|
2123
|
+
and kg_index_only == 0 and kg_content_mismatch == 0
|
|
2124
|
+
and dangling_links == 0 and dangling_citations == 0 and dangling_events == 0
|
|
2125
|
+
and citation_thread_mismatch == 0
|
|
2126
|
+
and fts_orphans == 0 and fts_shadow_rows == fts5_rows and not fts_unindexed
|
|
2127
|
+
)
|
|
2128
|
+
return {
|
|
2129
|
+
"ok": ok,
|
|
2130
|
+
"watermark": watermark,
|
|
2131
|
+
"events_index_only": len(index_only),
|
|
2132
|
+
"index_only_sample": index_only[:10],
|
|
2133
|
+
"events_missing_from_index": len(missing),
|
|
2134
|
+
"missing_sample": missing[:10],
|
|
2135
|
+
"events_key_mismatch": len(key_mismatch),
|
|
2136
|
+
"key_mismatch_sample": key_mismatch[:10],
|
|
2137
|
+
"events_superseded_twins": superseded,
|
|
2138
|
+
"thread_meta_mismatch": len(meta_mismatch),
|
|
2139
|
+
"thread_meta_sample": meta_mismatch[:10],
|
|
2140
|
+
"kg": {
|
|
2141
|
+
"index_only": kg_index_only, "truth_only": kg_truth_only,
|
|
2142
|
+
"content_mismatch": kg_content_mismatch, "watermark": int(kg_watermark),
|
|
2143
|
+
},
|
|
2144
|
+
"dangling": {
|
|
2145
|
+
"link_endpoints": int(dangling_links),
|
|
2146
|
+
"citation_events": int(dangling_citations),
|
|
2147
|
+
"citation_thread_mismatch": int(citation_thread_mismatch),
|
|
2148
|
+
"event_threads": int(dangling_events),
|
|
2149
|
+
},
|
|
2150
|
+
"duplicate_content_pairs_index": int(dup_pairs),
|
|
2151
|
+
"fts": {
|
|
2152
|
+
"orphan_rows": int(fts_orphans),
|
|
2153
|
+
"shadow_rows": int(fts_shadow_rows),
|
|
2154
|
+
"fts5_rows": int(fts5_rows),
|
|
2155
|
+
"unindexed_events": len(fts_unindexed),
|
|
2156
|
+
"unindexed_sample": fts_unindexed[:10],
|
|
2157
|
+
"empty_extract_events": int(fts_empty_extract),
|
|
2158
|
+
},
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def status(*, home: Optional[str] = None) -> dict:
|
|
2163
|
+
"""Archive health: paths + thread/event/topic/link/FTS counts, plus the
|
|
2164
|
+
operational records — when the last checkpoint, verify, and backup ran and
|
|
2165
|
+
how they went (``last_verify`` / ``last_backup`` from ``<home>/health.json``,
|
|
2166
|
+
written by :func:`verify` / :func:`backup`). Staleness here is the signal
|
|
2167
|
+
that a scheduled integrity job quietly stopped running."""
|
|
2168
|
+
paths = open_archive(home)
|
|
2169
|
+
from sqlalchemy import func, select
|
|
2170
|
+
|
|
2171
|
+
from ._retrieval import fts_status
|
|
2172
|
+
from ._store import Event, Thread, ThreadLink, get_session
|
|
2173
|
+
|
|
2174
|
+
with get_session() as s:
|
|
2175
|
+
threads = s.execute(select(func.count()).select_from(Thread)).scalar() or 0
|
|
2176
|
+
events = s.execute(select(func.count()).select_from(Event)).scalar() or 0
|
|
2177
|
+
topics = s.execute(
|
|
2178
|
+
select(func.count()).select_from(Thread).where(Thread.thread_type == "topic")
|
|
2179
|
+
).scalar() or 0
|
|
2180
|
+
links = s.execute(select(func.count()).select_from(ThreadLink)).scalar() or 0
|
|
2181
|
+
from ._retrieval.vectors import get_status as _vec_status
|
|
2182
|
+
from ._truth.jsonl_log import _read_manifest
|
|
2183
|
+
|
|
2184
|
+
health = _read_health()
|
|
2185
|
+
return {
|
|
2186
|
+
"home": str(paths.home),
|
|
2187
|
+
"truth_dir": str(paths.truth_dir),
|
|
2188
|
+
"index_path": str(paths.index_path),
|
|
2189
|
+
"threads": int(threads),
|
|
2190
|
+
"events": int(events),
|
|
2191
|
+
"topics": int(topics),
|
|
2192
|
+
"links": int(links),
|
|
2193
|
+
"fts_indexed": fts_status()["indexed"],
|
|
2194
|
+
"vectors_indexed": _vec_status().get("indexed", 0),
|
|
2195
|
+
"last_checkpoint_at": _read_manifest(paths.truth_dir).get("last_checkpoint_at"),
|
|
2196
|
+
"last_verify": health.get("verify_last"),
|
|
2197
|
+
"last_backup": health.get("backup_last"),
|
|
2198
|
+
"last_restore_drill": health.get("restore_drill_last"),
|
|
2199
|
+
"last_nightly": health.get("nightly_last"),
|
|
2200
|
+
"last_watch_errors": health.get("watch_errors_last"),
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
def repair(*, home: Optional[str] = None, dry_run: bool = False) -> dict:
|
|
2205
|
+
"""Quarantine unparseable truth lines and restore committed rows the truth
|
|
2206
|
+
lacks from the live index — the sanctioned path from a red ``verify`` back to
|
|
2207
|
+
green. See :func:`thread_archive._truth.repair.repair_truth`."""
|
|
2208
|
+
open_archive(home)
|
|
2209
|
+
from ._truth import repair_truth
|
|
2210
|
+
|
|
2211
|
+
return repair_truth(dry_run=dry_run)
|
|
2212
|
+
|
|
2213
|
+
|
|
2214
|
+
def knowledge_status(*, home: Optional[str] = None) -> dict:
|
|
2215
|
+
"""Topic-graph status: node/community/component counts (empty until topics exist)."""
|
|
2216
|
+
open_archive(home)
|
|
2217
|
+
from ._knowledge import get_status
|
|
2218
|
+
|
|
2219
|
+
return get_status()
|
|
2220
|
+
|
|
2221
|
+
|
|
2222
|
+
def bridge_topics(*, home: Optional[str] = None, limit: int = 20) -> list[dict]:
|
|
2223
|
+
"""Highest-betweenness topics — the structural bridges between communities."""
|
|
2224
|
+
open_archive(home)
|
|
2225
|
+
from ._knowledge import get_bridge_topics
|
|
2226
|
+
|
|
2227
|
+
return get_bridge_topics(limit=limit)
|
|
2228
|
+
|
|
2229
|
+
|
|
2230
|
+
def topic_peers(thread_id: int, *, home: Optional[str] = None, limit: int = 5) -> list[dict]:
|
|
2231
|
+
"""Topics in the same community as ``thread_id``, highest-pagerank first."""
|
|
2232
|
+
open_archive(home)
|
|
2233
|
+
from ._knowledge import get_community_peers
|
|
2234
|
+
|
|
2235
|
+
return get_community_peers(thread_id, limit=limit)
|