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/__init__.py +10 -0
- bf/__main__.py +5 -0
- bf/cli.py +298 -0
- bf/collect.py +274 -0
- bf/config.py +152 -0
- bf/evaluate.py +84 -0
- bf/health.py +48 -0
- bf/index.py +559 -0
- bf/markdown.py +277 -0
- bf/mcp.py +78 -0
- bf/models.py +279 -0
- bf/py.typed +0 -0
- bf/records.py +277 -0
- bf/retrieve.py +173 -0
- bf/storage.py +223 -0
- bf/update.py +58 -0
- bf/usage.py +56 -0
- bf/validate.py +77 -0
- brain_framework-9.0.1.dist-info/METADATA +136 -0
- brain_framework-9.0.1.dist-info/RECORD +24 -0
- brain_framework-9.0.1.dist-info/WHEEL +4 -0
- brain_framework-9.0.1.dist-info/entry_points.txt +3 -0
- brain_framework-9.0.1.dist-info/licenses/LICENSE +21 -0
- brain_framework-9.0.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +2187 -0
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
|