knodin 0.8.7 → 0.9.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.
Files changed (44) hide show
  1. package/dist/bin/cli.js +307 -85
  2. package/dist/fixtures/update-verification/config/application.xml +4 -0
  3. package/dist/fixtures/update-verification/dbt/manifest.json +17 -0
  4. package/dist/fixtures/update-verification/dbt/models/accounts.sql +1 -0
  5. package/dist/fixtures/update-verification/dbt/models/contacts.sql +1 -0
  6. package/dist/fixtures/update-verification/fixture.json +147 -0
  7. package/dist/fixtures/update-verification/force-app/main/default/classes/UpdateFixture.cls +14 -0
  8. package/dist/fixtures/update-verification/force-app/main/default/flows/Update_Verification.flow-meta.xml +9 -0
  9. package/dist/fixtures/update-verification/package.json +5 -0
  10. package/dist/fixtures/update-verification/prisma/schema.prisma +10 -0
  11. package/dist/fixtures/update-verification/sql/functions.sql +16 -0
  12. package/dist/fixtures/update-verification/src/CsharpFixture.cs +6 -0
  13. package/dist/fixtures/update-verification/src/JavaFixture.java +9 -0
  14. package/dist/fixtures/update-verification/src/javascript.js +7 -0
  15. package/dist/fixtures/update-verification/src/python_fixture.py +6 -0
  16. package/dist/fixtures/update-verification/src/typescript.ts +7 -0
  17. package/dist/fixtures/update-verification/terraform/main.tf +7 -0
  18. package/dist/fixtures/update-verification/terraform/modules/verified-child/main.tf +3 -0
  19. package/dist/src/agent-integration.js +26 -3
  20. package/dist/src/backup-retention.js +811 -0
  21. package/dist/src/cli-model.js +35 -8
  22. package/dist/src/docs-sections.js +1 -0
  23. package/dist/src/doctor.js +142 -12
  24. package/dist/src/engine/embeddings.js +8 -1
  25. package/dist/src/engine/index.js +355 -292
  26. package/dist/src/init.js +222 -179
  27. package/dist/src/manager-update.js +455 -0
  28. package/dist/src/release-preflight.js +106 -95
  29. package/dist/src/repair-lease.js +77 -8
  30. package/dist/src/response-budget.js +5 -1
  31. package/dist/src/server.js +18 -5
  32. package/dist/src/shared-index/compatibility.js +4 -2
  33. package/dist/src/tools/knodin-tools.js +69 -10
  34. package/dist/src/update-ceremony.js +20 -24
  35. package/dist/src/update-coordination.js +158 -0
  36. package/dist/src/update-executor.js +336 -0
  37. package/dist/src/update-verifier.js +358 -0
  38. package/docs/BACKUP-RETENTION.md +54 -0
  39. package/docs/CLI.md +6 -0
  40. package/docs/DOCTOR-AND-UPDATES.md +44 -12
  41. package/docs/SIGNED-UPDATES.md +29 -22
  42. package/docs/releases/0.9.0.md +43 -0
  43. package/package.json +7 -1
  44. package/roadmap/competitive-roadmap.md +8 -0
