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
|
@@ -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);
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import childProcess from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Database } from "./engine/sqlite.js";
|
|
5
|
+
import { resolveDbPath } from "./engine/state-paths.js";
|
|
6
|
+
import { deriveSharedOverlay } from "./shared-index/overlay.js";
|
|
7
|
+
import { commitRelation } from "./shared-index/selection.js";
|
|
8
|
+
import { KNODIN_VERSION } from "./version.js";
|
|
9
|
+
import { inspectWorktrees } from "./worktree-lifecycle.js";
|
|
10
|
+
const OPT_OUT_ENV = "KNODIN_DISABLE_WORKTREE_SEED";
|
|
11
|
+
function readHead(repoPath) {
|
|
12
|
+
try {
|
|
13
|
+
return childProcess
|
|
14
|
+
.execFileSync("git", ["rev-parse", "HEAD"], {
|
|
15
|
+
cwd: repoPath,
|
|
16
|
+
encoding: "utf8",
|
|
17
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
18
|
+
})
|
|
19
|
+
.trim();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Opens `dbPath` read-only and confirms schema + build compatibility with this process. */
|
|
26
|
+
function siblingIsCompatible(dbPath, schemaVersion) {
|
|
27
|
+
let db = null;
|
|
28
|
+
try {
|
|
29
|
+
db = new Database(dbPath, { readonly: true });
|
|
30
|
+
const version = db.query("PRAGMA user_version").get()?.user_version ?? 0;
|
|
31
|
+
if (version !== schemaVersion)
|
|
32
|
+
return null;
|
|
33
|
+
const versionRow = db
|
|
34
|
+
.query("SELECT value FROM meta WHERE key = ?")
|
|
35
|
+
.get("knodinVersion");
|
|
36
|
+
if (!versionRow || versionRow.value !== KNODIN_VERSION)
|
|
37
|
+
return null;
|
|
38
|
+
const headRow = db
|
|
39
|
+
.query("SELECT value FROM meta WHERE key = ?")
|
|
40
|
+
.get("lastIndexedHead");
|
|
41
|
+
return headRow?.value || null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
db?.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Finds the cheapest-to-reconcile already-indexed sibling worktree of `repoPath`,
|
|
52
|
+
* or `null` when no eligible sibling exists (single-worktree repo, no healthy
|
|
53
|
+
* siblings, or none pass the schema/build compatibility gate).
|
|
54
|
+
*/
|
|
55
|
+
export async function selectSeedSibling(repoPath, schemaVersion, status) {
|
|
56
|
+
const resolved = path.resolve(repoPath);
|
|
57
|
+
const head = readHead(resolved);
|
|
58
|
+
if (!head)
|
|
59
|
+
return null;
|
|
60
|
+
let worktrees;
|
|
61
|
+
try {
|
|
62
|
+
worktrees = (await inspectWorktrees(resolved, status)).worktrees;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (worktrees.length < 2)
|
|
68
|
+
return null;
|
|
69
|
+
const eligible = worktrees.filter((worktree) => path.resolve(worktree.path) !== resolved &&
|
|
70
|
+
worktree.graphInitialized &&
|
|
71
|
+
worktree.freshness !== "removed");
|
|
72
|
+
const ranked = eligible
|
|
73
|
+
.map((worktree) => {
|
|
74
|
+
const dbPath = resolveDbPath(worktree.path);
|
|
75
|
+
const indexedHead = siblingIsCompatible(dbPath, schemaVersion);
|
|
76
|
+
if (!indexedHead)
|
|
77
|
+
return null;
|
|
78
|
+
const relation = commitRelation(resolved, head, indexedHead);
|
|
79
|
+
const distance = relation ? relation.distance : Number.MAX_SAFE_INTEGER;
|
|
80
|
+
return { worktree, dbPath, indexedHead, distance };
|
|
81
|
+
})
|
|
82
|
+
.filter((entry) => entry !== null)
|
|
83
|
+
.sort((left, right) => left.distance - right.distance);
|
|
84
|
+
const best = ranked[0];
|
|
85
|
+
if (!best)
|
|
86
|
+
return null;
|
|
87
|
+
return {
|
|
88
|
+
siblingPath: best.worktree.path,
|
|
89
|
+
siblingDbPath: best.dbPath,
|
|
90
|
+
siblingIndexedHead: best.indexedHead,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Seeds a brand-new worktree's graph database from an already-indexed sibling's
|
|
95
|
+
* clean baseline (OS-level copy-on-write clone where supported) and reconciles it
|
|
96
|
+
* to this worktree's actual HEAD/working tree, instead of building from scratch.
|
|
97
|
+
*/
|
|
98
|
+
export async function seedWorktreeIndex(repoPath, engine, schemaVersion) {
|
|
99
|
+
const resolved = path.resolve(repoPath);
|
|
100
|
+
const sibling = await selectSeedSibling(resolved, schemaVersion, (p) => engine.status(p));
|
|
101
|
+
if (!sibling)
|
|
102
|
+
return { seeded: false, reason: "no eligible sibling worktree" };
|
|
103
|
+
let candidate;
|
|
104
|
+
try {
|
|
105
|
+
candidate = await engine.createCandidate(resolved, {
|
|
106
|
+
sourceDatabasePath: sibling.siblingDbPath,
|
|
107
|
+
sourceLabel: sibling.siblingPath,
|
|
108
|
+
});
|
|
109
|
+
const overlay = deriveSharedOverlay(resolved, sibling.siblingIndexedHead);
|
|
110
|
+
const reconciliation = await engine.reconcileCandidate(candidate, resolved, overlay.changedPaths);
|
|
111
|
+
const audit = await engine.auditCandidate(candidate, { mode: "deep" });
|
|
112
|
+
if (!audit.healthy) {
|
|
113
|
+
return { seeded: false, reason: audit.issues.join(",") || "candidate audit failed" };
|
|
114
|
+
}
|
|
115
|
+
await engine.promoteCandidate(candidate);
|
|
116
|
+
const promoted = candidate;
|
|
117
|
+
candidate = undefined;
|
|
118
|
+
const health = await engine.status(resolved, { audit: "deep", ignoreActiveOperation: true });
|
|
119
|
+
return {
|
|
120
|
+
seeded: true,
|
|
121
|
+
reflinked: promoted.reflinked ?? false,
|
|
122
|
+
seededFrom: sibling.siblingPath,
|
|
123
|
+
reconciledPaths: reconciliation.reconciled,
|
|
124
|
+
status: health,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return { seeded: false, reason: error instanceof Error ? error.message : "seed failed" };
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
if (candidate) {
|
|
132
|
+
try {
|
|
133
|
+
await engine.discardCandidate(candidate);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Best-effort cleanup; a fresh candidate directory is harmless to leave for the next attempt.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function toIndexResult(status, reconciledPaths) {
|
|
142
|
+
return {
|
|
143
|
+
indexed: reconciledPaths,
|
|
144
|
+
unchanged: [],
|
|
145
|
+
verification: {
|
|
146
|
+
status: status.status,
|
|
147
|
+
issueCount: status.missing.files.length + status.missing.records.length,
|
|
148
|
+
missing: status.missing,
|
|
149
|
+
verifiedAt: status.verification.verifiedAt,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Drop-in replacement for `(repo, options) => engine.index(repo, undefined, false, options)`
|
|
155
|
+
* used by every `knodin init`-family command: for a brand-new worktree, tries to
|
|
156
|
+
* seed-and-reconcile from a sibling before falling back to a full from-scratch
|
|
157
|
+
* index. For a worktree that already has a database (repair/update path),
|
|
158
|
+
* behaves exactly like the wrapped full index call.
|
|
159
|
+
*/
|
|
160
|
+
export function indexOrSeed(engine, schemaVersion) {
|
|
161
|
+
const fullIndex = (target, options) => engine.index(target, undefined, false, options);
|
|
162
|
+
return async (target, options) => {
|
|
163
|
+
if (fs.existsSync(resolveDbPath(target)))
|
|
164
|
+
return fullIndex(target, options);
|
|
165
|
+
if (process.env[OPT_OUT_ENV] === "1")
|
|
166
|
+
return fullIndex(target, options);
|
|
167
|
+
const seed = await seedWorktreeIndex(target, engine, schemaVersion);
|
|
168
|
+
if (seed.seeded)
|
|
169
|
+
return toIndexResult(seed.status, seed.reconciledPaths);
|
|
170
|
+
return fullIndex(target, options);
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -114,6 +114,15 @@ the signed output size and digest, rejects trailing compressed data, validates
|
|
|
114
114
|
the SQLite header and internal commit/schema/fingerprint, reconciles final Git
|
|
115
115
|
and filesystem truth, deep-audits, and only then promotes.
|
|
116
116
|
|
|
117
|
+
A brand-new worktree's distinct database may itself be populated by cloning an
|
|
118
|
+
already-indexed sibling worktree's baseline (an OS-level copy-on-write file
|
|
119
|
+
clone where the filesystem supports it, falling back to a plain copy
|
|
120
|
+
otherwise) instead of a from-scratch build — see `knodin init`'s seed-and-
|
|
121
|
+
reconcile path in `src/worktree-seed.ts`. That clone is reconciled to the new
|
|
122
|
+
worktree's actual state through this same overlay-diff machinery before
|
|
123
|
+
promotion, so the "distinct, writable" guarantee above is unchanged; only how
|
|
124
|
+
the initial bytes are produced differs.
|
|
125
|
+
|
|
117
126
|
## Operator surfaces
|
|
118
127
|
|
|
119
128
|
- `knodin shared status` is network-free. `--probe` is the explicit S3/auth
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# knodin 0.10.0
|
|
2
|
+
|
|
3
|
+
This release makes a full index roughly 2.8x faster, turns parallel parsing on
|
|
4
|
+
by default, adds sealed artifacts so a repository can be queried with no
|
|
5
|
+
checkout at all, and makes unreadable source distinguishable from source that
|
|
6
|
+
does not exist. It also makes `knodin init` fast on a brand-new git worktree by
|
|
7
|
+
seeding its graph from an already-indexed sibling.
|
|
8
|
+
|
|
9
|
+
## Sealed artifacts: query a repository with no checkout
|
|
10
|
+
|
|
11
|
+
An ordinary `.knodin/db.sqlite` stores `filePath` plus line ranges and reads the
|
|
12
|
+
bytes off a live working tree, so it cannot answer anything on a machine that
|
|
13
|
+
has no clone. `knodin seal` embeds those bytes and stamps an attestation
|
|
14
|
+
describing exactly which tree they came from.
|
|
15
|
+
|
|
16
|
+
- `knodin seal --output <path.sqlite>` produces a portable artifact. Sealing is
|
|
17
|
+
a post-index transform, never index-time work: an ordinary user with a
|
|
18
|
+
checkout should not pay for source already on disk, so opting in is
|
|
19
|
+
structural rather than a matter of flag discipline. The live database is
|
|
20
|
+
copied, never mutated.
|
|
21
|
+
- `knodin sealed <artifact> [symbol]` inspects or queries an artifact, and the
|
|
22
|
+
same capability is available as a `sealed` operation on the MCP gateway. Both
|
|
23
|
+
are read-only; creating an artifact stays CLI-only because it writes to disk.
|
|
24
|
+
- The attestation carries schema version, knodin version, canonical
|
|
25
|
+
`owner/name` repository identity, the exact commit, **and which ref** was
|
|
26
|
+
sealed — a citation cannot be checked without knowing whether it came from
|
|
27
|
+
`main` or from whatever production is running.
|
|
28
|
+
- Refusals are machine-readable codes rather than prose, because a caller
|
|
29
|
+
branches on them: `database-missing`, `index-unhealthy`, `working-tree-dirty`,
|
|
30
|
+
`commit-divergent`, `no-commit`, `source-drift`.
|
|
31
|
+
- `source-drift` refuses the whole seal when a file changed between indexing
|
|
32
|
+
and sealing. Embedded bytes that disagree with recorded line ranges make
|
|
33
|
+
`explain` return the wrong function's body with full confidence and no
|
|
34
|
+
omission — a false positive that looks correct, which is worse than any
|
|
35
|
+
absence. Two checks run: `mtimeMs`/`size` against `index_state`, and a bounds
|
|
36
|
+
check that recorded line ranges fit the embedded bytes, which holds without
|
|
37
|
+
consulting a clock at all.
|
|
38
|
+
- **Known residual, stated rather than implied:** a same-size edit that also
|
|
39
|
+
preserves line count and lands inside the mtime comparison window is still
|
|
40
|
+
invisible. Closing it needs a content hash in `index_state`, which forces a
|
|
41
|
+
full reindex and is tracked separately.
|
|
42
|
+
- Uncovered content reads as absent, never as an empty string. An empty string
|
|
43
|
+
is indistinguishable from an empty file and downstream reads as "this symbol
|
|
44
|
+
has no body" rather than "this was not covered".
|
|
45
|
+
- Size: profiles are excluded by default under the named rule
|
|
46
|
+
`profiles-excluded` (58% of one Salesforce payload in 187 files, already
|
|
47
|
+
captured as graph edges); blobs are zstd-compressed **inside** the database,
|
|
48
|
+
since SQLite does not transparently compress and a consumer holds the
|
|
49
|
+
decompressed file resident; storage is content-addressed so duplicate files
|
|
50
|
+
cost one blob; and embeddings are stripped unless `--keep-embeddings` is
|
|
51
|
+
passed, with semantic search then reporting itself unavailable rather than
|
|
52
|
+
returning zero results.
|
|
53
|
+
- Measured on this repository: 832 files, 6.05 MB of source compressing to
|
|
54
|
+
2.05 MB (2.95x), a 17 MB artifact, `integrity_check: ok`.
|
|
55
|
+
- Opening an artifact requires `mode: "sealed"`. Without it the engine
|
|
56
|
+
reconciles the graph against an empty mount, prunes every symbol, and answers
|
|
57
|
+
"not found" for content the artifact is carrying.
|
|
58
|
+
|
|
59
|
+
## A full index is about 2.8x faster
|
|
60
|
+
|
|
61
|
+
- Scoping an FTS trigger to the columns it actually mirrors, and removing a
|
|
62
|
+
whole-body string build from symbol identity, took a full index of this
|
|
63
|
+
repository from 60.5s to 21.3s.
|
|
64
|
+
- Salesforce metadata is now symbolized by kind rather than as one
|
|
65
|
+
undifferentiated `salesforce-metadata` symbol carrying identical boilerplate,
|
|
66
|
+
so FTS and embeddings over it mean something for the first time.
|
|
67
|
+
|
|
68
|
+
## Parallel parsing is on by default
|
|
69
|
+
|
|
70
|
+
- The parse pool now runs unless `KNODIN_DISABLE_PARSE_WORKERS=1` is set. Pool
|
|
71
|
+
size is derived from total memory and core count, and an unsuitable host
|
|
72
|
+
silently keeps the previous sequential behaviour rather than discovering the
|
|
73
|
+
problem partway through an index.
|
|
74
|
+
- Files that need a main-thread indexer are kept off the pool. `.page`,
|
|
75
|
+
`.component`, `.clp`, `.ws`, `.xml`, `.sql`, `.pkb`, `.pks` and `.prisma`
|
|
76
|
+
classify as generic but hand off to indexers that write to the database
|
|
77
|
+
themselves, and routing them to a worker made a Visualforce controller edge
|
|
78
|
+
resolve on some runs and not others.
|
|
79
|
+
|
|
80
|
+
## Unreadable source is distinguishable from missing source
|
|
81
|
+
|
|
82
|
+
- Source reads report an availability state instead of collapsing every failure
|
|
83
|
+
into `""`. A file that is gone, a file that could not be read this time, and a
|
|
84
|
+
symbol that does not exist are now three different answers.
|
|
85
|
+
- `dead_code` no longer reports a symbol as dead because its file could not be
|
|
86
|
+
read, and a missing file no longer aborts an entire `map` or `explain`.
|
|
87
|
+
|
|
88
|
+
## Faster `knodin init` on a new worktree
|
|
89
|
+
|
|
90
|
+
- A brand-new worktree's graph database is seeded from the cheapest-to-
|
|
91
|
+
reconcile already-indexed sibling worktree of the same repository, cloned
|
|
92
|
+
via an OS-level copy-on-write file clone where the filesystem supports it
|
|
93
|
+
(plain-copy fallback otherwise), then reconciled to the new worktree's
|
|
94
|
+
actual committed/staged/working/untracked state through the existing
|
|
95
|
+
candidate allocate/reconcile/audit/promote pipeline before atomic
|
|
96
|
+
promotion.
|
|
97
|
+
- Seeding is opportunistic: no eligible sibling, a schema/build-compatibility
|
|
98
|
+
mismatch, or any candidate-audit failure falls straight back to a full
|
|
99
|
+
from-scratch index, with no error surfaced to the caller. Every git
|
|
100
|
+
lifecycle operation (pull, reset, merge, rebase, checkout, stash, clean)
|
|
101
|
+
on a seeded worktree is handled by the same incremental reconciliation and
|
|
102
|
+
hook machinery as any other worktree.
|
|
103
|
+
- Measured on this repository (810 files, 5,086 symbols): `knodin init` on a
|
|
104
|
+
new worktree with an eligible sibling completed in roughly 5 seconds versus
|
|
105
|
+
30-40 seconds for a from-scratch build.
|
|
106
|
+
- Fixes a latent bug in the candidate reconciliation path: a candidate
|
|
107
|
+
populated from a foreign source (a downloaded shared-index artifact, or now
|
|
108
|
+
another worktree's database) kept `index_state` mtimes from wherever that
|
|
109
|
+
source was built, so every file outside the explicit changed-path set could
|
|
110
|
+
trip the deep audit's drift check once source and target were genuinely
|
|
111
|
+
different checkouts. `reconcileCandidate` now refreshes that bookkeeping
|
|
112
|
+
(a stat, not a re-index) for known files outside the changed-path set,
|
|
113
|
+
which also benefits the existing shared-index restore path.
|
|
114
|
+
- Set `KNODIN_DISABLE_WORKTREE_SEED=1` to always force a full from-scratch
|
|
115
|
+
index.
|
|
116
|
+
|
|
117
|
+
No existing worktree's database is touched by that change; it only affects the
|
|
118
|
+
code path for a worktree whose `.knodin/db.sqlite` does not yet exist.
|
|
119
|
+
|
|
120
|
+
## Also in this release
|
|
121
|
+
|
|
122
|
+
- Five benchmark runners no longer call `.replace("Z", "Z")`, a no-op that
|
|
123
|
+
CodeQL correctly flagged as `js/identity-replacement`. Output filenames are
|
|
124
|
+
byte-identical.
|
|
125
|
+
- Fixed an `update-executor` test that failed roughly one run in three: a
|
|
126
|
+
version probe raced a 10-second timeout, and the probe timeout is now
|
|
127
|
+
injectable so the suite does not depend on process-spawn latency.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# knodin 0.10.1
|
|
2
|
+
|
|
3
|
+
Three defects, none of which a test suite would have found on its own. Two were
|
|
4
|
+
found by upgrading knodin on a real machine: one only appears after Homebrew
|
|
5
|
+
moves the Node binary, the other only when a lifecycle event is already queued.
|
|
6
|
+
The third is a scheduling race that reported the same cause under two different
|
|
7
|
+
codes, and it surfaced as an unrelated pre-push failure after six clean runs.
|
|
8
|
+
|
|
9
|
+
## Background refresh survives a moved Node binary
|
|
10
|
+
|
|
11
|
+
`knodin init` wrote the generated hook with a fully-resolved path to whichever
|
|
12
|
+
Node was running at the time. `process.execPath` is symlink-resolved, so a
|
|
13
|
+
stable `/opt/homebrew/bin/node` became a version-pinned
|
|
14
|
+
`/opt/homebrew/Cellar/node/<version>/bin/node`.
|
|
15
|
+
|
|
16
|
+
Homebrew deletes that directory on upgrade, so every hook written beforehand
|
|
17
|
+
died with exit 127 — silently, because refresh runs in the background. The
|
|
18
|
+
graph simply stopped keeping up, and only a `knodin status` run revealed it.
|
|
19
|
+
It affected every repository initialized before the upgrade, not one.
|
|
20
|
+
|
|
21
|
+
The hook now resolves its interpreter when it runs. The recorded path stays
|
|
22
|
+
preferred, so nothing changes while it exists; `PATH` is consulted only once it
|
|
23
|
+
does not. Falling back first would silently change which runtime executes,
|
|
24
|
+
which is its own bug.
|
|
25
|
+
|
|
26
|
+
Hooks generated before this release still carry the old absolute path. They
|
|
27
|
+
keep failing until regenerated with `knodin init`; the fix prevents recurrence
|
|
28
|
+
after the next Node upgrade rather than repairing them retroactively.
|
|
29
|
+
|
|
30
|
+
## `knodin init` can drain its own lifecycle queue
|
|
31
|
+
|
|
32
|
+
`init` takes the lifecycle lease, then drains queued events by spawning the
|
|
33
|
+
background hook. The hook re-entered knodin, met the same lease, saw a live
|
|
34
|
+
pid — its own parent — and refused. `init` reported "queued lifecycle refresh
|
|
35
|
+
could not be drained (exit 1)" on every run, and `knodin status` then advised
|
|
36
|
+
running `init`, which reproduced the failure exactly.
|
|
37
|
+
|
|
38
|
+
Worse, the failed drain wrote a failure marker that `status` kept reporting, so
|
|
39
|
+
a transient condition looked permanent while the graph was in fact intact — in
|
|
40
|
+
the observed case 5,883 of 5,883 files indexed and 5,059 carrying symbols.
|
|
41
|
+
|
|
42
|
+
A lease holder now passes its nonce to processes it spawns, and a child whose
|
|
43
|
+
token matches the lease on disk is admitted re-entrantly. Re-entry returns a
|
|
44
|
+
no-op release, because releasing there would drop the lock while the parent
|
|
45
|
+
still believes it holds it.
|
|
46
|
+
|
|
47
|
+
The lease is not weakened. A missing token, or one that does not match the
|
|
48
|
+
lease on disk, still hits the existing contention check, so a genuinely
|
|
49
|
+
concurrent mutation from another process is refused exactly as before.
|
|
50
|
+
|
|
51
|
+
## One abort cause, one code
|
|
52
|
+
|
|
53
|
+
Worker startup and queue admission are each a race between "ready" and
|
|
54
|
+
"aborted". Each arm had an abort listener rejecting with
|
|
55
|
+
`KNODIN_CLIENT_DISCONNECTED` and a follow-up `signal.aborted` re-check
|
|
56
|
+
rejecting with `KNODIN_REQUEST_CANCELLED`, so the same client disconnect
|
|
57
|
+
surfaced as either code depending on which arm settled first.
|
|
58
|
+
|
|
59
|
+
That is worse than a consistently wrong code. It was stable on an idle machine
|
|
60
|
+
and unstable under load, so it looked trustworthy everywhere anyone would have
|
|
61
|
+
checked it — it surfaced as a pre-push gate failure on an unrelated change
|
|
62
|
+
after six clean runs.
|
|
63
|
+
|
|
64
|
+
It also fed the reliability journal, where the two codes map to distinct kinds.
|
|
65
|
+
One disconnect was recorded under two kinds, quietly skewing any rate computed
|
|
66
|
+
from them. **That contamination is retroactive and cannot be repaired:** journal
|
|
67
|
+
entries written before this release cannot be reattributed, so a
|
|
68
|
+
cancellation-versus-disconnect rate spanning the 0.10.1 boundary should be read
|
|
69
|
+
as suspect rather than corrected.
|
|
70
|
+
|
|
71
|
+
The taxonomy is unchanged. `superviseMcpRequest` can still tell an explicit
|
|
72
|
+
cancellation from a disconnect; the supervisor cannot, so it now names only the
|
|
73
|
+
cause it actually knows.
|
|
74
|
+
|
|
75
|
+
## Homebrew formula retention
|
|
76
|
+
|
|
77
|
+
The `Retain versioned Homebrew formula` job ran on a GitHub-hosted runner whose
|
|
78
|
+
ephemeral IP is not on the organization's allow list, so it failed at checkout
|
|
79
|
+
with a 403 on every release since it was added — and it cloned
|
|
80
|
+
`knodin/knodin`, a stale source mirror, rather than `knodin/homebrew-tap`.
|
|
81
|
+
|
|
82
|
+
Both are corrected. Every formula through 0.10.0 was retained by hand; this is
|
|
83
|
+
the first release where that job can do it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knodin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"knodin": {
|
|
5
5
|
"compatibility": "breaking"
|
|
6
6
|
},
|
|
@@ -71,6 +71,8 @@
|
|
|
71
71
|
"docs/releases/0.8.6.md",
|
|
72
72
|
"docs/releases/0.8.7.md",
|
|
73
73
|
"docs/releases/0.9.0.md",
|
|
74
|
+
"docs/releases/0.10.0.md",
|
|
75
|
+
"docs/releases/0.10.1.md",
|
|
74
76
|
"docs/assets/knodin-favicon.svg",
|
|
75
77
|
"docs/SYSTEMS-AND-RELATIONSHIPS.md",
|
|
76
78
|
"docs/TELEMETRY.md",
|