@moonquake2004/dsh-doctor 0.1.0
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/client/client.js +129 -0
- package/cordis.patch.yml +4 -0
- package/dsh-doctor.mjs +548 -0
- package/lib/index.js +94 -0
- package/package.json +45 -0
package/client/client.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({ id: "dsh-doctor", factory: (require) => {
|
|
2
|
+
var module = { exports: {} }; var exports = module.exports;
|
|
3
|
+
'use strict'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* dsh-doctor client: registers a "诊断 / Doctor" settings section that runs
|
|
7
|
+
* the offline checks via /dsh-doctor/run and renders the results.
|
|
8
|
+
* Hand-authored CJS bundle (no build step); the only external is the loader
|
|
9
|
+
* module table's `react`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const React = require('react')
|
|
13
|
+
const h = React.createElement
|
|
14
|
+
const { useState, useEffect, useCallback } = React
|
|
15
|
+
|
|
16
|
+
const NS = 'dsh-doctor'
|
|
17
|
+
const zh = {
|
|
18
|
+
nav: '诊断',
|
|
19
|
+
run: '运行诊断',
|
|
20
|
+
running: '诊断中…',
|
|
21
|
+
ok: '全部通过',
|
|
22
|
+
bad: '发现 {n} 个问题',
|
|
23
|
+
sectionEnv: '环境',
|
|
24
|
+
sectionProfile: 'Profile',
|
|
25
|
+
sectionSession: '会话',
|
|
26
|
+
fix: '修复',
|
|
27
|
+
quarantineHint: '隔离建议(手动执行,勿自动)',
|
|
28
|
+
error: '诊断失败:{msg}',
|
|
29
|
+
loading: '加载中…',
|
|
30
|
+
}
|
|
31
|
+
const en = {
|
|
32
|
+
nav: 'Doctor',
|
|
33
|
+
run: 'Run checks',
|
|
34
|
+
running: 'Running…',
|
|
35
|
+
ok: 'All checks passed',
|
|
36
|
+
bad: '{n} problem(s) found',
|
|
37
|
+
sectionEnv: 'Environment',
|
|
38
|
+
sectionProfile: 'Profile',
|
|
39
|
+
sectionSession: 'Session',
|
|
40
|
+
fix: 'Fix',
|
|
41
|
+
quarantineHint: 'Quarantine suggestion (run manually, never auto)',
|
|
42
|
+
error: 'Doctor failed: {msg}',
|
|
43
|
+
loading: 'Loading…',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function injectStyles() {
|
|
47
|
+
if (document.getElementById('dsh-doctor-style')) return
|
|
48
|
+
const style = document.createElement('style')
|
|
49
|
+
style.id = 'dsh-doctor-style'
|
|
50
|
+
style.textContent = `
|
|
51
|
+
.dshd-wrap { font-size: 13px; line-height: 1.6; max-width: 760px; }
|
|
52
|
+
.dshd-sum { padding: 10px 14px; border-radius: 8px; margin: 8px 0; font-weight: 600; }
|
|
53
|
+
.dshd-sum.ok { background: rgba(61,220,151,.12); color: #2fb47e; }
|
|
54
|
+
.dshd-sum.bad { background: rgba(255,93,93,.12); color: #e05656; }
|
|
55
|
+
.dshd-sec { margin-top: 12px; font-weight: 700; opacity: .85; }
|
|
56
|
+
.dshd-row { padding: 6px 10px; border-radius: 6px; margin: 4px 0; background: rgba(128,128,128,.07); }
|
|
57
|
+
.dshd-row .mark { font-weight: 700; margin-right: 6px; }
|
|
58
|
+
.dshd-row.ok .mark { color: #2fb47e; }
|
|
59
|
+
.dshd-row.bad .mark { color: #e05656; }
|
|
60
|
+
.dshd-row .det { word-break: break-all; }
|
|
61
|
+
.dshd-row .fix { color: #ff9d5d; margin-top: 2px; font-size: 12px; word-break: break-all; }
|
|
62
|
+
.dshd-btn { padding: 6px 14px; border-radius: 6px; border: 1px solid currentColor; background: transparent; cursor: pointer; }
|
|
63
|
+
.dshd-btn:disabled { opacity: .5; cursor: wait; }
|
|
64
|
+
`
|
|
65
|
+
document.head.appendChild(style)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function DoctorSection(props) {
|
|
69
|
+
const t = props.t
|
|
70
|
+
const localeSnap = React.useSyncExternalStore(
|
|
71
|
+
(cb) => props.locale.subscribe(cb),
|
|
72
|
+
() => props.locale.getSnapshot(),
|
|
73
|
+
)
|
|
74
|
+
const lang = String(localeSnap.active).toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
75
|
+
const L = lang === 'zh' ? zh : en
|
|
76
|
+
const [data, setData] = useState(null)
|
|
77
|
+
const [error, setError] = useState(null)
|
|
78
|
+
const [running, setRunning] = useState(false)
|
|
79
|
+
const run = useCallback(async () => {
|
|
80
|
+
setRunning(true); setError(null)
|
|
81
|
+
try {
|
|
82
|
+
const res = await fetch('/dsh-doctor/run', { cache: 'no-store' })
|
|
83
|
+
const json = await res.json()
|
|
84
|
+
setData(json)
|
|
85
|
+
} catch (e) { setError(String(e.message || e)) }
|
|
86
|
+
finally { setRunning(false) }
|
|
87
|
+
}, [])
|
|
88
|
+
useEffect(() => { injectStyles(); run() }, [run])
|
|
89
|
+
const sectionName = (s) => ({ env: L.sectionEnv, profile: L.sectionProfile, session: L.sectionSession }[s] || s)
|
|
90
|
+
return h('div', { className: 'dshd-wrap' },
|
|
91
|
+
h('div', { style: { display: 'flex', gap: '10px', alignItems: 'center' } },
|
|
92
|
+
h('button', { className: 'dshd-btn', onClick: run, disabled: running }, running ? L.running : L.run)),
|
|
93
|
+
error ? h('div', { className: 'dshd-sum bad' }, L.error.replace('{msg}', error))
|
|
94
|
+
: data ? h('div', null,
|
|
95
|
+
h('div', { className: 'dshd-sum ' + (data.ok ? 'ok' : 'bad') },
|
|
96
|
+
data.ok ? L.ok : L.bad.replace('{n}', String((data.checks || []).filter((c) => !c.ok).length))),
|
|
97
|
+
(data.checks || []).reduce((acc, c) => {
|
|
98
|
+
const prev = acc[acc.length - 1]
|
|
99
|
+
if (!prev || prev.section !== c.section) acc.push({ section: c.section, rows: [c] })
|
|
100
|
+
else prev.rows.push(c)
|
|
101
|
+
return acc
|
|
102
|
+
}, []).map((group) =>
|
|
103
|
+
h('div', { key: group.section },
|
|
104
|
+
h('div', { className: 'dshd-sec' }, sectionName(group.section)),
|
|
105
|
+
group.rows.map((c) =>
|
|
106
|
+
h('div', { key: c.id, className: 'dshd-row ' + (c.ok ? 'ok' : 'bad') },
|
|
107
|
+
h('span', { className: 'mark' }, c.ok ? '✓' : '✗'),
|
|
108
|
+
h('span', null, `[${c.id}] `),
|
|
109
|
+
h('span', { className: 'det' }, c.detail),
|
|
110
|
+
!c.ok && c.fix ? h('div', { className: 'fix' }, `↳ ${L.fix}: ${c.fix}`) : null)))))
|
|
111
|
+
: h('div', null, L.loading))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
exports.name = 'dsh-doctor'
|
|
115
|
+
exports.inject = ['slots', 'locale']
|
|
116
|
+
exports.apply = function apply(ctx) {
|
|
117
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-doctor: dictionaries')
|
|
118
|
+
const t = ctx.locale.bind(NS)
|
|
119
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
120
|
+
name: 'settings.section',
|
|
121
|
+
id: 'dsh-doctor',
|
|
122
|
+
order: 50,
|
|
123
|
+
label: () => t('nav'),
|
|
124
|
+
locale: NS,
|
|
125
|
+
inject: () => ({ t }),
|
|
126
|
+
}, () => h(DoctorSection, { t, locale: ctx.locale })))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return module.exports; } });
|
package/cordis.patch.yml
ADDED
package/dsh-doctor.mjs
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-doctor.mjs — DSH 离线诊断工具("装前/启动前跑一次,把坑提前填上")
|
|
4
|
+
*
|
|
5
|
+
* 整合社区讨论中可离线检测的故障类别:
|
|
6
|
+
* [profile]
|
|
7
|
+
* P1 bundle 条目无法解析(#917/#1377/#880:remove 残留、静默禁用、启动 fail-fast)
|
|
8
|
+
* P2 bundle patch 与用户 patch insert 的 id 冲突(#1404:duplicate loader entry id)
|
|
9
|
+
* P3 用户 patch 的 insert name 从 profile 锚点不可解析(#1197/#880)
|
|
10
|
+
* P4 file: 依赖指向不存在的目录(#1197:悬空 file: 链接)
|
|
11
|
+
* P5 profile 顶层 @deepseek-ai/* 与框架重复(#1486:双模块实例 → Symbol 不匹配)
|
|
12
|
+
* [session]
|
|
13
|
+
* S1 孤儿 tool_call(#1363:assistant tool_calls 无对应 tool 结果 → INVALID_REQUEST)
|
|
14
|
+
* S2 未闭合 turn(#466/#1265:turn/start 无 turn/end → 会话永久"运行中")
|
|
15
|
+
* S6 seq 不连续/空洞/重复(#1333/#1452/#1469:官方 seq==index 校验,chunk 行按 expandRow 展开)
|
|
16
|
+
* S7 end-seed 后重放已提交尾部(#1497:种子末尾之后出现更低 seq)
|
|
17
|
+
* S9 zstd 容器结构(#1043:单帧容器 → session.list 整体 500,侧边栏全消失)
|
|
18
|
+
* S10 sourceEventSeqs 悬空引用(#1469:压缩未重映射溯源 → history unavailable)
|
|
19
|
+
* S8 未知事件类型且无 ignorable(#1538:插件写的事件 harness 读不了 → 整包拒绝;清单从安装的 dsh-session 解析,内置 0.1.0-rc.6 回退)
|
|
20
|
+
* S11 全会话扫描(#1550:损坏会话 → 隔离建议;超大会话/工作区估算物化堆 → 冷启动风险警告;估算堆=解码MB×6+事件×200B,阈值默认 1GB,可设 DSH_DOCTOR_HEAP_MB)
|
|
21
|
+
* [env]
|
|
22
|
+
* E1 关键命令不在 PATH(#1270:node/pnpm/zstd)
|
|
23
|
+
* E2 .env 是目录而非文件(#71:failed to load .env: EISDIR)
|
|
24
|
+
* E3 node 版本 / --expose-internals 可及性(#113/#1313,headless/HMR 场景)
|
|
25
|
+
* E4 node-pty 原生模块完整性(#1219:pty.node 缺失 → dsh web 启动失败)
|
|
26
|
+
* E5 存储 JSON 文件合法性(#1357:并发写 workspace.json 乱码 → 工作区列表消失)
|
|
27
|
+
* E6 锚点元检查(tripwire:S6 的 expandRow seq0+k、S7 的 session/end-seed、S10 的 sourceEventSeqs 是否仍在安装的 dsh-session 中)
|
|
28
|
+
* (P6 Windows 空格参数 lint,#1420 —— 待实现)
|
|
29
|
+
*
|
|
30
|
+
* 用法:
|
|
31
|
+
* node dsh-doctor.mjs # 全部检查
|
|
32
|
+
* node dsh-doctor.mjs --profile web # 仅 profile 检查(可多次/逗号分隔)
|
|
33
|
+
* node dsh-doctor.mjs --session <path> # 仅会话检查(默认自动找最新会话)
|
|
34
|
+
* node dsh-doctor.mjs --env # 仅环境检查
|
|
35
|
+
* node dsh-doctor.mjs --json # 输出 JSON
|
|
36
|
+
*
|
|
37
|
+
* 退出码:0 = 全部通过;1 = 发现可修复问题;2 = 用法/环境错误。
|
|
38
|
+
*/
|
|
39
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
40
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
41
|
+
import { createRequire } from 'node:module';
|
|
42
|
+
import { basename, delimiter as PATH_DELIM, dirname, join } from 'node:path';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
|
|
45
|
+
const HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
46
|
+
const results = []; // { section, id, ok, detail, fix? }
|
|
47
|
+
const jsonOut = process.argv.includes('--json');
|
|
48
|
+
const only = process.argv
|
|
49
|
+
.filter((a) => a.startsWith('--profile') || a.startsWith('--session') || a === '--env')
|
|
50
|
+
.map((a) => a.startsWith('--') ? a.slice(2) : a);
|
|
51
|
+
const wants = (s) => only.length === 0 || only.includes(s) || only.includes(s.charAt(0).toUpperCase() + s.slice(1));
|
|
52
|
+
|
|
53
|
+
// S8:官方 KNOWN_SESSION_EVENT_TYPES(0.1.0-rc.6 内置回退;优先从安装的 dsh-session 解析)
|
|
54
|
+
const KNOWN_SESSION_EVENT_TYPES_FALLBACK = new Set([
|
|
55
|
+
'agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided', 'approval/policy',
|
|
56
|
+
'assistant/chunk', 'assistant/message', 'command/done', 'command/run', 'compaction/end', 'compaction/prune',
|
|
57
|
+
'compaction/start', 'compaction/summary', 'feedback/record', 'goal/change', 'hook/invoked', 'hook/result',
|
|
58
|
+
'llm/retry', 'llm/retry-started', 'permission/preset', 'plan/mode', 'request/context', 'request/header',
|
|
59
|
+
'sandbox/mode', 'schedule/change', 'session/end-seed', 'session/title', 'session/title-llm-request',
|
|
60
|
+
'step/end', 'step/start', 'subagent/descriptor', 'todo/write', 'tool-workflow/agent-end',
|
|
61
|
+
'tool-workflow/agent-start', 'tool-workflow/run-end', 'tool-workflow/run-start', 'tool/call',
|
|
62
|
+
'tool/code-dispatch', 'tool/code-dispatch-start', 'tool/result', 'turn/end', 'turn/start', 'user/message',
|
|
63
|
+
'web/deepseek-search-llm-request'
|
|
64
|
+
]);
|
|
65
|
+
// 存储行类型与 header,不属于事件门禁
|
|
66
|
+
const STORAGE_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks', 'session']);
|
|
67
|
+
function knownSessionEventTypes() {
|
|
68
|
+
for (const p of (process.env.PATH || '').split(PATH_DELIM)) {
|
|
69
|
+
if (!p.endsWith('node_modules/.bin') || !existsSync(join(p, 'dsh'))) continue;
|
|
70
|
+
try {
|
|
71
|
+
const src = readFileSync(join(dirname(p), '@deepseek-ai', 'dsh-session', 'lib', 'index.js'), 'utf8');
|
|
72
|
+
const m = /const KNOWN_SESSION_EVENT_TYPES = new Set\(\[(.*?)\]\);/.exec(src);
|
|
73
|
+
if (m) {
|
|
74
|
+
const items = [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
|
75
|
+
if (items.length) return new Set(items);
|
|
76
|
+
}
|
|
77
|
+
} catch { /* 回退 */ }
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
return KNOWN_SESSION_EVENT_TYPES_FALLBACK;
|
|
81
|
+
}
|
|
82
|
+
const KNOWN = knownSessionEventTypes();
|
|
83
|
+
|
|
84
|
+
function report(section, id, ok, detail, fix) {
|
|
85
|
+
results.push({ section, id, ok, detail, fix });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveProfile(name) {
|
|
89
|
+
if (!name || name.includes('/') || name.includes('\\')) throw new Error(`无效 profile 名: ${JSON.stringify(name)}`);
|
|
90
|
+
return join(HOME, 'profiles', name);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ================= env ================= */
|
|
94
|
+
function checkEnv() {
|
|
95
|
+
if (!wants('env')) return;
|
|
96
|
+
const find = (cmd) => { for (const w of process.platform === 'win32' ? ['where'] : ['which']) { const r = spawnSync(w, [cmd]); if (r.status === 0) { const p = String(r.stdout).split(/\r?\n/)[0].trim(); if (p) return p; } } return null; };
|
|
97
|
+
for (const cmd of ['node', 'pnpm', 'zstd']) {
|
|
98
|
+
const p = find(cmd);
|
|
99
|
+
report('env', `E1-${cmd}`, !!p, p ? `${cmd}: ${p}` : `${cmd} 不在 PATH(${cmd === 'node' ? '创建会话会失败 #1270' : cmd === 'pnpm' ? 'dsh plugin 不可用' : '会话日志解压不可用'})`, p ? undefined : `安装 ${cmd} 或加入 PATH`);
|
|
100
|
+
}
|
|
101
|
+
const envFile = join(HOME, '.env');
|
|
102
|
+
if (existsSync(envFile)) {
|
|
103
|
+
const isDir = lstatSync(envFile).isDirectory();
|
|
104
|
+
report('env', 'E2-env', !isDir, isDir ? `${envFile} 是目录,dsh 启动会报 failed to load .env: EISDIR(#71)` : `${envFile} 正常`, isDir ? '删除或改名该目录' : undefined);
|
|
105
|
+
}
|
|
106
|
+
const nv = spawnSync('node', ['-e', 'console.log(process.version)']);
|
|
107
|
+
if (nv.status === 0) report('env', 'E3-node', true, `node ${String(nv.stdout).trim()}`, undefined);
|
|
108
|
+
|
|
109
|
+
// E4:node-pty 原生模块完整性(#1219:pty.node 缺失 → dsh web 启动失败)
|
|
110
|
+
const ptyDirs = [];
|
|
111
|
+
for (const p of (process.env.PATH || '').split(PATH_DELIM)) {
|
|
112
|
+
if (p.endsWith('node_modules/.bin') && existsSync(join(p, 'dsh'))) {
|
|
113
|
+
ptyDirs.push(join(dirname(p), 'node-pty'));
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const profileNM = join(HOME, 'profiles', 'web', 'node_modules');
|
|
118
|
+
ptyDirs.push(join(profileNM, 'node-pty'));
|
|
119
|
+
const pnpmStore = join(profileNM, '.pnpm');
|
|
120
|
+
if (existsSync(pnpmStore)) {
|
|
121
|
+
for (const d of readdirSync(pnpmStore)) if (d.startsWith('node-pty@')) ptyDirs.push(join(pnpmStore, d, 'node_modules', 'node-pty'));
|
|
122
|
+
}
|
|
123
|
+
const plat = `${process.platform}-${process.arch}`;
|
|
124
|
+
const ptyFound = ptyDirs.filter((d) => existsSync(d));
|
|
125
|
+
let ptyBinary = null;
|
|
126
|
+
for (const d of ptyFound) {
|
|
127
|
+
for (const bin of [join(d, 'prebuilds', plat, 'pty.node'), join(d, 'build', 'Release', 'pty.node')]) {
|
|
128
|
+
if (existsSync(bin) && statSync(bin).size > 0) { ptyBinary = bin; break; }
|
|
129
|
+
}
|
|
130
|
+
if (ptyBinary) break;
|
|
131
|
+
}
|
|
132
|
+
if (ptyFound.length === 0) report('env', 'E4', false, '未找到 node-pty(dsh web 终端依赖它,#1219)', '重新安装 @deepseek-ai/dsh,确保 node-pty 装全');
|
|
133
|
+
else if (ptyBinary) report('env', 'E4', true, `node-pty 原生模块在位(${plat})`, undefined);
|
|
134
|
+
else report('env', 'E4', false, `node-pty 存在但缺 ${plat} 原生二进制(#1219: dsh web 启动失败)`, '重装 node-pty(npm rebuild node-pty)或从源码构建');
|
|
135
|
+
|
|
136
|
+
// E5:存储 JSON 文件合法性(#1357:并发写 workspace.json 乱码 → 工作区列表消失)
|
|
137
|
+
const storages = join(HOME, 'storages');
|
|
138
|
+
const badStorage = [];
|
|
139
|
+
if (existsSync(storages)) {
|
|
140
|
+
for (const f of readdirSync(storages)) {
|
|
141
|
+
if (!f.endsWith('.json')) continue;
|
|
142
|
+
const fp = join(storages, f);
|
|
143
|
+
let buf;
|
|
144
|
+
try { buf = readFileSync(fp); } catch { badStorage.push(`${f}(读取失败)`); continue; }
|
|
145
|
+
let utf8ok = true;
|
|
146
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(buf); } catch { utf8ok = false; }
|
|
147
|
+
let jsonok = false;
|
|
148
|
+
if (utf8ok) { try { JSON.parse(buf.toString('utf8')); jsonok = true; } catch { /* 非法 JSON */ } }
|
|
149
|
+
if (!jsonok) badStorage.push(`${f}(UTF-8:${utf8ok ? 'OK' : 'BAD'},JSON:${jsonok ? 'OK' : 'BAD'})`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (badStorage.length) report('env', 'E5', false, `存储文件损坏(#1357 并发写乱码类): ${badStorage.join(', ')}`, '排查是否有多个 dsh 实例并发写同一 storages;修复或删除损坏文件');
|
|
153
|
+
else report('env', 'E5', true, '存储 JSON 文件均合法', undefined);
|
|
154
|
+
|
|
155
|
+
// E6:锚点元检查(tripwire)——我们 S6/S7/S10 依赖的契约是否仍在安装的 dsh-session 里
|
|
156
|
+
// 上游改名/重构会让我们的离线结论静默腐烂(boyin111-1 的 --verify-anchors 同款思路)
|
|
157
|
+
let sessionLib = null;
|
|
158
|
+
for (const p of (process.env.PATH || '').split(PATH_DELIM)) {
|
|
159
|
+
if (p.endsWith('node_modules/.bin') && existsSync(join(p, 'dsh'))) {
|
|
160
|
+
const lib = join(dirname(p), '@deepseek-ai', 'dsh-session', 'lib', 'index.js');
|
|
161
|
+
if (existsSync(lib)) { sessionLib = lib; break; }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!sessionLib) {
|
|
165
|
+
report('env', 'E6', true, '⚠ 未定位到 dsh-session,锚点未校验(回退内置假设:expandRow/end-seed/sourceEventSeqs)', '安装 dsh 后重跑可校验');
|
|
166
|
+
} else {
|
|
167
|
+
const src = readFileSync(sessionLib, 'utf8');
|
|
168
|
+
const anchors = [
|
|
169
|
+
['expandRow 的 seq0+k 展开(S6 依赖)', /function expandRow[\s\S]*?row\.seq0/, src],
|
|
170
|
+
['session/end-seed 字面量(S7 依赖)', /"session\/end-seed"/, src],
|
|
171
|
+
['sourceEventSeqs 字段(S10 依赖)', /sourceEventSeqs/, src],
|
|
172
|
+
];
|
|
173
|
+
const missing = anchors.filter(([, re]) => !re.test(src));
|
|
174
|
+
if (missing.length) {
|
|
175
|
+
report('env', 'E6', false, `锚点缺失(上游可能改了契约,S6/S7/S10 结论需人工复核): ${missing.map(([n]) => n).join('; ')}(${sessionLib.slice(-60)})`, '对照上游变更更新 dsh-doctor 的对应检查');
|
|
176
|
+
} else {
|
|
177
|
+
report('env', 'E6', true, `锚点齐全(${anchors.length}/3: seq0+k / session/end-seed / sourceEventSeqs)`, undefined);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ================= profile ================= */
|
|
183
|
+
function checkProfile(name) {
|
|
184
|
+
if (!wants('profile')) return;
|
|
185
|
+
let dir;
|
|
186
|
+
try { dir = resolveProfile(name); } catch (e) { report('profile', 'P0', false, e.message); return; }
|
|
187
|
+
const manifestPath = join(dir, 'package.json');
|
|
188
|
+
if (!existsSync(manifestPath)) { report('profile', 'P0', false, `profile 不存在: ${dir}`); return; }
|
|
189
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
190
|
+
const bundles = manifest.dsh?.profile?.bundles ?? [];
|
|
191
|
+
const deps = manifest.dependencies ?? {};
|
|
192
|
+
|
|
193
|
+
const installAnchor = (() => {
|
|
194
|
+
// 从 PATH 找 dsh 的安装目录(node_modules),用于 bundle 双锚点解析
|
|
195
|
+
for (const p of (process.env.PATH || '').split(PATH_DELIM)) {
|
|
196
|
+
if (p.endsWith('node_modules/.bin') && existsSync(join(p, 'dsh'))) return dirname(p);
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
})();
|
|
200
|
+
const findPkg = (pkgName) => {
|
|
201
|
+
const cands = [
|
|
202
|
+
installAnchor ? join(installAnchor, pkgName) : null,
|
|
203
|
+
join(dir, 'node_modules', pkgName),
|
|
204
|
+
].filter(Boolean);
|
|
205
|
+
return cands.find((c) => existsSync(join(c, 'package.json'))) ?? null;
|
|
206
|
+
};
|
|
207
|
+
const readInsertIds = (patchFile) => {
|
|
208
|
+
const ids = new Set();
|
|
209
|
+
if (!existsSync(patchFile)) return ids;
|
|
210
|
+
const lines = readFileSync(patchFile, 'utf8').split('\n');
|
|
211
|
+
for (let i = 0; i < lines.length; i++) {
|
|
212
|
+
const m = lines[i].match(/^(\s*)- insert:\s*$/);
|
|
213
|
+
if (!m) continue;
|
|
214
|
+
const base = m[1].length;
|
|
215
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
216
|
+
const l = lines[j];
|
|
217
|
+
if (l.trim() === '') continue;
|
|
218
|
+
const indent = (l.match(/^\s*/) || [''])[0].length;
|
|
219
|
+
if (indent <= base) break; // insert 块结束
|
|
220
|
+
const im = l.match(/^\s*-\s*id:\s*['"]?([^'"\s]+)/);
|
|
221
|
+
if (im) ids.add(im[1]);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return ids;
|
|
225
|
+
};
|
|
226
|
+
const patchPath = join(dir, 'cordis.patch.yml');
|
|
227
|
+
const userIds = readInsertIds(patchPath);
|
|
228
|
+
const userNames = (() => {
|
|
229
|
+
const out = new Set();
|
|
230
|
+
if (!existsSync(patchPath)) return out;
|
|
231
|
+
const text = readFileSync(patchPath, 'utf8');
|
|
232
|
+
for (const m of text.matchAll(/^\s*-\s*name:\s*['"]?([^'"\s]+)/gm)) out.add(m[1]);
|
|
233
|
+
return out;
|
|
234
|
+
})();
|
|
235
|
+
|
|
236
|
+
// P1 bundles 可解析性
|
|
237
|
+
for (const b of bundles) {
|
|
238
|
+
const dir2 = findPkg(b);
|
|
239
|
+
if (!dir2) {
|
|
240
|
+
report('profile', 'P1', false, `bundle 条目 ${b} 无法在安装目录或 profile node_modules 解析(#917/#1377/#880)`, `dsh plugin --profile ${name} add ${b} 或从 dsh.profile.bundles 移除`);
|
|
241
|
+
} else {
|
|
242
|
+
const pkg = JSON.parse(readFileSync(join(dir2, 'package.json'), 'utf8'));
|
|
243
|
+
if (!pkg.dsh?.bundle?.patch) {
|
|
244
|
+
report('profile', 'P1', false, `bundle 条目 ${b} 存在但未声明 dsh.bundle(#1377 静默禁用类)`, '检查该包版本或移除条目');
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
// P2 id 冲突
|
|
249
|
+
const bundleIds = new Set();
|
|
250
|
+
for (const b of bundles) {
|
|
251
|
+
const dir2 = findPkg(b);
|
|
252
|
+
if (!dir2) continue;
|
|
253
|
+
const pkg = JSON.parse(readFileSync(join(dir2, 'package.json'), 'utf8'));
|
|
254
|
+
const rel = pkg.dsh?.bundle?.patch;
|
|
255
|
+
if (!rel) continue;
|
|
256
|
+
for (const id of readInsertIds(join(dir2, rel))) bundleIds.add(id);
|
|
257
|
+
}
|
|
258
|
+
const dup = [...bundleIds].filter((id) => userIds.has(id));
|
|
259
|
+
if (dup.length) {
|
|
260
|
+
report('profile', 'P2', false, `bundle 与用户 patch 的 id 冲突(启动必崩 duplicate loader entry id,#1404): ${dup.join(', ')}`, `备份后从 ${patchPath} 删除这些 insert(或运行 check-dsh-profile.mjs 查看详情)`);
|
|
261
|
+
} else {
|
|
262
|
+
report('profile', 'P2', true, '无 bundle/用户 patch id 冲突', undefined);
|
|
263
|
+
}
|
|
264
|
+
// P3 insert name 可解析性
|
|
265
|
+
const req = (() => { try { return createRequire(join(dir, '_anchor.js')); } catch { return null; } })();
|
|
266
|
+
const bad = [];
|
|
267
|
+
for (const n of userNames) {
|
|
268
|
+
if (n.startsWith('@local/') || n.startsWith('@liustack/')) {
|
|
269
|
+
const fp = deps[n];
|
|
270
|
+
if (fp && fp.startsWith('file:')) {
|
|
271
|
+
const target = join(dir, fp.slice(5));
|
|
272
|
+
if (!existsSync(target)) bad.push(`${n} (file: 目标不存在: ${fp})`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
let ok = false;
|
|
277
|
+
try { if (req) { req.resolve(n); ok = true; } } catch { ok = false; }
|
|
278
|
+
if (!ok) bad.push(n);
|
|
279
|
+
}
|
|
280
|
+
if (bad.length) report('profile', 'P3', false, `用户 patch 中不可解析的 name(#1197/#880): ${bad.join(', ')}`, `dsh plugin --profile ${name} add <包> 或修复 file: 依赖`);
|
|
281
|
+
else report('profile', 'P3', true, '用户 patch insert 均可解析', undefined);
|
|
282
|
+
// P4 file: 依赖悬空(file: 目标可能是相对(file:./plugins/x)或绝对(file:/abs/path))
|
|
283
|
+
const resolveFileSpec = (spec) => {
|
|
284
|
+
const target = spec.slice(5);
|
|
285
|
+
return /^[/\\]|^[A-Za-z]:/.test(target) ? target : join(dir, target);
|
|
286
|
+
};
|
|
287
|
+
const dangling = Object.entries(deps).filter(([, spec]) => spec.startsWith('file:')).filter(([, spec]) => !existsSync(resolveFileSpec(spec)));
|
|
288
|
+
if (dangling.length) report('profile', 'P4', false, `悬空 file: 依赖(#1197): ${dangling.map(([n, s]) => `${n} (${s})`).join(', ')}`, '恢复目录或移除依赖');
|
|
289
|
+
else report('profile', 'P4', true, 'file: 依赖完整', undefined);
|
|
290
|
+
// P5 顶层 @deepseek-ai/* 重复
|
|
291
|
+
const topDup = [];
|
|
292
|
+
const topDir = join(dir, 'node_modules', '@deepseek-ai');
|
|
293
|
+
if (existsSync(topDir)) {
|
|
294
|
+
for (const p of readdirSync(topDir)) {
|
|
295
|
+
const fp = join(topDir, p);
|
|
296
|
+
if (existsSync(join(fp, 'package.json'))) topDup.push(p);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (topDup.length) report('profile', 'P5', false, `profile 顶层存在 @deepseek-ai/* 重复(#1486 双实例风险): ${topDup.join(', ')}`, '清理 profile node_modules 中与框架版本相同的 @deepseek-ai 包(pnpm install 后会重建,需在 doctor 中提醒)');
|
|
300
|
+
else report('profile', 'P5', true, '无顶层 @deepseek-ai 重复', undefined);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* ================= session ================= */
|
|
304
|
+
function checkSession(targetPath) {
|
|
305
|
+
if (!wants('session')) return;
|
|
306
|
+
const target = targetPath || (() => {
|
|
307
|
+
let best = null, bestM = -1;
|
|
308
|
+
const root = join(HOME, 'sessions');
|
|
309
|
+
if (!existsSync(root)) return null;
|
|
310
|
+
for (const u of readdirSync(root)) {
|
|
311
|
+
const sd = join(root, u);
|
|
312
|
+
if (!existsSync(sd)) continue;
|
|
313
|
+
for (const s of readdirSync(sd)) {
|
|
314
|
+
const f = existsSync(join(sd, s, 'session.jsonl.zstd')) ? join(sd, s, 'session.jsonl.zstd') : join(sd, s, 'session.jsonl');
|
|
315
|
+
if (!existsSync(f)) continue;
|
|
316
|
+
const m = statSync(f).mtimeMs;
|
|
317
|
+
if (m > bestM) { bestM = m; best = f; }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return best;
|
|
321
|
+
})();
|
|
322
|
+
if (!target || !existsSync(target)) { report('session', 'S0', true, '无会话日志,跳过单会话检查(可用 --session <path> 指定)', undefined); return; }
|
|
323
|
+
let text;
|
|
324
|
+
try {
|
|
325
|
+
text = target.endsWith('.zstd') ? execFileSync('zstd', ['-dc', target], { maxBuffer: 512 * 1024 * 1024 }).toString('utf8') : readFileSync(target, 'utf8');
|
|
326
|
+
} catch (e) { report('session', 'S0', false, `解压失败: ${e.message.slice(0, 80)}`); return; }
|
|
327
|
+
|
|
328
|
+
// S9:zstd 容器结构(#1043:单帧容器会让 session.list 整体 500)
|
|
329
|
+
if (target.endsWith('.zstd')) {
|
|
330
|
+
try {
|
|
331
|
+
const raw = readFileSync(target);
|
|
332
|
+
const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
333
|
+
let frames = 0;
|
|
334
|
+
for (let i = 0; i <= raw.length - 4; i++) if (raw[i] === magic[0] && raw[i + 1] === magic[1] && raw[i + 2] === magic[2] && raw[i + 3] === magic[3]) frames++;
|
|
335
|
+
if (frames === 0) report('session', 'S9', false, '不是有效的 zstd 容器(无帧 magic)', '该日志无法被 harness 读取');
|
|
336
|
+
else if (frames === 1) report('session', 'S9', false, `单帧 zstd 容器(#1043:session.list 会整体 500,侧边栏全部消失): ${frames} 帧`, '用多帧容器重写(正常日志每写批一帧),或删除该会话');
|
|
337
|
+
else report('session', 'S9', true, `zstd 多帧容器正常(${frames} 帧)`, undefined);
|
|
338
|
+
} catch (e) { report('session', 'S9', false, `帧扫描失败: ${e.message.slice(0, 60)}`); }
|
|
339
|
+
} else {
|
|
340
|
+
report('session', 'S9', true, '非 zstd 输入,跳过容器检查', undefined);
|
|
341
|
+
}
|
|
342
|
+
const calls = new Map(); const results2 = new Set(); let maxSeq = -1;
|
|
343
|
+
const turnStarts = new Set(); const turnEnds = new Set();
|
|
344
|
+
const positions = []; const endSeedSeqs = [];
|
|
345
|
+
const expanded = []; const sesViolations = []; const s8Violations = []; let evIndex = 0;
|
|
346
|
+
for (const line of text.split('\n')) {
|
|
347
|
+
if (!line.trim()) continue;
|
|
348
|
+
let d; try { d = JSON.parse(line); } catch { continue; }
|
|
349
|
+
const seq = d.seq; if (typeof seq === 'number' && seq > maxSeq) maxSeq = seq;
|
|
350
|
+
// S8:未知事件类型且未标 ignorable(#1538:harness 整包拒绝)
|
|
351
|
+
if (!STORAGE_ROW_TYPES.has(d.type) && !KNOWN.has(d.type) && d.ignorable !== true) {
|
|
352
|
+
s8Violations.push(`"${d.type}"`);
|
|
353
|
+
}
|
|
354
|
+
// S6(官方版):按 decodeStorageRecord 语义展开 chunk 行,构建 seq==index 事件流
|
|
355
|
+
const t = d.type;
|
|
356
|
+
if (t === 'text-chunks' || t === 'reasoning-chunks' || t === 'tool-call-chunks') {
|
|
357
|
+
const members = (d.data ?? {})[t === 'tool-call-chunks' ? 'args' : 'texts'];
|
|
358
|
+
const base = typeof d.seq0 === 'number' ? d.seq0 : -1;
|
|
359
|
+
for (let k = 0; k < (members?.length ?? 0); k++) {
|
|
360
|
+
const eseq = base + k;
|
|
361
|
+
expanded.push(eseq);
|
|
362
|
+
if (eseq !== evIndex) sesViolations.push(`seq 空洞/重复 @${eseq}(期望 ${evIndex})`);
|
|
363
|
+
evIndex++;
|
|
364
|
+
}
|
|
365
|
+
} else if (typeof seq === 'number') {
|
|
366
|
+
expanded.push(seq);
|
|
367
|
+
if (seq !== evIndex) sesViolations.push(`seq 空洞/重复 @${seq}(期望 ${evIndex})`);
|
|
368
|
+
evIndex++;
|
|
369
|
+
}
|
|
370
|
+
// S10:sourceEventSeqs 悬空引用(#1469:必须引用早于自身的事件)
|
|
371
|
+
if (typeof seq === 'number' && Array.isArray(d.sourceEventSeqs)) {
|
|
372
|
+
for (const ref of d.sourceEventSeqs) {
|
|
373
|
+
if (typeof ref === 'number' && ref >= seq) sesViolations.push(`sourceEventSeqs 引用 ${ref} >= 当前 seq ${seq}(${t})`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// S6/S7:收集所有数值位置(seq 或 chunk 的 seq0),按文件序做单调/重复检测
|
|
377
|
+
const pos = typeof seq === 'number' ? seq : (typeof d.seq0 === 'number' ? d.seq0 : null);
|
|
378
|
+
if (pos !== null) positions.push({ pos, type: d.type, seq: seq ?? null });
|
|
379
|
+
if (d.type === 'session/end-seed' && typeof seq === 'number') endSeedSeqs.push(seq);
|
|
380
|
+
if (typeof d.turn === 'number') { if (d.type === 'turn/start') turnStarts.add(d.turn); if (d.type === 'turn/end') turnEnds.add(d.turn); }
|
|
381
|
+
const msg = d.data?.message;
|
|
382
|
+
if (!msg || !Array.isArray(msg.content)) continue;
|
|
383
|
+
for (const blk of msg.content) {
|
|
384
|
+
if (!blk || typeof blk !== 'object') continue;
|
|
385
|
+
if (blk.type === 'tool-call' && typeof blk.id === 'string') calls.set(blk.id, { seq: d.seq, name: blk.name });
|
|
386
|
+
else if (blk.type === 'tool-result' && typeof blk.toolCallId === 'string') results2.add(blk.toolCallId);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const orphans = [...calls].filter(([id]) => !results2.has(id)).map(([id, v]) => ({ id, ...v }));
|
|
390
|
+
const real = orphans.filter((o) => typeof o.seq === 'number' && o.seq < maxSeq - 1);
|
|
391
|
+
const inflight = orphans.filter((o) => !real.includes(o));
|
|
392
|
+
if (real.length) report('session', 'S1', false, `孤儿 tool_call(#1363,会 INVALID_REQUEST): ${real.map((o) => o.id).join(', ')}`, '该会话历史不完整,建议新建会话');
|
|
393
|
+
else report('session', 'S1', true, inflight.length ? `无真孤儿(仅尾部 in-flight: ${inflight.length} 个)` : '无孤儿 tool_call', undefined);
|
|
394
|
+
const unclosed = [...turnStarts].filter((t) => !turnEnds.has(t));
|
|
395
|
+
const realUnclosed = unclosed.filter((t) => t < Math.max(...turnStarts));
|
|
396
|
+
const tailUnclosed = unclosed.filter((t) => !realUnclosed.includes(t));
|
|
397
|
+
if (realUnclosed.length) report('session', 'S2', false, `未闭合 turn(#466/#1265,会话可能卡"运行中"): ${realUnclosed.join(', ')}`, '重启 host 或删除该会话的残留状态');
|
|
398
|
+
else report('session', 'S2', true, tailUnclosed.length ? `无历史未闭合 turn(尾部当前 turn 正常: ${tailUnclosed.join(', ')})` : '所有 turn 均已闭合', undefined);
|
|
399
|
+
|
|
400
|
+
// S6(官方版):seq == index 连续性(#1333/#1452 重复段 + #1469 seq 空洞),chunk 行按 expandRow 展开
|
|
401
|
+
const s6Violations = sesViolations.filter((v) => !v.startsWith('sourceEventSeqs'));
|
|
402
|
+
if (s6Violations.length) {
|
|
403
|
+
report('session', 'S6', false, `seq 不连续/空洞/重复(#1333/#1452/#1469): ${s6Violations.slice(0, 5).join('; ')}${s6Violations.length > 5 ? ` 等 ${s6Violations.length} 处` : ''}`, '会话事件序列损坏(可能被强制压缩/并发写坏),建议用端种子恢复或新建会话');
|
|
404
|
+
} else {
|
|
405
|
+
report('session', 'S6', true, `seq==index 连续(展开 ${expanded.length} 个事件,max seq ${maxSeq})`, undefined);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// S10:sourceEventSeqs 悬空引用(#1469:压缩未重映射溯源 → 历史永久无法加载)
|
|
409
|
+
const s10 = sesViolations.filter((v) => v.startsWith('sourceEventSeqs'));
|
|
410
|
+
if (s10.length) {
|
|
411
|
+
report('session', 'S10', false, `sourceEventSeqs 悬空引用(#1469,history unavailable): ${s10.slice(0, 5).join('; ')}${s10.length > 5 ? ` 等 ${s10.length} 处` : ''}`, '压缩写入路径未重映射溯源引用,需修复日志或回滚压缩');
|
|
412
|
+
} else {
|
|
413
|
+
report('session', 'S10', true, 'sourceEventSeqs 均引用早于自身的事件', undefined);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// S8:未知事件类型(#1538:不在 KNOWN_SESSION_EVENT_TYPES 且无 ignorable → 整包拒绝)
|
|
417
|
+
if (s8Violations.length) {
|
|
418
|
+
const seen = [...new Set(s8Violations)].slice(0, 5).join(', ');
|
|
419
|
+
report('session', 'S8', false, `未知事件类型且无 ignorable 标记(#1538,harness 将整包拒绝): ${seen}${new Set(s8Violations).size > 5 ? ` 等 ${new Set(s8Violations).size} 种` : ''}`, '该日志由更新版本/外部插件写入,当前 harness 无法读取;升级 harness 或标记 ignorable');
|
|
420
|
+
} else {
|
|
421
|
+
report('session', 'S8', true, `所有事件类型均在 KNOWN_SESSION_EVENT_TYPES 内(${KNOWN.size} 种)`, undefined);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// S7:end-seed 之后出现低于种子末尾 seq 的事件(#1497:已提交尾部被重放)
|
|
425
|
+
if (endSeedSeqs.length) {
|
|
426
|
+
const lastSeed = endSeedSeqs[endSeedSeqs.length - 1];
|
|
427
|
+
// 只查文件序在最后一个 end-seed 之后的记录
|
|
428
|
+
const lastSeedIdx = positions.map((p) => p.pos).lastIndexOf(lastSeed);
|
|
429
|
+
const after = positions.slice(lastSeedIdx + 1);
|
|
430
|
+
const replayed = after.filter((p) => p.pos < lastSeed);
|
|
431
|
+
if (replayed.length) {
|
|
432
|
+
const sample = replayed.slice(0, 5).map((p) => `${p.type}@${p.pos}`).join(', ');
|
|
433
|
+
report('session', 'S7', false, `end-seed 后重放已提交尾部(#1497): 种子末尾 seq=${lastSeed},其后出现 ${replayed.length} 条更低 seq(${sample}...)`, '单进程异常退出重放,需丢弃 end-seed 后的重放段');
|
|
434
|
+
} else {
|
|
435
|
+
report('session', 'S7', true, `end-seed(末次 seq=${lastSeed})之后无重放(其后 ${after.length} 条记录 seq 均更高)`, undefined);
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
report('session', 'S7', true, '日志中无 session/end-seed(未做尾部重放检查)', undefined);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* S11:全会话扫描 —— 损坏 → 隔离建议;超大 → 冷打开物化风险(#1550:一个坏/超大会话拖垮整个服务器) */
|
|
443
|
+
function scanAllSessions() {
|
|
444
|
+
if (!wants('session')) return;
|
|
445
|
+
const root = join(HOME, 'sessions');
|
|
446
|
+
if (!existsSync(root)) { report('session', 'S11', true, '无会话目录,跳过全会话扫描', undefined); return; }
|
|
447
|
+
const files = [];
|
|
448
|
+
for (const u of readdirSync(root)) {
|
|
449
|
+
const sd = join(root, u);
|
|
450
|
+
if (!existsSync(sd)) continue;
|
|
451
|
+
for (const s of readdirSync(sd)) {
|
|
452
|
+
const f = existsSync(join(sd, s, 'session.jsonl.zstd')) ? join(sd, s, 'session.jsonl.zstd') : join(sd, s, 'session.jsonl');
|
|
453
|
+
if (existsSync(f)) files.push(f);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (files.length === 0) { report('session', 'S11', true, '未发现会话日志', undefined); return; }
|
|
457
|
+
const corrupt = []; const oversized = []; const clean = [];
|
|
458
|
+
let totalDS = 0; let totalEvents = 0;
|
|
459
|
+
for (const f of files) {
|
|
460
|
+
const cs = statSync(f).size;
|
|
461
|
+
let raw, frames = 0;
|
|
462
|
+
try {
|
|
463
|
+
raw = readFileSync(f);
|
|
464
|
+
const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
465
|
+
for (let i = 0; i <= raw.length - 4; i++) if (raw[i] === magic[0] && raw[i + 1] === magic[1] && raw[i + 2] === magic[2] && raw[i + 3] === magic[3]) frames++;
|
|
466
|
+
} catch { corrupt.push({ id: basename(dirname(f)), problems: ['读取失败'] }); continue; }
|
|
467
|
+
let text;
|
|
468
|
+
try { text = f.endsWith('.zstd') ? execFileSync('zstd', ['-dc', f], { maxBuffer: 512 * 1024 * 1024 }).toString('utf8') : readFileSync(f, 'utf8'); }
|
|
469
|
+
catch { corrupt.push({ id: basename(dirname(f)), problems: ['解压/读取失败'] }); continue; }
|
|
470
|
+
const ds = Buffer.byteLength(text, 'utf8');
|
|
471
|
+
totalDS += ds;
|
|
472
|
+
// 轻量损坏扫描:seq==index + end-seed 重放 + 未知类型
|
|
473
|
+
const problems = [];
|
|
474
|
+
let evIndex = 0, lastSeed = -1, seedIdx = -1, posList = [];
|
|
475
|
+
const lines = text.split('\n');
|
|
476
|
+
for (let li = 0; li < lines.length; li++) {
|
|
477
|
+
const ln = lines[li]; if (!ln.trim()) continue;
|
|
478
|
+
let d; try { d = JSON.parse(ln); } catch { problems.push(`行 ${li + 1} 无法解析`); continue; }
|
|
479
|
+
if (!STORAGE_ROW_TYPES.has(d.type) && !KNOWN.has(d.type) && d.ignorable !== true) problems.push(`未知类型 ${d.type}`);
|
|
480
|
+
if (d.type === 'session/end-seed' && typeof d.seq === 'number') { lastSeed = d.seq; seedIdx = posList.length; }
|
|
481
|
+
const t = d.type;
|
|
482
|
+
if (t === 'text-chunks' || t === 'reasoning-chunks' || t === 'tool-call-chunks') {
|
|
483
|
+
const members = (d.data ?? {})[t === 'tool-call-chunks' ? 'args' : 'texts'];
|
|
484
|
+
const base = typeof d.seq0 === 'number' ? d.seq0 : -1;
|
|
485
|
+
for (let k = 0; k < (members?.length ?? 0); k++) {
|
|
486
|
+
const eseq = base + k;
|
|
487
|
+
if (eseq !== evIndex) problems.push(`seq 空洞 @${eseq}(期望 ${evIndex})`);
|
|
488
|
+
posList.push(eseq); evIndex++;
|
|
489
|
+
}
|
|
490
|
+
} else if (typeof d.seq === 'number') {
|
|
491
|
+
if (d.seq !== evIndex) problems.push(`seq 空洞 @${d.seq}(期望 ${evIndex})`);
|
|
492
|
+
posList.push(d.seq); evIndex++;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (lastSeed >= 0) {
|
|
496
|
+
const after = posList.slice(seedIdx + 1);
|
|
497
|
+
if (after.some((p) => p < lastSeed)) problems.push('end-seed 后重放已提交尾部');
|
|
498
|
+
}
|
|
499
|
+
const id = basename(dirname(f));
|
|
500
|
+
totalEvents += evIndex;
|
|
501
|
+
const entry = { id, csMB: (cs / 1048576).toFixed(1), dsMB: (ds / 1048576).toFixed(1), frames, events: evIndex, problems };
|
|
502
|
+
if (problems.length) corrupt.push(entry);
|
|
503
|
+
else if (ds > 10 * 1048576 || frames > 10000) oversized.push(entry);
|
|
504
|
+
else clean.push(entry);
|
|
505
|
+
}
|
|
506
|
+
const quars = corrupt.map((c) => `${c.id}(${c.problems.slice(0, 3).join('; ')})`);
|
|
507
|
+
const totalMB = Math.round(totalDS / 1048576);
|
|
508
|
+
// 校准后的物化风险:估算堆 = 解码字节×6(字节主导放大)+ 事件数×200B(小事件堆成本)
|
|
509
|
+
// 依据:#1550 7889545 场景 300-600MB 解码 → ~3GB 堆(5-10x);警告线 1GB 提前留余量
|
|
510
|
+
// 校准公式(实测 2026-08-14 本机 41.9 万小事件会话:对象图 259B/事件,×克隆2-3 → ~600B;大事件 5-10x 字节)
|
|
511
|
+
const estHeapMB = Math.round(Math.max(totalEvents * 600, totalDS * 6) / 1048576);
|
|
512
|
+
const heapLimit = Number(process.env.DSH_DOCTOR_HEAP_MB || 1024);
|
|
513
|
+
const totalRisk = estHeapMB > heapLimit;
|
|
514
|
+
if (quars.length) {
|
|
515
|
+
report('session', 'S11', false, `全会话扫描:${corrupt.length} 个损坏会话(#1550:冷打开会拖垮服务器): ${quars.join(' | ')}`, `隔离:把这些会话目录移出 ${join(HOME, 'sessions')}(如 mv 到备份目录)`);
|
|
516
|
+
} else if (oversized.length || totalRisk) {
|
|
517
|
+
const parts = [];
|
|
518
|
+
if (oversized.length) parts.push(`${oversized.length} 个超大会话: ${oversized.map((o) => `${o.id}(${o.dsMB}MB/${o.events}事件)`).join(' | ')}`);
|
|
519
|
+
if (totalRisk) parts.push(`工作区估算物化堆 ~${estHeapMB}MB(估算= max(${totalEvents}事件×600B, ${totalMB}MB×6),跨 ${files.length} 会话累积,#1550 场景;阈值 ${heapLimit}MB,可设 DSH_DOCTOR_HEAP_MB)`);
|
|
520
|
+
report('session', 'S11', true, `⚠ 全会话扫描:${parts.join(';')}(未损坏,可接受或归档)`, '冷启动会明显变慢;必要时压缩/归档历史会话');
|
|
521
|
+
} else {
|
|
522
|
+
report('session', 'S11', true, `全会话扫描:${clean.length} 个会话均健康(损坏 0 / 超大 0 / 估算物化堆 ${estHeapMB}MB)`, undefined);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/* ================= main ================= */
|
|
527
|
+
const profileArg = (() => { const i = process.argv.indexOf('--profile'); return i >= 0 ? process.argv[i + 1] : 'web'; })();
|
|
528
|
+
const sessionArg = (() => { const i = process.argv.indexOf('--session'); return i >= 0 ? process.argv[i + 1] : undefined; })();
|
|
529
|
+
|
|
530
|
+
try { checkEnv(); } catch (e) { report('env', 'E0', false, `env 检查异常: ${e.message.slice(0, 80)}`); }
|
|
531
|
+
try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
|
|
532
|
+
try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
|
|
533
|
+
try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
|
|
534
|
+
|
|
535
|
+
const bad = results.filter((r) => !r.ok);
|
|
536
|
+
if (jsonOut) {
|
|
537
|
+
console.log(JSON.stringify({ ok: bad.length === 0, checks: results }, null, 2));
|
|
538
|
+
} else {
|
|
539
|
+
let lastSection = '';
|
|
540
|
+
for (const r of results) {
|
|
541
|
+
if (r.section !== lastSection) { console.log(`\n== ${r.section.toUpperCase()} ==`); lastSection = r.section; }
|
|
542
|
+
const mark = r.ok ? '✓' : '✗';
|
|
543
|
+
console.log(` ${mark} [${r.id}] ${r.detail}`);
|
|
544
|
+
if (!r.ok && r.fix) console.log(` ↳ 修复: ${r.fix}`);
|
|
545
|
+
}
|
|
546
|
+
console.log(`\n${bad.length === 0 ? '✓ 全部通过' : `✗ ${bad.length} 个问题`}(profile=${profileArg})`);
|
|
547
|
+
}
|
|
548
|
+
process.exit(bad.length === 0 ? 0 : 1);
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-doctor host entry: mounts the diagnostic HTTP route once the profile
|
|
3
|
+
* composes the webServer service.
|
|
4
|
+
*
|
|
5
|
+
* The checks themselves are intentionally offline/filesystem-based (the whole
|
|
6
|
+
* point: they run when dsh can or can't boot), so the route shells out to the
|
|
7
|
+
* bundled `dsh-doctor.mjs --json` — same single source of truth as the CLI.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
export const name = 'dsh-doctor';
|
|
13
|
+
|
|
14
|
+
const SCRIPT = fileURLToPath(new URL('../dsh-doctor.mjs', import.meta.url));
|
|
15
|
+
const RUN_TIMEOUT_MS = 120000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register the diagnostic route against the host context.
|
|
19
|
+
* @param ctx - Host context that may acquire the webServer service.
|
|
20
|
+
* @param config - Optional profile override from the loader.
|
|
21
|
+
*/
|
|
22
|
+
export function apply(ctx, config) {
|
|
23
|
+
const resolved = { profile: config?.profile ?? 'web' };
|
|
24
|
+
ctx.inject(['webServer'], (hostCtx) => {
|
|
25
|
+
const host = hostCtx;
|
|
26
|
+
host.effect(() => mountDoctorRoutes(host, resolved), 'dsh-doctor: http routes');
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run the bundled CLI and return its JSON result.
|
|
32
|
+
* @param profile - profile name to check.
|
|
33
|
+
* @param sessionPath - optional session log path.
|
|
34
|
+
* @returns parsed { checks, ok } or a structured error.
|
|
35
|
+
*/
|
|
36
|
+
function runChecks(profile, sessionPath) {
|
|
37
|
+
return new Promise((resolvePromise) => {
|
|
38
|
+
const args = ['--json'];
|
|
39
|
+
if (profile) args.push('--profile', profile);
|
|
40
|
+
if (sessionPath) args.push('--session', sessionPath);
|
|
41
|
+
const child = spawn(process.execPath, [SCRIPT, ...args], {
|
|
42
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
43
|
+
env: { ...process.env, CI: 'true' },
|
|
44
|
+
});
|
|
45
|
+
let stdout = '';
|
|
46
|
+
let stderr = '';
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
child.kill();
|
|
49
|
+
resolvePromise({ checks: [], ok: false, error: `dsh-doctor timed out after ${RUN_TIMEOUT_MS / 1000}s` });
|
|
50
|
+
}, RUN_TIMEOUT_MS);
|
|
51
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
52
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
53
|
+
child.on('error', (error) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
resolvePromise({ checks: [], ok: false, error: `failed to spawn dsh-doctor: ${error.message}` });
|
|
56
|
+
});
|
|
57
|
+
child.on('close', (code) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
try {
|
|
60
|
+
const data = JSON.parse(stdout);
|
|
61
|
+
resolvePromise(data);
|
|
62
|
+
} catch {
|
|
63
|
+
resolvePromise({ checks: [], ok: false, error: `dsh-doctor output unparsable (exit ${code}): ${stderr.slice(0, 300) || stdout.slice(0, 300)}` });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Register the diagnostic HTTP routes. */
|
|
70
|
+
function mountDoctorRoutes(host, config) {
|
|
71
|
+
const disposers = [
|
|
72
|
+
host.webServer.register({
|
|
73
|
+
kind: 'exact',
|
|
74
|
+
path: '/dsh-doctor/run',
|
|
75
|
+
handler: async (request, response) => {
|
|
76
|
+
if (request.method !== 'GET') {
|
|
77
|
+
response.writeHead(405, { allow: 'GET' });
|
|
78
|
+
response.end();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const url = new URL(request.url ?? '/dsh-doctor/run', 'http://localhost');
|
|
82
|
+
// 默认跑全部检查;只有显式传 ?profile= 或 ?session= 时才收窄范围
|
|
83
|
+
const profile = url.searchParams.get('profile');
|
|
84
|
+
const sessionPath = url.searchParams.get('session');
|
|
85
|
+
const result = await runChecks(profile, sessionPath);
|
|
86
|
+
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
87
|
+
response.end(JSON.stringify({ ...result, profile, checkedAt: new Date().toISOString() }));
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
];
|
|
91
|
+
return () => {
|
|
92
|
+
for (const dispose of disposers) dispose();
|
|
93
|
+
};
|
|
94
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moonquake2004/dsh-doctor",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Offline diagnostic for DeepSeek Harness — 19 checks across env/profile/session, plus a 'Doctor' panel in the web UI settings.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"lib",
|
|
8
|
+
"client",
|
|
9
|
+
"dsh-doctor.mjs",
|
|
10
|
+
"cordis.patch.yml",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
15
|
+
},
|
|
16
|
+
"dsh": {
|
|
17
|
+
"bundle": {
|
|
18
|
+
"patch": "./cordis.patch.yml"
|
|
19
|
+
},
|
|
20
|
+
"client": {
|
|
21
|
+
"inject": [
|
|
22
|
+
"@deepseek-ai/dsh-client-connection",
|
|
23
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
24
|
+
"@deepseek-ai/dsh-client-locale",
|
|
25
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
26
|
+
],
|
|
27
|
+
"platform": "web"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"default": "./lib/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./client": "./client/client.js",
|
|
35
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"author": "moonquake2004 <moonquake2004@users.noreply.github.com>",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/moonquake2004/dsh-doctor.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/moonquake2004/dsh-doctor"
|
|
45
|
+
}
|