dsh-hashline-edit 0.2.0 → 0.2.2

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 (109) hide show
  1. package/README.md +59 -55
  2. package/cordis.patch.yml +1 -1
  3. package/lib/index.js +7225 -6445
  4. package/lib/types/dsh/contracts.d.ts +3 -12
  5. package/lib/types/dsh/fs.d.ts +0 -3
  6. package/lib/types/dsh/guidance.d.ts +5 -5
  7. package/lib/types/dsh/host-compat.d.ts +78 -7
  8. package/lib/types/dsh/index.d.ts +7 -17
  9. package/lib/types/dsh/metrics/events.d.ts +1 -1
  10. package/lib/types/dsh/metrics/observer.d.ts +1 -1
  11. package/lib/types/dsh/sandbox.d.ts +2 -2
  12. package/lib/types/dsh/shell-hook.d.ts +1 -1
  13. package/lib/types/dsh/tools/edit.d.ts +10 -0
  14. package/lib/types/dsh/tools/read.d.ts +3 -3
  15. package/lib/types/src/domain-errors.d.ts +2 -16
  16. package/lib/types/src/edit-tool.d.ts +41 -0
  17. package/lib/types/src/edit-undo.d.ts +2 -11
  18. package/lib/types/src/file-content/index.d.ts +1 -1
  19. package/lib/types/src/file-content/preview.d.ts +1 -1
  20. package/lib/types/src/hash-store.d.ts +2 -2
  21. package/lib/types/src/hashline/alphabet.d.ts +16 -1
  22. package/lib/types/src/hashline/hash-identity.d.ts +10 -4
  23. package/lib/types/src/mutation-engine/engine.d.ts +31 -0
  24. package/lib/types/src/mutation-engine/pipeline.d.ts +1 -8
  25. package/lib/types/src/mutation-engine/types.d.ts +50 -40
  26. package/lib/types/src/payload-contract.d.ts +135 -0
  27. package/lib/types/src/served-session/serve-render.d.ts +0 -6
  28. package/lib/types/src/undo-store.d.ts +1 -17
  29. package/package.json +21 -10
  30. package/prompts/edit-guidelines.md +10 -0
  31. package/prompts/edit-snippet.md +1 -0
  32. package/prompts/edit.md +1 -0
  33. package/prompts/read-guidelines.md +4 -4
  34. package/prompts/read.md +1 -1
  35. package/prompts/undo-last-edit-guidelines.md +2 -2
  36. package/prompts/undo-last-edit-snippet.md +1 -1
  37. package/prompts/undo-last-edit.md +1 -1
  38. package/src/bash-classifier.ts +1626 -0
  39. package/src/bash-conversion/decide.ts +812 -0
  40. package/src/bash-display-transform.ts +50 -0
  41. package/src/bash-multifile-config.ts +239 -0
  42. package/src/bash-view-config.ts +158 -0
  43. package/src/cut-repair.ts +11 -77
  44. package/src/domain-errors.ts +3 -35
  45. package/src/edit-normalize.ts +8 -0
  46. package/src/edit-render.ts +161 -0
  47. package/src/edit-tool.ts +118 -0
  48. package/src/edit-undo.ts +25 -379
  49. package/src/edit.ts +136 -0
  50. package/src/file-content/index.ts +1 -1
  51. package/src/file-content/preview.ts +1 -1
  52. package/src/file-content/served-header.ts +1 -1
  53. package/src/hash-store.ts +1 -18
  54. package/src/hashline/alphabet.ts +42 -1
  55. package/src/hashline/hash-identity.ts +12 -3
  56. package/src/integrations/pi-lens/command.ts +712 -0
  57. package/src/integrations/pi-lens/config.ts +148 -0
  58. package/src/integrations/pi-lens/io-bridge-adapter.ts +234 -0
  59. package/src/integrations/pi-lens/io-bridge.ts +112 -0
  60. package/src/integrations/pi-lens/settings-overlay.ts +272 -0
  61. package/src/lifecycle-hooks/index.ts +1992 -0
  62. package/src/lifecycle-hooks/types.ts +64 -0
  63. package/src/mutated-files.ts +75 -0
  64. package/src/mutation-engine/engine.ts +239 -0
  65. package/src/mutation-engine/index.ts +17 -0
  66. package/src/mutation-engine/pipeline.ts +27 -198
  67. package/src/mutation-engine/types.ts +57 -39
  68. package/src/mutation-queue.ts +1 -1
  69. package/src/payload-contract.ts +685 -0
  70. package/src/preview-controller.ts +84 -0
  71. package/src/prompts.ts +43 -10
  72. package/src/read.ts +865 -0
  73. package/src/search-hook.ts +41 -0
  74. package/src/served-session/serve-render.ts +2 -2
  75. package/src/store-config.ts +73 -0
  76. package/src/store-janitor-warning.ts +169 -0
  77. package/src/store-janitor.ts +539 -0
  78. package/src/store-kernel/settings.ts +233 -0
  79. package/src/store.ts +154 -0
  80. package/src/tui-presenter.ts +134 -0
  81. package/src/undo-store.ts +5 -74
  82. package/src/write-hook.ts +130 -0
  83. package/lib/types/dsh/hide.d.ts +0 -78
  84. package/lib/types/dsh/tools/apply-patch.d.ts +0 -38
  85. package/lib/types/src/apply-patch/commit.d.ts +0 -49
  86. package/lib/types/src/apply-patch/grammar.d.ts +0 -29
  87. package/lib/types/src/apply-patch/lifecycle.d.ts +0 -32
  88. package/lib/types/src/apply-patch/plan.d.ts +0 -50
  89. package/lib/types/src/apply-patch/render.d.ts +0 -51
  90. package/lib/types/src/apply-patch/resolve.d.ts +0 -129
  91. package/lib/types/src/apply-patch/tool.d.ts +0 -54
  92. package/lib/types/src/apply-patch/types.d.ts +0 -362
  93. package/lib/types/src/fs-lifecycle.d.ts +0 -44
  94. package/lib/types/src/host-utils.d.ts +0 -63
  95. package/lib/types/src/mutation-engine/edit-request.d.ts +0 -34
  96. package/prompts/apply-patch-guidelines.md +0 -10
  97. package/prompts/apply-patch.md +0 -1
  98. package/src/apply-patch/commit.ts +0 -190
  99. package/src/apply-patch/grammar.ts +0 -620
  100. package/src/apply-patch/index.ts +0 -17
  101. package/src/apply-patch/lifecycle.ts +0 -195
  102. package/src/apply-patch/plan.ts +0 -800
  103. package/src/apply-patch/render.ts +0 -137
  104. package/src/apply-patch/resolve.ts +0 -470
  105. package/src/apply-patch/tool.ts +0 -257
  106. package/src/apply-patch/types.ts +0 -387
  107. package/src/fs-lifecycle.ts +0 -201
  108. package/src/host-utils.ts +0 -224
  109. package/src/mutation-engine/edit-request.ts +0 -39
