zikaron 0.1.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.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""The verbs that change a knowledge base: add, rename, refresh, unlock, remove.
|
|
2
|
+
|
|
3
|
+
Looking at one without changing it is `reporting.py`'s half, and the two are separated on exactly
|
|
4
|
+
that line: everything here either writes, or decides whether a write may proceed.
|
|
5
|
+
|
|
6
|
+
**The registry is always mutated first**, for both `add` and `remove` — the same ordering, not
|
|
7
|
+
mirrored ones. The rule is to leave the recoverable state where one exists and the lesser harm
|
|
8
|
+
where none does, and the two interruptions are not symmetric, so one ordering satisfies both:
|
|
9
|
+
|
|
10
|
+
- An interrupted `add` leaves a **row without a file**: a dangling name, reported as
|
|
11
|
+
`reindex_required`, cleared by `remove` and re-created by `add`. Nothing is lost, because
|
|
12
|
+
nothing was ever indexed under it. A build cannot repair it — everything defining a corpus but
|
|
13
|
+
its name and description lives in the database that is missing — so a build refuses instead.
|
|
14
|
+
- An interrupted `remove` leaves a **file without a row**: an orphan, invisible to every query,
|
|
15
|
+
never opened for search, reported by `status`. It leaks disk until someone clears it, which is
|
|
16
|
+
the lesser harm.
|
|
17
|
+
|
|
18
|
+
The reverse ordering is worse in both directions. An `add` writing its row last would leave an
|
|
19
|
+
orphan on interruption, and because a retried `add` mints a fresh id that orphan is *permanent*,
|
|
20
|
+
where the dangling name it avoids costs one `remove`. A `remove` unlinking first would leave a
|
|
21
|
+
name still answering for a corpus the caller had just asked to destroy — one no build can rebuild,
|
|
22
|
+
since its definition went with the file, and which only another `remove` could clear.
|
|
23
|
+
|
|
24
|
+
**Those two are the states this code produces, not the only two physically reachable, and the
|
|
25
|
+
difference is worth stating because an exhaustive-sounding list is how the third one gets
|
|
26
|
+
overlooked.** Creating a knowledge base's database is itself several steps — the file comes into
|
|
27
|
+
existence when it is connected to, before any table is in it — so a failure in between would leave
|
|
28
|
+
a registered name pointing at an empty database, which reads back as *this index cannot be opened*
|
|
29
|
+
rather than as one needing a build. Creation removes what it made on **every failure it can catch**,
|
|
30
|
+
from the connect onwards, which folds those into the first state above. What remains is a **hard
|
|
31
|
+
kill inside that window**, which no in-process handler can catch: it leaves a row and an empty file,
|
|
32
|
+
reported as an unreadable index. Rarer than the two above, repaired by deleting the file, and named
|
|
33
|
+
here rather than left for somebody to meet without warning.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
from collections.abc import AsyncIterator
|
|
38
|
+
from contextlib import asynccontextmanager
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
import aiosqlite
|
|
43
|
+
|
|
44
|
+
from zikaron.core.clock import timestamp
|
|
45
|
+
from zikaron.core.config.resolution import EffectiveConfig
|
|
46
|
+
from zikaron.core.knowledge import (
|
|
47
|
+
builds,
|
|
48
|
+
database,
|
|
49
|
+
lock,
|
|
50
|
+
meta,
|
|
51
|
+
paths,
|
|
52
|
+
registry,
|
|
53
|
+
reporting,
|
|
54
|
+
roots,
|
|
55
|
+
scan,
|
|
56
|
+
)
|
|
57
|
+
from zikaron.core.knowledge.disposal import BuildSettings
|
|
58
|
+
from zikaron.core.knowledge.errors import (
|
|
59
|
+
DanglingKnowledgeBaseError,
|
|
60
|
+
IndexerBusyError,
|
|
61
|
+
InvalidSettingError,
|
|
62
|
+
SettingBounds,
|
|
63
|
+
)
|
|
64
|
+
from zikaron.core.knowledge.registry import KnowledgeBase
|
|
65
|
+
from zikaron.core.store import permissions
|
|
66
|
+
from zikaron.core.store.transactions import in_one_transaction, propagate
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class AddRequest:
|
|
71
|
+
"""A request to create a corpus, exactly as its caller stated it.
|
|
72
|
+
|
|
73
|
+
One type rather than a long parameter list, because two surfaces construct exactly this — a
|
|
74
|
+
command's parsed arguments and a tool call's validated payload — and a parameter list would
|
|
75
|
+
let the two drift into accepting different things. Nothing here is validated yet: `add` does
|
|
76
|
+
that, so neither surface can skip a check by being written second.
|
|
77
|
+
|
|
78
|
+
`home` is a parameter rather than a lookup so a test states the home directory it is checking
|
|
79
|
+
instead of mutating the process's. `None` means this process's own.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
root: Path
|
|
84
|
+
description: str
|
|
85
|
+
include_globs: tuple[str, ...] = ()
|
|
86
|
+
exclude_globs: tuple[str, ...] = ()
|
|
87
|
+
git_mode: meta.GitMode = meta.GitMode.TRACKED
|
|
88
|
+
max_file_bytes: int | None = None
|
|
89
|
+
home: Path | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class Created:
|
|
94
|
+
"""What `add` did, and what the caller should expect next.
|
|
95
|
+
|
|
96
|
+
`git_mode_effective` comes from `add`'s own probe rather than from any stored key: the stored
|
|
97
|
+
one is written by a build, which has not run yet, and a caller learning at creation time that
|
|
98
|
+
its `git_mode` will not take effect is the whole point of probing at all.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
knowledge_base: KnowledgeBase
|
|
102
|
+
database_path: Path
|
|
103
|
+
git_mode: meta.GitMode
|
|
104
|
+
git_mode_effective: meta.GitMode
|
|
105
|
+
status: reporting.Status
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True, slots=True)
|
|
109
|
+
class Refreshed:
|
|
110
|
+
"""What one build did, and where it left the corpus.
|
|
111
|
+
|
|
112
|
+
Both halves, because they answer different questions: the result says what this build changed,
|
|
113
|
+
and the status says whether the corpus can now be trusted — which a build can leave unchanged.
|
|
114
|
+
The case that produces it: configuration names a model whose vectors are not the width
|
|
115
|
+
configuration also states, so the corpus is rebuilt at the width the model actually emits and
|
|
116
|
+
goes on reporting that it needs rebuilding, because it does.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
knowledge_base: KnowledgeBase
|
|
120
|
+
result: scan.ScanResult
|
|
121
|
+
status: reporting.Status
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class Removed:
|
|
126
|
+
"""What `remove` destroyed — a final snapshot, since its corpus no longer exists to poll."""
|
|
127
|
+
|
|
128
|
+
knowledge_base: KnowledgeBase
|
|
129
|
+
status: reporting.Status
|
|
130
|
+
files_unlinked: tuple[Path, ...]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@asynccontextmanager
|
|
134
|
+
async def _open_registered(
|
|
135
|
+
store_dir: Path, registered: KnowledgeBase
|
|
136
|
+
) -> AsyncIterator[database.KnowledgeDatabase]:
|
|
137
|
+
"""This corpus's own database, open, for a verb that cannot proceed without one.
|
|
138
|
+
|
|
139
|
+
The absence check is explicit rather than inferred from a failed open, because the two mean
|
|
140
|
+
different things: a missing file is a corpus whose whole definition is missing, and a present
|
|
141
|
+
one that will not open is a corpus that exists and cannot be read.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
DanglingKnowledgeBaseError: the registry names this corpus and its database is gone. With
|
|
145
|
+
it goes everything that said what to index — the root, the globs, the size cap and the
|
|
146
|
+
encoder identity all live there rather than in the registry — so there is nothing to
|
|
147
|
+
rebuild from and nothing to guess.
|
|
148
|
+
"""
|
|
149
|
+
db_path = paths.knowledge_db_path(store_dir, registered.id)
|
|
150
|
+
if not db_path.is_file():
|
|
151
|
+
raise DanglingKnowledgeBaseError(
|
|
152
|
+
f"{registered.name!r} has no database, so nothing records what it indexes; "
|
|
153
|
+
f"remove it and add it again"
|
|
154
|
+
)
|
|
155
|
+
async with await database.KnowledgeDatabase.open(store_dir, registered.id) as opened:
|
|
156
|
+
yield opened
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def add(
|
|
160
|
+
store_dir: Path,
|
|
161
|
+
db: aiosqlite.Connection,
|
|
162
|
+
config: EffectiveConfig,
|
|
163
|
+
request: AddRequest,
|
|
164
|
+
) -> Created:
|
|
165
|
+
"""Register a corpus and create its database, in that order.
|
|
166
|
+
|
|
167
|
+
Every per-knowledge-base setting is persisted at creation, `max_file_bytes` included. A
|
|
168
|
+
setting accepted and not persisted would be silently undone by the next build, which would
|
|
169
|
+
re-walk under the global default and change the corpus the caller had just defined.
|
|
170
|
+
|
|
171
|
+
A successful `add` reports `reindex_required`, and that is the truth rather than a wart: the
|
|
172
|
+
database exists and its `meta` is sound, but no build has completed, so the honest answer to
|
|
173
|
+
*can I trust results from this corpus* is not yet.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
store_dir: the `.zikaron` directory this store lives in.
|
|
177
|
+
db: an open connection to `memory.db`, which carries the registry.
|
|
178
|
+
config: the effective configuration to seed this corpus's tuning keys from.
|
|
179
|
+
request: what to create, as supplied. Validated here rather than by the caller, so the
|
|
180
|
+
command and the tool surface cannot come to accept different things.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
InvalidNameError: the name is empty or blank.
|
|
184
|
+
InvalidRootError: the root is absent, is not a directory, or is degenerate.
|
|
185
|
+
DuplicateNameError: the name is taken. Never an upsert — silently reconfiguring a corpus
|
|
186
|
+
underneath whoever created it is worse than a failed call.
|
|
187
|
+
InvalidSettingError: `max_file_bytes` is outside the range configuration declares for it.
|
|
188
|
+
"""
|
|
189
|
+
home = request.home if request.home is not None else Path.home()
|
|
190
|
+
resolved_root = roots.validate_root(request.root, home=home)
|
|
191
|
+
stored_name = registry.normalize_name(request.name)
|
|
192
|
+
if request.max_file_bytes is not None:
|
|
193
|
+
_check_supplied_size_cap(request.max_file_bytes)
|
|
194
|
+
effective = await roots.effective_git_mode(resolved_root, request.git_mode)
|
|
195
|
+
|
|
196
|
+
async def _register(connection: aiosqlite.Connection) -> KnowledgeBase:
|
|
197
|
+
await registry.ensure_table(connection)
|
|
198
|
+
return await registry.insert(
|
|
199
|
+
connection,
|
|
200
|
+
name=stored_name,
|
|
201
|
+
description=request.description,
|
|
202
|
+
created_at=timestamp(),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
registered = await in_one_transaction(db, _register, failure=propagate)
|
|
206
|
+
|
|
207
|
+
spec = database.NewKnowledgeBase(
|
|
208
|
+
name=registered.name,
|
|
209
|
+
root=resolved_root,
|
|
210
|
+
description=request.description,
|
|
211
|
+
include_globs=request.include_globs,
|
|
212
|
+
exclude_globs=request.exclude_globs,
|
|
213
|
+
git_mode=request.git_mode,
|
|
214
|
+
max_file_bytes=request.max_file_bytes,
|
|
215
|
+
)
|
|
216
|
+
async with await database.KnowledgeDatabase.create(
|
|
217
|
+
store_dir, database.seed_identity(registered.id, spec, config)
|
|
218
|
+
):
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
observed = await reporting.observe(store_dir, registered, config)
|
|
222
|
+
return Created(
|
|
223
|
+
knowledge_base=registered,
|
|
224
|
+
database_path=paths.knowledge_db_path(store_dir, registered.id),
|
|
225
|
+
git_mode=request.git_mode,
|
|
226
|
+
git_mode_effective=effective,
|
|
227
|
+
status=observed.status,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _check_supplied_size_cap(value: int) -> None:
|
|
232
|
+
"""Refuse a size cap a caller chose, in the caller's own terms rather than the store's.
|
|
233
|
+
|
|
234
|
+
The range comes from the configuration schema, which is what keeps a corpus created this way
|
|
235
|
+
reproducible by writing a configuration file — but the *refusal* must not: a caller that typed
|
|
236
|
+
a number is not being told its configuration is unusable, and pointing it at a file to fix
|
|
237
|
+
would send it looking for a typo that is in its own request.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
InvalidSettingError: the value is outside the declared range.
|
|
241
|
+
"""
|
|
242
|
+
bounds, expected = meta.bounds_for(meta.MAX_FILE_BYTES_KEY)
|
|
243
|
+
if bounds.permits(value):
|
|
244
|
+
return
|
|
245
|
+
raise InvalidSettingError(
|
|
246
|
+
f"{meta.MAX_FILE_BYTES_KEY} must be {expected}, not {value}",
|
|
247
|
+
bounds=SettingBounds(key=meta.MAX_FILE_BYTES_KEY, value=value, expected=expected),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
async def rename(
|
|
252
|
+
store_dir: Path, db: aiosqlite.Connection, config: EffectiveConfig, *, name: str, new_name: str
|
|
253
|
+
) -> reporting.Status:
|
|
254
|
+
"""Change a corpus's name. A registry `UPDATE` that touches no file.
|
|
255
|
+
|
|
256
|
+
Safe while a build is running, and deliberately does not check the lock: the name lives in the
|
|
257
|
+
registry alone, so nothing about a rename can disturb a database being written.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
InvalidNameError: either name is empty or blank.
|
|
261
|
+
UnknownKnowledgeBaseError: nothing is registered under `name`.
|
|
262
|
+
DuplicateNameError: `new_name` is taken.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
async def _work(connection: aiosqlite.Connection) -> KnowledgeBase:
|
|
266
|
+
await registry.ensure_table(connection)
|
|
267
|
+
return await registry.rename(connection, name=name, new_name=new_name)
|
|
268
|
+
|
|
269
|
+
renamed = await in_one_transaction(db, _work, failure=propagate)
|
|
270
|
+
observed = await reporting.observe(store_dir, renamed, config)
|
|
271
|
+
return observed.status
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def refresh(
|
|
275
|
+
store_dir: Path,
|
|
276
|
+
db: aiosqlite.Connection,
|
|
277
|
+
config: EffectiveConfig,
|
|
278
|
+
*,
|
|
279
|
+
name: str,
|
|
280
|
+
build: BuildSettings,
|
|
281
|
+
) -> Refreshed:
|
|
282
|
+
"""Build one corpus's index, and report what the build did and where it left the corpus.
|
|
283
|
+
|
|
284
|
+
Runs the build to completion before returning, and holds the corpus's own lock for the whole
|
|
285
|
+
of it, so two of these against one knowledge base do not overlap whichever process they run
|
|
286
|
+
in.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
store_dir: the `.zikaron` directory this store lives in.
|
|
290
|
+
db: an open connection to `memory.db`, which carries the registry.
|
|
291
|
+
config: the effective configuration, used only to report whether the built corpus's
|
|
292
|
+
encoder identity still agrees with it. The corpus itself is defined entirely by its
|
|
293
|
+
own stored `meta`.
|
|
294
|
+
name: which corpus to build.
|
|
295
|
+
build: the encoder to index with, how many chunks to embed per pass, and whether to
|
|
296
|
+
reindex everything rather than only what changed. Supplied by the caller rather than
|
|
297
|
+
loaded here, because loading a model is the expensive part of starting a build and a
|
|
298
|
+
caller building several corpora loads it once.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
InvalidNameError: `name` is empty or blank.
|
|
302
|
+
UnknownKnowledgeBaseError: nothing is registered under `name`.
|
|
303
|
+
DanglingKnowledgeBaseError: the corpus is registered but its database is gone, and with it
|
|
304
|
+
everything that said what to index.
|
|
305
|
+
CorpusRootMissingError: the indexed directory is gone. The index is left as it is.
|
|
306
|
+
IndexerBusyError: another build holds this corpus's lock.
|
|
307
|
+
aiosqlite.Error | ZikaronError | OSError: the database is present and will not open. The
|
|
308
|
+
driver's own failure, propagated rather than renamed, since nothing the caller can do
|
|
309
|
+
fixes it.
|
|
310
|
+
"""
|
|
311
|
+
registered = await builds.prepare(store_dir, db, config, name=name)
|
|
312
|
+
async with await database.KnowledgeDatabase.open(store_dir, registered.id) as opened:
|
|
313
|
+
result = await scan.run(opened, build)
|
|
314
|
+
observed = await reporting.observe(store_dir, registered, config)
|
|
315
|
+
return Refreshed(knowledge_base=registered, result=result, status=observed.status)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def unlock(store_dir: Path, db: aiosqlite.Connection, *, name: str) -> lock.LockHolder | None:
|
|
319
|
+
"""Clear a build lock an operator has judged dead, refusing the one case evidence contradicts.
|
|
320
|
+
|
|
321
|
+
The way out of the state nothing clears on its own. A lock recorded on another machine is never
|
|
322
|
+
reclaimed automatically — a local process probe says nothing about a foreign process — and the
|
|
323
|
+
rules that follow compose into a knowledge base nothing can manage: every build reports it
|
|
324
|
+
busy, removal refuses, and no timeout runs out. This puts that judgement where the evidence is,
|
|
325
|
+
with whoever knows what else is running, and declines only where the holder answers a signal
|
|
326
|
+
from this machine.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
The holder that was cleared, or `None` if no lock was recorded at all.
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
InvalidNameError: `name` is empty or blank.
|
|
333
|
+
UnknownKnowledgeBaseError: nothing is registered under `name`.
|
|
334
|
+
DanglingKnowledgeBaseError: the corpus is registered but its database is gone, so there is
|
|
335
|
+
no lock to clear and nothing that could have taken one.
|
|
336
|
+
IndexerBusyError: a process on this host answers to the recorded pid — which is not proof
|
|
337
|
+
it is the indexer, and `lock.force_release` says so and names the manual exit.
|
|
338
|
+
"""
|
|
339
|
+
await registry.ensure(db)
|
|
340
|
+
registered = await registry.require(db, name)
|
|
341
|
+
async with _open_registered(store_dir, registered) as opened:
|
|
342
|
+
|
|
343
|
+
async def _work(connection: aiosqlite.Connection) -> lock.LockHolder | None:
|
|
344
|
+
return await lock.force_release(connection, host=lock.this_host())
|
|
345
|
+
|
|
346
|
+
return await in_one_transaction(opened.connection, _work, failure=propagate)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async def remove(
|
|
350
|
+
store_dir: Path, db: aiosqlite.Connection, config: EffectiveConfig, *, name: str
|
|
351
|
+
) -> Removed:
|
|
352
|
+
"""Destroy a corpus: delete its registry row, commit, then unlink its three files.
|
|
353
|
+
|
|
354
|
+
Refuses whenever the corpus's build lock is held by somebody who cannot be shown to be gone —
|
|
355
|
+
the same test a build's own acquisition uses, and the conservative half of it: refusing to
|
|
356
|
+
unlink a database a writer may hold is recoverable, and unlinking one it does hold is not. What
|
|
357
|
+
that test deliberately does **not** refuse is a lock left behind by a crashed indexer on this
|
|
358
|
+
host, because those rows survive on purpose and a knowledge base that could never be removed
|
|
359
|
+
again after one crash would be the worse failure.
|
|
360
|
+
|
|
361
|
+
**That refusal is a check followed by an act, not one atomic step, and the window is accepted
|
|
362
|
+
rather than overlooked.** The lock lives in the knowledge base's own database and the registry
|
|
363
|
+
row lives in `memory.db`, so no single transaction spans them: a build that takes the lock
|
|
364
|
+
after the check has its database unlinked underneath it. The harm is bounded — the racing
|
|
365
|
+
build's writes land on an unlinked inode and evaporate, which is what destroying the corpus was
|
|
366
|
+
asked for — and closing it properly would mean a lock somewhere both databases can see, which
|
|
367
|
+
is a larger change than the outcome justifies.
|
|
368
|
+
|
|
369
|
+
Unlinks all three of the `.db`, its `-wal` and its `-shm`, because in WAL mode a live database
|
|
370
|
+
is three files and a journal left behind would be inherited by whatever is created at that path
|
|
371
|
+
next. In practice it usually removes one: reading the corpus's state opens and closes it, and
|
|
372
|
+
SQLite reclaims its own `-wal` and `-shm` on the last close, so the other two are typically
|
|
373
|
+
already gone by the time this runs. `files_unlinked` reports what was actually there rather
|
|
374
|
+
than what was attempted, which is why it is normally a single path.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
InvalidNameError: `name` is empty or blank.
|
|
378
|
+
UnknownKnowledgeBaseError: nothing is registered under `name`.
|
|
379
|
+
IndexerBusyError: a build that cannot be shown to be dead holds this corpus's lock.
|
|
380
|
+
"""
|
|
381
|
+
await registry.ensure(db)
|
|
382
|
+
registered = await registry.require(db, name)
|
|
383
|
+
observed = await reporting.observe(store_dir, registered, config)
|
|
384
|
+
if observed.blocker is not None:
|
|
385
|
+
raise IndexerBusyError(
|
|
386
|
+
f"{registered.name!r}'s build lock is held by a process this host cannot show is "
|
|
387
|
+
f"gone ({observed.blocker.describe()}); wait for it, or clear the lock with "
|
|
388
|
+
f"`refresh --force-unlock` if you are sure it is gone",
|
|
389
|
+
holder=observed.blocker,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
async def _work(connection: aiosqlite.Connection) -> KnowledgeBase:
|
|
393
|
+
return await registry.delete(connection, name=name)
|
|
394
|
+
|
|
395
|
+
await in_one_transaction(db, _work, failure=propagate)
|
|
396
|
+
|
|
397
|
+
db_path = paths.knowledge_db_path(store_dir, registered.id)
|
|
398
|
+
unlinked = [
|
|
399
|
+
path
|
|
400
|
+
for path in permissions.database_files(db_path)
|
|
401
|
+
if await asyncio.to_thread(_unlink_if_present, path)
|
|
402
|
+
]
|
|
403
|
+
return Removed(
|
|
404
|
+
knowledge_base=registered, status=observed.status, files_unlinked=tuple(unlinked)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _unlink_if_present(path: Path) -> bool:
|
|
409
|
+
"""Remove `path`, reporting whether it was there.
|
|
410
|
+
|
|
411
|
+
Absence is an ordinary outcome rather than a fault: a knowledge base that was never built has
|
|
412
|
+
no `-wal` or `-shm`, and one whose file was deleted by hand has none of the three.
|
|
413
|
+
"""
|
|
414
|
+
try:
|
|
415
|
+
path.unlink()
|
|
416
|
+
except FileNotFoundError:
|
|
417
|
+
return False
|
|
418
|
+
return True
|