@luolingp/request-doctor 0.13.3 → 0.14.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  **产品名称**:request-doctor(请求急救员)
4
4
  **产品标识**:request-doctor
5
- **版本**:0.13.3
5
+ **版本**:0.14.0
6
6
 
7
7
  ---
8
8
 
@@ -1,7 +1,7 @@
1
1
  # 软件设计说明
2
2
 
3
3
  **产品名称**:request-doctor(请求急救员)
4
- **版本**:0.13.3
4
+ **版本**:0.14.0
5
5
  **编制依据**:GB/T 8567-2006《计算机软件文档编制规范》6.2
6
6
 
7
7
  ---
@@ -1,7 +1,7 @@
1
1
  # 软件测试报告
2
2
 
3
3
  **产品名称**:request-doctor(请求急救员)
4
- **版本**:0.13.3
4
+ **版本**:0.14.0
5
5
  **编制依据**:GB/T 8567-2006《计算机软件文档编制规范》6.6;GB/T 8566-2007 验证与确认过程
6
6
 
7
7
  ---
@@ -62,10 +62,11 @@
62
62
  | `test/client-half.test.mjs` | 9 | 客户端半:模块身份、注入声明、apply 注册、组件能渲染 |
63
63
  | `test/diagnostics.test.mjs` | 3 | 快照可选写入:不配置不写盘、突发合并、坏路径不致命 |
64
64
  | `test/restart-notice.test.mjs` | 3 |
65
+ | `test/allow-version.test.mjs` | 6 | 放行:版本必填、缺管理器拒绝、成功只调一次、失败如实报、请求体解析 |
65
66
  | `test/panel-blocked.test.mjs` | 7 | 冲突判定:审计兼容却被拒装、清单形状解析、给 allow 动作 |
66
67
  | `test/panel-briefs.test.mjs` | 6 | 界面摘要:按名索引、只送可见行、缺数据不抛、不污染诊断 |
67
68
  | `test/verdict-report.test.mjs` | 29 | why 与 list:判定、理由、环境摘要、自动扫描、分组、缺口、按形态给建议 | 缺导出判为需要重启;其它失败不误判;多次累积不覆盖 |
68
- | **合计** | **206** | **全部通过** |
69
+ | **合计** | **212** | **全部通过** |
69
70
 
70
71
  执行结果:
71
72
 
@@ -218,4 +219,4 @@
218
219
 
219
220
  ### 7.2 分文件执行结果
220
221
 
221
- 33 个测试文件逐个单独执行,每个都有用例且全部通过,不存在因导入失败而静默跳过的文件。用例数分布:compat.test.mjs 17、plugin.test.mjs 15、compat-env.test.mjs 14、classify.test.mjs 13,其余按职能分散,合计 206。
222
+ 34 个测试文件逐个单独执行,每个都有用例且全部通过,不存在因导入失败而静默跳过的文件。用例数分布:compat.test.mjs 17、plugin.test.mjs 15、compat-env.test.mjs 14、classify.test.mjs 13,其余按职能分散,合计 212。
@@ -1,7 +1,7 @@
1
1
  # 软件安装部署说明
2
2
 
3
3
  **产品名称**:request-doctor(请求急救员)
4
- **版本**:0.13.3
4
+ **版本**:0.14.0
5
5
  **编制依据**:GB/T 8567-2006《计算机软件文档编制规范》6.7
6
6
 
7
7
  ---
@@ -1,7 +1,7 @@
1
1
  # 软件用户手册
2
2
 
3
3
  **产品名称**:request-doctor(请求急救员)
4
- **版本**:0.13.3
4
+ **版本**:0.14.0
5
5
  **编制依据**:GB/T 8567-2006《计算机软件文档编制规范》6.5
6
6
 
7
7
  ---
@@ -1,7 +1,7 @@
1
1
  # 软件质量度量报告
2
2
 
3
3
  **产品名称**:request-doctor(请求急救员)
4
- **版本**:0.13.3
4
+ **版本**:0.14.0
5
5
  **度量标准**:GB/T 45717-2025《信息技术 软件测量 软件质量测量 自动化的源代码质量测度》
6
6
  **度量工具**:`tools/quality-measure.mjs`(本仓库自带,可复现)
7
7
 
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "standard": "GB/T 45717-2025 信息技术 软件测量 软件质量测量 自动化的源代码质量测度",
3
- "generatedAt": "2026-10-29T16:18:26.713Z",
3
+ "generatedAt": "2026-10-29T16:27:51.081Z",
4
4
  "scope": {
5
- "files": 34,
6
- "totalLines": 5295,
7
- "sourceLines": 3831,
8
- "commentLines": 975
5
+ "files": 36,
6
+ "totalLines": 5366,
7
+ "sourceLines": 3874,
8
+ "commentLines": 993
9
9
  },
10
10
  "measures": {
11
- "commentDensity": 25.45,
12
- "duplicationPercent": 4.92,
13
- "functionCount": 404,
11
+ "commentDensity": 25.63,
12
+ "duplicationPercent": 4.93,
13
+ "functionCount": 410,
14
14
  "maxComplexity": 10,
15
- "maxFunctionLines": 59,
15
+ "maxFunctionLines": 57,
16
16
  "maxFileLines": 500,
17
- "averageComplexity": 3.88
17
+ "averageComplexity": 3.85
18
18
  },