@@ -0,0 +1,336 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { createUpdateJournal, managerInstallArgv, readManagerUpdateState, readUpdateJournal, resolveManagerOwnership, setAutomaticRollbackPin, setManualPin, transitionUpdateJournal, writeManagerUpdateState, writeUpdateJournal, } from "./manager-update.js";
4
+ import { acquireExclusiveUpdateCoordination } from "./update-coordination.js";
5
+ import { verifyUpdateCandidate } from "./update-verifier.js";
6
+ export async function certifyAutomaticUpdates(options) {
7
+ const verify = options.verifyCandidate ??
8
+ (async (input) => verifyUpdateCandidate({ ...input, timeoutMs: 60_000, env: options.env }));
9
+ const durations = [];
10
+ let fixtureVersion = "unknown";
11
+ let passed = true;
12
+ for (let run = 0; run < 5; run++) {
13
+ const started = Date.now();
14
+ const result = await verify({
15
+ command: options.runtimeCommand,
16
+ expectedVersion: options.currentVersion,
17
+ verificationToken: `platform-certification-${randomUUID()}`,
18
+ });
19
+ durations.push(Date.now() - started);
20
+ fixtureVersion = result.record.fixtureVersion;
21
+ if (result.status !== "passed")
22
+ passed = false;
23
+ }
24
+ durations.sort((left, right) => left - right);
25
+ const p95Ms = durations[Math.ceil(durations.length * 0.95) - 1] ?? Number.POSITIVE_INFINITY;
26
+ const state = readManagerUpdateState(options.stateHome);
27
+ const next = {
28
+ ...state,
29
+ autoEnabled: passed && p95Ms <= 20_000,
30
+ platformAutoEligible: passed && p95Ms <= 20_000,
31
+ platformCertification: {
32
+ platform: `${process.platform}-${process.arch}`,
33
+ fixtureVersion,
34
+ measuredAt: new Date().toISOString(),
35
+ p95Ms,
36
+ runs: durations.length,
37
+ },
38
+ };
39
+ writeManagerUpdateState(next, options.stateHome);
40
+ return next;
41
+ }
42
+ function sameOwner(left, right) {
43
+ return (left.status === "owned" &&
44
+ right.status === "owned" &&
45
+ left.method === right.method &&
46
+ left.executable.realpath === right.executable.realpath &&
47
+ left.packageRoot === right.packageRoot);
48
+ }
49
+ async function defaultRunManager(argv) {
50
+ const [executable, ...args] = argv;
51
+ if (!executable)
52
+ return { status: 1, stderr: "manager command is empty" };
53
+ const result = spawnSync(executable, args, {
54
+ encoding: "utf8",
55
+ stdio: ["ignore", "pipe", "pipe"],
56
+ timeout: 120_000,
57
+ maxBuffer: 4 * 1024 * 1024,
58
+ });
59
+ return {
60
+ status: result.status,
61
+ stderr: (result.stderr || result.error?.message || "").slice(0, 4096),
62
+ };
63
+ }
64
+ export async function executeManagerUpdate(options) {
65
+ const owner = resolveManagerOwnership({
66
+ runtimeCommand: options.runtimeCommand,
67
+ executablePath: options.runtimeCommand.at(-1),
68
+ env: options.env,
69
+ });
70
+ const managerArgv = managerInstallArgv(owner.method, options.targetVersion);
71
+ if (owner.status !== "owned" || !managerArgv) {
72
+ return {
73
+ status: "refused",
74
+ owner,
75
+ previousVersion: options.currentVersion,
76
+ targetVersion: options.targetVersion,
77
+ reason: owner.status === "ambiguous"
78
+ ? "competing, stale, or shadowing installations require manual cleanup"
79
+ : "the active package-manager owner could not be proven",
80
+ };
81
+ }
82
+ if (!options.apply) {
83
+ return {
84
+ status: "planned",
85
+ owner,
86
+ previousVersion: options.currentVersion,
87
+ targetVersion: options.targetVersion,
88
+ managerArgv,
89
+ };
90
+ }
91
+ const verificationToken = randomUUID();
92
+ const lock = acquireExclusiveUpdateCoordination({
93
+ stateHome: options.stateHome,
94
+ verificationToken,
95
+ });
96
+ const runManager = options.runManager ?? defaultRunManager;
97
+ const verify = options.verifyCandidate ??
98
+ (async (input) => verifyUpdateCandidate({
99
+ ...input,
100
+ timeoutMs: 60_000,
101
+ env: options.env,
102
+ expectedOwner: owner,
103
+ }));
104
+ let journal = createUpdateJournal({
105
+ previousVersion: options.currentVersion,
106
+ targetVersion: options.targetVersion,
107
+ manager: owner,
108
+ fixtureVersion: "2026-08-14.1",
109
+ });
110
+ writeUpdateJournal(journal, options.stateHome);
111
+ try {
112
+ const revalidated = resolveManagerOwnership({
113
+ runtimeCommand: options.runtimeCommand,
114
+ executablePath: options.runtimeCommand.at(-1),
115
+ env: options.env,
116
+ });
117
+ if (!sameOwner(owner, revalidated)) {
118
+ journal = transitionUpdateJournal(journal, "terminal-unhealthy", { error: "package-manager ownership changed after update lock acquisition" }, options.stateHome);
119
+ return {
120
+ status: "refused",
121
+ owner: revalidated,
122
+ previousVersion: options.currentVersion,
123
+ targetVersion: options.targetVersion,
124
+ journal,
125
+ reason: journal.error,
126
+ };
127
+ }
128
+ journal = transitionUpdateJournal(journal, "manager-applying", {}, options.stateHome);
129
+ const installed = await runManager(managerArgv);
130
+ journal = transitionUpdateJournal(journal, installed.status === 0 ? "candidate-installed-unverified" : "rollback-required", installed.status === 0 ? {} : { error: installed.stderr || "manager application failed" }, options.stateHome);
131
+ if (installed.status === 0) {
132
+ journal = transitionUpdateJournal(journal, "candidate-verifying", {}, options.stateHome);
133
+ const verification = await verify({
134
+ command: options.runtimeCommand,
135
+ expectedVersion: options.targetVersion,
136
+ verificationToken,
137
+ });
138
+ journal = { ...journal, verification: verification.record };
139
+ if (verification.status === "passed") {
140
+ journal = transitionUpdateJournal(journal, "candidate-verified", {}, options.stateHome);
141
+ if (options.pinKind === "manual")
142
+ setManualPin(options.targetVersion, options.stateHome);
143
+ return {
144
+ status: "verified",
145
+ owner,
146
+ previousVersion: options.currentVersion,
147
+ targetVersion: options.targetVersion,
148
+ managerArgv,
149
+ journal,
150
+ verification: verification.record,
151
+ };
152
+ }
153
+ journal = transitionUpdateJournal(journal, "rollback-required", { error: verification.error ?? "candidate verification failed" }, options.stateHome);
154
+ }
155
+ const rollbackArgv = managerInstallArgv(owner.method, options.currentVersion);
156
+ if (!rollbackArgv)
157
+ throw new Error("exact rollback manager command is unavailable");
158
+ journal = transitionUpdateJournal(journal, "rollback-applying", {}, options.stateHome);
159
+ const restored = await runManager(rollbackArgv);
160
+ if (restored.status !== 0) {
161
+ journal = transitionUpdateJournal(journal, "terminal-unhealthy", { error: restored.stderr || "rollback manager application failed" }, options.stateHome);
162
+ return {
163
+ status: "terminal-unhealthy",
164
+ owner,
165
+ previousVersion: options.currentVersion,
166
+ targetVersion: options.targetVersion,
167
+ journal,
168
+ reason: journal.error,
169
+ };
170
+ }
171
+ journal = transitionUpdateJournal(journal, "rollback-installed-unverified", {}, options.stateHome);
172
+ journal = transitionUpdateJournal(journal, "rollback-verifying", {}, options.stateHome);
173
+ const rollbackVerification = await verify({
174
+ command: options.runtimeCommand,
175
+ expectedVersion: options.currentVersion,
176
+ verificationToken,
177
+ });
178
+ journal = { ...journal, verification: rollbackVerification.record };
179
+ if (rollbackVerification.status !== "passed") {
180
+ journal = transitionUpdateJournal(journal, "terminal-unhealthy", { error: rollbackVerification.error ?? "rollback verification failed" }, options.stateHome);
181
+ return {
182
+ status: "terminal-unhealthy",
183
+ owner,
184
+ previousVersion: options.currentVersion,
185
+ targetVersion: options.targetVersion,
186
+ journal,
187
+ verification: rollbackVerification.record,
188
+ reason: journal.error,
189
+ };
190
+ }
191
+ journal = transitionUpdateJournal(journal, "rollback-verified", {}, options.stateHome);
192
+ setAutomaticRollbackPin(options.currentVersion, options.targetVersion, options.stateHome);
193
+ return {
194
+ status: "rolled-back",
195
+ owner,
196
+ previousVersion: options.currentVersion,
197
+ targetVersion: options.targetVersion,
198
+ journal,
199
+ verification: rollbackVerification.record,
200
+ };
201
+ }
202
+ finally {
203
+ lock.release();
204
+ }
205
+ }
206
+ function installedRuntimeVersion(command, env) {
207
+ const [executable, ...prefix] = command;
208
+ if (!executable)
209
+ return null;
210
+ const result = spawnSync(executable, [...prefix, "--version"], {
211
+ encoding: "utf8",
212
+ env,
213
+ stdio: ["ignore", "pipe", "pipe"],
214
+ timeout: 10_000,
215
+ });
216
+ return result.status === 0 && /^\d+\.\d+\.\d+/.test(result.stdout.trim())
217
+ ? result.stdout.trim()
218
+ : null;
219
+ }
220
+ /** Resume an interrupted installation from its fsynced journal before ordinary dispatch. */
221
+ export async function recoverInterruptedUpdate(options) {
222
+ let journal = readUpdateJournal(options.stateHome);
223
+ if (!journal || ["planned", "candidate-verified", "rollback-verified"].includes(journal.state))
224
+ return null;
225
+ if (journal.state === "terminal-unhealthy")
226
+ return {
227
+ status: "terminal-unhealthy",
228
+ owner: journal.manager,
229
+ previousVersion: journal.previousVersion,
230
+ targetVersion: journal.targetVersion,
231
+ journal,
232
+ reason: journal.error,
233
+ };
234
+ const verificationToken = randomUUID();
235
+ const lock = acquireExclusiveUpdateCoordination({
236
+ stateHome: options.stateHome,
237
+ verificationToken,
238
+ recovery: true,
239
+ });
240
+ const runManager = options.runManager ?? defaultRunManager;
241
+ const expectedRecoveryOwner = journal.manager;
242
+ const verify = options.verifyCandidate ??
243
+ (async (input) => verifyUpdateCandidate({
244
+ ...input,
245
+ timeoutMs: 60_000,
246
+ env: options.env,
247
+ expectedOwner: expectedRecoveryOwner,
248
+ }));
249
+ try {
250
+ if (journal.state === "manager-applying") {
251
+ const active = installedRuntimeVersion(options.runtimeCommand, options.env);
252
+ journal = transitionUpdateJournal(journal, active === journal.targetVersion
253
+ ? "candidate-installed-unverified"
254
+ : active === journal.previousVersion
255
+ ? "rollback-installed-unverified"
256
+ : "rollback-required", active && [journal.targetVersion, journal.previousVersion].includes(active)
257
+ ? {}
258
+ : {
259
+ error: `manager application was interrupted with active version ${active ?? "unknown"}`,
260
+ }, options.stateHome);
261
+ }
262
+ if (["candidate-installed-unverified", "candidate-verifying"].includes(journal.state)) {
263
+ journal = transitionUpdateJournal(journal, "candidate-verifying", {}, options.stateHome);
264
+ const candidate = await verify({
265
+ command: options.runtimeCommand,
266
+ expectedVersion: journal.targetVersion,
267
+ verificationToken,
268
+ });
269
+ journal = { ...journal, verification: candidate.record };
270
+ if (candidate.status === "passed") {
271
+ journal = transitionUpdateJournal(journal, "candidate-verified", {}, options.stateHome);
272
+ return {
273
+ status: "verified",
274
+ owner: journal.manager,
275
+ previousVersion: journal.previousVersion,
276
+ targetVersion: journal.targetVersion,
277
+ journal,
278
+ verification: candidate.record,
279
+ };
280
+ }
281
+ journal = transitionUpdateJournal(journal, "rollback-required", { error: candidate.error ?? "interrupted candidate verification failed" }, options.stateHome);
282
+ }
283
+ if (["rollback-required", "rollback-applying"].includes(journal.state)) {
284
+ const rollbackArgv = managerInstallArgv(journal.manager.method, journal.previousVersion);
285
+ if (!rollbackArgv)
286
+ throw new Error("exact recovery rollback is unavailable");
287
+ journal = transitionUpdateJournal(journal, "rollback-applying", {}, options.stateHome);
288
+ const restored = await runManager(rollbackArgv);
289
+ if (restored.status !== 0) {
290
+ journal = transitionUpdateJournal(journal, "terminal-unhealthy", { error: restored.stderr || "recovery rollback manager failed" }, options.stateHome);
291
+ return {
292
+ status: "terminal-unhealthy",
293
+ owner: journal.manager,
294
+ previousVersion: journal.previousVersion,
295
+ targetVersion: journal.targetVersion,
296
+ journal,
297
+ reason: journal.error,
298
+ };
299
+ }
300
+ journal = transitionUpdateJournal(journal, "rollback-installed-unverified", {}, options.stateHome);
301
+ }
302
+ if (["rollback-installed-unverified", "rollback-verifying"].includes(journal.state)) {
303
+ journal = transitionUpdateJournal(journal, "rollback-verifying", {}, options.stateHome);
304
+ const restored = await verify({
305
+ command: options.runtimeCommand,
306
+ expectedVersion: journal.previousVersion,
307
+ verificationToken,
308
+ });
309
+ journal = { ...journal, verification: restored.record };
310
+ if (restored.status === "passed") {
311
+ journal = transitionUpdateJournal(journal, "rollback-verified", {}, options.stateHome);
312
+ setAutomaticRollbackPin(journal.previousVersion, journal.targetVersion, options.stateHome);
313
+ return {
314
+ status: "rolled-back",
315
+ owner: journal.manager,
316
+ previousVersion: journal.previousVersion,
317
+ targetVersion: journal.targetVersion,
318
+ journal,
319
+ verification: restored.record,
320
+ };
321
+ }
322
+ journal = transitionUpdateJournal(journal, "terminal-unhealthy", { error: restored.error ?? "interrupted rollback verification failed" }, options.stateHome);
323
+ }
324
+ return {
325
+ status: "terminal-unhealthy",
326
+ owner: journal.manager,
327
+ previousVersion: journal.previousVersion,
328
+ targetVersion: journal.targetVersion,
329
+ journal,
330
+ reason: journal.error ?? `recovery stopped in ${journal.state}`,
331
+ };
332
+ }
333
+ finally {
334
+ lock.release();
335
+ }
336
+ }
@@ -0,0 +1,358 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { getModelCacheDirectory } from "./engine/embeddings.js";
7
+ function packagedFixture() {
8
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../fixtures/update-verification");
9
+ }
10
+ function parseJson(output, label) {
11
+ try {
12
+ return JSON.parse(output);
13
+ }
14
+ catch {
15
+ throw new Error(`${label} did not return one complete JSON document`);
16
+ }
17
+ }
18
+ function run(command, args, options) {
19
+ const [executable, ...prefix] = command;
20
+ if (!executable)
21
+ throw new Error("candidate command is empty");
22
+ const timeout = options.remainingMs();
23
+ if (timeout <= 0)
24
+ throw new Error("verification deadline exceeded");
25
+ const result = spawnSync(executable, [...prefix, ...args], {
26
+ cwd: options.cwd,
27
+ env: options.env,
28
+ encoding: "utf8",
29
+ timeout,
30
+ maxBuffer: 4 * 1024 * 1024,
31
+ stdio: ["ignore", "pipe", "pipe"],
32
+ });
33
+ if (result.error && result.error.code === "ETIMEDOUT")
34
+ throw new Error("verification deadline exceeded");
35
+ if (result.status !== 0) {
36
+ const rawDetail = (result.stderr || result.stdout || `exit ${result.status}`).trim();
37
+ const detail = rawDetail.slice(Math.max(0, rawDetail.length - 4096));
38
+ throw new Error(`candidate command failed: ${detail}`);
39
+ }
40
+ return result.stdout;
41
+ }
42
+ const MAX_MCP_STREAM_BYTES = 64 * 1_024;
43
+ async function startMcpSession(command, cwd, env, remainingMs) {
44
+ const [executable, ...prefix] = command;
45
+ if (!executable)
46
+ throw new Error("candidate command is empty");
47
+ const child = spawn(executable, [...prefix, "serve"], {
48
+ cwd,
49
+ env,
50
+ stdio: ["pipe", "pipe", "pipe"],
51
+ });
52
+ let stdoutBuffer = "";
53
+ let stderr = "";
54
+ let nextId = 1;
55
+ let closed = false;
56
+ const pending = new Map();
57
+ const rejectPending = (message) => {
58
+ closed = true;
59
+ for (const request of pending.values()) {
60
+ clearTimeout(request.timeout);
61
+ request.reject(new Error(`${message}: ${stderr.slice(-2048)}`));
62
+ }
63
+ pending.clear();
64
+ };
65
+ child.stdout.setEncoding("utf8");
66
+ child.stderr.setEncoding("utf8");
67
+ child.stdout.on("data", (chunk) => {
68
+ stdoutBuffer += chunk;
69
+ if (Buffer.byteLength(stdoutBuffer) > MAX_MCP_STREAM_BYTES) {
70
+ rejectPending("MCP server emitted an oversized unterminated response");
71
+ child.kill("SIGKILL");
72
+ return;
73
+ }
74
+ for (;;) {
75
+ const newline = stdoutBuffer.indexOf("\n");
76
+ if (newline < 0)
77
+ break;
78
+ const line = stdoutBuffer.slice(0, newline).trim();
79
+ stdoutBuffer = stdoutBuffer.slice(newline + 1);
80
+ if (!line)
81
+ continue;
82
+ let response;
83
+ try {
84
+ response = JSON.parse(line);
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ if (typeof response.id !== "number")
90
+ continue;
91
+ const request = pending.get(response.id);
92
+ if (!request)
93
+ continue;
94
+ pending.delete(response.id);
95
+ clearTimeout(request.timeout);
96
+ request.resolve(response);
97
+ }
98
+ });
99
+ child.stderr.on("data", (chunk) => {
100
+ stderr = `${stderr}${chunk}`.slice(-MAX_MCP_STREAM_BYTES);
101
+ });
102
+ child.once("error", (error) => rejectPending(`MCP server failed: ${error.message}`));
103
+ child.once("exit", (code) => {
104
+ if (pending.size > 0)
105
+ rejectPending(`MCP server exited ${code}`);
106
+ else
107
+ closed = true;
108
+ });
109
+ const request = async (method, params) => {
110
+ if (closed)
111
+ throw new Error("MCP server is not running");
112
+ const timeoutMs = remainingMs();
113
+ if (timeoutMs <= 0)
114
+ throw new Error("MCP verification deadline exceeded");
115
+ const id = nextId++;
116
+ const response = new Promise((resolve, reject) => {
117
+ const timeout = setTimeout(() => {
118
+ pending.delete(id);
119
+ child.kill("SIGKILL");
120
+ reject(new Error("MCP verification deadline exceeded"));
121
+ }, Math.max(1, timeoutMs));
122
+ pending.set(id, { resolve, reject, timeout });
123
+ });
124
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
125
+ const value = await response;
126
+ if (value.error)
127
+ throw new Error(value.error.message ?? "MCP request failed");
128
+ return value;
129
+ };
130
+ const closeChild = async () => {
131
+ if (closed)
132
+ return;
133
+ child.stdin.end();
134
+ await new Promise((resolve) => {
135
+ const killTimeout = setTimeout(() => child.kill("SIGKILL"), 1_000);
136
+ const hardTimeout = setTimeout(resolve, 3_000);
137
+ child.once("close", () => {
138
+ clearTimeout(killTimeout);
139
+ clearTimeout(hardTimeout);
140
+ resolve();
141
+ });
142
+ });
143
+ };
144
+ try {
145
+ const initialized = await request("initialize", {
146
+ protocolVersion: "2025-06-18",
147
+ capabilities: {},
148
+ clientInfo: { name: "knodin-update-verifier", version: "1" },
149
+ });
150
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })}\n`);
151
+ const listed = await request("tools/list", {});
152
+ if (initialized.result?.serverInfo?.name !== "knodin" ||
153
+ !listed.result?.tools?.some((tool) => tool.name === "knodin"))
154
+ throw new Error("candidate MCP initialize/tools-list contract is incomplete");
155
+ }
156
+ catch (error) {
157
+ await closeChild();
158
+ throw error;
159
+ }
160
+ return {
161
+ async call(arguments_) {
162
+ const response = await request("tools/call", {
163
+ name: "knodin",
164
+ arguments: arguments_,
165
+ });
166
+ const toolResult = response.result;
167
+ const text = toolResult?.content?.find((item) => item.type === "text")?.text;
168
+ if (toolResult?.isError || !text)
169
+ throw new Error(`candidate MCP query failed${text ? `: ${text.slice(-2048)}` : ""}`);
170
+ return parseJson(text, "candidate MCP query");
171
+ },
172
+ close: closeChild,
173
+ };
174
+ }
175
+ function elapsedPhase(record, phase, action) {
176
+ const started = Date.now();
177
+ try {
178
+ return action();
179
+ }
180
+ finally {
181
+ record.phaseTimingsMs[phase] = Date.now() - started;
182
+ }
183
+ }
184
+ async function elapsedAsyncPhase(record, phase, action) {
185
+ const started = Date.now();
186
+ try {
187
+ return await action();
188
+ }
189
+ finally {
190
+ record.phaseTimingsMs[phase] = Date.now() - started;
191
+ }
192
+ }
193
+ export async function verifyUpdateCandidate(options) {
194
+ const source = options.fixtureSource ?? packagedFixture();
195
+ const manifest = JSON.parse(fs.readFileSync(path.join(source, "fixture.json"), "utf8"));
196
+ const record = {
197
+ fixtureVersion: manifest.fixtureVersion,
198
+ platform: process.platform,
199
+ startedAt: new Date().toISOString(),
200
+ phaseTimingsMs: {},
201
+ coverage: Object.fromEntries(manifest.languages.map((language) => [language, "unavailable"])),
202
+ };
203
+ const timeoutMs = Math.min(Math.max(options.timeoutMs ?? 60_000, 1), 60_000);
204
+ const deadline = Date.now() + timeoutMs;
205
+ const remainingMs = () => deadline - Date.now();
206
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "knodin-update-verification-"));
207
+ const repository = path.join(root, "fixture");
208
+ const env = {
209
+ ...options.env,
210
+ PATH: options.env?.PATH ?? process.env.PATH,
211
+ HOME: path.join(root, "home"),
212
+ XDG_CONFIG_HOME: path.join(root, "config"),
213
+ XDG_STATE_HOME: path.join(root, "state"),
214
+ KNODIN_UPDATE_CHECK: "0",
215
+ KNODIN_UPDATE_VERIFICATION_TOKEN: options.verificationToken ?? "",
216
+ // Repository/config state is isolated under the temporary HOME, while the
217
+ // immutable local model cache remains machine-scoped just as it is for an
218
+ // ordinary installation. Hiding it forces a redundant network fetch and can
219
+ // turn an otherwise healthy offline candidate into semanticReadiness=absent.
220
+ KNODIN_MODEL_CACHE: options.env?.KNODIN_MODEL_CACHE ?? getModelCacheDirectory({ env: process.env }),
221
+ // The immutable fixture is deliberately small. Four-way inference is
222
+ // measurably faster here while preserving every symbol and assertion; keep
223
+ // the override scoped to the verification child so production indexing
224
+ // retains its separately benchmarked default.
225
+ KNODIN_EMBEDDING_BATCH_SIZE: options.env?.KNODIN_EMBEDDING_BATCH_SIZE ?? "4",
226
+ };
227
+ let mcp;
228
+ try {
229
+ elapsedPhase(record, "copy-fixture", () => fs.cpSync(source, repository, { recursive: true, dereference: false }));
230
+ elapsedPhase(record, "git-fixture", () => {
231
+ run(["git"], ["init", "--quiet"], { cwd: repository, env, remainingMs });
232
+ run(["git"], ["config", "user.email", "verifier@knodin.local"], {
233
+ cwd: repository,
234
+ env,
235
+ remainingMs,
236
+ });
237
+ run(["git"], ["config", "user.name", "Knodin Verifier"], {
238
+ cwd: repository,
239
+ env,
240
+ remainingMs,
241
+ });
242
+ run(["git"], ["add", "."], { cwd: repository, env, remainingMs });
243
+ run(["git"], ["commit", "--quiet", "-m", "verification fixture"], {
244
+ cwd: repository,
245
+ env,
246
+ remainingMs,
247
+ });
248
+ });
249
+ elapsedPhase(record, "version-owner", () => {
250
+ const version = run(options.command, ["--version"], {
251
+ cwd: repository,
252
+ env,
253
+ remainingMs,
254
+ }).trim();
255
+ if (version !== options.expectedVersion)
256
+ throw new Error(`candidate version ${version || "empty"} does not equal ${options.expectedVersion}`);
257
+ if (options.expectedOwner) {
258
+ const status = parseJson(run(options.command, ["update", "status", "--json"], {
259
+ cwd: repository,
260
+ env,
261
+ remainingMs,
262
+ }), "candidate owner status");
263
+ const owner = status.owner;
264
+ if (owner?.status !== "owned" ||
265
+ owner.method !== options.expectedOwner.method ||
266
+ owner.executable.realpath !== options.expectedOwner.executable.realpath)
267
+ throw new Error("candidate executable owner changed or is ambiguous after installation");
268
+ }
269
+ });
270
+ const mcpStarted = Date.now();
271
+ const mcpSession = await startMcpSession(options.command, repository, env, remainingMs);
272
+ mcp = mcpSession;
273
+ record.phaseTimingsMs.mcp = Date.now() - mcpStarted;
274
+ elapsedPhase(record, "init", () => {
275
+ run(options.command, ["init", "--scope", "cli-only", "--json"], {
276
+ cwd: repository,
277
+ env,
278
+ remainingMs,
279
+ });
280
+ });
281
+ elapsedPhase(record, "deep-status", () => {
282
+ const status = parseJson(run(options.command, ["status", "--deep", "--json"], {
283
+ cwd: repository,
284
+ env,
285
+ remainingMs,
286
+ }), "deep status");
287
+ if (status.status !== "healthy")
288
+ throw new Error(`deep status is ${String(status.status)}`);
289
+ if (status.semanticReadiness !== "ready")
290
+ throw new Error(`semantic readiness is ${String(status.semanticReadiness ?? "missing")}`);
291
+ const coverage = status.coverage;
292
+ if (coverage?.percent !== 100)
293
+ throw new Error("graph coverage is partial");
294
+ });
295
+ await elapsedAsyncPhase(record, "assertions", async () => {
296
+ const batch = await mcpSession.call({
297
+ operation: "query_batch",
298
+ queries: manifest.assertions.map(({ pattern, symbol }) => ({
299
+ pattern,
300
+ symbol,
301
+ })),
302
+ repoPath: repository,
303
+ });
304
+ const batchResults = batch.results;
305
+ if (!Array.isArray(batchResults) || batchResults.length !== manifest.assertions.length)
306
+ throw new Error("candidate query batch returned incomplete results");
307
+ for (const assertion of manifest.assertions) {
308
+ const output = batchResults.find((row) => row.pattern === assertion.pattern && row.symbol === assertion.symbol)?.output;
309
+ if (!output)
310
+ throw new Error(`${assertion.pattern} ${assertion.symbol} was omitted from query batch`);
311
+ const results = output.results;
312
+ if (!Array.isArray(results) || results.length === 0)
313
+ throw new Error(`${assertion.pattern} ${assertion.symbol} returned empty results`);
314
+ const match = results.find((row) => Object.entries(assertion.expected).every(([key, value]) => row[key] === value));
315
+ if (!match || ("symbol" in assertion.expected && typeof match.identity !== "string"))
316
+ throw new Error(`${assertion.symbol} returned incorrect identity, source location, edge kind, or evidence`);
317
+ for (const language of assertion.coverage)
318
+ record.coverage[language] = "passed";
319
+ }
320
+ for (const language of manifest.languages)
321
+ if (record.coverage[language] !== "passed")
322
+ throw new Error(`${language} fixture coverage was not explicitly verified`);
323
+ });
324
+ record.finishedAt = new Date().toISOString();
325
+ return { status: "passed", record };
326
+ }
327
+ catch (error) {
328
+ const message = error instanceof Error ? error.message : String(error);
329
+ record.finishedAt = new Date().toISOString();
330
+ if (message.includes("deadline exceeded"))
331
+ record.timeoutCause = message;
332
+ for (const language of manifest.languages) {
333
+ if (record.coverage[language] !== "passed")
334
+ record.coverage[language] = "failed";
335
+ }
336
+ return {
337
+ status: message.includes("deadline exceeded") ? "timed-out" : "failed",
338
+ record,
339
+ error: message,
340
+ };
341
+ }
342
+ finally {
343
+ try {
344
+ await mcp?.close();
345
+ }
346
+ catch {
347
+ // Cleanup cannot turn a structured verification failure into an
348
+ // unjournaled thrown exception. The updater owns recovery decisions.
349
+ }
350
+ try {
351
+ fs.rmSync(root, { recursive: true, force: true });
352
+ }
353
+ catch {
354
+ // A still-closing candidate may temporarily hold fixture handles on
355
+ // Windows. Verification truth takes precedence over temp cleanup.
356
+ }
357
+ }
358
+ }