@@ -0,0 +1,539 @@
1
+ /**
2
+ * The store janitor — the store's PERIODIC maintenance item, wired to the lifecycle events that are
3
+ * OFF the tool-call hot path. Ticket #90; the open-time half is `src/hash-store.ts`'s stamp-gated
4
+ * sweep, which this module REUSES rather than restates (see the exported `*_SQL` predicates there).
5
+ *
6
+ * WHAT ONE RUN DOES, in order, and why that order:
7
+ * 1. THROTTLE — `due(db, JANITOR_META_KEY, intervalMs, now)`, the primitives the served-mirror
8
+ * prune already uses (#87c). Not due means the run ends here, having issued no DELETE and no
9
+ * `PRAGMA quick_check`. The interval is resolved from the shared config ladder
10
+ * (`src/store-config.ts`), defaulting to a day.
11
+ * 2. GC — foreign-generation, dangling and expired rows, deleted in statements of at most
12
+ * `batchSize` rows. Expiry covers BOTH terminal states (ADR-0045): a retired lease past its
13
+ * 1-hour grace and an ACTIVE lease past the 7-day session TTL.
14
+ * Batching is about HOLD TIME, not statement count: one unbounded DELETE scans and holds the write
15
+ * lock for its whole scan, so a rival writer waits on a maintenance pass; a loop of bounded
16
+ * statements releases the lock between batches and lets a rival interleave. The batch width
17
+ * defaults to 64, the width the snapshot store already deletes in (`STAT_BATCH`).
18
+ * 3. MARK — `markRun` records the run, and it is written BEFORE the deep check because a
19
+ * detected failure may close this connection (step 4).
20
+ * 4. DEEP CHECK — the open path's own `PRAGMA quick_check` (`isHealthy`, never reimplemented), on a
21
+ * store the fast path no longer checks (PR #101 replaced it there with the header smoke check).
22
+ * A failing check routes into `healUnhealthyStore`, the janitor's orchestration over the one
23
+ * `quarantineStore` primitive; the open path's own recovery is its other caller.
24
+ *
25
+ * NO CHECKPOINT HERE, deliberately: `shutdownDb` owns `PRAGMA wal_checkpoint(TRUNCATE)`, registered
26
+ * once by `openStore` on `exit`, `SIGINT` and `SIGTERM`. The janitor therefore adds no second
27
+ * checkpoint site and no second owner of the same WAL; its writes ride the WAL and are truncated by
28
+ * the close path that already truncates it, and `wal_checkpoint(TRUNCATE)` on an already-truncated
29
+ * WAL is a no-op. One writer, one checkpoint, measured on close — see the report for the WAL numbers.
30
+ *
31
+ * THE #90 / #100 BOUNDARY, stated here in this module's own header. Issue #100's three clauses, its
32
+ * own words: "not detected at open, not quarantined at use, not healed on reopen."
33
+ * - CLOSED HERE: "not detected at open" — the fast path stopped running `PRAGMA quick_check` (#87c),
34
+ * so page-level damage was detected by nothing at all; this janitor detects it periodically, on a
35
+ * store the fast path accepts.
36
+ * - CLOSED HERE: "not healed on reopen" — a detected failure routes into the SAME quarantine-and-
37
+ * rebuild the open path performs (`healUnhealthyStore`), so the store is rebuilt rather than left
38
+ * damaged until someone deletes it by hand.
39
+ * - LEFT TO #100: "not quarantined at use" — nothing triggers on the FIRST FAILING STATEMENT. A
40
+ * statement that hits a damaged page still surfaces its own error and leaves the file damaged until
41
+ * the next janitor run. That is a USE-TIME trigger, #100's own scope; this module must not grow a
42
+ * half-implemented one, and it does not.
43
+ */
44
+
45
+ import type { DatabaseSync } from "node:sqlite";
46
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
47
+ import {
48
+ DANGLING_LEASE_SQL,
49
+ EXPIRED_ACTIVE_LEASE_SQL,
50
+ FOREIGN_GENERATION_LEASE_SQL,
51
+ FOREIGN_GENERATION_SNAPSHOT_SQL,
52
+ ORPHAN_LINEAGE_SQL,
53
+ healUnhealthyStore,
54
+ isHealthy,
55
+ loadHashStore,
56
+ servedLeaseExpiryCutoff,
57
+ } from "./hash-store.js";
58
+ import { isBusyError, markRun, due, withBusyRetry } from "./store-kernel/index.js";
59
+ import { resolveStoreJanitorSettings, type StoreJanitorSettings } from "./store-kernel/settings.js";
60
+ import type { ConfigOnWarn } from "./json-config.js";
61
+ import { VACUUM_RETIRED_PIN_MS } from "./snapshot-store";
62
+ import { diagnosticsLogPath, reportDiagnostic, reportInfo, reportWarning } from "./diagnostics.js";
63
+ import {
64
+ JANITOR_WARNING_PREFIX,
65
+ formatJanitorWarning,
66
+ type JanitorWarning,
67
+ } from "./store-janitor-warning.js";
68
+
69
+ /**
70
+ * The `meta` key recording when the janitor last ran — the janitor's OWN row, exactly as the stamp
71
+ * comment in `src/hash-store.ts` requires: due-ness instants never enter the store's identity stamp.
72
+ */
73
+ export const JANITOR_META_KEY = "janitor_at";
74
+
75
+ /**
76
+ * The lifecycle events the janitor is wired to: the two that are OFF the tool-call hot path.
77
+ * `session_shutdown` (reason quit|reload|new|resume|fork) had no consumer in this repo before this
78
+ * module, so it is wired deliberately: it is the one event that fires while a session's work is
79
+ * finished and before the runtime is torn down, which is where a maintenance pass belongs.
80
+ *
81
+ * NEVER SUBSCRIBE THIS JANITOR TO — the hot path, where a janitor run would be paid by every tool
82
+ * call of the session and could hold the write lock a call is waiting on:
83
+ * `tool_call`, `tool_result`, `tool_execution_start`, `tool_execution_update`,
84
+ * `tool_execution_end`, `context`, `before_provider_request`.
85
+ * These are forbidden, not merely unused: a run there is the latency regression #90 exists to avoid,
86
+ * so a future reader adding one must see that it is prohibited rather than think it is untried.
87
+ * A background timer is not an option either: the extension API exposes no idle timer and no
88
+ * `deactivate` event, so a timer-based trigger is impossible rather than unimplemented.
89
+ */
90
+ export const JANITOR_EVENTS = ["session_start", "session_shutdown"] as const;
91
+
92
+ /** The rows one janitor run removed, per family. */
93
+ interface JanitorDeletions {
94
+ readonly snapshots: number;
95
+ readonly lineage: number;
96
+ readonly leases: number;
97
+ /** Lease rows removed for expiry: active past the session TTL plus retired past the grace. */
98
+ readonly expiredLeases: number;
99
+ }
100
+
101
+ /**
102
+ * What the compaction step did (ADR-0046). `freeBytesBefore` is the reclaimable page space the
103
+ * sweep left behind; `reclaimedBytes` is the logical file space the VACUUM actually returned.
104
+ * `reason` is present exactly when `ran` is false, so a caller can tell "nothing to do" from
105
+ * "contended" from "failed" without parsing prose.
106
+ */
107
+ interface CompactionOutcome {
108
+ readonly ran: boolean;
109
+ readonly reason?:
110
+ | "skipped"
111
+ | "in-transaction"
112
+ | "no-free-pages"
113
+ | "below-threshold"
114
+ | "busy"
115
+ | "failed";
116
+ readonly freeBytesBefore: number;
117
+ readonly reclaimedBytes: number;
118
+ /** The failure text when `reason` is `"failed"`; the log line carries the same message. */
119
+ readonly error?: string;
120
+ }
121
+
122
+ export interface JanitorOutcome {
123
+ /** Whether the run happened at all. `false` means the throttle or the mode said no. */
124
+ readonly ran: boolean;
125
+ readonly reason?: "off" | "throttled";
126
+ readonly deleted: JanitorDeletions;
127
+ readonly compacted: CompactionOutcome;
128
+ /** Whether the run's deep check found the store damaged (only ever set on a run that happened). */
129
+ readonly deepCheckFailed: boolean;
130
+ /** Whether the store was handed to the open path's quarantine-and-rebuild. */
131
+ readonly rebuilt: boolean;
132
+ }
133
+
134
+ const NOTHING_DELETED: JanitorDeletions = {
135
+ snapshots: 0,
136
+ lineage: 0,
137
+ leases: 0,
138
+ expiredLeases: 0,
139
+ };
140
+
141
+ const NOTHING_COMPACTED: CompactionOutcome = {
142
+ ran: false,
143
+ reason: "skipped",
144
+ freeBytesBefore: 0,
145
+ reclaimedBytes: 0,
146
+ };
147
+
148
+ function skipped(reason: "off" | "throttled"): JanitorOutcome {
149
+ return {
150
+ ran: false,
151
+ reason,
152
+ deleted: NOTHING_DELETED,
153
+ compacted: NOTHING_COMPACTED,
154
+ deepCheckFailed: false,
155
+ rebuilt: false,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Delete every row a predicate names, at most `batchSize` rows per statement, returning the count.
161
+ * The statement is re-prepared per batch so no state is held between them, and each batch carries
162
+ * the store's own busy budget (`withBusyRetry`) rather than the janitor inventing a retry.
163
+ *
164
+ * WHY `rowid IN (SELECT rowid ... LIMIT ?)`: `DELETE ... LIMIT` needs a SQLite build option this
165
+ * runtime does not guarantee, while every table here is an ordinary rowid table, so the bounded
166
+ * delete is spelled with a subquery instead. `table` admits only `JanitorTable` members, so the
167
+ * two interpolated table slots cannot name a table no call site passes (#105 rider).
168
+ *
169
+ * A short final batch ends the loop: no statement re-reads rows it could have deleted twice, because
170
+ * nothing in this store re-creates foreign-generation or expired rows while a janitor run is open.
171
+ */
172
+ /** The only tables the janitor's batched deletes may name (#105 rider). */
173
+ type JanitorTable = "file_snapshots" | "line_lineage" | "served_leases";
174
+
175
+ function deleteInBatches(
176
+ db: DatabaseSync,
177
+ table: JanitorTable,
178
+ predicate: string,
179
+ params: readonly (string | number)[],
180
+ batchSize: number,
181
+ ): number {
182
+ const sql = `DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} WHERE ${predicate} LIMIT ?)`;
183
+ let deleted = 0;
184
+ for (;;) {
185
+ const changes = withBusyRetry(() => {
186
+ const result = db.prepare(sql).run(...params, batchSize);
187
+ return Number(result.changes);
188
+ });
189
+ deleted += changes;
190
+ if (changes < batchSize) return deleted;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * ONE janitor run against an OPEN store, with `now` injected so the throttle is judged from the
196
+ * caller's clock rather than from whatever the host's is doing. Sync on purpose: every statement it
197
+ * issues is synchronous, so a caller can drive it from a test without touching the event loop.
198
+ */
199
+ export function runJanitor(input: {
200
+ readonly db: DatabaseSync;
201
+ readonly now: number;
202
+ readonly settings: StoreJanitorSettings;
203
+ /**
204
+ * Injection seam for the reclaim floor (ADR-0046): tests use a small value to exercise a VACUUM
205
+ * on a tiny store. Production omits it and takes `STORE_COMPACT_MIN_FREE_BYTES`.
206
+ */
207
+ readonly compactMinFreeBytes?: number;
208
+ }): JanitorOutcome {
209
+ const { db, now, settings } = input;
210
+ if (settings.mode === "off") return skipped("off");
211
+ if (!due(db, JANITOR_META_KEY, settings.intervalMs, now)) return skipped("throttled");
212
+
213
+ const batchSize = settings.batchSize;
214
+ // WHY: the generation family, in the sweep's own order: leases are swept before the snapshots they
215
+ // WHY: name (a lease whose snapshot a later batch removes is swept as dangling), and lineage after
216
+ // WHY: the snapshots that cascade it. All four predicates are the sweep's, imported — not restated.
217
+ const deletions = collectGarbage(db, now, batchSize);
218
+ // WHY: (ADR-0046) a GC pass leaves free pages in the file; compaction is the only step that gives
219
+ // WHY: them back to the filesystem. It runs after the sweep, in autocommit, on this maintenance
220
+ // WHY: boundary — never inside a read/edit transaction — and it defers rather than waits when a
221
+ // WHY: rival writer holds the store. A failed sweep skips it: a damaged store is the deep check's
222
+ // WHY: business, and VACUUM on damage can only fail again.
223
+ const compacted =
224
+ deletions === undefined
225
+ ? NOTHING_COMPACTED
226
+ : compactStore(db, input.compactMinFreeBytes ?? STORE_COMPACT_MIN_FREE_BYTES);
227
+
228
+ // WHY: the mark is written only by a run whose GC COMPLETED: the primitives' contract is that
229
+ // WHY: "ran" means "did something", and a sweep that threw did not — so a failed sweep stays due
230
+ // WHY: and is retried at the next lifecycle event instead of being hidden for a full interval.
231
+ // WHY: the mark precedes the deep check because a detected failure may close this connection.
232
+ if (deletions !== undefined) markRun(db, JANITOR_META_KEY, now);
233
+
234
+ // WHY: the deep check runs even when the GC threw, and that ordering is the point of ticket #100:
235
+ // WHY: a store whose pages are damaged is exactly a store the GC may fail on, and detection must
236
+ // WHY: not be conditional on the cleanup it is meant to trigger a heal for.
237
+ return {
238
+ ran: true,
239
+ deleted: deletions ?? NOTHING_DELETED,
240
+ compacted,
241
+ deepCheckFailed: !isHealthy(db),
242
+ rebuilt: false,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * The janitor's cleanup half: foreign-generation and expired rows, in bounded batches. `undefined`
248
+ * means a statement threw, which the caller reports as "nothing was removed" — a store damaged
249
+ * enough to fail a DELETE is the case the deep check exists for, so the failure is swallowed here
250
+ * and reported there rather than thrown out of a lifecycle hook.
251
+ */
252
+ function collectGarbage(
253
+ db: DatabaseSync,
254
+ now: number,
255
+ batchSize: number,
256
+ ): JanitorDeletions | undefined {
257
+ try {
258
+ const leases = deleteInBatches(
259
+ db,
260
+ "served_leases",
261
+ FOREIGN_GENERATION_LEASE_SQL,
262
+ [],
263
+ batchSize,
264
+ );
265
+ const snapshots = deleteInBatches(
266
+ db,
267
+ "file_snapshots",
268
+ FOREIGN_GENERATION_SNAPSHOT_SQL,
269
+ [],
270
+ batchSize,
271
+ );
272
+ const lineage = deleteInBatches(db, "line_lineage", ORPHAN_LINEAGE_SQL, [], batchSize);
273
+ const dangling = deleteInBatches(db, "served_leases", DANGLING_LEASE_SQL, [], batchSize);
274
+ // WHY: (ADR-0045) hard expiry makes an ACTIVE lease past the session TTL a dead row: it has no
275
+ // WHY: resolution authority and no pin authority, but it still inflated every lease index and the
276
+ // WHY: vacuum's per-snapshot EXISTS probe. It is deleted AFTER the dangling sweep (a dead lease
277
+ // WHY: whose snapshot was evicted is already gone) and in the same bounded, off-path manner.
278
+ const expiredActive = deleteInBatches(
279
+ db,
280
+ "served_leases",
281
+ EXPIRED_ACTIVE_LEASE_SQL,
282
+ [servedLeaseExpiryCutoff(now)],
283
+ batchSize,
284
+ );
285
+ const expiredRetired = deleteInBatches(
286
+ db,
287
+ "served_leases",
288
+ "retired_at IS NOT NULL AND retired_at < ?",
289
+ [now - VACUUM_RETIRED_PIN_MS],
290
+ batchSize,
291
+ );
292
+ return {
293
+ snapshots,
294
+ lineage,
295
+ leases: leases + dangling,
296
+ expiredLeases: expiredActive + expiredRetired,
297
+ };
298
+ } catch (error) {
299
+ // SAFETY: a failed sweep removes nothing and is retried by the next due run; the deep check below
300
+ // SAFETY: is what decides whether this store needs the open path's recovery instead.
301
+ reportDiagnostic("store-janitor", "[store-janitor] GC pass failed:", error);
302
+ return undefined;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * WHY these numbers: a VACUUM rewrites the whole database, so its cost scales with the FILE while its
308
+ * benefit scales with the FREE PAGES. A quarter-free file amortises the rewrite; below that, a big
309
+ * store would be rewritten for a few reclaimed pages. The megabyte floor keeps a tiny store from
310
+ * paying a rewrite for one free page, and the 100 ms start budget means a contended compaction
311
+ * defers to the next maintenance pass instead of stalling a session behind a rival writer.
312
+ */
313
+ const STORE_COMPACT_MIN_FREE_RATIO = 0.25;
314
+ const STORE_COMPACT_MIN_FREE_BYTES = 1024 * 1024;
315
+ const STORE_COMPACT_BUSY_TIMEOUT_MS = 100;
316
+
317
+ function pragmaNumber(db: DatabaseSync, sql: string): number {
318
+ const row = db.prepare(sql).get() as Record<string, unknown> | undefined;
319
+ const value = row === undefined ? undefined : Object.values(row)[0];
320
+ return typeof value === "number" ? value : 0;
321
+ }
322
+
323
+ /**
324
+ * Reclaim free pages into the file after a maintenance sweep (ADR-0046).
325
+ *
326
+ * One `VACUUM` in autocommit, gated on the reclaimable share, with a SHORT busy budget for STARTING
327
+ * it: if a rival writer holds the store the pass returns `busy` and the next maintenance pass
328
+ * retries. The VACUUM itself is not interruptible once started (SQLite owns it); the janitor never
329
+ * runs on the tool path, so a tool call never triggers this rewrite, and the store's existing busy
330
+ * budget covers a concurrent writer for the duration. Any failure is reported through the OUTCOME
331
+ * and never thrown out of a lifecycle hook.
332
+ *
333
+ * WHY this function writes NOTHING itself — neither console nor log: a console call here cannot see
334
+ * the per-event surface map, so it would print on `session_shutdown` too, where the console is
335
+ * already torn down (#199). The warning pipeline renders this outcome on the surface its event owns,
336
+ * and the record it writes carries the reason and the error text.
337
+ */
338
+ function compactStore(db: DatabaseSync, minFreeBytes: number): CompactionOutcome {
339
+ // WHY: VACUUM cannot run inside a transaction. The janitor runs in autocommit, so this is a
340
+ // WHY: defensive deferral for a caller that hands in a transaction, not an expected path.
341
+ // SAFETY: node:sqlite exposes `isTransaction` at runtime although DatabaseSync's public type omits it;
342
+ // SAFETY: a missing flag reads as autocommit, which is the janitor's own normal state.
343
+ const inTransaction = (db as unknown as { isTransaction?: boolean }).isTransaction === true;
344
+ if (inTransaction) {
345
+ return { ran: false, reason: "in-transaction", freeBytesBefore: 0, reclaimedBytes: 0 };
346
+ }
347
+ const pageSize = pragmaNumber(db, "PRAGMA page_size");
348
+ const pagesBefore = pragmaNumber(db, "PRAGMA page_count");
349
+ const freePages = pragmaNumber(db, "PRAGMA freelist_count");
350
+ const freeBytesBefore = freePages * pageSize;
351
+ if (freePages === 0) {
352
+ return { ran: false, reason: "no-free-pages", freeBytesBefore: 0, reclaimedBytes: 0 };
353
+ }
354
+ const totalBytes = pagesBefore * pageSize;
355
+ if (
356
+ freeBytesBefore < minFreeBytes ||
357
+ (totalBytes > 0 && freeBytesBefore / totalBytes < STORE_COMPACT_MIN_FREE_RATIO)
358
+ ) {
359
+ return { ran: false, reason: "below-threshold", freeBytesBefore, reclaimedBytes: 0 };
360
+ }
361
+
362
+ const previousTimeout = pragmaNumber(db, "PRAGMA busy_timeout");
363
+ try {
364
+ db.exec(`PRAGMA busy_timeout = ${STORE_COMPACT_BUSY_TIMEOUT_MS}`);
365
+ db.exec("VACUUM");
366
+ } catch (error) {
367
+ if (isBusyError(error)) {
368
+ return { ran: false, reason: "busy", freeBytesBefore, reclaimedBytes: 0 };
369
+ }
370
+ return {
371
+ ran: false,
372
+ reason: "failed",
373
+ freeBytesBefore,
374
+ reclaimedBytes: 0,
375
+ error: error instanceof Error ? error.message : String(error),
376
+ };
377
+ } finally {
378
+ try {
379
+ db.exec(`PRAGMA busy_timeout = ${previousTimeout}`);
380
+ } catch (error) {
381
+ // SAFETY: best-effort restore — the compaction outcome is authoritative, and the next open
382
+ // SAFETY: re-applies the store's own busy timeout. Log-only, never the console: this catch is
383
+ // SAFETY: reachable from the shutdown surface, where the console is already torn down (#199).
384
+ reportDiagnostic("store-janitor", "[store-janitor] failed to restore busy timeout:", error);
385
+ }
386
+ }
387
+ const pagesAfter = pragmaNumber(db, "PRAGMA page_count");
388
+ const reclaimedBytes = Math.max(0, (pagesBefore - pagesAfter) * pageSize);
389
+ return { ran: true, freeBytesBefore, reclaimedBytes };
390
+ }
391
+
392
+ export interface JanitorPassOptions {
393
+ /** The directory the config ladder resolves against; defaults to the process cwd. */
394
+ readonly cwd?: string;
395
+ /** The instant the throttle and the retirement grace are judged at; defaults to `Date.now()`. */
396
+ readonly now?: number;
397
+ /** Resolved knobs, for a caller that already read them (and for tests). */
398
+ readonly settings?: StoreJanitorSettings;
399
+ /** The open store, for a caller that already holds one (and for tests). */
400
+ readonly db?: DatabaseSync;
401
+ readonly onWarn?: ConfigOnWarn;
402
+ /** The heal step, injectable so a test can observe a detected failure without damaging a store. */
403
+ readonly heal?: () => Promise<boolean>;
404
+ }
405
+
406
+ /**
407
+ * A full janitor pass: resolve the knobs, open the store if the caller has none, run once, and hand a
408
+ * damaged store to the open path's recovery. Never throws for a store problem — the caller here is a
409
+ * lifecycle hook, and a maintenance item must not break the session it runs in.
410
+ */
411
+ export async function janitorPass(options: JanitorPassOptions = {}): Promise<JanitorOutcome> {
412
+ const settings =
413
+ options.settings ?? resolveStoreJanitorSettings(options.cwd ?? process.cwd(), options.onWarn);
414
+ // WHY: the early return: mode `off` means "leave this store untouched", so it must not even OPEN
415
+ // WHY: the store — an open is the store's own maintenance boundary (sweep, TTL prune, vacuum).
416
+ if (settings.mode === "off") return skipped("off");
417
+
418
+ const db = options.db ?? (await loadHashStore()).db;
419
+ const outcome = runJanitor({ db, now: options.now ?? Date.now(), settings });
420
+ if (!outcome.ran || !outcome.deepCheckFailed) return outcome;
421
+
422
+ const healed = await (options.heal ?? (() => healUnhealthyStore()))();
423
+ return { ...outcome, rebuilt: healed };
424
+ }
425
+
426
+ export interface JanitorHookDeps {
427
+ /** The pass one lifecycle event triggers; overridden in tests so wiring can be observed. */
428
+ readonly runJanitor: () => Promise<JanitorOutcome>;
429
+ }
430
+
431
+ /**
432
+ * WHERE one event's warning may appear. `session_start` runs while the session is live, so the
433
+ * operator sees the console; `session_shutdown` runs as the runtime tears that console down, so only
434
+ * the log survives. Modelled as a value because "which surface" is the whole rule, and a boolean
435
+ * would leave a reader to infer it from the event name.
436
+ */
437
+ type JanitorWarningSurface = "console" | "log";
438
+
439
+ /** The surface each wired event publishes on. Keyed by the tuple, so a new event cannot be silent. */
440
+ const WARNING_SURFACE_BY_EVENT: Record<(typeof JANITOR_EVENTS)[number], JanitorWarningSurface> = {
441
+ session_start: "console",
442
+ session_shutdown: "log",
443
+ };
444
+
445
+ /**
446
+ * Render one run's warning and publish it on `surface`. The TRIGGER is not re-derived here: the
447
+ * formatter owns "did anything observable happen" (#197), so `null` is the only decision left — and
448
+ * it means write nothing, on either surface.
449
+ *
450
+ * WHY the log path is resolved here and handed down: the formatter is pure by contract (#197), and a
451
+ * module that read the state root itself could not be tested without one. Resolving it at the call
452
+ * site also means the damaged-store shape points at the file this process is actually writing.
453
+ *
454
+ * WHY the level picks the sink helper (`reportInfo` / `reportWarning`) rather than `reportDiagnostic`:
455
+ * that one writes level `error`, and a routine cleanup filed as an error would make the log's
456
+ * severity column lie about the run it describes.
457
+ */
458
+ function publishJanitorWarning(outcome: JanitorOutcome, surface: JanitorWarningSurface): void {
459
+ const warning = formatJanitorWarning(outcome, { logPath: diagnosticsLogPath() });
460
+ if (warning === null) return;
461
+ if (surface === "console") printJanitorWarning(warning);
462
+ const report = warning.level === "info" ? reportInfo : reportWarning;
463
+ // WHY: one record joined by newlines — the damaged-store lines stay separate so a reader can quote
464
+ // WHY: one alone, and a JSONL record escapes the breaks while keeping every line intact.
465
+ report("store-janitor", recordBody(outcome, warning));
466
+ }
467
+
468
+ /**
469
+ * The record body: the rendered lines, plus — for a FAILED compaction only — the exception's own
470
+ * message. WHY the extra line: the rendered shape points at this log for details, so the log must
471
+ * carry them; WHY only here and not on the console: the console line stays short because the
472
+ * exception text is diagnostic evidence, not operator prose.
473
+ */
474
+ function recordBody(outcome: JanitorOutcome, warning: JanitorWarning): string {
475
+ const error = outcome.compacted.reason === "failed" ? outcome.compacted.error : undefined;
476
+ return [
477
+ ...warning.lines,
478
+ ...(error === undefined ? [] : [`${JANITOR_WARNING_PREFIX} Error: ${error}`]),
479
+ ].join("\n");
480
+ }
481
+
482
+ /**
483
+ * The console half, which only `session_start` has. `warn` goes to `console.error` because that is
484
+ * this repo's console habit for an operator-visible problem (`console.warn` appears nowhere in
485
+ * `src/`); the severity that matters is the diagnostic record's, not which fd the line lands on.
486
+ *
487
+ * Never throws: the session's own work must not fail because a surface refused a warning, and a
488
+ * console the host has already detached is exactly the shutdown case this module must survive.
489
+ */
490
+ function printJanitorWarning(warning: JanitorWarning): void {
491
+ try {
492
+ for (const line of warning.lines) {
493
+ if (warning.level === "info") console.info(line);
494
+ else console.error(line);
495
+ }
496
+ } catch {
497
+ // SAFETY: best-effort notice — the diagnostics record written next is the surface that survives.
498
+ }
499
+ }
500
+
501
+ /**
502
+ * Wire the janitor to `JANITOR_EVENTS`. Both handlers run the same best-effort pass and differ only in
503
+ * where they publish its warning: a failure is reported to the log and swallowed, because the
504
+ * session's own work must not fail with maintenance.
505
+ *
506
+ * WHY `session_shutdown` is wired even though the runtime is being torn down: on `quit` the exit path
507
+ * still runs `shutdownDb` (the WAL checkpoint's single owner), and the pass itself is synchronous
508
+ * SQLite work with no awaits of its own inside the run — a handler that cannot finish simply leaves
509
+ * the throttle row unwritten, and the next session_start runs the sweep instead.
510
+ */
511
+ export function registerJanitorHooks(
512
+ pi: ExtensionAPI,
513
+ overrides: Partial<JanitorHookDeps> = {},
514
+ ): void {
515
+ const deps: JanitorHookDeps = {
516
+ runJanitor: () => janitorPass(),
517
+ ...overrides,
518
+ };
519
+ const handlerFor = (surface: JanitorWarningSurface) => async (): Promise<void> => {
520
+ try {
521
+ publishJanitorWarning(await deps.runJanitor(), surface);
522
+ } catch (error) {
523
+ // SAFETY: best-effort maintenance — a failed janitor run leaves the store exactly as it was
524
+ // SAFETY: (every step is idempotent and its own work is its own throttle record), so the
525
+ // SAFETY: session continues and the next lifecycle event retries. The publish step itself
526
+ // SAFETY: cannot throw (see `printJanitorWarning`), so this catch stays the pass's alone.
527
+ reportDiagnostic("store-janitor", "[store-janitor] maintenance pass failed:", error);
528
+ }
529
+ };
530
+ // SAFETY: `pi.on` is typed per event name while `JANITOR_EVENTS` is a runtime tuple, so the
531
+ // SAFETY: registration needs the same string-key widening the entry point already uses; the
532
+ // SAFETY: handler's `(event, ctx) => Promise<void>` shape is what every lifecycle event accepts.
533
+ const register = (pi as unknown as { on: (event: string, handler: () => Promise<void>) => void })
534
+ .on;
535
+ for (const event of JANITOR_EVENTS) {
536
+ // SAFETY: narrowed to the handler signature the widened `on` above declares.
537
+ register.call(pi, event, handlerFor(WARNING_SURFACE_BY_EVENT[event]));
538
+ }
539
+ }