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.
- package/README.md +18 -3
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +371 -66
- package/dist/bin/launcher.js +16 -1
- package/dist/src/agent-integration.js +82 -16
- package/dist/src/artifact-refresh.js +2 -1
- package/dist/src/cli-args.js +19 -1
- package/dist/src/cli-model.js +28 -2
- package/dist/src/codeflow-replay.js +2 -1
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-constraints.js +2 -1
- package/dist/src/competitive-runner.js +4 -4
- package/dist/src/context-export.js +3 -2
- package/dist/src/context.js +1 -1
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1160 -133
- package/dist/src/doctor.js +3 -1
- package/dist/src/engine/ann-hnsw.js +2 -12
- package/dist/src/engine/file-walker.js +8 -2
- package/dist/src/engine/git-history.js +12 -12
- package/dist/src/engine/index.js +1174 -313
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +28 -13
- package/dist/src/engine/source-policy.js +16 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/execution-profile.js +15 -10
- package/dist/src/failure-diagnosis.js +7 -1
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/index-activity.js +2 -1
- package/dist/src/init.js +86 -45
- package/dist/src/lifecycle-health.js +41 -9
- package/dist/src/mcp-graph-worker.js +69 -0
- package/dist/src/mcp-reliability.js +154 -0
- package/dist/src/mcp-worker-supervisor.js +350 -0
- package/dist/src/mirror.js +290 -0
- package/dist/src/node-runtime.js +157 -0
- package/dist/src/output-compression.js +2 -1
- package/dist/src/output-telemetry.js +16 -11
- package/dist/src/progressive-evidence.js +30 -26
- package/dist/src/pure-compression-cli.js +4 -3
- package/dist/src/relationship-adapters.js +15 -8
- package/dist/src/release-preflight.js +13 -10
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repository-init-process.js +13 -9
- package/dist/src/repository-management.js +34 -4
- package/dist/src/response-budget.js +8 -6
- package/dist/src/server.js +80 -35
- package/dist/src/structural-fast-path.js +16 -10
- package/dist/src/structural-snapshot.js +6 -2
- package/dist/src/system-config.js +25 -2
- package/dist/src/tools/knodin-tools.js +142 -31
- package/dist/src/update-ceremony.js +9 -5
- package/dist/src/update-trust.js +5 -4
- package/dist/src/visualization.js +372 -19
- package/dist/src/worktree-lifecycle.js +5 -2
- package/docs/BEHAVIORAL-CONTRACT.md +72 -0
- package/docs/CLI.md +20 -1
- package/docs/COMPARISON.md +403 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/DIAGNOSTICS.md +46 -11
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -2
- package/docs/MCP.md +59 -8
- package/docs/PT-ACCESS-RECOMMENDATION.md +5 -7
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +5 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/releases/0.5.1.md +4 -4
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/package.json +17 -4
- package/roadmap/competitive-roadmap.md +3801 -0
- package/schemas/release-attestation-v1.schema.json +1 -1
- package/schemas/support-bundle-v2.schema.json +212 -0
package/dist/src/diagnostics.js
CHANGED
|
@@ -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
|
|
13
|
-
const MAX_BUNDLE_BYTES =
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
407
|
+
const ownerToken = crypto.randomBytes(16).toString("hex");
|
|
408
|
+
let ownedIdentity;
|
|
75
409
|
for (let attempt = 0;; attempt++) {
|
|
76
410
|
try {
|
|
77
|
-
|
|
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 (
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
90
|
-
|
|
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 >=
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
207
|
-
|
|
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
|
|
213
|
-
|
|
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
|
-
|
|
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 =
|
|
332
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
.
|
|
356
|
-
|
|
357
|
-
.
|
|
358
|
-
|
|
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
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
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:
|
|
376
|
-
|
|
377
|
-
privacy: "redacted-local-only",
|
|
1118
|
+
schemaVersion: 2,
|
|
1119
|
+
privacy: "explicit-allowlist-local-only",
|
|
378
1120
|
localOnly: true,
|
|
379
1121
|
uploaded: false,
|
|
380
|
-
|
|
381
|
-
omissions: [
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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:
|
|
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.
|
|
412
|
-
|
|
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 >
|
|
421
|
-
throw new Error("knodin diagnostics inspect: bundle exceeds the
|
|
422
|
-
let
|
|
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
|
-
|
|
425
|
-
.gunzipSync(
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
}
|