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.
Files changed (162) hide show
  1. zikaron/__init__.py +1 -0
  2. zikaron/cli/__init__.py +1 -0
  3. zikaron/cli/main.py +114 -0
  4. zikaron/core/__init__.py +1 -0
  5. zikaron/core/clock.py +78 -0
  6. zikaron/core/config/__init__.py +1 -0
  7. zikaron/core/config/keys.py +395 -0
  8. zikaron/core/config/resolution.py +267 -0
  9. zikaron/core/consolidation/__init__.py +1 -0
  10. zikaron/core/consolidation/authorization.py +316 -0
  11. zikaron/core/consolidation/candidates.py +147 -0
  12. zikaron/core/consolidation/context.py +166 -0
  13. zikaron/core/consolidation/grouping.py +383 -0
  14. zikaron/core/consolidation/groups.py +490 -0
  15. zikaron/core/consolidation/payload.py +246 -0
  16. zikaron/core/consolidation/planning.py +192 -0
  17. zikaron/core/consolidation/rowstate.py +68 -0
  18. zikaron/core/consolidation/runs.py +306 -0
  19. zikaron/core/consolidation/serving.py +462 -0
  20. zikaron/core/consolidation/verbs.py +500 -0
  21. zikaron/core/errors.py +355 -0
  22. zikaron/core/events.py +748 -0
  23. zikaron/core/indexing/__init__.py +1 -0
  24. zikaron/core/indexing/acquisition.py +255 -0
  25. zikaron/core/indexing/chunking.py +368 -0
  26. zikaron/core/indexing/encoder.py +537 -0
  27. zikaron/core/indexing/lexical.py +86 -0
  28. zikaron/core/indexing/model_cache.py +93 -0
  29. zikaron/core/indexing/model_pin.py +89 -0
  30. zikaron/core/indexing/vectors.py +223 -0
  31. zikaron/core/indexing/writes.py +461 -0
  32. zikaron/core/knowledge/__init__.py +5 -0
  33. zikaron/core/knowledge/arms.py +104 -0
  34. zikaron/core/knowledge/builds.py +204 -0
  35. zikaron/core/knowledge/candidates.py +130 -0
  36. zikaron/core/knowledge/changes.py +175 -0
  37. zikaron/core/knowledge/chunking.py +376 -0
  38. zikaron/core/knowledge/counters.py +228 -0
  39. zikaron/core/knowledge/database.py +380 -0
  40. zikaron/core/knowledge/ddl.py +196 -0
  41. zikaron/core/knowledge/disposal.py +213 -0
  42. zikaron/core/knowledge/errors.py +166 -0
  43. zikaron/core/knowledge/files.py +202 -0
  44. zikaron/core/knowledge/git.py +385 -0
  45. zikaron/core/knowledge/groups.py +450 -0
  46. zikaron/core/knowledge/lexical.py +64 -0
  47. zikaron/core/knowledge/lifecycle.py +418 -0
  48. zikaron/core/knowledge/lock.py +277 -0
  49. zikaron/core/knowledge/meta.py +393 -0
  50. zikaron/core/knowledge/paths.py +55 -0
  51. zikaron/core/knowledge/pending.py +59 -0
  52. zikaron/core/knowledge/registry.py +264 -0
  53. zikaron/core/knowledge/repair.py +152 -0
  54. zikaron/core/knowledge/reporting.py +436 -0
  55. zikaron/core/knowledge/roots.py +91 -0
  56. zikaron/core/knowledge/scan.py +429 -0
  57. zikaron/core/knowledge/search.py +346 -0
  58. zikaron/core/knowledge/state.py +174 -0
  59. zikaron/core/knowledge/text.py +166 -0
  60. zikaron/core/knowledge/vectors.py +102 -0
  61. zikaron/core/knowledge/walk.py +264 -0
  62. zikaron/core/knowledge/writes.py +127 -0
  63. zikaron/core/records/__init__.py +1 -0
  64. zikaron/core/records/memory.py +961 -0
  65. zikaron/core/records/receipts.py +161 -0
  66. zikaron/core/records/supersession.py +221 -0
  67. zikaron/core/retrieval/__init__.py +1 -0
  68. zikaron/core/retrieval/arms.py +318 -0
  69. zikaron/core/retrieval/block.py +107 -0
  70. zikaron/core/retrieval/eligibility.py +164 -0
  71. zikaron/core/retrieval/query.py +327 -0
  72. zikaron/core/retrieval/ranking.py +260 -0
  73. zikaron/core/retrieval/reads.py +294 -0
  74. zikaron/core/retrieval/retrieve.py +158 -0
  75. zikaron/core/retrieval/similarity.py +87 -0
  76. zikaron/core/signals/__init__.py +34 -0
  77. zikaron/core/signals/contention.py +106 -0
  78. zikaron/core/signals/dedup.py +201 -0
  79. zikaron/core/signals/horizon.py +47 -0
  80. zikaron/core/signals/repair.py +161 -0
  81. zikaron/core/signals/retirement.py +83 -0
  82. zikaron/core/signals/sessions.py +105 -0
  83. zikaron/core/signals/writes.py +200 -0
  84. zikaron/core/store/__init__.py +1 -0
  85. zikaron/core/store/connection.py +202 -0
  86. zikaron/core/store/ddl.py +215 -0
  87. zikaron/core/store/embedder.py +45 -0
  88. zikaron/core/store/meta.py +152 -0
  89. zikaron/core/store/permissions.py +160 -0
  90. zikaron/core/store/store.py +408 -0
  91. zikaron/core/store/transactions.py +181 -0
  92. zikaron/core/write/__init__.py +33 -0
  93. zikaron/core/write/dedup.py +145 -0
  94. zikaron/core/write/tools.py +290 -0
  95. zikaron/doctor/__init__.py +1 -0
  96. zikaron/doctor/checks.py +220 -0
  97. zikaron/doctor/main.py +64 -0
  98. zikaron/harness/__init__.py +1 -0
  99. zikaron/harness/detect.py +92 -0
  100. zikaron/harness/spec.py +320 -0
  101. zikaron/hook/__init__.py +1 -0
  102. zikaron/hook/connect.py +379 -0
  103. zikaron/hook/envelope.py +106 -0
  104. zikaron/hook/failure.py +104 -0
  105. zikaron/hook/limits.py +61 -0
  106. zikaron/hook/main.py +118 -0
  107. zikaron/hook/push.py +183 -0
  108. zikaron/hook/rpc.py +85 -0
  109. zikaron/hook/spawn_warm.py +81 -0
  110. zikaron/hook/subagent_policy.py +57 -0
  111. zikaron/hook/tripwire.py +54 -0
  112. zikaron/hook/warm_helper.py +137 -0
  113. zikaron/hook/write_policy.py +319 -0
  114. zikaron/install/__init__.py +4 -0
  115. zikaron/install/__main__.py +18 -0
  116. zikaron/install/assets.py +394 -0
  117. zikaron/install/entries.py +370 -0
  118. zikaron/install/harness.py +185 -0
  119. zikaron/install/main.py +375 -0
  120. zikaron/install/targets.py +789 -0
  121. zikaron/install/writer.py +973 -0
  122. zikaron/knowledge/__init__.py +1 -0
  123. zikaron/knowledge/__main__.py +17 -0
  124. zikaron/knowledge/indexer/__init__.py +1 -0
  125. zikaron/knowledge/indexer/__main__.py +17 -0
  126. zikaron/knowledge/indexer/detach.py +83 -0
  127. zikaron/knowledge/indexer/main.py +187 -0
  128. zikaron/knowledge/main.py +466 -0
  129. zikaron/knowledge/scope.py +133 -0
  130. zikaron/mcp/__init__.py +6 -0
  131. zikaron/mcp/connection.py +583 -0
  132. zikaron/mcp/consolidator.py +316 -0
  133. zikaron/mcp/errors.py +73 -0
  134. zikaron/mcp/main.py +66 -0
  135. zikaron/mcp/primary.py +420 -0
  136. zikaron/mcp/server.py +96 -0
  137. zikaron/mcp/spill.py +328 -0
  138. zikaron/mcp/tool_names.py +67 -0
  139. zikaron/py.typed +0 -0
  140. zikaron/service/__init__.py +1 -0
  141. zikaron/service/asyncio_compat.py +126 -0
  142. zikaron/service/context.py +251 -0
  143. zikaron/service/dispatch.py +332 -0
  144. zikaron/service/dispatch_consolidation.py +397 -0
  145. zikaron/service/dispatch_knowledge.py +469 -0
  146. zikaron/service/envelope.py +166 -0
  147. zikaron/service/lifecycle.py +467 -0
  148. zikaron/service/log.py +96 -0
  149. zikaron/service/main.py +531 -0
  150. zikaron/service/params.py +168 -0
  151. zikaron/service/paths.py +181 -0
  152. zikaron/service/rpc.py +176 -0
  153. zikaron/service/security.py +156 -0
  154. zikaron/service/serialize.py +204 -0
  155. zikaron/service/serialize_knowledge.py +238 -0
  156. zikaron/service/server.py +416 -0
  157. zikaron-0.1.0.dist-info/METADATA +770 -0
  158. zikaron-0.1.0.dist-info/RECORD +162 -0
  159. zikaron-0.1.0.dist-info/WHEEL +5 -0
  160. zikaron-0.1.0.dist-info/entry_points.txt +4 -0
  161. zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
  162. 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