knodin 0.6.0 → 0.7.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 +26 -3
- package/dist/bin/cli.js +168 -12
- package/dist/bin/launcher.js +11 -0
- package/dist/src/agent-integration.js +3 -1
- package/dist/src/cli-args.js +8 -1
- package/dist/src/cli-model.js +35 -1
- package/dist/src/codeflow-replay.js +80 -0
- package/dist/src/competitive-cold-mcp.js +40 -0
- package/dist/src/competitive-manifest.js +106 -25
- package/dist/src/competitive-runner.js +37 -3
- package/dist/src/competitive-sandbox.js +1 -1
- package/dist/src/diagnostics.js +449 -0
- package/dist/src/engine/git-history.js +289 -0
- package/dist/src/engine/index.js +417 -70
- package/dist/src/engine/scip-import.js +408 -0
- package/dist/src/execution-profile.js +203 -0
- package/dist/src/failure-diagnosis.js +69 -10
- package/dist/src/hook-manager-integration.js +156 -0
- package/dist/src/init.js +319 -33
- package/dist/src/lifecycle-health.js +42 -4
- package/dist/src/output-telemetry.js +4 -0
- package/dist/src/progressive-evidence.js +473 -0
- package/dist/src/pure-compression-cli.js +101 -0
- package/dist/src/release-preflight.js +510 -0
- package/dist/src/repository-management.js +142 -0
- package/dist/src/response-budget.js +11 -1
- package/dist/src/server.js +22 -2
- package/dist/src/structural-fast-path.js +303 -0
- package/dist/src/structural-snapshot.js +33 -0
- package/dist/src/tools/knodin-tools.js +105 -14
- package/dist/src/update-ceremony.js +158 -0
- package/docs/CLI.md +22 -0
- package/docs/COMMAND-OUTPUT-COMPRESSION.md +31 -15
- package/docs/CONTAINED-EXECUTION.md +77 -0
- package/docs/DIAGNOSTICS.md +45 -0
- package/docs/DOCTOR-AND-UPDATES.md +5 -2
- package/docs/GIT-HISTORY-REVIEW.md +39 -0
- package/docs/MCP.md +15 -0
- package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
- package/docs/REPOSITORIES-AND-WORKTREES.md +30 -0
- package/docs/SCIP-IMPORT.md +57 -0
- package/docs/SIGNED-UPDATES.md +5 -0
- package/docs/TELEMETRY.md +4 -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/package.json +33 -2
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import zlib from "node:zlib";
|
|
6
|
+
const CONFIG_PATH = ".knodin/diagnostics/config.json";
|
|
7
|
+
const JOURNAL_PATH = ".knodin/diagnostics/events.jsonl";
|
|
8
|
+
const JOURNAL_LOCK_PATH = ".knodin/diagnostics/events.lock";
|
|
9
|
+
const DEFAULT_RETENTION_DAYS = 14;
|
|
10
|
+
const MAX_RETENTION_DAYS = 365;
|
|
11
|
+
const MAX_RECORDS = 500;
|
|
12
|
+
const MAX_LOG_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_BUNDLE_BYTES = 20 * 1024 * 1024;
|
|
14
|
+
const MAX_JOURNAL_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
const OPERATIONS = new Set([
|
|
16
|
+
"init",
|
|
17
|
+
"configure",
|
|
18
|
+
"index",
|
|
19
|
+
"doctor",
|
|
20
|
+
"status",
|
|
21
|
+
"wait",
|
|
22
|
+
"repair",
|
|
23
|
+
"serve",
|
|
24
|
+
"context",
|
|
25
|
+
"explain",
|
|
26
|
+
"review",
|
|
27
|
+
"map",
|
|
28
|
+
"search",
|
|
29
|
+
"query",
|
|
30
|
+
"rename",
|
|
31
|
+
"wiki",
|
|
32
|
+
"visualize",
|
|
33
|
+
"pack",
|
|
34
|
+
"compress",
|
|
35
|
+
"prs",
|
|
36
|
+
"worktrees",
|
|
37
|
+
"telemetry",
|
|
38
|
+
"diagnostics",
|
|
39
|
+
"system",
|
|
40
|
+
"repos",
|
|
41
|
+
"repositories",
|
|
42
|
+
"update",
|
|
43
|
+
"docs",
|
|
44
|
+
"unknown",
|
|
45
|
+
]);
|
|
46
|
+
function containedPath(repoPath, requested, purpose) {
|
|
47
|
+
const repo = fs.realpathSync(repoPath);
|
|
48
|
+
const target = path.resolve(repo, requested);
|
|
49
|
+
if (target !== repo && !target.startsWith(`${repo}${path.sep}`))
|
|
50
|
+
throw new Error(`knodin diagnostics ${purpose}: path must stay inside the repository`);
|
|
51
|
+
let cursor = repo;
|
|
52
|
+
for (const segment of path.relative(repo, target).split(path.sep).filter(Boolean)) {
|
|
53
|
+
cursor = path.join(cursor, segment);
|
|
54
|
+
try {
|
|
55
|
+
if (fs.lstatSync(cursor).isSymbolicLink())
|
|
56
|
+
throw new Error(`knodin diagnostics ${purpose}: refusing symlinked path`);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === "ENOENT")
|
|
60
|
+
break;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { repo, target };
|
|
65
|
+
}
|
|
66
|
+
function atomicPrivateWrite(target, data) {
|
|
67
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
68
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString("hex")}.tmp`;
|
|
69
|
+
fs.writeFileSync(temporary, data, { mode: 0o600, flag: "wx" });
|
|
70
|
+
fs.renameSync(temporary, target);
|
|
71
|
+
}
|
|
72
|
+
function withJournalLock(repo, run) {
|
|
73
|
+
const { target: lock } = containedPath(repo, JOURNAL_LOCK_PATH, "record");
|
|
74
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
|
|
75
|
+
for (let attempt = 0;; attempt++) {
|
|
76
|
+
try {
|
|
77
|
+
fs.mkdirSync(lock, { mode: 0o700 });
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code !== "EEXIST")
|
|
82
|
+
throw error;
|
|
83
|
+
try {
|
|
84
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 30_000) {
|
|
85
|
+
fs.rmdirSync(lock);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (inspectionError) {
|
|
90
|
+
if (inspectionError.code === "ENOENT")
|
|
91
|
+
continue;
|
|
92
|
+
throw inspectionError;
|
|
93
|
+
}
|
|
94
|
+
if (attempt >= 100)
|
|
95
|
+
throw new Error("knodin diagnostics record: journal is busy");
|
|
96
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
return run();
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
try {
|
|
104
|
+
fs.rmdirSync(lock);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Recording is best-effort; stale-lock recovery handles interrupted cleanup.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function validRetention(days) {
|
|
112
|
+
if (!Number.isInteger(days) || days < 1 || days > MAX_RETENTION_DAYS)
|
|
113
|
+
throw new Error(`knodin diagnostics: retention days must be an integer from 1 to ${MAX_RETENTION_DAYS}`);
|
|
114
|
+
return days;
|
|
115
|
+
}
|
|
116
|
+
function readConfig(repoPath) {
|
|
117
|
+
const { target } = containedPath(repoPath, CONFIG_PATH, "status");
|
|
118
|
+
if (!fs.existsSync(target))
|
|
119
|
+
return null;
|
|
120
|
+
try {
|
|
121
|
+
const value = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
122
|
+
if (value.schemaVersion !== 1 || value.enabled !== true)
|
|
123
|
+
return null;
|
|
124
|
+
validRetention(value.retentionDays);
|
|
125
|
+
if (!Number.isInteger(value.generation) || value.generation < 1)
|
|
126
|
+
value.generation = 1;
|
|
127
|
+
if (typeof value.sessionId !== "string" || !/^[a-f0-9]{32}$/.test(value.sessionId))
|
|
128
|
+
value.sessionId = crypto
|
|
129
|
+
.createHash("sha256")
|
|
130
|
+
.update(value.enabledAt)
|
|
131
|
+
.digest("hex")
|
|
132
|
+
.slice(0, 32);
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function safeLabel(value, fallback) {
|
|
140
|
+
return value && /^[A-Za-z0-9][A-Za-z0-9:._-]{0,63}$/.test(value) ? value : fallback;
|
|
141
|
+
}
|
|
142
|
+
function errorCode(error) {
|
|
143
|
+
const code = error?.code;
|
|
144
|
+
return typeof code === "string" &&
|
|
145
|
+
/^(?:E[A-Z0-9]+|ERR_[A-Z0-9_]+|SQLITE_[A-Z0-9_]+|KNODIN_[A-Z0-9_]+)$/.test(code)
|
|
146
|
+
? code
|
|
147
|
+
: null;
|
|
148
|
+
}
|
|
149
|
+
function scrubText(raw, repo) {
|
|
150
|
+
let value = raw;
|
|
151
|
+
let redactions = 0;
|
|
152
|
+
const replace = (pattern, replacement) => {
|
|
153
|
+
value = value.replace(pattern, () => {
|
|
154
|
+
redactions++;
|
|
155
|
+
return replacement;
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
for (const exact of [repo, os.homedir()].filter(Boolean).sort((a, b) => b.length - a.length))
|
|
159
|
+
replace(new RegExp(exact.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "<path>");
|
|
160
|
+
replace(/\b(?:ghp|github_pat|sk|xox[baprs])-[-A-Za-z0-9_]{10,}\b/g, "<secret>");
|
|
161
|
+
replace(/\b(?:password|passwd|token|secret|api[_-]?key|authorization)\s*[=:]\s*[^\s,;]+/gi, "$1=<secret>");
|
|
162
|
+
replace(/\b[A-Z]:\\(?:[^\s<>:"|?*]+\\)*[^\s<>:"|?*]*/g, "<path>");
|
|
163
|
+
replace(/(?:^|[\s('"`])\/(?:[^\s)'"`]+\/)*[^\s)'"`]*/g, "<path>");
|
|
164
|
+
replace(/\b(?:[A-Za-z0-9_.-]+\/)+(?:[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,12})\b/g, "<path>");
|
|
165
|
+
replace(/\b[A-Za-z0-9_.+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, "<email>");
|
|
166
|
+
return { value: value.slice(0, 2_000), redactions };
|
|
167
|
+
}
|
|
168
|
+
function sanitizeStack(error, repo) {
|
|
169
|
+
return (error.stack ?? "")
|
|
170
|
+
.split(/\r?\n/)
|
|
171
|
+
.slice(1, 9)
|
|
172
|
+
.map((line) => `at ${line.includes(repo) ? "<repository>" : "<runtime>"}`);
|
|
173
|
+
}
|
|
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
|
+
function readEvents(repoPath, retentionDays, since) {
|
|
200
|
+
const { target } = containedPath(repoPath, JOURNAL_PATH, "read");
|
|
201
|
+
if (!fs.existsSync(target))
|
|
202
|
+
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
|
+
const cutoff = Math.max(Date.now() - validRetention(retentionDays) * 86_400_000, since?.getTime() ?? Number.NEGATIVE_INFINITY);
|
|
206
|
+
return fs
|
|
207
|
+
.readFileSync(target, "utf8")
|
|
208
|
+
.split(/\r?\n/)
|
|
209
|
+
.filter(Boolean)
|
|
210
|
+
.flatMap((line) => {
|
|
211
|
+
try {
|
|
212
|
+
const record = JSON.parse(line);
|
|
213
|
+
return record.schemaVersion === 1 && Date.parse(record.at) >= cutoff ? [record] : [];
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
.slice(-MAX_RECORDS);
|
|
220
|
+
}
|
|
221
|
+
export function enableDiagnostics(repoPath, retentionDays = DEFAULT_RETENTION_DAYS) {
|
|
222
|
+
const { repo, target } = containedPath(repoPath, CONFIG_PATH, "enable");
|
|
223
|
+
withJournalLock(repo, () => {
|
|
224
|
+
const previous = readConfig(repo);
|
|
225
|
+
const config = {
|
|
226
|
+
schemaVersion: 1,
|
|
227
|
+
enabled: true,
|
|
228
|
+
retentionDays: validRetention(retentionDays),
|
|
229
|
+
enabledAt: new Date().toISOString(),
|
|
230
|
+
generation: (previous?.generation ?? 0) + 1,
|
|
231
|
+
sessionId: crypto.randomBytes(16).toString("hex"),
|
|
232
|
+
};
|
|
233
|
+
atomicPrivateWrite(target, `${JSON.stringify(config, null, 2)}\n`);
|
|
234
|
+
});
|
|
235
|
+
return { ...diagnosticsStatus(repo), message: "Local diagnostics enabled; nothing is uploaded." };
|
|
236
|
+
}
|
|
237
|
+
export function disableDiagnostics(repoPath) {
|
|
238
|
+
const { repo, target } = containedPath(repoPath, CONFIG_PATH, "disable");
|
|
239
|
+
withJournalLock(repo, () => {
|
|
240
|
+
if (fs.existsSync(target))
|
|
241
|
+
fs.unlinkSync(target);
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
...diagnosticsStatus(repo),
|
|
245
|
+
message: "Local diagnostics disabled; retained events were not deleted.",
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
export function diagnosticsStatus(repoPath) {
|
|
249
|
+
const config = readConfig(repoPath);
|
|
250
|
+
const retentionDays = config?.retentionDays ?? DEFAULT_RETENTION_DAYS;
|
|
251
|
+
let events = [];
|
|
252
|
+
let journal = {
|
|
253
|
+
status: "healthy",
|
|
254
|
+
issue: null,
|
|
255
|
+
};
|
|
256
|
+
try {
|
|
257
|
+
events = readEvents(repoPath, retentionDays);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
journal = {
|
|
261
|
+
status: "unavailable",
|
|
262
|
+
issue: error instanceof Error ? error.message : "diagnostic journal is unavailable",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
schemaVersion: 1,
|
|
267
|
+
enabled: config !== null,
|
|
268
|
+
localOnly: true,
|
|
269
|
+
uploaded: false,
|
|
270
|
+
retentionDays,
|
|
271
|
+
records: events.length,
|
|
272
|
+
oldestAt: events[0]?.at ?? null,
|
|
273
|
+
newestAt: events.at(-1)?.at ?? null,
|
|
274
|
+
journal,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
export function recordDiagnosticFailure(repoPath, input) {
|
|
278
|
+
try {
|
|
279
|
+
const repo = fs.realpathSync(repoPath);
|
|
280
|
+
const observed = readConfig(repo);
|
|
281
|
+
if (!observed)
|
|
282
|
+
return { recorded: false, reason: "disabled" };
|
|
283
|
+
return withJournalLock(repo, () => {
|
|
284
|
+
const config = readConfig(repo);
|
|
285
|
+
if (!config)
|
|
286
|
+
return { recorded: false, reason: "disabled" };
|
|
287
|
+
if (config.sessionId !== observed.sessionId || config.generation !== observed.generation)
|
|
288
|
+
return { recorded: false, reason: "state-changed" };
|
|
289
|
+
const error = input.error instanceof Error ? input.error : new Error(String(input.error));
|
|
290
|
+
const message = scrubText(error.message, repo).value;
|
|
291
|
+
const event = {
|
|
292
|
+
schemaVersion: 1,
|
|
293
|
+
at: new Date().toISOString(),
|
|
294
|
+
correlationId: crypto.randomBytes(8).toString("hex"),
|
|
295
|
+
surface: input.surface,
|
|
296
|
+
operation: OPERATIONS.has(input.operation) ? input.operation : "unknown",
|
|
297
|
+
phase: safeLabel(input.phase, "operation"),
|
|
298
|
+
error: {
|
|
299
|
+
name: safeLabel(error.name, "Error"),
|
|
300
|
+
code: errorCode(input.error),
|
|
301
|
+
messageFingerprint: crypto
|
|
302
|
+
.createHash("sha256")
|
|
303
|
+
.update(message)
|
|
304
|
+
.digest("hex")
|
|
305
|
+
.slice(0, 16),
|
|
306
|
+
stack: sanitizeStack(error, repo),
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
const events = [...readEvents(repo, config.retentionDays), event].slice(-MAX_RECORDS);
|
|
310
|
+
const { target } = containedPath(repo, JOURNAL_PATH, "record");
|
|
311
|
+
atomicPrivateWrite(target, `${events.map((record) => JSON.stringify(record)).join("\n")}\n`);
|
|
312
|
+
return { recorded: true, correlationId: event.correlationId };
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return { recorded: false, reason: "unavailable" };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
export function clearDiagnostics(repoPath) {
|
|
320
|
+
const { repo, target } = containedPath(repoPath, JOURNAL_PATH, "clear");
|
|
321
|
+
return withJournalLock(repo, () => {
|
|
322
|
+
if (!fs.existsSync(target))
|
|
323
|
+
return { removed: false, records: 0, bytes: 0 };
|
|
324
|
+
let records = null;
|
|
325
|
+
try {
|
|
326
|
+
records = readEvents(repo, MAX_RETENTION_DAYS).length;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// Deletion must remain the recovery path for an unreadable or oversized journal.
|
|
330
|
+
}
|
|
331
|
+
const bytes = fs.statSync(target).size;
|
|
332
|
+
fs.unlinkSync(target);
|
|
333
|
+
const config = readConfig(repo);
|
|
334
|
+
if (config) {
|
|
335
|
+
atomicPrivateWrite(containedPath(repo, CONFIG_PATH, "clear").target, `${JSON.stringify({ ...config, generation: config.generation + 1 }, null, 2)}\n`);
|
|
336
|
+
}
|
|
337
|
+
return { removed: true, records, bytes };
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
function defaultBundlePath() {
|
|
341
|
+
return `.knodin/diagnostics/knodin-diagnostics-${new Date().toISOString().replace(/[:.]/g, "-")}.json.gz`;
|
|
342
|
+
}
|
|
343
|
+
function lifecycleLog(repo, state) {
|
|
344
|
+
const target = path.join(repo, ".knodin", "indexer.log");
|
|
345
|
+
if (!fs.existsSync(target) || fs.lstatSync(target).isSymbolicLink())
|
|
346
|
+
return [];
|
|
347
|
+
const size = fs.statSync(target).size;
|
|
348
|
+
const descriptor = fs.openSync(target, "r");
|
|
349
|
+
try {
|
|
350
|
+
const length = Math.min(size, MAX_LOG_BYTES);
|
|
351
|
+
const buffer = Buffer.alloc(length);
|
|
352
|
+
fs.readSync(descriptor, buffer, 0, length, Math.max(0, size - length));
|
|
353
|
+
return buffer
|
|
354
|
+
.toString("utf8")
|
|
355
|
+
.split(/\r?\n/)
|
|
356
|
+
.filter(Boolean)
|
|
357
|
+
.slice(-200)
|
|
358
|
+
.map((line) => sanitizeUnknown(line, repo, state));
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
fs.closeSync(descriptor);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
export function collectDiagnostics(repoPath, options = {}) {
|
|
365
|
+
const { repo, target } = containedPath(repoPath, options.outputPath ?? defaultBundlePath(), "collect");
|
|
366
|
+
if (fs.existsSync(target))
|
|
367
|
+
throw new Error("knodin diagnostics collect: refusing to overwrite an existing bundle");
|
|
368
|
+
const sinceHours = options.sinceHours ?? 24;
|
|
369
|
+
if (!Number.isFinite(sinceHours) || sinceHours <= 0 || sinceHours > 24 * 365)
|
|
370
|
+
throw new Error("knodin diagnostics collect: --since must be from 1h through 8760h");
|
|
371
|
+
const state = { redactions: 0 };
|
|
372
|
+
const status = diagnosticsStatus(repo);
|
|
373
|
+
const bundle = {
|
|
374
|
+
manifest: {
|
|
375
|
+
schemaVersion: 1,
|
|
376
|
+
generatedAt: new Date().toISOString(),
|
|
377
|
+
privacy: "redacted-local-only",
|
|
378
|
+
localOnly: true,
|
|
379
|
+
uploaded: false,
|
|
380
|
+
redactions: 0,
|
|
381
|
+
omissions: [
|
|
382
|
+
"source",
|
|
383
|
+
"queries-and-arguments",
|
|
384
|
+
"environment-values",
|
|
385
|
+
"git-remotes-diffs-and-messages",
|
|
386
|
+
"raw-paths",
|
|
387
|
+
],
|
|
388
|
+
},
|
|
389
|
+
runtime: {
|
|
390
|
+
...(options.knodinVersion ? { knodinVersion: options.knodinVersion } : {}),
|
|
391
|
+
node: process.version,
|
|
392
|
+
platform: process.platform,
|
|
393
|
+
arch: process.arch,
|
|
394
|
+
},
|
|
395
|
+
diagnostics: status,
|
|
396
|
+
diagnosticEvents: readEvents(repo, status.retentionDays, new Date(Date.now() - sinceHours * 3_600_000)),
|
|
397
|
+
telemetry: sanitizeUnknown(options.telemetry ?? [], repo, state),
|
|
398
|
+
doctor: sanitizeUnknown(options.doctor ?? null, repo, state),
|
|
399
|
+
graph: sanitizeUnknown(options.graph ?? null, repo, state),
|
|
400
|
+
lifecycleLog: lifecycleLog(repo, state),
|
|
401
|
+
};
|
|
402
|
+
bundle.manifest.redactions = state.redactions;
|
|
403
|
+
atomicPrivateWrite(target, zlib.gzipSync(`${JSON.stringify(bundle, null, 2)}\n`, { level: 9 }));
|
|
404
|
+
return {
|
|
405
|
+
schemaVersion: 1,
|
|
406
|
+
outputPath: path.relative(repo, target),
|
|
407
|
+
format: "gzip-json",
|
|
408
|
+
localOnly: true,
|
|
409
|
+
uploaded: false,
|
|
410
|
+
bytes: fs.statSync(target).size,
|
|
411
|
+
records: bundle.diagnosticEvents.length,
|
|
412
|
+
redactions: bundle.manifest.redactions,
|
|
413
|
+
message: "Bundle created locally. Run `knodin diagnostics inspect <bundle>` before sharing it.",
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
export function inspectDiagnosticsBundle(repoPath, bundlePath) {
|
|
417
|
+
const { target } = containedPath(repoPath, bundlePath, "inspect");
|
|
418
|
+
if (!fs.existsSync(target))
|
|
419
|
+
throw new Error("knodin diagnostics inspect: bundle does not exist");
|
|
420
|
+
if (fs.statSync(target).size > 10 * 1024 * 1024)
|
|
421
|
+
throw new Error("knodin diagnostics inspect: bundle exceeds the 10 MiB safety limit");
|
|
422
|
+
let parsed;
|
|
423
|
+
try {
|
|
424
|
+
parsed = JSON.parse(zlib
|
|
425
|
+
.gunzipSync(fs.readFileSync(target), { maxOutputLength: MAX_BUNDLE_BYTES })
|
|
426
|
+
.toString("utf8"));
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
throw new Error("knodin diagnostics inspect: invalid gzip JSON bundle");
|
|
430
|
+
}
|
|
431
|
+
if (parsed.manifest?.schemaVersion !== 1 || parsed.manifest.privacy !== "redacted-local-only")
|
|
432
|
+
throw new Error("knodin diagnostics inspect: unsupported or unsafe bundle manifest");
|
|
433
|
+
if (!Array.isArray(parsed.diagnosticEvents) ||
|
|
434
|
+
!Array.isArray(parsed.telemetry) ||
|
|
435
|
+
!Array.isArray(parsed.lifecycleLog))
|
|
436
|
+
throw new Error("knodin diagnostics inspect: malformed bundle sections");
|
|
437
|
+
return {
|
|
438
|
+
manifest: parsed.manifest,
|
|
439
|
+
runtime: parsed.runtime,
|
|
440
|
+
diagnostics: parsed.diagnostics,
|
|
441
|
+
sections: ["diagnosticEvents", "telemetry", "doctor", "graph", "lifecycleLog"],
|
|
442
|
+
counts: {
|
|
443
|
+
diagnosticEvents: parsed.diagnosticEvents.length,
|
|
444
|
+
telemetry: parsed.telemetry.length,
|
|
445
|
+
lifecycleLogLines: parsed.lifecycleLog.length,
|
|
446
|
+
},
|
|
447
|
+
bundle: parsed,
|
|
448
|
+
};
|
|
449
|
+
}
|