dsh-hashline-edit 0.1.1 → 0.2.0
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.
- package/README.md +70 -69
- package/cordis.patch.yml +1 -1
- package/lib/index.js +4143 -4518
- package/lib/types/dsh/contracts.d.ts +69 -6
- package/lib/types/dsh/fs.d.ts +38 -3
- package/lib/types/dsh/guidance.d.ts +5 -5
- package/lib/types/dsh/hide.d.ts +78 -0
- package/lib/types/dsh/host-compat.d.ts +7 -78
- package/lib/types/dsh/index.d.ts +17 -7
- package/lib/types/dsh/metrics/events.d.ts +8 -1
- package/lib/types/dsh/metrics/instrument.d.ts +20 -14
- package/lib/types/dsh/metrics/observer.d.ts +28 -0
- package/lib/types/dsh/sandbox.d.ts +2 -2
- package/lib/types/dsh/shell-hook.d.ts +17 -8
- package/lib/types/dsh/shell-view.d.ts +52 -0
- package/lib/types/dsh/tools/apply-patch.d.ts +38 -0
- package/lib/types/dsh/tools/context.d.ts +19 -1
- package/lib/types/dsh/tools/output.d.ts +32 -4
- package/lib/types/dsh/tools/read.d.ts +12 -15
- package/lib/types/dsh/tools/undo.d.ts +5 -0
- package/lib/types/dsh/write-hook.d.ts +8 -22
- package/lib/types/src/apply-patch/commit.d.ts +49 -0
- package/lib/types/src/apply-patch/grammar.d.ts +29 -0
- package/lib/types/src/apply-patch/lifecycle.d.ts +32 -0
- package/lib/types/src/apply-patch/plan.d.ts +50 -0
- package/lib/types/src/apply-patch/render.d.ts +51 -0
- package/lib/types/src/apply-patch/resolve.d.ts +129 -0
- package/lib/types/src/apply-patch/tool.d.ts +54 -0
- package/lib/types/src/apply-patch/types.d.ts +362 -0
- package/lib/types/src/domain-errors.d.ts +16 -2
- package/lib/types/src/edit-undo.d.ts +11 -2
- package/lib/types/src/file-content/detection.d.ts +17 -0
- package/lib/types/src/file-content/index.d.ts +2 -2
- package/lib/types/src/file-content/preview.d.ts +1 -1
- package/lib/types/src/fs-lifecycle.d.ts +44 -0
- package/lib/types/src/hashline/resolve.d.ts +6 -0
- package/lib/types/src/host-utils.d.ts +63 -0
- package/lib/types/src/mutation-engine/batch-span-gate.d.ts +6 -0
- package/lib/types/src/mutation-engine/edit-request.d.ts +34 -0
- package/lib/types/src/mutation-engine/pipeline.d.ts +18 -3
- package/lib/types/src/mutation-engine/types.d.ts +50 -58
- package/lib/types/src/read-expansion/grammar-manifest.d.ts +4 -2
- package/lib/types/src/read-expansion/java-decoration.d.ts +21 -0
- package/lib/types/src/read-expansion/span-expansion.d.ts +6 -2
- package/lib/types/src/read-expansion/span-kind.d.ts +3 -2
- package/lib/types/src/read-expansion/vue-decoration.d.ts +14 -0
- package/lib/types/src/served-session/serve-render.d.ts +9 -3
- package/lib/types/src/undo-store.d.ts +17 -1
- package/package.json +10 -22
- package/prompts/apply-patch-guidelines.md +10 -0
- package/prompts/apply-patch.md +1 -0
- package/prompts/read-guidelines.md +5 -5
- package/prompts/read.md +1 -1
- package/prompts/undo-last-edit-guidelines.md +2 -2
- package/prompts/undo-last-edit-snippet.md +1 -1
- package/prompts/undo-last-edit.md +1 -1
- package/src/apply-patch/commit.ts +190 -0
- package/src/apply-patch/grammar.ts +620 -0
- package/src/apply-patch/index.ts +17 -0
- package/src/apply-patch/lifecycle.ts +195 -0
- package/src/apply-patch/plan.ts +800 -0
- package/src/apply-patch/render.ts +137 -0
- package/src/apply-patch/resolve.ts +470 -0
- package/src/apply-patch/tool.ts +257 -0
- package/src/apply-patch/types.ts +387 -0
- package/src/cut-repair.ts +77 -11
- package/src/domain-errors.ts +58 -10
- package/src/edit-undo.ts +379 -25
- package/src/file-content/detection.ts +34 -0
- package/src/file-content/index.ts +2 -2
- package/src/file-content/preview.ts +1 -1
- package/src/file-content/served-header.ts +1 -1
- package/src/fs-lifecycle.ts +201 -0
- package/src/hash-store.ts +18 -1
- package/src/hashline/lease-resolve.ts +14 -8
- package/src/hashline/resolve.ts +30 -27
- package/src/host-utils.ts +224 -0
- package/src/mutation-engine/batch-span-gate.ts +17 -4
- package/src/mutation-engine/edit-request.ts +39 -0
- package/src/mutation-engine/pipeline.ts +535 -63
- package/src/mutation-engine/types.ts +49 -65
- package/src/mutation-queue.ts +1 -1
- package/src/prompts.ts +0 -28
- package/src/read-expansion/ast-provider.ts +4 -0
- package/src/read-expansion/grammar-manifest.ts +21 -2
- package/src/read-expansion/java-decoration.ts +34 -0
- package/src/read-expansion/span-expansion.ts +6 -2
- package/src/read-expansion/span-kind.ts +3 -1
- package/src/read-expansion/vue-decoration.ts +43 -0
- package/src/served-session/serve-render.ts +5 -5
- package/src/undo-store.ts +74 -5
- package/lib/types/dsh/encoding-bridge.d.ts +0 -25
- package/lib/types/dsh/encoding.d.ts +0 -144
- package/lib/types/dsh/tools/edit.d.ts +0 -10
- package/lib/types/src/edit-tool.d.ts +0 -41
- package/lib/types/src/mutation-engine/engine.d.ts +0 -31
- package/lib/types/src/payload-contract.d.ts +0 -124
- package/prompts/edit-guidelines.md +0 -10
- package/prompts/edit-snippet.md +0 -1
- package/prompts/edit.md +0 -1
- package/src/bash-classifier.ts +0 -1626
- package/src/bash-conversion/decide.ts +0 -812
- package/src/bash-display-transform.ts +0 -50
- package/src/bash-multifile-config.ts +0 -239
- package/src/bash-view-config.ts +0 -158
- package/src/edit-normalize.ts +0 -8
- package/src/edit-render.ts +0 -161
- package/src/edit-tool.ts +0 -116
- package/src/edit.ts +0 -136
- package/src/integrations/pi-lens/command.ts +0 -712
- package/src/integrations/pi-lens/config.ts +0 -148
- package/src/integrations/pi-lens/io-bridge-adapter.ts +0 -234
- package/src/integrations/pi-lens/io-bridge.ts +0 -112
- package/src/integrations/pi-lens/settings-overlay.ts +0 -272
- package/src/lifecycle-hooks/index.ts +0 -1992
- package/src/lifecycle-hooks/types.ts +0 -64
- package/src/mutated-files.ts +0 -75
- package/src/mutation-engine/engine.ts +0 -173
- package/src/mutation-engine/index.ts +0 -17
- package/src/payload-contract.ts +0 -637
- package/src/preview-controller.ts +0 -84
- package/src/read.ts +0 -865
- package/src/search-hook.ts +0 -41
- package/src/store-config.ts +0 -73
- package/src/store-janitor-warning.ts +0 -169
- package/src/store-janitor.ts +0 -539
- package/src/store-kernel/settings.ts +0 -233
- package/src/store.ts +0 -154
- package/src/tui-presenter.ts +0 -134
- package/src/write-hook.ts +0 -130
package/src/store-janitor.ts
DELETED
|
@@ -1,539 +0,0 @@
|
|
|
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
|
-
}
|