@moonquake2004/dsh-doctor 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/README.zh.md +7 -3
- package/dsh-doctor.mjs +485 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-doctor
|
|
2
2
|
|
|
3
|
+
**Verified against DSH `0.1.5-rc.1`** (session format v3, `dsh-session` located across npx / global / profile layouts; legacy v0 events read through the installed `dsh-session-format-*` migration chain).
|
|
4
|
+
|
|
3
5
|
> [中文版 README](README.zh.md) · English
|
|
4
6
|
|
|
5
7
|
Offline diagnostic for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — run it **before** boot or before installing plugins, and it tells you which of the failure classes this community has been reporting will bite.
|
|
@@ -24,7 +26,7 @@ node dsh-doctor.mjs --json --envelope # v1 doctor-contract envelope (lowercas
|
|
|
24
26
|
|
|
25
27
|
Exit codes (default mode): `0` = all pass · `1` = problems found (built-in checks + catalog `severity: error`) · warn-level catalog failures don't flip the exit code.
|
|
26
28
|
|
|
27
|
-
With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2` = any FAIL. The envelope follows the shared `dsh-doctor/v1` schema (`{ schema, generatedAt, profile, exitCode, summary, ok, checks:[{name,status,detail}] }`) so implementations are interchangeable for CI/marketplace use. Installed via npm, the CLI is also available as the `dsh-doctor` bin.
|
|
29
|
+
With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2` = any FAIL. The envelope follows the shared `dsh-doctor/v1` schema (`{ schema, generatedAt, profile, exitCode, summary, ok, checks:[{name,status,detail}] }`) so implementations are interchangeable for CI/marketplace use. Installed via npm, the CLI is also available as the `dsh-doctor` bin. `--remediation` (with `--json --envelope`) adds the opt-in v1.1 `remediation` array — ordered `[check-name] fix` lines for failing checks, absent otherwise so r5 consumers stay byte-stable.
|
|
28
30
|
|
|
29
31
|
## Checks (28 built-in + 5 catalog = 33)
|
|
30
32
|
|
|
@@ -36,7 +38,7 @@ With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2
|
|
|
36
38
|
| E3 | node version / `--expose-internals` reachability | [#113](https://github.com/deepseek-ai/deepseek-harness/discussions/113), [#1313](https://github.com/deepseek-ai/deepseek-harness/discussions/1313) |
|
|
37
39
|
| E4 | node-pty native binary present (`prebuilds/<platform>-<arch>/pty.node`) | [#1219](https://github.com/deepseek-ai/deepseek-harness/discussions/1219) |
|
|
38
40
|
| E5 | storage JSON files valid (strict UTF-8 + parse) | [#1357](https://github.com/deepseek-ai/deepseek-harness/discussions/1357) |
|
|
39
|
-
| E6 | anchor tripwire:
|
|
41
|
+
| E6 | anchor tripwire: the S6/S7/S10 contracts still exist wherever the current release keeps them (npx / global / profile layouts; `dsh-session` or the `dsh-session-format-*` migration chain) | [anti-rot idea](https://github.com/deepseek-ai/deepseek-harness/discussions/1534) |
|
|
40
42
|
| E10 | web port 3080 availability before launch (dsh web itself = OK; other process = FAIL; `DSH_DOCTOR_PORT` override) | [#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719) |
|
|
41
43
|
|
|
42
44
|
### profile
|
|
@@ -50,6 +52,7 @@ With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2
|
|
|
50
52
|
| P12 | profile-installed bundle version vs running CLI (emits vocabulary name `installed_bundle`, #1719 v1.1: skip when unlisted / warn on manifest-lies or divergence / pass when equal; the web "Doctor" panel / `/dsh-doctor/run` API run the bundle) | [#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719) |
|
|
51
53
|
| P13 | client-half `provide` service name clashes with core client services (`chatFileMentions` etc. from `@deepseek-ai/dsh-client-*`, warn) or cross-bundle same-name grabs (browser-side "service already registered" → UI white screen, server logs see nothing) | [#2752](https://github.com/deepseek-ai/deepseek-harness/discussions/2752) |
|
|
52
54
|
| P14 | declared `bin` executability (target file present + shebang required for text `bin`; exec-bit alone does not identify the interpreter → ENOEXEC on direct run, #1846) | [#1846](https://github.com/deepseek-ai/deepseek-harness/discussions/1846) |
|
|
55
|
+
| P16 | plugin imports a named export the installed package does not provide (warn; boot fails hard on one bad entry) | [#5864](https://github.com/deepseek-ai/deepseek-harness/discussions/5864) |
|
|
53
56
|
|
|
54
57
|
### session
|
|
55
58
|
| ID | Checks | Discussion |
|
|
@@ -58,10 +61,11 @@ With `--envelope` (doctor-contract mode): `0` = all pass · `1` = any WARN · `2
|
|
|
58
61
|
| S2 | unclosed turns (session stuck "running") | [#466](https://github.com/deepseek-ai/deepseek-harness/discussions/466), [#1265](https://github.com/deepseek-ai/deepseek-harness/discussions/1265) |
|
|
59
62
|
| S6 | `seq == index` contiguity (official semantics, chunk rows expanded like `expandRow`) | [#1333](https://github.com/deepseek-ai/deepseek-harness/discussions/1333), [#1452](https://github.com/deepseek-ai/deepseek-harness/discussions/1452), [#1469](https://github.com/deepseek-ai/deepseek-harness/discussions/1469) |
|
|
60
63
|
| S7 | post-`end-seed` replay (replayed committed tail) | [#1497](https://github.com/deepseek-ai/deepseek-harness/discussions/1497) |
|
|
61
|
-
| S8 | unknown event types without `ignorable` (wholesale refusal) | [#1538](https://github.com/deepseek-ai/deepseek-harness/discussions/1538) |
|
|
64
|
+
| S8 | unknown event types without `ignorable` (wholesale refusal) — readable set = current table ∪ legacy types recognized by the installed `dsh-session-format-*` migrators | [#1538](https://github.com/deepseek-ai/deepseek-harness/discussions/1538) |
|
|
62
65
|
| S9 | zstd container frame count (single-frame logs → `session.list` 500) | [#1043](https://github.com/deepseek-ai/deepseek-harness/discussions/1043) |
|
|
63
66
|
| S10 | `sourceEventSeqs` referencing non-earlier events | [#1469](https://github.com/deepseek-ai/deepseek-harness/discussions/1469) |
|
|
64
67
|
| S11 | whole-session scan: corrupt → quarantine suggestion; oversized / workspace estimated-heap (max(events×600B, bytes×6), default 1GiB, `DSH_DOCTOR_HEAP_MB`) → cold-start stall risk | [#1550](https://github.com/deepseek-ai/deepseek-harness/discussions/1550) |
|
|
68
|
+
| S12 | migration-refusal pre-flight: session logs the installed `dsh-session-format-*` chain will refuse (v0 `subagent/descriptor` version gate; v2→v3 source-kind whitelist) — sessions that look present but cannot be opened | [#6045](https://github.com/deepseek-ai/deepseek-harness/discussions/6045), [#6328](https://github.com/deepseek-ai/deepseek-harness/discussions/6328), [#6311](https://github.com/deepseek-ai/deepseek-harness/discussions/6311) |
|
|
65
69
|
|
|
66
70
|
## Notes
|
|
67
71
|
|
package/README.zh.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-doctor
|
|
2
2
|
|
|
3
|
+
**已针对 DSH `0.1.5-rc.1` 验证**(会话格式 v3;`dsh-session` 定位覆盖 npx / 全局 / profile 三种布局;v0 旧事件按已装 `dsh-session-format-*` 迁移链读取)。
|
|
4
|
+
|
|
3
5
|
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的离线诊断工具——在**启动前**或**安装插件前**跑一次,它会告诉你社区反复报告的哪几类故障会在你机器上咬人。
|
|
4
6
|
|
|
5
7
|
零 npm 依赖。单文件。任何有 `node` 的环境都能跑(`zstd` 只在扫 `.zstd` 会话日志时需要,E1 会检查它)。
|
|
@@ -31,7 +33,7 @@ node dsh-doctor.mjs --no-catalog # 不拉远程目录(只用内置副
|
|
|
31
33
|
| E3 | node 版本 / `--expose-internals` 可及性 | [#113](https://github.com/deepseek-ai/deepseek-harness/discussions/113), [#1313](https://github.com/deepseek-ai/deepseek-harness/discussions/1313) |
|
|
32
34
|
| E4 | node-pty 原生二进制在位(`prebuilds/<platform>-<arch>/pty.node`) | [#1219](https://github.com/deepseek-ai/deepseek-harness/discussions/1219) |
|
|
33
35
|
| E5 | 存储 JSON 文件合法(严格 UTF-8 + 可解析) | [#1357](https://github.com/deepseek-ai/deepseek-harness/discussions/1357) |
|
|
34
|
-
| E6 | 锚点 tripwire:S6/S7/S10
|
|
36
|
+
| E6 | 锚点 tripwire:S6/S7/S10 依赖的契约仍在**当前版本存放它的位置**(npx / 全局 / profile 三布局;`dsh-session` 或 `dsh-session-format-*` 迁移链) | [anti-rot idea](https://github.com/deepseek-ai/deepseek-harness/discussions/1534) |
|
|
35
37
|
| E10 | 启动前 Web 端口 3080 可用性(dsh web 自身占用=正常;其他进程=FAIL;`DSH_DOCTOR_PORT` 可覆盖) | [#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719) |
|
|
36
38
|
|
|
37
39
|
### profile
|
|
@@ -45,6 +47,7 @@ node dsh-doctor.mjs --no-catalog # 不拉远程目录(只用内置副
|
|
|
45
47
|
| P12 | profile 内 bundle 版本 vs 运行 CLI(发词汇名 `installed_bundle`,#1719 v1.1:未声明=skip / manifest 撒谎或分歧=warn / 一致=pass;web「诊断」面板 / `/dsh-doctor/run` API 跑的是 bundle) | [#1719](https://github.com/deepseek-ai/deepseek-harness/discussions/1719) |
|
|
46
48
|
| P13 | client 半 `provide` 服务名抢注核心客户端服务(`chatFileMentions` 等 `@deepseek-ai/dsh-client-*`,warn)或跨 bundle 同名(浏览器端 service already registered → UI 白屏、服务端日志无感知) | [#2752](https://github.com/deepseek-ai/deepseek-harness/discussions/2752) |
|
|
47
49
|
| P14 | 声明 `bin` 可执行性(目标文件在位 + 文本 bin 必须带 shebang;仅可执行位不识别解释器 → 直接执行 ENOEXEC,#1846) | [#1846](https://github.com/deepseek-ai/deepseek-harness/discussions/1846) |
|
|
50
|
+
| P16 | 插件导入了已装包未提供的命名导出(warn;单条坏 entry 即 boot 硬失败) | [#5864](https://github.com/deepseek-ai/deepseek-harness/discussions/5864) |
|
|
48
51
|
|
|
49
52
|
### session
|
|
50
53
|
| ID | 检查 | 对应讨论 |
|
|
@@ -53,10 +56,11 @@ node dsh-doctor.mjs --no-catalog # 不拉远程目录(只用内置副
|
|
|
53
56
|
| S2 | 未闭合 turn(会话卡"运行中") | [#466](https://github.com/deepseek-ai/deepseek-harness/discussions/466), [#1265](https://github.com/deepseek-ai/deepseek-harness/discussions/1265) |
|
|
54
57
|
| S6 | `seq == index` 连续性(官方语义,chunk 行按 `expandRow` 展开) | [#1333](https://github.com/deepseek-ai/deepseek-harness/discussions/1333), [#1452](https://github.com/deepseek-ai/deepseek-harness/discussions/1452), [#1469](https://github.com/deepseek-ai/deepseek-harness/discussions/1469) |
|
|
55
58
|
| S7 | `end-seed` 后重放(重放已提交尾部) | [#1497](https://github.com/deepseek-ai/deepseek-harness/discussions/1497) |
|
|
56
|
-
| S8 | 未知事件类型且无 `ignorable
|
|
59
|
+
| S8 | 未知事件类型且无 `ignorable`(整包拒绝)——可读集 = 当前表 ∪ 已装 `dsh-session-format-*` 迁移包认的旧类型 | [#1538](https://github.com/deepseek-ai/deepseek-harness/discussions/1538) |
|
|
57
60
|
| S9 | zstd 容器帧数(单帧日志 → `session.list` 整体 500) | [#1043](https://github.com/deepseek-ai/deepseek-harness/discussions/1043) |
|
|
58
61
|
| S10 | `sourceEventSeqs` 引用非更早事件 | [#1469](https://github.com/deepseek-ai/deepseek-harness/discussions/1469) |
|
|
59
62
|
| S11 | 全会话扫描:损坏 → 隔离建议;超大 / 工作区估算物化堆(max(事件×600B, 字节×6),默认 1GiB,`DSH_DOCTOR_HEAP_MB`)→ 冷启动卡顿风险 | [#1550](https://github.com/deepseek-ai/deepseek-harness/discussions/1550) |
|
|
63
|
+
| S12 | 迁移拒载预检:会被已装 `dsh-session-format-*` 迁移链拒绝的会话日志(v0 `subagent/descriptor` 版本门;v2→v3 source.kind 白名单)——列表里看着在、点开却打不开 | [#6045](https://github.com/deepseek-ai/deepseek-harness/discussions/6045), [#6328](https://github.com/deepseek-ai/deepseek-harness/discussions/6328), [#6311](https://github.com/deepseek-ai/deepseek-harness/discussions/6311) |
|
|
60
64
|
|
|
61
65
|
## 备注
|
|
62
66
|
|
|
@@ -95,7 +99,7 @@ If you're coming from a symptom (rather than from the machine), these are the ch
|
|
|
95
99
|
| approval internals | S2 | ⚠️ runtime policy; only the turn-level effect |
|
|
96
100
|
| credentials internals | E2, E5 | ⚠️ file-level only |
|
|
97
101
|
|
|
98
|
-
The `dsh-doctor/v1` envelope (`--json --envelope`) is the machine-readable form of any of these runs, so a symptom tool can consume the verdict directly.
|
|
102
|
+
The `dsh-doctor/v1` envelope (`--json --envelope`) is the machine-readable form of any of these runs, so a symptom tool can consume the verdict directly. `--remediation`(配合 `--json --envelope`)输出 v1.1 可选 `remediation` 数组——失败项的有序 `[检查名] 修复` 行;不带该 flag 时字段不存在,r5 消费者字节稳定。
|
|
99
103
|
|
|
100
104
|
## 自更新检查(v0.2.1,层 B)
|
|
101
105
|
|
package/dsh-doctor.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* P10 inject 引用客户端专属服务(#1947:@deepseek-ai/dsh-client-* 服务端永不提供 → Fiber 永久 PENDING → web boot 失败)
|
|
16
16
|
* P11 已装 bundle 的 main 入口产物缺失(#1965:市场装未构建源码树 → ERR_MODULE_NOT_FOUND → boot 崩)
|
|
17
17
|
* P13 client 端 provide 服务名抢注核心客户端服务 / 跨 bundle 同名(#2752:浏览器端 service already registered → UI 白屏,服务端日志无感知)
|
|
18
|
+
* P16 命名导入的导出缺失(#5864:warn 级,静态自省已装包导出面)
|
|
18
19
|
* P14 declared bin 可执行性(#1846:打包成功但 bin 缺 shebang/产物 → 直接执行 ENOEXEC;与 P11 互补)
|
|
19
20
|
* P12 `installed_bundle`(#1719 v1.1 词汇:profile 内 bundle 版本 vs 运行 CLI 版本——web 面板/API 跑的是 profile 里装的 bundle,可与独立 CLI 版本不一致)
|
|
20
21
|
* [session]
|
|
@@ -24,7 +25,8 @@
|
|
|
24
25
|
* S7 end-seed 后重放已提交尾部(#1497:种子末尾之后出现更低 seq)
|
|
25
26
|
* S9 zstd 容器结构(#1043:单帧容器 → session.list 整体 500,侧边栏全消失)
|
|
26
27
|
* S10 sourceEventSeqs 悬空引用(#1469:压缩未重映射溯源 → history unavailable)
|
|
27
|
-
* S8 未知事件类型且无 ignorable(#1538
|
|
28
|
+
* S8 未知事件类型且无 ignorable(#1538:harness 读不了 → 整包拒绝;可读集 = 安装的 dsh-session 当前表 ∪ dsh-session-format-* 迁移包旧类型,0.1.5-rc.1 回退)
|
|
29
|
+
* S12 迁移拒载预检(#6045/#6328/#6311:规则自省自已装 dsh-session-format-* 迁移包)
|
|
28
30
|
* S11 全会话扫描(#1550:损坏会话 → 隔离建议;超大会话/工作区估算物化堆 → 冷启动风险警告;估算堆=解码MB×6+事件×200B,阈值默认 1GB,可设 DSH_DOCTOR_HEAP_MB)
|
|
29
31
|
* [env]
|
|
30
32
|
* E1 关键命令不在 PATH(#1270:node/pnpm/zstd)
|
|
@@ -32,7 +34,7 @@
|
|
|
32
34
|
* E3 node 版本 / --expose-internals 可及性(#113/#1313,headless/HMR 场景)
|
|
33
35
|
* E4 node-pty 原生模块完整性(#1219:pty.node 缺失 → dsh web 启动失败)
|
|
34
36
|
* E5 存储 JSON 文件合法性(#1357:并发写 workspace.json 乱码 → 工作区列表消失)
|
|
35
|
-
* E6 锚点元检查(tripwire:S6 的
|
|
37
|
+
* E6 锚点元检查(tripwire:S6 的 v0 chunk 展开契约、S7 的 session/end-seed、S10 的 sourceEventSeqs 是否仍在安装的 dsh-session/迁移包中;定位覆盖 npx/全局/profile 三布局)
|
|
36
38
|
* E10 3080 Web 端口可用性(#1719:启动 dsh web 前检查;dsh web 自身占用=正常,其他程序占用=FAIL;DSH_DOCTOR_PORT 可覆盖)
|
|
37
39
|
* (P6 Windows 空格参数 lint,#1420 —— 待实现)
|
|
38
40
|
*
|
|
@@ -54,7 +56,7 @@ import { execFileSync, spawnSync } from 'node:child_process';
|
|
|
54
56
|
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
55
57
|
import { createRequire } from 'node:module';
|
|
56
58
|
import net from 'node:net';
|
|
57
|
-
import { basename, delimiter as PATH_DELIM, dirname, join, resolve } from 'node:path';
|
|
59
|
+
import { basename, delimiter as PATH_DELIM, dirname, join, relative, resolve } from 'node:path';
|
|
58
60
|
import { homedir } from 'node:os';
|
|
59
61
|
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
60
62
|
|
|
@@ -67,37 +69,148 @@ const only = process.argv
|
|
|
67
69
|
.map((a) => a.startsWith('--') ? a.slice(2) : a);
|
|
68
70
|
const wants = (s) => only.length === 0 || only.includes(s) || only.includes(s.charAt(0).toUpperCase() + s.slice(1));
|
|
69
71
|
|
|
70
|
-
// S8:官方 KNOWN_SESSION_EVENT_TYPES
|
|
72
|
+
// S8:官方 KNOWN_SESSION_EVENT_TYPES(回退表对齐 0.1.5-rc.1;优先从安装的 dsh-session 动态解析)
|
|
71
73
|
const KNOWN_SESSION_EVENT_TYPES_FALLBACK = new Set([
|
|
72
|
-
'agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided',
|
|
73
|
-
'
|
|
74
|
-
'
|
|
75
|
-
'
|
|
76
|
-
'
|
|
77
|
-
'
|
|
78
|
-
'
|
|
79
|
-
'
|
|
80
|
-
'
|
|
74
|
+
'agent-preset/selected', 'agent/inbox/spliced', 'approval/asked', 'approval/decided',
|
|
75
|
+
'approval/policy', 'assistant/attempt', 'assistant/message', 'command/done',
|
|
76
|
+
'command/run', 'compaction/end', 'compaction/prune', 'compaction/start',
|
|
77
|
+
'compaction/summary', 'feedback/message-delete', 'feedback/message-put', 'feedback/record',
|
|
78
|
+
'goal/change', 'hook/invoked', 'hook/result', 'llm/retry',
|
|
79
|
+
'llm/retry-started', 'model/selection', 'permission/preset', 'plan/mode',
|
|
80
|
+
'request/context', 'request/header', 'sandbox/mode', 'schedule/change',
|
|
81
|
+
'session-log-deepseek/delivery-accepted', 'session/end-seed', 'session/title', 'session/title-llm-request',
|
|
82
|
+
'step/end', 'step/start', 'subagent/descriptor', 'subagent/model-selection-policy',
|
|
83
|
+
'system/message', 'team/member', 'team/message/delivered', 'team/message/queued',
|
|
84
|
+
'team/task', 'todo/write', 'tool-workflow/agent-end', 'tool-workflow/agent-start',
|
|
85
|
+
'tool-workflow/run-end', 'tool-workflow/run-start', 'tool/call', 'tool/ptc-dispatch',
|
|
86
|
+
'tool/ptc-dispatch-start', 'tool/result', 'turn/end', 'turn/start',
|
|
87
|
+
'user/message', 'web/deepseek-search-llm-request'
|
|
81
88
|
]);
|
|
82
89
|
// 存储行类型与 header,不属于事件门禁
|
|
83
90
|
const STORAGE_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks', 'session']);
|
|
84
|
-
|
|
91
|
+
/** 定位已安装的 dsh-session lib/index.js —— 覆盖三种安装形态(0.1.5 起 dsh 转全局安装,旧的 npx-only 查找会静默回退):
|
|
92
|
+
* ① npx/pnpm:<root>/node_modules/.bin/dsh → <root>/node_modules/@deepseek-ai/dsh-session
|
|
93
|
+
* ② 全局 npm:<prefix>/bin/dsh → <prefix>/lib/node_modules/@deepseek-ai/dsh[/node_modules]/@deepseek-ai/dsh-session
|
|
94
|
+
* ③ profile:$DSH_HOME/profiles/<name>/node_modules/@deepseek-ai/dsh-session(含 .pnpm store)
|
|
95
|
+
* 找不到返回 null —— 调用方回退内置假设并**声明**(不静默)。 */
|
|
96
|
+
function findSessionLibs() {
|
|
97
|
+
const REL = join('@deepseek-ai', 'dsh-session', 'lib', 'index.js');
|
|
98
|
+
const cands = [];
|
|
85
99
|
for (const p of (process.env.PATH || '').split(PATH_DELIM)) {
|
|
86
|
-
if (!p
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
if (!p || !existsSync(join(p, 'dsh'))) continue;
|
|
101
|
+
cands.push(join(dirname(p), REL)); // ① npx/pnpm 布局
|
|
102
|
+
let real = null;
|
|
103
|
+
try { real = realpathSync(join(p, 'dsh')); } catch { /* 忽略 */ }
|
|
104
|
+
if (real) {
|
|
105
|
+
let d = dirname(real);
|
|
106
|
+
for (let i = 0; i < 5; i++) {
|
|
107
|
+
cands.push(join(d, 'node_modules', REL)); // ② <pkg>/node_modules/…
|
|
108
|
+
cands.push(join(d, REL)); // ② hoisted 到 <dir>/@deepseek-ai/
|
|
109
|
+
const up = dirname(d);
|
|
110
|
+
if (up === d) break;
|
|
111
|
+
d = up;
|
|
93
112
|
}
|
|
94
|
-
}
|
|
95
|
-
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const profiles = join(HOME, 'profiles'); // ③
|
|
116
|
+
if (existsSync(profiles)) {
|
|
117
|
+
for (const name of readdirSync(profiles)) {
|
|
118
|
+
const nm = join(profiles, name, 'node_modules');
|
|
119
|
+
cands.push(join(nm, REL));
|
|
120
|
+
const store = join(nm, '.pnpm');
|
|
121
|
+
if (existsSync(store)) {
|
|
122
|
+
for (const d of readdirSync(store)) {
|
|
123
|
+
if (d.startsWith('@deepseek-ai+dsh-session@')) cands.push(join(store, d, 'node_modules', REL));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...new Set(cands.filter((c) => existsSync(c)))];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 机器上可能同时装着多份 dsh(全局 + 多个 npx checkout)——取版本最高的一份做"当前契约",
|
|
132
|
+
* 可读类型则取所有安装的并集(任一安装能读的旧类型都不该判"读不了")。 */
|
|
133
|
+
function pickNewestSessionLib(libs) {
|
|
134
|
+
let best = null; let bestV = null;
|
|
135
|
+
for (const lib of libs) {
|
|
136
|
+
let v = null;
|
|
137
|
+
try { v = JSON.parse(readFileSync(join(dirname(dirname(lib)), 'package.json'), 'utf8')).version; } catch { /* 忽略 */ }
|
|
138
|
+
if (!v) { if (!best) best = lib; continue; }
|
|
139
|
+
const key = v.split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : 0));
|
|
140
|
+
if (!bestV || key.some((n, i) => n !== (bestV[i] ?? 0) && n > (bestV[i] ?? 0) && bestV.slice(0, i).every((m, j) => m === key[j]))) { best = lib; bestV = key; }
|
|
141
|
+
else if (!best) { best = lib; bestV = key; }
|
|
142
|
+
}
|
|
143
|
+
return best;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 单份安装的事件类型表(解析失败返回 null)。 */
|
|
147
|
+
function sessionTableFrom(lib) {
|
|
148
|
+
try {
|
|
149
|
+
const s = readFileSync(lib, 'utf8');
|
|
150
|
+
const m = /const KNOWN_SESSION_EVENT_TYPES = new Set\(\[(.*?)\]\);/.exec(s);
|
|
151
|
+
if (!m) return null;
|
|
152
|
+
const items = [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
|
153
|
+
return items.length ? new Set(items) : null;
|
|
154
|
+
} catch { return null; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 某份安装同级的会话格式迁移包(v0→v1→v2→v3)所认的旧类型。 */
|
|
158
|
+
function migratorTypesFor(lib) {
|
|
159
|
+
const out = [];
|
|
160
|
+
const nm = lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
161
|
+
const libs = [];
|
|
162
|
+
const scope = join(nm, '@deepseek-ai');
|
|
163
|
+
if (existsSync(scope)) {
|
|
164
|
+
for (const d of readdirSync(scope)) if (d.startsWith('dsh-session-format-')) libs.push(join(scope, d, 'lib', 'index.js'));
|
|
165
|
+
}
|
|
166
|
+
const store = join(nm, '.pnpm');
|
|
167
|
+
if (existsSync(store)) {
|
|
168
|
+
for (const d of readdirSync(store)) {
|
|
169
|
+
if (!d.startsWith('@deepseek-ai+dsh-session-format-')) continue;
|
|
170
|
+
const inner = join(store, d, 'node_modules', '@deepseek-ai');
|
|
171
|
+
if (!existsSync(inner)) continue;
|
|
172
|
+
for (const p of readdirSync(inner)) if (p.startsWith('dsh-session-format-')) libs.push(join(inner, p, 'lib', 'index.js'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const f of libs) {
|
|
176
|
+
try {
|
|
177
|
+
const s = readFileSync(f, 'utf8');
|
|
178
|
+
for (const m of s.matchAll(/"([a-z][a-z0-9-]*\/[a-z0-9-]+)"/g)) out.push(m[1]);
|
|
179
|
+
} catch { /* 忽略单个包 */ }
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const SESSION_LIBS = findSessionLibs();
|
|
185
|
+
/** 定位安装的 dsh-session(新→旧取最高版本;找不到返回 null)。 */
|
|
186
|
+
function findSessionLib() { return pickNewestSessionLib(SESSION_LIBS) || null; }
|
|
187
|
+
|
|
188
|
+
function knownSessionEventTypes() {
|
|
189
|
+
const lib = findSessionLib();
|
|
190
|
+
if (lib) {
|
|
191
|
+
const t = sessionTableFrom(lib);
|
|
192
|
+
if (t) return t;
|
|
96
193
|
}
|
|
97
194
|
return KNOWN_SESSION_EVENT_TYPES_FALLBACK;
|
|
98
195
|
}
|
|
196
|
+
|
|
99
197
|
const KNOWN = knownSessionEventTypes();
|
|
100
198
|
|
|
199
|
+
/** 可读类型集 = 当前 KNOWN ∪ 已装 dsh-session-format-* 迁移包认的旧类型(0.1.5 起会话格式有 v0→v1→v2→v3 迁移链)。
|
|
200
|
+
* 只比对当前表会把**可迁移的旧会话**误判为"harness 读不了"(#1538 的语义是"读不了",迁移得了就不算)。
|
|
201
|
+
* 旧类型在磁盘日志里真实存在(v0 日志含 assistant/chunk、tool/code-dispatch 等),迁移包负责转换。 */
|
|
202
|
+
function readableSessionEventTypes() {
|
|
203
|
+
const set = new Set(KNOWN);
|
|
204
|
+
for (const lib of SESSION_LIBS) {
|
|
205
|
+
const t = sessionTableFrom(lib);
|
|
206
|
+
if (t) for (const x of t) set.add(x);
|
|
207
|
+
for (const x of migratorTypesFor(lib)) set.add(x);
|
|
208
|
+
}
|
|
209
|
+
return set;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const READABLE = readableSessionEventTypes();
|
|
213
|
+
|
|
101
214
|
function report(section, id, ok, detail, fix, src) {
|
|
102
215
|
results.push({ section, id, ok, detail, fix, src: src ?? 'builtin' });
|
|
103
216
|
}
|
|
@@ -193,27 +306,31 @@ function checkEnv() {
|
|
|
193
306
|
|
|
194
307
|
// E6:锚点元检查(tripwire)——我们 S6/S7/S10 依赖的契约是否仍在安装的 dsh-session 里
|
|
195
308
|
// 上游改名/重构会让我们的离线结论静默腐烂(boyin111-1 的 --verify-anchors 同款思路)
|
|
196
|
-
|
|
197
|
-
for (const p of (process.env.PATH || '').split(PATH_DELIM)) {
|
|
198
|
-
if (p.endsWith('node_modules/.bin') && existsSync(join(p, 'dsh'))) {
|
|
199
|
-
const lib = join(dirname(p), '@deepseek-ai', 'dsh-session', 'lib', 'index.js');
|
|
200
|
-
if (existsSync(lib)) { sessionLib = lib; break; }
|
|
201
|
-
}
|
|
202
|
-
}
|
|
309
|
+
const sessionLib = findSessionLib();
|
|
203
310
|
if (!sessionLib) {
|
|
204
|
-
|
|
311
|
+
// r5 语义:锚点校验在此上下文"不适用"(找不到安装)——用 skip 而非静默 pass,理由写进 detail
|
|
312
|
+
reportSkip('env', 'E6', '未定位到 dsh-session(npx/全局/profile 三种布局都没有)——锚点未校验,S6/S7/S10 结论回退内置假设', undefined);
|
|
205
313
|
} else {
|
|
206
314
|
const src = readFileSync(sessionLib, 'utf8');
|
|
315
|
+
// 0.1.5 起 expandRow 从 dsh-session 移到会话格式迁移包(v0→v1→v2→v3),锚点随之迁移:
|
|
316
|
+
// S6 读的是磁盘上的 v0/v1 日志,其 chunk 展开契约由迁移链承载。
|
|
317
|
+
const migratorLib = (() => {
|
|
318
|
+
const nm = sessionLib.slice(0, sessionLib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
319
|
+
const p = join(nm, '@deepseek-ai', 'dsh-session-format-v0-to-v1', 'lib', 'index.js');
|
|
320
|
+
return existsSync(p) ? p : null;
|
|
321
|
+
})();
|
|
322
|
+
const expandOk = /function expandRow[\s\S]*?row\.seq0/.test(src)
|
|
323
|
+
|| (migratorLib ? /"assistant\/chunk":\s*disposition\(/.test(readFileSync(migratorLib, 'utf8')) : false);
|
|
207
324
|
const anchors = [
|
|
208
|
-
['
|
|
209
|
-
['session/end-seed 字面量(S7 依赖)', /"session\/end-seed"
|
|
210
|
-
['sourceEventSeqs 字段(S10 依赖)', /sourceEventSeqs
|
|
325
|
+
['v0 chunk 展开契约(S6 依赖;dsh-session 的 expandRow 或迁移包 disposition)', expandOk],
|
|
326
|
+
['session/end-seed 字面量(S7 依赖)', /"session\/end-seed"/.test(src)],
|
|
327
|
+
['sourceEventSeqs 字段(S10 依赖)', /sourceEventSeqs/.test(src)],
|
|
211
328
|
];
|
|
212
|
-
const missing = anchors.filter(([,
|
|
329
|
+
const missing = anchors.filter(([, okFlag]) => !okFlag);
|
|
213
330
|
if (missing.length) {
|
|
214
331
|
report('env', 'E6', false, `锚点缺失(上游可能改了契约,S6/S7/S10 结论需人工复核): ${missing.map(([n]) => n).join('; ')}(${sessionLib.slice(-60)})`, '对照上游变更更新 dsh-doctor 的对应检查');
|
|
215
332
|
} else {
|
|
216
|
-
report('env', 'E6', true, `锚点齐全(${anchors.length}/3:
|
|
333
|
+
report('env', 'E6', true, `锚点齐全(${anchors.length}/3: v0 chunk 展开 / session/end-seed / sourceEventSeqs)`, undefined);
|
|
217
334
|
}
|
|
218
335
|
}
|
|
219
336
|
}
|
|
@@ -450,6 +567,71 @@ function checkProfile(name) {
|
|
|
450
567
|
else report('profile', 'P7', true, 'cordis.patch.yml 结构正常(无 tab / 无 ~ insert / 无映射-序列混排)', undefined);
|
|
451
568
|
|
|
452
569
|
// P8/P9 需要扫描 bundle 构建产物:收集目录下有限深度的 .js 文件(lib/dist/根 + main 入口,跳过 node_modules)
|
|
570
|
+
|
|
571
|
+
/** 从 fromDir 向上找 node_modules 里的裸包目录(也查 profile 与 CLI 安装);找不到返回 null。 */
|
|
572
|
+
/** 用户 patch 中标记 `disabled: true` 的 entry id 集合(P16 用:区分"已禁用不会崩"与"启用即崩")。 */
|
|
573
|
+
function disabledPatchIds() {
|
|
574
|
+
const set = new Set();
|
|
575
|
+
const f = join(HOME, 'profiles', 'web', 'cordis.patch.yml');
|
|
576
|
+
if (!existsSync(f)) return set;
|
|
577
|
+
let text;
|
|
578
|
+
try { text = readFileSync(f, 'utf8'); } catch { return set; }
|
|
579
|
+
const lines = text.split('\n');
|
|
580
|
+
for (let i = 0; i < lines.length; i++) {
|
|
581
|
+
const m = /^\s*-?\s*id:\s*['"]?([^'"\s]+)['"]?\s*$/.exec(lines[i]);
|
|
582
|
+
if (!m) continue;
|
|
583
|
+
for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
|
|
584
|
+
if (/^\s*-?\s*id:/.test(lines[j])) break;
|
|
585
|
+
if (/^\s*disabled:\s*true\s*$/.test(lines[j])) { set.add(m[1]); break; }
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return set;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function resolveInstalledPackage(spec, fromDir) {
|
|
592
|
+
const seg = spec.split('/');
|
|
593
|
+
const name = spec.startsWith('@') ? seg.slice(0, 2).join('/') : seg[0];
|
|
594
|
+
const cands = [];
|
|
595
|
+
let d = fromDir;
|
|
596
|
+
for (let i = 0; i < 6; i++) {
|
|
597
|
+
cands.push(join(d, 'node_modules', name));
|
|
598
|
+
const up = dirname(d);
|
|
599
|
+
if (up === d) break;
|
|
600
|
+
d = up;
|
|
601
|
+
}
|
|
602
|
+
const profiles = join(HOME, 'profiles');
|
|
603
|
+
if (existsSync(profiles)) for (const p of readdirSync(profiles)) cands.push(join(profiles, p, 'node_modules', name));
|
|
604
|
+
for (const lib of SESSION_LIBS) {
|
|
605
|
+
const nm = lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
606
|
+
cands.push(join(nm, name));
|
|
607
|
+
}
|
|
608
|
+
return cands.find((c) => existsSync(join(c, 'package.json'))) || null;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** 包入口的命名导出集合;**静态不可确定时返回 null**(CJS 入口 / `export *` / 找不到入口)。 */
|
|
612
|
+
function packageNamedExports(pkgDir) {
|
|
613
|
+
let pkg;
|
|
614
|
+
try { pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')); } catch { return null; }
|
|
615
|
+
let rel = null;
|
|
616
|
+
const pick = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' ? (pick(v.import) ?? pick(v.default) ?? pick(v.require) ?? null) : null));
|
|
617
|
+
if (pkg.exports) rel = pick(typeof pkg.exports === 'object' && pkg.exports['.'] !== undefined ? pkg.exports['.'] : pkg.exports);
|
|
618
|
+
if (!rel) rel = pick(pkg.module) ?? pick(pkg.main) ?? 'index.js';
|
|
619
|
+
let code;
|
|
620
|
+
try { code = readFileSync(join(pkgDir, rel), 'utf8'); } catch { return null; }
|
|
621
|
+
if (/export\s*\*/.test(code)) return null; // 星号再导出 → 导出面不确定
|
|
622
|
+
const named = new Set();
|
|
623
|
+
for (const m of code.matchAll(/export\s*\{([^}]*)\}/g)) {
|
|
624
|
+
for (const x of m[1].split(',')) {
|
|
625
|
+
const n = x.trim().replace(/^type[ \t]+/, '').split(/[ \t]+as[ \t]+/).pop().trim();
|
|
626
|
+
if (n) named.add(n);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
for (const m of code.matchAll(/export\s+(?:async\s+)?(?:const|let|var|function|class)\s+([A-Za-z_$][\w$]*)/g)) named.add(m[1]);
|
|
630
|
+
if (/export\s+default/.test(code)) named.add('default');
|
|
631
|
+
if (named.size === 0) return null; // 没有任何 ESM 导出语法 → 视为 CJS,不判
|
|
632
|
+
return named;
|
|
633
|
+
}
|
|
634
|
+
|
|
453
635
|
const bundleDirs = new Map(); // bundle 名 → 目录(可解析的)
|
|
454
636
|
for (const b of bundles) {
|
|
455
637
|
const d = findPkg(b);
|
|
@@ -707,6 +889,74 @@ function checkProfile(name) {
|
|
|
707
889
|
} catch (e) {
|
|
708
890
|
report('profile', 'installed_bundle', false, `bundle 版本对比异常: ${e.message.slice(0, 60)}`, undefined);
|
|
709
891
|
}
|
|
892
|
+
|
|
893
|
+
// P15:关键文件 BOM 检测(#5176:package.json 被意外加 BOM 头导致 JSON 解析失败)
|
|
894
|
+
// UTF-8 BOM = EF BB BF = '\uFEFF',pnpm/node 解析 JSON 时不认识 BOM → 报错
|
|
895
|
+
const bomTargets = [
|
|
896
|
+
join(dir, 'package.json'),
|
|
897
|
+
join(dir, 'cordis.patch.yml'),
|
|
898
|
+
join(dir, 'settings.yaml'),
|
|
899
|
+
];
|
|
900
|
+
// 加上 config/*.json
|
|
901
|
+
const configDir = join(dir, 'config');
|
|
902
|
+
if (existsSync(configDir)) {
|
|
903
|
+
try {
|
|
904
|
+
for (const f of readdirSync(configDir)) {
|
|
905
|
+
if (f.endsWith('.json')) bomTargets.push(join(configDir, f));
|
|
906
|
+
}
|
|
907
|
+
} catch { /* skip */ }
|
|
908
|
+
}
|
|
909
|
+
const bomFiles = [];
|
|
910
|
+
for (const f of bomTargets) {
|
|
911
|
+
if (!existsSync(f)) continue;
|
|
912
|
+
try {
|
|
913
|
+
const head = readFileSync(f, 'utf8').slice(0, 1);
|
|
914
|
+
if (head === '\uFEFF') bomFiles.push(f.replace(dir + '/', ''));
|
|
915
|
+
} catch { /* skip */ }
|
|
916
|
+
}
|
|
917
|
+
if (bomFiles.length > 0) {
|
|
918
|
+
report('profile', 'P15', false, `检测到 BOM 头(#5176:JSON/YAML 解析将失败): ${bomFiles.join(', ')}`, '用文本编辑器打开文件,删除首字符(BOM/U+FEFF)后保存;或运行: sed -i "" "1s/^\xEF\xBB\xBF//" <file>');
|
|
919
|
+
} else {
|
|
920
|
+
report('profile', 'P15', true, '关键文件无 BOM 头', undefined);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/* P16:插件命名导入的导出缺失检测(#5864:一个缺失导出 → 整棵插件树 boot 崩溃循环、
|
|
924
|
+
* 启动器每 ~11s 重启一次)。静态校验 `import { A } from 'pkg'` 的 A 是否存在于已装 pkg 的命名导出。
|
|
925
|
+
* **能不确定就不判**(本检查 warn 级,宁可漏报不误报):解析不到包 / 入口含 `export *` / CJS 入口 /
|
|
926
|
+
* 条件导出取不到 ESM 入口 / type-only 导入 / 非裸说明符 —— 全部跳过,且只在"确定不存在"时报。 */
|
|
927
|
+
const exportIssues = [];
|
|
928
|
+
const disabledIds = disabledPatchIds();
|
|
929
|
+
for (const [b, d] of bundleDirs) {
|
|
930
|
+
const seen = new Map(); // "pkg → 缺失符号" → 证据文件
|
|
931
|
+
for (const f of collectJsFiles(d)) {
|
|
932
|
+
const code = readJs(f);
|
|
933
|
+
for (const m of code.matchAll(/(?:^|\n)[ \t]*(?:import|export)[ \t]*\{([^}]*)\}[ \t]*from[ \t]*['"]([^'"]+)['"]/g)) {
|
|
934
|
+
const names = m[1].split(',').map((x) => x.trim().replace(/^type[ \t]+/, '').split(/[ \t]+as[ \t]+/)[0].trim()).filter((x) => x && x !== 'default' && /^[A-Za-z_$][\w$]*$/.test(x));
|
|
935
|
+
const spec = m[2];
|
|
936
|
+
if (!names.length || !/^[@A-Za-z]/.test(spec) || spec.startsWith('node:')) continue; // 只查裸包说明符
|
|
937
|
+
// 带子路径的说明符(pkg/sub、pkg/a/b)走各自的 exports 入口,静态判定不可靠 → 跳过(防误报)
|
|
938
|
+
const segs = spec.split('/');
|
|
939
|
+
if (spec.startsWith('@') ? segs.length > 2 : segs.length > 1) continue;
|
|
940
|
+
const pkgDir = resolveInstalledPackage(spec, d);
|
|
941
|
+
if (!pkgDir) continue; // 解析不到 → 不判
|
|
942
|
+
const exports = packageNamedExports(pkgDir);
|
|
943
|
+
if (!exports) continue; // 静态不可确定 → 不判
|
|
944
|
+
const miss = names.filter((n) => !exports.has(n));
|
|
945
|
+
if (miss.length) seen.set(`${spec} → ${miss.join(', ')}`, relative(d, f));
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (seen.size) {
|
|
949
|
+
// patch 的 id 可能是裸名(dsh-noema)而 bundle 名是 scoped(@zseven-w/dsh-noema)→ 去 scope 归一化比对
|
|
950
|
+
const bare = (n) => (n.startsWith('@') ? n.split('/').slice(1).join('/') : n);
|
|
951
|
+
const isDisabled = disabledIds.has(b) || disabledIds.has(bare(b));
|
|
952
|
+
exportIssues.push(`${b}(${[...seen].map(([k, f]) => `${k} [${f}]`).join('; ')})${isDisabled ? '〔当前已禁用,重新启用会 boot 失败〕' : '〔已启用 → boot 会失败〕'}`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (exportIssues.length) {
|
|
956
|
+
report('profile', 'P16', false, `插件导入了已装包未提供的导出(#5864:整棵插件树 boot 崩溃循环,启动器会反复重启): ${exportIssues.join('; ')}`, '把该插件升到与所装 @deepseek-ai/* 版本匹配的版本(或降级/移除);这类错误在 boot 期是硬失败,单条 entry 会拖垮整棵树');
|
|
957
|
+
} else {
|
|
958
|
+
report('profile', 'P16', true, '插件命名导入均在已装包的导出里(静态可判定的部分)', undefined);
|
|
959
|
+
}
|
|
710
960
|
}
|
|
711
961
|
|
|
712
962
|
/* ================= session ================= */
|
|
@@ -757,7 +1007,7 @@ function checkSession(targetPath) {
|
|
|
757
1007
|
let d; try { d = JSON.parse(line); } catch { continue; }
|
|
758
1008
|
const seq = d.seq; if (typeof seq === 'number' && seq > maxSeq) maxSeq = seq;
|
|
759
1009
|
// S8:未知事件类型且未标 ignorable(#1538:harness 整包拒绝)
|
|
760
|
-
if (!STORAGE_ROW_TYPES.has(d.type) && !
|
|
1010
|
+
if (!STORAGE_ROW_TYPES.has(d.type) && !READABLE.has(d.type) && d.ignorable !== true) {
|
|
761
1011
|
s8Violations.push(`"${d.type}"`);
|
|
762
1012
|
}
|
|
763
1013
|
// S6(官方版):按 decodeStorageRecord 语义展开 chunk 行,构建 seq==index 事件流
|
|
@@ -827,7 +1077,7 @@ function checkSession(targetPath) {
|
|
|
827
1077
|
const seen = [...new Set(s8Violations)].slice(0, 5).join(', ');
|
|
828
1078
|
report('session', 'S8', false, `未知事件类型且无 ignorable 标记(#1538,harness 将整包拒绝): ${seen}${new Set(s8Violations).size > 5 ? ` 等 ${new Set(s8Violations).size} 种` : ''}`, '该日志由更新版本/外部插件写入,当前 harness 无法读取;升级 harness 或标记 ignorable');
|
|
829
1079
|
} else {
|
|
830
|
-
report('session', 'S8', true,
|
|
1080
|
+
report('session', 'S8', true, `所有事件类型均可读(当前表 ${KNOWN.size} 种 + 迁移包旧类型,共 ${READABLE.size} 种)`, undefined);
|
|
831
1081
|
}
|
|
832
1082
|
|
|
833
1083
|
// S7:end-seed 之后出现低于种子末尾 seq 的事件(#1497:已提交尾部被重放)
|
|
@@ -848,6 +1098,170 @@ function checkSession(targetPath) {
|
|
|
848
1098
|
}
|
|
849
1099
|
}
|
|
850
1100
|
|
|
1101
|
+
/* S12:迁移拒载预检(#6045/#6328/#6311)——升级/打开前列出会被"会话格式迁移链"拒绝的会话。
|
|
1102
|
+
* 规则不硬编码:从已装 dsh-session-format-* 迁移包自省(v0→v1 的 descriptor 版本门 + v2→v3 的 source.kind 白名单);
|
|
1103
|
+
* 定位不到迁移包 → skip(不猜)。surface 事件集合照抄 v2→v3 的 assertSource 调用点(user/assistant/tool/inbox/title-llm-request)。 */
|
|
1104
|
+
const SURFACE_SOURCE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result', 'agent/inbox/spliced', 'session/title-llm-request']);
|
|
1105
|
+
|
|
1106
|
+
function findPkgLib(nm, name) {
|
|
1107
|
+
const a = join(nm, '@deepseek-ai', name, 'lib', 'index.js');
|
|
1108
|
+
if (existsSync(a)) return a;
|
|
1109
|
+
const store = join(nm, '.pnpm');
|
|
1110
|
+
if (existsSync(store)) {
|
|
1111
|
+
for (const d of readdirSync(store)) {
|
|
1112
|
+
if (!d.startsWith(`@deepseek-ai+${name}@`)) continue;
|
|
1113
|
+
const b = join(store, d, 'node_modules', '@deepseek-ai', name, 'lib', 'index.js');
|
|
1114
|
+
if (existsSync(b)) return b;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function migrationRules() {
|
|
1121
|
+
const out = { descriptorVersion: null, kinds: null, from: null };
|
|
1122
|
+
for (const lib of SESSION_LIBS) {
|
|
1123
|
+
const nm = lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
1124
|
+
if (out.descriptorVersion === null) {
|
|
1125
|
+
const v0 = findPkgLib(nm, 'dsh-session-format-v0-to-v1');
|
|
1126
|
+
if (v0) {
|
|
1127
|
+
try {
|
|
1128
|
+
const m = /data\["version"\]\s*!==\s*(\d+)/.exec(readFileSync(v0, 'utf8'));
|
|
1129
|
+
if (m) { out.descriptorVersion = Number(m[1]); out.from = nm; }
|
|
1130
|
+
} catch { /* 忽略 */ }
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
if (out.kinds === null) {
|
|
1134
|
+
const v3 = findPkgLib(nm, 'dsh-session-format-v2-to-v3');
|
|
1135
|
+
if (v3) {
|
|
1136
|
+
try {
|
|
1137
|
+
const m = /const SOURCE_KINDS = new Set\(\[(.*?)\]\);/s.exec(readFileSync(v3, 'utf8'));
|
|
1138
|
+
if (m) {
|
|
1139
|
+
const items = [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
|
1140
|
+
if (items.length) { out.kinds = new Set(items); out.from = nm; }
|
|
1141
|
+
}
|
|
1142
|
+
} catch { /* 忽略 */ }
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (out.descriptorVersion !== null && out.kinds !== null) break;
|
|
1146
|
+
}
|
|
1147
|
+
return out;
|
|
1148
|
+
}
|
|
1149
|
+
const MIGRATION_RULES = migrationRules();
|
|
1150
|
+
|
|
1151
|
+
/** 加载已装的会话格式目录(真实迁移链入口)。找不到/加载失败返回 null —— 调用方回退启发式规则或 skip。 */
|
|
1152
|
+
function loadFormatCatalog() {
|
|
1153
|
+
for (const lib of SESSION_LIBS) {
|
|
1154
|
+
const nm = lib.slice(0, lib.indexOf(join('@deepseek-ai', 'dsh-session')));
|
|
1155
|
+
const cands = [join(nm, '@deepseek-ai', 'dsh-session-format-catalog', 'lib', 'index.js')];
|
|
1156
|
+
const store = join(nm, '.pnpm');
|
|
1157
|
+
if (existsSync(store)) {
|
|
1158
|
+
for (const d of readdirSync(store)) {
|
|
1159
|
+
if (d.startsWith('@deepseek-ai+dsh-session-format-catalog@')) {
|
|
1160
|
+
cands.push(join(store, d, 'node_modules', '@deepseek-ai', 'dsh-session-format-catalog', 'lib', 'index.js'));
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
for (const c of cands) {
|
|
1165
|
+
if (!existsSync(c)) continue;
|
|
1166
|
+
try {
|
|
1167
|
+
const req = createRequire(c);
|
|
1168
|
+
const m = req(c);
|
|
1169
|
+
if (m?.sessionFormatCatalog?.createRestore) return m.sessionFormatCatalog;
|
|
1170
|
+
} catch { /* 试下一个 */ }
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return null;
|
|
1174
|
+
}
|
|
1175
|
+
const FORMAT_CATALOG = loadFormatCatalog();
|
|
1176
|
+
|
|
1177
|
+
/** 用**真实迁移链**在内存里跑一遍:返回 null = 可恢复,否则返回拒载原因(截断)。
|
|
1178
|
+
* 比复刻规则更准(上游每条 fail-closed 规则都覆盖,且自动跟随版本变化)。 */
|
|
1179
|
+
function realChainRefusal(text) {
|
|
1180
|
+
if (!FORMAT_CATALOG) return undefined; // undefined = 链不可用(区别于 null = 可恢复)
|
|
1181
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
1182
|
+
if (!lines.length) return null;
|
|
1183
|
+
try {
|
|
1184
|
+
const header = JSON.parse(lines[0]);
|
|
1185
|
+
const r = FORMAT_CATALOG.createRestore(header, { validation: 'current', recovery: 'strict' });
|
|
1186
|
+
for (let i = 1; i < lines.length; i++) r.decodeRow(JSON.parse(lines[i]));
|
|
1187
|
+
r.finish();
|
|
1188
|
+
return null;
|
|
1189
|
+
} catch (e) {
|
|
1190
|
+
return String(e?.message ?? e).replace(/\s+/g, ' ').slice(0, 140);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
/** 单个会话文本 → 会被迁移链拒绝的理由(空数组 = 可读)。 */
|
|
1196
|
+
function migrationRefusals(text) {
|
|
1197
|
+
const reasons = [];
|
|
1198
|
+
const { descriptorVersion, kinds } = MIGRATION_RULES;
|
|
1199
|
+
let version = null;
|
|
1200
|
+
for (const ln of text.split('\n')) {
|
|
1201
|
+
if (!ln.trim()) continue;
|
|
1202
|
+
let d; try { d = JSON.parse(ln); } catch { continue; }
|
|
1203
|
+
if (version === null && d.type === 'session' && typeof d.version === 'number') { version = d.version; continue; }
|
|
1204
|
+
if (version === 0 && descriptorVersion !== null && d.type === 'subagent/descriptor' && d.data?.version !== descriptorVersion) {
|
|
1205
|
+
reasons.push(`subagent/descriptor version ${d.data?.version} 会被 v0→v1 拒载(该迁移只接受 version ${descriptorVersion})`);
|
|
1206
|
+
}
|
|
1207
|
+
if (version === 2 && kinds && SURFACE_SOURCE_TYPES.has(d.type)) {
|
|
1208
|
+
const s = d.data?.source ?? d.data?.message?.source;
|
|
1209
|
+
if (s && typeof s.kind === 'string' && !kinds.has(s.kind)) reasons.push(`source.kind "${s.kind}" 会被 v2→v3 拒载(不在 ${kinds.size} 项白名单内)`);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
return [...new Set(reasons)];
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* 全会话库迁移拒载预检(#6045:某真实库 321 个日志中 282 个中招;#6328:一个坏产物拖垮搜索索引)。
|
|
1217
|
+
* 在升级/打开前给出"哪些会话将会打不开",避免列表里看着在、点开就报错。
|
|
1218
|
+
*/
|
|
1219
|
+
function scanMigrationRefusals() {
|
|
1220
|
+
if (!wants('session')) return;
|
|
1221
|
+
if (MIGRATION_RULES.descriptorVersion === null && !MIGRATION_RULES.kinds) {
|
|
1222
|
+
reportSkip('session', 'S12', '未定位到 dsh-session-format-* 迁移包,拒载规则不可自省——预检跳过(不猜)', undefined);
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const root = join(HOME, 'sessions');
|
|
1226
|
+
if (!existsSync(root)) { reportSkip('session', 'S12', '无会话目录,迁移拒载预检不适用', undefined); return; }
|
|
1227
|
+
const files = [];
|
|
1228
|
+
for (const u of readdirSync(root)) {
|
|
1229
|
+
const sd = join(root, u);
|
|
1230
|
+
if (!existsSync(sd)) continue;
|
|
1231
|
+
for (const s of readdirSync(sd)) {
|
|
1232
|
+
const f = existsSync(join(sd, s, 'session.jsonl.zstd')) ? join(sd, s, 'session.jsonl.zstd') : join(sd, s, 'session.jsonl');
|
|
1233
|
+
if (existsSync(f)) files.push(f);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (files.length === 0) { reportSkip('session', 'S12', '未发现会话日志', undefined); return; }
|
|
1237
|
+
const refused = [];
|
|
1238
|
+
let viaChain = 0;
|
|
1239
|
+
for (const f of files) {
|
|
1240
|
+
let text;
|
|
1241
|
+
try { text = f.endsWith('.zstd') ? execFileSync('zstd', ['-dc', f], { maxBuffer: 512 * 1024 * 1024 }).toString('utf8') : readFileSync(f, 'utf8'); }
|
|
1242
|
+
catch { continue; } // 解压失败由 S11 报,不在 S12 重复
|
|
1243
|
+
const chain = realChainRefusal(text);
|
|
1244
|
+
if (chain === undefined) {
|
|
1245
|
+
// 真实链不可用 → 回退启发式规则(覆盖子集,明确标注)
|
|
1246
|
+
const rs = migrationRefusals(text);
|
|
1247
|
+
if (rs.length) refused.push(`${basename(dirname(f))}(${rs[0]})`);
|
|
1248
|
+
} else if (chain !== null) {
|
|
1249
|
+
viaChain++;
|
|
1250
|
+
refused.push(`${basename(dirname(f))}(${chain})`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const rule = FORMAT_CATALOG
|
|
1254
|
+
? `判定方式:真实迁移链(format-catalog ${FORMAT_CATALOG.currentVersion ? `v${FORMAT_CATALOG.currentVersion}` : ''},内存试迁移)`
|
|
1255
|
+
: `判定方式:启发式规则(descriptor v${MIGRATION_RULES.descriptorVersion ?? '?'} / source.kind ${MIGRATION_RULES.kinds?.size ?? '?'} 项;真实链不可用,覆盖子集)`;
|
|
1256
|
+
if (refused.length) {
|
|
1257
|
+
report('session', 'S12', false,
|
|
1258
|
+
`迁移拒载预检:${refused.length}/${files.length} 个会话会被当前迁移链拒绝(#6045/#6328/#6311——列表里看着在、点开即报 cannot safely transform / unsupported descriptor version): ${refused.slice(0, 5).join('; ')}${refused.length > 5 ? ` 等 ${refused.length} 个` : ''}`,
|
|
1259
|
+
`① 先备份这些会话目录(勿删);② 等上游放宽版本门(#6045 已报);③ 应急:把日志里 subagent/descriptor 的 version 改为 ${MIGRATION_RULES.descriptorVersion ?? 3} 再迁移(改前务必备份)`);
|
|
1260
|
+
} else {
|
|
1261
|
+
report('session', 'S12', true, `迁移拒载预检:${files.length} 个会话均可被当前迁移链读取(${rule})`, undefined);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
|
|
851
1265
|
/* S11:全会话扫描 —— 损坏 → 隔离建议;超大 → 冷打开物化风险(#1550:一个坏/超大会话拖垮整个服务器) */
|
|
852
1266
|
function scanAllSessions() {
|
|
853
1267
|
if (!wants('session')) return;
|
|
@@ -885,7 +1299,7 @@ function scanAllSessions() {
|
|
|
885
1299
|
for (let li = 0; li < lines.length; li++) {
|
|
886
1300
|
const ln = lines[li]; if (!ln.trim()) continue;
|
|
887
1301
|
let d; try { d = JSON.parse(ln); } catch { problems.push(`行 ${li + 1} 无法解析`); continue; }
|
|
888
|
-
if (!STORAGE_ROW_TYPES.has(d.type) && !
|
|
1302
|
+
if (!STORAGE_ROW_TYPES.has(d.type) && !READABLE.has(d.type) && d.ignorable !== true) problems.push(`未知类型 ${d.type}`);
|
|
889
1303
|
if (d.type === 'session/end-seed' && typeof d.seq === 'number') { lastSeed = d.seq; seedIdx = posList.length; }
|
|
890
1304
|
const t = d.type;
|
|
891
1305
|
if (t === 'text-chunks' || t === 'reasoning-chunks' || t === 'tool-call-chunks') {
|
|
@@ -945,6 +1359,8 @@ catalogSeverity.set('E3-node', 'warn');
|
|
|
945
1359
|
catalogSeverity.set('installed_bundle', 'warn');
|
|
946
1360
|
catalogSeverity.set('P13', 'warn');
|
|
947
1361
|
catalogSeverity.set('P14', 'warn');
|
|
1362
|
+
catalogSeverity.set('P15', 'error');
|
|
1363
|
+
catalogSeverity.set('P16', 'warn');
|
|
948
1364
|
|
|
949
1365
|
function bundledCatalog() {
|
|
950
1366
|
const p = new URL('./checks.json', import.meta.url);
|
|
@@ -1175,6 +1591,26 @@ export function localVersion() {
|
|
|
1175
1591
|
}
|
|
1176
1592
|
|
|
1177
1593
|
/** 返回 { current, latest, available };latest=null 表示无法确认(离线且无缓存)。 */
|
|
1594
|
+
|
|
1595
|
+
/** 简易 semver 比较(支持 x.y.z-预发布):a>b 返回 1,a<b 返回 -1,相等 0。
|
|
1596
|
+
* 用途:本地版本可能领先 npm(未发布的工作版本),只比"不相等"会误报"新版本可用"。 */
|
|
1597
|
+
function compareVersions(a, b) {
|
|
1598
|
+
const parse = (v) => {
|
|
1599
|
+
const [core, pre = ''] = String(v).split('-');
|
|
1600
|
+
const nums = core.split('.').map((x) => parseInt(x, 10) || 0);
|
|
1601
|
+
return { nums, pre };
|
|
1602
|
+
};
|
|
1603
|
+
const A = parse(a); const B = parse(b);
|
|
1604
|
+
for (let i = 0; i < 3; i++) {
|
|
1605
|
+
const x = A.nums[i] ?? 0; const y = B.nums[i] ?? 0;
|
|
1606
|
+
if (x !== y) return x > y ? 1 : -1;
|
|
1607
|
+
}
|
|
1608
|
+
if (A.pre === B.pre) return 0;
|
|
1609
|
+
if (!A.pre) return 1; // 正式版 > 预发布
|
|
1610
|
+
if (!B.pre) return -1;
|
|
1611
|
+
return A.pre > B.pre ? 1 : -1;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1178
1614
|
export async function checkForUpdate({ noRemote = false, fetchImpl, home = HOME } = {}) {
|
|
1179
1615
|
const current = localVersion();
|
|
1180
1616
|
if (noRemote || typeof fetchImpl !== 'function') return { current, latest: null, available: false };
|
|
@@ -1182,7 +1618,7 @@ export async function checkForUpdate({ noRemote = false, fetchImpl, home = HOME
|
|
|
1182
1618
|
const readCache = () => { try { const d = JSON.parse(readFileSync(cachePath, 'utf8')); return d && typeof d.latest === 'string' ? d : null; } catch { return null; } };
|
|
1183
1619
|
try {
|
|
1184
1620
|
const c = readCache();
|
|
1185
|
-
if (c && Date.now() - statSync(cachePath).mtimeMs < UPDATE_TTL_MS) return { current, latest: c.latest, available: c.latest !== current };
|
|
1621
|
+
if (c && Date.now() - statSync(cachePath).mtimeMs < UPDATE_TTL_MS) return { current, latest: c.latest, available: c.latest !== current && compareVersions(c.latest, current) > 0 };
|
|
1186
1622
|
} catch { /* 回退 */ }
|
|
1187
1623
|
try {
|
|
1188
1624
|
const ac = new AbortController();
|
|
@@ -1194,7 +1630,7 @@ export async function checkForUpdate({ noRemote = false, fetchImpl, home = HOME
|
|
|
1194
1630
|
const latest = data?.['dist-tags']?.latest;
|
|
1195
1631
|
if (typeof latest === 'string') {
|
|
1196
1632
|
try { mkdirSync(dirname(cachePath), { recursive: true }); writeFileSync(cachePath, JSON.stringify({ latest, checkedAt: new Date().toISOString() })); } catch { /* 缓存失败不影响 */ }
|
|
1197
|
-
return { current, latest, available: latest !== current };
|
|
1633
|
+
return { current, latest, available: latest !== current && compareVersions(latest, current) > 0 };
|
|
1198
1634
|
}
|
|
1199
1635
|
}
|
|
1200
1636
|
} catch { /* 离线/超时 → last-known-good */ }
|
|
@@ -1270,6 +1706,7 @@ async function run() {
|
|
|
1270
1706
|
try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
|
|
1271
1707
|
try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
|
|
1272
1708
|
try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
|
|
1709
|
+
try { scanMigrationRefusals(); } catch (e) { report('session', 'S12', false, `迁移拒载预检异常: ${e.message.slice(0, 100)}`); }
|
|
1273
1710
|
}
|
|
1274
1711
|
|
|
1275
1712
|
// 远程检查目录(层 A):内置检查之后追加执行;--no-catalog 只走内置副本;--security-only 跳过
|
|
@@ -1397,6 +1834,12 @@ async function run() {
|
|
|
1397
1834
|
}
|
|
1398
1835
|
const baseExit = baseFail > 0 ? 2 : baseWarn > 0 ? 1 : 0;
|
|
1399
1836
|
const exitCode = Math.max(baseExit, secExit);
|
|
1837
|
+
// v1.1 remediation(#1719 ADOPTED:ciceroyang 提名、两位 reviewer +1):opt-in --remediation,
|
|
1838
|
+
// 顶层有序数组 ["[<checks[].name>] <fix>", ...],仅失败且有 fix 的项;键名取到首个 ']',']' 是唯一禁用字符。
|
|
1839
|
+
// 不带 --remediation 时字段不存在(r5 消费者字节稳定)。
|
|
1840
|
+
const remediation = process.argv.includes('--remediation')
|
|
1841
|
+
? results.filter((r) => !r.ok && r.fix && !r.id.includes(']')).map((r) => `[${r.id}] ${r.fix}`)
|
|
1842
|
+
: null;
|
|
1400
1843
|
const out = {
|
|
1401
1844
|
schema: 'dsh-doctor/v1',
|
|
1402
1845
|
tool: 'dsh-doctor',
|
|
@@ -1406,6 +1849,7 @@ async function run() {
|
|
|
1406
1849
|
summary,
|
|
1407
1850
|
ok: exitCode === 0,
|
|
1408
1851
|
checks,
|
|
1852
|
+
...(remediation ? { remediation } : {}),
|
|
1409
1853
|
};
|
|
1410
1854
|
if (securityMeta.enabled) {
|
|
1411
1855
|
out.security = { enabled: true, summary: securityMeta.summary, ...(securityMeta.error ? { error: securityMeta.error } : {}) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonquake2004/dsh-doctor",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Offline diagnostic for DeepSeek Harness — 28 built-in + 5 catalog checks across env/profile/session (Layer A checks-as-data), self-update (Layer B), and a semi-automatic LLM observer (Layer C, --observe); Doctor panel in web UI settings.",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"files": [
|