knodin 0.7.5 → 0.8.2

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 (75) hide show
  1. package/README.md +18 -3
  2. package/benchmarks/competitors/SYNTHESIS.md +66 -0
  3. package/dist/bin/cli.js +371 -66
  4. package/dist/bin/launcher.js +16 -1
  5. package/dist/src/agent-integration.js +82 -16
  6. package/dist/src/artifact-refresh.js +2 -1
  7. package/dist/src/cli-args.js +19 -1
  8. package/dist/src/cli-model.js +28 -2
  9. package/dist/src/codeflow-replay.js +2 -1
  10. package/dist/src/compare.js +39 -0
  11. package/dist/src/competitive-constraints.js +2 -1
  12. package/dist/src/competitive-runner.js +4 -4
  13. package/dist/src/context-export.js +3 -2
  14. package/dist/src/context.js +1 -1
  15. package/dist/src/deterministic-random.js +34 -0
  16. package/dist/src/diagnostics-write-helper.js +473 -0
  17. package/dist/src/diagnostics.js +1160 -133
  18. package/dist/src/doctor.js +3 -1
  19. package/dist/src/engine/ann-hnsw.js +2 -12
  20. package/dist/src/engine/file-walker.js +8 -2
  21. package/dist/src/engine/git-history.js +12 -12
  22. package/dist/src/engine/index.js +1174 -313
  23. package/dist/src/engine/sarif-import.js +341 -0
  24. package/dist/src/engine/scip-import.js +28 -13
  25. package/dist/src/engine/source-policy.js +16 -0
  26. package/dist/src/engine/state-paths.js +175 -0
  27. package/dist/src/execution-profile.js +15 -10
  28. package/dist/src/failure-diagnosis.js +7 -1
  29. package/dist/src/graph-layout.js +173 -0
  30. package/dist/src/index-activity.js +2 -1
  31. package/dist/src/init.js +86 -45
  32. package/dist/src/lifecycle-health.js +41 -9
  33. package/dist/src/mcp-graph-worker.js +69 -0
  34. package/dist/src/mcp-reliability.js +154 -0
  35. package/dist/src/mcp-worker-supervisor.js +350 -0
  36. package/dist/src/mirror.js +290 -0
  37. package/dist/src/node-runtime.js +157 -0
  38. package/dist/src/output-compression.js +2 -1
  39. package/dist/src/output-telemetry.js +16 -11
  40. package/dist/src/progressive-evidence.js +30 -26
  41. package/dist/src/pure-compression-cli.js +4 -3
  42. package/dist/src/relationship-adapters.js +15 -8
  43. package/dist/src/release-preflight.js +13 -10
  44. package/dist/src/repair-lease.js +85 -0
  45. package/dist/src/repository-init-process.js +13 -9
  46. package/dist/src/repository-management.js +34 -4
  47. package/dist/src/response-budget.js +8 -6
  48. package/dist/src/server.js +80 -35
  49. package/dist/src/structural-fast-path.js +16 -10
  50. package/dist/src/structural-snapshot.js +6 -2
  51. package/dist/src/system-config.js +25 -2
  52. package/dist/src/tools/knodin-tools.js +142 -31
  53. package/dist/src/update-ceremony.js +9 -5
  54. package/dist/src/update-trust.js +5 -4
  55. package/dist/src/visualization.js +372 -19
  56. package/dist/src/worktree-lifecycle.js +5 -2
  57. package/docs/BEHAVIORAL-CONTRACT.md +72 -0
  58. package/docs/CLI.md +20 -1
  59. package/docs/COMPARISON.md +403 -0
  60. package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
  61. package/docs/DIAGNOSTICS.md +46 -11
  62. package/docs/HANDOFF.md +180 -0
  63. package/docs/INSTALLATION.md +21 -2
  64. package/docs/MCP.md +59 -8
  65. package/docs/PT-ACCESS-RECOMMENDATION.md +5 -7
  66. package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
  67. package/docs/SCIP-IMPORT.md +5 -0
  68. package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
  69. package/docs/releases/0.5.1.md +4 -4
  70. package/docs/releases/0.8.0.md +74 -0
  71. package/docs/releases/0.8.2.md +34 -0
  72. package/package.json +17 -4
  73. package/roadmap/competitive-roadmap.md +3801 -0
  74. package/schemas/release-attestation-v1.schema.json +1 -1
  75. package/schemas/support-bundle-v2.schema.json +212 -0
@@ -1,17 +1,62 @@
1
+ import child_process from "node:child_process";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
5
7
  import zlib from "node:zlib";
8
+ import { compareBytes } from "./compare.js";
6
9
  const CONFIG_PATH = ".knodin/diagnostics/config.json";
7
10
  const JOURNAL_PATH = ".knodin/diagnostics/events.jsonl";
8
11
  const JOURNAL_LOCK_PATH = ".knodin/diagnostics/events.lock";
12
+ /**
13
+ * How long a helper round trip may take before it is treated as wedged.
14
+ *
15
+ * The helper is spawned as compiled JavaScript in a release install, where 2s is
16
+ * generous for start-up plus a pipe exchange. Running from source it is spawned
17
+ * as TypeScript through tsx, which must boot and transpile before it can answer
18
+ * — seconds of legitimate start-up that has nothing to do with a hung helper,
19
+ * and more again under a loaded machine or coverage instrumentation. Budgeting
20
+ * per runtime keeps the shipped bound at 2s instead of making production wait
21
+ * for a cost it never pays.
22
+ */
23
+ const HELPER_DEADLINE_MS = import.meta.url.endsWith(".ts") ? 30_000 : 2_000;
24
+ /** Lock-free markers for records that could not be written. See DROPPED_PATH use. */
25
+ const DROPPED_PATH = ".knodin/diagnostics/dropped";
26
+ /**
27
+ * Poll interval while contending for the journal lock.
28
+ *
29
+ * The retry budget below is counted in ATTEMPTS, not wall-clock, and that is
30
+ * deliberate: an attempt is not just this sleep but several stat/read/parse
31
+ * operations, so each one costs more precisely when the lock is contended. The
32
+ * budget therefore stretches automatically under load, which is when patience is
33
+ * needed. Replacing it with a fixed deadline, or spacing retries with
34
+ * exponential backoff, both measurably LOSE more records — a freed lock must be
35
+ * claimed immediately, and fewer attempts means fewer chances.
36
+ */
37
+ const JOURNAL_LOCK_POLL_MS = 5;
38
+ /**
39
+ * Retry ceiling for the acquisition loop, which is 0-based and throws at
40
+ * `attempt >= JOURNAL_LOCK_ATTEMPTS`. So this is the index of the last attempt,
41
+ * not a count: acquisition is tried 41 times and waits 40 times between them.
42
+ * Stated because "attempts before giving up" reads as a count and invites an
43
+ * off-by-one the next time someone tunes it.
44
+ */
45
+ const JOURNAL_LOCK_ATTEMPTS = 40;
46
+ const PREVIEW_STORES = [
47
+ ".knodin/diagnostics/previews-a.json",
48
+ ".knodin/diagnostics/previews-b.json",
49
+ ];
9
50
  const DEFAULT_RETENTION_DAYS = 14;
10
51
  const MAX_RETENTION_DAYS = 365;
11
52
  const MAX_RECORDS = 500;
12
- const MAX_LOG_BYTES = 64 * 1024;
13
- const MAX_BUNDLE_BYTES = 20 * 1024 * 1024;
53
+ const MAX_MCP_LOG_BYTES = 256 * 1024;
54
+ const MAX_BUNDLE_BYTES = 512 * 1024;
55
+ const MAX_BUNDLE_RECORDS = 32;
14
56
  const MAX_JOURNAL_BYTES = 2 * 1024 * 1024;