19
19
  "limits": {
20
20
  "maxComplexity": 10,
package/index.js CHANGED
@@ -1,486 +1,478 @@
1
- // request-doctor —— Host 端。
2
- //
3
- // 自动修复由本机引起的请求失败,并处理挡住已装插件的版本不匹配:
4
- //
5
- // - 模型请求失败(agent/request-error):传输、超时、空响应、上下文超限、缺适配器
6
- // 这几类会被分类并修复,然后按有界指数退避重试。provider 自己声明了重试策略的
7
- // 优先听它的,只有下游全都放弃重试时这里才接手。
8
- // - 工具调用(tools/execute):只读的工具被瞬时传输故障打断时重放一次。
9
- // - 浏览器载体请求(connection/request):响应头还没发出去就失败的,换成可重试的
10
- // 503,让页面自己的恢复逻辑能重发,而不是直接断掉。
11
- // - 插件与 DSH 版本不匹配:不是让插件去接受一个它没被设计的环境,而是给它原本那个。
12
- // 每个被挡住的 bundle 先审计,只有证明兼容的才拿到嵌套 node_modules,里面的垫片
13
- // 声明的是当前版本,转发的是真实实现。没有任何版本检查被绕过;某个导出真的没了,
14
- // 就如实报告,不伪造。
15
- //
16
- // 修复过程会写进已有的 llm/retry 和 llm/retry-started 会话事件,对话里的重试行
17
- // 因此能显示出来,不用发明新的事件类型。
18
- //
19
- // 这个文件是装配处:只管生命周期和监听器,具体判断都在 lib/ 里。
20
- import { explainVerdict, listAudited } from './lib/verdict-report.js';
21
- import { panelBriefs } from './lib/panel-briefs.js';
22
- import { readRefused, refusedRowsOf } from './lib/refused-list.js';
23
- import { parseRetryConfig } from './lib/classify.js';
24
- import { collectPackageFacts, entryPathOf } from './lib/compat-repair.js';
25
- import { unmetPeers } from './lib/compat.js';
26
- import { prepareForInstall } from './lib/install-support.js';
27
- import { installRemoteBundle, registerBundle } from './lib/install-bundle.js';
28
- import { installWithExemption } from './lib/install-rescue.js';
29
- import { createDiagnostics } from './lib/diagnostics.js';
30
- import { createRetryLedger } from './lib/retry-ledger.js';
31
- import { createRuntimeProbe } from './lib/runtime-probe.js';
32
- import { createVersionRepair } from './lib/version-repair.js';
33
- import { createRepairListeners } from './lib/repair-listeners.js';
34
- import { createTimers } from './lib/timers.js';
35
-
36
- export const name = 'request-doctor';
37
-
38
- // 后台扫描的基础间隔
39
- const BASE_SCAN_INTERVAL = 120000;
40
-
41
- // 一切正常时退避到这个上限
42
- const MAX_SCAN_INTERVAL = 1800000;
43
-
44
- // 等 HMR 激活完再扫
45
- const STARTUP_DELAY = 1500;
46
- const EVENT_DELAY = 2500;
47
-
48
- // 后台扫描最多退避几档
49
- const MAX_BACKOFF_STEPS = 5;
50
-
51
- // 遇到 HMR 嵌套错误时最多再试几次
52
- const MAX_HMR_RETRIES = 6;
53
-
54
- // 把插件装起来。
55
- export function apply(ctx, rawConfig) {
56
- const config = parseRetryConfig(rawConfig);
57
- if (!config.autoRepair) {
58
- ctx.logger.info('request-doctor: autoRepair is disabled; no repairs will run');
59
- return;
60
- }
61
-
62
- const lifetime = new AbortController();
63
- const timers = new Set();
64
- const { later } = createTimers(ctx, timers);
65
- const warn = (message) => ctx.logger.warn(message);
66
- const policy = () => config;
67
-
68
- const diagnostics = createDiagnostics({
69
- statusFile: typeof rawConfig?.statusFile === 'string' ? rawConfig.statusFile : '',
70
- log: (level, message) => ctx.logger[level]?.(message),
71
- describe: () => ({ ...config, retryableToolPattern: String(config.retryableToolPattern) }),
72
- trackTimer: (timer) => timers.add(timer),
73
- });
74
-
75
- const probe = createRuntimeProbe({ ctx, collectFacts: collectPackageFacts });
76
- const ledger = createRetryLedger({ config: policy, warn });
77
- const versionRepair = createVersionRepair({
78
- config: policy,
79
- probe,
80
- diagnostics,
81
- warn,
82
- later,
83
- maxHmrRetries: MAX_HMR_RETRIES,
84
- });
85
- const { onRequestError, onToolExecute, onConnectionRequest } = createRepairListeners({
86
- config: policy,
87
- lifetime,
88
- diagnostics,
89
- ledger,
90
- versionRepair,
91
- services: probe,
92
- warn,
93
- });
94
-
95
- ctx.on('agent/request-error', onRequestError);
96
- ctx.on('tools/execute', onToolExecute);
97
- // 浏览器载体和 profile 管理器在兄弟上下文里,它们的事件只有全局总线看得见。
98
- ctx.on('connection/request', onConnectionRequest, { global: true });
99
-
100
- const commandDeps = { probe, config: policy, diagnostics, versionRepair, stats: diagnostics.stats, record: diagnostics.record };
101
- const scans = createScanScheduler({ ctx, diagnostics, versionRepair, later, commandDeps, probe });
102
- registerDoctorCommand(ctx, commandDeps);
103
- registerPanelRoutes(ctx, commandDeps);
104
-
105
- ctx.effect(
106
- () => () => {
107
- lifetime.abort(new Error('request-doctor disposed'));
108
- scans.stop();
109
- for (const timer of timers) clearTimeout(timer);
110
- timers.clear();
111
- diagnostics.flush();
112
- },
113
- 'request-doctor: cancel pending repairs',
114
- );
115
- }
116
-
117
- // 接上事件触发和周期性的版本扫描。
118
- function createScanScheduler(input) {
119
- const { ctx, diagnostics, versionRepair, later } = input;
120
- let idleScans = 0;
121
- let pendingScan;
122
- let handle;
123
-
124
- // 扫描计数,事件扫描和后台轮询共用。
125
- const resetIdle = () => {
126
- idleScans = 0;
127
- };
128
-
129
- // 排一次扫描。
130
- //
131
- // 连续几个配置重载或插件变更会各排一次扫描,每次都要把整个 profile 重读一遍。
132
- // 排一个就够了:它跑的时候看到的就是当时的真实状态。
133
- const schedule = (reason, delayMs = STARTUP_DELAY) => {
134
- diagnostics.diagnostics.scheduled = (diagnostics.diagnostics.scheduled ?? 0) + 1;
135
- diagnostics.scheduleWrite();
136
- if (pendingScan !== undefined) return;
137
- pendingScan = later(() => {
138
- pendingScan = undefined;
139
- diagnostics.diagnostics.fired = (diagnostics.diagnostics.fired ?? 0) + 1;
140
- diagnostics.scheduleWrite();
141
- versionRepair.run(reason).catch((error) => {
142
- diagnostics.diagnostics.scanError = String(error);
143
- diagnostics.scheduleWrite();
144
- });
145
- }, delayMs);
146
- };
147
-
148
- // 观察到变化后,把后台退避复位。
149
- const onEvent = (counter, reason) => {
150
- diagnostics.diagnostics[counter] = (diagnostics.diagnostics[counter] ?? 0) + 1;
151
- resetIdle();
152
- diagnostics.scheduleWrite();
153
- schedule(reason, EVENT_DELAY);
154
- };
155
-
156
- ctx.on('app-boot/config-reload', () => onEvent('configReload', 'config-reload'), { global: true });
157
- ctx.on('plugin-manager/changed', () => onEvent('pluginManagerChanged', 'plugin-manager-change'), { global: true });
158
-
159
- // 兜底用的后台扫描,专门对付那些没有任何事件会通知的不匹配——
160
- // 手动改过 node_modules,或者从备份恢复了一个目录。
161
- //
162
- // 一直没发现问题就逐步拉长间隔,正常 profile 几乎不花什么钱;一旦有变化立刻回到基础周期。
163
- ctx.inject(['pluginManager'], (pluginCtx) => {
164
- // 安装命令要用它重载刚装好的 bundle,所以放进共享的 deps 里。
165
- if (input.commandDeps !== undefined) input.commandDeps.pluginManager = pluginCtx.pluginManager;
166
-
167
- // 图形界面里的普通安装也要能兜住:版本检查失败时替它把包装好。
168
-
169
- pluginCtx.effect(() => {
170
- diagnostics.scheduleWrite();
171
- let cancelled = false;
172
- const cancelFirst = later(() => {
173
- versionRepair.run('startup', pluginCtx.pluginManager).catch(() => {});
174
- }, STARTUP_DELAY);
175
-
176
- const tick = () => {
177
- if (cancelled) return;
178
- versionRepair.run('poll', pluginCtx.pluginManager)
179
- .then((fixed) => {
180
- idleScans = fixed.length > 0 ? 0 : Math.min(idleScans + 1, MAX_BACKOFF_STEPS);
181
- })
182
- .catch(() => {})
183
- .finally(() => {
184
- if (cancelled) return;
185
- handle = setTimeout(tick, Math.min(BASE_SCAN_INTERVAL * 2 ** idleScans, MAX_SCAN_INTERVAL));
186
- handle.unref?.();
187
- });
188
- };
189
- handle = setTimeout(tick, BASE_SCAN_INTERVAL);
190
- handle.unref?.();
191
-
192
- return () => {
193
- cancelled = true;
194
- cancelFirst();
195
- if (handle !== undefined) clearTimeout(handle);
196
- };
197
- }, 'request-doctor: background version scan');
198
- });
199
-
200
- return {
201
- stop: () => {
202
- if (handle !== undefined) clearTimeout(handle);
203
- },
204
- };
205
- }
206
-
207
- // 注册 /doctor 命令。
208
- function registerDoctorCommand(ctx, deps) {
209
- ctx.inject(['commands'], (commandCtx) => {
210
- commandCtx.effect(
211
- () =>
212
- commandCtx.commands.register({
213
- name: 'doctor',
214
- description: 'Show request-doctor repair activity, repair version mismatches now, or prepare a plugin for installation',
215
- input: { hint: 'install <path-or-package> | allow <package@version> | why <package> | list' },
216
- handler: async (invocation) => runDoctorCommand(invocation, deps),
217
- }),
218
- 'request-doctor: /doctor command',
219
- );
220
- });
221
- }
222
-
223
- // 汇总当前需要处理的插件:本插件扫描的审计结果,加上管理器的拒装记录,按名去重。
224
- async function collectPanelRows(deps, manager) {
225
- const runtimeVersion = deps.probe.runtimeFacts().runtimeVersion;
226
- const rows = [];
227
- for (const item of deps.diagnostics?.versionScan?.audited ?? []) {
228
- if (item.verdict === 'compatible') continue;
229
- rows.push({ name: item.name, version: item.version, runtimeVersion, source: 'audit' });
230
- }
231
- if (manager !== undefined) {
232
- try {
233
- for (const row of refusedRowsOf(await manager.listBundles())) rows.push({ ...row, source: 'manager' });
234
- } catch {
235
- // 管理器读不动就只报审计结果,页面仍然有内容。
236
- }
237
- }
238
- return dedupeRows(rows);
239
- }
240
-
241
- // 同一个插件可能既在本插件审计里、也在管理器的拒装清单里。
242
- // 管理器那条是「现在真的装不上」,信息更强,所以优先保留它。
243
- function dedupeRows(rows) {
244
- const byKey = new Map();
245
- for (const row of rows) {
246
- const key = `${row.name}@${row.version}`;
247
- const existing = byKey.get(key);
248
- if (existing === undefined || (existing.source !== 'manager' && row.source === 'manager')) byKey.set(key, row);
249
- }
250
- return [...byKey.values()];
251
- }
252
-
253
- // 界面要的一份数据:待处理的行,加上「修好了但需要重启」的提示。
254
- async function panelPayload(deps, manager) {
255
- const box = deps.diagnostics?.diagnostics ?? {};
256
- const rows = await collectPanelRows(deps, manager);
257
- return {
258
- ok: true,
259
- rows,
260
- restart: box.restartRequired ?? [],
261
- briefs: panelBriefs(box, rows.map((row) => row.name)),
262
- };
263
- }
264
- // 界面用的 HTTP 路由。
265
- //
266
- // 客户端半拿不到 typert 的 remote 门面(动态模块的上下文里没有那个服务),
267
- // 但它和页面同源,可以直接 fetch。插件管理器自己的界面同样走 webServer 路由,
268
- // 这条路不依赖任何远程服务,是这里最稳的一条。
269
- function registerPanelRoutes(ctx, deps) {
270
- ctx.inject(['webServer'], (webCtx) => {
271
- const json = (res, status, payload) => {
272
- res.writeHead(status, { 'content-type': 'application/json' });
273
- res.end(JSON.stringify(payload));
274
- };
275
-
276
- // 列出当前被拒装的插件:读管理器的清单,把带 incompatible 的行挑出来。
277
- const listRoute = {
278
- kind: 'exact',
279
- path: '/doctor/incompatible',
280
- handler: async (_req, res) => {
281
- const manager = deps.pluginManager ?? ctx.get('pluginManager');
282
- return json(res, 200, await panelPayload(deps, manager));
283
- },
284
- };
285
-
286
- // 装一个包:请求体里的 spec 交给和 /doctor install 完全相同的实现。
287
- const installRoute = {
288
- kind: 'exact',
289
- path: '/doctor/install',
290
- handler: (req, res) => {
291
- const chunks = [];
292
- req.on('data', (chunk) => chunks.push(chunk));
293
- req.on('end', async () => {
294
- let spec = '';
295
- try {
296
- spec = String(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').spec ?? '').trim();
297
- } catch {
298
- return json(res, 400, { ok: false, text: '请求体不是合法的 JSON' });
299
- }
300
- if (spec === '') return json(res, 400, { ok: false, text: 'spec 不能为空' });
301
- const manager = deps.pluginManager ?? ctx.get('pluginManager');
302
- if (manager === undefined) return json(res, 200, { ok: false, text: '拿不到插件管理器' });
303
- try {
304
- const result = await installFromRegistry(spec, { ...deps, pluginManager: manager });
305
- return json(res, 200, { ok: result.kind === 'success', text: result.text });
306
- } catch (error) {
307
- return json(res, 200, { ok: false, text: String(error) });
308
- }
309
- });
310
- },
311
- };
312
-
313
- ctx.effect(() => webCtx.webServer.register(listRoute), 'request-doctor: 界面清单路由');
314
- // 让界面能主动触发一轮审计:装完东西之后不用等下一次定时扫描。
315
- const rescanRoute = {
316
- kind: 'exact',
317
- path: '/doctor/rescan',
318
- handler: async (_req, res) => {
319
- try {
320
- await deps.versionRepair.run('manual');
321
- return json(res, 200, { ok: true });
322
- } catch (error) {
323
- return json(res, 200, { ok: false, reason: String(error) });
324
- }
325
- },
326
- };
327
-
328
- ctx.effect(() => webCtx.webServer.register(installRoute), 'request-doctor: 界面安装路由');
329
- ctx.effect(() => webCtx.webServer.register(rescanRoute), 'request-doctor: 界面重扫路由');
330
- });
331
- }
332
-
333
- // 处理一次 /doctor 调用。
334
- async function runDoctorCommand(invocation, deps) {
335
- const raw = String(invocation?.rawInput ?? '').trim();
336
- const [sub, ...rest] = raw.split(/\s+/).filter((part) => part !== '');
337
-
338
- // install 先把本地插件准备好,让管理器自己的版本检查按它的规则通过,
339
- // 而不是装完之后又被回滚掉。
340
- if (sub === 'install') return prepareInstallCommand(rest.join(' '), deps);
341
- // allow 只授权、不安装:给那些想继续在图形界面里点安装的人用。
342
- if (sub === 'allow') return allowVersionCommand(rest.join(' '), deps);
343
- // why 回答「这个插件为什么被判为不可兼容」,答案就在最近一轮审计里。
344
- // list 列出所有审计过的插件,先看全貌再用 why 追问。
345
- if (sub === 'list') return listAudited({ ...deps, refusedRows: await readRefused(deps.probe?.get?.('pluginManager')) });
346
- if (sub === 'why') return explainVerdict(rest.join(' '), deps);
347
- return summarizeRepairs(deps);
348
- }
349
-
350
-
351
-
352
- async function prepareInstallCommand(spec, deps) {
353
- if (spec === '') return { kind: 'error', text: 'usage: /doctor install <目录路径或包名> — 两者都行' };
354
-
355
- // 注册表里的包:管理器自己装不上,那就我们自己装。
356
- const profileDir = deps.probe.runtimeFacts().profileDir ?? '';
357
- if (classifySpec(spec, profileDir).kind === 'remote') return installFromRegistry(spec, deps);
358
-
359
- const facts = deps.probe.runtimeFacts();
360
- const resolveDir = deps.probe.runtimeDirOf(facts);
361
- const prepared = prepareForInstall({
362
- spec,
363
- profileDir: facts.profileDir ?? '',
364
- runtimeVersion: facts.runtimeVersion ?? '',
365
- resolveDir,
366
- factsOf: (dir, entry) => deps.probe.factsOf(dir, entry, facts.runtimeVersion),
367
- entryOf: entryPathOf,
368
- unmetPeers,
369
- });
370
- if (!prepared.prepared) {
371
- return { kind: 'error', text: `not prepared: ${prepared.reason ?? 'unknown reason'}` };
372
- }
373
- const built = prepared.packages.join(', ') || 'environment deferred to the next scan';
374
- deps.record('install-prepare', `${spec} prepared with ${built}`);
375
-
376
- // 顺带报告每个依赖能不能解析到。装不上的时候,第一件要知道的就是这个。
377
- const lines = [];
378
- for (const name of Object.keys(prepared.requirements ?? {})) {
379
- const dir = resolveDir(name);
380
- lines.push(`${name}: ${dir === undefined ? '解析不到' : dir}`);
381
- }
382
- const detail = lines.length === 0 ? '' : `\n\n依赖解析情况:\n${lines.join('\n')}`;
383
- return {
384
- kind: 'success',
385
- text: `prepared ${spec} for this runtime (${built}).\nNow install it normally; the manager's compatibility check will pass.${detail}`,
386
- };
387
- }
388
-
389
- // 提前授权一个「当前运行时本来不让装」的插件版本。
390
- //
391
- // 图形界面里的安装走的是管理器自己的流程,插件侧拦不住。那条路要过版本检查,
392
- // 而检查只认显式授权。所以给用户一个在点安装之前把这件事做掉的入口:
393
- // 先 /doctor allow,再在界面上装,装完扫描会自动把声明对齐、环境建好。
394
- async function allowVersionCommand(spec, deps) {
395
- const trimmed = String(spec ?? '').trim();
396
- if (trimmed === '') return { kind: 'error', text: '用法:/doctor allow <包名@版本>' };
397
- if (trimmed.includes('@', 1) === false) {
398
- return { kind: 'error', text: '要写清楚版本,例如 /doctor allow dsh-ssh-ops@0.4.0' };
399
- }
400
-
401
- // 授权必须有明确的运行时版本和管理器实例,缺一个都做不了。
402
- const runtimeVersion = deps.probe.runtimeFacts().runtimeVersion ?? '';
403
- const ready = runtimeVersion !== '' && deps.pluginManager !== undefined;
404
- if (ready === false) return { kind: 'error', text: '拿不到当前 DSH 版本或管理器不可用,无法授权' };
405
-
406
- try {
407
- await deps.pluginManager.setVersionExemption(trimmed, runtimeVersion, true, true);
408
- } catch (error) {
409
- return { kind: 'error', text: `授权失败:${String(error)}` };
410
- }
411
- const note = `已放行 ${trimmed} 在 ${runtimeVersion} 上运行`;
412
- deps.record('version-allowed', note);
413
-
414
- return {
415
- kind: 'success',
416
- text: [
417
- `已授权 ${trimmed} 运行在 ${runtimeVersion}`,
418
- '现在可以在插件面板里正常安装它了。',
419
- '装完之后,声明对齐和兼容环境会自动补上。',
420
- ].join('\n'),
421
- };
422
- }
423
-
424
- // 自己装一个注册表里的包。
425
- //
426
- // 管理器的安装路径是 pnpm 加一次版本检查。远程包在检查之前就已经落地了,
427
- // 没法像本地目录那样提前把 manifest 改好,所以它对不兼容的包永远装不上。
428
- // 这里把 pnpm add 该做的事自己做一遍:取包、取依赖、对齐声明、建环境、登记、重载。
429
- async function installFromRegistry(spec, deps) {
430
- const facts = deps.probe.runtimeFacts();
431
- if ((facts.profileDir ?? '') === '') return { kind: 'error', text: '拿不到 profile 目录,无法安装' };
432
-
433
- // 先授权版本豁免再装:管理器的检查按它自己的规则通过,装完我们再补上兼容形态。
434
- const result = await installWithExemption({
435
- spec,
436
- probe: deps.probe,
437
- pluginManager: deps.pluginManager,
438
- runtimeVersion: facts.runtimeVersion ?? '',
439
- factsOf: (dir, entry) => deps.probe.factsOf(dir, entry, facts.runtimeVersion),
440
- entryOf: entryPathOf,
441
- record: (kind, message) => deps.record(kind, message),
442
- });
443
- if (!result.installed) return { kind: 'error', text: `装不上 ${spec}:${result.reason ?? '未知原因'}` };
444
-
445
- const summary = [
446
- `已安装 ${result.name}@${result.version}`,
447
- `位置:${result.path}`,
448
- `兼容环境:${result.environment}`,
449
- ];
450
- return { kind: 'success', text: summary.join('\n') };
451
- }
452
-
453
- // 现在修一遍,顺带把之前修过什么报出来。
454
- async function summarizeRepairs(deps) {
455
- if (deps.config.autoVersionRepair) await deps.versionRepair.run('manual');
456
- const { stats } = deps;
457
- const lines = [
458
- `request-doctor: ${stats.modelRepairs} model, ${stats.toolRepairs} tool, ${stats.connectionRepairs} carrier, ${stats.versionRepairs} version repair(s)`,
459
- `budget: ${deps.config.maxRetries} model retries per step, ${deps.config.maxToolRetries} tool replays`,
460
-
461
- 'usage: /doctor install <path> — prepare a local plugin so it can be installed',
462
- ];
463
- for (const entry of stats.last.slice(-8)) lines.push(`- ${entry.at} ${entry.kind}: ${entry.detail}`);
464
-
465
- // 把这一轮审计的结论也带上。插件没被适配时,原因就在这里面。
466
- const scan = deps.diagnostics.diagnostics.versionScan;
467
- if (scan?.audited !== undefined && scan.audited.length > 0) {
468
- lines.push('', '最近一轮版本审计:');
469
- for (const item of scan.audited) {
470
- lines.push(`- ${item.name}@${item.version}: ${item.verdict}`);
471
- for (const reason of (item.reasons ?? []).slice(-2)) lines.push(` ${reason}`);
472
- if ((item.missing ?? []).length > 0) lines.push(` 缺符号:${item.missing.join(', ')}`);
473
- if (item.environment !== undefined) lines.push(` 环境:${item.environment}`);
474
- }
475
- } else if (scan?.error !== undefined) {
476
- lines.push('', `最近一轮扫描出错:${scan.error}`);
477
- }
478
-
479
- // 有插件已经修好但必须重启才生效:说清楚,别让人以为修复失败。
480
- const waiting = deps.diagnostics.diagnostics.restartRequired ?? [];
481
- if (waiting.length > 0) {
482
- lines.push('', '需要重启后生效:');
483
- for (const item of waiting) lines.push(`- ${item.hint}`);
484
- }
485
- return { kind: 'success', text: lines.join('\n') };
486
- }
1
+ // request-doctor —— Host 端。
2
+ //
3
+ // 自动修复由本机引起的请求失败,并处理挡住已装插件的版本不匹配:
4
+ //
5
+ // - 模型请求失败(agent/request-error):传输、超时、空响应、上下文超限、缺适配器
6
+ // 这几类会被分类并修复,然后按有界指数退避重试。provider 自己声明了重试策略的
7
+ // 优先听它的,只有下游全都放弃重试时这里才接手。
8
+ // - 工具调用(tools/execute):只读的工具被瞬时传输故障打断时重放一次。
9
+ // - 浏览器载体请求(connection/request):响应头还没发出去就失败的,换成可重试的
10
+ // 503,让页面自己的恢复逻辑能重发,而不是直接断掉。
11
+ // - 插件与 DSH 版本不匹配:不是让插件去接受一个它没被设计的环境,而是给它原本那个。
12
+ // 每个被挡住的 bundle 先审计,只有证明兼容的才拿到嵌套 node_modules,里面的垫片
13
+ // 声明的是当前版本,转发的是真实实现。没有任何版本检查被绕过;某个导出真的没了,
14
+ // 就如实报告,不伪造。
15
+ //
16
+ // 修复过程会写进已有的 llm/retry 和 llm/retry-started 会话事件,对话里的重试行
17
+ // 因此能显示出来,不用发明新的事件类型。
18
+ //
19
+ // 这个文件是装配处:只管生命周期和监听器,具体判断都在 lib/ 里。
20
+ import { explainVerdict, listAudited } from './lib/verdict-report.js';
21
+ import { panelBriefs } from './lib/panel-briefs.js';
22
+ import { readRefused, refusedRowsOf } from './lib/refused-list.js';
23
+ import { allowVersion } from './lib/allow-version.js';
24
+ import { blockedButCompatible } from './lib/panel-blocked.js';
25
+ import { BAD_BODY, createAllowRoute, readSpec } from './lib/allow-route.js';
26
+ import { parseRetryConfig } from './lib/classify.js';
27
+ import { collectPackageFacts, entryPathOf } from './lib/compat-repair.js';
28
+ import { unmetPeers } from './lib/compat.js';
29
+ import { prepareForInstall } from './lib/install-support.js';
30
+ import { installRemoteBundle, registerBundle } from './lib/install-bundle.js';
31
+ import { installWithExemption } from './lib/install-rescue.js';
32
+ import { createDiagnostics } from './lib/diagnostics.js';
33
+ import { createRetryLedger } from './lib/retry-ledger.js';
34
+ import { createRuntimeProbe } from './lib/runtime-probe.js';
35
+ import { createVersionRepair } from './lib/version-repair.js';
36
+ import { createRepairListeners } from './lib/repair-listeners.js';
37
+ import { createTimers } from './lib/timers.js';
38
+
39
+ export const name = 'request-doctor';
40
+
41
+ // 后台扫描的基础间隔
42
+ const BASE_SCAN_INTERVAL = 120000;
43
+
44
+ // 一切正常时退避到这个上限
45
+ const MAX_SCAN_INTERVAL = 1800000;
46
+
47
+ // 等 HMR 激活完再扫
48
+ const STARTUP_DELAY = 1500;
49
+ const EVENT_DELAY = 2500;
50
+
51
+ // 后台扫描最多退避几档
52
+ const MAX_BACKOFF_STEPS = 5;
53
+
54
+ // 遇到 HMR 嵌套错误时最多再试几次
55
+ const MAX_HMR_RETRIES = 6;
56
+
57
+ // 把插件装起来。
58
+ export function apply(ctx, rawConfig) {
59
+ const config = parseRetryConfig(rawConfig);
60
+ if (!config.autoRepair) {
61
+ ctx.logger.info('request-doctor: autoRepair is disabled; no repairs will run');
62
+ return;
63
+ }
64
+
65
+ const lifetime = new AbortController();
66
+ const timers = new Set();
67
+ const { later } = createTimers(ctx, timers);
68
+ const warn = (message) => ctx.logger.warn(message);
69
+ const policy = () => config;
70
+
71
+ const diagnostics = createDiagnostics({
72
+ statusFile: typeof rawConfig?.statusFile === 'string' ? rawConfig.statusFile : '',
73
+ log: (level, message) => ctx.logger[level]?.(message),
74
+ describe: () => ({ ...config, retryableToolPattern: String(config.retryableToolPattern) }),
75
+ trackTimer: (timer) => timers.add(timer),
76
+ });
77
+
78
+ const probe = createRuntimeProbe({ ctx, collectFacts: collectPackageFacts });
79
+ const ledger = createRetryLedger({ config: policy, warn });
80
+ const versionRepair = createVersionRepair({
81
+ config: policy,
82
+ probe,
83
+ diagnostics,
84
+ warn,
85
+ later,
86
+ maxHmrRetries: MAX_HMR_RETRIES,
87
+ });
88
+ const { onRequestError, onToolExecute, onConnectionRequest } = createRepairListeners({
89
+ config: policy,
90
+ lifetime,
91
+ diagnostics,
92
+ ledger,
93
+ versionRepair,
94
+ services: probe,
95
+ warn,
96
+ });
97
+
98
+ ctx.on('agent/request-error', onRequestError);
99
+ ctx.on('tools/execute', onToolExecute);
100
+ // 浏览器载体和 profile 管理器在兄弟上下文里,它们的事件只有全局总线看得见。
101
+ ctx.on('connection/request', onConnectionRequest, { global: true });
102
+
103
+ const commandDeps = { probe, config: policy, diagnostics, versionRepair, stats: diagnostics.stats, record: diagnostics.record };
104
+ const scans = createScanScheduler({ ctx, diagnostics, versionRepair, later, commandDeps, probe });
105
+ registerDoctorCommand(ctx, commandDeps);
106
+ registerPanelRoutes(ctx, commandDeps);
107
+
108
+ ctx.effect(
109
+ () => () => {
110
+ lifetime.abort(new Error('request-doctor disposed'));
111
+ scans.stop();
112
+ for (const timer of timers) clearTimeout(timer);
113
+ timers.clear();
114
+ diagnostics.flush();
115
+ },
116
+ 'request-doctor: cancel pending repairs',
117
+ );
118
+ }
119
+
120
+ // 接上事件触发和周期性的版本扫描。
121
+ function createScanScheduler(input) {
122
+ const { ctx, diagnostics, versionRepair, later } = input;
123
+ let idleScans = 0;
124
+ let pendingScan;
125
+ let handle;
126
+
127
+ // 扫描计数,事件扫描和后台轮询共用。
128
+ const resetIdle = () => {
129
+ idleScans = 0;
130
+ };
131
+
132
+ // 排一次扫描。
133
+ //
134
+ // 连续几个配置重载或插件变更会各排一次扫描,每次都要把整个 profile 重读一遍。
135
+ // 排一个就够了:它跑的时候看到的就是当时的真实状态。
136
+ const schedule = (reason, delayMs = STARTUP_DELAY) => {
137
+ diagnostics.diagnostics.scheduled = (diagnostics.diagnostics.scheduled ?? 0) + 1;
138
+ diagnostics.scheduleWrite();
139
+ if (pendingScan !== undefined) return;
140
+ pendingScan = later(() => {
141
+ pendingScan = undefined;
142
+ diagnostics.diagnostics.fired = (diagnostics.diagnostics.fired ?? 0) + 1;
143
+ diagnostics.scheduleWrite();
144
+ versionRepair.run(reason).catch((error) => {
145
+ diagnostics.diagnostics.scanError = String(error);
146
+ diagnostics.scheduleWrite();
147
+ });
148
+ }, delayMs);
149
+ };
150
+
151
+ // 观察到变化后,把后台退避复位。
152
+ const onEvent = (counter, reason) => {
153
+ diagnostics.diagnostics[counter] = (diagnostics.diagnostics[counter] ?? 0) + 1;
154
+ resetIdle();
155
+ diagnostics.scheduleWrite();
156
+ schedule(reason, EVENT_DELAY);
157
+ };
158
+
159
+ ctx.on('app-boot/config-reload', () => onEvent('configReload', 'config-reload'), { global: true });
160
+ ctx.on('plugin-manager/changed', () => onEvent('pluginManagerChanged', 'plugin-manager-change'), { global: true });
161
+
162
+ // 兜底用的后台扫描,专门对付那些没有任何事件会通知的不匹配——
163
+ // 手动改过 node_modules,或者从备份恢复了一个目录。
164
+ //
165
+ // 一直没发现问题就逐步拉长间隔,正常 profile 几乎不花什么钱;一旦有变化立刻回到基础周期。
166
+ ctx.inject(['pluginManager'], (pluginCtx) => {
167
+ // 安装命令要用它重载刚装好的 bundle,所以放进共享的 deps 里。
168
+ if (input.commandDeps !== undefined) input.commandDeps.pluginManager = pluginCtx.pluginManager;
169
+
170
+ // 图形界面里的普通安装也要能兜住:版本检查失败时替它把包装好。
171
+
172
+ pluginCtx.effect(() => {
173
+ diagnostics.scheduleWrite();
174
+ let cancelled = false;
175
+ const cancelFirst = later(() => {
176
+ versionRepair.run('startup', pluginCtx.pluginManager).catch(() => {});
177
+ }, STARTUP_DELAY);
178
+
179
+ const tick = () => {
180
+ if (cancelled) return;
181
+ versionRepair.run('poll', pluginCtx.pluginManager)
182
+ .then((fixed) => {
183
+ idleScans = fixed.length > 0 ? 0 : Math.min(idleScans + 1, MAX_BACKOFF_STEPS);
184
+ })
185
+ .catch(() => {})
186
+ .finally(() => {
187
+ if (cancelled) return;
188
+ handle = setTimeout(tick, Math.min(BASE_SCAN_INTERVAL * 2 ** idleScans, MAX_SCAN_INTERVAL));
189
+ handle.unref?.();
190
+ });
191
+ };
192
+ handle = setTimeout(tick, BASE_SCAN_INTERVAL);
193
+ handle.unref?.();
194
+
195
+ return () => {
196
+ cancelled = true;
197
+ cancelFirst();
198
+ if (handle !== undefined) clearTimeout(handle);
199
+ };
200
+ }, 'request-doctor: background version scan');
201
+ });
202
+
203
+ return {
204
+ stop: () => {
205
+ if (handle !== undefined) clearTimeout(handle);
206
+ },
207
+ };
208
+ }
209
+
210
+ // 注册 /doctor 命令。
211
+ function registerDoctorCommand(ctx, deps) {
212
+ ctx.inject(['commands'], (commandCtx) => {
213
+ commandCtx.effect(
214
+ () =>
215
+ commandCtx.commands.register({
216
+ name: 'doctor',
217
+ description: 'Show request-doctor repair activity, repair version mismatches now, or prepare a plugin for installation',
218
+ input: { hint: 'install <path-or-package> | allow <package@version> | why <package> | list' },
219
+ handler: async (invocation) => runDoctorCommand(invocation, deps),
220
+ }),
221
+ 'request-doctor: /doctor command',
222
+ );
223
+ });
224
+ }
225
+
226
+ // 汇总当前需要处理的插件:本插件扫描的审计结果,加上管理器的拒装记录,按名去重。
227
+ async function collectPanelRows(deps, manager) {
228
+ const runtimeVersion = deps.probe.runtimeFacts().runtimeVersion;
229
+ const rows = [];
230
+ for (const item of deps.diagnostics?.versionScan?.audited ?? []) {
231
+ if (item.verdict === 'compatible') continue;
232
+ rows.push({ name: item.name, version: item.version, runtimeVersion, source: 'audit' });
233
+ }
234
+ if (manager !== undefined) {
235
+ try {
236
+ for (const row of refusedRowsOf(await manager.listBundles())) rows.push({ ...row, source: 'manager' });
237
+ } catch {
238
+ // 管理器读不动就只报审计结果,页面仍然有内容。
239
+ }
240
+ }
241
+ return dedupeRows(rows);
242
+ }
243
+
244
+ // 同一个插件可能既在本插件审计里、也在管理器的拒装清单里。
245
+ // 管理器那条是「现在真的装不上」,信息更强,所以优先保留它。
246
+ function dedupeRows(rows) {
247
+ const byKey = new Map();
248
+ for (const row of rows) {
249
+ const key = `${row.name}@${row.version}`;
250
+ const existing = byKey.get(key);
251
+ if (existing === undefined || (existing.source !== 'manager' && row.source === 'manager')) byKey.set(key, row);
252
+ }
253
+ return [...byKey.values()];
254
+ }
255
+
256
+ // 运行时事实读一次就够,三处都要它,取法统一到一处。
257
+ function factsOf(deps) {
258
+ return deps.probe.runtimeFacts();
259
+ }
260
+
261
+ // 把命令结果翻成路由响应:kind 决定成败,text 原样带回。
262
+ function replyResult(json, res, result) {
263
+ return json(res, 200, { ok: result.kind === 'success', text: result.text });
264
+ }
265
+
266
+ // 界面要的一份数据:待处理的行,加上「修好了但需要重启」的提示。
267
+ async function panelPayload(deps, manager) {
268
+ const box = deps.diagnostics?.diagnostics ?? {};
269
+ const rows = await collectPanelRows(deps, manager);
270
+ const refused = rows.filter((row) => row.source === 'manager');
271
+ return {
272
+ ok: true,
273
+ rows,
274
+ restart: box.restartRequired ?? [],
275
+ briefs: panelBriefs(box, rows.map((row) => row.name)),
276
+ // 审计说兼容、管理器仍拒装:界面要在这类行上给出授权建议。
277
+ conflicts: blockedButCompatible(box.versionScan?.audited ?? [], refused),
278
+ };
279
+ }
280
+ // 界面用的 HTTP 路由。
281
+ //
282
+ // 客户端半拿不到 typert 的 remote 门面(动态模块的上下文里没有那个服务),
283
+ // 但它和页面同源,可以直接 fetch。插件管理器自己的界面同样走 webServer 路由,
284
+ // 这条路不依赖任何远程服务,是这里最稳的一条。
285
+ function registerPanelRoutes(ctx, deps) {
286
+
287
+ ctx.inject(['webServer'], (webCtx) => {
288
+ const json = (res, status, payload) => {
289
+ res.writeHead(status, { 'content-type': 'application/json' });
290
+ res.end(JSON.stringify(payload));
291
+ };
292
+
293
+ // 列出当前被拒装的插件:读管理器的清单,把带 incompatible 的行挑出来。
294
+ const listRoute = {
295
+ kind: 'exact',
296
+ path: '/doctor/incompatible',
297
+ handler: async (_req, res) => {
298
+ const manager = deps.pluginManager ?? ctx.get('pluginManager');
299
+ return json(res, 200, await panelPayload(deps, manager));
300
+ },
301
+ };
302
+
303
+ // 装一个包:请求体里的 spec 交给和 /doctor install 完全相同的实现。
304
+ const installRoute = {
305
+ kind: 'exact',
306
+ path: '/doctor/install',
307
+ handler: (req, res) => {
308
+ readSpec(req).then(async (spec) => {
309
+ if (spec === undefined) return json(res, 400, { ok: false, text: '请求体不是合法的 JSON' });
310
+ if (spec === '') return json(res, 400, { ok: false, text: 'spec 不能为空' });
311
+ const manager = deps.pluginManager ?? ctx.get('pluginManager');
312
+ const result = await prepareInstallCommand(spec, { ...deps, pluginManager: manager });
313
+ return replyResult(json, res, result);
314
+ });
315
+ },
316
+ };
317
+
318
+ ctx.effect(() => webCtx.webServer.register(listRoute), 'request-doctor: 界面清单路由');
319
+ // 授权一个版本:界面上的按钮走这里,和 /doctor allow 同一套规则。
320
+ const allowRoute = createAllowRoute({
321
+ json,
322
+ runtimeVersion: deps.probe.runtimeFacts().runtimeVersion,
323
+ manager: deps.pluginManager ?? ctx.get('pluginManager'),
324
+ record: deps.record,
325
+ });
326
+
327
+ // 让界面能主动触发一轮审计:装完东西之后不用等下一次定时扫描。
328
+ const rescanRoute = {
329
+ kind: 'exact',
330
+ path: '/doctor/rescan',
331
+ handler: async (_req, res) => {
332
+ try {
333
+ await deps.versionRepair.run('manual');
334
+ return json(res, 200, { ok: true });
335
+ } catch (error) {
336
+ return json(res, 200, { ok: false, reason: String(error) });
337
+ }
338
+ },
339
+ };
340
+
341
+ ctx.effect(() => webCtx.webServer.register(installRoute), 'request-doctor: 界面安装路由');
342
+ ctx.effect(() => webCtx.webServer.register(rescanRoute), 'request-doctor: 重扫路由');
343
+ ctx.effect(() => webCtx.webServer.register(allowRoute), 'request-doctor: 授权路由');
344
+ });
345
+ }
346
+
347
+ // 处理一次 /doctor 调用。
348
+ async function runDoctorCommand(invocation, deps) {
349
+ const raw = String(invocation?.rawInput ?? '').trim();
350
+ const [sub, ...rest] = raw.split(/\s+/).filter((part) => part !== '');
351
+
352
+ // install 先把本地插件准备好,让管理器自己的版本检查按它的规则通过,
353
+ // 而不是装完之后又被回滚掉。
354
+ if (sub === 'install') return prepareInstallCommand(rest.join(' '), deps);
355
+ // allow 只授权、不安装:给那些想继续在图形界面里点安装的人用。
356
+ if (sub === 'allow') return allowVersionCommand(rest.join(' '), deps);
357
+ // why 回答「这个插件为什么被判为不可兼容」,答案就在最近一轮审计里。
358
+ // list 列出所有审计过的插件,先看全貌再用 why 追问。
359
+ if (sub === 'list') return listAudited({ ...deps, refusedRows: await readRefused(deps.probe?.get?.('pluginManager')) });
360
+ if (sub === 'why') return explainVerdict(rest.join(' '), deps);
361
+ return summarizeRepairs(deps);
362
+ }
363
+
364
+
365
+
366
+ async function prepareInstallCommand(spec, deps) {
367
+ if (spec === '') return { kind: 'error', text: 'usage: /doctor install <目录路径或包名> — 两者都行' };
368
+
369
+ // 注册表里的包:管理器自己装不上,那就我们自己装。
370
+ const profileDir = deps.probe.runtimeFacts().profileDir ?? '';
371
+ if (classifySpec(spec, profileDir).kind === 'remote') return installFromRegistry(spec, deps);
372
+
373
+ const facts = factsOf(deps);
374
+ const resolveDir = deps.probe.runtimeDirOf(facts);
375
+ const prepared = prepareForInstall({
376
+ spec,
377
+ profileDir: facts.profileDir ?? '',
378
+ runtimeVersion: facts.runtimeVersion ?? '',
379
+ resolveDir,
380
+ factsOf: (dir, entry) => deps.probe.factsOf(dir, entry, facts.runtimeVersion),
381
+ entryOf: entryPathOf,
382
+ unmetPeers,
383
+ });
384
+ if (!prepared.prepared) {
385
+ return { kind: 'error', text: `not prepared: ${prepared.reason ?? 'unknown reason'}` };
386
+ }
387
+ const built = prepared.packages.join(', ') || 'environment deferred to the next scan';
388
+ deps.record('install-prepare', `${spec} prepared with ${built}`);
389
+
390
+ // 顺带报告每个依赖能不能解析到。装不上的时候,第一件要知道的就是这个。
391
+ const lines = [];
392
+ for (const name of Object.keys(prepared.requirements ?? {})) {
393
+ const dir = resolveDir(name);
394
+ lines.push(`${name}: ${dir === undefined ? '解析不到' : dir}`);
395
+ }
396
+ const detail = lines.length === 0 ? '' : `\n\n依赖解析情况:\n${lines.join('\n')}`;
397
+ return {
398
+ kind: 'success',
399
+ text: `prepared ${spec} for this runtime (${built}).\nNow install it normally; the manager's compatibility check will pass.${detail}`,
400
+ };
401
+ }
402
+
403
+ // 提前授权一个「当前运行时本来不让装」的插件版本。
404
+ //
405
+ // 图形界面里的安装走的是管理器自己的流程,插件侧拦不住。那条路要过版本检查,
406
+ // 而检查只认显式授权。所以给用户一个在点安装之前把这件事做掉的入口:
407
+ // 先 /doctor allow,再在界面上装,装完扫描会自动把声明对齐、环境建好。
408
+ async function allowVersionCommand(spec, deps) {
409
+ return allowVersion(spec, {
410
+ runtimeVersion: facts.runtimeVersion,
411
+ manager: deps.pluginManager,
412
+ record: deps.record,
413
+ });
414
+ }
415
+
416
+ // 自己装一个注册表里的包。
417
+ //
418
+ // 管理器的安装路径是 pnpm 加一次版本检查。远程包在检查之前就已经落地了,
419
+ // 没法像本地目录那样提前把 manifest 改好,所以它对不兼容的包永远装不上。
420
+ // 这里把 pnpm add 该做的事自己做一遍:取包、取依赖、对齐声明、建环境、登记、重载。
421
+ async function installFromRegistry(spec, deps) {
422
+ const facts = factsOf(deps);
423
+ if ((facts.profileDir ?? '') === '') return { kind: 'error', text: '拿不到 profile 目录,无法安装' };
424
+
425
+ // 先授权版本豁免再装:管理器的检查按它自己的规则通过,装完我们再补上兼容形态。
426
+ const result = await installWithExemption({
427
+ spec,
428
+ probe: deps.probe,
429
+ pluginManager: deps.pluginManager,
430
+ runtimeVersion: facts.runtimeVersion ?? '',
431
+ factsOf: (dir, entry) => deps.probe.factsOf(dir, entry, facts.runtimeVersion),
432
+ entryOf: entryPathOf,
433
+ record: (kind, message) => deps.record(kind, message),
434
+ });
435
+ if (!result.installed) return { kind: 'error', text: `装不上 ${spec}:${result.reason ?? '未知原因'}` };
436
+
437
+ const summary = [
438
+ `已安装 ${result.name}@${result.version}`,
439
+ `位置:${result.path}`,
440
+ `兼容环境:${result.environment}`,
441
+ ];
442
+ return { kind: 'success', text: summary.join('\n') };
443
+ }
444
+
445
+ // 现在修一遍,顺带把之前修过什么报出来。
446
+ async function summarizeRepairs(deps) {
447
+ if (deps.config.autoVersionRepair) await deps.versionRepair.run('manual');
448
+ const { stats } = deps;
449
+ const lines = [
450
+ `request-doctor: ${stats.modelRepairs} model, ${stats.toolRepairs} tool, ${stats.connectionRepairs} carrier, ${stats.versionRepairs} version repair(s)`,
451
+ `budget: ${deps.config.maxRetries} model retries per step, ${deps.config.maxToolRetries} tool replays`,
452
+
453
+ 'usage: /doctor install <path> — prepare a local plugin so it can be installed',
454
+ ];
455
+ for (const entry of stats.last.slice(-8)) lines.push(`- ${entry.at} ${entry.kind}: ${entry.detail}`);
456
+
457
+ // 把这一轮审计的结论也带上。插件没被适配时,原因就在这里面。
458
+ const scan = deps.diagnostics.diagnostics.versionScan;
459
+ if (scan?.audited !== undefined && scan.audited.length > 0) {
460
+ lines.push('', '最近一轮版本审计:');
461
+ for (const item of scan.audited) {
462
+ lines.push(`- ${item.name}@${item.version}: ${item.verdict}`);
463
+ for (const reason of (item.reasons ?? []).slice(-2)) lines.push(` ${reason}`);
464
+ if ((item.missing ?? []).length > 0) lines.push(` 缺符号:${item.missing.join(', ')}`);
465
+ if (item.environment !== undefined) lines.push(` 环境:${item.environment}`);
466
+ }
467
+ } else if (scan?.error !== undefined) {
468
+ lines.push('', `最近一轮扫描出错:${scan.error}`);
469
+ }
470
+
471
+ // 有插件已经修好但必须重启才生效:说清楚,别让人以为修复失败。
472
+ const waiting = deps.diagnostics.diagnostics.restartRequired ?? [];
473
+ if (waiting.length > 0) {
474
+ lines.push('', '需要重启后生效:');
475
+ for (const item of waiting) lines.push(`- ${item.hint}`);
476
+ }
477
+ return { kind: 'success', text: lines.join('\n') };
478
+ }
@@ -0,0 +1,39 @@
1
+ // 界面上的「授权」路由。
2
+ //
3
+ // 和 /doctor allow 走同一套规则,只是入口不同:界面上点按钮不该被要求去敲命令。
4
+ import { allowVersion } from './allow-version.js';
5
+
6
+ // 两条写路由共用同一句报错,省得各写各的措辞。
7
+ export const BAD_BODY = { ok: false, text: '请求体不是合法的 JSON' };
8
+
9
+ // 读请求体并把 spec 取出来:写路由都要这一段。
10
+ // 请求体坏了返回 undefined,空 spec 返回空串,两种情况调用方分开处理。
11
+ export function readSpec(req) {
12
+ return new Promise((resolve) => {
13
+ const chunks = [];
14
+ req.on('data', (chunk) => chunks.push(chunk));
15
+ req.on('end', () => {
16
+ try {
17
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
18
+ resolve(String(body.spec ?? '').trim());
19
+ } catch {
20
+ resolve(undefined);
21
+ }
22
+ });
23
+ });
24
+ }
25
+
26
+ // 造一条授权路由。json 由调用方给,它负责响应头这些细节。
27
+ export function createAllowRoute(input) {
28
+ return {
29
+ kind: 'exact',
30
+ path: '/doctor/allow',
31
+ handler: (req, res) => {
32
+ readSpec(req).then(async (spec) => {
33
+ if (spec === undefined) return input.json(res, 400, BAD_BODY);
34
+ const result = await allowVersion(spec, input);
35
+ return input.json(res, 200, { ok: result.kind === 'success', text: result.text });
36
+ });
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,38 @@
1
+ // 放行一个插件在某个运行时版本上运行。
2
+ //
3
+ // /doctor allow 与界面上的按钮都要做这件事,规则只有一份:
4
+ // 必须写清版本,必须有当前运行时版本和管理器实例。
5
+
6
+ // @param {string} spec - 形如 包名@版本。
7
+ // @param {object} input - { runtimeVersion, manager, record }。
8
+ // @returns {Promise<{kind: string, text: string}>} 命令结果。
9
+ export async function allowVersion(spec, input) {
10
+ const trimmed = String(spec ?? '').trim();
11
+ if (trimmed === '') return { kind: 'error', text: '用法:/doctor allow <包名@版本>' };
12
+ if (trimmed.includes('@', 1) === false) return { kind: 'error', text: '要写清楚版本,例如 /doctor allow dsh-ssh-ops@0.4.0' };
13
+
14
+ const runtimeVersion = input.runtimeVersion ?? '';
15
+ if (runtimeVersion === '' || input.manager === undefined) return { kind: 'error', text: '拿不到当前 DSH 版本或管理器不可用,无法授权' };
16
+
17
+ try {
18
+ await input.manager.setVersionExemption(trimmed, runtimeVersion, true, true);
19
+ } catch (error) {
20
+ return { kind: 'error', text: `授权失败:${String(error)}` };
21
+ }
22
+ input.record?.('version-allowed', `已放行 ${trimmed} 在 ${runtimeVersion} 上运行`);
23
+
24
+ return {
25
+ kind: 'success',
26
+ text: [
27
+ `已授权 ${trimmed} 运行在 ${runtimeVersion}`,
28
+ '现在可以在插件面板里正常安装它了。',
29
+ '装完之后,声明对齐和兼容环境会自动补上。',
30
+ ].join('\n'),
31
+ };
32
+ }
33
+ // 没写版本时给一条能直接照抄的例子,省得去翻文档。
34
+ export function allowHint(name) {
35
+ const base = String(name ?? '').trim();
36
+ if (base === '') return '用法:/doctor allow <包名@版本>';
37
+ return `要在插件页安装它,先执行:/doctor allow ${base}@<版本>`;
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luolingp/request-doctor",
3
- "version": "0.13.3",
3
+ "version": "0.14.0",
4
4
  "description": "自动修复客户端引起的请求失败,并让版本不匹配的 DSH 插件能装上、能跑起来。",
5
5
  "keywords": [
6
6
  "dsh-plugin",