knodin 0.7.6 → 0.8.3
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 +41 -12
- package/benchmarks/competitors/SYNTHESIS.md +66 -0
- package/dist/bin/cli.js +2181 -108
- package/dist/bin/launcher.js +25 -3
- package/dist/src/agent-integration.js +304 -0
- package/dist/src/artifact-refresh.js +82 -0
- package/dist/src/cli-args.js +292 -0
- package/dist/src/cli-model.js +384 -0
- package/dist/src/codeflow-replay.js +81 -0
- package/dist/src/compact-structural.js +96 -0
- package/dist/src/compare.js +39 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-constraints.js +21 -0
- package/dist/src/competitive-manifest.js +411 -0
- package/dist/src/competitive-measurement.js +183 -0
- package/dist/src/competitive-runner.js +487 -0
- package/dist/src/competitive-sandbox.js +108 -0
- package/dist/src/context-export.js +423 -0
- package/dist/src/context.js +102 -0
- package/dist/src/deterministic-random.js +34 -0
- package/dist/src/diagnostics-write-helper.js +473 -0
- package/dist/src/diagnostics.js +1476 -0
- package/dist/src/docs-sections.js +142 -0
- package/dist/src/doctor.js +382 -0
- package/dist/src/engine/ann-hnsw.js +261 -0
- package/dist/src/engine/embeddings.js +193 -0
- package/dist/src/engine/file-walker.js +49 -0
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +15094 -0
- package/dist/src/engine/perf.js +115 -0
- package/dist/src/engine/prune.js +112 -0
- package/dist/src/engine/sarif-import.js +341 -0
- package/dist/src/engine/scip-import.js +423 -0
- package/dist/src/engine/source-policy.js +85 -0
- package/dist/src/engine/sqlite.js +71 -0
- package/dist/src/engine/state-paths.js +175 -0
- package/dist/src/engine/symbol-delete.js +58 -0
- package/dist/src/execution-profile.js +208 -0
- package/dist/src/failure-diagnosis.js +655 -0
- package/dist/src/fleet.js +7 -0
- package/dist/src/git-executable.js +31 -0
- package/dist/src/graph-layout.js +173 -0
- package/dist/src/graph-query-health.js +115 -0
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/index-activity.js +126 -0
- package/dist/src/init-progress-worker.js +70 -2
- package/dist/src/init-progress.js +155 -0
- package/dist/src/init.js +1295 -0
- package/dist/src/lifecycle-health.js +282 -0
- package/dist/src/lsp-readonly.js +217 -0
- 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 +630 -0
- package/dist/src/output-telemetry.js +368 -0
- package/dist/src/pr-triage.js +638 -0
- package/dist/src/progress-worker-runtime.js +46 -0
- package/dist/src/progressive-evidence.js +477 -0
- package/dist/src/pure-compression-cli.js +102 -0
- package/dist/src/relationship-adapters.js +377 -0
- package/dist/src/release-attestation.js +533 -0
- package/dist/src/release-preflight.js +513 -0
- package/dist/src/repair-lease.js +85 -0
- package/dist/src/repair-progress-worker.js +83 -2
- package/dist/src/repair-progress.js +262 -0
- package/dist/src/repository-init-process.js +177 -0
- package/dist/src/repository-management.js +1261 -0
- package/dist/src/resource-reachability.js +456 -0
- package/dist/src/response-budget.js +200 -0
- package/dist/src/server.js +217 -0
- package/dist/src/structural-fast-path.js +344 -0
- package/dist/src/structural-snapshot.js +37 -0
- package/dist/src/system-config.js +638 -0
- package/dist/src/terminal-help.js +83 -0
- package/dist/src/tools/knodin-tools.js +1645 -0
- package/dist/src/update-ceremony.js +162 -0
- package/dist/src/update-policy.js +944 -0
- package/dist/src/update-trust.js +504 -0
- package/dist/src/version.js +13 -0
- package/dist/src/visualization.js +515 -0
- package/dist/src/wait-for-fresh.js +98 -0
- package/dist/src/worktree-lifecycle.js +234 -0
- package/docs/BEHAVIORAL-CONTRACT.md +114 -0
- package/docs/CLI.md +30 -1
- package/docs/COMPARISON.md +413 -0
- package/docs/COMPETITIVE-LANDSCAPE-2026-08.md +267 -0
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DEMO.md +49 -0
- package/docs/DIAGNOSTICS.md +80 -0
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/HANDOFF.md +180 -0
- package/docs/INSTALLATION.md +21 -18
- package/docs/MCP.md +64 -8
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/PT-ACCESS-RECOMMENDATION.md +89 -0
- package/docs/RELEASE-0.3-EVIDENCE.md +73 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +18 -6
- package/docs/SCIP-IMPORT.md +62 -0
- package/docs/SIGNED-UPDATES.md +151 -0
- package/docs/TELEMETRY.md +46 -0
- package/docs/TOKEN-OPTIMIZER-SCORECARD.md +79 -0
- package/docs/assets/knodin-favicon.svg +4 -0
- package/docs/releases/0.3.0.md +46 -0
- package/docs/releases/0.4.0.md +68 -0
- package/docs/releases/0.4.1.md +28 -0
- package/docs/releases/0.4.2.md +27 -0
- package/docs/releases/0.4.3.md +23 -0
- package/docs/releases/0.5.0.md +29 -0
- package/docs/releases/0.5.1.md +17 -0
- package/docs/releases/0.6.0.md +18 -0
- package/docs/releases/0.7.0.md +24 -0
- package/docs/releases/0.7.1.md +21 -0
- package/docs/releases/0.7.2.md +21 -0
- package/docs/releases/0.7.3.md +23 -0
- package/docs/releases/0.7.4.md +17 -0
- package/docs/releases/0.7.5.md +20 -0
- package/docs/releases/0.8.0.md +74 -0
- package/docs/releases/0.8.2.md +34 -0
- package/docs/releases/0.8.3.md +47 -0
- package/package.json +139 -4
- package/roadmap/competitive-roadmap.md +3896 -0
- package/schemas/release-attestation-v1.schema.json +210 -0
- package/schemas/support-bundle-v2.schema.json +212 -0
- package/dist/chunks/chunk-DMQAGX77.js +0 -654
- package/dist/chunks/chunk-F4Z3Z766.js +0 -4
- package/dist/chunks/chunk-SIJAQVSX.js +0 -3
- package/dist/chunks/chunk-X6M4HUUE.js +0 -2
- package/dist/chunks/chunk-YPRMY2LP.js +0 -8
- package/dist/chunks/pure-compression-cli-4TA2TQD5.js +0 -5
- package/dist/chunks/server-7EDF4CBY.js +0 -14
- package/dist/chunks/structural-fast-path-KD5KQSPX.js +0 -4
- package/docs/releases/0.7.6.md +0 -25
|
@@ -0,0 +1,1476 @@
|
|
|
1
|
+
import child_process from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import zlib from "node:zlib";
|
|
8
|
+
import { compareBytes } from "./compare.js";
|
|
9
|
+
const CONFIG_PATH = ".knodin/diagnostics/config.json";
|
|
10
|
+
const JOURNAL_PATH = ".knodin/diagnostics/events.jsonl";
|
|
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
|
+
];
|
|
50
|
+
const DEFAULT_RETENTION_DAYS = 14;
|
|
51
|
+
const MAX_RETENTION_DAYS = 365;
|
|
52
|
+
const MAX_RECORDS = 500;
|
|
53
|
+
const MAX_MCP_LOG_BYTES = 256 * 1024;
|
|
54
|
+
const MAX_BUNDLE_BYTES = 512 * 1024;
|
|
55
|
+
const MAX_BUNDLE_RECORDS = 32;
|
|
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;
|
|
60
|
+
const OPERATIONS = new Set([
|
|
61
|
+
"init",
|
|
62
|
+
"configure",
|
|
63
|
+
"index",
|
|
64
|
+
"doctor",
|
|
65
|
+
"status",
|
|
66
|
+
"wait",
|
|
67
|
+
"repair",
|
|
68
|
+
"serve",
|
|
69
|
+
"context",
|
|
70
|
+
"explain",
|
|
71
|
+
"review",
|
|
72
|
+
"map",
|
|
73
|
+
"search",
|
|
74
|
+
"query",
|
|
75
|
+
"rename",
|
|
76
|
+
"wiki",
|
|
77
|
+
"visualize",
|
|
78
|
+
"pack",
|
|
79
|
+
"compress",
|
|
80
|
+
"prs",
|
|
81
|
+
"worktrees",
|
|
82
|
+
"telemetry",
|
|
83
|
+
"diagnostics",
|
|
84
|
+
"system",
|
|
85
|
+
"repos",
|
|
86
|
+
"repositories",
|
|
87
|
+
"remote",
|
|
88
|
+
"update",
|
|
89
|
+
"docs",
|
|
90
|
+
"unknown",
|
|
91
|
+
]);
|
|
92
|
+
function containedPath(repoPath, requested, purpose) {
|
|
93
|
+
const repo = fs.realpathSync(repoPath);
|
|
94
|
+
const target = path.resolve(repo, requested);
|
|
95
|
+
if (target !== repo && !target.startsWith(`${repo}${path.sep}`))
|
|
96
|
+
throw new Error(`knodin diagnostics ${purpose}: path must stay inside the repository`);
|
|
97
|
+
let cursor = repo;
|
|
98
|
+
for (const segment of path.relative(repo, target).split(path.sep).filter(Boolean)) {
|
|
99
|
+
cursor = path.join(cursor, segment);
|
|
100
|
+
try {
|
|
101
|
+
if (fs.lstatSync(cursor).isSymbolicLink())
|
|
102
|
+
throw new Error(`knodin diagnostics ${purpose}: refusing symlinked path`);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error.code === "ENOENT")
|
|
106
|
+
break;
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { repo, target };
|
|
111
|
+
}
|
|
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
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function withJournalLock(repo, run) {
|
|
406
|
+
const { target: lock } = containedPath(repo, JOURNAL_LOCK_PATH, "record");
|
|
407
|
+
const ownerToken = crypto.randomBytes(16).toString("hex");
|
|
408
|
+
let ownedIdentity;
|
|
409
|
+
for (let attempt = 0;; attempt++) {
|
|
410
|
+
try {
|
|
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");
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
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)
|
|
433
|
+
throw error;
|
|
434
|
+
try {
|
|
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
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// A malformed or partially-created owner is busy until its bounded retry expires.
|
|
464
|
+
}
|
|
465
|
+
if (attempt >= JOURNAL_LOCK_ATTEMPTS)
|
|
466
|
+
throw new Error("knodin diagnostics record: journal is busy");
|
|
467
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, JOURNAL_LOCK_POLL_MS);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
return run();
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
try {
|
|
475
|
+
privateDelete(repo, lock, ownerToken);
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// Recording is best-effort; stale-lock recovery handles interrupted cleanup.
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function validRetention(days) {
|
|
483
|
+
if (!Number.isInteger(days) || days < 1 || days > MAX_RETENTION_DAYS)
|
|
484
|
+
throw new Error(`knodin diagnostics: retention days must be an integer from 1 to ${MAX_RETENTION_DAYS}`);
|
|
485
|
+
return days;
|
|
486
|
+
}
|
|
487
|
+
function readConfig(repoPath) {
|
|
488
|
+
const { repo, target } = containedPath(repoPath, CONFIG_PATH, "status");
|
|
489
|
+
if (!fs.existsSync(target))
|
|
490
|
+
return null;
|
|
491
|
+
try {
|
|
492
|
+
const value = JSON.parse(readPrivateFile(repo, target, 64 * 1024, "config").toString("utf8"));
|
|
493
|
+
if (value.schemaVersion !== 1 || value.enabled !== true)
|
|
494
|
+
return null;
|
|
495
|
+
validRetention(value.retentionDays);
|
|
496
|
+
if (!Number.isInteger(value.generation) || value.generation < 1)
|
|
497
|
+
value.generation = 1;
|
|
498
|
+
if (typeof value.sessionId !== "string" || !/^[a-f0-9]{32}$/.test(value.sessionId))
|
|
499
|
+
value.sessionId = crypto
|
|
500
|
+
.createHash("sha256")
|
|
501
|
+
.update(value.enabledAt)
|
|
502
|
+
.digest("hex")
|
|
503
|
+
.slice(0, 32);
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function safeLabel(value, fallback) {
|
|
511
|
+
return value && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,63}$/.test(value) ? value : fallback;
|
|
512
|
+
}
|
|
513
|
+
function errorCode(error) {
|
|
514
|
+
const code = error?.code;
|
|
515
|
+
return typeof code === "string" &&
|
|
516
|
+
/^(?:E[A-Z0-9]+|ERR_[A-Z0-9_]+|SQLITE_[A-Z0-9_]+|KNODIN_[A-Z0-9_]+)$/.test(code)
|
|
517
|
+
? code
|
|
518
|
+
: null;
|
|
519
|
+
}
|
|
520
|
+
function scrubText(raw, repo) {
|
|
521
|
+
let value = raw;
|
|
522
|
+
let redactions = 0;
|
|
523
|
+
const replace = (pattern, replacement) => {
|
|
524
|
+
value = value.replace(pattern, () => {
|
|
525
|
+
redactions++;
|
|
526
|
+
return replacement;
|
|
527
|
+
});
|
|
528
|
+
};
|
|
529
|
+
for (const exact of [repo, os.homedir()].filter(Boolean).sort((a, b) => b.length - a.length))
|
|
530
|
+
replace(new RegExp(exact.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`), "g"), "<path>");
|
|
531
|
+
replace(/\b(?:ghp|github_pat|sk|xox[baprs])-[-A-Za-z0-9_]{10,}\b/g, "<secret>");
|
|
532
|
+
replace(/\b(?:password|passwd|token|secret|api[_-]?key|authorization)\s*[=:]\s*[^\s,;]+/gi, "$1=<secret>");
|
|
533
|
+
replace(/\b[A-Z]:\\(?:[^\s<>:"|?*]+\\)*[^\s<>:"|?*]*/g, "<path>");
|
|
534
|
+
replace(/(?:^|[\s('"`])\/(?:[^\s)'"`]+\/)*[^\s)'"`]*/g, "<path>");
|
|
535
|
+
replace(/\b(?:[A-Za-z0-9_.-]+\/)+(?:[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,12})\b/g, "<path>");
|
|
536
|
+
replace(/\b[A-Za-z0-9_.+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "<email>");
|
|
537
|
+
return { value: value.slice(0, 2_000), redactions };
|
|
538
|
+
}
|
|
539
|
+
function sanitizeStack(error, repo) {
|
|
540
|
+
return (error.stack ?? "")
|
|
541
|
+
.split(/\r?\n/)
|
|
542
|
+
.slice(1, 9)
|
|
543
|
+
.map((line) => `at ${line.includes(repo) ? "<repository>" : "<runtime>"}`);
|
|
544
|
+
}
|
|
545
|
+
function readEvents(repoPath, retentionDays, since) {
|
|
546
|
+
const { target } = containedPath(repoPath, JOURNAL_PATH, "read");
|
|
547
|
+
if (!fs.existsSync(target))
|
|
548
|
+
return [];
|
|
549
|
+
const cutoff = Math.max(Date.now() - validRetention(retentionDays) * 86_400_000, since?.getTime() ?? Number.NEGATIVE_INFINITY);
|
|
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")
|
|
561
|
+
.split(/\r?\n/)
|
|
562
|
+
.filter(Boolean)
|
|
563
|
+
.flatMap((line) => {
|
|
564
|
+
try {
|
|
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];
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
return [];
|
|
605
|
+
}
|
|
606
|
+
})
|
|
607
|
+
.slice(-MAX_RECORDS);
|
|
608
|
+
}
|
|
609
|
+
export function enableDiagnostics(repoPath, retentionDays = DEFAULT_RETENTION_DAYS) {
|
|
610
|
+
const { repo, target } = containedPath(repoPath, CONFIG_PATH, "enable");
|
|
611
|
+
withJournalLock(repo, () => {
|
|
612
|
+
const previous = readConfig(repo);
|
|
613
|
+
const config = {
|
|
614
|
+
schemaVersion: 1,
|
|
615
|
+
enabled: true,
|
|
616
|
+
retentionDays: validRetention(retentionDays),
|
|
617
|
+
enabledAt: new Date().toISOString(),
|
|
618
|
+
generation: (previous?.generation ?? 0) + 1,
|
|
619
|
+
sessionId: crypto.randomBytes(16).toString("hex"),
|
|
620
|
+
};
|
|
621
|
+
atomicPrivateWrite(repo, target, `${JSON.stringify(config, null, 2)}\n`);
|
|
622
|
+
});
|
|
623
|
+
return { ...diagnosticsStatus(repo), message: "Local diagnostics enabled; nothing is uploaded." };
|
|
624
|
+
}
|
|
625
|
+
export function disableDiagnostics(repoPath) {
|
|
626
|
+
const { repo, target } = containedPath(repoPath, CONFIG_PATH, "disable");
|
|
627
|
+
withJournalLock(repo, () => {
|
|
628
|
+
privateDelete(repo, target);
|
|
629
|
+
});
|
|
630
|
+
return {
|
|
631
|
+
...diagnosticsStatus(repo),
|
|
632
|
+
message: "Local diagnostics disabled; retained events were not deleted.",
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
export function diagnosticsStatus(repoPath) {
|
|
636
|
+
const config = readConfig(repoPath);
|
|
637
|
+
const retentionDays = config?.retentionDays ?? DEFAULT_RETENTION_DAYS;
|
|
638
|
+
let events = [];
|
|
639
|
+
let journal = {
|
|
640
|
+
status: "healthy",
|
|
641
|
+
issue: null,
|
|
642
|
+
};
|
|
643
|
+
try {
|
|
644
|
+
events = readEvents(repoPath, retentionDays);
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
journal = {
|
|
648
|
+
status: "unavailable",
|
|
649
|
+
issue: error instanceof Error ? error.message : "diagnostic journal is unavailable",
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
schemaVersion: 1,
|
|
654
|
+
enabled: config !== null,
|
|
655
|
+
localOnly: true,
|
|
656
|
+
uploaded: false,
|
|
657
|
+
retentionDays,
|
|
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),
|
|
671
|
+
oldestAt: events[0]?.at ?? null,
|
|
672
|
+
newestAt: events.at(-1)?.at ?? null,
|
|
673
|
+
journal,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
export function recordDiagnosticFailure(repoPath, input) {
|
|
677
|
+
try {
|
|
678
|
+
const repo = fs.realpathSync(repoPath);
|
|
679
|
+
const observed = readConfig(repo);
|
|
680
|
+
if (!observed)
|
|
681
|
+
return { recorded: false, reason: "disabled" };
|
|
682
|
+
return withJournalLock(repo, () => {
|
|
683
|
+
const config = readConfig(repo);
|
|
684
|
+
if (!config)
|
|
685
|
+
return { recorded: false, reason: "disabled" };
|
|
686
|
+
if (config.sessionId !== observed.sessionId || config.generation !== observed.generation)
|
|
687
|
+
return { recorded: false, reason: "state-changed" };
|
|
688
|
+
const error = input.error instanceof Error ? input.error : new Error(String(input.error));
|
|
689
|
+
const message = scrubText(error.message, repo).value;
|
|
690
|
+
const event = {
|
|
691
|
+
schemaVersion: 1,
|
|
692
|
+
at: new Date().toISOString(),
|
|
693
|
+
correlationId: crypto.randomBytes(8).toString("hex"),
|
|
694
|
+
surface: input.surface,
|
|
695
|
+
operation: OPERATIONS.has(input.operation) ? input.operation : "unknown",
|
|
696
|
+
phase: safeLabel(input.phase, "operation"),
|
|
697
|
+
error: {
|
|
698
|
+
name: safeLabel(error.name, "Error"),
|
|
699
|
+
code: errorCode(input.error),
|
|
700
|
+
messageFingerprint: crypto
|
|
701
|
+
.createHash("sha256")
|
|
702
|
+
.update(message)
|
|
703
|
+
.digest("hex")
|
|
704
|
+
.slice(0, 16),
|
|
705
|
+
stack: sanitizeStack(error, repo),
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
const events = [...readEvents(repo, config.retentionDays), event].slice(-MAX_RECORDS);
|
|
709
|
+
const { target } = containedPath(repo, JOURNAL_PATH, "record");
|
|
710
|
+
atomicPrivateWrite(repo, target, `${events.map((record) => JSON.stringify(record)).join("\n")}\n`);
|
|
711
|
+
return { recorded: true, correlationId: event.correlationId };
|
|
712
|
+
});
|
|
713
|
+
}
|
|
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);
|
|
720
|
+
return { recorded: false, reason: "unavailable" };
|
|
721
|
+
}
|
|
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
|
+
}
|
|
754
|
+
export function clearDiagnostics(repoPath) {
|
|
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
|
+
}
|
|
766
|
+
return withJournalLock(repo, () => {
|
|
767
|
+
if (!fs.existsSync(target))
|
|
768
|
+
return { removed: false, records: 0, bytes: 0 };
|
|
769
|
+
let records = null;
|
|
770
|
+
try {
|
|
771
|
+
records = readEvents(repo, MAX_RETENTION_DAYS).length;
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
// Deletion must remain the recovery path for an unreadable or oversized journal.
|
|
775
|
+
}
|
|
776
|
+
const bytes = privateDelete(repo, target);
|
|
777
|
+
if (bytes === null)
|
|
778
|
+
return { removed: false, records: 0, bytes: 0 };
|
|
779
|
+
const config = readConfig(repo);
|
|
780
|
+
if (config) {
|
|
781
|
+
atomicPrivateWrite(repo, containedPath(repo, CONFIG_PATH, "clear").target, `${JSON.stringify({ ...config, generation: config.generation + 1 }, null, 2)}\n`);
|
|
782
|
+
}
|
|
783
|
+
return { removed: true, records, bytes };
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
function defaultBundlePath() {
|
|
787
|
+
return `.knodin/diagnostics/knodin-diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.json.gz`;
|
|
788
|
+
}
|
|
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;
|
|
857
|
+
try {
|
|
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
|
+
}
|
|
909
|
+
}
|
|
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
|
+
};
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
records: records.slice(-MAX_BUNDLE_RECORDS),
|
|
920
|
+
unavailable: found ? null : "No MCP trace journal exists for this checkout",
|
|
921
|
+
};
|
|
922
|
+
}
|
|
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);
|
|
1002
|
+
const sinceHours = options.sinceHours ?? 24;
|
|
1003
|
+
if (!Number.isFinite(sinceHours) || sinceHours <= 0 || sinceHours > 24 * 365)
|
|
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,
|
|
1117
|
+
manifest: {
|
|
1118
|
+
schemaVersion: 2,
|
|
1119
|
+
privacy: "explicit-allowlist-local-only",
|
|
1120
|
+
localOnly: true,
|
|
1121
|
+
uploaded: false,
|
|
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),
|
|
1129
|
+
],
|
|
1130
|
+
files: manifestFiles,
|
|
1131
|
+
},
|
|
1132
|
+
report,
|
|
1133
|
+
files,
|
|
1134
|
+
};
|
|
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);
|
|
1235
|
+
return {
|
|
1236
|
+
schemaVersion: 2,
|
|
1237
|
+
outputPath: path.relative(repo, target),
|
|
1238
|
+
format: "gzip-json",
|
|
1239
|
+
localOnly: true,
|
|
1240
|
+
uploaded: false,
|
|
1241
|
+
bytes: fs.statSync(target).size,
|
|
1242
|
+
records: bundle.files["failures.json"].records.length,
|
|
1243
|
+
manifest: bundle.manifest,
|
|
1244
|
+
message: "Bundle created locally. Run `knodin diagnostics inspect <bundle>` before sharing it.",
|
|
1245
|
+
};
|
|
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
|
+
}
|
|
1452
|
+
export function inspectDiagnosticsBundle(repoPath, bundlePath) {
|
|
1453
|
+
const { repo, target } = containedPath(repoPath, bundlePath, "inspect");
|
|
1454
|
+
if (!fs.existsSync(target))
|
|
1455
|
+
throw new Error("knodin diagnostics inspect: bundle does not exist");
|
|
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;
|
|
1459
|
+
try {
|
|
1460
|
+
raw = JSON.parse(zlib
|
|
1461
|
+
.gunzipSync(readPrivateFile(repo, target, MAX_BUNDLE_BYTES, "inspect"), {
|
|
1462
|
+
maxOutputLength: MAX_BUNDLE_BYTES,
|
|
1463
|
+
})
|
|
1464
|
+
.toString("utf8"));
|
|
1465
|
+
}
|
|
1466
|
+
catch {
|
|
1467
|
+
throw new Error("knodin diagnostics inspect: invalid gzip JSON bundle");
|
|
1468
|
+
}
|
|
1469
|
+
const parsed = validateInspectedBundle(raw);
|
|
1470
|
+
return {
|
|
1471
|
+
manifest: parsed.manifest,
|
|
1472
|
+
report: parsed.report,
|
|
1473
|
+
sections: parsed.manifest.files.map((file) => file.path),
|
|
1474
|
+
bundle: parsed,
|
|
1475
|
+
};
|
|
1476
|
+
}
|