knodin 0.9.0 → 0.10.1
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/dist/bin/cli.js +35 -7
- package/dist/fixtures/update-verification/fixture.json +1 -1
- package/dist/src/cli-model.js +14 -0
- package/dist/src/engine/index.js +919 -355
- package/dist/src/engine/parse-pool-resources.js +96 -0
- package/dist/src/engine/parse-pool.js +352 -0
- package/dist/src/engine/parse-protocol.js +1 -0
- package/dist/src/engine/parse-worker.js +51 -0
- package/dist/src/engine/reflink-copy.js +23 -0
- package/dist/src/engine/seal-command.js +116 -0
- package/dist/src/engine/seal.js +270 -0
- package/dist/src/engine/sealed-open.js +116 -0
- package/dist/src/engine/sealed-query.js +49 -0
- package/dist/src/init.js +59 -8
- package/dist/src/mcp-worker-supervisor.js +25 -2
- package/dist/src/repair-lease.js +25 -0
- package/dist/src/shared-index/selection.js +1 -1
- package/dist/src/tools/knodin-tools.js +16 -3
- package/dist/src/update-executor.js +29 -10
- package/dist/src/worktree-seed.js +172 -0
- package/docs/SHARED-INDEX-CONTRACT.md +9 -0
- package/docs/releases/0.10.0.md +127 -0
- package/docs/releases/0.10.1.md +83 -0
- package/package.json +3 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import zlib from "node:zlib";
|
|
5
|
+
import { KNODIN_VERSION } from "../version.js";
|
|
6
|
+
import { Database } from "./sqlite.js";
|
|
7
|
+
/** Storage for embedded source. Content-addressed, so duplicate files cost one blob. */
|
|
8
|
+
const SEAL_SCHEMA = [
|
|
9
|
+
`CREATE TABLE IF NOT EXISTS sealed_source (
|
|
10
|
+
sha256 TEXT PRIMARY KEY,
|
|
11
|
+
bytes BLOB NOT NULL,
|
|
12
|
+
compressed INTEGER NOT NULL,
|
|
13
|
+
storedBytes INTEGER NOT NULL,
|
|
14
|
+
rawBytes INTEGER NOT NULL
|
|
15
|
+
)`,
|
|
16
|
+
`CREATE TABLE IF NOT EXISTS sealed_file (
|
|
17
|
+
filePath TEXT PRIMARY KEY,
|
|
18
|
+
sha256 TEXT NOT NULL REFERENCES sealed_source(sha256)
|
|
19
|
+
)`,
|
|
20
|
+
];
|
|
21
|
+
/** Line count under the same `split("\n")` convention the read paths use. */
|
|
22
|
+
function countLines(buf) {
|
|
23
|
+
return buf.toString("utf8").split("\n").length;
|
|
24
|
+
}
|
|
25
|
+
export function readSealAttestation(db) {
|
|
26
|
+
try {
|
|
27
|
+
const row = db
|
|
28
|
+
.query("SELECT value FROM meta WHERE key = 'sealAttestation'")
|
|
29
|
+
.get();
|
|
30
|
+
return row ? JSON.parse(row.value) : null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reads embedded source out of a sealed artifact.
|
|
38
|
+
*
|
|
39
|
+
* Returns `null` — never `""` — for a path the artifact does not carry. An
|
|
40
|
+
* empty string is indistinguishable from an empty file, and downstream that
|
|
41
|
+
* reads as "this symbol has no body" rather than "this was not covered",
|
|
42
|
+
* which is exactly the silent false negative the consumer needs to be able to
|
|
43
|
+
* detect in order to fall back.
|
|
44
|
+
*/
|
|
45
|
+
export function readSealedSource(db, filePath) {
|
|
46
|
+
const row = db
|
|
47
|
+
.query("SELECT s.bytes bytes, s.compressed compressed FROM sealed_file f JOIN sealed_source s ON s.sha256 = f.sha256 WHERE f.filePath = ?")
|
|
48
|
+
.get(filePath);
|
|
49
|
+
if (!row)
|
|
50
|
+
return null;
|
|
51
|
+
const raw = Buffer.from(row.bytes);
|
|
52
|
+
return (row.compressed ? zlib.zstdDecompressSync(raw) : raw).toString("utf8");
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Every path the sealed artifact carries source for.
|
|
56
|
+
*
|
|
57
|
+
* A sealed repository has no directory tree to walk, so anything that
|
|
58
|
+
* discovers files by traversal (module resolution, package manifests) has to
|
|
59
|
+
* enumerate coverage instead.
|
|
60
|
+
*/
|
|
61
|
+
export function listSealedFiles(db) {
|
|
62
|
+
return db
|
|
63
|
+
.query("SELECT filePath FROM sealed_file ORDER BY filePath")
|
|
64
|
+
.all()
|
|
65
|
+
.map((row) => row.filePath);
|
|
66
|
+
}
|
|
67
|
+
/** True when this database carries embedded source, i.e. can answer with no checkout. */
|
|
68
|
+
export function isSealedDatabase(db) {
|
|
69
|
+
return readSealAttestation(db) !== null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Produce a sealed artifact, or refuse with a machine-readable code.
|
|
73
|
+
*
|
|
74
|
+
* Refusal is the interesting half. A seal of a dirty or diverged index
|
|
75
|
+
* produces citations that look exactly like correct ones — the failure is
|
|
76
|
+
* invisible downstream — so it is refused here rather than annotated.
|
|
77
|
+
*/
|
|
78
|
+
export function sealIndex(options) {
|
|
79
|
+
const { repoPath, sourceDatabasePath, outputPath, health, identity, commit, ref, dirtyPaths } = options;
|
|
80
|
+
const stripEmbeddings = options.stripEmbeddings !== false;
|
|
81
|
+
if (!fs.existsSync(sourceDatabasePath))
|
|
82
|
+
return { ok: false, code: "database-missing", reason: `no index at ${sourceDatabasePath}` };
|
|
83
|
+
if (health.status !== "healthy")
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
code: "index-unhealthy",
|
|
87
|
+
reason: `index status is ${health.status}; seal requires a healthy index`,
|
|
88
|
+
};
|
|
89
|
+
if (dirtyPaths.length > 0)
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: "working-tree-dirty",
|
|
93
|
+
reason: `${dirtyPaths.length} path(s) differ from the index; re-index before sealing`,
|
|
94
|
+
};
|
|
95
|
+
if (!commit)
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
code: "no-commit",
|
|
99
|
+
reason: "repository has no resolvable commit to attest",
|
|
100
|
+
};
|
|
101
|
+
if (health.pendingPaths > 0)
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
code: "commit-divergent",
|
|
105
|
+
reason: `${health.pendingPaths} path(s) pending; the graph does not describe ${commit}`,
|
|
106
|
+
};
|
|
107
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
108
|
+
fs.rmSync(outputPath, { force: true });
|
|
109
|
+
// Copy rather than seal in place: the live database keeps serving its own
|
|
110
|
+
// repository, and a half-written artifact can never corrupt it.
|
|
111
|
+
fs.copyFileSync(sourceDatabasePath, outputPath);
|
|
112
|
+
const db = new Database(outputPath);
|
|
113
|
+
try {
|
|
114
|
+
db.run("PRAGMA journal_mode = WAL;");
|
|
115
|
+
for (const statement of SEAL_SCHEMA)
|
|
116
|
+
db.run(statement);
|
|
117
|
+
db.run("DELETE FROM sealed_file;");
|
|
118
|
+
db.run("DELETE FROM sealed_source;");
|
|
119
|
+
let removedEmbeddings = 0;
|
|
120
|
+
if (stripEmbeddings) {
|
|
121
|
+
removedEmbeddings =
|
|
122
|
+
db.query("SELECT COUNT(*) count FROM symbol_embeddings").get()
|
|
123
|
+
?.count ?? 0;
|
|
124
|
+
db.run("DELETE FROM symbol_embeddings;");
|
|
125
|
+
}
|
|
126
|
+
const indexed = db
|
|
127
|
+
.query("SELECT filePath, mtimeMs, size FROM index_state ORDER BY filePath")
|
|
128
|
+
.all();
|
|
129
|
+
const insertBlob = db.prepare("INSERT OR IGNORE INTO sealed_source (sha256, bytes, compressed, storedBytes, rawBytes) VALUES (?, ?, ?, ?, ?)");
|
|
130
|
+
const insertFile = db.prepare("INSERT OR REPLACE INTO sealed_file (filePath, sha256) VALUES (?, ?)");
|
|
131
|
+
const maxEndLine = new Map();
|
|
132
|
+
for (const row of db
|
|
133
|
+
.query("SELECT filePath, MAX(endLine) lastLine FROM symbols GROUP BY filePath")
|
|
134
|
+
.all())
|
|
135
|
+
maxEndLine.set(row.filePath, row.lastLine);
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
const drifted = [];
|
|
138
|
+
let files = 0;
|
|
139
|
+
let rawBytes = 0;
|
|
140
|
+
let storedBytes = 0;
|
|
141
|
+
db.run("BEGIN TRANSACTION;");
|
|
142
|
+
try {
|
|
143
|
+
for (const row of indexed) {
|
|
144
|
+
if (options.excludeSource?.(row.filePath))
|
|
145
|
+
continue;
|
|
146
|
+
const absolute = path.join(repoPath, row.filePath);
|
|
147
|
+
let buf;
|
|
148
|
+
let stat;
|
|
149
|
+
try {
|
|
150
|
+
stat = fs.statSync(absolute);
|
|
151
|
+
buf = fs.readFileSync(absolute);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// A file that vanished between indexing and sealing is simply not
|
|
155
|
+
// embedded. Queries for it report unavailable rather than empty.
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// The drift hazard, and the reason this is a refusal rather than a
|
|
159
|
+
// warning. Seal reads the tree; the graph was built earlier. If a file
|
|
160
|
+
// changed in between, embedded bytes disagree with recorded line
|
|
161
|
+
// ranges and `explain` returns the WRONG function's body with full
|
|
162
|
+
// confidence and no omission — a false positive that looks correct,
|
|
163
|
+
// which is strictly worse than any false negative here.
|
|
164
|
+
if ((row.size !== null && row.size !== stat.size) ||
|
|
165
|
+
(row.mtimeMs !== null && Math.abs(row.mtimeMs - stat.mtimeMs) > 1)) {
|
|
166
|
+
drifted.push(row.filePath);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// Defence in depth behind the mtime/size check above, and the reason
|
|
170
|
+
// it is worth having: mtime+size cannot see a same-size edit that
|
|
171
|
+
// lands inside the mtime comparison window. A range that now runs off
|
|
172
|
+
// the end of the file is proof the graph and these bytes disagree,
|
|
173
|
+
// independent of any timestamp.
|
|
174
|
+
const lastLine = maxEndLine.get(row.filePath);
|
|
175
|
+
if (lastLine !== undefined && lastLine > countLines(buf)) {
|
|
176
|
+
drifted.push(row.filePath);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const hash = crypto.createHash("sha256").update(buf).digest("hex");
|
|
180
|
+
files++;
|
|
181
|
+
rawBytes += buf.length;
|
|
182
|
+
if (!seen.has(hash)) {
|
|
183
|
+
seen.add(hash);
|
|
184
|
+
// Compressed PER BLOB, not just in transit: SQLite does not
|
|
185
|
+
// transparently compress, so a consumer holds whatever is stored
|
|
186
|
+
// here. Transport compression would do nothing for that.
|
|
187
|
+
//
|
|
188
|
+
// Keep the raw bytes when the compressed form is not smaller.
|
|
189
|
+
// zstd framing exceeds the payload on very short files, and
|
|
190
|
+
// storing those compressed would make the artifact larger than
|
|
191
|
+
// the source it embeds.
|
|
192
|
+
const packed = zlib.zstdCompressSync(buf);
|
|
193
|
+
const useCompressed = packed.length < buf.length;
|
|
194
|
+
const stored = useCompressed ? packed : buf;
|
|
195
|
+
storedBytes += stored.length;
|
|
196
|
+
insertBlob.run(hash, stored, useCompressed ? 1 : 0, stored.length, buf.length);
|
|
197
|
+
}
|
|
198
|
+
insertFile.run(row.filePath, hash);
|
|
199
|
+
}
|
|
200
|
+
db.run("COMMIT;");
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
db.run("ROLLBACK;");
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
insertBlob.finalize();
|
|
208
|
+
insertFile.finalize();
|
|
209
|
+
}
|
|
210
|
+
if (drifted.length > 0) {
|
|
211
|
+
db.close();
|
|
212
|
+
// Discard the partial artifact rather than shipping one whose coverage
|
|
213
|
+
// silently excludes the very files that moved.
|
|
214
|
+
fs.rmSync(outputPath, { force: true });
|
|
215
|
+
const sample = drifted.slice(0, 5).join(", ");
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
code: "source-drift",
|
|
219
|
+
reason: `${drifted.length} file(s) changed since indexing (${sample}${drifted.length > 5 ? ", …" : ""}); re-index before sealing`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const attestation = {
|
|
223
|
+
sealVersion: 1,
|
|
224
|
+
knodinVersion: KNODIN_VERSION,
|
|
225
|
+
schemaVersion: db.query("PRAGMA user_version").get()?.user_version ?? 0,
|
|
226
|
+
repository: { identity, path: repoPath, remoteUrl: options.remoteUrl ?? null },
|
|
227
|
+
sealedCommit: commit,
|
|
228
|
+
sealedRef: ref,
|
|
229
|
+
sealedAt: new Date().toISOString(),
|
|
230
|
+
health: { ...health },
|
|
231
|
+
source: {
|
|
232
|
+
policy: options.excludeSource ? "excluded" : "all-indexed",
|
|
233
|
+
files,
|
|
234
|
+
uniqueBlobs: seen.size,
|
|
235
|
+
rawBytes,
|
|
236
|
+
storedBytes,
|
|
237
|
+
},
|
|
238
|
+
embeddings: { stripped: stripEmbeddings, removed: removedEmbeddings },
|
|
239
|
+
};
|
|
240
|
+
db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('sealAttestation', ?)", [
|
|
241
|
+
JSON.stringify(attestation),
|
|
242
|
+
]);
|
|
243
|
+
// Reclaim the pages freed by stripping embeddings and by any deletion
|
|
244
|
+
// above. Without this the artifact keeps every freed page and stripping
|
|
245
|
+
// saves nothing on disk — the whole reason embeddings are dropped. Must
|
|
246
|
+
// run outside a transaction, which is why it lands here rather than
|
|
247
|
+
// alongside the deletes.
|
|
248
|
+
db.run("VACUUM;");
|
|
249
|
+
const integrity = db.query("PRAGMA integrity_check").get()?.integrity_check ??
|
|
250
|
+
"unknown";
|
|
251
|
+
// Fold the WAL back in: a consumer receives ONE file, and an artifact
|
|
252
|
+
// whose committed state lives in a sidecar it never got is a silent
|
|
253
|
+
// truncation rather than a loud failure.
|
|
254
|
+
db.run("PRAGMA wal_checkpoint(TRUNCATE);");
|
|
255
|
+
db.run("PRAGMA journal_mode = DELETE;");
|
|
256
|
+
db.close();
|
|
257
|
+
for (const suffix of ["-wal", "-shm"])
|
|
258
|
+
fs.rmSync(`${outputPath}${suffix}`, { force: true });
|
|
259
|
+
const bytes = fs.statSync(outputPath).size;
|
|
260
|
+
const sha256 = crypto.createHash("sha256").update(fs.readFileSync(outputPath)).digest("hex");
|
|
261
|
+
return { ok: true, outputPath, bytes, attestation, integrityCheck: integrity, sha256 };
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
try {
|
|
265
|
+
db.close();
|
|
266
|
+
}
|
|
267
|
+
catch { }
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { KNODIN_VERSION as RUNTIME_VERSION } from "../version.js";
|
|
5
|
+
import { KNODIN_SCHEMA_VERSION, registerSealedRepository, unregisterSealedRepository, } from "./index.js";
|
|
6
|
+
import { readSealAttestation } from "./seal.js";
|
|
7
|
+
import { Database } from "./sqlite.js";
|
|
8
|
+
export function openSealedArtifact(artifactPath, options = {}) {
|
|
9
|
+
const resolved = path.resolve(artifactPath);
|
|
10
|
+
if (!fs.existsSync(resolved))
|
|
11
|
+
return { ok: false, code: "artifact-missing", reason: `no artifact at ${resolved}` };
|
|
12
|
+
let db;
|
|
13
|
+
try {
|
|
14
|
+
db = new Database(resolved, { readonly: true });
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
return {
|
|
18
|
+
ok: false,
|
|
19
|
+
code: "unreadable",
|
|
20
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const attestation = readSealAttestation(db);
|
|
24
|
+
if (!attestation) {
|
|
25
|
+
db.close();
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
code: "not-sealed",
|
|
29
|
+
reason: "database carries no seal attestation; it is an ordinary index, not an artifact",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// HARD gate. The graph schema is what every query is written against, so a
|
|
33
|
+
// mismatch is not a degradation — the answers would be wrong rather than
|
|
34
|
+
// narrow. A consumer falls back to live source on this code.
|
|
35
|
+
if (attestation.schemaVersion !== KNODIN_SCHEMA_VERSION) {
|
|
36
|
+
db.close();
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
code: "schema-mismatch",
|
|
40
|
+
reason: `artifact schema ${attestation.schemaVersion} != engine schema ${KNODIN_SCHEMA_VERSION}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const degraded = [];
|
|
44
|
+
// Stripped embeddings must make semantic search report itself UNAVAILABLE.
|
|
45
|
+
// Left to return zero results it would be indistinguishable from "nothing
|
|
46
|
+
// matched", which is the silent false negative this whole design exists to
|
|
47
|
+
// prevent.
|
|
48
|
+
if (attestation.embeddings.stripped)
|
|
49
|
+
degraded.push({
|
|
50
|
+
capability: "semanticSearch",
|
|
51
|
+
reason: "embeddings were stripped at seal time; semantic search cannot run",
|
|
52
|
+
});
|
|
53
|
+
// SOFT gate, and the asymmetry with the schema check is deliberate: a
|
|
54
|
+
// sealed artifact is never re-parsed, so a knodin version that parses
|
|
55
|
+
// differently does not invalidate symbols already extracted.
|
|
56
|
+
if (attestation.knodinVersion !== KNODIN_VERSION_AT_RUNTIME()) {
|
|
57
|
+
if (options.strictCompat) {
|
|
58
|
+
db.close();
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
code: "schema-mismatch",
|
|
62
|
+
reason: `--strict-compat: artifact knodin ${attestation.knodinVersion} != runtime`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
degraded.push({
|
|
66
|
+
capability: "review",
|
|
67
|
+
reason: `sealed by knodin ${attestation.knodinVersion}; heuristics may differ`,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// A sealed artifact has no working tree to write to, and no way to reindex.
|
|
71
|
+
degraded.push({ capability: "rename", reason: "sealed artifacts are read-only" }, { capability: "index", reason: "sealed artifacts cannot be reindexed" });
|
|
72
|
+
const mountDir = options.mountDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "knodin-sealed-mount-"));
|
|
73
|
+
// The artifact lives under `candidates/` so the engine's explicit-database
|
|
74
|
+
// guard accepts it: that guard refuses a path colliding with the active
|
|
75
|
+
// database, which is a candidate-isolation invariant rather than an
|
|
76
|
+
// obstacle to route around.
|
|
77
|
+
const mountedDb = path.join(mountDir, ".knodin", "candidates", "sealed", "db.sqlite");
|
|
78
|
+
fs.mkdirSync(path.dirname(mountedDb), { recursive: true });
|
|
79
|
+
if (!fs.existsSync(mountedDb))
|
|
80
|
+
fs.copyFileSync(resolved, mountedDb);
|
|
81
|
+
// Registered under BOTH paths. Callers address the artifact by its mount
|
|
82
|
+
// directory, but the engine resolves a repository from paths recorded in
|
|
83
|
+
// the database itself — the checkout the index was built from. Registering
|
|
84
|
+
// only the mount would leave those internal reads falling through to a
|
|
85
|
+
// filesystem that does not have the source, which fails as "" rather than
|
|
86
|
+
// loudly.
|
|
87
|
+
registerSealedRepository(mountDir, db);
|
|
88
|
+
const attestedPath = attestation.repository.path;
|
|
89
|
+
if (attestedPath && path.resolve(attestedPath) !== path.resolve(mountDir))
|
|
90
|
+
registerSealedRepository(attestedPath, db);
|
|
91
|
+
const sealedAt = Date.parse(attestation.sealedAt);
|
|
92
|
+
const ageDays = Number.isNaN(sealedAt)
|
|
93
|
+
? 0
|
|
94
|
+
: Math.floor((Date.now() - sealedAt) / (24 * 60 * 60 * 1000));
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
repoPath: mountDir,
|
|
98
|
+
databasePath: mountedDb,
|
|
99
|
+
attestation,
|
|
100
|
+
degraded,
|
|
101
|
+
ageDays,
|
|
102
|
+
close() {
|
|
103
|
+
unregisterSealedRepository(mountDir);
|
|
104
|
+
if (attestedPath)
|
|
105
|
+
unregisterSealedRepository(attestedPath);
|
|
106
|
+
db.close();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Read at call time rather than imported as a constant so that a test can
|
|
112
|
+
* exercise the soft gate without rebuilding the package version.
|
|
113
|
+
*/
|
|
114
|
+
function KNODIN_VERSION_AT_RUNTIME() {
|
|
115
|
+
return process.env.KNODIN_VERSION_OVERRIDE ?? RUNTIME_VERSION;
|
|
116
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createEngine } from "./index.js";
|
|
2
|
+
import { openSealedArtifact, } from "./sealed-open.js";
|
|
3
|
+
export async function runSealedQuery(artifactPath, symbol, options = {}) {
|
|
4
|
+
const handle = openSealedArtifact(artifactPath, { strictCompat: options.strictCompat });
|
|
5
|
+
if (!handle.ok)
|
|
6
|
+
return { ok: false, code: handle.code, reason: handle.reason };
|
|
7
|
+
try {
|
|
8
|
+
const summary = {
|
|
9
|
+
ok: true,
|
|
10
|
+
repository: {
|
|
11
|
+
identity: handle.attestation.repository.identity,
|
|
12
|
+
commit: handle.attestation.sealedCommit,
|
|
13
|
+
ref: handle.attestation.sealedRef,
|
|
14
|
+
},
|
|
15
|
+
sealedAt: handle.attestation.sealedAt,
|
|
16
|
+
ageDays: handle.ageDays,
|
|
17
|
+
knodinVersion: handle.attestation.knodinVersion,
|
|
18
|
+
schemaVersion: handle.attestation.schemaVersion,
|
|
19
|
+
coverage: {
|
|
20
|
+
files: handle.attestation.source.files,
|
|
21
|
+
uniqueBlobs: handle.attestation.source.uniqueBlobs,
|
|
22
|
+
rawBytes: handle.attestation.source.rawBytes,
|
|
23
|
+
storedBytes: handle.attestation.source.storedBytes,
|
|
24
|
+
},
|
|
25
|
+
degraded: handle.degraded,
|
|
26
|
+
};
|
|
27
|
+
if (!symbol)
|
|
28
|
+
return summary;
|
|
29
|
+
// `mode: "sealed"` is the whole reason this wrapper exists. Without it the
|
|
30
|
+
// engine reconciles against the empty mount, prunes every symbol, and
|
|
31
|
+
// answers "not found" for content the artifact is carrying — a negative
|
|
32
|
+
// that looks entirely correct. Too important to leave to each caller.
|
|
33
|
+
const engine = createEngine({
|
|
34
|
+
watcher: "disabled",
|
|
35
|
+
mode: "sealed",
|
|
36
|
+
databasePath: handle.databasePath,
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
summary.explained = await engine.explain(symbol, handle.repoPath);
|
|
40
|
+
return summary;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await engine.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
handle.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/src/init.js
CHANGED
|
@@ -7,7 +7,7 @@ import { compareBytes } from "./compare.js";
|
|
|
7
7
|
import { isIndexableSourcePath } from "./engine/source-policy.js";
|
|
8
8
|
import { lookupMirror } from "./engine/state-paths.js";
|
|
9
9
|
import { inspectLefthookIntegration, installHookManagerIntegration, isActiveLefthookHook, } from "./hook-manager-integration.js";
|
|
10
|
-
import { acquireRepairLease } from "./repair-lease.js";
|
|
10
|
+
import { acquireRepairLease, LIFECYCLE_LEASE_TOKEN_ENV } from "./repair-lease.js";
|
|
11
11
|
import { installKnodinSkills, removeKnodinSkills } from "./skill-management.js";
|
|
12
12
|
import { registerInitializedWorktree } from "./worktree-lifecycle.js";
|
|
13
13
|
const MANAGED_MARKER = "KNODIN MANAGED HOOK";
|
|
@@ -43,6 +43,13 @@ function runGit(repo, args, encoding = "utf-8") {
|
|
|
43
43
|
cwd: repo,
|
|
44
44
|
encoding,
|
|
45
45
|
stdio: ["ignore", "pipe", "ignore"],
|
|
46
|
+
// A commit/checkout/merge touching many files (a large monorepo, an
|
|
47
|
+
// initial import, a mass rename) can produce a path list well past
|
|
48
|
+
// Node's 1 MiB execFileSync default, which throws rather than
|
|
49
|
+
// truncates. Match the 64 MiB bound already used for git output
|
|
50
|
+
// elsewhere in this codebase (see gitTrackedCandidates,
|
|
51
|
+
// execGitAbortable in src/engine/index.ts).
|
|
52
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
46
53
|
});
|
|
47
54
|
}
|
|
48
55
|
const MUTATION_AUDIT_MAX_ENTRIES = 2_048;
|
|
@@ -264,6 +271,24 @@ function diffPaths(repo, before, after) {
|
|
|
264
271
|
}
|
|
265
272
|
return nulPaths(repo, ["diff", "--name-only", before, after, "--"]);
|
|
266
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Runs a git-diff-shaped path computation and degrades to "no known changes"
|
|
276
|
+
* on failure instead of throwing. A lifecycle hook process crashing on an
|
|
277
|
+
* oversized or otherwise-failing git subprocess call has historically taken
|
|
278
|
+
* down the whole background refresh (exit code 1, nothing indexed at all);
|
|
279
|
+
* the per-query freshness guard and the next reconcile pass already detect
|
|
280
|
+
* and catch up any drift this misses, so failing soft here is strictly safer
|
|
281
|
+
* than failing hard.
|
|
282
|
+
*/
|
|
283
|
+
function gitPathsOrEmpty(compute, context) {
|
|
284
|
+
try {
|
|
285
|
+
return compute();
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
console.warn(`knodin: lifecycle hook refresh could not compute changed paths for ${context} (${error instanceof Error ? error.message : String(error)}); the next reconcile will catch up any drift.`);
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
267
292
|
/**
|
|
268
293
|
* Resolve changed paths from a Git lifecycle event and incrementally reconcile
|
|
269
294
|
* exactly the candidates accepted by the engine's full-repository policy.
|
|
@@ -274,17 +299,17 @@ export async function refreshFromGitEvent(repo, event, index) {
|
|
|
274
299
|
let rewriteInputToRemove;
|
|
275
300
|
try {
|
|
276
301
|
if (event.kind === "commit") {
|
|
277
|
-
changed = nulPaths(resolvedRepo, [
|
|
302
|
+
changed = gitPathsOrEmpty(() => nulPaths(resolvedRepo, [
|
|
278
303
|
"diff-tree",
|
|
279
304
|
"--root",
|
|
280
305
|
"--no-commit-id",
|
|
281
306
|
"--name-only",
|
|
282
307
|
"-r",
|
|
283
308
|
"HEAD",
|
|
284
|
-
]);
|
|
309
|
+
]), "a commit event");
|
|
285
310
|
}
|
|
286
311
|
else if (event.kind === "checkout" || event.kind === "merge") {
|
|
287
|
-
changed = diffPaths(resolvedRepo, event.before, event.after);
|
|
312
|
+
changed = gitPathsOrEmpty(() => diffPaths(resolvedRepo, event.before, event.after), `a ${event.kind} event`);
|
|
288
313
|
}
|
|
289
314
|
else {
|
|
290
315
|
const hooksRoot = path.join(resolvedRepo, ".knodin", "hooks");
|
|
@@ -316,7 +341,7 @@ export async function refreshFromGitEvent(repo, event, index) {
|
|
|
316
341
|
.map((line) => line.trim().split(/\s+/))
|
|
317
342
|
.filter((pair) => pair.length >= 2);
|
|
318
343
|
for (const [before, after] of pairs) {
|
|
319
|
-
changed.push(...diffPaths(resolvedRepo, before, after));
|
|
344
|
+
changed.push(...gitPathsOrEmpty(() => diffPaths(resolvedRepo, before, after), "a rewrite pair"));
|
|
320
345
|
}
|
|
321
346
|
}
|
|
322
347
|
const eligible = [...new Set(changed.map((file) => file.replaceAll("\\", "/")))]
|
|
@@ -332,10 +357,29 @@ export async function refreshFromGitEvent(repo, event, index) {
|
|
|
332
357
|
}
|
|
333
358
|
}
|
|
334
359
|
function backgroundScript(command) {
|
|
335
|
-
|
|
360
|
+
// The interpreter is resolved at RUN time, not baked in. `process.execPath`
|
|
361
|
+
// is symlink-resolved by Node, so a stable `/opt/homebrew/bin/node` becomes
|
|
362
|
+
// a version-pinned `/opt/homebrew/Cellar/node/<version>/bin/node`. Homebrew
|
|
363
|
+
// deletes that directory on upgrade, and every hook generated beforehand
|
|
364
|
+
// then dies with exit 127 — silently, because refresh runs in the
|
|
365
|
+
// background, so the graph just stops keeping up.
|
|
366
|
+
//
|
|
367
|
+
// The generated path stays PREFERRED, so nothing changes while it exists;
|
|
368
|
+
// the fallback only engages once it does not. Falling back first would
|
|
369
|
+
// silently switch which runtime executes, which is its own bug.
|
|
370
|
+
const [interpreter, ...rest] = command;
|
|
371
|
+
const invocation = ['"$KNODIN_NODE"', ...rest.map(shellQuote)].join(" ");
|
|
372
|
+
const preferred = shellQuote(interpreter ?? process.execPath);
|
|
336
373
|
return String.raw `#!/bin/sh
|
|
337
374
|
# knodin packaged background refresh. Generated by knodin init.
|
|
338
375
|
set -u
|
|
376
|
+
KNODIN_NODE=${preferred}
|
|
377
|
+
if [ ! -x "$KNODIN_NODE" ]; then
|
|
378
|
+
KNODIN_NODE="$(command -v node 2>/dev/null || true)"
|
|
379
|
+
# No interpreter at all: fall through to the recorded path so the failure is
|
|
380
|
+
# the existing loud "not found", not a silent no-op.
|
|
381
|
+
[ -n "$KNODIN_NODE" ] || KNODIN_NODE=${preferred}
|
|
382
|
+
fi
|
|
339
383
|
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
|
340
384
|
[ -n "$REPO_ROOT" ] || exit 0
|
|
341
385
|
cd "$REPO_ROOT" || exit 0
|
|
@@ -435,7 +479,7 @@ function refreshProcessorIsRunning(repo) {
|
|
|
435
479
|
return false;
|
|
436
480
|
}
|
|
437
481
|
}
|
|
438
|
-
function drainQueuedLifecycleEvents(repo, backgroundPath) {
|
|
482
|
+
function drainQueuedLifecycleEvents(repo, backgroundPath, leaseToken) {
|
|
439
483
|
const before = queuedLifecycleEvents(repo);
|
|
440
484
|
if (before.length === 0)
|
|
441
485
|
return { state: "fresh", queuedEvents: 0 };
|
|
@@ -444,6 +488,13 @@ function drainQueuedLifecycleEvents(repo, backgroundPath) {
|
|
|
444
488
|
encoding: "utf-8",
|
|
445
489
|
stdio: ["ignore", "pipe", "pipe"],
|
|
446
490
|
timeout: 30_000,
|
|
491
|
+
// The caller holds the lifecycle lease while this runs. Without handing
|
|
492
|
+
// the token down, the child re-enters knodin, meets its own parent's
|
|
493
|
+
// lease, and refuses — so the drain could never succeed and init
|
|
494
|
+
// reported failure on every run.
|
|
495
|
+
env: leaseToken
|
|
496
|
+
? { ...process.env, [LIFECYCLE_LEASE_TOKEN_ENV]: leaseToken }
|
|
497
|
+
: { ...process.env },
|
|
447
498
|
});
|
|
448
499
|
if (result.status !== 0) {
|
|
449
500
|
const detail = result.error?.message || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
|
|
@@ -1264,7 +1315,7 @@ export async function initializeRepository(repo, options) {
|
|
|
1264
1315
|
if (isIndexResult(indexResult) && indexResult.verification.status !== "healthy") {
|
|
1265
1316
|
throw new InitializationHealthError(indexResult);
|
|
1266
1317
|
}
|
|
1267
|
-
const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath);
|
|
1318
|
+
const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
|
|
1268
1319
|
await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), {
|
|
1269
1320
|
force: true,
|
|
1270
1321
|
});
|
|
@@ -200,8 +200,19 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
200
200
|
}
|
|
201
201
|
const { requestId, operation } = context;
|
|
202
202
|
try {
|
|
203
|
+
// Same race as the startup one below, one phase earlier: the queueAbort
|
|
204
|
+
// arm above reports CLIENT_DISCONNECTED, and this re-check reported
|
|
205
|
+
// CANCELLED for the identical event whenever the queue wait resolved in
|
|
206
|
+
// the same instant the abort landed.
|
|
207
|
+
//
|
|
208
|
+
// The supervisor observes only "the signal aborted" and cannot tell a
|
|
209
|
+
// disconnect from an explicit cancel; every abort-listener arm here
|
|
210
|
+
// already calls that a disconnect, so the re-checks now agree with them.
|
|
211
|
+
// The distinction belongs to the layer that knows why the signal fired —
|
|
212
|
+
// superviseMcpRequest, which raises REQUEST_CANCELLED for a client
|
|
213
|
+
// cancellation it can actually identify.
|
|
203
214
|
if (signal.aborted)
|
|
204
|
-
throw codedError("
|
|
215
|
+
throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker dispatch");
|
|
205
216
|
if (!this.child) {
|
|
206
217
|
if (this.restartTimes.length >= MAX_RESTARTS)
|
|
207
218
|
throw codedError("KNODIN_RESTART_LIMIT", "graph worker restart limit reached");
|
|
@@ -233,8 +244,20 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
233
244
|
if (startupAbort)
|
|
234
245
|
signal.removeEventListener("abort", startupAbort);
|
|
235
246
|
});
|
|
247
|
+
// Same event as the startupAbort arm above, same code. Both describe an
|
|
248
|
+
// abort arriving during worker startup; which one reports it is decided
|
|
249
|
+
// by whether the startup race settles as ready or as aborted in the same
|
|
250
|
+
// instant. Reporting different codes made the SAME client disconnect
|
|
251
|
+
// surface as CLIENT_DISCONNECTED or REQUEST_CANCELLED depending on
|
|
252
|
+
// scheduling — stable on an idle machine, unstable under load, which is
|
|
253
|
+
// worse than a consistently wrong value because it invites trust.
|
|
254
|
+
//
|
|
255
|
+
// These codes name a CAUSE, not a phase. classifyMcpFailure maps them to
|
|
256
|
+
// distinct kinds in the reliability journal, so the race was also
|
|
257
|
+
// recording one disconnect under two kinds and silently contaminating
|
|
258
|
+
// any disconnect-versus-cancellation rate computed from it.
|
|
236
259
|
if (signal.aborted)
|
|
237
|
-
throw codedError("
|
|
260
|
+
throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected during worker startup");
|
|
238
261
|
const predecessorTraceId = this.lastFailureTraceId;
|
|
239
262
|
if (predecessorTraceId)
|
|
240
263
|
recordMcpLifecycle(context, "restart", { predecessorTraceId });
|
package/dist/src/repair-lease.js
CHANGED
|
@@ -92,10 +92,34 @@ function removeStaleLeaseOrThrow(target, operation) {
|
|
|
92
92
|
throw error;
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Environment channel by which a lease holder tells a process it spawns that
|
|
97
|
+
* the lease it is about to meet is its OWN.
|
|
98
|
+
*
|
|
99
|
+
* `knodin init` holds the lease and then drains the queued lifecycle refresh by
|
|
100
|
+
* spawning the background hook, which re-enters knodin and tries to take the
|
|
101
|
+
* same lease. Without this the child sees a live pid — its own parent — and
|
|
102
|
+
* refuses, so init can never drain its own queue and reports failure on every
|
|
103
|
+
* run while telling the user to run init again.
|
|
104
|
+
*/
|
|
105
|
+
export const LIFECYCLE_LEASE_TOKEN_ENV = "KNODIN_LIFECYCLE_LEASE_TOKEN";
|
|
95
106
|
/** Atomically serialize repair post-processing across independent CLI/MCP processes. */
|
|
96
107
|
export function acquireRepairLease(repo, operation = "repair") {
|
|
97
108
|
const target = leasePath(repo);
|
|
98
109
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
110
|
+
// Re-entrant ONLY for a token that matches the lease actually on disk. A
|
|
111
|
+
// stale or forged token falls through to the normal contention check, so a
|
|
112
|
+
// genuinely concurrent mutation from another process is still refused —
|
|
113
|
+
// this makes the lease stop blocking its own holder, it does not disable it.
|
|
114
|
+
const inheritedToken = process.env[LIFECYCLE_LEASE_TOKEN_ENV];
|
|
115
|
+
if (inheritedToken) {
|
|
116
|
+
const held = readLease(target);
|
|
117
|
+
if (held?.nonce === inheritedToken) {
|
|
118
|
+
// The holder releases it. Releasing here would drop the lock while the
|
|
119
|
+
// parent still believes it holds it.
|
|
120
|
+
return { release() { } };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
99
123
|
const lifecycleCommand = [
|
|
100
124
|
"repair",
|
|
101
125
|
"init",
|
|
@@ -124,6 +148,7 @@ export function acquireRepairLease(repo, operation = "repair") {
|
|
|
124
148
|
writeLease(target, record);
|
|
125
149
|
writeActivity(repo, record);
|
|
126
150
|
return {
|
|
151
|
+
token: record.nonce,
|
|
127
152
|
release() {
|
|
128
153
|
try {
|
|
129
154
|
const current = readLease(target);
|
|
@@ -55,7 +55,7 @@ async function objectBytes(store, key, maxBytes, signal) {
|
|
|
55
55
|
throw new SharedIndexError("integrity", "shared-index object length changed during download");
|
|
56
56
|
return bytes;
|
|
57
57
|
}
|
|
58
|
-
function commitRelation(repo, head, commit) {
|
|
58
|
+
export function commitRelation(repo, head, commit) {
|
|
59
59
|
if (commit === head)
|
|
60
60
|
return { relation: "exact", distance: 0 };
|
|
61
61
|
const ancestor = git(repo, ["merge-base", "--is-ancestor", commit, head]);
|