57
+ const MAX_PREVIEW_COUNT = 8;
58
+ const MAX_PREVIEW_AGE_MS = 7 * 24 * 60 * 60 * 1000;
59
+ const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
15
60
  const OPERATIONS = new Set([
16
61
  "init",
17
62
  "configure",
@@ -39,6 +84,7 @@ const OPERATIONS = new Set([
39
84
  "system",
40
85
  "repos",
41
86
  "repositories",
87
+ "remote",
42
88
  "update",
43
89
  "docs",
44
90
  "unknown",
@@ -63,37 +109,362 @@ function containedPath(repoPath, requested, purpose) {
63
109
  }
64
110
  return { repo, target };
65
111
  }
66
- function atomicPrivateWrite(target, data) {
67
- fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
68
- const temporary = `${target}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString("hex")}.tmp`;
69
- fs.writeFileSync(temporary, data, { mode: 0o600, flag: "wx" });
70
- fs.renameSync(temporary, target);
112
+ function helperPipeDescriptor(stream) {
113
+ const descriptor = stream._handle?.fd;
114
+ if (typeof descriptor !== "number")
115
+ throw new Error("knodin diagnostics helper pipe unavailable");
116
+ return descriptor;
117
+ }
118
+ function waitForPipe(deadline) {
119
+ if (Date.now() >= deadline)
120
+ throw new Error("knodin diagnostics helper deadline exceeded");
121
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2);
122
+ }
123
+ function writeHelperPayload(descriptor, payload, deadline) {
124
+ let offset = 0;
125
+ while (offset < payload.length) {
126
+ try {
127
+ offset += fs.writeSync(descriptor, payload, offset, payload.length - offset);
128
+ }
129
+ catch (error) {
130
+ if (error.code === "EAGAIN") {
131
+ waitForPipe(deadline);
132
+ continue;
133
+ }
134
+ throw error;
135
+ }
136
+ }
137
+ }
138
+ function readPipeMessage(session, deadline) {
139
+ for (;;) {
140
+ const newline = session.outputBuffer.indexOf(10);
141
+ if (newline >= 0) {
142
+ const line = session.outputBuffer.subarray(0, newline);
143
+ session.outputBuffer = session.outputBuffer.subarray(newline + 1);
144
+ if (line.byteLength > session.protocolLimit)
145
+ throw new Error("knodin diagnostics helper protocol overflow");
146
+ try {
147
+ return JSON.parse(line.toString("utf8"));
148
+ }
149
+ catch {
150
+ throw new Error("knodin diagnostics helper emitted malformed protocol data");
151
+ }
152
+ }
153
+ try {
154
+ const chunk = Buffer.allocUnsafe(4096);
155
+ const count = fs.readSync(session.output, chunk, 0, chunk.length, null);
156
+ if (count === 0)
157
+ throw new Error("knodin diagnostics helper closed its protocol pipe");
158
+ session.outputBuffer = Buffer.concat([session.outputBuffer, chunk.subarray(0, count)]);
159
+ if (session.outputBuffer.byteLength > session.protocolLimit)
160
+ throw new Error("knodin diagnostics helper protocol overflow");
161
+ }
162
+ catch (error) {
163
+ if (error.code !== "EAGAIN")
164
+ throw error;
165
+ waitForPipe(deadline);
166
+ }
167
+ }
168
+ }
169
+ const appendSessions = new Map();
170
+ function appendWithSession(session, payload, deadline) {
171
+ if (session.idle)
172
+ clearTimeout(session.idle);
173
+ const sequence = ++session.sequence;
174
+ const frame = Buffer.allocUnsafe(8 + payload.byteLength);
175
+ frame.writeUInt32BE(sequence, 0);
176
+ frame.writeUInt32BE(payload.byteLength, 4);
177
+ payload.copy(frame, 8);
178
+ writeHelperPayload(session.input, frame, deadline);
179
+ const receipt = readPipeMessage(session, deadline);
180
+ if (receipt.type === "ERROR")
181
+ throw new Error(typeof receipt.message === "string" ? receipt.message : "diagnostics helper failed");
182
+ if (receipt.version !== 1 ||
183
+ receipt.type !== "RECEIPT" ||
184
+ receipt.dev !== session.ready.dev ||
185
+ receipt.ino !== session.ready.ino ||
186
+ receipt.nonce !== session.nonce ||
187
+ receipt.sequence !== sequence ||
188
+ receipt.bytes !== payload.byteLength)
189
+ throw new Error("knodin diagnostics helper receipt mismatch");
190
+ session.idle = setTimeout(() => {
191
+ if (appendSessions.get(session.key) !== session)
192
+ return;
193
+ appendSessions.delete(session.key);
194
+ session.child.kill("SIGTERM");
195
+ session.child.stdin?.destroy();
196
+ session.child.stdout?.destroy();
197
+ }, 750);
198
+ session.idle.unref();
199
+ return payload.byteLength;
200
+ }
201
+ export function repositoryBoundWrite(repo, target, data, mode, maxBytes, maxAgeMs = 0, onBound) {
202
+ const requestedRepo = path.resolve(repo);
203
+ const requestedTarget = path.resolve(target);
204
+ if (requestedTarget === requestedRepo ||
205
+ !requestedTarget.startsWith(`${requestedRepo}${path.sep}`))
206
+ throw new Error("knodin repository-bound write escaped the repository");
207
+ const boundRepo = fs.realpathSync(repo);
208
+ const normalizedTarget = path.resolve(boundRepo, path.relative(requestedRepo, requestedTarget));
209
+ const relativeTarget = path.relative(boundRepo, normalizedTarget);
210
+ const isJournalLock = relativeTarget === JOURNAL_LOCK_PATH;
211
+ const root = fs.statSync(boundRepo);
212
+ containedPath(boundRepo, normalizedTarget, "write");
213
+ if (mode === "append") {
214
+ try {
215
+ const observed = fs.lstatSync(normalizedTarget);
216
+ if (!observed.isFile() || observed.isSymbolicLink() || observed.nlink !== 1)
217
+ throw new Error("knodin repository-bound append target is not a private file");
218
+ }
219
+ catch (error) {
220
+ if (error.code !== "ENOENT")
221
+ throw error;
222
+ }
223
+ }
224
+ const payload = Buffer.isBuffer(data) ? data : Buffer.from(data);
225
+ if (payload.byteLength > maxBytes)
226
+ throw new Error("knodin diagnostics write exceeds its bound");
227
+ const nonce = crypto.randomBytes(16).toString("hex");
228
+ const appendKey = `${root.dev}:${root.ino}:${normalizedTarget}`;
229
+ if (mode === "append") {
230
+ const existing = appendSessions.get(appendKey);
231
+ if (existing?.child.exitCode === null) {
232
+ try {
233
+ return appendWithSession(existing, payload, Date.now() + HELPER_DEADLINE_MS);
234
+ }
235
+ catch (error) {
236
+ existing.child.kill("SIGKILL");
237
+ appendSessions.delete(appendKey);
238
+ throw error;
239
+ }
240
+ }
241
+ appendSessions.delete(appendKey);
242
+ }
243
+ const extension = import.meta.url.endsWith(".ts") ? "ts" : "js";
244
+ const entry = fileURLToPath(new URL(extension === "ts" ? "./diagnostics-write-helper.ts" : "../src/diagnostics-write-helper.js", import.meta.url));
245
+ const runtimeArgs = extension === "ts" ? ["--import", fileURLToPath(import.meta.resolve("tsx"))] : [];
246
+ if (!isJournalLock &&
247
+ extension === "ts" &&
248
+ process.env.KNODIN_DIAGNOSTICS_TEST_FIXTURES === "1" &&
249
+ process.env.KNODIN_DIAGNOSTICS_TEST_PRESPAWN_SWAP_TO) {
250
+ const parentPath = path.dirname(target);
251
+ fs.renameSync(parentPath, `${parentPath}-displaced`);
252
+ fs.symlinkSync(process.env.KNODIN_DIAGNOSTICS_TEST_PRESPAWN_SWAP_TO, parentPath);
253
+ }
254
+ if (!isJournalLock &&
255
+ extension === "ts" &&
256
+ process.env.KNODIN_DIAGNOSTICS_TEST_FIXTURES === "1" &&
257
+ process.env.KNODIN_DIAGNOSTICS_TEST_PREPARENT_SWAP_TO) {
258
+ fs.renameSync(boundRepo, `${boundRepo}-displaced`);
259
+ fs.symlinkSync(process.env.KNODIN_DIAGNOSTICS_TEST_PREPARENT_SWAP_TO, boundRepo);
260
+ }
261
+ const faultFixturesEnabled = extension === "ts" && process.env.KNODIN_DIAGNOSTICS_TEST_FIXTURES === "1";
262
+ const componentSwapTarget = isJournalLock
263
+ ? (process.env.KNODIN_DIAGNOSTICS_TEST_LOCK_COMPONENT_SWAP_TO ?? "")
264
+ : (process.env.KNODIN_DIAGNOSTICS_TEST_COMPONENT_SWAP_TO ?? "");
265
+ const child = child_process.spawn(process.execPath, [
266
+ ...runtimeArgs,
267
+ entry,
268
+ String(root.dev),
269
+ String(root.ino),
270
+ relativeTarget,
271
+ mode,
272
+ String(maxBytes),
273
+ String(payload.byteLength),
274
+ nonce,
275
+ String(maxAgeMs),
276
+ extension === "ts" &&
277
+ process.env.KNODIN_DIAGNOSTICS_TEST_FIXTURES === "1" &&
278
+ (!isJournalLock ||
279
+ Boolean(process.env.KNODIN_DIAGNOSTICS_TEST_LOCK_COMPONENT_SWAP_TO) ||
280
+ process.env.KNODIN_DIAGNOSTICS_TEST_LOCK_SUCCESSOR === "1" ||
281
+ Boolean(process.env.KNODIN_DIAGNOSTICS_TEST_POST_READY_PARENT_SWAP_TO) ||
282
+ Boolean(process.env.KNODIN_DIAGNOSTICS_TEST_POST_READY_ROOT_SWAP_TO) ||
283
+ Boolean(process.env.KNODIN_DIAGNOSTICS_TEST_POST_PUBLISH_TARGET_SWAP_TO))
284
+ ? "enabled"
285
+ : "",
286
+ faultFixturesEnabled ? componentSwapTarget : "",
287
+ extension === "ts" && process.env.KNODIN_DIAGNOSTICS_TEST_FIXTURES === "1" && !isJournalLock
288
+ ? (process.env.KNODIN_DIAGNOSTICS_TEST_DELETE_SWAP_TO ?? "")
289
+ : "",
290
+ ], { cwd: boundRepo, stdio: ["pipe", "pipe", "ignore"] });
291
+ if (mode === "append") {
292
+ child.unref();
293
+ child.stdin?.unref?.();
294
+ child.stdout?.unref?.();
295
+ }
296
+ const deadline = Date.now() + HELPER_DEADLINE_MS;
297
+ try {
298
+ if (!child.stdin)
299
+ throw new Error("knodin diagnostics helper protocol unavailable");
300
+ const input = helperPipeDescriptor(child.stdin);
301
+ if (!child.stdout)
302
+ throw new Error("knodin diagnostics helper error pipe unavailable");
303
+ const output = helperPipeDescriptor(child.stdout);
304
+ const bootstrap = {
305
+ child,
306
+ input,
307
+ output,
308
+ outputBuffer: Buffer.alloc(0),
309
+ protocolLimit: mode === "read" ? Math.ceil((maxBytes * 4) / 3) + 4096 : 4096,
310
+ key: appendKey,
311
+ nonce,
312
+ ready: {},
313
+ sequence: 0,
314
+ };
315
+ const ready = readPipeMessage(bootstrap, deadline);
316
+ if (ready.type === "ERROR")
317
+ throw new Error(typeof ready.message === "string" ? ready.message : "diagnostics helper failed");
318
+ if (ready.version !== 1 ||
319
+ ready.type !== "READY" ||
320
+ ready.rootDev !== root.dev ||
321
+ ready.rootIno !== root.ino ||
322
+ ready.nonce !== nonce)
323
+ throw new Error("knodin diagnostics helper READY identity mismatch");
324
+ if (mode === "append") {
325
+ const session = {
326
+ ...bootstrap,
327
+ outputBuffer: bootstrap.outputBuffer,
328
+ ready,
329
+ };
330
+ appendSessions.set(appendKey, session);
331
+ child.once("exit", () => {
332
+ if (appendSessions.get(appendKey) === session)
333
+ appendSessions.delete(appendKey);
334
+ child.stdin?.destroy();
335
+ child.stdout?.destroy();
336
+ });
337
+ return appendWithSession(session, payload, deadline);
338
+ }
339
+ writeHelperPayload(input, payload, deadline);
340
+ const receipt = readPipeMessage(bootstrap, deadline);
341
+ if (receipt.type === "ERROR")
342
+ throw new Error(typeof receipt.message === "string" ? receipt.message : "diagnostics helper failed");
343
+ if (receipt.version !== 1 ||
344
+ receipt.type !== "RECEIPT" ||
345
+ receipt.dev !== ready.dev ||
346
+ receipt.ino !== ready.ino ||
347
+ receipt.nonce !== nonce ||
348
+ (mode !== "read" && receipt.bytes !== payload.byteLength))
349
+ throw new Error("knodin diagnostics helper receipt mismatch");
350
+ if (mode === "create" || mode === "replace" || mode === "read") {
351
+ if (!Number.isSafeInteger(receipt.targetDev) || !Number.isSafeInteger(receipt.targetIno))
352
+ throw new Error("knodin diagnostics helper target identity mismatch");
353
+ onBound?.({ dev: receipt.targetDev, ino: receipt.targetIno });
354
+ }
355
+ child.stdin.end();
356
+ if (mode === "read") {
357
+ if (typeof receipt.data !== "string" || typeof receipt.bytes !== "number")
358
+ throw new Error("knodin diagnostics helper read receipt mismatch");
359
+ const content = Buffer.from(receipt.data, "base64");
360
+ if (content.byteLength !== receipt.bytes || content.byteLength > maxBytes)
361
+ throw new Error("knodin diagnostics helper read bound mismatch");
362
+ return content;
363
+ }
364
+ return mode === "delete" && typeof receipt.deletedBytes === "number"
365
+ ? receipt.deletedBytes
366
+ : payload.byteLength;
367
+ }
368
+ finally {
369
+ if (mode !== "append" && child.exitCode === null)
370
+ child.kill("SIGKILL");
371
+ }
372
+ }
373
+ function privateDescriptorReplace(repo, target, data) {
374
+ repositoryBoundWrite(repo, target, data, "replace", MAX_PREVIEW_BYTES / 2);
375
+ }
376
+ function atomicPrivateWrite(repo, target, data) {
377
+ repositoryBoundWrite(repo, target, data, "replace", MAX_JOURNAL_BYTES);
378
+ }
379
+ function privateCreate(repo, target, data) {
380
+ repositoryBoundWrite(repo, target, data, "create", MAX_BUNDLE_BYTES);
381
+ }
382
+ function privateDelete(repo, target, token, kind = "file") {
383
+ let observed;
384
+ try {
385
+ observed = fs.lstatSync(target);
386
+ }
387
+ catch (error) {
388
+ if (error.code === "ENOENT")
389
+ return null;
390
+ throw error;
391
+ }
392
+ if (observed.isSymbolicLink() ||
393
+ (kind === "file" ? !observed.isFile() || observed.nlink !== 1 : !observed.isDirectory()))
394
+ throw new Error("knodin diagnostics delete: target is not a private file");
395
+ return repositoryBoundWrite(repo, target, JSON.stringify({ dev: observed.dev, ino: observed.ino, token, kind }), "delete", 4096);
396
+ }
397
+ function readPrivateFile(repo, target, maxBytes, purpose, onOpen) {
398
+ try {
399
+ return repositoryBoundWrite(repo, target, Buffer.alloc(0), "read", maxBytes, 0, onOpen);
400
+ }
401
+ catch (error) {
402
+ throw new Error(`knodin diagnostics ${purpose}: ${error.message}`);
403
+ }
71
404
  }
72
405
  function withJournalLock(repo, run) {
73
406
  const { target: lock } = containedPath(repo, JOURNAL_LOCK_PATH, "record");
74
- fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
407
+ const ownerToken = crypto.randomBytes(16).toString("hex");
408
+ let ownedIdentity;
75
409
  for (let attempt = 0;; attempt++) {
76
410
  try {
77
- fs.mkdirSync(lock, { mode: 0o700 });
411
+ repositoryBoundWrite(repo, lock, `${JSON.stringify({ token: ownerToken, acquiredAt: new Date().toISOString() })}\n`, "create", 4096, 0, (identity) => (ownedIdentity = identity));
412
+ const observed = fs.lstatSync(lock);
413
+ let readIdentity;
414
+ const owner = JSON.parse(readPrivateFile(repo, lock, 4096, "journal lock", (identity) => {
415
+ readIdentity = identity;
416
+ }).toString("utf8"));
417
+ if (!observed.isFile() ||
418
+ observed.nlink !== 1 ||
419
+ !ownedIdentity ||
420
+ !readIdentity ||
421
+ observed.dev !== ownedIdentity.dev ||
422
+ observed.ino !== ownedIdentity.ino ||
423
+ readIdentity.dev !== ownedIdentity.dev ||
424
+ readIdentity.ino !== ownedIdentity.ino ||
425
+ owner.token !== ownerToken)
426
+ throw new Error("knodin diagnostics record: unsafe journal lock");
78
427
  break;
79
428
  }
80
429
  catch (error) {
81
- if (error.code !== "EEXIST")
430
+ if (import.meta.url.endsWith(".ts") &&
431
+ process.env.KNODIN_DIAGNOSTICS_TEST_FIXTURES === "1" &&
432
+ process.env.KNODIN_DIAGNOSTICS_TEST_LOCK_COMPONENT_SWAP_TO)
82
433
  throw error;
83
434
  try {
84
- if (Date.now() - fs.statSync(lock).mtimeMs > 30_000) {
85
- fs.rmdirSync(lock);
86
- continue;
435
+ const first = fs.lstatSync(lock);
436
+ if (first.isDirectory() && Date.now() - first.mtimeMs > 30_000) {
437
+ const legacyOwnerPath = containedPath(repo, path.join(JOURNAL_LOCK_PATH, "owner.json"), "legacy journal lock").target;
438
+ const legacyOwner = JSON.parse(readPrivateFile(repo, legacyOwnerPath, 4096, "legacy journal lock").toString("utf8"));
439
+ if (typeof legacyOwner.token === "string" &&
440
+ /^[a-f0-9]{32}$/.test(legacyOwner.token) &&
441
+ typeof legacyOwner.acquiredAt === "string" &&
442
+ Date.now() - Date.parse(legacyOwner.acquiredAt) > 30_000) {
443
+ privateDelete(repo, lock, legacyOwner.token, "directory");
444
+ continue;
445
+ }
446
+ }
447
+ if (!first.isFile() || first.nlink !== 1)
448
+ throw error;
449
+ const owner = JSON.parse(readPrivateFile(repo, lock, 4096, "journal lock").toString("utf8"));
450
+ const acquired = typeof owner.acquiredAt === "string" ? Date.parse(owner.acquiredAt) : 0;
451
+ if (typeof owner.token === "string" &&
452
+ /^[a-f0-9]{32}$/.test(owner.token) &&
453
+ Number.isFinite(acquired) &&
454
+ Date.now() - acquired > 30_000) {
455
+ const second = fs.lstatSync(lock);
456
+ if (first.dev === second.dev && first.ino === second.ino) {
457
+ privateDelete(repo, lock, owner.token);
458
+ continue;
459
+ }
87
460
  }
88
461
  }
89
- catch (inspectionError) {
90
- if (inspectionError.code === "ENOENT")
91
- continue;
92
- throw inspectionError;
462
+ catch {
463
+ // A malformed or partially-created owner is busy until its bounded retry expires.
93
464
  }
94
- if (attempt >= 100)
465
+ if (attempt >= JOURNAL_LOCK_ATTEMPTS)
95
466
  throw new Error("knodin diagnostics record: journal is busy");
96
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
467
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, JOURNAL_LOCK_POLL_MS);
97
468
  }
98
469
  }
99
470
  try {
@@ -101,7 +472,7 @@ function withJournalLock(repo, run) {
101
472
  }
102
473
  finally {
103
474
  try {
104
- fs.rmdirSync(lock);
475
+ privateDelete(repo, lock, ownerToken);
105
476
  }
106
477
  catch {
107
478
  // Recording is best-effort; stale-lock recovery handles interrupted cleanup.
@@ -114,11 +485,11 @@ function validRetention(days) {
114
485
  return days;
115
486
  }
116
487
  function readConfig(repoPath) {
117
- const { target } = containedPath(repoPath, CONFIG_PATH, "status");
488
+ const { repo, target } = containedPath(repoPath, CONFIG_PATH, "status");
118
489
  if (!fs.existsSync(target))
119
490
  return null;
120
491
  try {
121
- const value = JSON.parse(fs.readFileSync(target, "utf8"));
492
+ const value = JSON.parse(readPrivateFile(repo, target, 64 * 1024, "config").toString("utf8"));
122
493
  if (value.schemaVersion !== 1 || value.enabled !== true)
123
494
  return null;
124
495
  validRetention(value.retentionDays);
@@ -156,7 +527,7 @@ function scrubText(raw, repo) {
156
527
  });
157
528
  };
158
529
  for (const exact of [repo, os.homedir()].filter(Boolean).sort((a, b) => b.length - a.length))
159
- replace(new RegExp(exact.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "<path>");
530
+ replace(new RegExp(exact.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`), "g"), "<path>");
160
531
  replace(/\b(?:ghp|github_pat|sk|xox[baprs])-[-A-Za-z0-9_]{10,}\b/g, "<secret>");
161
532
  replace(/\b(?:password|passwd|token|secret|api[_-]?key|authorization)\s*[=:]\s*[^\s,;]+/gi, "$1=<secret>");
162
533
  replace(/\b[A-Z]:\\(?:[^\s<>:"|?*]+\\)*[^\s<>:"|?*]*/g, "<path>");
@@ -171,46 +542,63 @@ function sanitizeStack(error, repo) {
171
542
  .slice(1, 9)
172
543
  .map((line) => `at ${line.includes(repo) ? "<repository>" : "<runtime>"}`);
173
544
  }
174
- function sanitizeUnknown(value, repo, state) {
175
- if (typeof value === "string") {
176
- const scrubbed = scrubText(value, repo);
177
- state.redactions += scrubbed.redactions;
178
- return scrubbed.value;
179
- }
180
- if (Array.isArray(value))
181
- return value.slice(0, 500).map((item) => sanitizeUnknown(item, repo, state));
182
- if (value && typeof value === "object") {
183
- const output = {};
184
- for (const [key, item] of Object.entries(value).slice(0, 500)) {
185
- const safeKey = /[\\/]|\.[A-Za-z0-9]{1,12}$/.test(key) ? "<path-key>" : key;
186
- if (safeKey !== key)
187
- state.redactions++;
188
- if (/^(?:source|content|query|command|arguments?|env(?:ironment)?|remote)$/i.test(key)) {
189
- output[safeKey] = "<omitted>";
190
- state.redactions++;
191
- }
192
- else
193
- output[safeKey] = sanitizeUnknown(item, repo, state);
194
- }
195
- return output;
196
- }
197
- return value;
198
- }
199
545
  function readEvents(repoPath, retentionDays, since) {
200
546
  const { target } = containedPath(repoPath, JOURNAL_PATH, "read");
201
547
  if (!fs.existsSync(target))
202
548
  return [];
203
- if (fs.statSync(target).size > MAX_JOURNAL_BYTES)
204
- throw new Error("knodin diagnostics read: journal exceeds the 2 MiB safety limit");
205
549
  const cutoff = Math.max(Date.now() - validRetention(retentionDays) * 86_400_000, since?.getTime() ?? Number.NEGATIVE_INFINITY);
206
- return fs
207
- .readFileSync(target, "utf8")
550
+ let journal;
551
+ try {
552
+ journal = readPrivateFile(fs.realpathSync(repoPath), target, MAX_JOURNAL_BYTES, "read");
553
+ }
554
+ catch (error) {
555
+ if (error instanceof Error && /exceeds its safety limit/.test(error.message))
556
+ throw new Error("knodin diagnostics read: journal exceeds the 2 MiB safety limit");
557
+ throw error;
558
+ }
559
+ return journal
560
+ .toString("utf8")
208
561
  .split(/\r?\n/)
209
562
  .filter(Boolean)
210
563
  .flatMap((line) => {
211
564
  try {
212
- const record = JSON.parse(line);
213
- return record.schemaVersion === 1 && Date.parse(record.at) >= cutoff ? [record] : [];
565
+ const raw = JSON.parse(line);
566
+ const error = raw.error;
567
+ if (raw.schemaVersion !== 1 ||
568
+ typeof raw.at !== "string" ||
569
+ !Number.isFinite(Date.parse(raw.at)) ||
570
+ Date.parse(raw.at) < cutoff ||
571
+ typeof raw.correlationId !== "string" ||
572
+ !/^[a-f0-9]{16}$/.test(raw.correlationId) ||
573
+ !["cli", "mcp", "lifecycle"].includes(raw.surface) ||
574
+ typeof raw.operation !== "string" ||
575
+ !OPERATIONS.has(raw.operation) ||
576
+ typeof raw.phase !== "string" ||
577
+ safeLabel(raw.phase, "") !== raw.phase ||
578
+ !error ||
579
+ typeof error.name !== "string" ||
580
+ safeLabel(error.name, "") !== error.name ||
581
+ (error.code !== null && errorCode(error) === null) ||
582
+ typeof error.messageFingerprint !== "string" ||
583
+ !/^[a-f0-9]{16}$/.test(error.messageFingerprint) ||
584
+ !Array.isArray(error.stack) ||
585
+ error.stack.some((line) => line !== "at <repository>" && line !== "at <runtime>"))
586
+ return [];
587
+ const record = {
588
+ schemaVersion: 1,
589
+ at: raw.at,
590
+ correlationId: raw.correlationId,
591
+ surface: raw.surface,
592
+ operation: raw.operation,
593
+ phase: raw.phase,
594
+ error: {
595
+ name: error.name,
596
+ code: error.code,
597
+ messageFingerprint: error.messageFingerprint,
598
+ stack: error.stack.slice(0, 8),
599
+ },
600
+ };
601
+ return [record];
214
602
  }
215
603
  catch {
216
604
  return [];
@@ -230,15 +618,14 @@ export function enableDiagnostics(repoPath, retentionDays = DEFAULT_RETENTION_DA
230
618
  generation: (previous?.generation ?? 0) + 1,
231
619
  sessionId: crypto.randomBytes(16).toString("hex"),
232
620
  };
233
- atomicPrivateWrite(target, `${JSON.stringify(config, null, 2)}\n`);
621
+ atomicPrivateWrite(repo, target, `${JSON.stringify(config, null, 2)}\n`);
234
622
  });
235
623
  return { ...diagnosticsStatus(repo), message: "Local diagnostics enabled; nothing is uploaded." };
236
624
  }
237
625
  export function disableDiagnostics(repoPath) {
238
626
  const { repo, target } = containedPath(repoPath, CONFIG_PATH, "disable");
239
627
  withJournalLock(repo, () => {
240
- if (fs.existsSync(target))
241
- fs.unlinkSync(target);
628
+ privateDelete(repo, target);
242
629
  });
243
630
  return {
244
631
  ...diagnosticsStatus(repo),
@@ -269,6 +656,18 @@ export function diagnosticsStatus(repoPath) {
269
656
  uploaded: false,
270
657
  retentionDays,
271
658
  records: events.length,
659
+ /**
660
+ * Failures that could not be written to the journal at all — for ANY
661
+ * reason. Lock contention is the expected one, but the marker is written
662
+ * from a bare `catch` around the whole write, so a read error, a malformed
663
+ * journal, or a full disk lands here too. Deliberately broad: the point is
664
+ * that nothing is lost silently, and narrowing it to lock-busy would let
665
+ * the other cases vanish exactly as before.
666
+ *
667
+ * Non-zero means `records` is an undercount — without this the two are
668
+ * indistinguishable.
669
+ */
670
+ droppedRecords: droppedRecordCount(repoPath),
272
671
  oldestAt: events[0]?.at ?? null,
273
672
  newestAt: events.at(-1)?.at ?? null,
274
673
  journal,
@@ -308,16 +707,62 @@ export function recordDiagnosticFailure(repoPath, input) {
308
707
  };
309
708
  const events = [...readEvents(repo, config.retentionDays), event].slice(-MAX_RECORDS);
310
709
  const { target } = containedPath(repo, JOURNAL_PATH, "record");
311
- atomicPrivateWrite(target, `${events.map((record) => JSON.stringify(record)).join("\n")}\n`);
710
+ atomicPrivateWrite(repo, target, `${events.map((record) => JSON.stringify(record)).join("\n")}\n`);
312
711
  return { recorded: true, correlationId: event.correlationId };
313
712
  });
314
713
  }
315
714
  catch {
715
+ // Every caller discards this return value, so a dropped record used to
716
+ // vanish without trace: the count in `status` was the only evidence, and it
717
+ // cannot distinguish "nothing failed" from "the failure was lost". Leave a
718
+ // durable marker so the two are always distinguishable.
719
+ noteDroppedRecord(repoPath);
316
720
  return { recorded: false, reason: "unavailable" };
317
721
  }
318
722
  }
723
+ /**
724
+ * Records that a diagnostic could not be written.
725
+ *
726
+ * Cannot take the journal lock — failing to acquire it is precisely why we are
727
+ * here — so each dropping process writes its own uniquely named marker into a
728
+ * directory. Concurrent writers therefore never collide and no update can be
729
+ * lost, which is the property the shared journal could not offer under
730
+ * contention. Best-effort by necessity: if even this fails there is nowhere left
731
+ * to report, and throwing would turn a lost record into a crashed command.
732
+ */
733
+ function noteDroppedRecord(repoPath) {
734
+ try {
735
+ const { repo, target } = containedPath(repoPath, DROPPED_PATH, "record");
736
+ fs.mkdirSync(target, { recursive: true, mode: 0o700 });
737
+ const marker = containedPath(repo, path.join(DROPPED_PATH, `${process.pid}-${crypto.randomBytes(8).toString("hex")}`), "record").target;
738
+ atomicPrivateWrite(repo, marker, `${new Date().toISOString()}\n`);
739
+ }
740
+ catch {
741
+ // Nowhere left to report; never escalate a lost record into a failure.
742
+ }
743
+ }
744
+ /** Count of records that were dropped rather than written. */
745
+ function droppedRecordCount(repoPath) {
746
+ try {
747
+ const { target } = containedPath(repoPath, DROPPED_PATH, "status");
748
+ return fs.readdirSync(target).length;
749
+ }
750
+ catch {
751
+ return 0;
752
+ }
753
+ }
319
754
  export function clearDiagnostics(repoPath) {
320
755
  const { repo, target } = containedPath(repoPath, JOURNAL_PATH, "clear");
756
+ // Drop markers describe the journal being cleared, so they go with it.
757
+ try {
758
+ fs.rmSync(containedPath(repo, DROPPED_PATH, "clear").target, {
759
+ recursive: true,
760
+ force: true,
761
+ });
762
+ }
763
+ catch {
764
+ // Clearing must stay the recovery path even if markers are unreadable.
765
+ }
321
766
  return withJournalLock(repo, () => {
322
767
  if (!fs.existsSync(target))
323
768
  return { removed: false, records: 0, bytes: 0 };
@@ -328,11 +773,12 @@ export function clearDiagnostics(repoPath) {
328
773
  catch {
329
774
  // Deletion must remain the recovery path for an unreadable or oversized journal.
330
775
  }
331
- const bytes = fs.statSync(target).size;
332
- fs.unlinkSync(target);
776
+ const bytes = privateDelete(repo, target);
777
+ if (bytes === null)
778
+ return { removed: false, records: 0, bytes: 0 };
333
779
  const config = readConfig(repo);
334
780
  if (config) {
335
- atomicPrivateWrite(containedPath(repo, CONFIG_PATH, "clear").target, `${JSON.stringify({ ...config, generation: config.generation + 1 }, null, 2)}\n`);
781
+ atomicPrivateWrite(repo, containedPath(repo, CONFIG_PATH, "clear").target, `${JSON.stringify({ ...config, generation: config.generation + 1 }, null, 2)}\n`);
336
782
  }
337
783
  return { removed: true, records, bytes };
338
784
  });
@@ -340,110 +786,691 @@ export function clearDiagnostics(repoPath) {
340
786
  function defaultBundlePath() {
341
787
  return `.knodin/diagnostics/knodin-diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.json.gz`;
342
788
  }
343
- function lifecycleLog(repo, state) {
344
- const target = path.join(repo, ".knodin", "indexer.log");
345
- if (!fs.existsSync(target) || fs.lstatSync(target).isSymbolicLink())
346
- return [];
347
- const size = fs.statSync(target).size;
348
- const descriptor = fs.openSync(target, "r");
789
+ const MCP_LOG_PATHS = [".knodin/mcp-reliability.jsonl.1", ".knodin/mcp-reliability.jsonl"];
790
+ const MCP_EVENTS = new Set([
791
+ "start",
792
+ "progress",
793
+ "failure",
794
+ "cleanup",
795
+ "success",
796
+ "restart",
797
+ "recovery",
798
+ ]);
799
+ const MCP_FAILURES = new Set([
800
+ "cancelled",
801
+ "client-disconnected",
802
+ "deadline-exceeded",
803
+ "graph-locked",
804
+ "memory-pressure",
805
+ "worker-crashed",
806
+ "operation-failed",
807
+ ]);
808
+ const BUNDLE_OMISSIONS = [
809
+ "repository-source-and-diffs",
810
+ "raw-and-external-paths",
811
+ "credentials-and-environment",
812
+ "usernames-and-identities",
813
+ "command-and-lifecycle-output",
814
+ "git-remotes-messages-and-arguments",
815
+ "telemetry-record-fields",
816
+ ];
817
+ const UNAVAILABLE_REASONS = new Set([
818
+ "MCP trace journal is symlinked and was refused",
819
+ "MCP trace journal exceeds its documented bound",
820
+ "MCP trace journal could not be read safely",
821
+ "MCP traces unavailable",
822
+ "No MCP trace journal exists for this checkout",
823
+ "Diagnostic failure journal could not be read safely",
824
+ "Diagnostic journal is unavailable",
825
+ "Graph health was not supplied or recognized by the local caller",
826
+ "Repository scale was not supplied or recognized by the local caller",
827
+ ]);
828
+ const GRAPH_STATUSES = ["healthy", "repair-needed", "unavailable", "degraded"];
829
+ const FRESHNESS_STATES = ["fresh", "stale", "dirty", "unknown", "unavailable"];
830
+ const COMMIT_RELATIONS = ["equal", "ahead", "behind", "diverged", "unknown"];
831
+ const LIFECYCLE_STATUSES = ["healthy", "degraded", "unconfigured", "unavailable"];
832
+ const PROCESSOR_STATES = ["idle", "running", "stale", "unavailable"];
833
+ const INSTALLATION_STATUSES = [
834
+ "healthy",
835
+ "ready",
836
+ "attention-required",
837
+ "repair-needed",
838
+ "unavailable",
839
+ ];
840
+ function safeEnum(value, allowed, fallback = "unknown") {
841
+ return typeof value === "string" && allowed.includes(value) ? value : fallback;
842
+ }
843
+ function safeCount(value) {
844
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
845
+ }
846
+ function recordObject(value) {
847
+ return value && typeof value === "object" && !Array.isArray(value)
848
+ ? value
849
+ : {};
850
+ }
851
+ function child(value, key) {
852
+ return recordObject(recordObject(value)[key]);
853
+ }
854
+ function readMcpTraces(repo, since) {
855
+ const records = [];
856
+ let found = false;
349
857
  try {
350
- const length = Math.min(size, MAX_LOG_BYTES);
351
- const buffer = Buffer.alloc(length);
352
- fs.readSync(descriptor, buffer, 0, length, Math.max(0, size - length));
353
- return buffer
354
- .toString("utf8")
355
- .split(/\r?\n/)
356
- .filter(Boolean)
357
- .slice(-200)
358
- .map((line) => sanitizeUnknown(line, repo, state));
858
+ for (const relative of MCP_LOG_PATHS) {
859
+ const { target } = containedPath(repo, relative, "preview");
860
+ if (!fs.existsSync(target))
861
+ continue;
862
+ found = true;
863
+ if (fs.lstatSync(target).isSymbolicLink())
864
+ return { records: [], unavailable: "MCP trace journal is symlinked and was refused" };
865
+ if (fs.statSync(target).size > MAX_MCP_LOG_BYTES)
866
+ return { records: [], unavailable: "MCP trace journal exceeds its documented bound" };
867
+ for (const line of fs.readFileSync(target, "utf8").split(/\r?\n/).filter(Boolean)) {
868
+ let raw;
869
+ try {
870
+ raw = recordObject(JSON.parse(line));
871
+ }
872
+ catch {
873
+ continue;
874
+ }
875
+ if (raw.schemaVersion !== 1 ||
876
+ typeof raw.at !== "string" ||
877
+ !Number.isFinite(Date.parse(raw.at)) ||
878
+ Date.parse(raw.at) < since.getTime() ||
879
+ typeof raw.requestId !== "string" ||
880
+ !/^[-A-Za-z0-9:._]{1,128}$/.test(raw.requestId) ||
881
+ typeof raw.traceId !== "string" ||
882
+ !/^[-a-fA-F0-9]{8,64}$/.test(raw.traceId) ||
883
+ typeof raw.operation !== "string" ||
884
+ !OPERATIONS.has(raw.operation) ||
885
+ !MCP_EVENTS.has(String(raw.event)) ||
886
+ safeCount(raw.deadlineMs) === null)
887
+ continue;
888
+ const trace = {
889
+ at: raw.at,
890
+ event: String(raw.event),
891
+ requestId: raw.requestId,
892
+ traceId: raw.traceId,
893
+ operation: raw.operation,
894
+ deadlineMs: safeCount(raw.deadlineMs),
895
+ };
896
+ if (typeof raw.kind === "string" && MCP_FAILURES.has(raw.kind))
897
+ trace.kind = raw.kind;
898
+ for (const key of ["elapsedMs", "sequence"]) {
899
+ const count = safeCount(raw[key]);
900
+ if (count !== null)
901
+ trace[key] = count;
902
+ }
903
+ if (typeof raw.predecessorTraceId === "string" &&
904
+ /^[-a-fA-F0-9]{8,64}$/.test(raw.predecessorTraceId))
905
+ trace.predecessorTraceId = raw.predecessorTraceId;
906
+ records.push(trace);
907
+ }
908
+ }
359
909
  }
360
- finally {
361
- fs.closeSync(descriptor);
910
+ catch (error) {
911
+ return {
912
+ records: [],
913
+ unavailable: error instanceof Error
914
+ ? "MCP trace journal could not be read safely"
915
+ : "MCP traces unavailable",
916
+ };
362
917
  }
918
+ return {
919
+ records: records.slice(-MAX_BUNDLE_RECORDS),
920
+ unavailable: found ? null : "No MCP trace journal exists for this checkout",
921
+ };
363
922
  }
364
- export function collectDiagnostics(repoPath, options = {}) {
365
- const { repo, target } = containedPath(repoPath, options.outputPath ?? defaultBundlePath(), "collect");
366
- if (fs.existsSync(target))
367
- throw new Error("knodin diagnostics collect: refusing to overwrite an existing bundle");
923
+ function fieldPaths(value, prefix = "$") {
924
+ if (Array.isArray(value)) {
925
+ const nested = new Set([`${prefix}[]`]);
926
+ for (const item of value)
927
+ for (const field of fieldPaths(item, `${prefix}[]`))
928
+ nested.add(field);
929
+ return [...nested].sort(compareBytes);
930
+ }
931
+ if (value && typeof value === "object") {
932
+ const output = [];
933
+ for (const [key, item] of Object.entries(value)) {
934
+ const next = `${prefix}.${key}`;
935
+ output.push(next, ...fieldPaths(item, next));
936
+ }
937
+ // Byte order: this field list is recorded in the support-bundle descriptor
938
+ // and later compared verbatim against a freshly computed one.
939
+ return [...new Set(output)].sort(compareBytes);
940
+ }
941
+ return [];
942
+ }
943
+ function recoveryFor(kind) {
944
+ switch (kind) {
945
+ case "graph-locked":
946
+ return "Wait for the current writer, then run `knodin status`; use `knodin repair` if health is not restored.";
947
+ case "deadline-exceeded":
948
+ return "Retry once with a narrower operation, then run `knodin status` if the next request does not succeed.";
949
+ case "memory-pressure":
950
+ return "Release local memory, restart the MCP client, and run `knodin status`.";
951
+ case "worker-crashed":
952
+ case "client-disconnected":
953
+ return "Restart the MCP client and run `knodin status`; do not claim recovery until a new request succeeds.";
954
+ default:
955
+ return "Run `knodin status`, then follow its repair steps; use `knodin repair` for graph damage.";
956
+ }
957
+ }
958
+ function renderSupportReport(generatedAt, health, traceRecords, failures) {
959
+ const traceFailures = traceRecords.filter((record) => record.event === "failure");
960
+ const recoveries = traceRecords.filter((record) => record.event === "recovery");
961
+ const reportLines = [
962
+ "knodin privacy-safe support report",
963
+ `Generated: ${generatedAt}`,
964
+ `Graph: ${health.graph.status}; freshness: ${health.graph.freshness.state}; lifecycle: ${health.lifecycle.status}.`,
965
+ `Recent classified MCP failures: ${traceFailures.length}; diagnostic failures: ${failures.length}.`,
966
+ ];
967
+ for (const failure of traceFailures.slice(-10)) {
968
+ const recovered = recoveries.find((record) => record.predecessorTraceId === failure.traceId);
969
+ reportLines.push(`Trace ${failure.traceId} / request ${failure.requestId}: ${failure.kind ?? "operation-failed"}; ${recovered
970
+ ? `next request ${recovered.traceId} recorded recovery.`
971
+ : "no subsequent recovery is recorded."}`, `Recovery: ${recoveryFor(failure.kind)}`);
972
+ }
973
+ if (traceFailures.length === 0)
974
+ reportLines.push("No classified MCP failure is available. Run `knodin status`; use `knodin repair` only when status reports graph damage.");
975
+ return `${reportLines.join("\n")}\n`;
976
+ }
977
+ function unavailableFieldSections(health, scale) {
978
+ const unavailable = [];
979
+ const healthFields = [
980
+ ["$.graph.status", health.graph.status],
981
+ ["$.graph.freshness.state", health.graph.freshness.state],
982
+ ["$.graph.freshness.commitRelation", health.graph.freshness.commitRelation],
983
+ ["$.graph.freshness.commitDistance", health.graph.freshness.commitDistance],
984
+ ["$.lifecycle.status", health.lifecycle.status],
985
+ ["$.lifecycle.queuedEvents", health.lifecycle.queuedEvents],
986
+ ["$.lifecycle.processorState", health.lifecycle.processorState],
987
+ ["$.installation.status", health.installation.status],
988
+ ];
989
+ for (const [field, value] of healthFields)
990
+ if (value === null || value === "unknown" || value === "unavailable")
991
+ unavailable.push(`health.json: ${field} unavailable`);
992
+ for (const [field, value] of Object.entries(scale))
993
+ if (value === null)
994
+ unavailable.push(`repository-scale.json: $.${field} unavailable`);
995
+ return unavailable;
996
+ }
997
+ function previewIdentity(value) {
998
+ return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 32);
999
+ }
1000
+ export function previewDiagnosticsBundle(repoPath, options = {}) {
1001
+ const repo = fs.realpathSync(repoPath);
368
1002
  const sinceHours = options.sinceHours ?? 24;
369
1003
  if (!Number.isFinite(sinceHours) || sinceHours <= 0 || sinceHours > 24 * 365)
370
- throw new Error("knodin diagnostics collect: --since must be from 1h through 8760h");
371
- const state = { redactions: 0 };
372
- const status = diagnosticsStatus(repo);
373
- const bundle = {
1004
+ throw new Error("knodin diagnostics preview: --since must be from 1h through 8760h");
1005
+ const generatedAt = options.generatedAt ?? new Date().toISOString();
1006
+ if (!Number.isFinite(Date.parse(generatedAt)))
1007
+ throw new Error("knodin diagnostics preview: invalid generation time");
1008
+ const since = new Date(Date.parse(generatedAt) - sinceHours * 3_600_000);
1009
+ const diagnostics = diagnosticsStatus(repo);
1010
+ let failures = [];
1011
+ let failuresUnavailable = null;
1012
+ try {
1013
+ failures = readEvents(repo, diagnostics.retentionDays, since).slice(-MAX_BUNDLE_RECORDS);
1014
+ }
1015
+ catch {
1016
+ failuresUnavailable = "Diagnostic failure journal could not be read safely";
1017
+ }
1018
+ const traces = readMcpTraces(repo, since);
1019
+ const graph = recordObject(options.graph);
1020
+ const coverage = child(graph, "coverage");
1021
+ const freshness = child(graph, "freshness");
1022
+ const lifecycle = child(graph, "lifecycle");
1023
+ const doctor = recordObject(options.doctor);
1024
+ const graphStatus = safeEnum(graph.status, GRAPH_STATUSES, "unavailable");
1025
+ const lifecycleStatus = safeEnum(lifecycle.status, LIFECYCLE_STATUSES, "unavailable");
1026
+ const installationStatus = safeEnum(doctor.status, INSTALLATION_STATUSES, "unavailable");
1027
+ const health = {
1028
+ graph: {
1029
+ status: options.graph === undefined ? "unavailable" : graphStatus,
1030
+ freshness: {
1031
+ state: safeEnum(freshness.state, FRESHNESS_STATES),
1032
+ commitRelation: safeEnum(freshness.commitRelation, COMMIT_RELATIONS),
1033
+ commitDistance: safeCount(freshness.commitDistance),
1034
+ },
1035
+ },
1036
+ lifecycle: {
1037
+ status: options.graph === undefined ? "unavailable" : lifecycleStatus,
1038
+ queuedEvents: safeCount(lifecycle.queuedEvents),
1039
+ processorState: safeEnum(child(lifecycle, "processor").state, PROCESSOR_STATES, "unavailable"),
1040
+ },
1041
+ installation: { status: options.doctor === undefined ? "unavailable" : installationStatus },
1042
+ };
1043
+ const repositoryScale = {
1044
+ sourceFiles: safeCount(coverage.sourceFiles),
1045
+ indexedFiles: safeCount(coverage.indexedFiles),
1046
+ filesWithSymbols: safeCount(coverage.filesWithSymbols),
1047
+ coveragePercent: typeof coverage.percent === "number" && coverage.percent >= 0 && coverage.percent <= 100
1048
+ ? coverage.percent
1049
+ : null,
1050
+ missingFileCount: Array.isArray(child(graph, "missing").files)
1051
+ ? child(graph, "missing").files.length
1052
+ : null,
1053
+ missingRecordCount: Array.isArray(child(graph, "missing").records)
1054
+ ? child(graph, "missing").records.length
1055
+ : null,
1056
+ };
1057
+ const runtime = {
1058
+ knodin: typeof options.knodinVersion === "string" &&
1059
+ /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(options.knodinVersion)
1060
+ ? options.knodinVersion
1061
+ : "unavailable",
1062
+ node: process.version,
1063
+ platform: process.platform,
1064
+ arch: process.arch,
1065
+ };
1066
+ const report = renderSupportReport(generatedAt, health, traces.records, failures);
1067
+ const files = {
1068
+ "runtime.json": runtime,
1069
+ "health.json": health,
1070
+ "repository-scale.json": repositoryScale,
1071
+ "mcp-traces.json": { records: traces.records },
1072
+ "failures.json": { records: failures },
1073
+ };
1074
+ const unavailableByPath = {};
1075
+ if (traces.unavailable)
1076
+ unavailableByPath["mcp-traces.json"] = traces.unavailable;
1077
+ if (failuresUnavailable || diagnostics.journal.status === "unavailable")
1078
+ unavailableByPath["failures.json"] = failuresUnavailable ?? "Diagnostic journal is unavailable";
1079
+ const graphRecognized = options.graph !== undefined && graphStatus !== "unavailable";
1080
+ const scaleRecognized = graphRecognized && Object.values(repositoryScale).some((value) => value !== null);
1081
+ if (!graphRecognized) {
1082
+ unavailableByPath["health.json"] =
1083
+ "Graph health was not supplied or recognized by the local caller";
1084
+ }
1085
+ if (!scaleRecognized) {
1086
+ unavailableByPath["repository-scale.json"] =
1087
+ "Repository scale was not supplied or recognized by the local caller";
1088
+ }
1089
+ const ordered = [
1090
+ "support-report.txt",
1091
+ "runtime.json",
1092
+ "health.json",
1093
+ "repository-scale.json",
1094
+ "mcp-traces.json",
1095
+ "failures.json",
1096
+ ];
1097
+ const descriptor = (filePath) => {
1098
+ const content = filePath === "support-report.txt" ? report : files[filePath];
1099
+ let retention = "point-in-time snapshot; archive retained until user deletes it";
1100
+ if (filePath === "failures.json")
1101
+ retention = `${diagnostics.retentionDays} days; preview window ${sinceHours} hours`;
1102
+ else if (filePath === "mcp-traces.json")
1103
+ retention = `C89 journal maximum 7 days; preview window ${sinceHours} hours`;
1104
+ return {
1105
+ path: filePath,
1106
+ bytes: Buffer.byteLength(typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`),
1107
+ fields: typeof content === "string" ? ["$.text"] : fieldPaths(content),
1108
+ retention,
1109
+ redaction: ["explicit-allowlist"],
1110
+ unavailable: unavailableByPath[filePath] ?? null,
1111
+ };
1112
+ };
1113
+ const manifestFiles = ordered.map(descriptor);
1114
+ const preview = {
1115
+ schemaVersion: 2,
1116
+ generatedAt,
374
1117
  manifest: {
375
- schemaVersion: 1,
376
- generatedAt: new Date().toISOString(),
377
- privacy: "redacted-local-only",
1118
+ schemaVersion: 2,
1119
+ privacy: "explicit-allowlist-local-only",
378
1120
  localOnly: true,
379
1121
  uploaded: false,
380
- redactions: 0,
381
- omissions: [
382
- "source",
383
- "queries-and-arguments",
384
- "environment-values",
385
- "git-remotes-diffs-and-messages",
386
- "raw-paths",
1122
+ maxArchiveBytes: MAX_BUNDLE_BYTES,
1123
+ omissions: [...BUNDLE_OMISSIONS],
1124
+ unavailableSections: [
1125
+ ...manifestFiles
1126
+ .filter((file) => file.unavailable !== null)
1127
+ .map((file) => `${file.path}: ${file.unavailable}`),
1128
+ ...unavailableFieldSections(health, repositoryScale),
387
1129
  ],
1130
+ files: manifestFiles,
388
1131
  },
389
- runtime: {
390
- ...(options.knodinVersion ? { knodinVersion: options.knodinVersion } : {}),
391
- node: process.version,
392
- platform: process.platform,
393
- arch: process.arch,
394
- },
395
- diagnostics: status,
396
- diagnosticEvents: readEvents(repo, status.retentionDays, new Date(Date.now() - sinceHours * 3_600_000)),
397
- telemetry: sanitizeUnknown(options.telemetry ?? [], repo, state),
398
- doctor: sanitizeUnknown(options.doctor ?? null, repo, state),
399
- graph: sanitizeUnknown(options.graph ?? null, repo, state),
400
- lifecycleLog: lifecycleLog(repo, state),
1132
+ report,
1133
+ files,
401
1134
  };
402
- bundle.manifest.redactions = state.redactions;
403
- atomicPrivateWrite(target, zlib.gzipSync(`${JSON.stringify(bundle, null, 2)}\n`, { level: 9 }));
1135
+ return { ...preview, previewId: previewIdentity(preview) };
1136
+ }
1137
+ export function persistDiagnosticsPreview(repoPath, options = {}) {
1138
+ const preview = previewDiagnosticsBundle(repoPath, options);
1139
+ const repo = fs.realpathSync(repoPath);
1140
+ withJournalLock(repo, () => {
1141
+ const current = readPreviewStores(repo).at(-1);
1142
+ const now = Date.now();
1143
+ let entries = [
1144
+ { persistedAt: new Date(now).toISOString(), preview },
1145
+ ...(current?.entries ?? []).filter((entry) => {
1146
+ const persisted = Date.parse(entry.persistedAt);
1147
+ return (entry.preview.previewId !== preview.previewId &&
1148
+ Number.isFinite(persisted) &&
1149
+ persisted <= now + 60_000 &&
1150
+ now - persisted <= MAX_PREVIEW_AGE_MS);
1151
+ }),
1152
+ ].slice(0, MAX_PREVIEW_COUNT);
1153
+ const generation = (current?.generation ?? 0) + 1;
1154
+ let serialized = "";
1155
+ do {
1156
+ serialized = `${JSON.stringify({ schemaVersion: 1, generation, entries }, null, 2)}\n`;
1157
+ if (Buffer.byteLength(serialized) <= MAX_PREVIEW_BYTES / 2)
1158
+ break;
1159
+ entries = entries.slice(0, -1);
1160
+ } while (entries.length > 0);
1161
+ if (entries.length === 0 || Buffer.byteLength(serialized) > MAX_PREVIEW_BYTES / 2)
1162
+ throw new Error("knodin diagnostics preview: one preview exceeds the store bound");
1163
+ const target = containedPath(repo, PREVIEW_STORES[generation % 2], "preview").target;
1164
+ privateDescriptorReplace(repo, target, serialized);
1165
+ });
1166
+ return preview;
1167
+ }
1168
+ function readPreviewStores(repo) {
1169
+ return PREVIEW_STORES.flatMap((relative) => {
1170
+ const target = containedPath(repo, relative, "preview store").target;
1171
+ if (!fs.existsSync(target))
1172
+ return [];
1173
+ try {
1174
+ const raw = JSON.parse(readPrivateFile(repo, target, MAX_PREVIEW_BYTES / 2, "preview store").toString("utf8"));
1175
+ if (raw.schemaVersion !== 1 ||
1176
+ !Number.isSafeInteger(raw.generation) ||
1177
+ Number(raw.generation) < 1 ||
1178
+ !Array.isArray(raw.entries))
1179
+ return [];
1180
+ const entries = raw.entries.flatMap((entry) => {
1181
+ const candidate = recordObject(entry);
1182
+ const persisted = typeof candidate.persistedAt === "string"
1183
+ ? Date.parse(candidate.persistedAt)
1184
+ : Number.NaN;
1185
+ if (!Number.isFinite(persisted) || persisted > Date.now() + 60_000)
1186
+ return [];
1187
+ try {
1188
+ return [
1189
+ {
1190
+ persistedAt: candidate.persistedAt,
1191
+ preview: validateInspectedBundle(candidate.preview),
1192
+ },
1193
+ ];
1194
+ }
1195
+ catch {
1196
+ return [];
1197
+ }
1198
+ });
1199
+ return [{ generation: Number(raw.generation), entries }];
1200
+ }
1201
+ catch {
1202
+ return [];
1203
+ }
1204
+ }).sort((a, b) => a.generation - b.generation);
1205
+ }
1206
+ function readPersistedPreview(repo, previewId) {
1207
+ if (!/^[a-f0-9]{32}$/.test(previewId))
1208
+ throw new Error("knodin diagnostics archive: invalid preview ID");
1209
+ const retrievable = [...readPreviewStores(repo)]
1210
+ .reverse()
1211
+ .flatMap((store) => store.entries)
1212
+ .filter((entry, index, entries) => entries.findIndex((candidate) => candidate.preview.previewId === entry.preview.previewId) === index)
1213
+ .slice(0, MAX_PREVIEW_COUNT);
1214
+ for (const entry of retrievable) {
1215
+ const age = Date.now() - Date.parse(entry.persistedAt);
1216
+ if (age < 0 || age > MAX_PREVIEW_AGE_MS)
1217
+ continue;
1218
+ if (entry.preview.previewId === previewId)
1219
+ return entry.preview;
1220
+ }
1221
+ throw new Error("knodin diagnostics archive: preview is unavailable or expired");
1222
+ }
1223
+ export function collectDiagnostics(repoPath, options = {}) {
1224
+ const { repo, target } = containedPath(repoPath, options.outputPath ?? defaultBundlePath(), "collect");
1225
+ if (fs.existsSync(target))
1226
+ throw new Error("knodin diagnostics collect: refusing to overwrite an existing bundle");
1227
+ const bundle = options.previewId
1228
+ ? readPersistedPreview(repo, options.previewId)
1229
+ : previewDiagnosticsBundle(repo, options);
1230
+ const archive = zlib.gzipSync(`${JSON.stringify(bundle, null, 2)}\n`, { level: 9 });
1231
+ if (archive.byteLength > MAX_BUNDLE_BYTES) {
1232
+ throw new Error("knodin diagnostics collect: bundle exceeds the 512 KiB safety limit");
1233
+ }
1234
+ privateCreate(repo, target, archive);
404
1235
  return {
405
- schemaVersion: 1,
1236
+ schemaVersion: 2,
406
1237
  outputPath: path.relative(repo, target),
407
1238
  format: "gzip-json",
408
1239
  localOnly: true,
409
1240
  uploaded: false,
410
1241
  bytes: fs.statSync(target).size,
411
- records: bundle.diagnosticEvents.length,
412
- redactions: bundle.manifest.redactions,
1242
+ records: bundle.files["failures.json"].records.length,
1243
+ manifest: bundle.manifest,
413
1244
  message: "Bundle created locally. Run `knodin diagnostics inspect <bundle>` before sharing it.",
414
1245
  };
415
1246
  }
1247
+ function exactObject(value, keys, section) {
1248
+ if (!value || typeof value !== "object" || Array.isArray(value))
1249
+ throw new Error(`knodin diagnostics inspect: malformed ${section}`);
1250
+ const object = value;
1251
+ // Byte order: a collation that ties two distinct keys leaves their relative
1252
+ // order arbitrary, which would fail this strict comparison spuriously.
1253
+ const actual = Object.keys(object).sort(compareBytes);
1254
+ const expected = [...keys].sort(compareBytes);
1255
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
1256
+ throw new Error(`knodin diagnostics inspect: unallowlisted field in ${section}`);
1257
+ return object;
1258
+ }
1259
+ function validBundleRetention(filePath, value) {
1260
+ if (typeof value !== "string")
1261
+ return false;
1262
+ if (filePath === "failures.json") {
1263
+ const match = /^(\d{1,3}) days; preview window (\d{1,4}) hours$/.exec(value);
1264
+ return Boolean(match && Number(match[1]) <= MAX_RETENTION_DAYS && Number(match[2]) <= 8760);
1265
+ }
1266
+ if (filePath === "mcp-traces.json") {
1267
+ const match = /^C89 journal maximum 7 days; preview window (\d{1,4}) hours$/.exec(value);
1268
+ return Boolean(match && Number(match[1]) <= 8760);
1269
+ }
1270
+ return value === "point-in-time snapshot; archive retained until user deletes it";
1271
+ }
1272
+ function validateInspectedBundle(value) {
1273
+ const root = exactObject(value, ["schemaVersion", "previewId", "generatedAt", "manifest", "report", "files"], "bundle");
1274
+ if (root.schemaVersion !== 2 ||
1275
+ typeof root.generatedAt !== "string" ||
1276
+ !Number.isFinite(Date.parse(root.generatedAt)))
1277
+ throw new Error("knodin diagnostics inspect: unsupported bundle version or time");
1278
+ if (typeof root.previewId !== "string" || !/^[a-f0-9]{32}$/.test(root.previewId))
1279
+ throw new Error("knodin diagnostics inspect: malformed preview identity");
1280
+ const manifest = exactObject(root.manifest, [
1281
+ "schemaVersion",
1282
+ "privacy",
1283
+ "localOnly",
1284
+ "uploaded",
1285
+ "maxArchiveBytes",
1286
+ "omissions",
1287
+ "unavailableSections",
1288
+ "files",
1289
+ ], "manifest");
1290
+ if (manifest.schemaVersion !== 2 ||
1291
+ manifest.privacy !== "explicit-allowlist-local-only" ||
1292
+ manifest.localOnly !== true ||
1293
+ manifest.uploaded !== false ||
1294
+ manifest.maxArchiveBytes !== MAX_BUNDLE_BYTES ||
1295
+ JSON.stringify(manifest.omissions) !== JSON.stringify(BUNDLE_OMISSIONS))
1296
+ throw new Error("knodin diagnostics inspect: unsupported or unsafe bundle manifest");
1297
+ const files = exactObject(root.files, ["runtime.json", "health.json", "repository-scale.json", "mcp-traces.json", "failures.json"], "files");
1298
+ const runtime = exactObject(files["runtime.json"], ["knodin", "node", "platform", "arch"], "runtime");
1299
+ if (typeof runtime.knodin !== "string" ||
1300
+ !/^(?:unavailable|\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)$/.test(runtime.knodin) ||
1301
+ typeof runtime.node !== "string" ||
1302
+ !/^v\d+\.\d+\.\d+$/.test(runtime.node) ||
1303
+ typeof runtime.platform !== "string" ||
1304
+ !/^[a-z0-9_-]{1,32}$/.test(runtime.platform) ||
1305
+ typeof runtime.arch !== "string" ||
1306
+ !/^[a-z0-9_-]{1,32}$/.test(runtime.arch))
1307
+ throw new Error("knodin diagnostics inspect: malformed runtime fields");
1308
+ const health = exactObject(files["health.json"], ["graph", "lifecycle", "installation"], "health");
1309
+ const graph = exactObject(health.graph, ["status", "freshness"], "graph health");
1310
+ const freshness = exactObject(graph.freshness, ["state", "commitRelation", "commitDistance"], "freshness");
1311
+ const lifecycle = exactObject(health.lifecycle, ["status", "queuedEvents", "processorState"], "lifecycle health");
1312
+ const installation = exactObject(health.installation, ["status"], "installation health");
1313
+ if (typeof graph.status !== "string" ||
1314
+ !GRAPH_STATUSES.includes(graph.status) ||
1315
+ typeof freshness.state !== "string" ||
1316
+ !FRESHNESS_STATES.includes(freshness.state) ||
1317
+ typeof freshness.commitRelation !== "string" ||
1318
+ !COMMIT_RELATIONS.includes(freshness.commitRelation) ||
1319
+ (freshness.commitDistance !== null && safeCount(freshness.commitDistance) === null) ||
1320
+ typeof lifecycle.status !== "string" ||
1321
+ !LIFECYCLE_STATUSES.includes(lifecycle.status) ||
1322
+ (lifecycle.queuedEvents !== null && safeCount(lifecycle.queuedEvents) === null) ||
1323
+ typeof lifecycle.processorState !== "string" ||
1324
+ !PROCESSOR_STATES.includes(lifecycle.processorState) ||
1325
+ typeof installation.status !== "string" ||
1326
+ !INSTALLATION_STATUSES.includes(installation.status))
1327
+ throw new Error("knodin diagnostics inspect: malformed health fields");
1328
+ const scale = exactObject(files["repository-scale.json"], [
1329
+ "sourceFiles",
1330
+ "indexedFiles",
1331
+ "filesWithSymbols",
1332
+ "coveragePercent",
1333
+ "missingFileCount",
1334
+ "missingRecordCount",
1335
+ ], "repository scale");
1336
+ for (const key of [
1337
+ "sourceFiles",
1338
+ "indexedFiles",
1339
+ "filesWithSymbols",
1340
+ "missingFileCount",
1341
+ "missingRecordCount",
1342
+ ])
1343
+ if (scale[key] !== null && safeCount(scale[key]) === null)
1344
+ throw new Error("knodin diagnostics inspect: malformed repository scale");
1345
+ if (scale.coveragePercent !== null &&
1346
+ (typeof scale.coveragePercent !== "number" ||
1347
+ scale.coveragePercent < 0 ||
1348
+ scale.coveragePercent > 100))
1349
+ throw new Error("knodin diagnostics inspect: malformed repository coverage");
1350
+ const traceFile = exactObject(files["mcp-traces.json"], ["records"], "MCP traces");
1351
+ if (!Array.isArray(traceFile.records) || traceFile.records.length > MAX_BUNDLE_RECORDS)
1352
+ throw new Error("knodin diagnostics inspect: malformed MCP traces");
1353
+ for (const item of traceFile.records) {
1354
+ const record = item;
1355
+ const required = ["at", "event", "requestId", "traceId", "operation", "deadlineMs"];
1356
+ const optional = ["kind", "elapsedMs", "sequence", "predecessorTraceId"];
1357
+ if (!record ||
1358
+ typeof record !== "object" ||
1359
+ Object.keys(record).some((key) => ![...required, ...optional].includes(key)) ||
1360
+ required.some((key) => !(key in record)))
1361
+ throw new Error("knodin diagnostics inspect: unallowlisted MCP trace field");
1362
+ if (typeof record.at !== "string" ||
1363
+ !Number.isFinite(Date.parse(record.at)) ||
1364
+ typeof record.event !== "string" ||
1365
+ !MCP_EVENTS.has(record.event) ||
1366
+ typeof record.requestId !== "string" ||
1367
+ !/^[-A-Za-z0-9:._]{1,128}$/.test(record.requestId) ||
1368
+ typeof record.traceId !== "string" ||
1369
+ !/^[-a-fA-F0-9]{8,64}$/.test(record.traceId) ||
1370
+ typeof record.operation !== "string" ||
1371
+ !OPERATIONS.has(record.operation) ||
1372
+ safeCount(record.deadlineMs) === null ||
1373
+ (record.kind !== undefined &&
1374
+ (typeof record.kind !== "string" || !MCP_FAILURES.has(record.kind))) ||
1375
+ (record.predecessorTraceId !== undefined &&
1376
+ (typeof record.predecessorTraceId !== "string" ||
1377
+ !/^[-a-fA-F0-9]{8,64}$/.test(record.predecessorTraceId))) ||
1378
+ [record.elapsedMs, record.sequence].some((count) => count !== undefined && safeCount(count) === null))
1379
+ throw new Error("knodin diagnostics inspect: malformed MCP trace field");
1380
+ }
1381
+ const failureFile = exactObject(files["failures.json"], ["records"], "failures");
1382
+ if (!Array.isArray(failureFile.records) || failureFile.records.length > MAX_BUNDLE_RECORDS)
1383
+ throw new Error("knodin diagnostics inspect: malformed failures");
1384
+ for (const item of failureFile.records) {
1385
+ const failure = exactObject(item, ["schemaVersion", "at", "correlationId", "surface", "operation", "phase", "error"], "failure");
1386
+ const error = exactObject(failure.error, ["name", "code", "messageFingerprint", "stack"], "failure error");
1387
+ if (failure.schemaVersion !== 1 ||
1388
+ typeof failure.at !== "string" ||
1389
+ !Number.isFinite(Date.parse(failure.at)) ||
1390
+ typeof failure.correlationId !== "string" ||
1391
+ !/^[a-f0-9]{16}$/.test(failure.correlationId) ||
1392
+ !["cli", "mcp", "lifecycle"].includes(failure.surface) ||
1393
+ typeof failure.operation !== "string" ||
1394
+ !OPERATIONS.has(failure.operation) ||
1395
+ typeof failure.phase !== "string" ||
1396
+ safeLabel(failure.phase, "") !== failure.phase ||
1397
+ typeof error.name !== "string" ||
1398
+ safeLabel(error.name, "") !== error.name ||
1399
+ (error.code !== null && errorCode(error) === null) ||
1400
+ typeof error.messageFingerprint !== "string" ||
1401
+ !/^[a-f0-9]{16}$/.test(error.messageFingerprint) ||
1402
+ !Array.isArray(error.stack) ||
1403
+ error.stack.some((line) => line !== "at <repository>" && line !== "at <runtime>"))
1404
+ throw new Error("knodin diagnostics inspect: malformed failure field");
1405
+ }
1406
+ const typedHealth = health;
1407
+ const traces = traceFile.records;
1408
+ const failures = failureFile.records;
1409
+ if (root.report !== renderSupportReport(root.generatedAt, typedHealth, traces, failures))
1410
+ throw new Error("knodin diagnostics inspect: report does not match allowlisted evidence");
1411
+ const descriptors = manifest.files;
1412
+ if (!Array.isArray(descriptors) || descriptors.length !== 6)
1413
+ throw new Error("knodin diagnostics inspect: malformed file manifest");
1414
+ const ordered = [
1415
+ "support-report.txt",
1416
+ "runtime.json",
1417
+ "health.json",
1418
+ "repository-scale.json",
1419
+ "mcp-traces.json",
1420
+ "failures.json",
1421
+ ];
1422
+ for (const [index, descriptorValue] of descriptors.entries()) {
1423
+ const descriptor = exactObject(descriptorValue, ["path", "bytes", "fields", "retention", "redaction", "unavailable"], "file descriptor");
1424
+ const filePath = ordered[index];
1425
+ const content = filePath === "support-report.txt" ? root.report : files[filePath];
1426
+ const bytes = Buffer.byteLength(typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`);
1427
+ if (descriptor.path !== filePath ||
1428
+ descriptor.bytes !== bytes ||
1429
+ JSON.stringify(descriptor.fields) !==
1430
+ JSON.stringify(typeof content === "string" ? ["$.text"] : fieldPaths(content)) ||
1431
+ JSON.stringify(descriptor.redaction) !== JSON.stringify(["explicit-allowlist"]) ||
1432
+ !validBundleRetention(filePath, descriptor.retention) ||
1433
+ (descriptor.unavailable !== null &&
1434
+ (typeof descriptor.unavailable !== "string" ||
1435
+ !UNAVAILABLE_REASONS.has(descriptor.unavailable))))
1436
+ throw new Error("knodin diagnostics inspect: file manifest does not match payload");
1437
+ }
1438
+ const unavailable = [
1439
+ ...descriptors
1440
+ .filter((file) => file.unavailable !== null)
1441
+ .map((file) => `${file.path}: ${file.unavailable}`),
1442
+ ...unavailableFieldSections(typedHealth, scale),
1443
+ ];
1444
+ if (JSON.stringify(manifest.unavailableSections) !== JSON.stringify(unavailable))
1445
+ throw new Error("knodin diagnostics inspect: unavailable sections do not match manifest");
1446
+ const typed = value;
1447
+ const { previewId, ...withoutIdentity } = typed;
1448
+ if (previewIdentity(withoutIdentity) !== previewId)
1449
+ throw new Error("knodin diagnostics inspect: preview identity does not match contents");
1450
+ return typed;
1451
+ }
416
1452
  export function inspectDiagnosticsBundle(repoPath, bundlePath) {
417
- const { target } = containedPath(repoPath, bundlePath, "inspect");
1453
+ const { repo, target } = containedPath(repoPath, bundlePath, "inspect");
418
1454
  if (!fs.existsSync(target))
419
1455
  throw new Error("knodin diagnostics inspect: bundle does not exist");
420
- if (fs.statSync(target).size > 10 * 1024 * 1024)
421
- throw new Error("knodin diagnostics inspect: bundle exceeds the 10 MiB safety limit");
422
- let parsed;
1456
+ if (fs.statSync(target).size > MAX_BUNDLE_BYTES)
1457
+ throw new Error("knodin diagnostics inspect: bundle exceeds the 512 KiB safety limit");
1458
+ let raw;
423
1459
  try {
424
- parsed = JSON.parse(zlib
425
- .gunzipSync(fs.readFileSync(target), { maxOutputLength: MAX_BUNDLE_BYTES })
1460
+ raw = JSON.parse(zlib
1461
+ .gunzipSync(readPrivateFile(repo, target, MAX_BUNDLE_BYTES, "inspect"), {
1462
+ maxOutputLength: MAX_BUNDLE_BYTES,
1463
+ })
426
1464
  .toString("utf8"));
427
1465
  }
428
1466
  catch {
429
1467
  throw new Error("knodin diagnostics inspect: invalid gzip JSON bundle");
430
1468
  }
431
- if (parsed.manifest?.schemaVersion !== 1 || parsed.manifest.privacy !== "redacted-local-only")
432
- throw new Error("knodin diagnostics inspect: unsupported or unsafe bundle manifest");
433
- if (!Array.isArray(parsed.diagnosticEvents) ||
434
- !Array.isArray(parsed.telemetry) ||
435
- !Array.isArray(parsed.lifecycleLog))
436
- throw new Error("knodin diagnostics inspect: malformed bundle sections");
1469
+ const parsed = validateInspectedBundle(raw);
437
1470
  return {
438
1471
  manifest: parsed.manifest,
439
- runtime: parsed.runtime,
440
- diagnostics: parsed.diagnostics,
441
- sections: ["diagnosticEvents", "telemetry", "doctor", "graph", "lifecycleLog"],
442
- counts: {
443
- diagnosticEvents: parsed.diagnosticEvents.length,
444
- telemetry: parsed.telemetry.length,
445
- lifecycleLogLines: parsed.lifecycleLog.length,
446
- },
1472
+ report: parsed.report,
1473
+ sections: parsed.manifest.files.map((file) => file.path),
447
1474
  bundle: parsed,
448
1475
  };
449
1476
  }