brainiac-cli 0.16.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brain/__init__.py +39 -0
- brain/__main__.py +14 -0
- brain/_assets/AGENTS.md +564 -0
- brain/_assets/overlay/template/brand/brand-guide.md +24 -0
- brain/_assets/overlay/template/keywords/glossary.md +15 -0
- brain/_assets/overlay/template/people/roster.md +14 -0
- brain/_assets/overlay/template/voice/voice-profile.md +34 -0
- brain/_assets/routines/manifest.json +257 -0
- brain/_assets/scripts/brain-brief-mac.plist +59 -0
- brain/_assets/scripts/brain-brief.sh +74 -0
- brain/_assets/scripts/brain-synthesis-mac.plist +48 -0
- brain/_assets/scripts/brain-synthesis.sh +214 -0
- brain/_assets/scripts/install-brief-mac.sh +152 -0
- brain/_assets/scripts/install-brief-windows.ps1 +97 -0
- brain/_assets/scripts/register_tasks.py +386 -0
- brain/_assets/templates/company.md +21 -0
- brain/_assets/templates/concept.md +27 -0
- brain/_assets/templates/daily.md +20 -0
- brain/_assets/templates/decision.md +42 -0
- brain/_assets/templates/meeting.md +33 -0
- brain/_assets/templates/person.md +20 -0
- brain/_assets/templates/project.md +23 -0
- brain/_assets/templates/state-moc.md +40 -0
- brain/_version.py +12 -0
- brain/anchor.py +121 -0
- brain/audit.py +422 -0
- brain/backup.py +210 -0
- brain/brief.py +417 -0
- brain/capture.py +117 -0
- brain/chunk.py +249 -0
- brain/classification.py +134 -0
- brain/cli.py +1906 -0
- brain/config.py +368 -0
- brain/connect.py +362 -0
- brain/context.py +108 -0
- brain/core.py +3018 -0
- brain/doctor.py +1161 -0
- brain/egress.py +148 -0
- brain/embed.py +857 -0
- brain/encryption.py +217 -0
- brain/frontmatter.py +102 -0
- brain/golden_probe.py +678 -0
- brain/graph.py +369 -0
- brain/graphify.py +352 -0
- brain/index.py +1576 -0
- brain/ingest/__init__.py +19 -0
- brain/ingest/handlers/__init__.py +43 -0
- brain/ingest/handlers/base.py +95 -0
- brain/ingest/handlers/docx.py +78 -0
- brain/ingest/handlers/email.py +228 -0
- brain/ingest/handlers/html.py +142 -0
- brain/ingest/handlers/image.py +91 -0
- brain/ingest/handlers/pdf.py +99 -0
- brain/ingest/handlers/pptx.py +69 -0
- brain/ingest/handlers/tables.py +41 -0
- brain/ingest/handlers/text.py +43 -0
- brain/ingest/handlers/xlsx.py +100 -0
- brain/ingest/handlers/zip.py +163 -0
- brain/ingest/pipeline.py +839 -0
- brain/ingest/transcript.py +158 -0
- brain/init.py +870 -0
- brain/maintenance.py +2266 -0
- brain/mcp_adapter.py +217 -0
- brain/multihop.py +232 -0
- brain/notes.py +195 -0
- brain/overlay.py +183 -0
- brain/projection.py +79 -0
- brain/rerank.py +425 -0
- brain/snapshot.py +231 -0
- brain/update.py +743 -0
- brain/vectors.py +225 -0
- brainiac_cli-0.16.0.dist-info/METADATA +306 -0
- brainiac_cli-0.16.0.dist-info/RECORD +77 -0
- brainiac_cli-0.16.0.dist-info/WHEEL +5 -0
- brainiac_cli-0.16.0.dist-info/entry_points.txt +4 -0
- brainiac_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- brainiac_cli-0.16.0.dist-info/top_level.txt +1 -0
brain/ingest/pipeline.py
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
"""ING-01 orchestrator: dispatcher + quarantine + immutable archival + audited
|
|
2
|
+
promotion. Pure(ish) — the only I/O is the drop zone / vault filesystem and
|
|
3
|
+
(non-dry-run) ``BrainCore.write_note``. Never called with role=vm: BrainCore
|
|
4
|
+
refuses ``ingest_dropzone`` via ``_require_host`` BEFORE this module is even
|
|
5
|
+
imported, so a VM leg has zero side effects here (S06 hard guarantee, mirrors
|
|
6
|
+
``write_note``/``drain_drafts``).
|
|
7
|
+
|
|
8
|
+
HARDENED (codex + grill):
|
|
9
|
+
- concurrency: each drop-zone file is CLAIMED via an atomic ``os.rename``
|
|
10
|
+
into ``inbox/_processing/`` before extraction, so a manual ``brain
|
|
11
|
+
ingest`` and the scheduled ``maintain`` drain can never double-process
|
|
12
|
+
the same file (the loser's rename raises ``OSError`` and it's skipped).
|
|
13
|
+
- immutability-safe writes: the archived original and the ``raw/`` source
|
|
14
|
+
are CREATE-EXCLUSIVE — same sha256 at the target = idempotent no-op,
|
|
15
|
+
different sha256 = quarantine as a collision (never silently overwritten).
|
|
16
|
+
- duplicate-content idempotency: a manifest keyed by the ORIGINAL file's
|
|
17
|
+
sha256 makes re-ingesting identical bytes a no-op (moved to
|
|
18
|
+
``inbox/_duplicate/`` with a report line, never re-signed).
|
|
19
|
+
- quality gate: nothing reaches ``write_note`` unless the handler's
|
|
20
|
+
extraction passed its density/encryption/size gates (see
|
|
21
|
+
``handlers.base.density_gate`` + each handler's own guards).
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime as _dt
|
|
26
|
+
import errno
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from . import handlers as H
|
|
36
|
+
from ..audit import AuditError
|
|
37
|
+
from ..notes import safe_slug
|
|
38
|
+
|
|
39
|
+
INBOX_DIRNAME = "inbox"
|
|
40
|
+
PROCESSING_DIRNAME = "_processing"
|
|
41
|
+
QUARANTINE_DIRNAME = "_quarantine"
|
|
42
|
+
DUPLICATE_DIRNAME = "_duplicate"
|
|
43
|
+
MANIFEST_RELPATH = ("ingest-manifest.json",) # under .brain/
|
|
44
|
+
FAILURES_RELPATH = ("ingest-failures.json",) # under .brain/ — per-file retry counter (C2)
|
|
45
|
+
|
|
46
|
+
# C2: a file that deterministically fails processing must not be retried
|
|
47
|
+
# forever (that would starve every later-alphabetical candidate's chance to
|
|
48
|
+
# ever be reported as "quarantined" and clutter the inbox root indefinitely
|
|
49
|
+
# with one file bouncing back and forth). After this many failed attempts
|
|
50
|
+
# (across separate run_ingest calls) it is quarantined instead of retried.
|
|
51
|
+
MAX_INGEST_FAILURES = 3
|
|
52
|
+
|
|
53
|
+
# B1: infra-wide outages (no signing key resolved; disk full/read-only fs)
|
|
54
|
+
# are NOT a per-file defect — they hit every remaining candidate in the batch
|
|
55
|
+
# identically. Counting one against the per-file poison counter (and
|
|
56
|
+
# eventually quarantining a perfectly good file) mistakes an outage for a
|
|
57
|
+
# poison file; see _is_systemic_error below.
|
|
58
|
+
_SYSTEMIC_OSERRNOS = frozenset({errno.ENOSPC, errno.EDQUOT, errno.EROFS})
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_systemic_error(exc: BaseException) -> bool:
|
|
62
|
+
"""True for a batch-wide outage (signing-key unavailable, or an OSError
|
|
63
|
+
whose errno says disk-full/quota/read-only-fs) as opposed to a per-file
|
|
64
|
+
extraction/content defect. ``AuditError`` covers ``KeyUnavailable`` and
|
|
65
|
+
any sibling audit/signing failure (e.g. the 'cryptography' package
|
|
66
|
+
missing) — anything raised by ``core.write_note``'s signing step."""
|
|
67
|
+
if isinstance(exc, AuditError):
|
|
68
|
+
return True
|
|
69
|
+
if isinstance(exc, OSError) and exc.errno in _SYSTEMIC_OSERRNOS:
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# HARDENED (code-review rework): a claim (rename into _processing/) followed
|
|
75
|
+
# by a crash before the file is unlinked/moved out would strand it forever —
|
|
76
|
+
# run_ingest only scans the inbox ROOT. Files older than this are swept back
|
|
77
|
+
# to the inbox root at the start of the NEXT run_ingest call (crash backstop).
|
|
78
|
+
# Files younger than this are left alone: they may belong to another process
|
|
79
|
+
# that is CURRENTLY (and legitimately) extracting them — sweeping those would
|
|
80
|
+
# break the atomic-claim guarantee. The per-file try/except below (moves the
|
|
81
|
+
# claim back to inbox on any in-process exception) is the primary mechanism;
|
|
82
|
+
# this sweep only catches a hard crash (killed process, power loss) that never
|
|
83
|
+
# got to run its except-clause.
|
|
84
|
+
STALE_PROCESSING_SECONDS = 15 * 60
|
|
85
|
+
|
|
86
|
+
# Cap checked via stat() BEFORE any read_bytes() of the claimed file, so a
|
|
87
|
+
# pathological multi-GB drop never loads fully into memory before rejection.
|
|
88
|
+
# Matches the largest per-handler cap (pdf.py); handlers still enforce their
|
|
89
|
+
# own (possibly smaller) cap on top of this.
|
|
90
|
+
MAX_INGEST_BYTES = 200 * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
# S06 (ING-03): a handler (zip, eml) can return ``metadata["nested"]`` —
|
|
93
|
+
# member/attachment bytes that re-enter the SAME dispatcher as their own
|
|
94
|
+
# ingest candidates. Bounded on TWO axes so a nested-archive-of-archives
|
|
95
|
+
# ("zip bomb via nesting") can't blow past each handler's own per-level caps:
|
|
96
|
+
# - MAX_NESTED_DEPTH: how many levels of re-entry (zip-in-zip, eml
|
|
97
|
+
# attachment that is itself a zip, ...) before giving up.
|
|
98
|
+
# - a shared per-top-level-candidate BUDGET (bytes + item count) threaded
|
|
99
|
+
# through the WHOLE recursion tree, not just checked per level — each
|
|
100
|
+
# handler already caps its own single-level ``nested`` list, but nesting
|
|
101
|
+
# N archives each at their own cap multiplies past any single-level limit.
|
|
102
|
+
MAX_NESTED_DEPTH = 3
|
|
103
|
+
MAX_TOTAL_NESTED_BYTES = 500 * 1024 * 1024
|
|
104
|
+
MAX_TOTAL_NESTED_ITEMS = 1000
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def inbox_dir(vault: Path) -> Path:
|
|
108
|
+
return vault / INBOX_DIRNAME
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _manifest_path(vault: Path) -> Path:
|
|
112
|
+
from .. import config
|
|
113
|
+
|
|
114
|
+
return config.brain_runtime_dir(vault) / MANIFEST_RELPATH[0]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_manifest(vault: Path) -> dict[str, str]:
|
|
118
|
+
path = _manifest_path(vault)
|
|
119
|
+
if not path.exists():
|
|
120
|
+
return {}
|
|
121
|
+
try:
|
|
122
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
123
|
+
except (OSError, ValueError):
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _save_manifest(vault: Path, manifest: dict[str, str]) -> None:
|
|
128
|
+
path = _manifest_path(vault)
|
|
129
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
tmp = path.with_suffix(".tmp")
|
|
131
|
+
tmp.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
|
|
132
|
+
os.replace(tmp, path) # atomic on same filesystem
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _failures_path(vault: Path) -> Path:
|
|
136
|
+
from .. import config
|
|
137
|
+
|
|
138
|
+
return config.brain_runtime_dir(vault) / FAILURES_RELPATH[0]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _load_failures(vault: Path) -> dict[str, int]:
|
|
142
|
+
path = _failures_path(vault)
|
|
143
|
+
if not path.exists():
|
|
144
|
+
return {}
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
147
|
+
except (OSError, ValueError):
|
|
148
|
+
return {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _save_failures(vault: Path, failures: dict[str, int]) -> None:
|
|
152
|
+
path = _failures_path(vault)
|
|
153
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
tmp = path.with_suffix(".tmp")
|
|
155
|
+
tmp.write_text(json.dumps(failures, indent=2, sort_keys=True), encoding="utf-8")
|
|
156
|
+
os.replace(tmp, path)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sha256_bytes(data: bytes) -> str:
|
|
160
|
+
return hashlib.sha256(data).hexdigest()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _content_key(path: Path) -> str:
|
|
164
|
+
"""E5: the stable key for the per-file retry/failure counter. Keyed on
|
|
165
|
+
the CONTENT hash rather than the filename — ``_claim`` disambiguates a
|
|
166
|
+
same-named collision in ``_processing/`` (e.g. ``poison.1.pdf``), and a
|
|
167
|
+
failed attempt is re-dropped into the inbox under THAT (possibly renamed)
|
|
168
|
+
name. A name-keyed counter loses its accumulated count the moment the
|
|
169
|
+
name changes; the sha256 of the bytes is stable across every rename."""
|
|
170
|
+
return _sha256_bytes(path.read_bytes())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_SLUG_SANITIZE = re.compile(r"[^A-Za-z0-9]+")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _slugify_stem(stem: str) -> str:
|
|
177
|
+
cleaned = _SLUG_SANITIZE.sub("-", stem).strip("-").lower()
|
|
178
|
+
return cleaned or "file"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# C7/B2: chars that either break the hand-rolled double-quoted YAML wrapping
|
|
182
|
+
# in _build_frontmatter (`"`) or are unsafe/awkward across filesystems (`:`,
|
|
183
|
+
# `\`) — a hostile/careless original filename (e.g. `report:"final".pdf`)
|
|
184
|
+
# must never be able to bake malformed YAML into a signed, immutable raw/
|
|
185
|
+
# note. Also strip ALL control characters (0x00-0x1F incl. newline/tab, and
|
|
186
|
+
# DEL 0x7F) — an embedded literal newline flows into `origin:` as a bare
|
|
187
|
+
# unescaped scalar and corrupts the YAML just as surely as an unescaped quote.
|
|
188
|
+
_ARCHIVE_NAME_SANITIZE = re.compile(r'[\x00-\x1f\x7f:"\\]')
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _sanitize_archive_name(name: str) -> str:
|
|
192
|
+
"""Sanitize the archived-original's filename component (used for BOTH the
|
|
193
|
+
on-disk archive path and the `origin:` frontmatter value it flows into)
|
|
194
|
+
so it can never carry a character that would corrupt the signed
|
|
195
|
+
frontmatter or misbehave on a legacy filesystem."""
|
|
196
|
+
cleaned = _ARCHIVE_NAME_SANITIZE.sub("_", name)
|
|
197
|
+
return cleaned or "file"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _move(src: Path, dest: Path) -> None:
|
|
201
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
try:
|
|
203
|
+
os.rename(src, dest)
|
|
204
|
+
except OSError:
|
|
205
|
+
shutil.move(str(src), str(dest)) # cross-device fallback
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _claim(path: Path, processing_dir: Path) -> Path | None:
|
|
209
|
+
"""Atomically claim ``path`` for this process. Returns the claimed path,
|
|
210
|
+
or ``None`` if another process/thread claimed it first (or it vanished)."""
|
|
211
|
+
processing_dir.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
dest = processing_dir / path.name
|
|
213
|
+
i = 0
|
|
214
|
+
while dest.exists():
|
|
215
|
+
i += 1
|
|
216
|
+
dest = processing_dir / f"{path.stem}.{i}{path.suffix}"
|
|
217
|
+
try:
|
|
218
|
+
os.rename(path, dest)
|
|
219
|
+
except OSError:
|
|
220
|
+
return None
|
|
221
|
+
# C1: os.rename PRESERVES the source's mtime, so a file that was old
|
|
222
|
+
# (e.g. downloaded/copied, keeping its original timestamp) looks
|
|
223
|
+
# instantly "stale" to _sweep_stale_processing the moment it's claimed —
|
|
224
|
+
# a concurrent drain would then sweep this LIVE claim back to the inbox.
|
|
225
|
+
# Touch to now so the processing-dir entry's mtime measures CLAIM time,
|
|
226
|
+
# not the source file's original timestamp.
|
|
227
|
+
try:
|
|
228
|
+
os.utime(dest, None)
|
|
229
|
+
except OSError:
|
|
230
|
+
pass # non-fatal: worst case the staleness backstop is slightly off
|
|
231
|
+
return dest
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _unique_dest(target_dir: Path, name: str) -> Path:
|
|
235
|
+
stem, suffix = Path(name).stem, Path(name).suffix
|
|
236
|
+
dest = target_dir / name
|
|
237
|
+
i = 0
|
|
238
|
+
while dest.exists():
|
|
239
|
+
i += 1
|
|
240
|
+
dest = target_dir / f"{stem}.{i}{suffix}"
|
|
241
|
+
return dest
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _sweep_stale_processing(
|
|
245
|
+
processing_dir: Path, inbox: Path, *,
|
|
246
|
+
vault: Path, quarantine_dir: Path, failures: dict[str, int],
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Crash backstop: rescue files stranded in ``_processing/`` by a process
|
|
249
|
+
that claimed them and then died before finishing (see
|
|
250
|
+
``STALE_PROCESSING_SECONDS``). Only sweeps files older than the staleness
|
|
251
|
+
threshold, so a concurrently-running claim by another live process is
|
|
252
|
+
never touched.
|
|
253
|
+
|
|
254
|
+
E3: a process death (OOM/segfault) mid-extraction is indistinguishable
|
|
255
|
+
from a deterministically-poison file after enough attempts — without this,
|
|
256
|
+
a file that reliably kills the process is swept back and reclaimed FOREVER
|
|
257
|
+
(never counted, never quarantined), and every nightly ``maintain`` dies
|
|
258
|
+
before ``index.sync``/publish ever runs. Count each sweep as a failed
|
|
259
|
+
attempt against the SAME persisted counter the in-process per-file
|
|
260
|
+
handler uses (keyed on content sha256, see E5/``_content_key``), so a
|
|
261
|
+
crash-looping file quarantines after ``MAX_INGEST_FAILURES`` just like an
|
|
262
|
+
in-process exception would."""
|
|
263
|
+
if not processing_dir.is_dir():
|
|
264
|
+
return
|
|
265
|
+
now = _dt.datetime.now().timestamp()
|
|
266
|
+
touched = False
|
|
267
|
+
for stuck in list(processing_dir.iterdir()):
|
|
268
|
+
if not stuck.is_file():
|
|
269
|
+
continue
|
|
270
|
+
try:
|
|
271
|
+
age = now - stuck.stat().st_mtime
|
|
272
|
+
except OSError:
|
|
273
|
+
continue
|
|
274
|
+
if age < STALE_PROCESSING_SECONDS:
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
key = _content_key(stuck)
|
|
278
|
+
except OSError:
|
|
279
|
+
key = stuck.name # unreadable; fall back to name-keying
|
|
280
|
+
count = failures.get(key, 0) + 1
|
|
281
|
+
failures[key] = count
|
|
282
|
+
touched = True
|
|
283
|
+
if count >= MAX_INGEST_FAILURES:
|
|
284
|
+
reason = "repeated_ingest_failure"
|
|
285
|
+
_quarantine(
|
|
286
|
+
stuck, quarantine_dir, reason,
|
|
287
|
+
[f"swept from stale {PROCESSING_DIRNAME}/ {count} time(s) — "
|
|
288
|
+
"process likely died mid-extraction (crash-death is "
|
|
289
|
+
"indistinguishable from poison after N attempts); giving up"],
|
|
290
|
+
)
|
|
291
|
+
failures.pop(key, None)
|
|
292
|
+
else:
|
|
293
|
+
_move(stuck, _unique_dest(inbox, stuck.name))
|
|
294
|
+
if touched:
|
|
295
|
+
_save_failures(vault, failures)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _create_exclusive_or_collision(dest: Path, data: bytes, known_sha: str | None = None) -> str:
|
|
299
|
+
"""Write ``data`` to ``dest`` create-exclusive. Returns "written",
|
|
300
|
+
"idempotent" (dest already holds identical bytes), or "collision" (dest
|
|
301
|
+
holds DIFFERENT bytes — never overwritten).
|
|
302
|
+
|
|
303
|
+
``known_sha`` lets a caller that already hashed ``data`` (e.g. for the
|
|
304
|
+
manifest content-key) skip re-hashing a potentially large buffer on the
|
|
305
|
+
collision-check path."""
|
|
306
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
try:
|
|
308
|
+
fd = os.open(dest, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
309
|
+
except FileExistsError:
|
|
310
|
+
existing = dest.read_bytes()
|
|
311
|
+
if existing == data:
|
|
312
|
+
return "idempotent"
|
|
313
|
+
data_sha = known_sha if known_sha is not None else _sha256_bytes(data)
|
|
314
|
+
return "idempotent" if _sha256_bytes(existing) == data_sha else "collision"
|
|
315
|
+
try:
|
|
316
|
+
with os.fdopen(fd, "wb") as f:
|
|
317
|
+
f.write(data)
|
|
318
|
+
except Exception:
|
|
319
|
+
dest.unlink(missing_ok=True)
|
|
320
|
+
raise
|
|
321
|
+
return "written"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _yaml_dq_escape(s: str) -> str:
|
|
325
|
+
"""Escape a string for embedding inside a YAML double-quoted scalar."""
|
|
326
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _build_frontmatter(meta: dict[str, Any], body: str) -> str:
|
|
330
|
+
lines = ["---"]
|
|
331
|
+
for k, v in meta.items():
|
|
332
|
+
if isinstance(v, bool):
|
|
333
|
+
lines.append(f"{k}: {'true' if v else 'false'}")
|
|
334
|
+
elif isinstance(v, str):
|
|
335
|
+
# S06 HARDENED (root-cause, not per-caller): strip raw control
|
|
336
|
+
# chars (embedded newline/tab/...) from EVERY string value here,
|
|
337
|
+
# in the ONE function every ingest caller routes through —
|
|
338
|
+
# previously a value with a control char but none of `:#"\\`
|
|
339
|
+
# skipped quoting entirely (see the C7 fix below) and could
|
|
340
|
+
# inject a bogus line into signed, immutable frontmatter. This
|
|
341
|
+
# covers both the drop-zone handlers' filenames AND
|
|
342
|
+
# transcript.py's caller-supplied `origin`/`language` without
|
|
343
|
+
# requiring each new caller to remember to pre-sanitize.
|
|
344
|
+
v = H.strip_control_chars(v)
|
|
345
|
+
if ":" in v or "#" in v or '"' in v or "\\" in v:
|
|
346
|
+
# C7: previously wrapped in double quotes WITHOUT escaping an
|
|
347
|
+
# embedded `"` — a value carrying one (e.g. origin embedding a
|
|
348
|
+
# hostile original filename) baked malformed YAML into a signed,
|
|
349
|
+
# immutable raw/ note that could never be fixed. Escape properly.
|
|
350
|
+
lines.append(f'{k}: "{_yaml_dq_escape(v)}"')
|
|
351
|
+
else:
|
|
352
|
+
lines.append(f"{k}: {v}")
|
|
353
|
+
else:
|
|
354
|
+
lines.append(f"{k}: {v}")
|
|
355
|
+
lines.append("---")
|
|
356
|
+
return "\n".join(lines) + "\n\n" + body.rstrip() + "\n"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def capability_report() -> dict[str, dict]:
|
|
360
|
+
return H.capability_report()
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def run_ingest(core: Any, *, dry_run: bool = False) -> dict[str, Any]:
|
|
364
|
+
"""Drain the drop zone. ``core`` is a HOST-role ``BrainCore`` — the caller
|
|
365
|
+
(``BrainCore.ingest_dropzone``) already enforced ``_require_host`` before
|
|
366
|
+
calling in, so this function assumes host privileges are available."""
|
|
367
|
+
vault = core.vault
|
|
368
|
+
inbox = inbox_dir(vault)
|
|
369
|
+
report: dict[str, Any] = {
|
|
370
|
+
"processed": [], "quarantined": [], "duplicates": [], "skipped": [],
|
|
371
|
+
"dry_run": dry_run,
|
|
372
|
+
}
|
|
373
|
+
if not inbox.is_dir():
|
|
374
|
+
report["reason"] = "no-inbox-dir"
|
|
375
|
+
return report
|
|
376
|
+
|
|
377
|
+
processing_dir = inbox / PROCESSING_DIRNAME
|
|
378
|
+
quarantine_dir = inbox / QUARANTINE_DIRNAME
|
|
379
|
+
duplicate_dir = inbox / DUPLICATE_DIRNAME
|
|
380
|
+
reserved = {PROCESSING_DIRNAME, QUARANTINE_DIRNAME, DUPLICATE_DIRNAME}
|
|
381
|
+
|
|
382
|
+
# E6: loaded once, up-front, and threaded through the sweep below too, so
|
|
383
|
+
# a single failures.json read/write pair covers both the crash-backstop
|
|
384
|
+
# sweep and the main per-file loop in this run.
|
|
385
|
+
failures = _load_failures(vault) if not dry_run else {}
|
|
386
|
+
|
|
387
|
+
# Crash backstop: rescue anything a PRIOR run left stranded in
|
|
388
|
+
# _processing/ (claimed, then the process died before finishing) so it
|
|
389
|
+
# re-enters this run's candidate scan instead of being lost forever.
|
|
390
|
+
if not dry_run:
|
|
391
|
+
_sweep_stale_processing(
|
|
392
|
+
processing_dir, inbox, vault=vault,
|
|
393
|
+
quarantine_dir=quarantine_dir, failures=failures,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
candidates = sorted(
|
|
397
|
+
p for p in inbox.iterdir()
|
|
398
|
+
if p.is_file() and not p.name.startswith(".") and p.parent.name not in reserved
|
|
399
|
+
)
|
|
400
|
+
# Also skip anything whose top-level parent is a reserved dir (iterdir only
|
|
401
|
+
# lists the inbox root, so this is defense-in-depth, not load-bearing).
|
|
402
|
+
candidates = [p for p in candidates if p.name not in reserved]
|
|
403
|
+
|
|
404
|
+
if dry_run:
|
|
405
|
+
for path in candidates:
|
|
406
|
+
handler = H.handler_for(path)
|
|
407
|
+
if handler is None:
|
|
408
|
+
report["skipped"].append({"file": path.name, "reason": "no_handler_for_extension"})
|
|
409
|
+
continue
|
|
410
|
+
if not handler.available():
|
|
411
|
+
report["skipped"].append(
|
|
412
|
+
{"file": path.name, "reason": f"missing_dependency:{handler.dependency_name}"}
|
|
413
|
+
)
|
|
414
|
+
continue
|
|
415
|
+
# C6: the stat()-based size gate was only applied on the
|
|
416
|
+
# non-dry-run path — dry-run called handler.extract() directly,
|
|
417
|
+
# and a handler like TextHandler does an unconditional
|
|
418
|
+
# read_bytes(), so a pathological multi-GB drop got fully loaded
|
|
419
|
+
# into memory even for a mere preview. Gate BEFORE extract here too.
|
|
420
|
+
try:
|
|
421
|
+
size = path.stat().st_size
|
|
422
|
+
except OSError:
|
|
423
|
+
size = 0
|
|
424
|
+
if size > MAX_INGEST_BYTES:
|
|
425
|
+
report["quarantined"].append({"file": path.name, "reason": "file_too_large"})
|
|
426
|
+
continue
|
|
427
|
+
result = handler.extract(path)
|
|
428
|
+
if result.ok:
|
|
429
|
+
report["processed"].append({"file": path.name, "would_write": True})
|
|
430
|
+
else:
|
|
431
|
+
report["quarantined"].append({"file": path.name, "reason": result.quarantine_reason})
|
|
432
|
+
return report
|
|
433
|
+
|
|
434
|
+
manifest = _load_manifest(vault)
|
|
435
|
+
today = _dt.date.today().isoformat()
|
|
436
|
+
|
|
437
|
+
for path in candidates:
|
|
438
|
+
claimed = _claim(path, processing_dir)
|
|
439
|
+
if claimed is None:
|
|
440
|
+
report["skipped"].append({"file": path.name, "reason": "claimed_elsewhere"})
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
# Size gate BEFORE any read_bytes() of the claimed file, so a
|
|
444
|
+
# pathological multi-GB drop never loads fully into memory before
|
|
445
|
+
# rejection — checked here (ahead of the content-key read below) so
|
|
446
|
+
# the gate still runs first even though _content_key now reads the
|
|
447
|
+
# file too.
|
|
448
|
+
try:
|
|
449
|
+
size = claimed.stat().st_size
|
|
450
|
+
except OSError:
|
|
451
|
+
size = 0
|
|
452
|
+
if size > MAX_INGEST_BYTES:
|
|
453
|
+
reason = "file_too_large"
|
|
454
|
+
_quarantine(claimed, quarantine_dir, reason,
|
|
455
|
+
[f"{size} bytes exceeds ingest cap {MAX_INGEST_BYTES}"])
|
|
456
|
+
report["quarantined"].append({"file": claimed.name, "reason": reason})
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
# E5: key the per-file retry counter on the CONTENT hash, not the
|
|
460
|
+
# (possibly claim-collision-renamed / retry-renamed) filename.
|
|
461
|
+
original_bytes = claimed.read_bytes()
|
|
462
|
+
original_sha = _sha256_bytes(original_bytes)
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
_process_claimed(
|
|
466
|
+
claimed, path.name, original_bytes=original_bytes,
|
|
467
|
+
original_sha=original_sha, core=core, manifest=manifest,
|
|
468
|
+
vault=vault, today=today, quarantine_dir=quarantine_dir,
|
|
469
|
+
duplicate_dir=duplicate_dir, processing_dir=processing_dir,
|
|
470
|
+
report=report,
|
|
471
|
+
)
|
|
472
|
+
except Exception as exc:
|
|
473
|
+
if _is_systemic_error(exc):
|
|
474
|
+
# B1: KeyUnavailable / disk-full / read-only-fs is an OUTAGE,
|
|
475
|
+
# not a per-file defect — it will hit every remaining
|
|
476
|
+
# candidate in this batch identically. Leave the file for the
|
|
477
|
+
# next drain untouched (no counter bump, no quarantine risk)
|
|
478
|
+
# and stop this run's batch rather than burn through every
|
|
479
|
+
# remaining candidate against the same wall, WITHOUT raising
|
|
480
|
+
# (a systemic outage must never abort the surrounding sync).
|
|
481
|
+
if claimed.exists():
|
|
482
|
+
_move(claimed, _unique_dest(inbox, claimed.name))
|
|
483
|
+
report["skipped"].append({
|
|
484
|
+
"file": path.name,
|
|
485
|
+
"reason": f"systemic_error:{type(exc).__name__}: {exc}",
|
|
486
|
+
})
|
|
487
|
+
break
|
|
488
|
+
# C2: a per-file exception used to be moved back to the inbox
|
|
489
|
+
# AND re-raised, aborting the whole run_ingest call — one
|
|
490
|
+
# deterministically-failing ("poison") file meant `brain sync`
|
|
491
|
+
# exited nonzero on EVERY invocation, so the index never
|
|
492
|
+
# reconciled and the snapshot never republished, forever. Now:
|
|
493
|
+
# retry a few times (moved back to the inbox root, NOT raised, so
|
|
494
|
+
# later candidates in THIS run still get processed), then
|
|
495
|
+
# quarantine it once it's clearly not transient.
|
|
496
|
+
count = failures.get(original_sha, 0) + 1
|
|
497
|
+
failures[original_sha] = count
|
|
498
|
+
_save_failures(vault, failures)
|
|
499
|
+
if not claimed.exists():
|
|
500
|
+
continue
|
|
501
|
+
if count >= MAX_INGEST_FAILURES:
|
|
502
|
+
reason = "repeated_ingest_failure"
|
|
503
|
+
_quarantine(
|
|
504
|
+
claimed, quarantine_dir, reason,
|
|
505
|
+
[f"{type(exc).__name__}: {exc}", f"failed {count} time(s), giving up"],
|
|
506
|
+
)
|
|
507
|
+
report["quarantined"].append({"file": path.name, "reason": reason})
|
|
508
|
+
failures.pop(original_sha, None)
|
|
509
|
+
_save_failures(vault, failures)
|
|
510
|
+
else:
|
|
511
|
+
_move(claimed, _unique_dest(inbox, claimed.name))
|
|
512
|
+
report["skipped"].append({
|
|
513
|
+
"file": path.name,
|
|
514
|
+
"reason": f"processing_error:{type(exc).__name__} (attempt {count}/{MAX_INGEST_FAILURES})",
|
|
515
|
+
})
|
|
516
|
+
continue
|
|
517
|
+
else:
|
|
518
|
+
# E6: clear the counter entry on success so a since-fixed/
|
|
519
|
+
# renamed file doesn't carry a stale count into a later,
|
|
520
|
+
# unrelated drop that happens to hash the same (vanishingly
|
|
521
|
+
# unlikely, but the entry should never outlive its file anyway).
|
|
522
|
+
if original_sha in failures:
|
|
523
|
+
failures.pop(original_sha, None)
|
|
524
|
+
_save_failures(vault, failures)
|
|
525
|
+
|
|
526
|
+
return report
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _existing_note_classification(vault: Path, existing_id: str) -> str | None:
|
|
530
|
+
"""E4: the classification of an already-ingested ``raw/<id>.md`` note, so
|
|
531
|
+
a duplicate-report entry can be routed through the same egress gate as
|
|
532
|
+
``processed`` (a duplicate's ``existing_id`` is a real note id — it must
|
|
533
|
+
not leak an above-max-tier note's identity just because the CONTENT was a
|
|
534
|
+
dedup hit rather than a fresh promotion)."""
|
|
535
|
+
note_path = vault / "raw" / f"{existing_id}.md"
|
|
536
|
+
if not note_path.is_file():
|
|
537
|
+
return None
|
|
538
|
+
from .. import frontmatter as fm
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
meta, _ = fm.parse_text(note_path.read_text(encoding="utf-8"))
|
|
542
|
+
except OSError:
|
|
543
|
+
return None
|
|
544
|
+
val = meta.get("classification")
|
|
545
|
+
return str(val) if val else None
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _process_claimed(
|
|
549
|
+
claimed: Path, orig_name: str, *, original_bytes: bytes, original_sha: str,
|
|
550
|
+
core: Any, manifest: dict[str, str],
|
|
551
|
+
vault: Path, today: str, quarantine_dir: Path, duplicate_dir: Path,
|
|
552
|
+
processing_dir: Path, report: dict[str, Any],
|
|
553
|
+
depth: int = 0, budget: dict[str, int] | None = None, parent: str | None = None,
|
|
554
|
+
) -> None:
|
|
555
|
+
"""Process one already-claimed file (in ``inbox/_processing/``) to
|
|
556
|
+
completion: quarantine, duplicate, or promote. On success the claimed
|
|
557
|
+
copy is always consumed (moved or unlinked). On any exception the caller
|
|
558
|
+
(``run_ingest`` or, for a nested item, ``_process_nested``) moves the
|
|
559
|
+
(still-existing) claim back to the inbox root / quarantines it so the
|
|
560
|
+
next drain retries it — see the HARDENED note above.
|
|
561
|
+
|
|
562
|
+
``original_bytes``/``original_sha`` are precomputed by the caller (which
|
|
563
|
+
needs the same content hash for its own retry-counter keying, E5) — read
|
|
564
|
+
once, not twice.
|
|
565
|
+
|
|
566
|
+
``depth``/``budget``/``parent`` (S06, ING-03): non-default only when this
|
|
567
|
+
call is a zip member or eml attachment re-entering the dispatcher (see
|
|
568
|
+
``_process_nested`` below) — all three are their defaults for every
|
|
569
|
+
TOP-LEVEL inbox candidate. ``parent`` (the container's own note id) is
|
|
570
|
+
stamped onto every report entry this call produces, so a nested item's
|
|
571
|
+
provenance is traceable in the ingest report."""
|
|
572
|
+
if budget is None:
|
|
573
|
+
budget = {"bytes": 0, "items": 0}
|
|
574
|
+
|
|
575
|
+
def _append(bucket: str, entry: dict[str, Any]) -> None:
|
|
576
|
+
if parent is not None:
|
|
577
|
+
entry["parent"] = parent
|
|
578
|
+
report[bucket].append(entry)
|
|
579
|
+
|
|
580
|
+
if original_sha in manifest:
|
|
581
|
+
existing_id = manifest[original_sha]
|
|
582
|
+
_move(claimed, duplicate_dir / claimed.name)
|
|
583
|
+
(duplicate_dir / f"{claimed.name}.duplicate-of.txt").write_text(
|
|
584
|
+
f"identical content already ingested as raw/{existing_id}.md\n",
|
|
585
|
+
encoding="utf-8",
|
|
586
|
+
)
|
|
587
|
+
_append("duplicates", {
|
|
588
|
+
"file": claimed.name, "existing_id": existing_id,
|
|
589
|
+
"classification": _existing_note_classification(vault, existing_id),
|
|
590
|
+
})
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
handler = H.handler_for(claimed)
|
|
594
|
+
if handler is None:
|
|
595
|
+
reason = "no_handler_for_extension"
|
|
596
|
+
_quarantine(claimed, quarantine_dir, reason, [])
|
|
597
|
+
_append("quarantined", {"file": claimed.name, "reason": reason})
|
|
598
|
+
return
|
|
599
|
+
if not handler.available():
|
|
600
|
+
reason = f"missing_dependency:{handler.dependency_name}"
|
|
601
|
+
_quarantine(claimed, quarantine_dir, reason, [])
|
|
602
|
+
_append("quarantined", {"file": claimed.name, "reason": reason})
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
result = handler.extract(claimed)
|
|
606
|
+
if not result.ok:
|
|
607
|
+
_quarantine(claimed, quarantine_dir, result.quarantine_reason, result.warnings)
|
|
608
|
+
_append("quarantined", {"file": claimed.name, "reason": result.quarantine_reason})
|
|
609
|
+
return
|
|
610
|
+
|
|
611
|
+
stem = _slugify_stem(claimed.stem)
|
|
612
|
+
slug = safe_slug(f"{today}-{stem}")
|
|
613
|
+
archive_subdir = vault / "raw" / "originals" / f"{today}-{stem}"
|
|
614
|
+
# C7: the archived filename flows verbatim into the signed `origin:`
|
|
615
|
+
# frontmatter value (and the filesystem path) — sanitize it so a hostile
|
|
616
|
+
# or careless original name can't carry a quote/colon/backslash into
|
|
617
|
+
# either.
|
|
618
|
+
archive_path = archive_subdir / _sanitize_archive_name(claimed.name)
|
|
619
|
+
|
|
620
|
+
arch_status = _create_exclusive_or_collision(archive_path, original_bytes, known_sha=original_sha)
|
|
621
|
+
if arch_status == "collision":
|
|
622
|
+
reason = "archive_collision"
|
|
623
|
+
_quarantine(claimed, quarantine_dir, reason,
|
|
624
|
+
[f"archived-original target already holds different content: {archive_path}"])
|
|
625
|
+
_append("quarantined", {"file": claimed.name, "reason": reason})
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
meta = _meta(slug, today, archive_path, vault, hashlib.sha256(result.markdown.encode("utf-8")).hexdigest())
|
|
629
|
+
body_sha = meta["sha256"]
|
|
630
|
+
classification = meta["classification"]
|
|
631
|
+
note_rel = f"raw/{slug}.md"
|
|
632
|
+
note_path = vault / note_rel
|
|
633
|
+
if note_path.exists():
|
|
634
|
+
# Manifest miss but the target id already exists (e.g. a
|
|
635
|
+
# hand-deleted/corrupted manifest) — defense in depth. Compare the
|
|
636
|
+
# frontmatter `sha256:` of the existing note against this body's
|
|
637
|
+
# hash rather than re-serialising: same body -> idempotent no-op,
|
|
638
|
+
# different -> collision, never overwritten.
|
|
639
|
+
from .. import frontmatter as fm
|
|
640
|
+
|
|
641
|
+
existing_meta, _ = fm.parse_text(note_path.read_text(encoding="utf-8"))
|
|
642
|
+
if str(existing_meta.get("sha256", "")) != body_sha:
|
|
643
|
+
reason = "note_id_collision"
|
|
644
|
+
_quarantine(claimed, quarantine_dir, reason,
|
|
645
|
+
[f"raw/{slug}.md already exists with different content"])
|
|
646
|
+
_append("quarantined", {"file": claimed.name, "reason": reason})
|
|
647
|
+
return
|
|
648
|
+
manifest[original_sha] = slug
|
|
649
|
+
_save_manifest(vault, manifest)
|
|
650
|
+
claimed.unlink(missing_ok=True)
|
|
651
|
+
existing_classification = existing_meta.get("classification")
|
|
652
|
+
_append("duplicates", {
|
|
653
|
+
"file": orig_name, "existing_id": slug,
|
|
654
|
+
"classification": str(existing_classification) if existing_classification else None,
|
|
655
|
+
})
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
content = _build_frontmatter(meta, result.markdown)
|
|
659
|
+
core.write_note(
|
|
660
|
+
note_rel, content,
|
|
661
|
+
reason=f"ingest {orig_name} -> raw/{slug}.md "
|
|
662
|
+
f"(original archived at {archive_path.relative_to(vault)})",
|
|
663
|
+
subtree="raw",
|
|
664
|
+
)
|
|
665
|
+
manifest[original_sha] = slug
|
|
666
|
+
_save_manifest(vault, manifest)
|
|
667
|
+
claimed.unlink(missing_ok=True) # promoted; the processing copy is spent
|
|
668
|
+
_append("processed", {
|
|
669
|
+
"file": orig_name, "id": slug, "note": note_rel,
|
|
670
|
+
"archived": str(archive_path.relative_to(vault)),
|
|
671
|
+
"classification": classification,
|
|
672
|
+
"warnings": result.warnings,
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
# S06 (ING-03): zip members / eml attachments re-enter the SAME
|
|
676
|
+
# dispatcher, one level deeper. Only on a FRESH promotion (never on a
|
|
677
|
+
# duplicate/id-collision return above) — a duplicate top-level archive's
|
|
678
|
+
# members were already fully expanded the first time it was ingested, so
|
|
679
|
+
# re-walking them here would just re-report already-known duplicates for
|
|
680
|
+
# no benefit (and risks re-running expensive recursion on every re-drop
|
|
681
|
+
# of the same file).
|
|
682
|
+
nested = result.metadata.get("nested") if isinstance(result.metadata, dict) else None
|
|
683
|
+
if nested:
|
|
684
|
+
_process_nested(
|
|
685
|
+
nested, parent_slug=slug, depth=depth, budget=budget,
|
|
686
|
+
core=core, manifest=manifest, vault=vault, today=today,
|
|
687
|
+
quarantine_dir=quarantine_dir, duplicate_dir=duplicate_dir,
|
|
688
|
+
processing_dir=processing_dir, report=report,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _process_nested(
|
|
693
|
+
nested: list[dict], *, parent_slug: str, depth: int, budget: dict[str, int],
|
|
694
|
+
core: Any, manifest: dict[str, str], vault: Path, today: str,
|
|
695
|
+
quarantine_dir: Path, duplicate_dir: Path, processing_dir: Path,
|
|
696
|
+
report: dict[str, Any],
|
|
697
|
+
) -> None:
|
|
698
|
+
"""Re-enter the dispatcher for each nested (name, data) item a handler
|
|
699
|
+
returned (zip member / eml attachment). Bounded by ``MAX_NESTED_DEPTH``
|
|
700
|
+
and by the shared ``budget`` (bytes + item count) across the WHOLE
|
|
701
|
+
recursion tree for this one top-level candidate — see the module-level
|
|
702
|
+
constants' docstring. A poison nested item is quarantined on its own;
|
|
703
|
+
it never aborts its siblings or the parent's already-completed promotion."""
|
|
704
|
+
if not nested:
|
|
705
|
+
return
|
|
706
|
+
if depth >= MAX_NESTED_DEPTH:
|
|
707
|
+
for item in nested:
|
|
708
|
+
report["skipped"].append({
|
|
709
|
+
"file": H.strip_control_chars(item.get("name") or "?"),
|
|
710
|
+
"reason": "nested_depth_exceeded", "parent": parent_slug,
|
|
711
|
+
})
|
|
712
|
+
return
|
|
713
|
+
|
|
714
|
+
for idx, item in enumerate(nested):
|
|
715
|
+
name = H.strip_control_chars(item.get("name") or f"member-{idx}")
|
|
716
|
+
data = item.get("data", b"")
|
|
717
|
+
if budget["items"] >= MAX_TOTAL_NESTED_ITEMS or budget["bytes"] + len(data) > MAX_TOTAL_NESTED_BYTES:
|
|
718
|
+
report["quarantined"].append({
|
|
719
|
+
"file": name, "reason": "nested_budget_exceeded", "parent": parent_slug,
|
|
720
|
+
})
|
|
721
|
+
continue
|
|
722
|
+
budget["items"] += 1
|
|
723
|
+
budget["bytes"] += len(data)
|
|
724
|
+
|
|
725
|
+
# Member/attachment names NEVER become filesystem paths directly —
|
|
726
|
+
# only a slugified BASENAME feeds a wholly synthetic temp filename.
|
|
727
|
+
ext = Path(name).suffix.lower()
|
|
728
|
+
safe_stem = _slugify_stem(Path(name).stem)
|
|
729
|
+
synth_name = f"{parent_slug}-nested-{idx}-{safe_stem}{ext}"
|
|
730
|
+
temp_path = _unique_dest(processing_dir, synth_name)
|
|
731
|
+
try:
|
|
732
|
+
processing_dir.mkdir(parents=True, exist_ok=True)
|
|
733
|
+
temp_path.write_bytes(data)
|
|
734
|
+
except OSError as exc:
|
|
735
|
+
report["skipped"].append({
|
|
736
|
+
"file": name, "reason": f"nested_write_error:{type(exc).__name__}", "parent": parent_slug,
|
|
737
|
+
})
|
|
738
|
+
continue
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
_process_claimed(
|
|
742
|
+
temp_path, name, original_bytes=data, original_sha=_sha256_bytes(data),
|
|
743
|
+
core=core, manifest=manifest, vault=vault, today=today,
|
|
744
|
+
quarantine_dir=quarantine_dir, duplicate_dir=duplicate_dir,
|
|
745
|
+
processing_dir=processing_dir, report=report,
|
|
746
|
+
depth=depth + 1, budget=budget, parent=parent_slug,
|
|
747
|
+
)
|
|
748
|
+
except Exception as exc:
|
|
749
|
+
if _is_systemic_error(exc):
|
|
750
|
+
# A batch-wide outage (signing key vanished, disk full) hits
|
|
751
|
+
# every remaining nested item (and every remaining top-level
|
|
752
|
+
# candidate) identically — bubble it up so run_ingest's own
|
|
753
|
+
# systemic handling takes over, rather than quarantining a
|
|
754
|
+
# perfectly good nested item as if it were poison.
|
|
755
|
+
raise
|
|
756
|
+
# A per-item defect. Unlike top-level candidates, a nested item
|
|
757
|
+
# has no stable identity across separate `brain sync` runs to
|
|
758
|
+
# retry against (it is re-derived from its parent archive every
|
|
759
|
+
# time) — quarantine it immediately, never abort its siblings.
|
|
760
|
+
reason = "nested_processing_error"
|
|
761
|
+
if temp_path.exists():
|
|
762
|
+
_quarantine(temp_path, quarantine_dir, reason, [f"{type(exc).__name__}: {exc}"])
|
|
763
|
+
report["quarantined"].append({"file": name, "reason": reason, "parent": parent_slug})
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# Leading-date filename styles a dropped document commonly carries. Anchored
|
|
767
|
+
# at the start and followed by a non-digit so partial numbers (audit codes
|
|
768
|
+
# like "2024_011", MMYYYY stamps like "042022") never misparse into a date.
|
|
769
|
+
_DOC_DATE_RES = (
|
|
770
|
+
re.compile(r"^(\d{4})[-_. ](\d{1,2})[-_. ](\d{1,2})(?!\d)"), # 2026-03-25
|
|
771
|
+
re.compile(r"^(\d{4})(\d{2})(\d{2})(?!\d)"), # 20260325
|
|
772
|
+
re.compile(r"^(\d{2})(\d{2})(\d{2})(?!\d)"), # 260325 (YYMMDD)
|
|
773
|
+
# Embedded full-ISO fallback (workspace naming style:
|
|
774
|
+
# "_scenario_board_2026-06-15_v16.md"). Only the unambiguous hyphenated
|
|
775
|
+
# ISO form is accepted mid-name — never the digit-run styles, which would
|
|
776
|
+
# false-positive on version/id numbers.
|
|
777
|
+
re.compile(r"(?<!\d)(\d{4})-(\d{2})-(\d{2})(?!\d)"),
|
|
778
|
+
# Trailing YYYYMMDD before the extension ("b2c-gp-analysis-20260331.md")
|
|
779
|
+
# — end-anchored so a digit run mid-name never matches; the calendar +
|
|
780
|
+
# range checks below still reject non-dates.
|
|
781
|
+
re.compile(r"(?<!\d)(\d{4})(\d{2})(\d{2})(?=\.[A-Za-z0-9]+$|$)"),
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _derive_document_date(name: str, today: str) -> str | None:
|
|
786
|
+
"""Best-effort ``document_date`` from a leading date in the ORIGINAL
|
|
787
|
+
filename. Without it, a bulk re-ingestion of old documents ranks as the
|
|
788
|
+
freshest content in the vault (recency keys on capture date) and "latest"
|
|
789
|
+
queries ground on months-old material. Conservative by design: anything
|
|
790
|
+
ambiguous, non-calendar, pre-1990 or in the future returns None — an
|
|
791
|
+
undated source is NEUTRAL in recency ranking, a misdated one poisons it."""
|
|
792
|
+
for rx in _DOC_DATE_RES:
|
|
793
|
+
# search(), not match(): the first three patterns are ^-anchored (so
|
|
794
|
+
# search behaves identically), the embedded-ISO fallback is not.
|
|
795
|
+
m = rx.search(name)
|
|
796
|
+
if not m:
|
|
797
|
+
continue
|
|
798
|
+
y, mo, d = (int(g) for g in m.groups())
|
|
799
|
+
if y < 100:
|
|
800
|
+
y += 2000
|
|
801
|
+
try:
|
|
802
|
+
dd = _dt.date(y, mo, d)
|
|
803
|
+
except ValueError:
|
|
804
|
+
continue
|
|
805
|
+
if y < 1990 or dd > _dt.date.fromisoformat(today):
|
|
806
|
+
continue
|
|
807
|
+
return dd.isoformat()
|
|
808
|
+
return None
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _meta(slug: str, today: str, archive_path: Path, vault: Path, body_sha: str) -> dict[str, Any]:
|
|
812
|
+
meta: dict[str, Any] = {
|
|
813
|
+
"id": slug,
|
|
814
|
+
"type": "source",
|
|
815
|
+
"classification": "Internal", # ADR-0003: unlabelled -> MNPI; ingest
|
|
816
|
+
# DECLARES Internal explicitly, matching
|
|
817
|
+
# capture.py's own missing-classification
|
|
818
|
+
# default (never left unlabelled).
|
|
819
|
+
"captured": today,
|
|
820
|
+
"origin": str(archive_path.relative_to(vault)),
|
|
821
|
+
"sha256": body_sha,
|
|
822
|
+
"immutable": True,
|
|
823
|
+
}
|
|
824
|
+
doc_date = _derive_document_date(archive_path.name, today)
|
|
825
|
+
if doc_date and doc_date != today:
|
|
826
|
+
meta["document_date"] = doc_date
|
|
827
|
+
return meta
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _quarantine(claimed: Path, quarantine_dir: Path, reason: str, warnings: list[str]) -> None:
|
|
831
|
+
dest_dir = quarantine_dir / reason
|
|
832
|
+
# C3: _move is an os.rename, which SILENTLY REPLACES a same-named file —
|
|
833
|
+
# a second same-named corrupt drop would clobber the first quarantined
|
|
834
|
+
# original (the only copy). Uniquify the destination like every other
|
|
835
|
+
# sink (_claim, _sweep_stale_processing) does.
|
|
836
|
+
dest = _unique_dest(dest_dir, claimed.name)
|
|
837
|
+
_move(claimed, dest)
|
|
838
|
+
report_lines = [f"quarantine_reason: {reason}"] + [f"- {w}" for w in warnings]
|
|
839
|
+
(dest_dir / f"{dest.name}.reason.txt").write_text("\n".join(report_lines) + "\n", encoding="utf-8")
|