knodin 0.9.0 → 0.10.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/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 +29 -4
- 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/package.json +2 -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
|
@@ -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("\\", "/")))]
|
|
@@ -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]);
|
|
@@ -16,8 +16,9 @@ import { exportContext, grepPackedArtifact, readPackedArtifact } from "../contex
|
|
|
16
16
|
import { collectDiagnostics, persistDiagnosticsPreview } from "../diagnostics.js";
|
|
17
17
|
import { getDocSection, listDocTopics } from "../docs-sections.js";
|
|
18
18
|
import { diagnoseInstallation } from "../doctor.js";
|
|
19
|
-
import { createEngine, REPO_WIDE_QUERY_PATTERNS, } from "../engine/index.js";
|
|
19
|
+
import { createEngine, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../engine/index.js";
|
|
20
20
|
import { measurePerfPhaseSync } from "../engine/perf.js";
|
|
21
|
+
import { runSealedQuery } from "../engine/sealed-query.js";
|
|
21
22
|
import { resolveDbPath } from "../engine/state-paths.js";
|
|
22
23
|
import { runExecutionProfile } from "../execution-profile.js";
|
|
23
24
|
import { diagnoseFailure, } from "../failure-diagnosis.js";
|
|
@@ -41,6 +42,7 @@ import { applySystemPlan, planSystemImport, planSystemLink, planSystemRelate, pl
|
|
|
41
42
|
import { trustedUpdateStatus } from "../update-policy.js";
|
|
42
43
|
import { waitForFresh } from "../wait-for-fresh.js";
|
|
43
44
|
import { inspectWorktrees, reconcileWorktrees, removeManagedWorktree, } from "../worktree-lifecycle.js";
|
|
45
|
+
import { indexOrSeed } from "../worktree-seed.js";
|
|
44
46
|
let initializedEngine = null;
|
|
45
47
|
function gatewayEngine() {
|
|
46
48
|
initializedEngine ??= createEngine();
|
|
@@ -200,8 +202,9 @@ function buildDocumentedKnodinTools() {
|
|
|
200
202
|
"system",
|
|
201
203
|
"telemetry",
|
|
202
204
|
"diagnostics",
|
|
205
|
+
"sealed",
|
|
203
206
|
],
|
|
204
|
-
description: "Which knodin capability to run. context: call this FIRST when starting any investigation and unsure which operation to reach for — one ultra-compact orientation (repo stats + top subsystems/hubs/flows + a risk score if there's a diff + a heuristic next-operation suggestion); the suggestion is only a hint and never blocks calling any operation directly. explain: use when orienting on a symbol/file or before editing it — returns edit-ready source + call paths + blast radius. review: use before writing a PR description or approving a diff — risk-scored context (changed symbols, affected flows, test gaps). map: use before a cross-cutting refactor or to understand subsystem boundaries — communities + hub/bridge nodes + confidence-tagged edges. search: use when you don't know the exact symbol name — hybrid semantic + keyword lookup over code symbols. query: use for a structured question about a known symbol — callers_of, tests_for, shortest_path, dead_code, rename_preview, flows, and more (see `pattern`). pack: create deterministic Markdown/JSON/XML source context under hard budgets, or bounded-read/exact-regex-grep a saved artifact. compress: reduce already-produced diagnostic text under exact line/content-byte budgets, preserve exit metadata and detected signals, retain private local drill-down data by default, and never label insufficient-fidelity output complete. execute: run one immutable repository-defined profile only when independently enabled globally and locally; no executable or argv is accepted from the caller, unsupported containment fails closed, and output is compressed then diagnosed. prs: use to triage open GitHub PRs (via your local authenticated `gh`) — per-PR status, CI, and blast radius sorted ready-small-impact first; pass `prNumber` for one PR's impacted files + community names. wiki: write a static markdown documentation site for the repository's logical subsystems to `.knodin/wiki/`. Generates an index plus one page per mapped community. Reuses the `map` output. Idempotent: unchanged pages are untouched on disk unless `force` is true. docs: call this to retrieve curated, focused markdown usage guidance directly over MCP. remote: list read-only mirrors of repositories that are reachable but not checked out locally, so you can run explain/query/map/search against one by passing its `path` as `repoPath`. Each mirror is a snapshot pinned at a commit; the remote is not watched. Acquiring, refreshing, and removing mirrors is CLI-only (`knodin remote add|refresh|remove`) because it clones another repository's source onto this machine and consumes disk nothing reclaims automatically — the user's decision to make, not an agent's.",
|
|
207
|
+
description: "Which knodin capability to run. context: call this FIRST when starting any investigation and unsure which operation to reach for — one ultra-compact orientation (repo stats + top subsystems/hubs/flows + a risk score if there's a diff + a heuristic next-operation suggestion); the suggestion is only a hint and never blocks calling any operation directly. explain: use when orienting on a symbol/file or before editing it — returns edit-ready source + call paths + blast radius. review: use before writing a PR description or approving a diff — risk-scored context (changed symbols, affected flows, test gaps). map: use before a cross-cutting refactor or to understand subsystem boundaries — communities + hub/bridge nodes + confidence-tagged edges. search: use when you don't know the exact symbol name — hybrid semantic + keyword lookup over code symbols. query: use for a structured question about a known symbol — callers_of, tests_for, shortest_path, dead_code, rename_preview, flows, and more (see `pattern`). pack: create deterministic Markdown/JSON/XML source context under hard budgets, or bounded-read/exact-regex-grep a saved artifact. compress: reduce already-produced diagnostic text under exact line/content-byte budgets, preserve exit metadata and detected signals, retain private local drill-down data by default, and never label insufficient-fidelity output complete. execute: run one immutable repository-defined profile only when independently enabled globally and locally; no executable or argv is accepted from the caller, unsupported containment fails closed, and output is compressed then diagnosed. prs: use to triage open GitHub PRs (via your local authenticated `gh`) — per-PR status, CI, and blast radius sorted ready-small-impact first; pass `prNumber` for one PR's impacted files + community names. wiki: write a static markdown documentation site for the repository's logical subsystems to `.knodin/wiki/`. Generates an index plus one page per mapped community. Reuses the `map` output. Idempotent: unchanged pages are untouched on disk unless `force` is true. docs: call this to retrieve curated, focused markdown usage guidance directly over MCP. remote: list read-only mirrors of repositories that are reachable but not checked out locally, so you can run explain/query/map/search against one by passing its `path` as `repoPath`. Each mirror is a snapshot pinned at a commit; the remote is not watched. Acquiring, refreshing, and removing mirrors is CLI-only (`knodin remote add|refresh|remove`) because it clones another repository's source onto this machine and consumes disk nothing reclaims automatically — the user's decision to make, not an agent's. sealed: query a sealed artifact (`artifactPath`) with NO checkout — source is embedded in it. Returns attested identity, commit, ref, age, coverage, and a `degraded` list of what it cannot do; pass `symbol` to explain. Describes the attested commit and no later one, so staleness is `unknown`, never `fresh`. Creating one is CLI-only (`knodin seal`).",
|
|
205
208
|
},
|
|
206
209
|
profile: {
|
|
207
210
|
type: "string",
|
|
@@ -821,7 +824,7 @@ async function handleRepositoriesOperation(options) {
|
|
|
821
824
|
const memoryLimitBytes = configuredRepositoryInitMemoryLimitBytes(systemConfig);
|
|
822
825
|
const summary = await initializeRepositories(discoveryRoots, {
|
|
823
826
|
command,
|
|
824
|
-
index: (
|
|
827
|
+
index: indexOrSeed(engine, KNODIN_SCHEMA_VERSION),
|
|
825
828
|
status: (target) => engine.status(target),
|
|
826
829
|
depth,
|
|
827
830
|
worktrees: linkedWorktrees,
|
|
@@ -1054,6 +1057,7 @@ function compactExplainSource(result) {
|
|
|
1054
1057
|
identity: result.identity,
|
|
1055
1058
|
symbol: result.symbol,
|
|
1056
1059
|
source,
|
|
1060
|
+
sourceAvailability: result.sourceAvailability,
|
|
1057
1061
|
staleness: result.staleness,
|
|
1058
1062
|
};
|
|
1059
1063
|
}
|
|
@@ -1595,6 +1599,15 @@ async function dispatchKnodinTool(args) {
|
|
|
1595
1599
|
note: "Each mirror is a snapshot pinned at `snapshot`; the remote is not watched, so results describe that commit and no later one.",
|
|
1596
1600
|
}, "remote:list");
|
|
1597
1601
|
}
|
|
1602
|
+
case "sealed": {
|
|
1603
|
+
// Read-only, and safe over MCP for the same reason `remote:list` is:
|
|
1604
|
+
// it acquires nothing and writes nothing. It opens an artifact the
|
|
1605
|
+
// caller already has and answers from bytes embedded inside it.
|
|
1606
|
+
if (!artifactPath)
|
|
1607
|
+
throw new Error("knodin sealed requires `artifactPath`");
|
|
1608
|
+
const sealedResult = await runSealedQuery(artifactPath, symbol);
|
|
1609
|
+
return bounded(sealedResult, "sealed:query");
|
|
1610
|
+
}
|
|
1598
1611
|
case "repositories": {
|
|
1599
1612
|
if (!repositoryAction)
|
|
1600
1613
|
throw new Error("knodin repositories requires `repositoryAction`");
|
|
@@ -203,19 +203,28 @@ export async function executeManagerUpdate(options) {
|
|
|
203
203
|
lock.release();
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
|
-
|
|
206
|
+
/** How long the `--version` probe may take. Overridable so tests are not bound to wall clock. */
|
|
207
|
+
export const DEFAULT_VERSION_PROBE_TIMEOUT_MS = 10_000;
|
|
208
|
+
function installedRuntimeVersion(command, env, timeoutMs = DEFAULT_VERSION_PROBE_TIMEOUT_MS) {
|
|
207
209
|
const [executable, ...prefix] = command;
|
|
208
210
|
if (!executable)
|
|
209
|
-
return
|
|
211
|
+
return { kind: "unreadable", reason: "no runtime command" };
|
|
210
212
|
const result = spawnSync(executable, [...prefix, "--version"], {
|
|
211
213
|
encoding: "utf8",
|
|
212
214
|
env,
|
|
213
215
|
stdio: ["ignore", "pipe", "pipe"],
|
|
214
|
-
timeout:
|
|
216
|
+
timeout: timeoutMs,
|
|
215
217
|
});
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
218
|
+
if (result.error)
|
|
219
|
+
return { kind: "unreadable", reason: String(result.error) };
|
|
220
|
+
if (result.signal)
|
|
221
|
+
return { kind: "unreadable", reason: `killed by ${result.signal}` };
|
|
222
|
+
if (result.status !== 0)
|
|
223
|
+
return { kind: "unreadable", reason: `exited with status ${result.status}` };
|
|
224
|
+
const output = result.stdout?.trim() ?? "";
|
|
225
|
+
return /^\d+\.\d+\.\d+/.test(output)
|
|
226
|
+
? { kind: "version", version: output }
|
|
227
|
+
: { kind: "unrecognized", output };
|
|
219
228
|
}
|
|
220
229
|
/** Resume an interrupted installation from its fsynced journal before ordinary dispatch. */
|
|
221
230
|
export async function recoverInterruptedUpdate(options) {
|
|
@@ -248,16 +257,26 @@ export async function recoverInterruptedUpdate(options) {
|
|
|
248
257
|
}));
|
|
249
258
|
try {
|
|
250
259
|
if (journal.state === "manager-applying") {
|
|
251
|
-
const
|
|
260
|
+
const probe = installedRuntimeVersion(options.runtimeCommand, options.env, options.versionProbeTimeoutMs);
|
|
261
|
+
const active = probe.kind === "version" ? probe.version : null;
|
|
262
|
+
// Say which of the three actually happened. "active version unknown"
|
|
263
|
+
// implied a value was read; a probe that timed out or could not be
|
|
264
|
+
// executed never got that far, and an operator reading this journal
|
|
265
|
+
// needs to know whether the install is wrong or merely unreadable.
|
|
266
|
+
const describe = () => {
|
|
267
|
+
if (probe.kind === "unreadable")
|
|
268
|
+
return `installed version could not be read: ${probe.reason}`;
|
|
269
|
+
if (probe.kind === "unrecognized")
|
|
270
|
+
return `installed runtime reported an unrecognized version ${JSON.stringify(probe.output)}`;
|
|
271
|
+
return `manager application was interrupted with active version ${probe.version}`;
|
|
272
|
+
};
|
|
252
273
|
journal = transitionUpdateJournal(journal, active === journal.targetVersion
|
|
253
274
|
? "candidate-installed-unverified"
|
|
254
275
|
: active === journal.previousVersion
|
|
255
276
|
? "rollback-installed-unverified"
|
|
256
277
|
: "rollback-required", active && [journal.targetVersion, journal.previousVersion].includes(active)
|
|
257
278
|
? {}
|
|
258
|
-
: {
|
|
259
|
-
error: `manager application was interrupted with active version ${active ?? "unknown"}`,
|
|
260
|
-
}, options.stateHome);
|
|
279
|
+
: { error: describe() }, options.stateHome);
|
|
261
280
|
}
|
|
262
281
|
if (["candidate-installed-unverified", "candidate-verifying"].includes(journal.state)) {
|
|
263
282
|
journal = transitionUpdateJournal(journal, "candidate-verifying", {}, options.stateHome);
|