@sidady/dsh-steward 0.0.0-stage → 0.9.12
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/ACCEPTANCE.md +204 -0
- package/LICENSE +21 -0
- package/README.md +339 -2
- package/cordis.patch.yml +9 -0
- package/icon.svg +4 -0
- package/lib/actions.js +534 -0
- package/lib/brain.js +167 -0
- package/lib/client.js +1587 -0
- package/lib/index.js +780 -0
- package/lib/intent.js +298 -0
- package/lib/models.js +212 -0
- package/lib/report.js +1696 -0
- package/lib/settings.js +286 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +63 -4
- package/smoke.mjs +1381 -0
package/lib/report.js
ADDED
|
@@ -0,0 +1,1696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-steward · report.js
|
|
3
|
+
*
|
|
4
|
+
* Pure, read-only health-check data collection. ZERO imports from other DSH
|
|
5
|
+
* packages on purpose: this bundle must not inherit version drift from
|
|
6
|
+
* dshmarket / dsh-config-manager internals (that drift is pain point #1).
|
|
7
|
+
*
|
|
8
|
+
* Everything here is injectable so smoke.mjs can exercise it with fixtures
|
|
9
|
+
* without touching the real machine. All IO is best-effort: a failure becomes
|
|
10
|
+
* an entry in `errors`, never an exception that reaches the host boot fiber.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import { defaults as defaultSettings, value as settingValue } from './settings.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 不可由用户调整的硬限流(防止一份异常数据把体检拖死)。
|
|
20
|
+
*
|
|
21
|
+
* v0.8.0:原来这里还有 maxLogs / maxBytesPerLog,现在它们由用户在「设置」页
|
|
22
|
+
* 调(collect.maxLogs / collect.maxBytesPerLog),因为「读多少份日志」属于使用
|
|
23
|
+
* 偏好而不是安全底线。这两个仍留作兜底上限——即使用户填了极端值也不会越过。
|
|
24
|
+
*/
|
|
25
|
+
export const LIMITS = {
|
|
26
|
+
maxLogs: 200,
|
|
27
|
+
maxBytesPerLog: 16 * 1024 * 1024,
|
|
28
|
+
maxPatchBackups: 50,
|
|
29
|
+
maxBackups: 20,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** 由用户设置算出本次采集的实际限流(夹在用户值上,再夹在硬上限下)。 */
|
|
33
|
+
function limitsFor(settings) {
|
|
34
|
+
const logs = settingValue(settings, 'collect.maxLogs');
|
|
35
|
+
const bytesKb = settingValue(settings, 'collect.maxBytesPerLog');
|
|
36
|
+
return {
|
|
37
|
+
maxLogs: Math.min(LIMITS.maxLogs, logs),
|
|
38
|
+
maxBytesPerLog: Math.min(LIMITS.maxBytesPerLog, bytesKb * 1024),
|
|
39
|
+
maxPatchBackups: LIMITS.maxPatchBackups,
|
|
40
|
+
maxBackups: LIMITS.maxBackups,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
/** `%APPDATA%\@deepseek-ai\dsh-desktop\logs\crash-*.log` and profile dirs. */
|
|
47
|
+
export function defaultPaths(env = process.env, platform = process.platform) {
|
|
48
|
+
const home = os.homedir();
|
|
49
|
+
const appData = env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
50
|
+
const dshHome = env.DSH_HOME || path.join(home, '.dsh');
|
|
51
|
+
const logDirs = [path.join(appData, '@deepseek-ai', 'dsh-desktop', 'logs')];
|
|
52
|
+
if (platform !== 'win32') {
|
|
53
|
+
logDirs.push(path.join(home, 'Library', 'Application Support', '@deepseek-ai', 'dsh-desktop', 'logs'));
|
|
54
|
+
logDirs.push(path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), '@deepseek-ai', 'dsh-desktop', 'logs'));
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
logDirs,
|
|
58
|
+
profileDirs: [
|
|
59
|
+
path.join(dshHome, 'profiles', 'desktop'),
|
|
60
|
+
path.join(dshHome, 'profiles', 'default'),
|
|
61
|
+
],
|
|
62
|
+
dshHome,
|
|
63
|
+
appData,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Reproduces dshmarket/lib/recovery.js:92 `parseBootFailure` without importing it. */
|
|
68
|
+
export function parseBootFailure(text) {
|
|
69
|
+
if (typeof text !== 'string' || text.length === 0) return null;
|
|
70
|
+
const lines = text.split(/\r?\n/);
|
|
71
|
+
let didNotActivate = null;
|
|
72
|
+
let bootLine = null;
|
|
73
|
+
const suspects = [];
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
const m = /(\d+)\s+entr(?:y|ies) did not activate/i.exec(line);
|
|
76
|
+
if (m && didNotActivate === null) {
|
|
77
|
+
didNotActivate = Number(m[1]);
|
|
78
|
+
bootLine = line.trim();
|
|
79
|
+
}
|
|
80
|
+
const im = /([A-Za-z0-9@._-]+(?:\/[A-Za-z0-9@._-]+)?)\s*:\s*import failed/i.exec(line);
|
|
81
|
+
if (im && !suspects.includes(im[1])) suspects.push(im[1]);
|
|
82
|
+
}
|
|
83
|
+
// v0.9.3: parse the CAUSE, not just the victim. The renderer console section
|
|
84
|
+
// of a real crash log says it outright —
|
|
85
|
+
// client-modules: duplicate factory registration for "dsh-steward"
|
|
86
|
+
// (bundle executed twice without invalidate?)
|
|
87
|
+
// — while the `X: import failed` line only names the plugin that happened to
|
|
88
|
+
// lose the race. Reporting the second as the suspect is how a broken
|
|
89
|
+
// directory layout gets misdiagnosed as a broken plugin: the victim's own
|
|
90
|
+
// module imports fine.
|
|
91
|
+
const clientDuplicates = [];
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const dm = /duplicate factory registration for ["']([^"']+)["']/i.exec(line);
|
|
94
|
+
if (dm && !clientDuplicates.includes(dm[1])) clientDuplicates.push(dm[1]);
|
|
95
|
+
}
|
|
96
|
+
const isBoot = didNotActivate !== null || suspects.length > 0 || clientDuplicates.length > 0 || /web boot/i.test(text);
|
|
97
|
+
if (!isBoot) return null;
|
|
98
|
+
return { didNotActivate, suspects, clientDuplicates, bootLine, isBoot };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractMeta(text) {
|
|
102
|
+
const meta = {};
|
|
103
|
+
// Real crash logs put a plain `key: value` header BEFORE `--- error ---`.
|
|
104
|
+
// Read that block first. JSON-style "key":"value" is only a fallback for
|
|
105
|
+
// synthetic fixtures; otherwise a stray "phase":"started" in the
|
|
106
|
+
// operation-history JSON further down would be misread as the boot phase.
|
|
107
|
+
const header = String(text).split(/---\s*error\s*---/i)[0];
|
|
108
|
+
const grab = (out, lineKey, jsonRe) => {
|
|
109
|
+
const lineRe = new RegExp('^\\s*' + lineKey + '\\s*:\\s*(.+?)\\s*$', 'im');
|
|
110
|
+
const lm = lineRe.exec(header);
|
|
111
|
+
if (lm && lm[1] && lm[1].trim()) { meta[out] = lm[1].trim(); return; }
|
|
112
|
+
const jm = jsonRe.exec(text);
|
|
113
|
+
if (jm) meta[out] = jm[1];
|
|
114
|
+
};
|
|
115
|
+
grab('app', 'app', /"app"\s*:\s*"([^"]+)"/);
|
|
116
|
+
grab('electron', 'electron', /"electron"\s*:\s*"([^"]+)"/);
|
|
117
|
+
grab('node', 'node', /"node"\s*:\s*"([^"]+)"/);
|
|
118
|
+
grab('source', 'source', /"source"\s*:\s*"([^"]+)"/);
|
|
119
|
+
grab('phase', 'phase', /"phase"\s*:\s*"([^"]+)"/);
|
|
120
|
+
grab('shellPid', 'shell pid', /"pid"\s*:\s*(\d+)/);
|
|
121
|
+
return meta;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseCrashLog(text, file) {
|
|
125
|
+
const failure = parseBootFailure(text);
|
|
126
|
+
if (!failure) return null;
|
|
127
|
+
return {
|
|
128
|
+
file: file.name,
|
|
129
|
+
size: file.size,
|
|
130
|
+
mtime: file.mtime,
|
|
131
|
+
didNotActivate: failure.didNotActivate,
|
|
132
|
+
bootLine: failure.bootLine,
|
|
133
|
+
suspects: failure.suspects,
|
|
134
|
+
clientDuplicates: failure.clientDuplicates || [],
|
|
135
|
+
meta: extractMeta(text),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function safeIo(io) {
|
|
140
|
+
return {
|
|
141
|
+
readDir(dir) {
|
|
142
|
+
try { return (io?.readDir || fs.readdirSync)(dir, { withFileTypes: false }); }
|
|
143
|
+
catch (e) { return { __error: String(e && e.message || e) }; }
|
|
144
|
+
},
|
|
145
|
+
stat(file) {
|
|
146
|
+
try { return (io?.stat || fs.statSync)(file); }
|
|
147
|
+
catch (e) { return { __error: String(e && e.message || e) }; }
|
|
148
|
+
},
|
|
149
|
+
readText(file) {
|
|
150
|
+
try { return (io?.readText || ((p) => fs.readFileSync(p, 'utf8')))(file); }
|
|
151
|
+
catch (e) { return { __error: String(e && e.message || e) }; }
|
|
152
|
+
},
|
|
153
|
+
realpath(target) {
|
|
154
|
+
// Resolve a symlinked package dir to its true location. Two different
|
|
155
|
+
// names pointing at one realpath is exactly the state that broke boot on
|
|
156
|
+
// 2026-11-09 — and it is invisible to every name-based check.
|
|
157
|
+
try {
|
|
158
|
+
const fn = io?.realpath || (fs.realpathSync.native || fs.realpathSync);
|
|
159
|
+
return fn(target);
|
|
160
|
+
} catch { return null; }
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isAbsentDirError(err) {
|
|
166
|
+
return /ENOENT|no such file or directory/i.test(String(err || ''));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function collectCrashLogs(paths, io, errors, limits) {
|
|
170
|
+
const found = [];
|
|
171
|
+
for (let di = 0; di < paths.logDirs.length; di += 1) {
|
|
172
|
+
const dir = paths.logDirs[di];
|
|
173
|
+
const names = io.readDir(dir);
|
|
174
|
+
if (names && names.__error) {
|
|
175
|
+
// A missing CANDIDATE dir (non-primary platform layout) is expected.
|
|
176
|
+
// Don't let benign absence masquerade as a degraded report.
|
|
177
|
+
if (!(di > 0 && isAbsentDirError(names.__error))) {
|
|
178
|
+
errors.push({ step: 'crash-dir', dir, error: names.__error });
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!Array.isArray(names)) continue;
|
|
183
|
+
for (const name of names) {
|
|
184
|
+
if (!/^crash-.*\.log$/i.test(name)) continue;
|
|
185
|
+
const full = path.join(dir, name);
|
|
186
|
+
const st = io.stat(full);
|
|
187
|
+
if (st && st.__error) { errors.push({ step: 'crash-stat', file: full, error: st.__error }); continue; }
|
|
188
|
+
if (!st || !st.isFile || !st.isFile()) continue;
|
|
189
|
+
if (st.size > limits.maxBytesPerLog) {
|
|
190
|
+
errors.push({ step: 'crash-size', file: full, error: 'exceeds maxBytesPerLog=' + limits.maxBytesPerLog });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (found.length >= limits.maxLogs) { errors.push({ step: 'crash-limit', error: 'maxLogs=' + limits.maxLogs + ' reached' }); break; }
|
|
194
|
+
const text = io.readText(full);
|
|
195
|
+
if (text && text.__error) { errors.push({ step: 'crash-read', file: full, error: text.__error }); continue; }
|
|
196
|
+
const parsed = parseCrashLog(text, { name, size: st.size, mtime: st.mtime ? st.mtime.toISOString() : null });
|
|
197
|
+
if (parsed) found.push(parsed);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
found.sort((a, b) => String(b.mtime || '').localeCompare(String(a.mtime || '')));
|
|
201
|
+
return found;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function collectPatchBackups(paths, io, errors) {
|
|
205
|
+
const found = [];
|
|
206
|
+
for (let di = 0; di < paths.profileDirs.length; di += 1) {
|
|
207
|
+
const dir = paths.profileDirs[di];
|
|
208
|
+
const names = io.readDir(dir);
|
|
209
|
+
if (names && names.__error) {
|
|
210
|
+
if (!(di > 0 && isAbsentDirError(names.__error))) {
|
|
211
|
+
errors.push({ step: 'profile-dir', dir, error: names.__error });
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (!Array.isArray(names)) continue;
|
|
216
|
+
for (const name of names) {
|
|
217
|
+
const m = /^cordis\.patch\.yml\.bak-(\d{10,16})$/.exec(name);
|
|
218
|
+
if (!m) continue;
|
|
219
|
+
if (found.length >= LIMITS.maxPatchBackups) break;
|
|
220
|
+
const full = path.join(dir, name);
|
|
221
|
+
const st = io.stat(full);
|
|
222
|
+
const live = io.stat(path.join(dir, 'cordis.patch.yml'));
|
|
223
|
+
const liveText = io.readText(path.join(dir, 'cordis.patch.yml'));
|
|
224
|
+
const bakText = io.readText(full);
|
|
225
|
+
found.push({
|
|
226
|
+
file: name,
|
|
227
|
+
dir,
|
|
228
|
+
epochMs: Number(m[1]),
|
|
229
|
+
size: st && st.size != null ? st.size : null,
|
|
230
|
+
liveBytes: live && live.size != null ? live.size : null,
|
|
231
|
+
liveText: typeof liveText === 'string' ? liveText : '',
|
|
232
|
+
text: typeof bakText === 'string' ? bakText : '',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return found;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* ① Backup freshness (v0.7.6). Reads ONLY the export directory that
|
|
241
|
+
* dsh-config-manager already writes to — it does not produce backups itself and
|
|
242
|
+
* does not import the backup engine. The point is narrow and load-bearing: the
|
|
243
|
+
* report already says "volatile data is not in the default backup set", but it
|
|
244
|
+
* never knew whether the user had actually backed up recently. A caretaker that
|
|
245
|
+
* cannot answer "how many days since the last backup" is just a linter.
|
|
246
|
+
*/
|
|
247
|
+
export function collectBackupExports(paths, io, errors) {
|
|
248
|
+
const dir = path.join(paths.dshHome, 'dsh-config-manager', 'exports');
|
|
249
|
+
const names = io.readDir(dir);
|
|
250
|
+
if (names && names.__error) {
|
|
251
|
+
if (!isAbsentDirError(names.__error)) {
|
|
252
|
+
errors.push({ step: 'backup-dir', dir, error: names.__error });
|
|
253
|
+
}
|
|
254
|
+
return { dir, found: false, backups: [] };
|
|
255
|
+
}
|
|
256
|
+
if (!Array.isArray(names)) return { dir, found: false, backups: [] };
|
|
257
|
+
const backups = [];
|
|
258
|
+
for (const name of names) {
|
|
259
|
+
if (!/\.zip$/i.test(name)) continue;
|
|
260
|
+
if (backups.length >= LIMITS.maxBackups) break;
|
|
261
|
+
const full = path.join(dir, name);
|
|
262
|
+
const st = io.stat(full);
|
|
263
|
+
if (!st || st.__error || (st.isFile && !st.isFile())) continue;
|
|
264
|
+
backups.push({
|
|
265
|
+
file: name,
|
|
266
|
+
size: st.size != null ? st.size : null,
|
|
267
|
+
mtime: st.mtime ? st.mtime.toISOString() : null,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
backups.sort((a, b) => String(b.mtime || '').localeCompare(String(a.mtime || '')));
|
|
271
|
+
return { dir, found: true, backups };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* ④ Config drift (v0.7.6). Hashes cordis.patch.yml and package.json.
|
|
276
|
+
*
|
|
277
|
+
* Why this is load-bearing: host-recovery's sanitizeProfile renames
|
|
278
|
+
* cordis.patch.yml to <patch>.bak-<epoch>, writes a default stub, and one
|
|
279
|
+
* reported incident silently dropped 22 KB of providers/models/permission
|
|
280
|
+
* presets. It is far cheaper to notice "my 2692-line patch became 40 lines" than
|
|
281
|
+
* to reconstruct it. We never edit the file — we only record its digest.
|
|
282
|
+
*/
|
|
283
|
+
export function collectConfigFingerprint(paths, io, errors) {
|
|
284
|
+
const watched = ['cordis.patch.yml', 'package.json'];
|
|
285
|
+
const out = [];
|
|
286
|
+
for (const dir of paths.profileDirs) {
|
|
287
|
+
for (const name of watched) {
|
|
288
|
+
const full = path.join(dir, name);
|
|
289
|
+
const st = io.stat(full);
|
|
290
|
+
if (!st || st.__error) continue; // absent profile layout is normal
|
|
291
|
+
const text = io.readText(full);
|
|
292
|
+
if (text && text.__error) {
|
|
293
|
+
errors.push({ step: 'config-read', file: full, error: text.__error });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const buf = Buffer.from(String(text), 'utf8');
|
|
297
|
+
out.push({
|
|
298
|
+
file: name,
|
|
299
|
+
dir,
|
|
300
|
+
bytes: buf.length,
|
|
301
|
+
lines: String(text).split('\n').length,
|
|
302
|
+
digest: crypto.createHash('sha256').update(buf).digest('hex').slice(0, 16),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* ② Workspace / session inventory (v0.7.7).
|
|
311
|
+
*
|
|
312
|
+
* Reads the workspace index the host itself maintains
|
|
313
|
+
* ($DSH_HOME/storages/workspace.json) and measures each workspace's session
|
|
314
|
+
* directory. Strictly read-only: it never archives or deletes anything — it can
|
|
315
|
+
* only tell you which workspace is eating the disk and how long since it moved.
|
|
316
|
+
*
|
|
317
|
+
* Session directories are named after the workspace path by replacing path
|
|
318
|
+
* separators with '-', so "C:\\work" becomes "--C-work--". We encode
|
|
319
|
+
* the same way rather than guessing from the directory listing, so a workspace
|
|
320
|
+
* whose sessions were all deleted shows up as 0 B instead of being invisible.
|
|
321
|
+
*/
|
|
322
|
+
export function encodeWorkspaceDir(wsPath) {
|
|
323
|
+
if (typeof wsPath !== 'string' || !wsPath.trim()) return null;
|
|
324
|
+
const slug = wsPath.replace(/[:\\/]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
325
|
+
if (!slug) return null;
|
|
326
|
+
return '--' + slug + '--';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function dirSize(io, dir, depth = 0) {
|
|
330
|
+
if (depth > 3) return 0;
|
|
331
|
+
const names = io.readDir(dir);
|
|
332
|
+
if (!Array.isArray(names)) return 0;
|
|
333
|
+
let total = 0;
|
|
334
|
+
for (const name of names) {
|
|
335
|
+
const full = path.join(dir, name);
|
|
336
|
+
const st = io.stat(full);
|
|
337
|
+
if (!st || st.__error) continue;
|
|
338
|
+
if (st.isFile && st.isFile()) { total += st.size || 0; continue; }
|
|
339
|
+
if (st.isDirectory && st.isDirectory()) total += dirSize(io, full, depth + 1);
|
|
340
|
+
}
|
|
341
|
+
return total;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function collectWorkspaces(paths, io, errors) {
|
|
345
|
+
const file = path.join(paths.dshHome, 'storages', 'workspace.json');
|
|
346
|
+
const raw = io.readText(file);
|
|
347
|
+
const sessionsDir0 = path.join(paths.dshHome, 'sessions');
|
|
348
|
+
// Optional source: absence or an unreadable index means "not available", which
|
|
349
|
+
// the confidence layer reports as degraded evidence. It must NOT push an error
|
|
350
|
+
// entry — that would flip the whole report to 'degraded' over an optional file.
|
|
351
|
+
if (!raw || raw.__error || !String(raw).trim()) {
|
|
352
|
+
return { available: false, workspaces: [], totalBytes: 0, sessionsDir: sessionsDir0 };
|
|
353
|
+
}
|
|
354
|
+
let parsed;
|
|
355
|
+
try { parsed = JSON.parse(raw); } catch (e) {
|
|
356
|
+
return { available: false, workspaces: [], totalBytes: 0, sessionsDir: sessionsDir0 };
|
|
357
|
+
}
|
|
358
|
+
const table = (parsed && parsed.tables && parsed.tables.workspaces) || {};
|
|
359
|
+
const ids = (parsed && parsed.global && Array.isArray(parsed.global.workspaceIds))
|
|
360
|
+
? parsed.global.workspaceIds
|
|
361
|
+
: Object.keys(table);
|
|
362
|
+
const sessionsDir = path.join(paths.dshHome, 'sessions');
|
|
363
|
+
const out = [];
|
|
364
|
+
for (const id of ids) {
|
|
365
|
+
const w = table[id];
|
|
366
|
+
if (!w) continue;
|
|
367
|
+
const enc = encodeWorkspaceDir(w.path);
|
|
368
|
+
const dir = enc ? path.join(sessionsDir, enc) : null;
|
|
369
|
+
let bytes = 0;
|
|
370
|
+
let ageDays = null;
|
|
371
|
+
if (dir) {
|
|
372
|
+
const st = io.stat(dir);
|
|
373
|
+
if (st && !st.__error) {
|
|
374
|
+
bytes = dirSize(io, dir);
|
|
375
|
+
if (st.mtime) ageDays = Math.max(0, Math.floor((Date.now() - st.mtime.getTime()) / 864e5));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
let exists = false;
|
|
379
|
+
try { exists = !!(w.path && fs.existsSync(w.path)); } catch { exists = false; }
|
|
380
|
+
out.push({
|
|
381
|
+
id,
|
|
382
|
+
title: w.title || path.basename(w.path || '') || id,
|
|
383
|
+
path: w.path || null,
|
|
384
|
+
dirName: enc,
|
|
385
|
+
exists,
|
|
386
|
+
sessionCount: Array.isArray(w.sessionIds) ? w.sessionIds.length : 0,
|
|
387
|
+
bytes,
|
|
388
|
+
ageDays,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
out.sort((a, b) => b.bytes - a.bytes);
|
|
392
|
+
const totalBytes = out.reduce((s, w) => s + w.bytes, 0);
|
|
393
|
+
return { available: true, workspaces: out, totalBytes, sessionsDir };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* ③ Plugin update inventory (v0.7.7).
|
|
398
|
+
*
|
|
399
|
+
* The comparison itself is trivial; the hard part is where to read it from. The
|
|
400
|
+
* market keeps a single cache file at $DSH_HOME/plugin-update-cache.json
|
|
401
|
+
* (311 KB on this machine, refreshed whenever the market checks for updates).
|
|
402
|
+
* We read THAT instead of rebuilding a registry client: no network, no auth, no
|
|
403
|
+
* inferred format. If the file is missing or stale we say so rather than
|
|
404
|
+
* pretending everything is current.
|
|
405
|
+
*/
|
|
406
|
+
/**
|
|
407
|
+
* Direction between two published versions: 1 = a is newer, -1 = older,
|
|
408
|
+
* 0 = same, null = can't tell. `verCmp` is fed by the semver-range matcher and
|
|
409
|
+
* treats junk as 0, which is fine there but dangerous here — reading a tag like
|
|
410
|
+
* "beta" as "same version" would silently drop a real update. So gate on shape
|
|
411
|
+
* first and refuse to guess.
|
|
412
|
+
*/
|
|
413
|
+
function versionDirection(a, b) {
|
|
414
|
+
const shaped = (v) => typeof v === 'string' && /^\d+(\.\d+){0,3}([-+][0-9A-Za-z.-]+)?$/.test(v.trim());
|
|
415
|
+
if (!shaped(a) || !shaped(b)) return null;
|
|
416
|
+
return verCmp(a, b);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function collectUpdates(paths, io, errors) {
|
|
420
|
+
const file = path.join(paths.dshHome, 'plugin-update-cache.json');
|
|
421
|
+
const raw = io.readText(file);
|
|
422
|
+
const empty = { available: false, reason: '没有找到市场更新缓存', outdated: [], checked: 0, cacheAgeHours: null };
|
|
423
|
+
// Optional source, same rule as the workspace index: absence is information
|
|
424
|
+
// (and gets surfaced by the confidence layer), not a degraded report.
|
|
425
|
+
if (!raw || raw.__error || !String(raw).trim()) return empty;
|
|
426
|
+
let parsed;
|
|
427
|
+
try { parsed = JSON.parse(raw); } catch (e) {
|
|
428
|
+
return { ...empty, reason: '更新缓存无法解析' };
|
|
429
|
+
}
|
|
430
|
+
const packages = (parsed && parsed.packages) || {};
|
|
431
|
+
const fetchedAt = typeof parsed.fetchedAt === 'number' ? parsed.fetchedAt : null;
|
|
432
|
+
const cacheAgeHours = fetchedAt ? Math.max(0, Math.round((Date.now() - fetchedAt) / 36e5)) : null;
|
|
433
|
+
|
|
434
|
+
// Installed versions come from each profile's node_modules — the same place
|
|
435
|
+
// the compat check already reads, so no new contract is introduced.
|
|
436
|
+
const installed = new Map();
|
|
437
|
+
for (const dir of paths.profileDirs) {
|
|
438
|
+
const nm = path.join(dir, 'node_modules');
|
|
439
|
+
const entries = io.readDir(nm);
|
|
440
|
+
if (!Array.isArray(entries)) continue;
|
|
441
|
+
for (const name of entries) {
|
|
442
|
+
const readOne = (pkgName, base) => {
|
|
443
|
+
const pj = io.readText(path.join(base, 'package.json'));
|
|
444
|
+
if (!pj || pj.__error) return;
|
|
445
|
+
try {
|
|
446
|
+
const v = JSON.parse(pj);
|
|
447
|
+
if (v && v.name && v.version) installed.set(v.name, v.version);
|
|
448
|
+
} catch { /* unreadable package.json is not our problem */ }
|
|
449
|
+
};
|
|
450
|
+
if (name.startsWith('@')) {
|
|
451
|
+
const scoped = io.readDir(path.join(nm, name));
|
|
452
|
+
if (!Array.isArray(scoped)) continue;
|
|
453
|
+
for (const sub of scoped) readOne(name + '/' + sub, path.join(nm, name, sub));
|
|
454
|
+
} else {
|
|
455
|
+
readOne(name, path.join(nm, name));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const outdated = [];
|
|
461
|
+
const aheadOfCache = [];
|
|
462
|
+
let checked = 0;
|
|
463
|
+
let undetermined = 0;
|
|
464
|
+
for (const [pkgName, info] of Object.entries(packages)) {
|
|
465
|
+
const cur = installed.get(pkgName);
|
|
466
|
+
if (!cur) continue; // not installed here — the market cache lists everything
|
|
467
|
+
checked += 1;
|
|
468
|
+
const latest = info && typeof info.latest === 'string' ? info.latest : null;
|
|
469
|
+
if (!latest || latest === cur) continue;
|
|
470
|
+
// v0.9.4: "different" is not "newer". This machine's cache listed
|
|
471
|
+
// dsh-disk-cleaner 1.6.3 → 1.6.2 — a plain inequality test turned a stale
|
|
472
|
+
// cache into an "upgrade available" that would have been a DOWNGRADE.
|
|
473
|
+
// Compare direction, and say "undetermined" when a version is not parseable
|
|
474
|
+
// rather than guessing which way it points.
|
|
475
|
+
const dir = versionDirection(latest, cur);
|
|
476
|
+
if (dir === null) { undetermined += 1; continue; }
|
|
477
|
+
if (dir > 0) outdated.push({ name: pkgName, installed: cur, latest });
|
|
478
|
+
else if (dir < 0) aheadOfCache.push({ name: pkgName, installed: cur, latest });
|
|
479
|
+
}
|
|
480
|
+
outdated.sort((a, b) => a.name.localeCompare(b.name));
|
|
481
|
+
aheadOfCache.sort((a, b) => a.name.localeCompare(b.name));
|
|
482
|
+
return { available: true, reason: null, outdated, aheadOfCache, checked, undetermined, cacheAgeHours };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Advisory-only coverage notes. The steward does NOT re-implement disk scanning
|
|
487
|
+
* (dsh-disk-cleaner owns it), backup (dsh-config-manager), archiving or memory
|
|
488
|
+
* (Mnemon). It only points at the right existing capability and states the
|
|
489
|
+
* boundaries it will not cross. This is the "thin orchestration" layer.
|
|
490
|
+
*/
|
|
491
|
+
function advisoryFindings() {
|
|
492
|
+
return [
|
|
493
|
+
{
|
|
494
|
+
id: 'data-at-risk',
|
|
495
|
+
severity: 'warning',
|
|
496
|
+
category: '备份覆盖',
|
|
497
|
+
title: '易失数据不在默认备份分区内',
|
|
498
|
+
detail: '定时/默认备份只含 defaultIncluded=true 分区;sessions、pluginData、pluginAssets、pluginFiles、secrets 不在其中,Mnemon 知识落在 sessions/*/mnemon-run-* 同样不进任何默认分区。会话历史与知识沉淀是最怕丢的部分。',
|
|
499
|
+
evidence: 'dsh-config-manager/lib/schema/section-registry.js:193-247、:307;lib/sync/backup-scheduler.js:163-200(导出不传 only);lib/core/exporter.js:268',
|
|
500
|
+
recommendation: '手动做一次带口令的加密导出(GUI 的加密导出,或 config_backup 后整体封成 DCA1),覆盖 sessions/pluginData/pluginAssets;恢复走合并式定向恢复,旧配置禁止整体回灌。凭据永不进备份。',
|
|
501
|
+
data: { sections: ['sessions', 'pluginData', 'pluginAssets', 'Mnemon'] },
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
id: 'disk-advice',
|
|
505
|
+
severity: 'info',
|
|
506
|
+
category: 'C 盘治理',
|
|
507
|
+
title: '可自动清 / 不可自动清必须分开',
|
|
508
|
+
detail: 'C 盘占用通常分成两类:一类是纯缓存(包管理器 store、浏览器缓存、日志、回收站),删掉可重建;另一类是 WSL ext4.vhdx、已安装程序、WinSxS 组件库、下载/文档等,属于「不可自动清理」。后者往往占大头,且清理需要提权、卸载或压缩虚拟盘,必须由用户自己决定。',
|
|
509
|
+
evidence: '判据:C 盘占用按「可自动清 / 不可自动清」分类,具体体积以 disk_cleaner action=audit 的实时结果为准(本条不写死数字,以免把某次快照当成现状)',
|
|
510
|
+
recommendation: '用现成的 disk_cleaner 看分级建议;管家只呈现「可清 vs 不可清」,不代跑 DISM / diskpart / 卸载命令,不自动删。',
|
|
511
|
+
data: { boundary: 'read-only' },
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
id: 'knowledge',
|
|
515
|
+
severity: 'info',
|
|
516
|
+
category: '知识积累',
|
|
517
|
+
title: '有效模式靠 Mnemon 沉淀,不靠重复造轮子',
|
|
518
|
+
detail: '核查结论、契约坑、设计定稿应写入项目知识库,供后续同风格工作复用;管家本轮只读不改任何知识。',
|
|
519
|
+
evidence: '定稿 mnemon 文档 f7e5a482(rev6);逐轮文档 7f939c34 / 19a0a790 / dcb90e6e',
|
|
520
|
+
recommendation: '有价值的结论经用户确认后写入 Mnemon;管家不自动写记忆、不自动改 profile。',
|
|
521
|
+
data: { boundary: 'read-only' },
|
|
522
|
+
},
|
|
523
|
+
];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Build the full report. Pure and side-effect-free apart from injected IO.
|
|
528
|
+
* options: { now, env, platform, logDirs, profileDirs, pluginChanges, io }
|
|
529
|
+
*/
|
|
530
|
+
/**
|
|
531
|
+
* Importance scoring — one auditable formula, four factors, each explained.
|
|
532
|
+
* Stays pure: reads only finding.data that already exists, mutates nothing
|
|
533
|
+
* outside the finding. Score is 0–100; `priority` is a coarse band so users
|
|
534
|
+
* can talk about it ("top-priority") without reading the number.
|
|
535
|
+
*/
|
|
536
|
+
const SEV_BASE = { critical: 60, warning: 40, info: 20 };
|
|
537
|
+
const ACTIONABLE_IDS = new Set(['boot-crash-latest', 'data-at-risk', 'disk-advice', 'knowledge', 'backup-freshness', 'model-health']);
|
|
538
|
+
/** Human-readable byte size, used by the backup/config findings. */
|
|
539
|
+
function mbLabel(bytes) {
|
|
540
|
+
if (typeof bytes !== 'number' || !isFinite(bytes)) return '未知大小';
|
|
541
|
+
if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + ' GB';
|
|
542
|
+
if (bytes >= 1024 ** 2) return (bytes / 1024 ** 2).toFixed(1) + ' MB';
|
|
543
|
+
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
544
|
+
return bytes + ' B';
|
|
545
|
+
}
|
|
546
|
+
function scoreFindings(findings, ctx) {
|
|
547
|
+
const bootedAt = ctx && ctx.bootedAt ? ctx.bootedAt : null;
|
|
548
|
+
for (const f of findings) {
|
|
549
|
+
const reasons = [];
|
|
550
|
+
const base = SEV_BASE[f.severity] != null ? SEV_BASE[f.severity] : 20;
|
|
551
|
+
reasons.push('级别 ' + f.severity + ' +' + base);
|
|
552
|
+
let score = base;
|
|
553
|
+
|
|
554
|
+
// freshness: evidence produced in this process lifetime counts more.
|
|
555
|
+
let fresh = 0;
|
|
556
|
+
if (f.id === 'boot-crash-latest') {
|
|
557
|
+
if (f.data && f.data.stale === false) { fresh = 15; reasons.push('崩溃是本次运行期内 +' + fresh); }
|
|
558
|
+
else if (f.data && f.data.stale === true) { fresh = -15; reasons.push('崩溃是历史记录 −' + Math.abs(fresh)); }
|
|
559
|
+
} else if (f.id === 'plugin-change') {
|
|
560
|
+
fresh = 10; reasons.push('变更是本会话内存计数 +' + fresh);
|
|
561
|
+
} else if (f.id === 'patch-backup') {
|
|
562
|
+
fresh = 10; reasons.push('备份文件此刻就在磁盘上 +' + fresh);
|
|
563
|
+
} else if (f.id === 'compat-check') {
|
|
564
|
+
fresh = 10; reasons.push('按当前宿主实测 +' + fresh);
|
|
565
|
+
} else if (f.id === 'client-identity') {
|
|
566
|
+
// Read straight off the install tree in this run — a live conflict is a
|
|
567
|
+
// live boot hazard, not a historical note.
|
|
568
|
+
fresh = 10; reasons.push('按当前安装目录实测 +' + fresh);
|
|
569
|
+
} else {
|
|
570
|
+
reasons.push('常备建议,不加分');
|
|
571
|
+
}
|
|
572
|
+
score += fresh;
|
|
573
|
+
|
|
574
|
+
// actionable: a one-click authorized path exists (or the user can act now).
|
|
575
|
+
// A stale crash does NOT count: its disable action is blocked by the
|
|
576
|
+
// attribution gate, so promising a shortcut there would be dishonest.
|
|
577
|
+
const gateBlocked = f.id === 'boot-crash-latest' && f.data && f.data.stale === true;
|
|
578
|
+
if (ACTIONABLE_IDS.has(f.id) && !f.muted && !gateBlocked) {
|
|
579
|
+
score += 15; reasons.push('有直达处置动作 +15');
|
|
580
|
+
} else if (gateBlocked) {
|
|
581
|
+
reasons.push('处置被归因门封锁,不计可直达');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// magnitude: how large the underlying evidence is.
|
|
585
|
+
let mag = 0;
|
|
586
|
+
const d = f.data || {};
|
|
587
|
+
if (f.id === 'boot-crash-latest') {
|
|
588
|
+
if (d.crashCount > 1) mag += Math.min(10, d.crashCount * 3);
|
|
589
|
+
if (Array.isArray(d.suspects) && !d.stale && d.suspects.length > 0) mag += 5;
|
|
590
|
+
} else if (f.id === 'patch-backup') {
|
|
591
|
+
if (Array.isArray(d.backups)) mag += Math.min(10, d.backups.length * 5);
|
|
592
|
+
} else if (f.id === 'plugin-change') {
|
|
593
|
+
if (Array.isArray(d.changes)) mag += Math.min(10, d.changes.length * 3);
|
|
594
|
+
} else if (f.id === 'compat-check') {
|
|
595
|
+
if (Array.isArray(d.mismatched) && d.mismatched.length > 0) mag += Math.min(10, d.mismatched.length * 3);
|
|
596
|
+
else if (d.declared) mag += Math.min(5, Math.floor(d.declared / 10));
|
|
597
|
+
} else if (f.id === 'model-health') {
|
|
598
|
+
if (f.severity === 'warning') mag += 10; // 默认模型挂了是当下真的用不了
|
|
599
|
+
if (Array.isArray(d.deadModels) && d.deadModels.length > 0) mag += Math.min(6, d.deadModels.length * 2);
|
|
600
|
+
} else if (f.id === 'data-at-risk') {
|
|
601
|
+
mag += 8; // the gap is constant but its blast radius is the whole volatile set
|
|
602
|
+
} else if (f.id === 'client-identity') {
|
|
603
|
+
// Every distinct kind of conflict is another way boot can die; a live one
|
|
604
|
+
// is by definition worth more than a declaration-mismatch tally.
|
|
605
|
+
const kinds = [d.sameReal, d.drifted, d.collisions].filter((x) => Array.isArray(x) && x.length > 0).length;
|
|
606
|
+
if (kinds > 0) mag += Math.min(10, kinds * 4);
|
|
607
|
+
}
|
|
608
|
+
if (mag) { score += mag; reasons.push('体量/条数 +' + mag); }
|
|
609
|
+
|
|
610
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
611
|
+
f.score = score;
|
|
612
|
+
f.priority = score >= 70 ? 'high' : score >= 45 ? 'medium' : 'low';
|
|
613
|
+
f.scoreReasons = reasons;
|
|
614
|
+
// muted items never lead the queue regardless of raw score; the UI puts
|
|
615
|
+
// them in the last tier, so we record the intent without faking the number.
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ---- minimal semver-range helpers (v0.3.0 ③ compat check) ----
|
|
620
|
+
// Honest scope: handles the comparator forms actually observed in installed
|
|
621
|
+
// packages (">=x", "<x", "^x", plain "=" and "||" unions). Anything fancier
|
|
622
|
+
// degrades to "undetermined" — never a false all-clear.
|
|
623
|
+
function verCmp(a, b) {
|
|
624
|
+
const pa = String(a).replace(/^v/, '').split('-');
|
|
625
|
+
const pb = String(b).replace(/^v/, '').split('-');
|
|
626
|
+
const na = pa[0].split('.').map((x) => parseInt(x, 10) || 0);
|
|
627
|
+
const nb = pb[0].split('.').map((x) => parseInt(x, 10) || 0);
|
|
628
|
+
for (let i = 0; i < 3; i++) { const d = (na[i] || 0) - (nb[i] || 0); if (d) return d; }
|
|
629
|
+
if (pa[1] && !pb[1]) return -1; if (!pa[1] && pb[1]) return 1;
|
|
630
|
+
if (pa[1] && pb[1]) return pa[1] < pb[1] ? -1 : pa[1] > pb[1] ? 1 : 0;
|
|
631
|
+
return 0;
|
|
632
|
+
}
|
|
633
|
+
// caret 上界:^1.2.3 → <2.0.0;^0.2.3 → <0.3.0;^0.0.3 → <0.0.4;^0 → <1.0.0。
|
|
634
|
+
// 只取 '-' 前的数字段(prerelease 不参与上界计算),按 semver caret 语义递增
|
|
635
|
+
// 第一个非零段;全零(如 '0' / '0.0')退回 <1.0.0。
|
|
636
|
+
function caretUpperBound(val) {
|
|
637
|
+
const parts = val.replace(/^v/, '').split('-')[0].split('.').map((x) => parseInt(x, 10) || 0);
|
|
638
|
+
const major = parts[0] || 0, minor = parts[1] || 0, patch = parts[2] || 0;
|
|
639
|
+
if (major > 0) return (major + 1) + '.0.0';
|
|
640
|
+
if (minor > 0) return '0.' + (minor + 1) + '.0';
|
|
641
|
+
if (patch > 0) return '0.0.' + (patch + 1);
|
|
642
|
+
return '1.0.0';
|
|
643
|
+
}
|
|
644
|
+
function satisfiesToken(token, version) {
|
|
645
|
+
const m = /^(>=|<=|\^|>|<|=)?\s*(.+)$/.exec(String(token).trim());
|
|
646
|
+
if (!m) return null;
|
|
647
|
+
const [, op, valRaw] = m;
|
|
648
|
+
const val = valRaw.trim();
|
|
649
|
+
if (/[a-zA-Z*]/.test(val) && !/^\d/.test(val)) return null; // ranges like "latest", "*" → undetermined
|
|
650
|
+
switch (op) {
|
|
651
|
+
case '>=': return verCmp(version, val) >= 0;
|
|
652
|
+
case '<=': return verCmp(version, val) <= 0;
|
|
653
|
+
case '>': return verCmp(version, val) > 0;
|
|
654
|
+
case '<': return verCmp(version, val) < 0;
|
|
655
|
+
case '^': return verCmp(version, val) >= 0 && verCmp(version, caretUpperBound(val)) < 0;
|
|
656
|
+
default: return verCmp(version, val) === 0;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function satisfiesRange(range, version) {
|
|
660
|
+
const parts = String(range).split('||');
|
|
661
|
+
let sawUndetermined = false;
|
|
662
|
+
for (const part of parts) {
|
|
663
|
+
const tokens = part.trim().split(/\s+/).filter(Boolean);
|
|
664
|
+
let all = true; let anyUnknown = false;
|
|
665
|
+
for (const t of tokens) {
|
|
666
|
+
const r = satisfiesToken(t, version);
|
|
667
|
+
if (r === null) anyUnknown = true; else if (!r) all = false;
|
|
668
|
+
}
|
|
669
|
+
if (!anyUnknown && all) return true;
|
|
670
|
+
if (anyUnknown) sawUndetermined = true;
|
|
671
|
+
}
|
|
672
|
+
return sawUndetermined ? null : false;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Read installed bundles' engines.dsh declarations and the host dsh-base
|
|
677
|
+
* version straight off the profile's node_modules (read-only file IO).
|
|
678
|
+
* Returns { hostVersion: string|null, entries: [{name,version,range}], checked }.
|
|
679
|
+
*/
|
|
680
|
+
function collectCompat(paths, io, hostVersionOverride) {
|
|
681
|
+
const entries = [];
|
|
682
|
+
let hostVersion = hostVersionOverride || null;
|
|
683
|
+
const seen = new Set();
|
|
684
|
+
for (const dir of paths.profileDirs) {
|
|
685
|
+
const nm = dir.replace(/[\\/]+$/, '') + '/node_modules';
|
|
686
|
+
if (!hostVersion) {
|
|
687
|
+
try {
|
|
688
|
+
const pkg = JSON.parse(io.readText(nm + '/@deepseek-ai/dsh-base/package.json'));
|
|
689
|
+
if (pkg && typeof pkg.version === 'string') hostVersion = pkg.version;
|
|
690
|
+
} catch { /* not readable — try next dir / stay null */ }
|
|
691
|
+
}
|
|
692
|
+
let tops = [];
|
|
693
|
+
try { tops = io.readDir(nm); if (tops && tops.__error) tops = []; } catch { tops = []; }
|
|
694
|
+
if (!Array.isArray(tops)) continue;
|
|
695
|
+
for (const t of tops.slice(0, 300)) {
|
|
696
|
+
const candidates = [];
|
|
697
|
+
if (String(t).startsWith('@')) {
|
|
698
|
+
let scoped = [];
|
|
699
|
+
try { scoped = io.readDir(nm + '/' + t); if (scoped && scoped.__error) scoped = []; } catch { scoped = []; }
|
|
700
|
+
if (Array.isArray(scoped)) for (const s of scoped.slice(0, 100)) candidates.push(nm + '/' + t + '/' + s + '/package.json');
|
|
701
|
+
} else {
|
|
702
|
+
candidates.push(nm + '/' + t + '/package.json');
|
|
703
|
+
}
|
|
704
|
+
for (const file of candidates) {
|
|
705
|
+
try {
|
|
706
|
+
const pkg = JSON.parse(io.readText(file));
|
|
707
|
+
if (!pkg || typeof pkg.name !== 'string' || seen.has(pkg.name)) continue;
|
|
708
|
+
const range = pkg.engines && (pkg.engines.dsh || pkg.engines['@deepseek-ai/dsh-base']);
|
|
709
|
+
if (typeof range !== 'string' || !range.trim()) continue;
|
|
710
|
+
seen.add(pkg.name);
|
|
711
|
+
entries.push({ name: pkg.name, version: String(pkg.version || ''), range: range.trim() });
|
|
712
|
+
} catch { /* package unreadable or not JSON — skip silently */ }
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const checked = [];
|
|
717
|
+
for (const e of entries) {
|
|
718
|
+
if (!hostVersion) { checked.push({ ...e, state: 'unknown' }); continue; }
|
|
719
|
+
const r = satisfiesRange(e.range, hostVersion);
|
|
720
|
+
checked.push({ ...e, state: r === true ? 'ok' : r === false ? 'mismatch' : 'unknown' });
|
|
721
|
+
}
|
|
722
|
+
return { hostVersion, entries: checked };
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* v0.9.3 client identity check.
|
|
727
|
+
*
|
|
728
|
+
* DSH client bundles self-register by id inside `window.__ModuleLoader__.load(
|
|
729
|
+
* { id: 'X', ... })`. The renderer keeps a Map of factories keyed by that id,
|
|
730
|
+
* so a SECOND registration of the same id throws
|
|
731
|
+
* client-modules: duplicate factory registration for "X"
|
|
732
|
+
* and takes the whole web boot down with it — one duplicate id, no UI at all.
|
|
733
|
+
*
|
|
734
|
+
* Three ways to get there, none of which any existing check looks at:
|
|
735
|
+
* 1. one package installed under two node_modules names (this is what
|
|
736
|
+
* actually happened on 2026-11-09: bare `dsh-steward/` plus
|
|
737
|
+
* `@sidady/dsh-steward/` both linking to the same source dir);
|
|
738
|
+
* 2. a client.js whose declared id no longer matches its package name
|
|
739
|
+
* (renaming a package without touching the client bundle);
|
|
740
|
+
* 3. two unrelated packages picking the same id.
|
|
741
|
+
*
|
|
742
|
+
* dshmarket's static analysis and trial boot both stay green on all three:
|
|
743
|
+
* trialValidate replays the LOADER tree, and the loader is perfectly happy with
|
|
744
|
+
* duplicate client ids because it never sees them. Verified on synthetic
|
|
745
|
+
* fixtures 2026-11-10 — see the report section in the design doc.
|
|
746
|
+
*
|
|
747
|
+
* Read-only, and deliberately NOT actionable: the fix is deleting a symlink or
|
|
748
|
+
* renaming an id, i.e. edits to other packages' install state. We name the
|
|
749
|
+
* exact directories and stop there.
|
|
750
|
+
*/
|
|
751
|
+
function collectClientIdentity(paths, io, errors) {
|
|
752
|
+
const entries = [];
|
|
753
|
+
// v0.9.5: where the host actually looks. package.json exports["./client"]
|
|
754
|
+
// first (that is the documented hook and what most packages use), then an
|
|
755
|
+
// explicit dsh.client.entry, then the two conventional paths.
|
|
756
|
+
const entryCandidates = (pkg) => {
|
|
757
|
+
const out = [];
|
|
758
|
+
const push = (v) => {
|
|
759
|
+
if (typeof v === 'string' && v) out.push(v.replace(/^\.\//, ''));
|
|
760
|
+
else if (v && typeof v === 'object') {
|
|
761
|
+
if (typeof v.default === 'string') out.push(v.default.replace(/^\.\//, ''));
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
const ex = pkg.exports;
|
|
765
|
+
if (ex && typeof ex === 'object') push(ex['./client']);
|
|
766
|
+
const dc = pkg.dsh && pkg.dsh.client;
|
|
767
|
+
if (typeof dc === 'string') push(dc);
|
|
768
|
+
else if (dc && typeof dc === 'object') { push(dc.entry); push(dc.path); push(dc.file); }
|
|
769
|
+
out.push('lib/client.js', 'client.js', 'dist/client.js');
|
|
770
|
+
return [...new Set(out)];
|
|
771
|
+
};
|
|
772
|
+
const seenDirs = new Set();
|
|
773
|
+
let scanned = 0;
|
|
774
|
+
const readId = (text) => {
|
|
775
|
+
// Quotes may be ', " or ` — minified bundles switch between them freely.
|
|
776
|
+
const m = /__ModuleLoader__\.load\(\s*\{[^}]*?\bid:\s*['"`]([^'"`]+)['"`]/.exec(text);
|
|
777
|
+
if (m) return m[1];
|
|
778
|
+
// Some bundles (dsh-cost-meter) route the call through a load-wrap proxy, so the
|
|
779
|
+
// literal __ModuleLoader__ token never appears in the module body. Fall back to
|
|
780
|
+
// the first object literal that carries an id, which is that same call argument.
|
|
781
|
+
const loose = /\{\s*id\s*:\s*['"`]([^'"`]+)['"`]/.exec(text);
|
|
782
|
+
return loose ? loose[1] : null;
|
|
783
|
+
};
|
|
784
|
+
for (const dir of paths.profileDirs) {
|
|
785
|
+
const nm = dir.replace(/[\\/]+$/, '') + '/node_modules';
|
|
786
|
+
let tops = [];
|
|
787
|
+
try { tops = io.readDir(nm); if (tops && tops.__error) tops = []; } catch { tops = []; }
|
|
788
|
+
if (!Array.isArray(tops)) continue;
|
|
789
|
+
scanned += 1;
|
|
790
|
+
const pkgDirs = [];
|
|
791
|
+
for (const t of tops.slice(0, 400)) {
|
|
792
|
+
const name = String(t);
|
|
793
|
+
if (name.startsWith('@')) {
|
|
794
|
+
let scoped = [];
|
|
795
|
+
try { scoped = io.readDir(nm + '/' + name); if (scoped && scoped.__error) scoped = []; } catch { scoped = []; }
|
|
796
|
+
if (Array.isArray(scoped)) for (const s of scoped.slice(0, 120)) pkgDirs.push(nm + '/' + name + '/' + s);
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
// Skip node_modules/.bin and friends — they never carry a package.json.
|
|
800
|
+
if (name.startsWith('.')) continue;
|
|
801
|
+
pkgDirs.push(nm + '/' + name);
|
|
802
|
+
}
|
|
803
|
+
for (const pkgDir of pkgDirs) {
|
|
804
|
+
if (seenDirs.has(pkgDir)) continue;
|
|
805
|
+
seenDirs.add(pkgDir);
|
|
806
|
+
let pkg = null;
|
|
807
|
+
try { pkg = JSON.parse(io.readText(pkgDir + '/package.json')); } catch { continue; }
|
|
808
|
+
if (!pkg || typeof pkg.name !== 'string') continue;
|
|
809
|
+
if (!(pkg.dsh && pkg.dsh.client)) continue;
|
|
810
|
+
let text = null;
|
|
811
|
+
let usedPath = null;
|
|
812
|
+
for (const rel of entryCandidates(pkg)) {
|
|
813
|
+
try {
|
|
814
|
+
const raw = io.readText(pkgDir + '/' + rel);
|
|
815
|
+
if (typeof raw === 'string' && raw.trim()) { text = raw; usedPath = rel; break; }
|
|
816
|
+
} catch { /* try the next candidate */ }
|
|
817
|
+
}
|
|
818
|
+
const id = text ? readId(text) : null;
|
|
819
|
+
entries.push({
|
|
820
|
+
dir: pkgDir.slice(nm.length + 1),
|
|
821
|
+
name: pkg.name,
|
|
822
|
+
id,
|
|
823
|
+
entry: usedPath,
|
|
824
|
+
readable: text !== null,
|
|
825
|
+
realpath: io.realpath(pkgDir),
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// C1: same real directory answering to more than one node_modules name.
|
|
831
|
+
const byReal = new Map();
|
|
832
|
+
for (const e of entries) {
|
|
833
|
+
if (!e.realpath) continue;
|
|
834
|
+
if (!byReal.has(e.realpath)) byReal.set(e.realpath, []);
|
|
835
|
+
byReal.get(e.realpath).push(e.dir);
|
|
836
|
+
}
|
|
837
|
+
const sameReal = [...byReal.entries()]
|
|
838
|
+
.filter(([, dirs]) => dirs.length > 1)
|
|
839
|
+
.map(([realpath, dirs]) => ({ realpath, dirs }));
|
|
840
|
+
|
|
841
|
+
// C2: declared client id != package name.
|
|
842
|
+
const drifted = entries.filter((e) => e.id && e.id !== e.name);
|
|
843
|
+
|
|
844
|
+
// C3: two packages claiming one id.
|
|
845
|
+
const byId = new Map();
|
|
846
|
+
for (const e of entries) {
|
|
847
|
+
if (!e.id) continue;
|
|
848
|
+
if (!byId.has(e.id)) byId.set(e.id, []);
|
|
849
|
+
byId.get(e.id).push(e.name);
|
|
850
|
+
}
|
|
851
|
+
const collisions = [...byId.entries()]
|
|
852
|
+
.filter(([, names]) => new Set(names).size > 1)
|
|
853
|
+
.map(([id, names]) => ({ id, names: [...new Set(names)] }));
|
|
854
|
+
|
|
855
|
+
const unreadable = entries.filter((e) => !e.readable).length;
|
|
856
|
+
const withoutId = entries.filter((e) => e.readable && !e.id).length;
|
|
857
|
+
return { entries, sameReal, drifted, collisions, unreadable, withoutId, scanned };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
export function collectReport(options = {}) {
|
|
861
|
+
const now = options.now || new Date();
|
|
862
|
+
const env = options.env || process.env;
|
|
863
|
+
const platform = options.platform || process.platform;
|
|
864
|
+
const paths = {
|
|
865
|
+
...defaultPaths(env, platform),
|
|
866
|
+
...(options.logDirs ? { logDirs: options.logDirs } : {}),
|
|
867
|
+
...(options.profileDirs ? { profileDirs: options.profileDirs } : {}),
|
|
868
|
+
};
|
|
869
|
+
const io = safeIo(options.io);
|
|
870
|
+
const errors = [];
|
|
871
|
+
// v0.8.0:判据阈值与采集限流全部来自用户设置(默认值 = v0.7.8 行为)。
|
|
872
|
+
const settings = options.settings && typeof options.settings === 'object' ? options.settings : defaultSettings();
|
|
873
|
+
const limits = limitsFor(settings);
|
|
874
|
+
|
|
875
|
+
const bootedAt = options.bootedAt || new Date(Date.now() - Math.floor(process.uptime()) * 1000);
|
|
876
|
+
const crashes = collectCrashLogs(paths, io, errors, limits);
|
|
877
|
+
const patchBackups = collectPatchBackups(paths, io, errors);
|
|
878
|
+
const pluginChanges = Array.isArray(options.pluginChanges) ? options.pluginChanges.slice(0, 50) : [];
|
|
879
|
+
|
|
880
|
+
// v0.4 disk trend: system-drive free bytes via fs.statfsSync (native, zero
|
|
881
|
+
// deps, sync-safe at report time). Best-effort — failure yields null.
|
|
882
|
+
let cFreeBytes = null;
|
|
883
|
+
try {
|
|
884
|
+
const diskPath = platform === 'win32' ? 'C:\\' : '/';
|
|
885
|
+
const st = typeof fs.statfsSync === 'function' ? fs.statfsSync(diskPath) : null;
|
|
886
|
+
if (st && typeof st.bavail === 'number' && typeof st.bsize === 'number') cFreeBytes = st.bavail * st.bsize;
|
|
887
|
+
} catch { /* unavailable — trend row just omits it */ }
|
|
888
|
+
|
|
889
|
+
const findings = [];
|
|
890
|
+
|
|
891
|
+
if (crashes.length > 0) {
|
|
892
|
+
const latest = crashes[0];
|
|
893
|
+
const allSuspects = [...new Set(crashes.flatMap((c) => c.suspects))];
|
|
894
|
+
const latestSuspects = latest.suspects || [];
|
|
895
|
+
// Staleness gate: this report is produced INSIDE a running host, so the
|
|
896
|
+
// current instance booted successfully. DSH boot is all-or-nothing — if a
|
|
897
|
+
// crash log predates the current boot, every plugin it named has by
|
|
898
|
+
// construction loaded fine since. Reporting that as "critical" is noise.
|
|
899
|
+
const latestTime = latest.mtime ? new Date(latest.mtime) : null;
|
|
900
|
+
const stale = !!(latestTime && latestTime.getTime() <= bootedAt.getTime());
|
|
901
|
+
// v0.9.10: two different questions were sharing one number.
|
|
902
|
+
// "how long ago did this happen" -> now - latestTime (what the prose says)
|
|
903
|
+
// "how much earlier than this boot" -> bootedAt - latestTime (what the gate uses)
|
|
904
|
+
// Rounding the second one to whole hours printed "约 0 小时前" for a crash
|
|
905
|
+
// 49 seconds before boot, seven hours after it actually happened.
|
|
906
|
+
const ageMs = latestTime ? Math.max(0, now.getTime() - latestTime.getTime()) : null;
|
|
907
|
+
const bootLagMs = latestTime ? Math.max(0, bootedAt.getTime() - latestTime.getTime()) : null;
|
|
908
|
+
const ageHours = ageMs == null ? null : Math.round((ageMs / 36e5) * 10) / 10;
|
|
909
|
+
const ageText = ageMs == null ? null
|
|
910
|
+
: ageMs < 6e4 ? "不到 1 分钟前"
|
|
911
|
+
: ageMs < 36e5 ? Math.round(ageMs / 6e4) + " 分钟前"
|
|
912
|
+
: ageMs < 864e5 ? Math.round(ageMs / 36e5) + " 小时前"
|
|
913
|
+
: Math.round(ageMs / 864e5) + " 天前";
|
|
914
|
+
const bootLagText = bootLagMs == null ? null
|
|
915
|
+
: bootLagMs < 6e4 ? "不到 1 分钟"
|
|
916
|
+
: bootLagMs < 36e5 ? Math.round(bootLagMs / 6e4) + " 分钟"
|
|
917
|
+
: bootLagMs < 864e5 ? Math.round(bootLagMs / 36e5) + " 小时"
|
|
918
|
+
: Math.round(bootLagMs / 864e5) + " 天";
|
|
919
|
+
// v0.9.3: when the log states the cause, report the CAUSE. `X: import failed`
|
|
920
|
+
// names whoever lost the race; the renderer console line names the duplicate
|
|
921
|
+
// id. Leading with the victim is how a directory problem gets misread as a
|
|
922
|
+
// broken plugin.
|
|
923
|
+
const causes = latest.clientDuplicates || [];
|
|
924
|
+
const causeText = causes.length > 0
|
|
925
|
+
? 'client id "' + causes.join('"、"') + '" 被注册了两次'
|
|
926
|
+
: null;
|
|
927
|
+
findings.push({
|
|
928
|
+
id: 'boot-crash-latest',
|
|
929
|
+
severity: stale ? 'info' : 'critical',
|
|
930
|
+
category: '启动诊断',
|
|
931
|
+
title: (stale ? '历史 web-boot 崩溃(当前实例启动正常):' : '最近一次 web-boot 崩溃:')
|
|
932
|
+
+ (causeText || (latest.didNotActivate != null ? latest.didNotActivate + ' 个条目未激活' : '插件导入失败')),
|
|
933
|
+
detail: (causeText
|
|
934
|
+
? '病因(日志原文):`client-modules: duplicate factory registration for "' + causes[0] + '"` —— '
|
|
935
|
+
+ '同一个 client id 被注册了两次,渲染端直接抛错。'
|
|
936
|
+
+ (latestSuspects.length > 0
|
|
937
|
+
? '\n被点名「import failed」的 ' + latestSuspects.join('、') + ' 是这一局的受害者而不是病因:它的包本身单独 import 是成功的,'
|
|
938
|
+
+ '输在 id 撞车。真正的修法是清理重复的安装目录或统一 id,不是去修那个插件。'
|
|
939
|
+
: '')
|
|
940
|
+
: (latestSuspects.length > 0
|
|
941
|
+
? '崩溃日志点名疑似插件:' + latestSuspects.join('、') + '。'
|
|
942
|
+
+ (stale
|
|
943
|
+
? '这份日志比当前运行实例的启动还早 ' + bootLagText + ',也就是 ' + ageText + '发生的事'
|
|
944
|
+
+ ';DSH 的 boot 是 all-or-nothing,当前界面能打开就证明这些插件后来全部加载成功了——属历史记录,不是现行风险。'
|
|
945
|
+
: 'DSH 的 boot 是 all-or-nothing:一个插件没激活,整个 Web UI 起不来;这与「插件变更 → boot 风险」是同一条链。')
|
|
946
|
+
+ (crashes.length > 1 ? '另有 ' + (crashes.length - 1) + ' 份更早的崩溃日志,明细见 data.all。' : '')
|
|
947
|
+
: '日志显示 boot 期导入失败但未点名具体插件,请结合 crash 日志全文排查。'))
|
|
948
|
+
+ (stale && causeText ? '\n这份日志比当前实例的启动还早 ' + bootLagText + ',是 ' + ageText + '发生的事,当前能打开就说明已经恢复。' : ''),
|
|
949
|
+
evidence: latest.file + (latest.bootLine ? ' — ' + latest.bootLine : '') + (latest.meta.app ? ' — app ' + latest.meta.app : ''),
|
|
950
|
+
recommendation: causeText
|
|
951
|
+
? '看下面「client 标识体检」列出的具体路径:同一份代码只留一个 node_modules 名字、id 与包名对齐。管家只读不代改。'
|
|
952
|
+
: (stale
|
|
953
|
+
? '已恢复,无需处置。再次发生启动失败时优先看这里点名的第一个插件;恢复动作前先备份 cordis.patch.yml。管家不自动拉起、不碰启动流程。'
|
|
954
|
+
: '先确认该插件的 node_modules 是否补齐、版本是否匹配宿主;恢复前备份 cordis.patch.yml,禁用坏插件而非反复重启。管家不自动拉起、不碰启动流程。'),
|
|
955
|
+
data: {
|
|
956
|
+
crashCount: crashes.length,
|
|
957
|
+
stale,
|
|
958
|
+
bootedAt: bootedAt.toISOString ? bootedAt.toISOString() : String(bootedAt),
|
|
959
|
+
ageHours,
|
|
960
|
+
bootLagHours: bootLagMs == null ? null : Math.round((bootLagMs / 36e5) * 100) / 100,
|
|
961
|
+
ageText,
|
|
962
|
+
bootLagText,
|
|
963
|
+
suspects: latestSuspects,
|
|
964
|
+
suspectsAllLogs: allSuspects,
|
|
965
|
+
clientDuplicates: causes,
|
|
966
|
+
meta: latest.meta,
|
|
967
|
+
all: crashes.map((c) => ({ file: c.file, mtime: c.mtime, didNotActivate: c.didNotActivate, suspects: c.suspects, clientDuplicates: c.clientDuplicates || [] })),
|
|
968
|
+
},
|
|
969
|
+
});
|
|
970
|
+
} else {
|
|
971
|
+
findings.push({
|
|
972
|
+
id: 'boot-crash-none',
|
|
973
|
+
severity: 'info',
|
|
974
|
+
category: '启动诊断',
|
|
975
|
+
title: '未发现 web-boot 崩溃日志',
|
|
976
|
+
detail: '在已知日志目录中没有解析到 crash-*.log 的「did not activate / import failed」记录。这不等于「从未崩过」——只代表当前可见日志里没有。',
|
|
977
|
+
evidence: '扫描目录:' + paths.logDirs.join('、'),
|
|
978
|
+
recommendation: '保持现状即可;崩溃日志由桌面端在下次启动时落盘,管家会在下次体检时读到。',
|
|
979
|
+
data: { crashCount: 0 },
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (patchBackups.length > 0) {
|
|
984
|
+
// v0.9.7:字节数不是判据。本机实测 patch 比备份小 42%,但 14 个 - id: 块一个都没少——
|
|
985
|
+
// 少的是块内字段(插件重写默认值,或用户改了选择)。拿总字节报「配置被换掉了」
|
|
986
|
+
// 是把「小」当成「丢」,正是这份报告一直在批评的毛病。
|
|
987
|
+
// 现在按结构判:块数少了 → 真丢了整块(warning);块都在只是缩水 → info,
|
|
988
|
+
// 把缩水的 id 与字节差列出来交给人判断,并明说「这不等于丢」。
|
|
989
|
+
const livePatch = patchBackups[0].liveText || '';
|
|
990
|
+
const liveIds = (livePatch.match(/^- id:\s*(.+)$/gm) || []).map((l) => l.replace(/^- id:\s*/, '').trim());
|
|
991
|
+
const bakIds = [];
|
|
992
|
+
for (const b of patchBackups) {
|
|
993
|
+
if (!b.text) continue;
|
|
994
|
+
for (const l of b.text.match(/^- id:\s*(.+)$/gm) || []) bakIds.push(l.replace(/^- id:\s*/, '').trim());
|
|
995
|
+
}
|
|
996
|
+
const missing = bakIds.filter((id) => liveIds.length > 0 && !liveIds.includes(id));
|
|
997
|
+
const kb = (n) => (n == null ? '?' : (n / 1024).toFixed(1) + ' KB');
|
|
998
|
+
const shrunk = patchBackups.filter((b) => b.size != null && b.liveBytes != null && b.liveBytes < b.size * 0.8);
|
|
999
|
+
if (missing.length > 0) {
|
|
1000
|
+
findings.push({
|
|
1001
|
+
id: 'patch-backup',
|
|
1002
|
+
severity: 'warning',
|
|
1003
|
+
category: '配置备份',
|
|
1004
|
+
title: '启动期备份里有 ' + missing.length + ' 个配置块在当前 patch 里不存在了',
|
|
1005
|
+
detail: '启动期 sanitizeProfile 会把 patch 改名为 bak-<epoch> 再写默认 stub,该动作曾有静默丢 providers/模型/权限预设的报告。\n'
|
|
1006
|
+
+ '当前 patch 少了这些 id 的整块:' + missing.join('、') + '\n'
|
|
1007
|
+
+ '这是结构性缺失,不是「块内字段变了」——值得对照 .bak 补回来。',
|
|
1008
|
+
evidence: patchBackups.map((b) => b.file + '(' + kb(b.size) + ')').join('、'),
|
|
1009
|
+
recommendation: '对照 .bak 逐块补回缺的 id,补之前先把现有 patch 另存一份。旧配置禁止整体回灌——只拣缺的块。管家只读不改 patch。',
|
|
1010
|
+
data: { backups: patchBackups.map((b) => ({ file: b.file, size: b.size, liveBytes: b.liveBytes })), missing },
|
|
1011
|
+
});
|
|
1012
|
+
} else if (shrunk.length > 0) {
|
|
1013
|
+
findings.push({
|
|
1014
|
+
id: 'patch-backup',
|
|
1015
|
+
severity: 'info',
|
|
1016
|
+
category: '配置备份',
|
|
1017
|
+
title: '当前 patch 比启动期备份小,但配置块一个没少',
|
|
1018
|
+
detail: '对比结果:备份里的 ' + bakIds.length + ' 个 - id: 块在当前 patch 里全都还在,缩水发生在块内字段。\n'
|
|
1019
|
+
+ shrunk.map((b) => '· ' + b.file + ' 是 ' + kb(b.size) + ',当前 ' + kb(b.liveBytes)
|
|
1020
|
+
+ '(小 ' + (100 - Math.round((b.liveBytes / b.size) * 100)) + '%)').join('\n')
|
|
1021
|
+
+ '\n块内字段变少有两种可能:插件把默认值重写成了空(如 enabledModelKeys: []),或者你的选择被清掉了。'
|
|
1022
|
+
+ '这**不等于配置丢了**,但值得你自己对着 .bak 看一眼具体是哪几个字段。',
|
|
1023
|
+
evidence: patchBackups.map((b) => b.file + '(' + kb(b.size) + ')').join('、')
|
|
1024
|
+
+ ';结构对比:- id: 块数 live ' + liveIds.length + ' / bak ' + bakIds.length,
|
|
1025
|
+
recommendation: '如果发现某个插件的设置被重置了,从 .bak 里把那一个块挑回来;不要整体回灌。管家只读不改 patch。',
|
|
1026
|
+
data: { backups: patchBackups.map((b) => ({ file: b.file, size: b.size, liveBytes: b.liveBytes })), liveIds, bakIds, shrunk: shrunk.map((b) => b.file) },
|
|
1027
|
+
});
|
|
1028
|
+
} else {
|
|
1029
|
+
findings.push({
|
|
1030
|
+
id: 'patch-backup',
|
|
1031
|
+
severity: 'info',
|
|
1032
|
+
category: '配置备份',
|
|
1033
|
+
title: '留有 ' + patchBackups.length + ' 个启动期 patch 备份(当前配置未被换掉)',
|
|
1034
|
+
detail: '这些 cordis.patch.yml.bak-<epoch> 是启动期 sanitizeProfile 改名留下的历史残留。'
|
|
1035
|
+
+ '对比过结构:当前 patch 的配置块与体积都没有明显缺失。',
|
|
1036
|
+
evidence: patchBackups.map((b) => b.file + '(' + kb(b.size) + ')').join('、'),
|
|
1037
|
+
recommendation: '可以留着做参照,也可以自己删;管家不代删。要还原请用市场自带的 .bak 检测还原入口。',
|
|
1038
|
+
data: { backups: patchBackups.map((b) => ({ file: b.file, size: b.size, liveBytes: b.liveBytes })) },
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (pluginChanges.length > 0) {
|
|
1044
|
+
findings.push({
|
|
1045
|
+
id: 'plugin-change',
|
|
1046
|
+
severity: 'warning',
|
|
1047
|
+
category: '版本适配',
|
|
1048
|
+
title: '本会话记录到 ' + pluginChanges.length + ' 次插件变更',
|
|
1049
|
+
detail: '插件装/更/启停是 boot 风险的上游。DSH 市场在 enable 期已有 hostPeerGate 版本门槛与「#575 失败不落 enabled」守卫;管家不重复造拦截,只把变更标进报告供你对照。',
|
|
1050
|
+
evidence: '事件 plugin-manager/changed(本进程内存计数,重启清零);dshmarket/lib/routes.js:912-930、:1088-1100',
|
|
1051
|
+
recommendation: '变更后若出现启动失败,回到上面「启动诊断」点名的插件核对版本;pre-flight 预警以市场已有守卫为准,管家不介入安装流程。',
|
|
1052
|
+
data: { changes: pluginChanges },
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
findings.push(...advisoryFindings());
|
|
1057
|
+
|
|
1058
|
+
// ③ v0.7.7 plugin updates. Only reports "installed vs available" — it never
|
|
1059
|
+
// upgrades. Upgrades are exactly the change that produces boot failures, so
|
|
1060
|
+
// this stays an information item with a pointer, not a button.
|
|
1061
|
+
let updateInfo = { available: false, outdated: [], checked: 0, cacheAgeHours: null };
|
|
1062
|
+
try {
|
|
1063
|
+
updateInfo = collectUpdates(paths, io, errors);
|
|
1064
|
+
if (!updateInfo.available) {
|
|
1065
|
+
findings.push({
|
|
1066
|
+
id: 'plugin-updates', severity: 'info', category: '版本适配',
|
|
1067
|
+
title: '插件更新体检未执行:' + (updateInfo.reason || '数据源不可用'),
|
|
1068
|
+
detail: '市场把可安装版本缓存在 $DSH_HOME/plugin-update-cache.json;没读到它就不猜有没有新版。这不影响其他体检项。',
|
|
1069
|
+
evidence: path.join(paths.dshHome, 'plugin-update-cache.json'),
|
|
1070
|
+
recommendation: '在插件市场里打开一次列表页(会刷新该缓存),然后重新体检。',
|
|
1071
|
+
data: { available: false },
|
|
1072
|
+
});
|
|
1073
|
+
} else if (updateInfo.outdated.length > 0) {
|
|
1074
|
+
// v0.9.4: also name the packages whose installed copy is AHEAD of the cache.
|
|
1075
|
+
// Those are not updates — they mean the cache is older than the disk, which is
|
|
1076
|
+
// worth saying out loud exactly once instead of silently hiding them.
|
|
1077
|
+
const ahead = updateInfo.aheadOfCache || [];
|
|
1078
|
+
const aheadNote = ahead.length > 0
|
|
1079
|
+
? '\n另有 ' + ahead.length + ' 个包的已装版本比缓存里的 latest 还新('
|
|
1080
|
+
+ ahead.slice(0, 3).map((u) => u.name + ' ' + u.installed + ' > ' + u.latest).join('、')
|
|
1081
|
+
+ (ahead.length > 3 ? ' 等' : '')
|
|
1082
|
+
+ ')——那是缓存比磁盘旧,不是该升级。'
|
|
1083
|
+
: '';
|
|
1084
|
+
const unknownNote = updateInfo.undetermined > 0
|
|
1085
|
+
? '\n还有 ' + updateInfo.undetermined + ' 个包的版本号写法无法比较,未计入。'
|
|
1086
|
+
: '';
|
|
1087
|
+
findings.push({
|
|
1088
|
+
id: 'plugin-updates',
|
|
1089
|
+
severity: 'info',
|
|
1090
|
+
category: '版本适配',
|
|
1091
|
+
title: updateInfo.outdated.length + ' 个已装插件有新版本可用',
|
|
1092
|
+
detail: updateInfo.outdated.slice(0, 10).map((u) => '· ' + u.name + ':' + u.installed + ' → ' + u.latest).join('\n')
|
|
1093
|
+
+ (updateInfo.outdated.length > 10 ? '\n(共 ' + updateInfo.outdated.length + ' 个)' : '')
|
|
1094
|
+
+ aheadNote + unknownNote
|
|
1095
|
+
+ '\n\n升级是「插件变更 → boot 风险」的上游;升级前先确认真能备份到当前可用状态。',
|
|
1096
|
+
evidence: '来源:$DSH_HOME/plugin-update-cache.json(' + (updateInfo.cacheAgeHours != null ? updateInfo.cacheAgeHours + ' 小时前刷新' : '刷新时间未知') + ');对比 ' + updateInfo.checked + ' 个已装包;版本方向按 semver 比,只把「更新」算作可用',
|
|
1097
|
+
recommendation: '逐个升而不是批量升,升完看「启动诊断」;不要在要赶活的时候动它们。管家不代升级。',
|
|
1098
|
+
data: { outdated: updateInfo.outdated.slice(0, 20), aheadOfCache: ahead.slice(0, 20), undetermined: updateInfo.undetermined || 0, checked: updateInfo.checked, cacheAgeHours: updateInfo.cacheAgeHours },
|
|
1099
|
+
});
|
|
1100
|
+
} else if (updateInfo.checked > 0) {
|
|
1101
|
+
findings.push({
|
|
1102
|
+
id: 'plugin-updates', severity: 'info', category: '版本适配',
|
|
1103
|
+
title: '插件更新体检:' + updateInfo.checked + ' 个已装包都是最新',
|
|
1104
|
+
detail: '与市场缓存里的 latest 按 semver 比方向,没有落后项。'
|
|
1105
|
+
+ ((updateInfo.aheadOfCache || []).length > 0
|
|
1106
|
+
? '\n其中 ' + updateInfo.aheadOfCache.length + ' 个已装版本比缓存更新(缓存偏旧,不是该升级)。'
|
|
1107
|
+
: '')
|
|
1108
|
+
+ (updateInfo.undetermined > 0 ? '\n另有 ' + updateInfo.undetermined + ' 个包的版本号写法无法比较,未计入。' : ''),
|
|
1109
|
+
evidence: '来源:$DSH_HOME/plugin-update-cache.json(' + (updateInfo.cacheAgeHours != null ? updateInfo.cacheAgeHours + ' 小时前刷新' : '刷新时间未知') + ')',
|
|
1110
|
+
recommendation: '无需处理。',
|
|
1111
|
+
data: { outdated: [], aheadOfCache: (updateInfo.aheadOfCache || []).slice(0, 20), undetermined: updateInfo.undetermined || 0, checked: updateInfo.checked, cacheAgeHours: updateInfo.cacheAgeHours },
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
} catch (e) {
|
|
1115
|
+
errors.push({ step: 'plugin-updates', error: String((e && e.message) || e) });
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ② v0.7.7 workspace / session inventory. The report previously mentioned
|
|
1119
|
+
// sessions only inside a prose string; this measures them.
|
|
1120
|
+
//
|
|
1121
|
+
// v0.7.8 — 「50 MB 超线」改判为增长而不是绝对体积。一个固定阀值在你没长胖时
|
|
1122
|
+
// 会天天亮,那不是发现问题,是把阀值当成了问题(ACCEPTANCE A5)。现在需要
|
|
1123
|
+
// 上一个快照才能判:体积没变化就降到 info,只有真涨了才 warning。没有上一个
|
|
1124
|
+
// 快照时(首跑、或日志被清)不给「超标」结论,只说当前体积。
|
|
1125
|
+
let workspaceInfo = { available: false, workspaces: [], totalBytes: 0 };
|
|
1126
|
+
try {
|
|
1127
|
+
workspaceInfo = collectWorkspaces(paths, io, errors);
|
|
1128
|
+
if (workspaceInfo.available && workspaceInfo.workspaces.length > 0) {
|
|
1129
|
+
const prev = options.previous && options.previous.workspace;
|
|
1130
|
+
const prevList = prev && Array.isArray(prev.workspaces) ? prev.workspaces : [];
|
|
1131
|
+
const prevAt = prev && prev.at ? Date.parse(prev.at) : NaN;
|
|
1132
|
+
const prevById = new Map(prevList.map((w) => [w.id || w.title, w]));
|
|
1133
|
+
const GROWTH_WARN_BYTES = settingValue(settings, 'threshold.growthWarnMb') * 1024 * 1024; // 由设置页控制,默认 20 MB
|
|
1134
|
+
const grown = [];
|
|
1135
|
+
for (const w of workspaceInfo.workspaces) {
|
|
1136
|
+
const before = prevById.get(w.id || w.title);
|
|
1137
|
+
if (!before || typeof before.bytes !== 'number') continue;
|
|
1138
|
+
const delta = w.bytes - before.bytes;
|
|
1139
|
+
if (delta >= GROWTH_WARN_BYTES) grown.push({ ...w, deltaBytes: delta, beforeBytes: before.bytes });
|
|
1140
|
+
}
|
|
1141
|
+
grown.sort((a, b) => b.deltaBytes - a.deltaBytes);
|
|
1142
|
+
const gone = workspaceInfo.workspaces.filter((w) => !w.exists);
|
|
1143
|
+
// v0.9.10: never let the list quietly stop earlier than the count in the title.
|
|
1144
|
+
const WS_SHOWN = 6;
|
|
1145
|
+
const wsRest = workspaceInfo.workspaces.length - WS_SHOWN;
|
|
1146
|
+
const lines = workspaceInfo.workspaces.slice(0, WS_SHOWN).map((w) => {
|
|
1147
|
+
const before = prevById.get(w.id || w.title);
|
|
1148
|
+
const delta = before && typeof before.bytes === 'number' ? w.bytes - before.bytes : null;
|
|
1149
|
+
const deltaTxt = delta == null ? ''
|
|
1150
|
+
: delta === 0 ? ',与上次持平'
|
|
1151
|
+
: ',较上次 ' + (delta > 0 ? '+' : '−') + mbLabel(Math.abs(delta));
|
|
1152
|
+
return '· ' + w.title + ':' + mbLabel(w.bytes) + ',' + w.sessionCount + ' 个会话'
|
|
1153
|
+
+ deltaTxt
|
|
1154
|
+
+ (w.ageDays != null ? ',目录 ' + w.ageDays + ' 天前有改动' : '')
|
|
1155
|
+
+ (w.exists ? '' : '(目录已不存在)');
|
|
1156
|
+
}).join('\n');
|
|
1157
|
+
const compared = prevList.length > 0;
|
|
1158
|
+
const compareNote = compared
|
|
1159
|
+
? '\n对照 ' + (isFinite(prevAt) ? new Date(prevAt).toISOString() : '上一次体检') + ' 的快照;净增 ≥ ' + mbLabel(GROWTH_WARN_BYTES) + ' 的工作区才升为 warning。'
|
|
1160
|
+
: '\n没有上一个快照可对照(首次或报告被清),本次只记体积,不判定是否「长胖」。';
|
|
1161
|
+
// Say how many were left out, and on what basis -- a title that says 7 while
|
|
1162
|
+
// the body lists 6 reads as a rendering bug, and a reader cannot tell whether
|
|
1163
|
+
// the missing one is the small one or the one that matters.
|
|
1164
|
+
const wsMoreNote = wsRest > 0
|
|
1165
|
+
? '\n另有 ' + wsRest + ' 个工作区未逐条列出(上面按会话数据体积降序取前 ' + WS_SHOWN + ' 个,完整列表见 data.workspaces)。'
|
|
1166
|
+
: '';
|
|
1167
|
+
findings.push({
|
|
1168
|
+
id: 'workspace-inventory',
|
|
1169
|
+
severity: gone.length > 0 || grown.length > 0 ? 'warning' : 'info',
|
|
1170
|
+
category: '工作区与会话',
|
|
1171
|
+
title: gone.length > 0
|
|
1172
|
+
? '有 ' + gone.length + ' 个工作区指向的目录已不存在'
|
|
1173
|
+
: grown.length > 0
|
|
1174
|
+
? grown.length + ' 个工作区的会话数据在增长(合计净增 ' + mbLabel(grown.reduce((s, w) => s + w.deltaBytes, 0)) + ')'
|
|
1175
|
+
: workspaceInfo.workspaces.length + ' 个工作区,会话数据合计 ' + mbLabel(workspaceInfo.totalBytes) + '(与上次相比无显著增长)',
|
|
1176
|
+
detail: lines
|
|
1177
|
+
+ compareNote
|
|
1178
|
+
+ wsMoreNote
|
|
1179
|
+
+ (gone.length > 0 ? '\n有 ' + gone.length + ' 个工作区指向的目录已不存在,其会话数据成了孤儿。' : ''),
|
|
1180
|
+
evidence: path.join(paths.dshHome, 'storages', 'workspace.json') + ' + ' + workspaceInfo.sessionsDir,
|
|
1181
|
+
recommendation: '会话数据不在默认备份分区内,建议先加密备份再清理;旧会话可用 @michengai/dsh-archive-manager 归档(它的入口在 Web UI,管家不代劳)。管家只报告,不删、不移。',
|
|
1182
|
+
data: {
|
|
1183
|
+
totalBytes: workspaceInfo.totalBytes,
|
|
1184
|
+
workspaces: workspaceInfo.workspaces.slice(0, 8),
|
|
1185
|
+
workspacesTotal: workspaceInfo.workspaces.length,
|
|
1186
|
+
grown: grown.map((w) => ({ title: w.title, deltaBytes: w.deltaBytes, bytes: w.bytes })),
|
|
1187
|
+
orphan: gone.map((w) => w.title),
|
|
1188
|
+
comparedTo: compared && isFinite(prevAt) ? new Date(prevAt).toISOString() : null,
|
|
1189
|
+
growthWarnBytes: GROWTH_WARN_BYTES,
|
|
1190
|
+
},
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
} catch (e) {
|
|
1194
|
+
errors.push({ step: 'workspace-inventory', error: String((e && e.message) || e) });
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// ① v0.7.6 backup freshness. The advisory above says "not in the default
|
|
1198
|
+
// backup set"; this one answers the operational question the advisory cannot:
|
|
1199
|
+
// how long has it been since a real encrypted export landed on disk?
|
|
1200
|
+
let backupInfo = { dir: null, found: false, backups: [], latestAgeDays: null, latestHuman: null };
|
|
1201
|
+
try {
|
|
1202
|
+
const scanned = collectBackupExports(paths, io, errors);
|
|
1203
|
+
backupInfo = { ...scanned, latestAgeDays: null, latestHuman: null };
|
|
1204
|
+
if (scanned.backups.length > 0) {
|
|
1205
|
+
const latest = scanned.backups[0];
|
|
1206
|
+
const t = latest.mtime ? new Date(latest.mtime) : null;
|
|
1207
|
+
const ageDays = t ? Math.max(0, Math.floor((now.getTime() - t.getTime()) / 864e5)) : null;
|
|
1208
|
+
backupInfo.latestAgeDays = ageDays;
|
|
1209
|
+
backupInfo.latestHuman = t
|
|
1210
|
+
? ageDays + ' 天前(' + latest.file + ',' + mbLabel(latest.size) + ')'
|
|
1211
|
+
: latest.file;
|
|
1212
|
+
}
|
|
1213
|
+
const staleDays = settingValue(settings, 'threshold.backupStaleDays');
|
|
1214
|
+
const never = !scanned.found || scanned.backups.length === 0;
|
|
1215
|
+
const tooOld = backupInfo.latestAgeDays != null && backupInfo.latestAgeDays > staleDays;
|
|
1216
|
+
findings.push({
|
|
1217
|
+
id: 'backup-freshness',
|
|
1218
|
+
severity: never ? 'warning' : tooOld ? 'warning' : 'info',
|
|
1219
|
+
category: '备份覆盖',
|
|
1220
|
+
title: never
|
|
1221
|
+
? '没有找到任何加密备份文件'
|
|
1222
|
+
: tooOld
|
|
1223
|
+
? '上次加密备份是 ' + backupInfo.latestAgeDays + ' 天前(超过 ' + staleDays + ' 天)'
|
|
1224
|
+
: '上次加密备份:' + backupInfo.latestHuman,
|
|
1225
|
+
detail: never
|
|
1226
|
+
? '在 ' + (scanned.dir || '导出目录') + ' 下未找到 .zip 备份。易失数据(sessions/pluginData/pluginAssets/Mnemon)不在默认备份分区里,且本机从未启用过定时备份(backup-schedule.json 不存在)。'
|
|
1227
|
+
: '共 ' + scanned.backups.length + ' 份导出;最新一份 ' + backupInfo.latestHuman + '。默认备份不包含 sessions/pluginData/pluginAssets,真实保护靠这份手动导出。'
|
|
1228
|
+
+ (tooOld ? '超过 ' + staleDays + ' 天未更新,期间新增的会话与知识不在任何备份里。' : ''),
|
|
1229
|
+
evidence: (scanned.dir || '')
|
|
1230
|
+
+ (scanned.backups.length
|
|
1231
|
+
? ' → ' + scanned.backups.slice(0, 5).map((b) => b.file).join('、')
|
|
1232
|
+
+ (scanned.backups.length > 5 ? ' 等共 ' + scanned.backups.length + ' 份' : '')
|
|
1233
|
+
: ''),
|
|
1234
|
+
recommendation: never || tooOld
|
|
1235
|
+
? '在插件面板的「加密备份」卡里预览后执行(口令只在本次请求内存在,不经会话记录);或在 GUI 里手动加密导出。'
|
|
1236
|
+
: '保持现状;下次会话量明显增长后再导一次。',
|
|
1237
|
+
data: {
|
|
1238
|
+
dir: scanned.dir,
|
|
1239
|
+
count: scanned.backups.length,
|
|
1240
|
+
countNote: scanned.backups.length > 5 ? 'evidence 只列前 5 份,data.backups 含全部' : null,
|
|
1241
|
+
latestAgeDays: backupInfo.latestAgeDays,
|
|
1242
|
+
staleDays,
|
|
1243
|
+
backups: scanned.backups.slice(0, 5),
|
|
1244
|
+
},
|
|
1245
|
+
});
|
|
1246
|
+
} catch (e) {
|
|
1247
|
+
errors.push({ step: 'backup-freshness', error: String((e && e.message) || e) });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// ④ v0.7.6 config drift. Compare cordis.patch.yml / package.json digests
|
|
1251
|
+
// against the baseline recorded on the previous run. We never write the
|
|
1252
|
+
// config: the only side effect is the baseline file the host passes back in.
|
|
1253
|
+
let configNow = [];
|
|
1254
|
+
try {
|
|
1255
|
+
configNow = collectConfigFingerprint(paths, io, errors);
|
|
1256
|
+
const prev = Array.isArray(options.configBaseline) ? options.configBaseline : [];
|
|
1257
|
+
const prevKey = (x) => x.dir + '|' + x.file;
|
|
1258
|
+
const prevMap = new Map(prev.map((x) => [prevKey(x), x]));
|
|
1259
|
+
const changed = [];
|
|
1260
|
+
const added = [];
|
|
1261
|
+
for (const cur of configNow) {
|
|
1262
|
+
const old = prevMap.get(prevKey(cur));
|
|
1263
|
+
if (!old) { added.push(cur); continue; }
|
|
1264
|
+
if (old.digest !== cur.digest) {
|
|
1265
|
+
changed.push({
|
|
1266
|
+
file: cur.file,
|
|
1267
|
+
dir: cur.dir,
|
|
1268
|
+
bytes: cur.bytes,
|
|
1269
|
+
lines: cur.lines,
|
|
1270
|
+
fromBytes: old.bytes,
|
|
1271
|
+
fromLines: old.lines,
|
|
1272
|
+
deltaLines: (cur.lines || 0) - (old.lines || 0),
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
const shrunk = changed.filter((c) => c.deltaLines < -settingValue(settings, 'threshold.driftShrinkLines'));
|
|
1277
|
+
if (changed.length > 0) {
|
|
1278
|
+
const lines = changed.map((c) =>
|
|
1279
|
+
'· ' + c.file + ':' + (c.fromLines != null ? c.fromLines + ' → ' : '') + c.lines + ' 行'
|
|
1280
|
+
+ (c.deltaLines ? '(' + (c.deltaLines > 0 ? '+' : '') + c.deltaLines + ')' : '')
|
|
1281
|
+
+ ',' + mbLabel(c.bytes)).join('\n');
|
|
1282
|
+
findings.push({
|
|
1283
|
+
id: 'config-drift',
|
|
1284
|
+
severity: shrunk.length > 0 ? 'warning' : 'info',
|
|
1285
|
+
category: '配置完整性',
|
|
1286
|
+
title: shrunk.length > 0
|
|
1287
|
+
? '配置文件明显变短了:' + shrunk.map((c) => c.file).join('、')
|
|
1288
|
+
: '配置自上次体检后有改动(' + changed.length + ' 个文件)',
|
|
1289
|
+
detail: lines
|
|
1290
|
+
+ (shrunk.length > 0
|
|
1291
|
+
? '\n行数大幅减少是恢复/重置流程(sanitizeProfile 写默认 stub)的典型痕迹,曾有过静默丢 providers / 模型 / 权限预设的报告。'
|
|
1292
|
+
: '\n如果是你自己改的,忽略即可;如果没改过,值得看一眼。'),
|
|
1293
|
+
evidence: '指纹:' + changed.map((c) => c.file + '=' + (c.lines || 0) + '行').join('、') + ';基线来自上次体检(本机记录)',
|
|
1294
|
+
recommendation: shrunk.length > 0
|
|
1295
|
+
? '先备份当前 cordis.patch.yml(或找 .bak-<epoch> 兄弟文件),再对比差异;旧配置禁止整体回灌。管家只读不改 patch。'
|
|
1296
|
+
: '无需处理。若确认改动是有意的,下次体检会以新内容作为基线。',
|
|
1297
|
+
data: { changed, shrunk: shrunk.map((c) => c.file), added: added.map((a) => a.file) },
|
|
1298
|
+
});
|
|
1299
|
+
} else if (configNow.length > 0 && prev.length === 0) {
|
|
1300
|
+
findings.push({
|
|
1301
|
+
id: 'config-drift',
|
|
1302
|
+
severity: 'info', category: '配置完整性',
|
|
1303
|
+
title: '已记录配置指纹作为基线(首次)',
|
|
1304
|
+
detail: configNow.map((c) => '· ' + c.file + ':' + c.lines + ' 行,' + mbLabel(c.bytes)).join('\n')
|
|
1305
|
+
+ '\n从下一次体检起,这两个文件一旦变化就会在这里提示;变短会升为 warning。',
|
|
1306
|
+
evidence: 'cordis.patch.yml / package.json 的 sha256 前 16 位',
|
|
1307
|
+
recommendation: '无需处理。这条用于日后发现「配置被悄悄换掉」。',
|
|
1308
|
+
data: { baseline: configNow, added: [], shrunk: [], changed: [] },
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
} catch (e) {
|
|
1312
|
+
errors.push({ step: 'config-drift', error: String((e && e.message) || e) });
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// ③ v0.3.0 upgrade-compatibility check: engines.dsh declared by installed
|
|
1316
|
+
// bundles vs the actual host dsh-base version. This is the only half of pain
|
|
1317
|
+
// point ① nobody else covers (the host gate blocks NEW installs; it cannot
|
|
1318
|
+
// re-audit what is already installed after a host update).
|
|
1319
|
+
let compat = { hostVersion: null, entries: [] };
|
|
1320
|
+
try {
|
|
1321
|
+
// v0.4 hostVersion source chain: explicit option → newest crash log's
|
|
1322
|
+
// meta.app ("@deepseek-ai/dsh-desktop 0.2.2-rc.1" — the desktop app version
|
|
1323
|
+
// IS the host version) → null (只列声明不判等). @deepseek-ai/dsh-base is not
|
|
1324
|
+
// resolvable in the profile node_modules on this install (probed 2026-11-09,
|
|
1325
|
+
// MODULE_NOT_FOUND), so meta.app is the primary real source.
|
|
1326
|
+
let hostVersion = options.hostVersion || null;
|
|
1327
|
+
if (!hostVersion) {
|
|
1328
|
+
for (const c of crashes) {
|
|
1329
|
+
const app = c && c.meta && c.meta.app;
|
|
1330
|
+
if (typeof app !== 'string') continue;
|
|
1331
|
+
const m = /\s(\d+\.\d+\.\d+[^\s]*)/.exec(app);
|
|
1332
|
+
if (m) { hostVersion = m[1]; break; }
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
compat = collectCompat(paths, io, hostVersion);
|
|
1336
|
+
const mismatched = compat.entries.filter((e) => e.state === 'mismatch');
|
|
1337
|
+
const unknown = compat.entries.filter((e) => e.state === 'unknown');
|
|
1338
|
+
if (!compat.hostVersion) {
|
|
1339
|
+
findings.push({
|
|
1340
|
+
id: 'compat-check', severity: 'info', category: '版本适配',
|
|
1341
|
+
title: '升级兼容体检:宿主版本未确定,只列声明',
|
|
1342
|
+
detail: `读到 ${compat.entries.length} 个包声明了 engines.dsh 门槛,但当前宿主版本无法确定(既没有显式传入,crash 日志 meta.app 也没有版本)。按红线不做猜测,只列声明不判等。`,
|
|
1343
|
+
evidence: '来源:profile node_modules 各包 package.json 的 engines.dsh;宿主版本源候选:options.hostVersion → crash meta.app',
|
|
1344
|
+
recommendation: '宿主升级后重新体检;若 crash 日志也没有版本行,可在体检时显式传 options.hostVersion。',
|
|
1345
|
+
data: { hostVersion: null, declared: compat.entries.length, entries: compat.entries.slice(0, 40) },
|
|
1346
|
+
});
|
|
1347
|
+
} else if (mismatched.length > 0) {
|
|
1348
|
+
findings.push({
|
|
1349
|
+
id: 'compat-check', severity: 'warning', category: '版本适配',
|
|
1350
|
+
title: `${mismatched.length} 个已装包的宿主版本门槛不满足当前宿主`,
|
|
1351
|
+
detail: '当前宿主 ' + compat.hostVersion + ',以下包声明的范围不含它——它们是「升级后最先出问题」的候选:\n'
|
|
1352
|
+
+ mismatched.map((e) => `· ${e.name}@${e.version} 需要 ${e.range}`).join('\n')
|
|
1353
|
+
+ (unknown.length ? `\n(另有 ${unknown.length} 个声明无法判定,未计入)` : ''),
|
|
1354
|
+
evidence: '判据:semver 范围本地实现(支持 >= <= > < ^ = 与 || 并集);数据源 profile node_modules/*/package.json engines.dsh',
|
|
1355
|
+
recommendation: '优先核对这些包的更新版本;宿主已装清单里市场不会替你重审旧包,这是 pre-flight 的存量半边。管家不改安装状态。',
|
|
1356
|
+
data: { hostVersion: compat.hostVersion, mismatched, unknown: unknown.length, declared: compat.entries.length },
|
|
1357
|
+
});
|
|
1358
|
+
} else {
|
|
1359
|
+
findings.push({
|
|
1360
|
+
id: 'compat-check', severity: 'info', category: '版本适配',
|
|
1361
|
+
title: `升级兼容体检通过:${compat.entries.length} 个声明门槛的包都适配宿主 ${compat.hostVersion}`,
|
|
1362
|
+
detail: unknown.length
|
|
1363
|
+
? `其中 ${unknown.length} 个声明写法超出本地判据范围,标记为未判定而非通过。`
|
|
1364
|
+
: '所有已声明 engines.dsh 的包,其范围都包含当前宿主版本。',
|
|
1365
|
+
evidence: '判据:semver 范围本地实现;数据源 profile node_modules/*/package.json engines.dsh',
|
|
1366
|
+
recommendation: '宿主升级后重新体检即可复查存量适配。',
|
|
1367
|
+
data: { hostVersion: compat.hostVersion, declared: compat.entries.length, unknown: unknown.length },
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
} catch (e) {
|
|
1371
|
+
errors.push({ step: 'compat-check', error: String(e && e.message || e) });
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// ⑨ v0.9.3 client 标识体检(boot 的第三道盲区)。dshmarket 的静态体检按
|
|
1375
|
+
// loader entry id / bundle 名统计,trial boot 回放的是 loader 组合树——两者
|
|
1376
|
+
// 都看不见渲染端的 client 工厂表。2026-11-09 那次「整个 Web UI 起不来」就是
|
|
1377
|
+
// 从这个缝里漏过去的。判据只看磁盘,不启动任何东西。
|
|
1378
|
+
let clientIdentity = { entries: [], sameReal: [], drifted: [], collisions: [], unreadable: 0, withoutId: 0, scanned: 0 };
|
|
1379
|
+
try {
|
|
1380
|
+
clientIdentity = collectClientIdentity(paths, io, errors);
|
|
1381
|
+
const realHits = clientIdentity.sameReal || [];
|
|
1382
|
+
const driftHits = clientIdentity.drifted || [];
|
|
1383
|
+
const collHits = clientIdentity.collisions || [];
|
|
1384
|
+
const total = (clientIdentity.entries || []).length;
|
|
1385
|
+
const blame = crashes.length > 0 ? (crashes[0].clientDuplicates || []) : [];
|
|
1386
|
+
if (realHits.length > 0 || driftHits.length > 0 || collHits.length > 0) {
|
|
1387
|
+
const lines = [];
|
|
1388
|
+
for (const hit of realHits) {
|
|
1389
|
+
lines.push('· 同一份代码被装成多个名字:' + hit.dirs.join(' 与 ') + ' 都指向 ' + hit.realpath);
|
|
1390
|
+
}
|
|
1391
|
+
for (const hit of driftHits) {
|
|
1392
|
+
lines.push('· ' + hit.dir + ':client.js 声明的 id 是 "' + hit.id + '",但包名是 "' + hit.name + '"');
|
|
1393
|
+
}
|
|
1394
|
+
for (const hit of collHits) {
|
|
1395
|
+
lines.push('· 两个包用了同一个 client id "' + hit.id + '":' + hit.names.join('、'));
|
|
1396
|
+
}
|
|
1397
|
+
findings.push({
|
|
1398
|
+
id: 'client-identity',
|
|
1399
|
+
severity: 'warning',
|
|
1400
|
+
category: '启动诊断',
|
|
1401
|
+
title: '发现 ' + (realHits.length + driftHits.length + collHits.length) + ' 处会让整个 Web UI 起不来的 client 标识冲突',
|
|
1402
|
+
detail: 'DSH 的渲染端按 `__ModuleLoader__.load({ id })` 里的 id 建工厂表,同一个 id 注册第二次会直接抛 '
|
|
1403
|
+
+ '`client-modules: duplicate factory registration` 并让 boot 失败——一个重复 id,整个界面都打不开。\n'
|
|
1404
|
+
+ lines.join('\n')
|
|
1405
|
+
+ '\n这类状态不会被市场现有的体检抓到:静态体检按 loader entry id 与 bundle 名统计,trial boot 回放的也是 loader 层;'
|
|
1406
|
+
+ 'client 工厂表在渲染端,两边都看不见。',
|
|
1407
|
+
evidence: '遍历 ' + total + ' 个含 dsh.client 的已装包,按 exports["./client"] 解析出入口,读其中 `__ModuleLoader__.load({ id })`,'
|
|
1408
|
+
+ '并用 realpath 比对目录真身'
|
|
1409
|
+
+ (blame.length > 0 ? ';最近一次崩溃日志也点名了重复 id:' + blame.join('、') : ''),
|
|
1410
|
+
recommendation: '按上面点名的路径清理:同一份代码只留一个 node_modules 名字(多出来的是旧改名残留,删链接即可);'
|
|
1411
|
+
+ 'id 与包名不一致的,把 client.js 里的 id 改成包名。管家只读不代改——这类动作会动别的包,不属于授权执行面。',
|
|
1412
|
+
data: {
|
|
1413
|
+
checked: total,
|
|
1414
|
+
sameReal: realHits.slice(0, 10),
|
|
1415
|
+
drifted: driftHits.slice(0, 10),
|
|
1416
|
+
collisions: collHits.slice(0, 10),
|
|
1417
|
+
unreadable: clientIdentity.unreadable,
|
|
1418
|
+
withoutId: clientIdentity.withoutId,
|
|
1419
|
+
},
|
|
1420
|
+
});
|
|
1421
|
+
} else if (total > 0) {
|
|
1422
|
+
findings.push({
|
|
1423
|
+
id: 'client-identity',
|
|
1424
|
+
severity: 'info',
|
|
1425
|
+
category: '启动诊断',
|
|
1426
|
+
title: 'client 标识体检通过:' + total + ' 个已装客户端包的 id 互不冲突且与包名一致',
|
|
1427
|
+
detail: '核对了三件事:没有同一份代码被装成两个名字、每个客户端入口声明的 id 与包名一致、没有两个包抢同一个 id。'
|
|
1428
|
+
+ (clientIdentity.withoutId > 0
|
|
1429
|
+
? '\n(其中 ' + clientIdentity.withoutId + ' 个包没在 client.js 里声明 id,按宿主约定走包名,不计入冲突。)'
|
|
1430
|
+
: '')
|
|
1431
|
+
+ (clientIdentity.unreadable > 0
|
|
1432
|
+
? '\n(另有 ' + clientIdentity.unreadable + ' 个包的 client.js 读不到,这次没能核对。)'
|
|
1433
|
+
: ''),
|
|
1434
|
+
evidence: '数据源:profile node_modules 下 ' + total + ' 个含 dsh.client 的包,入口按 exports["./client"] → dsh.client → lib/client.js 依次解析;realpath 用于识别同一份代码的多个名字',
|
|
1435
|
+
recommendation: '无需处理。这类冲突只在改包名或重复安装时出现,平时安静是正确的。',
|
|
1436
|
+
data: {
|
|
1437
|
+
checked: total,
|
|
1438
|
+
unreadable: clientIdentity.unreadable,
|
|
1439
|
+
withoutId: clientIdentity.withoutId,
|
|
1440
|
+
},
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
} catch (e) {
|
|
1444
|
+
errors.push({ step: 'client-identity', error: String((e && e.message) || e) });
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// ⑧ v0.9.1 模型健康:默认模型是你此刻真正在用的那一个,它挂了比任何配置问题
|
|
1448
|
+
// 都直接。判据来自 models.js(只探哨兵集、只标不判死)。这里只负责把结论
|
|
1449
|
+
// 变成一条 finding:没探过 -> 不报;探过且默认模型 ok -> info 一句;默认模型
|
|
1450
|
+
// 不是 ok -> warning,并给出可切换的替代。
|
|
1451
|
+
const health = options.modelHealth;
|
|
1452
|
+
// index.js 交给这里的是 { at, default, defaultVerdict, summary, suggest, ... },
|
|
1453
|
+
// 摊平的计数在 summary 里。曾经直接读 health.probed,两边形状对不上,
|
|
1454
|
+
// 结果这条 finding 在真机上从不出现——单测用扁平形状喂进去反而过了。
|
|
1455
|
+
const mh = health && typeof health === 'object' && health.summary ? health.summary : health;
|
|
1456
|
+
if (mh && typeof mh === 'object' && Number(mh.probed) > 0) {
|
|
1457
|
+
const dv = health.defaultVerdict || { state: 'unprobed' };
|
|
1458
|
+
try {
|
|
1459
|
+
if (dv.state === 'dead' || (dv.state === 'busy' && health.defaultBusyRounds >= 2)) {
|
|
1460
|
+
const alt = health.suggest;
|
|
1461
|
+
findings.push({
|
|
1462
|
+
id: 'model-health',
|
|
1463
|
+
severity: 'warning',
|
|
1464
|
+
category: '模型可用性',
|
|
1465
|
+
title: '默认模型这会儿答不上来:' + health.default.provider + ' / ' + health.default.model,
|
|
1466
|
+
detail: (dv.state === 'dead'
|
|
1467
|
+
? '探测结果是「这个模型已不存在」(404/422),不是网络问题。'
|
|
1468
|
+
: '探测连续两轮都拿不到内容(配额/网络/模型侧故障)。')
|
|
1469
|
+
+ '\n这一轮共探了 ' + mh.probed + ' 个模型:' + mh.ok + ' 个正常、' + mh.dead + ' 个明确不可用、' + mh.busy + ' 个此刻不行。'
|
|
1470
|
+
+ (alt
|
|
1471
|
+
? '\n可切的替代:' + alt.provider + ' / ' + alt.model + (alt.sameProvider ? '(同一个服务商,换过去代价最小)' : '(另一个服务商)') + '。'
|
|
1472
|
+
: '\n这一轮没找到可推荐的替代——所有探过的模型都没正常回复,先查网络或配额。'),
|
|
1473
|
+
evidence: '探测口径:问答式单轮探针,超时 ' + Math.round((health.timeoutMs || 45000) / 1000) + ' 秒;判死仅限 HTTP 404/422,其余归为「此刻不行」;结果缓存 ' + Math.round((health.ttlMs || 3600000) / 60000) + ' 分钟。',
|
|
1474
|
+
recommendation: alt
|
|
1475
|
+
? '在面板里点「切到这个模型」——走 plan→确认→审计,只改默认模型选择,不动任何插件配置。'
|
|
1476
|
+
: '先在模型设置里确认这个服务商的密钥/配额,再重新体检。',
|
|
1477
|
+
data: {
|
|
1478
|
+
default: health.default, state: dv.state, note: dv.note || '',
|
|
1479
|
+
probed: mh.probed, ok: mh.ok, dead: mh.dead, busy: mh.busy,
|
|
1480
|
+
deadModels: mh.deadModels || [], suggest: alt || null, at: health.at,
|
|
1481
|
+
},
|
|
1482
|
+
});
|
|
1483
|
+
} else if (dv.state === 'busy') {
|
|
1484
|
+
// 只忙了一轮:还不足以说它坏了(配额/冷却会自动恢复),但也不能一个字都不报 ——
|
|
1485
|
+
// 那条路会让用户以为「模型巡检根本没在跑」。给它 info:看得见、不报警。
|
|
1486
|
+
findings.push({
|
|
1487
|
+
id: 'model-health',
|
|
1488
|
+
severity: 'info',
|
|
1489
|
+
category: '模型可用性',
|
|
1490
|
+
title: '默认模型这一轮没答上来(第 ' + (Number(health.defaultBusyRounds) || 1) + ' 轮):' + health.default.provider + ' / ' + health.default.model,
|
|
1491
|
+
detail: '探针没拿到内容,原因可能是配额、冷却或网络,不一定是模型坏了。'
|
|
1492
|
+
+ '\n判据:只有 HTTP 404/422 才算「这个模型已不存在」;连续两轮都答不上来才会升成「严重」。'
|
|
1493
|
+
+ (health.suggest ? '\n可以先切到 ' + health.suggest.provider + ' / ' + health.suggest.model + ',等它恢复再切回来。' : ''),
|
|
1494
|
+
evidence: health.at ? '最近一次巡检:' + health.at : '本轮巡检',
|
|
1495
|
+
recommendation: health.suggest
|
|
1496
|
+
? '可以先点「切到这个模型」应急;不改也完全可以,下一轮巡检会再判一次。'
|
|
1497
|
+
: '不改也行,下一轮巡检会再判一次;连续两轮答不上来它才会变成严重。',
|
|
1498
|
+
data: {
|
|
1499
|
+
default: health.default, state: 'busy', ms: dv.ms,
|
|
1500
|
+
probed: mh.probed, ok: mh.ok, dead: mh.dead, busy: mh.busy,
|
|
1501
|
+
deadModels: mh.deadModels || [], suggest: health.suggest || null, at: health.at,
|
|
1502
|
+
},
|
|
1503
|
+
});
|
|
1504
|
+
} else if (dv.state === 'ok') {
|
|
1505
|
+
const suspect = (mh.providers || []).filter((p) => p && p.offlineTwoRounds);
|
|
1506
|
+
findings.push({
|
|
1507
|
+
id: 'model-health',
|
|
1508
|
+
severity: 'info',
|
|
1509
|
+
category: '模型可用性',
|
|
1510
|
+
title: '默认模型可用(' + (dv.ms != null ? dv.ms + ' ms' : '已探通') + ')',
|
|
1511
|
+
detail: '这一轮探了 ' + mh.probed + ' 个模型(默认模型 + 每个服务商前几个):'
|
|
1512
|
+
+ mh.ok + ' 个正常、' + mh.dead + ' 个明确不可用、' + mh.busy + ' 个此刻不行。'
|
|
1513
|
+
+ (suspect.length > 0
|
|
1514
|
+
? '\n连续两轮都不可用的服务商:' + suspect.map((p) => p.provider).join('、') + '。'
|
|
1515
|
+
: '')
|
|
1516
|
+
+ '\n「此刻不行」不算坏:配额与冷却会自动恢复,所以管家只标出来,不把它们从选择器里删。',
|
|
1517
|
+
evidence: health.at ? '最近一次巡检:' + health.at : '本轮巡检',
|
|
1518
|
+
recommendation: suspect.length > 0
|
|
1519
|
+
? '那些服务商可能是密钥或配额问题;默认模型不受影响,可以不当急事。'
|
|
1520
|
+
: '无需处理。',
|
|
1521
|
+
data: {
|
|
1522
|
+
default: health.default, state: 'ok', ms: dv.ms,
|
|
1523
|
+
probed: mh.probed, ok: mh.ok, dead: mh.dead, busy: mh.busy,
|
|
1524
|
+
deadModels: mh.deadModels || [], offlineTwoRounds: suspect.map((p) => p.provider), at: health.at,
|
|
1525
|
+
},
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
} catch (e) {
|
|
1529
|
+
errors.push({ step: 'model-health', error: String((e && e.message) || e) });
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// ⑤ v0.7.7 confidence self-check. A report is only as good as what it managed
|
|
1534
|
+
// to read. This records, per collector, whether the source was actually
|
|
1535
|
+
// available — so "no problems found" can be distinguished from "I couldn't
|
|
1536
|
+
// look". The panel surfaces it as a banner when the coverage is thin; the
|
|
1537
|
+
// report never silently upgrades "unknown" to "fine".
|
|
1538
|
+
const coverage = {
|
|
1539
|
+
crashLogs: crashes.length > 0 ? 'data' : 'empty',
|
|
1540
|
+
patchConfig: (() => {
|
|
1541
|
+
const hit = configNow.find((c) => c.file === 'cordis.patch.yml');
|
|
1542
|
+
return hit ? 'data' : 'unavailable';
|
|
1543
|
+
})(),
|
|
1544
|
+
backupExports: !backupInfo.found ? 'unavailable' : backupInfo.backups.length > 0 ? 'data' : 'empty',
|
|
1545
|
+
workspaces: workspaceInfo.available ? (workspaceInfo.workspaces.length > 0 ? 'data' : 'empty') : 'unavailable',
|
|
1546
|
+
updates: updateInfo.available ? (updateInfo.checked > 0 ? 'data' : 'empty') : 'unavailable',
|
|
1547
|
+
compat: compat.entries.length > 0 ? 'data' : 'empty',
|
|
1548
|
+
clientIdentity: (clientIdentity.entries || []).length > 0
|
|
1549
|
+
? 'data'
|
|
1550
|
+
: (clientIdentity.scanned > 0 ? 'empty' : 'unavailable'),
|
|
1551
|
+
};
|
|
1552
|
+
if (coverage.patchConfig === 'unavailable' && backupInfo.found) {
|
|
1553
|
+
// An empty profile dir is the only reason patch is unavailable; that is a
|
|
1554
|
+
// missing install, not a broken read. Keep it visible but not alarming.
|
|
1555
|
+
coverage.patchConfig = 'empty';
|
|
1556
|
+
}
|
|
1557
|
+
const unavailable = Object.entries(coverage).filter(([, v]) => v === 'unavailable').map(([k]) => k);
|
|
1558
|
+
const totalChecked = Object.values(coverage).filter((v) => v === 'data').length;
|
|
1559
|
+
coverage.unavailable = unavailable;
|
|
1560
|
+
coverage.sourcesWithData = totalChecked;
|
|
1561
|
+
coverage.sourcesTotal = Object.keys(coverage).filter((k) => k !== 'unavailable' && k !== 'sourcesWithData' && k !== 'sourcesTotal').length;
|
|
1562
|
+
try {
|
|
1563
|
+
if (unavailable.length > 0) {
|
|
1564
|
+
findings.push({
|
|
1565
|
+
id: 'confidence',
|
|
1566
|
+
severity: unavailable.length >= 3 ? 'warning' : 'info',
|
|
1567
|
+
category: '体检可信度',
|
|
1568
|
+
title: unavailable.length + ' 个数据源这次没读到,结论可能不完整',
|
|
1569
|
+
detail: '读不到:' + unavailable.join('、') + '。\n'
|
|
1570
|
+
+ '这意味着对应体检项可能报「没发现问题」,但那不是「真的没问题」,是「没看见」。\n'
|
|
1571
|
+
+ '其余 ' + totalChecked + ' 个数据源正常。',
|
|
1572
|
+
evidence: '采集告警 ' + errors.length + ' 条'
|
|
1573
|
+
+ (errors.length
|
|
1574
|
+
? ':' + errors.slice(0, 3).map((e) => e.step).join('、')
|
|
1575
|
+
+ (errors.length > 3 ? ' 等 ' + errors.length + ' 条(完整见 data.errors)' : '')
|
|
1576
|
+
: ''),
|
|
1577
|
+
recommendation: '按对应项的取数路径排查(如市场缓存需先打开一次插件列表页)。管家宁可不报,也不会把「没读到」写成「合格」。',
|
|
1578
|
+
data: {
|
|
1579
|
+
coverage,
|
|
1580
|
+
errors: errors.map((e) => ({ step: e.step, error: String(e.error) })),
|
|
1581
|
+
},
|
|
1582
|
+
});
|
|
1583
|
+
} else if (errors.length > 0) {
|
|
1584
|
+
findings.push({
|
|
1585
|
+
id: 'confidence', severity: 'info', category: '体检可信度',
|
|
1586
|
+
title: '全部主要数据源都读到了(' + totalChecked + '/' + coverage.sourcesTotal + '),采集告警 ' + errors.length + ' 条',
|
|
1587
|
+
detail: '采集过程中有少量次要不完整,已在报告的 errors 里留档;不影响主要结论。',
|
|
1588
|
+
evidence: errors.slice(0, 5).map((e) => e.step + ': ' + e.error).join('\n')
|
|
1589
|
+
+ (errors.length > 5 ? '\n… 另 ' + (errors.length - 5) + ' 条(完整见 data.errors)' : ''),
|
|
1590
|
+
recommendation: '无需处理。',
|
|
1591
|
+
data: {
|
|
1592
|
+
coverage,
|
|
1593
|
+
errors: errors.map((e) => ({ step: e.step, error: String(e.error) })),
|
|
1594
|
+
},
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
} catch (e) {
|
|
1598
|
+
errors.push({ step: 'confidence', error: String((e && e.message) || e) });
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// ③ v0.3.1 knowledge recall: match current active findings against the
|
|
1602
|
+
// steward's own append-only knowledge log. When the same finding was seen
|
|
1603
|
+
// before, surface that prior record inline — "上次也是这样、当时记了什么".
|
|
1604
|
+
// This is the line between a measuring instrument and a caretaker. Read-only;
|
|
1605
|
+
// a missing/corrupt knowledge log simply yields no recalls (never degrades).
|
|
1606
|
+
const knowledgeLog = Array.isArray(options.knowledge) ? options.knowledge : [];
|
|
1607
|
+
const priorById = new Map();
|
|
1608
|
+
for (const k of knowledgeLog) {
|
|
1609
|
+
if (!k || !Array.isArray(k.findingIds)) continue;
|
|
1610
|
+
for (const id of k.findingIds) {
|
|
1611
|
+
if (typeof id !== 'string') continue;
|
|
1612
|
+
const prev = priorById.get(id);
|
|
1613
|
+
// Keep the MOST RECENT record per finding id (recall the latest lesson).
|
|
1614
|
+
if (!prev || String(k.at || '') > String(prev.at || '')) priorById.set(id, k);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
for (const f of findings) {
|
|
1618
|
+
const hit = priorById.get(f.id);
|
|
1619
|
+
if (hit && String(hit.at || '') < String(now.toISOString ? now.toISOString() : now)) {
|
|
1620
|
+
f.recall = {
|
|
1621
|
+
at: hit.at,
|
|
1622
|
+
headline: hit.headline,
|
|
1623
|
+
counts: hit.counts,
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Mute (ack) layer: user-acknowledged findings keep their evidence but stop
|
|
1629
|
+
// counting toward risk level. Reversible via kind=unack. v0.7.7: an ack that
|
|
1630
|
+
// carried an expiresAt stops suppressing once it lapses — silence is a note
|
|
1631
|
+
// about "right now", not a permanent gag. Entries without expiresAt keep the
|
|
1632
|
+
// old permanent behaviour so pre-existing acks do not suddenly reappear.
|
|
1633
|
+
const ackedRaw = Array.isArray(options.acked) ? options.acked : [];
|
|
1634
|
+
const ackedIds = new Set(
|
|
1635
|
+
ackedRaw
|
|
1636
|
+
.filter((a) => {
|
|
1637
|
+
if (!a) return false;
|
|
1638
|
+
const exp = a && typeof a === 'object' ? a.expiresAt : null;
|
|
1639
|
+
if (!exp) return true;
|
|
1640
|
+
const t = Date.parse(exp);
|
|
1641
|
+
if (!isFinite(t)) return true;
|
|
1642
|
+
return t > now.getTime();
|
|
1643
|
+
})
|
|
1644
|
+
.map((a) => (a && typeof a === 'object' ? a.id : a))
|
|
1645
|
+
.filter((x) => typeof x === 'string'),
|
|
1646
|
+
);
|
|
1647
|
+
let mutedCount = 0;
|
|
1648
|
+
for (const f of findings) {
|
|
1649
|
+
f.muted = ackedIds.has(f.id);
|
|
1650
|
+
if (f.muted) mutedCount += 1;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Importance scoring (v0.3.2) — runs AFTER the mute layer so "actionable"
|
|
1654
|
+
// honours the user's ack. Four observable factors, each stated in
|
|
1655
|
+
// f.scoreReasons: severity base (60/40/20) ± freshness (this process
|
|
1656
|
+
// lifetime) + actionable (one-click authorized path exists) + magnitude
|
|
1657
|
+
// (how big the evidence is). Auditable number, never a black box.
|
|
1658
|
+
try {
|
|
1659
|
+
scoreFindings(findings, { bootedAt });
|
|
1660
|
+
} catch (e) {
|
|
1661
|
+
errors.push({ step: 'priority-score', error: String(e && e.message || e) });
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const counts = { critical: 0, warning: 0, info: 0, muted: mutedCount };
|
|
1665
|
+
for (const f of findings) if (!f.muted && counts[f.severity] != null) counts[f.severity] += 1;
|
|
1666
|
+
const level = counts.critical > 0 ? 'critical' : counts.warning > 0 ? 'warning' : 'ok';
|
|
1667
|
+
const headline = counts.critical > 0
|
|
1668
|
+
? '发现启动崩溃记录,请优先看「启动诊断」'
|
|
1669
|
+
: counts.warning > 0
|
|
1670
|
+
? '有需要留意的风险项'
|
|
1671
|
+
: '当前未发现异常,以下为常备建议';
|
|
1672
|
+
|
|
1673
|
+
return {
|
|
1674
|
+
generatedAt: now.toISOString ? now.toISOString() : String(now),
|
|
1675
|
+
host: { platform, arch: process.arch, node: process.version, dshHome: paths.dshHome },
|
|
1676
|
+
cFreeBytes,
|
|
1677
|
+
backup: backupInfo,
|
|
1678
|
+
config: configNow,
|
|
1679
|
+
workspace: workspaceInfo,
|
|
1680
|
+
updates: updateInfo,
|
|
1681
|
+
coverage,
|
|
1682
|
+
status: errors.length > 0 ? 'degraded' : 'ok',
|
|
1683
|
+
summary: { level, headline, counts },
|
|
1684
|
+
findings,
|
|
1685
|
+
limitations: [
|
|
1686
|
+
'只读:不删文件、不代跑提权命令、不自动拉起/重启、不改启动流程。',
|
|
1687
|
+
'不重复实现磁盘扫描 / 备份 / 归档 / 记忆,只指向现成能力。',
|
|
1688
|
+
'不 import 任何其它 DSH 包内部模块,避免版本漂移。',
|
|
1689
|
+
'崩溃检测是「启动后读日志」,不是实时守护;本进程内事件随宿主 fatal 消失。',
|
|
1690
|
+
'采集限流:日志 ≤ ' + limits.maxLogs + ' 份、单份 ≤ ' + limits.maxBytesPerLog + ' B(可在面板「设置」里调)。',
|
|
1691
|
+
],
|
|
1692
|
+
errors,
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
export default collectReport;
|