knodin 0.10.8 → 0.12.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 +41 -4
- package/dist/src/cli-model.js +1 -0
- package/dist/src/compact-structural.js +8 -3
- package/dist/src/credential-patterns.js +14 -1
- package/dist/src/engine/index.js +371 -74
- package/dist/src/engine/parse-pool.js +21 -4
- package/dist/src/engine/seal.js +122 -3
- package/dist/src/engine/sealed-open.js +125 -8
- package/dist/src/engine/text-matches.js +121 -79
- package/dist/src/graph-query-health.js +9 -3
- package/dist/src/init.js +109 -3
- package/dist/src/lifecycle-health.js +46 -3
- package/dist/src/mcp-worker-supervisor.js +10 -2
- package/dist/src/repair-progress.js +12 -0
- package/dist/src/structural-fast-path.js +64 -1
- package/dist/src/tools/knodin-tools.js +32 -9
- package/docs/releases/0.11.0.md +227 -0
- package/docs/releases/0.12.0.md +281 -0
- package/package.json +5 -2
package/dist/src/init.js
CHANGED
|
@@ -478,8 +478,15 @@ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
|
478
478
|
if [ -f "$LOCK_DIR/pid" ]; then
|
|
479
479
|
LOCK_PID="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)"
|
|
480
480
|
if [ -n "$LOCK_PID" ] && ! kill -0 "$LOCK_PID" 2>/dev/null; then
|
|
481
|
-
rm -rf "
|
|
482
|
-
|
|
481
|
+
# Claim the stale lock by RENAMING it: "rm -rf then mkdir" leaves a window
|
|
482
|
+
# in which two processes that both saw the same dead pid can each succeed.
|
|
483
|
+
# Only one rename can win, because the loser's source no longer exists.
|
|
484
|
+
if mv "$LOCK_DIR" "$LOCK_DIR.stale.$$" 2>/dev/null; then
|
|
485
|
+
rm -rf "$LOCK_DIR.stale.$$"
|
|
486
|
+
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
|
|
487
|
+
else
|
|
488
|
+
exit 0
|
|
489
|
+
fi
|
|
483
490
|
else
|
|
484
491
|
exit 0
|
|
485
492
|
fi
|
|
@@ -488,7 +495,25 @@ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
|
488
495
|
fi
|
|
489
496
|
fi
|
|
490
497
|
printf '%s\n' "$$" > "$LOCK_DIR/pid"
|
|
491
|
-
|
|
498
|
+
# Release only a lock this process still owns. The previous handler removed
|
|
499
|
+
# $LOCK_DIR unconditionally, so a script that had already lost the lock deleted
|
|
500
|
+
# its SUCCESSOR's — and init sends SIGTERM here on every drain that outruns its
|
|
501
|
+
# 30s spawnSync timeout, which made that the common case rather than the rare
|
|
502
|
+
# one (KNODIN-23).
|
|
503
|
+
knodin_release_lock() {
|
|
504
|
+
if [ "$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" = "$$" ]; then
|
|
505
|
+
rm -rf "$LOCK_DIR"
|
|
506
|
+
fi
|
|
507
|
+
rm -f "$FAILURE_TMP"
|
|
508
|
+
}
|
|
509
|
+
trap 'knodin_release_lock' EXIT
|
|
510
|
+
# Each signal exits explicitly. POSIX sh defers a trap until the foreground
|
|
511
|
+
# command finishes and then RESUMES where it left off, so a handler that only
|
|
512
|
+
# cleaned up dropped the lock and kept draining — two processors at once, and
|
|
513
|
+
# Ctrl-C failed to cancel a foreground run at all.
|
|
514
|
+
trap 'knodin_release_lock; trap - EXIT; exit 129' HUP
|
|
515
|
+
trap 'knodin_release_lock; trap - EXIT; exit 130' INT
|
|
516
|
+
trap 'knodin_release_lock; trap - EXIT; exit 143' TERM
|
|
492
517
|
LOG_PATH="$REPO_ROOT/.knodin/indexer.log"
|
|
493
518
|
if [ -f "$LOG_PATH" ]; then
|
|
494
519
|
LOG_BYTES="$(wc -c < "$LOG_PATH" 2>/dev/null | tr -d '[:space:]')"
|
|
@@ -1433,6 +1458,87 @@ export async function initializeRepository(repo, options) {
|
|
|
1433
1458
|
lifecycleLease.release();
|
|
1434
1459
|
}
|
|
1435
1460
|
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Rewrite lifecycle routing — the managed Git hooks and the background refresh
|
|
1463
|
+
* script — without reading or writing a single line of agent configuration.
|
|
1464
|
+
*
|
|
1465
|
+
* This exists because a team-integrated repository whose hooks had died had no
|
|
1466
|
+
* safe exit (KNODIN-31). `repair` fixes graph rows and leaves the dead indexer
|
|
1467
|
+
* dead, so the schema drifts again; `init` rewrites tracked `AGENTS.md` /
|
|
1468
|
+
* `GEMINI.md` / `.mcp.json`, which is churn every teammate diffs on a branch
|
|
1469
|
+
* that has nothing to do with it; and `configure --scope personal` strips the
|
|
1470
|
+
* shared block for everyone else on that branch. The one clean option,
|
|
1471
|
+
* `init --scope personal`, is precisely the one refused in that state.
|
|
1472
|
+
*
|
|
1473
|
+
* So fault and remedy are matched in scope: the fault is in the hooks'
|
|
1474
|
+
* contents, and this rewrites the hooks' contents. It deliberately does not
|
|
1475
|
+
* index, does not touch graph rows, and never enters the `configureIntegration`
|
|
1476
|
+
* block — a broken hook should not be able to change a scope preference. Run
|
|
1477
|
+
* plain `knodin repair` alongside it when graph content is also damaged.
|
|
1478
|
+
*/
|
|
1479
|
+
export async function repairLifecycleRouting(repoPath, options) {
|
|
1480
|
+
const resolvedRepo = path.resolve(repoPath);
|
|
1481
|
+
if (runGit(resolvedRepo, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") {
|
|
1482
|
+
throw new Error(`knodin repair --lifecycle: not a Git worktree: ${resolvedRepo}`);
|
|
1483
|
+
}
|
|
1484
|
+
const mirror = lookupMirror(resolvedRepo);
|
|
1485
|
+
if (mirror) {
|
|
1486
|
+
throw new Error(`knodin repair --lifecycle: ${resolvedRepo} is a read-only mirror of ${mirror.url}. Mirrors carry no lifecycle hooks by design, so there is no routing here to repair.`);
|
|
1487
|
+
}
|
|
1488
|
+
if (!path.isAbsolute(options.command[0])) {
|
|
1489
|
+
throw new Error("knodin repair --lifecycle: packaged runtime command must be an absolute path");
|
|
1490
|
+
}
|
|
1491
|
+
const lifecycleLease = acquireRepairLease(resolvedRepo, "init");
|
|
1492
|
+
try {
|
|
1493
|
+
const knodinHooksDir = path.join(resolvedRepo, ".knodin", "hooks");
|
|
1494
|
+
const hookInstallation = await prepareHookInstallation(resolvedRepo);
|
|
1495
|
+
const hooksDir = hookInstallation.hooksDir;
|
|
1496
|
+
await Promise.all([
|
|
1497
|
+
fs.promises.mkdir(knodinHooksDir, { recursive: true }),
|
|
1498
|
+
fs.promises.mkdir(hooksDir, { recursive: true }),
|
|
1499
|
+
]);
|
|
1500
|
+
const backgroundPath = path.join(knodinHooksDir, "background-index.sh");
|
|
1501
|
+
await fs.promises.writeFile(backgroundPath, backgroundScript(options.command), {
|
|
1502
|
+
encoding: "utf-8",
|
|
1503
|
+
mode: 0o755,
|
|
1504
|
+
});
|
|
1505
|
+
await fs.promises.chmod(backgroundPath, 0o755);
|
|
1506
|
+
const hookManagerIntegration = installHookManagerIntegration(resolvedRepo);
|
|
1507
|
+
for (const hookName of HOOK_NAMES) {
|
|
1508
|
+
let preservedOriginal;
|
|
1509
|
+
if (hookInstallation.trackedHooksDir) {
|
|
1510
|
+
const relativeHook = `${hookInstallation.trackedHooksDir}/${hookName}`;
|
|
1511
|
+
const sourceHook = path.join(resolvedRepo, relativeHook);
|
|
1512
|
+
try {
|
|
1513
|
+
const stat = await fs.promises.stat(sourceHook);
|
|
1514
|
+
preservedOriginal =
|
|
1515
|
+
stat.isFile() && (stat.mode & 0o111) !== 0 ? `repo:${relativeHook}` : null;
|
|
1516
|
+
}
|
|
1517
|
+
catch {
|
|
1518
|
+
preservedOriginal = null;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
await installManagedHook(hooksDir, hookName, preservedOriginal);
|
|
1522
|
+
}
|
|
1523
|
+
for (const entry of await fs.promises.readdir(hooksDir, { withFileTypes: true })) {
|
|
1524
|
+
if (entry.isFile())
|
|
1525
|
+
await preventLefthookAutoInstall(path.join(hooksDir, entry.name));
|
|
1526
|
+
}
|
|
1527
|
+
const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
|
|
1528
|
+
await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), { force: true });
|
|
1529
|
+
return {
|
|
1530
|
+
backgroundIndexer: ".knodin/hooks/background-index.sh",
|
|
1531
|
+
hooksDirectory: path.relative(resolvedRepo, hooksDir) || ".",
|
|
1532
|
+
gitHooks: [...HOOK_NAMES],
|
|
1533
|
+
hookManagerIntegration,
|
|
1534
|
+
lifecycleRefresh,
|
|
1535
|
+
configurationChanges: [],
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
finally {
|
|
1539
|
+
lifecycleLease.release();
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1436
1542
|
async function matchesManagedFile(filePath, expected, executable) {
|
|
1437
1543
|
try {
|
|
1438
1544
|
const [content, stat] = await Promise.all([
|
|
@@ -169,6 +169,41 @@ function recordedInterpreterIfMissing(backgroundScriptPath) {
|
|
|
169
169
|
// wrong and the next environment without that fallback breaks silently.
|
|
170
170
|
return recorded;
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The interpreter a pre-fallback generated script pins, when it pins one.
|
|
174
|
+
*
|
|
175
|
+
* Scripts written before the `KNODIN_NODE` fallback (EASFDC-8497) invoke an
|
|
176
|
+
* absolute interpreter path with nothing to fall back to. Homebrew deletes its
|
|
177
|
+
* Cellar directory on upgrade, so `.../Cellar/node/<version>/bin/node`
|
|
178
|
+
* evaporates and every background refresh afterwards dies with exit 127 — in
|
|
179
|
+
* the background, where the only trace is a log file nobody reads. The graph
|
|
180
|
+
* then drifts until someone happens to run `status`. Observed across three
|
|
181
|
+
* repositories on one machine from a single `brew upgrade` (KNODIN-32), so the
|
|
182
|
+
* blast radius is every checkout initialized before the fix, not one.
|
|
183
|
+
*
|
|
184
|
+
* `recordedInterpreterIfMissing` cannot see this: it keys off a `KNODIN_NODE=`
|
|
185
|
+
* line, and these scripts have none, so the most dangerous shape reads as
|
|
186
|
+
* healthy. Detection is the absence of that line in a script that is otherwise
|
|
187
|
+
* recognisably ours.
|
|
188
|
+
*
|
|
189
|
+
* Returns null when the script is current, unreadable, or shaped unfamiliarly.
|
|
190
|
+
* A script we cannot parse is not evidence of a pinned interpreter, and
|
|
191
|
+
* guessing would invent a failure rather than report one.
|
|
192
|
+
*/
|
|
193
|
+
function pinnedInterpreterWithoutFallback(backgroundScriptPath) {
|
|
194
|
+
let contents;
|
|
195
|
+
try {
|
|
196
|
+
contents = fs.readFileSync(backgroundScriptPath, "utf8");
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (/^KNODIN_NODE=/m.test(contents))
|
|
202
|
+
return null;
|
|
203
|
+
if (!contents.includes("hook-refresh"))
|
|
204
|
+
return null;
|
|
205
|
+
return /'(\/[^']*\/bin\/node)'/.exec(contents)?.[1] ?? null;
|
|
206
|
+
}
|
|
172
207
|
function readRefreshFailure(repo) {
|
|
173
208
|
const failurePath = path.join(repo, ".knodin", "hooks", HOOK_FAILURE_FILE);
|
|
174
209
|
if (!fs.existsSync(failurePath))
|
|
@@ -253,7 +288,15 @@ export function inspectLifecycleHealth(repoPath) {
|
|
|
253
288
|
issues.push("background indexer is missing or not executable");
|
|
254
289
|
const staleInterpreter = backgroundReady ? recordedInterpreterIfMissing(background) : null;
|
|
255
290
|
if (staleInterpreter)
|
|
256
|
-
issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin
|
|
291
|
+
issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin repair --lifecycle\` to rewrite the hook`);
|
|
292
|
+
// Reported even while that interpreter still exists. The failure is latent
|
|
293
|
+
// by construction — the script works right up until the next `brew upgrade`
|
|
294
|
+
// deletes the path out from under it, and then fails silently in the
|
|
295
|
+
// background. Waiting for the breakage would mean reporting it only after
|
|
296
|
+
// the graph had already drifted (KNODIN-32).
|
|
297
|
+
const pinnedInterpreter = backgroundReady && !staleInterpreter ? pinnedInterpreterWithoutFallback(background) : null;
|
|
298
|
+
if (pinnedInterpreter)
|
|
299
|
+
issues.push(`background indexer predates the interpreter fallback and pins ${pinnedInterpreter} with no alternative; a Node upgrade will break refresh silently — run \`knodin repair --lifecycle\` to rewrite the hook`);
|
|
257
300
|
const lastError = readRefreshFailure(repo);
|
|
258
301
|
if (lastError)
|
|
259
302
|
issues.push(`${lastError} See .knodin/indexer.log for details`);
|
|
@@ -299,7 +342,7 @@ export function attachLifecycleHealth(repo, graph) {
|
|
|
299
342
|
},
|
|
300
343
|
repairSteps: [
|
|
301
344
|
...graph.repairSteps,
|
|
302
|
-
"Run `knodin
|
|
345
|
+
"Run `knodin repair --lifecycle` to restore managed lifecycle hooks without touching agent configuration.",
|
|
303
346
|
"Run `knodin status` again to verify graph and refresh-path health.",
|
|
304
347
|
],
|
|
305
348
|
lifecycle,
|
|
@@ -312,7 +355,7 @@ export function attachRepairLifecycle(repo, result) {
|
|
|
312
355
|
...result,
|
|
313
356
|
lifecycle,
|
|
314
357
|
nextSteps: lifecycle.status === "degraded"
|
|
315
|
-
? ["Run `knodin
|
|
358
|
+
? ["Run `knodin repair --lifecycle` to restore lifecycle routing, then `knodin status`."]
|
|
316
359
|
: [],
|
|
317
360
|
};
|
|
318
361
|
}
|
|
@@ -214,9 +214,17 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
214
214
|
if (signal.aborted)
|
|
215
215
|
throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker dispatch");
|
|
216
216
|
if (!this.child) {
|
|
217
|
+
// Prune HERE as well as in `exited`. Once this gate throws, no child is
|
|
218
|
+
// spawned, so no exit event ever fires to prune the array — filtering
|
|
219
|
+
// only on exit turned the 60s rate window into a permanent per-repository
|
|
220
|
+
// latch that outlived whatever caused the crashes, and the cached
|
|
221
|
+
// RepositoryWorker is never evicted, so only a gateway restart cleared
|
|
222
|
+
// it (KNODIN-17).
|
|
223
|
+
const startedAt = Date.now();
|
|
224
|
+
this.restartTimes = this.restartTimes.filter((at) => startedAt - at < RESTART_WINDOW_MS);
|
|
217
225
|
if (this.restartTimes.length >= MAX_RESTARTS)
|
|
218
|
-
throw codedError("KNODIN_RESTART_LIMIT",
|
|
219
|
-
this.restartTimes.push(
|
|
226
|
+
throw codedError("KNODIN_RESTART_LIMIT", `graph worker restarted ${MAX_RESTARTS} times within ${RESTART_WINDOW_MS / 1000}s; it will be retried once that window elapses`);
|
|
227
|
+
this.restartTimes.push(startedAt);
|
|
220
228
|
}
|
|
221
229
|
const startup = this.start();
|
|
222
230
|
const remainingForStartup = Math.max(1, deadlineAt - Date.now());
|
|
@@ -23,6 +23,7 @@ export function parseRepairCliArgs(args) {
|
|
|
23
23
|
let json = false;
|
|
24
24
|
let jsonl = false;
|
|
25
25
|
let plan = false;
|
|
26
|
+
let lifecycle = false;
|
|
26
27
|
const seen = new Set();
|
|
27
28
|
const markSeen = (flag) => {
|
|
28
29
|
if (seen.has(flag))
|
|
@@ -42,6 +43,11 @@ export function parseRepairCliArgs(args) {
|
|
|
42
43
|
plan = true;
|
|
43
44
|
continue;
|
|
44
45
|
}
|
|
46
|
+
if (argument === "--lifecycle") {
|
|
47
|
+
markSeen(argument);
|
|
48
|
+
lifecycle = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
45
51
|
if (argument === "--json" || argument === "--jsonl") {
|
|
46
52
|
markSeen(argument);
|
|
47
53
|
if (argument === "--json")
|
|
@@ -86,12 +92,18 @@ export function parseRepairCliArgs(args) {
|
|
|
86
92
|
}
|
|
87
93
|
if (json && jsonl)
|
|
88
94
|
throw new Error("knodin repair: --json and --jsonl are mutually exclusive");
|
|
95
|
+
// `--plan` explains what a graph repair would do; a lifecycle repair does not
|
|
96
|
+
// touch the graph, so there is nothing for it to explain. Refusing beats
|
|
97
|
+
// printing a graph plan for a run that would not carry it out.
|
|
98
|
+
if (plan && lifecycle)
|
|
99
|
+
throw new Error("knodin repair: --plan and --lifecycle are mutually exclusive");
|
|
89
100
|
return {
|
|
90
101
|
progress,
|
|
91
102
|
progressExplicit,
|
|
92
103
|
progressIntervalMs,
|
|
93
104
|
output: jsonl ? "jsonl" : json ? "json" : "human",
|
|
94
105
|
plan,
|
|
106
|
+
lifecycle,
|
|
95
107
|
};
|
|
96
108
|
}
|
|
97
109
|
export function createRepairPlan(health) {
|
|
@@ -169,6 +169,55 @@ function directFile(repo, filePath) {
|
|
|
169
169
|
symbols: directSymbols(filePath, content),
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
|
+
function directoryOf(filePath) {
|
|
173
|
+
const slash = filePath.lastIndexOf("/");
|
|
174
|
+
return slash === -1 ? "." : filePath.slice(0, slash);
|
|
175
|
+
}
|
|
176
|
+
function extensionOf(name) {
|
|
177
|
+
const dot = name.lastIndexOf(".");
|
|
178
|
+
return dot > 0 ? name.slice(dot) : null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* True when every directory the aggregate covers holds only files the snapshot
|
|
182
|
+
* already knows about.
|
|
183
|
+
*
|
|
184
|
+
* The indexable extensions are taken from the snapshot's own file list rather
|
|
185
|
+
* than hardcoded, so this stays calibrated to whatever the walker actually
|
|
186
|
+
* indexed. Membership is tested against the WHOLE snapshot, not the filtered
|
|
187
|
+
* subset, so a sibling that is indexed but outside the caller's target does not
|
|
188
|
+
* read as an omission.
|
|
189
|
+
*/
|
|
190
|
+
function snapshotCoversDirectories(repo, snapshot, covered) {
|
|
191
|
+
const indexed = new Set((snapshot?.files ?? []).map((file) => file.path));
|
|
192
|
+
if (indexed.size === 0)
|
|
193
|
+
return false;
|
|
194
|
+
const extensions = new Set();
|
|
195
|
+
for (const known of indexed) {
|
|
196
|
+
const extension = extensionOf(known.slice(known.lastIndexOf("/") + 1));
|
|
197
|
+
if (extension)
|
|
198
|
+
extensions.add(extension);
|
|
199
|
+
}
|
|
200
|
+
for (const directory of new Set(covered.map((file) => directoryOf(file.path)))) {
|
|
201
|
+
let entries;
|
|
202
|
+
try {
|
|
203
|
+
entries = fs.readdirSync(path.resolve(repo, directory), { withFileTypes: true });
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
for (const entry of entries) {
|
|
209
|
+
if (!entry.isFile())
|
|
210
|
+
continue;
|
|
211
|
+
const extension = extensionOf(entry.name);
|
|
212
|
+
if (!extension || !extensions.has(extension))
|
|
213
|
+
continue;
|
|
214
|
+
const relative = directory === "." ? entry.name : `${directory}/${entry.name}`;
|
|
215
|
+
if (!indexed.has(relative))
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
172
221
|
export function runStructuralFastPath(repo, pattern, target, limit = 100) {
|
|
173
222
|
const snapshot = readStructuralSnapshot(repo);
|
|
174
223
|
if (!snapshot && pattern !== "file_summary")
|
|
@@ -195,7 +244,7 @@ export function runStructuralFastPath(repo, pattern, target, limit = 100) {
|
|
|
195
244
|
let fingerprint;
|
|
196
245
|
let fresh = false;
|
|
197
246
|
if (pattern === "project_overview") {
|
|
198
|
-
|
|
247
|
+
const unchanged = matching.every((file) => {
|
|
199
248
|
try {
|
|
200
249
|
const stat = fs.statSync(path.resolve(repo, file.path));
|
|
201
250
|
return stat.size === file.sizeBytes && stat.mtimeMs === file.mtimeMs;
|
|
@@ -204,6 +253,20 @@ export function runStructuralFastPath(repo, pattern, target, limit = 100) {
|
|
|
204
253
|
return false;
|
|
205
254
|
}
|
|
206
255
|
});
|
|
256
|
+
// A size+mtime sweep can only ever prove that the files the snapshot
|
|
257
|
+
// ALREADY knows about are unchanged. A file added since the snapshot is not
|
|
258
|
+
// in that set, so it was never checked, never counted, and never mentioned
|
|
259
|
+
// — while the overview still reported itself "fresh" (KNODIN-25). So also
|
|
260
|
+
// read back the directories being aggregated and look for an indexable
|
|
261
|
+
// sibling the snapshot has never seen.
|
|
262
|
+
//
|
|
263
|
+
// Known bound: this sees additions to directories the snapshot already
|
|
264
|
+
// covers, which is where source files are overwhelmingly added. A brand new
|
|
265
|
+
// top-level directory is still missed, because recognising one would mean
|
|
266
|
+
// re-deriving the walker's prune rules here and re-walking the tree, which
|
|
267
|
+
// is the cost this fast path exists to avoid. `upgrade` already points at
|
|
268
|
+
// the full `architecture_overview` query for callers that need certainty.
|
|
269
|
+
fresh = unchanged && snapshotCoversDirectories(repo, snapshot, matching);
|
|
207
270
|
const directories = new Map();
|
|
208
271
|
for (const file of matching) {
|
|
209
272
|
const directory = file.path.includes("/") ? file.path.split("/", 1)[0] : ".";
|
|
@@ -70,10 +70,8 @@ export async function closeKnodinToolEngine() {
|
|
|
70
70
|
if (initializedEngine)
|
|
71
71
|
await initializedEngine.close();
|
|
72
72
|
initializedEngine = null;
|
|
73
|
-
compactReadyRepos.clear();
|
|
74
73
|
}
|
|
75
74
|
const localTelemetry = [];
|
|
76
|
-
const compactReadyRepos = new Set();
|
|
77
75
|
let cachedSchemaTokens;
|
|
78
76
|
function gatewaySchemaTokens() {
|
|
79
77
|
cachedSchemaTokens ??= countOutputTokens(JSON.stringify(buildDocumentedKnodinTools()[0]?.inputSchema ?? {}));
|
|
@@ -82,15 +80,24 @@ function gatewaySchemaTokens() {
|
|
|
82
80
|
function inspectGatewayGraphHealth(repo) {
|
|
83
81
|
return inspectGraphQueryHealth(repo, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached" })));
|
|
84
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Probe on EVERY compact call, exactly as the non-compact path does.
|
|
85
|
+
*
|
|
86
|
+
* This used to latch a repository as ready after one successful probe and never
|
|
87
|
+
* look again for the life of the process. Nothing invalidated that latch —
|
|
88
|
+
* `closeKnodinToolEngine` clears it but has no production caller — so once
|
|
89
|
+
* `.knodin/` was deleted or re-inited from another terminal, a long-lived MCP
|
|
90
|
+
* server kept answering compact queries against a database the open path had
|
|
91
|
+
* silently recreated as an empty schema (`CREATE TABLE IF NOT EXISTS`). The
|
|
92
|
+
* result was "no matches": indistinguishable from a true negative, and the exact
|
|
93
|
+
* confident-but-wrong answer this product exists to avoid (KNODIN-18).
|
|
94
|
+
*
|
|
95
|
+
* The probe is cheap — `engine.status(..., { audit: "cached" })` — so the latch
|
|
96
|
+
* was buying very little in exchange for that failure mode.
|
|
97
|
+
*/
|
|
85
98
|
async function compactGraphUnavailable(repo) {
|
|
86
|
-
const key = nodePath.resolve(repo);
|
|
87
|
-
if (compactReadyRepos.has(key))
|
|
88
|
-
return null;
|
|
89
99
|
const health = await inspectGatewayGraphHealth(repo);
|
|
90
|
-
|
|
91
|
-
return health;
|
|
92
|
-
compactReadyRepos.add(key);
|
|
93
|
-
return null;
|
|
100
|
+
return health.available ? null : health;
|
|
94
101
|
}
|
|
95
102
|
/** Runs `fn`, converting any thrown error into a structured `{ error }` result
|
|
96
103
|
* instead of letting it escape into the stdio transport. Extracted from the
|
|
@@ -1045,6 +1052,22 @@ function validateKnodinArgs(args) {
|
|
|
1045
1052
|
if (args.client !== undefined &&
|
|
1046
1053
|
!["claude", "codex", "gemini", "copilot", "antigravity"].includes(args.client))
|
|
1047
1054
|
throw new Error("knodin: invalid client");
|
|
1055
|
+
// `auditRange`/`auditBase`/`auditHead` select a range of pull requests for
|
|
1056
|
+
// `prs`, and nothing else consumes them. `review` used to accept them and
|
|
1057
|
+
// silently review HEAD~1..HEAD instead, returning `base: "HEAD~1"` under a
|
|
1058
|
+
// full `staleness: fresh` envelope — so it did not merely fail to answer the
|
|
1059
|
+
// question asked, it confidently answered a different one, and a caller who
|
|
1060
|
+
// did not inspect `base` would read the result as authoritative for their
|
|
1061
|
+
// range (KNODIN-27). An unused-parameter error is always better than that.
|
|
1062
|
+
//
|
|
1063
|
+
// The review equivalent is `diffScope: "compare"` with `from`/`toRevision`,
|
|
1064
|
+
// which is what the CLI's `--scope compare --from X --to Y` maps to.
|
|
1065
|
+
const auditOnly = ["auditRange", "auditBase", "auditHead"].filter((name) => args[name] !== undefined);
|
|
1066
|
+
if (auditOnly.length > 0 && args.operation !== "prs")
|
|
1067
|
+
throw new Error(`knodin ${args.operation}: ${auditOnly.join(", ")} ${auditOnly.length === 1 ? "is" : "are"} accepted only by \`prs\`` +
|
|
1068
|
+
(args.operation === "review"
|
|
1069
|
+
? '; to review a revision range pass diffScope: "compare" with `from` and `toRevision`'
|
|
1070
|
+
: ""));
|
|
1048
1071
|
}
|
|
1049
1072
|
function compactExplainSource(result) {
|
|
1050
1073
|
const source = result.source
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# knodin 0.11.0
|
|
2
|
+
|
|
3
|
+
Nine defects from one adversarial review of `main`, plus the two things that
|
|
4
|
+
review's own fixes exposed. The theme running through most of them is the
|
|
5
|
+
failure this tool exists to prevent: an answer that is wrong and looks fine.
|
|
6
|
+
|
|
7
|
+
## A compact answer could report "no matches" from a graph that was gone
|
|
8
|
+
|
|
9
|
+
`compactGraphUnavailable` cached a repository as ready after one successful
|
|
10
|
+
probe and never looked again for the life of the process. Nothing invalidated
|
|
11
|
+
that latch — `closeKnodinToolEngine` clears it but has no production caller — so
|
|
12
|
+
in a long-lived MCP server, deleting or re-initialising `.knodin` from another
|
|
13
|
+
terminal left compact operations answering from a database the open path had
|
|
14
|
+
silently recreated as an empty schema. `CREATE TABLE IF NOT EXISTS` does not
|
|
15
|
+
error; it just produces a graph with nothing in it.
|
|
16
|
+
|
|
17
|
+
The result was "no matches", which is indistinguishable from a true negative.
|
|
18
|
+
The non-compact path had it right all along: it re-probes before **and** after
|
|
19
|
+
every query. Compact now does the same. The probe is
|
|
20
|
+
`engine.status(..., { audit: "cached" })`, so the latch was buying very little
|
|
21
|
+
in exchange for that failure mode.
|
|
22
|
+
|
|
23
|
+
Two other things pointed the same direction and are fixed with it. The compact
|
|
24
|
+
renderer defaulted a **missing** staleness to `fresh`, the opposite of the
|
|
25
|
+
engine's own `stalenessFor()` default of `unknown` — and because search stamps
|
|
26
|
+
staleness per row while `SearchPage` carries no page-level field, a zero-row
|
|
27
|
+
page had nothing to read it from. An empty compact search on a stale or
|
|
28
|
+
unprobed graph therefore rendered as `fresh 0/0`: an authoritative-looking
|
|
29
|
+
negative assembled entirely from absent evidence. Missing evidence is now
|
|
30
|
+
`unknown`.
|
|
31
|
+
|
|
32
|
+
And the CLI's `project_overview` fast path reported `fresh` on the strength of a
|
|
33
|
+
size-and-mtime sweep over the files the snapshot **already knew about**. A file
|
|
34
|
+
added since the snapshot is not in that set, so it was never checked, never
|
|
35
|
+
counted, and never mentioned — while the overview still called itself fresh. It
|
|
36
|
+
now also reads back the directories it aggregates and looks for an indexable
|
|
37
|
+
sibling the snapshot has never seen, with the indexable extensions derived from
|
|
38
|
+
the snapshot's own file list rather than hardcoded.
|
|
39
|
+
|
|
40
|
+
That last check has a bound worth stating: it sees additions to directories the
|
|
41
|
+
snapshot already covers, which is where source files are overwhelmingly added,
|
|
42
|
+
but a brand-new top-level directory is still missed. Recognising one would mean
|
|
43
|
+
re-deriving the walker's prune rules and re-walking the tree, which is the cost
|
|
44
|
+
this fast path exists to avoid. The result's `upgrade` pointer already routes a
|
|
45
|
+
caller who needs certainty to the full `architecture_overview` query.
|
|
46
|
+
|
|
47
|
+
## The MCP worker restart limit was a permanent latch
|
|
48
|
+
|
|
49
|
+
`MAX_RESTARTS` is meant to be a rate — three crashes inside a sixty-second
|
|
50
|
+
window. It was not. `restartTimes` was pruned only in the child-exit handler,
|
|
51
|
+
and once the gate threw `KNODIN_RESTART_LIMIT` no child was ever spawned, so no
|
|
52
|
+
exit event could ever fire to prune it. The window never elapsed.
|
|
53
|
+
|
|
54
|
+
Three transient crashes — an OOM under load, a full disk, a corrupt database
|
|
55
|
+
later repaired — therefore killed that repository for the life of the gateway
|
|
56
|
+
process. The `RepositoryWorker` is cached per repository and never evicted, so
|
|
57
|
+
nothing short of restarting the whole server cleared it, and the error message
|
|
58
|
+
never said so. The gate now prunes on the way in, and names the window.
|
|
59
|
+
|
|
60
|
+
## One crash killed a parse-pool lane for the whole run
|
|
61
|
+
|
|
62
|
+
`PoolWorkerHandle` has no listener removal, so a replaced worker's listeners
|
|
63
|
+
stay live — and they closed over the *slot*, whose `.handle` is by then the
|
|
64
|
+
replacement. A crashed worker's inevitable late `exit` re-entered
|
|
65
|
+
`onSlotFailure` and terminated its own replacement, cancelling the one
|
|
66
|
+
documented retry and leaving the lane dead. On a single-worker pool the whole
|
|
67
|
+
run degraded to sequential inline parsing; if the replacement's response landed
|
|
68
|
+
first, the late exit instead killed an unrelated in-flight file.
|
|
69
|
+
|
|
70
|
+
Listeners are now bound to the handle they were registered on, mirroring the
|
|
71
|
+
`pending.child !== child` guard the MCP supervisor already carried.
|
|
72
|
+
|
|
73
|
+
Worth recording how this survived review: `FakeWorker` in `parse-pool.spec.ts`
|
|
74
|
+
never emitted `exit` — not after an error, not on `terminate()`. The pool never
|
|
75
|
+
saw the second event, so the bug had no way to appear. With realistic process
|
|
76
|
+
semantics restored, removing the new guard fails the new test **and** the
|
|
77
|
+
pre-existing "retries a crashed task once" test. That retry path was dead code
|
|
78
|
+
against real workers, and had been asserted as working.
|
|
79
|
+
|
|
80
|
+
## Secrets spelled the way secrets are actually spelled
|
|
81
|
+
|
|
82
|
+
The shared redaction pattern began with `\b`, which cannot match between `_` and
|
|
83
|
+
a letter. So it caught a bare `password=` and let through
|
|
84
|
+
`AWS_SECRET_ACCESS_KEY=`, `DB_PASSWORD=`, `NPM_TOKEN=`, and every `*_SECRET=` —
|
|
85
|
+
the dominant real-world spelling, and the one that appears in a `.env` line
|
|
86
|
+
quoted into an error message or a CI log dump. Both disclosure paths, the
|
|
87
|
+
diagnostics bundle and the compressed output artifact, drew on that one pattern,
|
|
88
|
+
so both leaked.
|
|
89
|
+
|
|
90
|
+
The keyword now takes a `[\w-]*` prefix and a `[_-]`-led suffix, so the whole
|
|
91
|
+
identifier matches and the name is preserved while the value is not. The suffix
|
|
92
|
+
is `[_-]`-led rather than a bare `[\w-]*` deliberately: knodin echoes source
|
|
93
|
+
into both artifacts, and an unanchored tail redacted the value of a plain
|
|
94
|
+
`const tokenCount = 5`.
|
|
95
|
+
|
|
96
|
+
Not added, deliberately: a standalone value-shape pattern for AWS secret keys.
|
|
97
|
+
Those are `[A-Za-z0-9/+]{40}`, which also matches every 40-character git SHA and
|
|
98
|
+
any similar-length base64 blob. A context-free pattern would redact commit
|
|
99
|
+
hashes throughout diagnostics, and every realistic carrier of that value — an
|
|
100
|
+
environment variable, a credentials file — is name-prefixed and now covered.
|
|
101
|
+
|
|
102
|
+
## A sealed artifact could serve source for a checkout it does not describe
|
|
103
|
+
|
|
104
|
+
Sealed artifacts are designed to be shared across machines, which makes an
|
|
105
|
+
artifact untrusted input. Its attestation was read with a bare
|
|
106
|
+
`JSON.parse(...) as SealAttestation` — an unchecked cast — and the
|
|
107
|
+
self-declared `repository.path` was registered into a process-global map that
|
|
108
|
+
every engine source read consults **before** the filesystem. A crafted artifact
|
|
109
|
+
naming a victim's real checkout path could therefore serve its own bytes to a
|
|
110
|
+
concurrent live query on that repository.
|
|
111
|
+
|
|
112
|
+
The attestation is now validated as a whole shape, and a database that
|
|
113
|
+
misrepresents itself raises a typed error rather than being quietly downgraded
|
|
114
|
+
to "not a sealed artifact". The attested path is refused on **content
|
|
115
|
+
disagreement** rather than on mere existence: sealing and then querying on the
|
|
116
|
+
same checkout is a real flow that an existence check would break, so instead
|
|
117
|
+
every embedded file that also exists under the attested directory is compared
|
|
118
|
+
against the hash recorded at seal time. Files absent there are not disagreement
|
|
119
|
+
— a removed checkout is exactly what an artifact is for.
|
|
120
|
+
|
|
121
|
+
Identity and `sealedCommit` are deliberately **not** used for this. Both are
|
|
122
|
+
attacker-supplied strings, and an attacker targeting a specific victim already
|
|
123
|
+
knows their `owner/name`. Content hashes are the only fingerprint in the
|
|
124
|
+
attestation an attacker cannot satisfy while still doing harm: to poison a live
|
|
125
|
+
checkout the embedded bytes must differ from the real files, which is precisely
|
|
126
|
+
what trips the gate.
|
|
127
|
+
|
|
128
|
+
Registrations are also refcounted now. Two artifacts attesting the same path
|
|
129
|
+
used to have the first `close()` unregister the other's source, leaving it
|
|
130
|
+
reading `""`.
|
|
131
|
+
|
|
132
|
+
## A repo-wide text search held the whole working tree in memory
|
|
133
|
+
|
|
134
|
+
`searchRepoText` read every readable file's full content into one array before
|
|
135
|
+
searching any of it, with no size cap, and the binary NUL-sniff happened *after*
|
|
136
|
+
the read rather than before. The walk deliberately skips the source-extension
|
|
137
|
+
filter, so lockfiles, NDJSON dumps, fixtures and generated JSON all came in
|
|
138
|
+
whole. On a tree carrying gigabytes of non-pruned text, one call drove RSS to
|
|
139
|
+
the total text size. The response budget could not help: it applies to a result
|
|
140
|
+
object that by then already exists.
|
|
141
|
+
|
|
142
|
+
Files are now stat-checked and skipped above 4 MiB — reported through the same
|
|
143
|
+
`uncovered` list, never dropped — and searched one at a time, so at most one
|
|
144
|
+
file's content is live. Matches are capped at 5,000 with a new `truncated` flag,
|
|
145
|
+
documented as meaning the remaining files were **not** searched, so an absent
|
|
146
|
+
file proves nothing.
|
|
147
|
+
|
|
148
|
+
## The background refresh script deleted locks it did not own
|
|
149
|
+
|
|
150
|
+
The generated `background-index.sh` trapped `HUP`/`INT`/`TERM` with a handler
|
|
151
|
+
that removed the lock directory and did not exit. POSIX `sh` defers a trap until
|
|
152
|
+
the foreground command finishes and then resumes where it left off — so when
|
|
153
|
+
`init` sent `SIGTERM` at its thirty-second drain timeout, the script freed the
|
|
154
|
+
lock and **kept draining**. The next commit's instance acquired the now-free
|
|
155
|
+
lock, and the first script's eventual `EXIT` trap deleted its successor's lock
|
|
156
|
+
unconditionally, because that `rm -rf` had no ownership check. Stale-lock
|
|
157
|
+
takeover was also `rm -rf` followed by `mkdir`, which two processes observing
|
|
158
|
+
the same dead pid could both complete.
|
|
159
|
+
|
|
160
|
+
Release is now guarded on the recorded pid, each signal exits explicitly, and
|
|
161
|
+
takeover claims the stale lock by renaming it, which only one racer can win. The
|
|
162
|
+
inner repair lease kept this away from database corruption; what it produced was
|
|
163
|
+
spurious failure markers, retained events, and `Ctrl-C` failing to cancel a
|
|
164
|
+
foreground run.
|
|
165
|
+
|
|
166
|
+
## Each commit is now keyed to its own Jira
|
|
167
|
+
|
|
168
|
+
`scripts/jira-work-guard.sh` required every non-merge commit to carry the branch
|
|
169
|
+
name's **first** Jira key, which made a branch equivalent to exactly one issue.
|
|
170
|
+
Work spanning several tickets then had two options and both lose something: land
|
|
171
|
+
it as one commit labelled with a ticket covering a fraction of it, or split it
|
|
172
|
+
into one branch per ticket and multiply CI runs for a single coherent change.
|
|
173
|
+
This release hit exactly that — nine fixes from one review.
|
|
174
|
+
|
|
175
|
+
A branch is a container for work. It still has to be Jira-backed, but the
|
|
176
|
+
per-issue claim now belongs to each commit, which is the more durable record
|
|
177
|
+
anyway: branch names are deleted after merge and commit subjects are not.
|
|
178
|
+
`pre-push` also names the offending commit's short SHA now, because a push
|
|
179
|
+
carries many commits and "commit message has no JIRA key" left the author
|
|
180
|
+
bisecting their own branch.
|
|
181
|
+
|
|
182
|
+
The trade-off is real and worth naming: this drops the check that caught a
|
|
183
|
+
commit keyed to an unrelated project — a typo, or a message copied from another
|
|
184
|
+
branch. Restoring it would mean validating keys against Jira, which this guard
|
|
185
|
+
deliberately does not do. It is a local hook with no network.
|
|
186
|
+
|
|
187
|
+
## Timing budgets in the test suite were measuring the scheduler
|
|
188
|
+
|
|
189
|
+
Found because this release's own two new spec files made the suite fail, twice,
|
|
190
|
+
on two different tests that had nothing to do with the changes.
|
|
191
|
+
|
|
192
|
+
Eleven specs independently hard-coded a fifteen-second budget for a spawned
|
|
193
|
+
child process. Shard 12 also holds `behavior-contract.spec.ts`, which runs
|
|
194
|
+
CPU-bound for about 380 seconds — 85% of that shard's wall time. A child spawned
|
|
195
|
+
beside it is starved, not slow: a CLI spawn costs about 0.4s idle and a progress
|
|
196
|
+
worker about 1.7s, so fifteen seconds looks like a 40x margin right up until
|
|
197
|
+
contention eats it whole.
|
|
198
|
+
|
|
199
|
+
Which spec pays is decided by where vitest queues its file, and that moves
|
|
200
|
+
whenever any spec file is added anywhere in the repository. Measured on an
|
|
201
|
+
uninstrumented `npm test`, same host, identical shard membership: the progress
|
|
202
|
+
worker started in 1.7s queued at t=28s, and blew the whole fifteen seconds
|
|
203
|
+
queued at t=408s. The shard's total wall time was unchanged (454s against 448s
|
|
204
|
+
before the change) and `behavior-contract` was in fact 8s faster, so this was
|
|
205
|
+
never added load — separately confirmed by measuring CLI spawn cost directly at
|
|
206
|
+
367–468ms against 374–571ms before.
|
|
207
|
+
|
|
208
|
+
`SUBPROCESS_BUDGET_MS` now lives once, in the support helper those specs already
|
|
209
|
+
import, carrying that measurement. It is unconditional: two specs had already
|
|
210
|
+
been widened only under `KNODIN_COVERAGE_SHARD`, on the theory that
|
|
211
|
+
instrumentation causes the contention. It does not — the sixteen concurrent
|
|
212
|
+
shards do, in uninstrumented runs too — and gating on that is why this kept
|
|
213
|
+
being rediscovered one file at a time. The budget is an upper bound, not a
|
|
214
|
+
sleep, so it costs nothing in the passing case and no assertion is weakened.
|
|
215
|
+
|
|
216
|
+
One thing this does not fix, recorded so it is not rediscovered: a single spec
|
|
217
|
+
at ~380 seconds sets the critical path for the entire gate and is what starves
|
|
218
|
+
everything scheduled beside it. Widening budgets treats the symptom.
|
|
219
|
+
|
|
220
|
+
## Verification
|
|
221
|
+
|
|
222
|
+
`npm test` (sixteen shards, 2,088 tests), `npm run lint`, `npm run typecheck`,
|
|
223
|
+
`npm run check:hygiene`, and `npm run test:pack-install` pass. Every fix above
|
|
224
|
+
is pinned by a test, including two new specs —
|
|
225
|
+
`background-refresh-lock.spec.ts` and `compact-graph-availability.spec.ts` — and
|
|
226
|
+
new cases in the credential-parity, supervisor, parse-pool, structural-fast-path,
|
|
227
|
+
sealed-open, text-matches and jira-work-guard specs.
|