brain-framework 9.0.1__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.
bf/records.py ADDED
@@ -0,0 +1,277 @@
1
+ """Records live in monthly JSON Lines partitions: one line per source item, upserted by id."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager, suppress
8
+ from datetime import date
9
+ from typing import Annotated, Literal
10
+
11
+ from pydantic import Field, ValidationError
12
+
13
+ from bf.models import MAX_PARTITION, NAME, Error, Model, Record, decode, encode, explain
14
+ from bf.storage import Store, reader
15
+
16
+ SNAPSHOT = "snapshot"
17
+ UNDATED = "undated"
18
+ _PENDING = "memories/.pending"
19
+ _MANIFEST = _PENDING + "/manifest.json"
20
+ _MANIFEST_LIMIT = 1 << 20
21
+
22
+
23
+ class _Change(Model):
24
+ name: Annotated[str, Field(pattern=r"^(?:[0-9]{4}-[0-9]{2}|snapshot|undated)\.jsonl$")]
25
+ existed: bool
26
+
27
+
28
+ class _Journal(Model):
29
+ version: Literal[1] = 1
30
+ source: Annotated[str, Field(pattern=NAME)]
31
+ complete: bool = False
32
+ changes: Annotated[list[_Change], Field(max_length=10_000)]
33
+
34
+
35
+ def _pending(store: Store) -> bool:
36
+ try:
37
+ with store.parent(_MANIFEST):
38
+ return True
39
+ except FileNotFoundError:
40
+ return False
41
+
42
+
43
+ def _clear(store: Store) -> None:
44
+ names = store.files(_PENDING)
45
+ if any(
46
+ not re.fullmatch(r"memories/\.pending/(?:manifest\.json|[0-9]+\.before|\.write-[0-9a-f]{32})", n) for n in names
47
+ ):
48
+ raise Error("unexpected file in memories/.pending; preserve it and repair the pending transaction")
49
+ # The completion marker remains until every staged file is gone.
50
+ for name in sorted(names, key=lambda name: name == _MANIFEST):
51
+ store.delete(name)
52
+ with suppress(FileNotFoundError):
53
+ store.rmdir(_PENDING)
54
+
55
+
56
+ def recover(store: Store) -> None:
57
+ """Roll back an interrupted commit, under the caller's exclusive brain writer lock.
58
+
59
+ Originals and the manifest are durable evidence under memories/, never disposable state.
60
+ A completion marker means all partitions were committed (or restored) and only cleanup remains.
61
+ """
62
+ if not _pending(store):
63
+ return
64
+ try:
65
+ raw = store.read(_MANIFEST, _MANIFEST_LIMIT)
66
+ except FileNotFoundError:
67
+ # Preparation never changes a source before its manifest is durable.
68
+ _clear(store)
69
+ return
70
+ try:
71
+ journal = _Journal.model_validate(decode(raw))
72
+ except ValidationError as error:
73
+ raise Error("invalid memories/.pending manifest; preserve it and repair the pending transaction") from error
74
+ if len({change.name for change in journal.changes}) != len(journal.changes):
75
+ raise Error("duplicate paths in memories/.pending manifest")
76
+ if not journal.complete:
77
+ # Check all backups before restoring any source file.
78
+ for number, change in enumerate(journal.changes):
79
+ if change.existed:
80
+ store.read(f"{_PENDING}/{number}.before", MAX_PARTITION)
81
+ for number, change in enumerate(journal.changes):
82
+ target = f"memories/{journal.source}/{change.name}"
83
+ if change.existed:
84
+ store.write(target, store.read(f"{_PENDING}/{number}.before", MAX_PARTITION))
85
+ else:
86
+ with suppress(FileNotFoundError):
87
+ store.delete(target)
88
+ journal.complete = True
89
+ store.write(_MANIFEST, encode(journal.model_dump()))
90
+ _clear(store)
91
+
92
+
93
+ def require_ready(store: Store) -> None:
94
+ """Keep read operations from applying an untrusted on-disk recovery journal."""
95
+ if _pending(store):
96
+ raise Error("records contain an interrupted transaction; run bf build to recover it before reading")
97
+
98
+
99
+ @contextmanager
100
+ def reading(store: Store) -> Iterator[None]:
101
+ """Read a consistent set of partitions without changing durable evidence."""
102
+ with reader(store):
103
+ require_ready(store)
104
+ yield
105
+
106
+
107
+ def _commit(store: Store, source: str, replacements: dict[str, bytes | None]) -> None:
108
+ if not replacements:
109
+ return
110
+ changes = []
111
+ for name in replacements:
112
+ try:
113
+ store.fingerprint(name)
114
+ existed = True
115
+ except FileNotFoundError:
116
+ existed = False
117
+ changes.append(_Change(name=name.removeprefix(f"memories/{source}/"), existed=existed))
118
+ journal = _Journal(source=source, changes=changes)
119
+ manifest = encode(journal.model_dump())
120
+ if len(manifest) > _MANIFEST_LIMIT:
121
+ raise Error("record transaction exceeds its manifest limit")
122
+ try:
123
+ for number, (name, change) in enumerate(zip(replacements, changes, strict=True)):
124
+ if change.existed:
125
+ store.write(f"{_PENDING}/{number}.before", store.read(name, MAX_PARTITION))
126
+ store.write(_MANIFEST, manifest)
127
+ for name, data in replacements.items():
128
+ if data is None:
129
+ store.delete(name)
130
+ else:
131
+ store.write(name, data)
132
+ journal.complete = True
133
+ store.write(_MANIFEST, encode(journal.model_dump()))
134
+ except BaseException as error:
135
+ try:
136
+ recover(store)
137
+ except (Error, OSError) as recovery_error:
138
+ raise Error("record transaction needs recovery; preserve memories/.pending and retry") from recovery_error
139
+ raise error
140
+ # A cleanup failure cannot turn a durable successful commit into a failed collection.
141
+ with suppress(OSError):
142
+ _clear(store)
143
+
144
+
145
+ def partition(record: Record) -> str:
146
+ return record.time[:7] if record.time else UNDATED
147
+
148
+
149
+ def path(source: str, name: str) -> str:
150
+ return f"memories/{source}/{name}.jsonl"
151
+
152
+
153
+ def line(record: Record) -> bytes:
154
+ return encode(record.model_dump(exclude_defaults=True))
155
+
156
+
157
+ def partitions(store: Store, source: str = "") -> list[str]:
158
+ """Record partitions of one source, or of every source; other files under memories/ are ignored."""
159
+ return [name for name in store.files(f"memories/{source}" if source else "memories") if name.endswith(".jsonl")]
160
+
161
+
162
+ def parse(name: str, data: bytes) -> Iterator[Record]:
163
+ for number, raw in enumerate(data.splitlines(), 1):
164
+ if not raw.strip():
165
+ continue
166
+ try:
167
+ yield Record.model_validate(decode(raw))
168
+ except ValidationError as error:
169
+ raise Error(f"{name}:{number}: invalid record: {explain(error)}") from error
170
+ except Error as error:
171
+ raise Error(f"{name}:{number}: {error}") from error
172
+
173
+
174
+ def load(store: Store, name: str) -> list[Record]:
175
+ source_of(name)
176
+ return list(parse(name, store.read(name, MAX_PARTITION)))
177
+
178
+
179
+ def source_of(name: str) -> str:
180
+ parts = name.split("/")
181
+ if len(parts) != 3 or parts[0] != "memories" or not re.fullmatch(NAME, parts[1]):
182
+ raise Error(f"{name}: expected memories/<source>/<YYYY-MM|undated|snapshot>.jsonl")
183
+ filename = parts[2]
184
+ if filename not in {"undated.jsonl", "snapshot.jsonl"}:
185
+ try:
186
+ if not re.fullmatch(r"[0-9]{4}-[0-9]{2}\.jsonl", filename):
187
+ raise ValueError
188
+ date.fromisoformat(filename.removesuffix(".jsonl") + "-01")
189
+ except ValueError:
190
+ raise Error(f"{name}: expected a YYYY-MM, undated or snapshot JSON Lines partition") from None
191
+ return parts[1]
192
+
193
+
194
+ def upsert(store: Store, source: str, incoming: list[Record], *, snapshot: bool) -> dict[str, int]:
195
+ """Window sources add or replace items by id; snapshot sources replace their complete catalog.
196
+
197
+ Each id appears once per source, in the partition of its event month, so a rescheduled
198
+ event moves between partitions instead of leaving a stale copy behind.
199
+ """
200
+ recover(store)
201
+ if not re.fullmatch(NAME, source):
202
+ raise Error("invalid record source")
203
+ if not incoming and not snapshot:
204
+ return {"added": 0, "updated": 0, "unchanged": 0, "removed": 0}
205
+ existing: dict[str, dict[str, Record]] = {}
206
+ located: dict[str, str] = {}
207
+ for name in partitions(store, source):
208
+ key = name.rsplit("/", 1)[1].removesuffix(".jsonl")
209
+ partition_records: dict[str, Record] = {}
210
+ for record in load(store, name):
211
+ if record.id in located:
212
+ raise Error(f"source {source} contains duplicate record ids; run bf validate and reconcile them")
213
+ located[record.id] = key
214
+ partition_records[record.id] = record
215
+ existing[key] = partition_records
216
+ before = {key: dict(records) for key, records in existing.items()}
217
+ counts = {"added": 0, "updated": 0, "unchanged": 0, "removed": 0}
218
+ if snapshot:
219
+ wanted = {r.id for r in incoming}
220
+ counts["removed"] = len(located.keys() - wanted)
221
+ existing = {SNAPSHOT: {}}
222
+ for record in incoming:
223
+ key = SNAPSHOT if snapshot else partition(record)
224
+ previous = before.get(located.get(record.id, ""), {}).get(record.id)
225
+ if previous and previous.updated and record.updated and previous.updated > record.updated:
226
+ # Historical backfills must not replace a known newer upstream revision.
227
+ record = previous
228
+ key = SNAPSHOT if snapshot else partition(record)
229
+ if previous is None:
230
+ counts["added"] += 1
231
+ elif previous.model_dump(exclude={"attributes": {"observed"}}) == record.model_dump(
232
+ exclude={"attributes": {"observed"}}
233
+ ):
234
+ counts["unchanged"] += 1
235
+ if previous.observed:
236
+ record = previous
237
+ else:
238
+ counts["updated"] += 1
239
+ if not snapshot and located.get(record.id, key) != key:
240
+ existing[located[record.id]].pop(record.id)
241
+ existing.setdefault(key, {})[record.id] = record
242
+ replacements: dict[str, bytes | None] = {}
243
+ for key in sorted(before.keys() | existing.keys()):
244
+ records = existing.get(key, {})
245
+ if records == before.get(key, {}):
246
+ continue
247
+ if records:
248
+ ordered = sorted(records.values(), key=lambda r: (r.time, r.id))
249
+ data = b"".join(line(r) for r in ordered)
250
+ if len(data) > MAX_PARTITION:
251
+ raise Error(f"{path(source, key)}: record partition exceeds its {MAX_PARTITION}-byte limit")
252
+ replacements[path(source, key)] = data
253
+ else:
254
+ replacements[path(source, key)] = None
255
+ _commit(store, source, replacements)
256
+ return counts
257
+
258
+
259
+ def find(store: Store, source: str, record_id: str) -> tuple[str, Record] | None:
260
+ """Scan one source directly: exact record reads never depend on the derived index."""
261
+ if not re.fullmatch(NAME, source):
262
+ return None
263
+ unreadable = False
264
+ with reading(store):
265
+ for name in sorted(partitions(store, source), reverse=True):
266
+ try:
267
+ items = load(store, name)
268
+ except Error, OSError, UnicodeError:
269
+ # Match indexing's file boundary: one malformed partition must not hide other evidence.
270
+ unreadable = True
271
+ continue
272
+ for record in items:
273
+ if record.id == record_id:
274
+ return name, record
275
+ if unreadable:
276
+ raise Error(f"source {source} has unreadable partitions; run bf validate before concluding a record is absent")
277
+ return None
bf/retrieve.py ADDED
@@ -0,0 +1,173 @@
1
+ """Search across brains and read exact notes, sections and records."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from contextlib import suppress
7
+
8
+ from bf import index, records, usage
9
+ from bf.config import load
10
+ from bf.health import source_health
11
+ from bf.markdown import authored, section, split_ref
12
+ from bf.models import MAX_REPLY, NOTICE, Error, Query, encode
13
+ from bf.storage import Store, relative
14
+
15
+
16
+ def bounded(value: dict[str, object], limit: int = MAX_REPLY) -> dict[str, object]:
17
+ if len(encode(value)) > limit:
18
+ raise Error("response exceeds its byte limit; narrow the request")
19
+ return value
20
+
21
+
22
+ def search(stores: list[Store], query: Query, *, counted: bool = True) -> dict[str, object]:
23
+ """Merge per-brain results: relevance keeps each brain's order, a time window interleaves by time."""
24
+ results: list[list[dict[str, object]]] = []
25
+ stale = []
26
+ brains = []
27
+ problems: list[dict[str, object]] = []
28
+ last_error: Exception | None = None
29
+ owners: set[tuple[str, str]] = set()
30
+ identity = index.identity(query.text)
31
+ for store in stores:
32
+ name = store.root.name
33
+ try:
34
+ config = load(store)
35
+ name = config.name
36
+ with index.database(store) as (connection, state):
37
+ items = index.search(
38
+ connection, query, active_sources={n for n, sensor in config.sensors.items() if sensor.enabled}
39
+ )
40
+ skipped = index.problems(connection)
41
+ if identity:
42
+ owners.update((name, ref) for ref in index.identity_owners(connection, query.text))
43
+ except (Error, OSError, UnicodeError, sqlite3.DatabaseError) as error:
44
+ if len(stores) == 1:
45
+ raise
46
+ last_error = error
47
+ message = str(error) if isinstance(error, Error) else "inaccessible brain or cache; run bf status"
48
+ problems.append({"brain": name, "error": message.replace(str(store.root), "<brain>")})
49
+ continue
50
+ if skipped:
51
+ problems.append({"brain": name, "files": skipped})
52
+ brains.append((store, name))
53
+ if state != "ready":
54
+ stale.append(name)
55
+ if counted:
56
+ usage.note(store, "search", len(items))
57
+ results.append([{"brain": name, **item} for item in items])
58
+ if not results:
59
+ raise Error("no selected brain could be searched; run bf status with --brain for each brain") from last_error
60
+ if identity or query.recent or not query.text.strip():
61
+ merged = sorted(
62
+ (i for r in results for i in r), key=lambda i: (str(i.get("time", "")), str(i["ref"])), reverse=True
63
+ )
64
+ if identity:
65
+ # Keep canonical refs, alias owners and related evidence in that order, then newest first.
66
+ merged.sort(
67
+ key=lambda item: (
68
+ str(item["ref"]) != query.text.strip(),
69
+ (str(item["brain"]), str(item["ref"])) not in owners,
70
+ )
71
+ )
72
+ else:
73
+ # Interleave by rank: bm25 scores from different corpora are not comparable.
74
+ merged = [i for rank in range(query.limit) for r in results if rank < len(r) for i in [r[rank]]]
75
+ selected = merged[: query.limit]
76
+ reply: dict[str, object] = {"items": selected, "notice": NOTICE}
77
+ coverage = []
78
+ for store, name in brains:
79
+ names = {str(item["source"]) for item in selected if item["brain"] == name and "source" in item}
80
+ if query.source:
81
+ names.add(query.source)
82
+ if names:
83
+ try:
84
+ coverage.extend(
85
+ {"brain": name, "source": source, **health}
86
+ for source, health in source_health(store, names).items()
87
+ if source in names
88
+ )
89
+ except Error, OSError, UnicodeError:
90
+ problems.append({"brain": name, "error": "collection coverage is unavailable; run bf status"})
91
+ if coverage:
92
+ reply["sources"] = coverage
93
+ if stale:
94
+ reply["stale"] = stale
95
+ if problems:
96
+ reply["problems"] = problems
97
+ return bounded(reply)
98
+
99
+
100
+ def _brain(stores: list[Store], name: str) -> list[Store]:
101
+ if not name:
102
+ return stores
103
+ chosen = [store for store in stores if load(store).name == name]
104
+ if not chosen:
105
+ raise Error(f"unknown brain {name}; pass one of the searched brains")
106
+ return chosen
107
+
108
+
109
+ def read(stores: list[Store], ref: str, brain: str = "") -> dict[str, object]:
110
+ """Resolve a note path, note section, `source:id` record or explicit identity in exactly one brain."""
111
+ if not ref or len(ref) > 8192:
112
+ raise Error("expected a reference of at most 8192 characters")
113
+ found = [(store, value) for store in _brain(stores, brain) if (value := _read(store, ref)) is not None]
114
+ if not found:
115
+ raise Error("reference not found; use bf search to locate it")
116
+ if len(found) > 1:
117
+ raise Error("reference exists in several brains; pass --brain NAME")
118
+ reply = bounded({**found[0][1], "notice": NOTICE})
119
+ usage.note(found[0][0], "read", 1)
120
+ return reply
121
+
122
+
123
+ def _read(store: Store, ref: str) -> dict[str, object] | None:
124
+ name = load(store).name
125
+ path, fragment = split_ref(ref)
126
+ if authored(path):
127
+ relative(path)
128
+ try:
129
+ data = store.read(path, 4 << 20)
130
+ except FileNotFoundError:
131
+ return None
132
+ text = section(path, data, fragment) if fragment else data.decode("utf-8")
133
+ return {"brain": name, "ref": ref, "text": text}
134
+ located, aliases, cache_error = None, [], None
135
+ try:
136
+ with index.database(store) as (connection, _state):
137
+ located = connection.execute("SELECT path FROM items WHERE ref=?", (ref,)).fetchone()
138
+ aliases = connection.execute(
139
+ "SELECT i.ref FROM names n JOIN items i ON i.id=n.item WHERE n.name=? ORDER BY i.ref LIMIT 2",
140
+ (ref,),
141
+ ).fetchall()
142
+ except (Error, OSError, sqlite3.DatabaseError) as error:
143
+ # An exact source:id still has a file recovery path when its disposable cache is unavailable.
144
+ cache_error = error
145
+ source, separator, record_id = ref.partition(":")
146
+ if separator:
147
+ # The cache only locates the partition; the answer always comes from the record file itself.
148
+ found = None
149
+ if located:
150
+ hint = str(located["path"])
151
+ parts: tuple[str, ...] = ()
152
+ with suppress(Error):
153
+ parts = relative(hint)
154
+ if len(parts) == 3 and parts[:2] == ("memories", source) and parts[2].endswith(".jsonl"):
155
+ # Journal checks run before suppression; only an unusable cache hint is ignored.
156
+ with records.reading(store), suppress(Error, OSError, UnicodeError):
157
+ hinted = next((r for r in records.load(store, hint) if r.id == record_id), None)
158
+ if hinted:
159
+ found = hint, hinted
160
+ found = found or records.find(store, source, record_id)
161
+ if found:
162
+ return {
163
+ "brain": name,
164
+ "ref": ref,
165
+ "path": found[0],
166
+ "record": found[1].model_dump(exclude_defaults=True),
167
+ "collection": source_health(store, [source])[source],
168
+ }
169
+ if cache_error:
170
+ raise Error("the search cache is unavailable; run bf build to resolve identities") from cache_error
171
+ if len(aliases) > 1:
172
+ raise Error("ambiguous identity; use bf search and read an exact ref")
173
+ return _read(store, aliases[0]["ref"]) if aliases and aliases[0]["ref"] != ref else None
bf/storage.py ADDED
@@ -0,0 +1,223 @@
1
+ """Confined, bounded filesystem access and one physical-brain writer lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import os
7
+ import stat
8
+ import time
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager, suppress
11
+ from pathlib import Path, PurePosixPath
12
+
13
+ from bf.models import MAX_FILE, MAX_FILES, Error, digest
14
+
15
+
16
+ class BusyError(Error):
17
+ """Another process holds the brain writer lock."""
18
+
19
+
20
+ _DIR = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
21
+
22
+
23
+ def relative(value: str) -> tuple[str, ...]:
24
+ path = PurePosixPath(value)
25
+ parts = value.split("/")
26
+ if path.is_absolute() or any(p in {"", ".", ".."} for p in parts) or "\x00" in value or "\\" in value:
27
+ raise Error("expected a normalized brain-relative path")
28
+ return tuple(parts)
29
+
30
+
31
+ class Store:
32
+ """All descendants are opened through no-follow directory descriptors."""
33
+
34
+ def __init__(self, root: Path) -> None:
35
+ try:
36
+ self.root = root.resolve(strict=True)
37
+ except OSError as error:
38
+ raise Error("brain directory does not exist; pass an existing --brain or BF_BRAIN") from error
39
+ if not self.root.is_dir():
40
+ raise Error("brain must be a directory")
41
+
42
+ @contextmanager
43
+ def parent(self, name: str, *, create: bool = False) -> Iterator[tuple[int, str]]:
44
+ parts = relative(name)
45
+ descriptor = os.open(self.root, _DIR)
46
+ try:
47
+ for part in parts[:-1]:
48
+ if create:
49
+ try:
50
+ os.mkdir(part, 0o700, dir_fd=descriptor)
51
+ except FileExistsError:
52
+ pass
53
+ else:
54
+ # Persist the new directory entry before a transaction can depend on its files.
55
+ os.fsync(descriptor)
56
+ child = os.open(part, _DIR, dir_fd=descriptor)
57
+ os.close(descriptor)
58
+ descriptor = child
59
+ yield descriptor, parts[-1]
60
+ finally:
61
+ os.close(descriptor)
62
+
63
+ def read(self, name: str, limit: int = MAX_FILE) -> bytes:
64
+ with self.parent(name) as (parent, leaf):
65
+ fd = os.open(leaf, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=parent)
66
+ with os.fdopen(fd, "rb") as stream:
67
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
68
+ raise Error(f"{name}: expected a regular file")
69
+ data = stream.read(limit + 1)
70
+ if len(data) > limit:
71
+ raise Error(f"{name}: file exceeds {limit} bytes")
72
+ return data
73
+
74
+ def write(self, name: str, data: bytes) -> None:
75
+ """Replace a regular file atomically; readers see the old or the new bytes, never a mix."""
76
+ with self.parent(name, create=True) as (parent, leaf):
77
+ temporary = ".write-" + os.urandom(16).hex()
78
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent)
79
+ try:
80
+ with os.fdopen(fd, "wb") as stream:
81
+ stream.write(data)
82
+ stream.flush()
83
+ os.fsync(stream.fileno())
84
+ try:
85
+ info = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
86
+ except FileNotFoundError:
87
+ pass
88
+ else:
89
+ if not stat.S_ISREG(info.st_mode):
90
+ raise Error(f"{name}: refusing to replace a non-regular file")
91
+ os.replace(temporary, leaf, src_dir_fd=parent, dst_dir_fd=parent)
92
+ os.fsync(parent)
93
+ finally:
94
+ with suppress(FileNotFoundError):
95
+ os.unlink(temporary, dir_fd=parent)
96
+
97
+ def delete(self, name: str) -> None:
98
+ with self.parent(name) as (parent, leaf):
99
+ if not stat.S_ISREG(os.stat(leaf, dir_fd=parent, follow_symlinks=False).st_mode):
100
+ raise Error(f"{name}: refusing to delete a non-regular file")
101
+ os.unlink(leaf, dir_fd=parent)
102
+ os.fsync(parent)
103
+
104
+ def rmdir(self, name: str) -> None:
105
+ """Remove an empty directory without following links."""
106
+ with self.parent(name) as (parent, leaf):
107
+ if not stat.S_ISDIR(os.stat(leaf, dir_fd=parent, follow_symlinks=False).st_mode):
108
+ raise Error(f"{name}: expected a directory")
109
+ os.rmdir(leaf, dir_fd=parent)
110
+ os.fsync(parent)
111
+
112
+ def files(self, directory: str) -> list[str]:
113
+ """Bound the entire traversal, including directories and ignored extensions."""
114
+ relative(directory)
115
+ result: list[str] = []
116
+ visited = 0
117
+
118
+ def visit(fd: int, prefix: str) -> None:
119
+ nonlocal visited
120
+ if prefix.count("/") > 64:
121
+ raise Error("directory exceeds the 64-level depth limit")
122
+ with os.scandir(fd) as entries:
123
+ for entry in entries:
124
+ visited += 1
125
+ if visited > MAX_FILES:
126
+ raise Error(f"{directory} exceeds the {MAX_FILES:,}-entry scan limit")
127
+ path = f"{prefix}/{entry.name}"
128
+ info = entry.stat(follow_symlinks=False)
129
+ if stat.S_ISDIR(info.st_mode):
130
+ child = os.open(entry.name, _DIR, dir_fd=fd)
131
+ try:
132
+ visit(child, path)
133
+ finally:
134
+ os.close(child)
135
+ elif stat.S_ISREG(info.st_mode):
136
+ result.append(path)
137
+ else:
138
+ raise Error(f"{path}: symlinks and special files are forbidden")
139
+
140
+ try:
141
+ with self.parent(directory) as (parent, leaf):
142
+ fd = os.open(leaf, _DIR, dir_fd=parent)
143
+ except FileNotFoundError:
144
+ return []
145
+ try:
146
+ visit(fd, directory)
147
+ finally:
148
+ os.close(fd)
149
+ return sorted(result)
150
+
151
+ def fingerprint(self, name: str) -> tuple[int, int, int, int]:
152
+ """Size, mtime, ctime and inode: ctime catches an edit whose mtime was preserved."""
153
+ with self.parent(name) as (parent, leaf):
154
+ info = os.stat(leaf, dir_fd=parent, follow_symlinks=False)
155
+ if not stat.S_ISREG(info.st_mode):
156
+ raise Error(f"{name}: expected a regular file")
157
+ return info.st_size, info.st_mtime_ns, info.st_ctime_ns, info.st_ino
158
+
159
+
160
+ def state_store(root: Path) -> Store:
161
+ """Private runtime state stays outside the evidence brain."""
162
+ home = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state"))).expanduser()
163
+ if not home.is_absolute() or home.resolve().is_relative_to(root):
164
+ raise Error("state directory must be absolute and outside the brain")
165
+ directory = home / "bf" / digest(os.fsencode(root))
166
+ # Reject redirected state roots before creating any descendant.
167
+ for path in [directory, *directory.parents]:
168
+ if path.is_symlink():
169
+ raise Error("state directory may not contain symlinks")
170
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
171
+ if directory.stat().st_uid != os.getuid() or directory.stat().st_mode & 0o077:
172
+ raise Error("state directory must be owned by you with mode 700")
173
+ return Store(directory)
174
+
175
+
176
+ @contextmanager
177
+ def _lock(store: Store, name: str, wait: float, *, shared: bool = False) -> Iterator[None]:
178
+ state = state_store(store.root)
179
+ with state.parent(name) as (parent, leaf):
180
+ flags = os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK
181
+ try:
182
+ fd = os.open(leaf, flags | os.O_CREAT, 0o600, dir_fd=parent)
183
+ except FileNotFoundError:
184
+ # Concurrent first creation can report ENOENT on macOS. Reopen only an existing lock;
185
+ # keep the pinned parent and safety flags, and never replace a missing lock on recovery.
186
+ fd = os.open(leaf, flags, dir_fd=parent)
187
+ try:
188
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
189
+ raise Error("invalid writer lock")
190
+ deadline = time.monotonic() + wait
191
+ while True:
192
+ try:
193
+ fcntl.flock(fd, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB)
194
+ break
195
+ except BlockingIOError as error:
196
+ if time.monotonic() >= deadline:
197
+ raise BusyError("another writer is active for this brain; retry after it finishes") from error
198
+ time.sleep(0.05)
199
+ yield
200
+ finally:
201
+ os.close(fd)
202
+
203
+
204
+ @contextmanager
205
+ def writer(store: Store, wait: float = 0) -> Iterator[None]:
206
+ """Serialize writers of one physical brain; wait up to `wait` seconds for another writer."""
207
+ with _lock(store, "write.lock", wait):
208
+ yield
209
+
210
+
211
+ @contextmanager
212
+ def reader(store: Store, wait: float = 120) -> Iterator[None]:
213
+ """Keep record partitions stable during one exact read; readers can run concurrently."""
214
+ with _lock(store, "write.lock", wait, shared=True):
215
+ yield
216
+
217
+
218
+ @contextmanager
219
+ def collecting(store: Store, sensor: str, wait: float = 0) -> Iterator[None]:
220
+ """Order runs of one sensor without blocking offline reads while its provider is running."""
221
+ relative(sensor)
222
+ with _lock(store, f"collect-{sensor}.lock", wait):
223
+ yield