@luolingp/request-doctor 0.0.0-stage → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +184 -3
- package/client.js +174 -0
- package/cordis.patch.yml +16 -0
- package/docs/00-/346/226/207/346/241/243/347/264/242/345/274/225.md +26 -0
- package/docs/01-/350/275/257/344/273/266/351/234/200/346/261/202/350/247/204/346/240/274/350/257/264/346/230/216.md +191 -0
- package/docs/02-/350/275/257/344/273/266/350/256/276/350/256/241/350/257/264/346/230/216.md +391 -0
- package/docs/03-/350/275/257/344/273/266/346/265/213/350/257/225/346/212/245/345/221/212.md +212 -0
- package/docs/04-/345/256/211/350/243/205/351/203/250/347/275/262/350/257/264/346/230/216.md +176 -0
- package/docs/05-/347/224/250/346/210/267/346/211/213/345/206/214.md +181 -0
- package/docs/06-/350/275/257/344/273/266/350/264/250/351/207/217/345/272/246/351/207/217/346/212/245/345/221/212.md +109 -0
- package/docs/quality-report.json +27 -0
- package/icon.svg +12 -0
- package/index.js +464 -0
- package/lib/arbitrate.js +45 -0
- package/lib/cache-store.js +79 -0
- package/lib/classify.js +389 -0
- package/lib/compat-env.js +490 -0
- package/lib/compat-repair.js +196 -0
- package/lib/compat.js +332 -0
- package/lib/diagnostics.js +77 -0
- package/lib/env-verify.js +160 -0
- package/lib/install-bundle.js +199 -0
- package/lib/install-rescue.js +161 -0
- package/lib/install-support.js +206 -0
- package/lib/model-repair.js +190 -0
- package/lib/package-fetch.js +181 -0
- package/lib/plugin-discovery.js +232 -0
- package/lib/repair-listeners.js +126 -0
- package/lib/retry-ledger.js +67 -0
- package/lib/runtime-probe.js +171 -0
- package/lib/semver.js +159 -0
- package/lib/sleep.js +18 -0
- package/lib/stat-cache.js +67 -0
- package/lib/symbol-alias.js +44 -0
- package/lib/timers.js +39 -0
- package/lib/version-repair.js +495 -0
- package/lib/version-search.js +44 -0
- package/locale/en.json +17 -0
- package/locale/zh.json +17 -0
- package/package.json +51 -3
package/index.js
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
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 { parseRetryConfig } from './lib/classify.js';
|
|
21
|
+
import { collectPackageFacts, entryPathOf } from './lib/compat-repair.js';
|
|
22
|
+
import { unmetPeers } from './lib/compat.js';
|
|
23
|
+
import { prepareForInstall } from './lib/install-support.js';
|
|
24
|
+
import { installRemoteBundle, registerBundle } from './lib/install-bundle.js';
|
|
25
|
+
import { installWithExemption } from './lib/install-rescue.js';
|
|
26
|
+
import { createDiagnostics } from './lib/diagnostics.js';
|
|
27
|
+
import { createRetryLedger } from './lib/retry-ledger.js';
|
|
28
|
+
import { createRuntimeProbe } from './lib/runtime-probe.js';
|
|
29
|
+
import { createVersionRepair } from './lib/version-repair.js';
|
|
30
|
+
import { createRepairListeners } from './lib/repair-listeners.js';
|
|
31
|
+
import { createTimers } from './lib/timers.js';
|
|
32
|
+
|
|
33
|
+
export const name = 'request-doctor';
|
|
34
|
+
|
|
35
|
+
// 后台扫描的基础间隔
|
|
36
|
+
const BASE_SCAN_INTERVAL = 120000;
|
|
37
|
+
|
|
38
|
+
// 一切正常时退避到这个上限
|
|
39
|
+
const MAX_SCAN_INTERVAL = 1800000;
|
|
40
|
+
|
|
41
|
+
// 等 HMR 激活完再扫
|
|
42
|
+
const STARTUP_DELAY = 1500;
|
|
43
|
+
const EVENT_DELAY = 2500;
|
|
44
|
+
|
|
45
|
+
// 后台扫描最多退避几档
|
|
46
|
+
const MAX_BACKOFF_STEPS = 5;
|
|
47
|
+
|
|
48
|
+
// 遇到 HMR 嵌套错误时最多再试几次
|
|
49
|
+
const MAX_HMR_RETRIES = 6;
|
|
50
|
+
|
|
51
|
+
// 把插件装起来。
|
|
52
|
+
export function apply(ctx, rawConfig) {
|
|
53
|
+
const config = parseRetryConfig(rawConfig);
|
|
54
|
+
if (!config.autoRepair) {
|
|
55
|
+
ctx.logger.info('request-doctor: autoRepair is disabled; no repairs will run');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const lifetime = new AbortController();
|
|
60
|
+
const timers = new Set();
|
|
61
|
+
const { later } = createTimers(ctx, timers);
|
|
62
|
+
const warn = (message) => ctx.logger.warn(message);
|
|
63
|
+
const policy = () => config;
|
|
64
|
+
|
|
65
|
+
const diagnostics = createDiagnostics({
|
|
66
|
+
statusFile: typeof rawConfig?.statusFile === 'string' ? rawConfig.statusFile : '',
|
|
67
|
+
log: (level, message) => ctx.logger[level]?.(message),
|
|
68
|
+
describe: () => ({ ...config, retryableToolPattern: String(config.retryableToolPattern) }),
|
|
69
|
+
trackTimer: (timer) => timers.add(timer),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const probe = createRuntimeProbe({ ctx, collectFacts: collectPackageFacts });
|
|
73
|
+
const ledger = createRetryLedger({ config: policy, warn });
|
|
74
|
+
const versionRepair = createVersionRepair({
|
|
75
|
+
config: policy,
|
|
76
|
+
probe,
|
|
77
|
+
diagnostics,
|
|
78
|
+
warn,
|
|
79
|
+
later,
|
|
80
|
+
maxHmrRetries: MAX_HMR_RETRIES,
|
|
81
|
+
});
|
|
82
|
+
const { onRequestError, onToolExecute, onConnectionRequest } = createRepairListeners({
|
|
83
|
+
config: policy,
|
|
84
|
+
lifetime,
|
|
85
|
+
diagnostics,
|
|
86
|
+
ledger,
|
|
87
|
+
versionRepair,
|
|
88
|
+
services: probe,
|
|
89
|
+
warn,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
ctx.on('agent/request-error', onRequestError);
|
|
93
|
+
ctx.on('tools/execute', onToolExecute);
|
|
94
|
+
// 浏览器载体和 profile 管理器在兄弟上下文里,它们的事件只有全局总线看得见。
|
|
95
|
+
ctx.on('connection/request', onConnectionRequest, { global: true });
|
|
96
|
+
|
|
97
|
+
const commandDeps = { probe, config: policy, diagnostics, versionRepair, stats: diagnostics.stats, record: diagnostics.record };
|
|
98
|
+
const scans = createScanScheduler({ ctx, diagnostics, versionRepair, later, commandDeps, probe });
|
|
99
|
+
registerDoctorCommand(ctx, commandDeps);
|
|
100
|
+
registerPanelRoutes(ctx, commandDeps);
|
|
101
|
+
|
|
102
|
+
ctx.effect(
|
|
103
|
+
() => () => {
|
|
104
|
+
lifetime.abort(new Error('request-doctor disposed'));
|
|
105
|
+
scans.stop();
|
|
106
|
+
for (const timer of timers) clearTimeout(timer);
|
|
107
|
+
timers.clear();
|
|
108
|
+
diagnostics.flush();
|
|
109
|
+
},
|
|
110
|
+
'request-doctor: cancel pending repairs',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 接上事件触发和周期性的版本扫描。
|
|
115
|
+
function createScanScheduler(input) {
|
|
116
|
+
const { ctx, diagnostics, versionRepair, later } = input;
|
|
117
|
+
let idleScans = 0;
|
|
118
|
+
let pendingScan;
|
|
119
|
+
let handle;
|
|
120
|
+
|
|
121
|
+
// 扫描计数,事件扫描和后台轮询共用。
|
|
122
|
+
const resetIdle = () => {
|
|
123
|
+
idleScans = 0;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// 排一次扫描。
|
|
127
|
+
//
|
|
128
|
+
// 连续几个配置重载或插件变更会各排一次扫描,每次都要把整个 profile 重读一遍。
|
|
129
|
+
// 排一个就够了:它跑的时候看到的就是当时的真实状态。
|
|
130
|
+
const schedule = (reason, delayMs = STARTUP_DELAY) => {
|
|
131
|
+
diagnostics.diagnostics.scheduled = (diagnostics.diagnostics.scheduled ?? 0) + 1;
|
|
132
|
+
diagnostics.scheduleWrite();
|
|
133
|
+
if (pendingScan !== undefined) return;
|
|
134
|
+
pendingScan = later(() => {
|
|
135
|
+
pendingScan = undefined;
|
|
136
|
+
diagnostics.diagnostics.fired = (diagnostics.diagnostics.fired ?? 0) + 1;
|
|
137
|
+
diagnostics.scheduleWrite();
|
|
138
|
+
versionRepair.run(reason).catch((error) => {
|
|
139
|
+
diagnostics.diagnostics.scanError = String(error);
|
|
140
|
+
diagnostics.scheduleWrite();
|
|
141
|
+
});
|
|
142
|
+
}, delayMs);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// 观察到变化后,把后台退避复位。
|
|
146
|
+
const onEvent = (counter, reason) => {
|
|
147
|
+
diagnostics.diagnostics[counter] = (diagnostics.diagnostics[counter] ?? 0) + 1;
|
|
148
|
+
resetIdle();
|
|
149
|
+
diagnostics.scheduleWrite();
|
|
150
|
+
schedule(reason, EVENT_DELAY);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
ctx.on('app-boot/config-reload', () => onEvent('configReload', 'config-reload'), { global: true });
|
|
154
|
+
ctx.on('plugin-manager/changed', () => onEvent('pluginManagerChanged', 'plugin-manager-change'), { global: true });
|
|
155
|
+
|
|
156
|
+
// 兜底用的后台扫描,专门对付那些没有任何事件会通知的不匹配——
|
|
157
|
+
// 手动改过 node_modules,或者从备份恢复了一个目录。
|
|
158
|
+
//
|
|
159
|
+
// 一直没发现问题就逐步拉长间隔,正常 profile 几乎不花什么钱;一旦有变化立刻回到基础周期。
|
|
160
|
+
ctx.inject(['pluginManager'], (pluginCtx) => {
|
|
161
|
+
// 安装命令要用它重载刚装好的 bundle,所以放进共享的 deps 里。
|
|
162
|
+
if (input.commandDeps !== undefined) input.commandDeps.pluginManager = pluginCtx.pluginManager;
|
|
163
|
+
|
|
164
|
+
// 图形界面里的普通安装也要能兜住:版本检查失败时替它把包装好。
|
|
165
|
+
|
|
166
|
+
pluginCtx.effect(() => {
|
|
167
|
+
diagnostics.scheduleWrite();
|
|
168
|
+
let cancelled = false;
|
|
169
|
+
const cancelFirst = later(() => {
|
|
170
|
+
versionRepair.run('startup', pluginCtx.pluginManager).catch(() => {});
|
|
171
|
+
}, STARTUP_DELAY);
|
|
172
|
+
|
|
173
|
+
const tick = () => {
|
|
174
|
+
if (cancelled) return;
|
|
175
|
+
versionRepair.run('poll', pluginCtx.pluginManager)
|
|
176
|
+
.then((fixed) => {
|
|
177
|
+
idleScans = fixed.length > 0 ? 0 : Math.min(idleScans + 1, MAX_BACKOFF_STEPS);
|
|
178
|
+
})
|
|
179
|
+
.catch(() => {})
|
|
180
|
+
.finally(() => {
|
|
181
|
+
if (cancelled) return;
|
|
182
|
+
handle = setTimeout(tick, Math.min(BASE_SCAN_INTERVAL * 2 ** idleScans, MAX_SCAN_INTERVAL));
|
|
183
|
+
handle.unref?.();
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
handle = setTimeout(tick, BASE_SCAN_INTERVAL);
|
|
187
|
+
handle.unref?.();
|
|
188
|
+
|
|
189
|
+
return () => {
|
|
190
|
+
cancelled = true;
|
|
191
|
+
cancelFirst();
|
|
192
|
+
if (handle !== undefined) clearTimeout(handle);
|
|
193
|
+
};
|
|
194
|
+
}, 'request-doctor: background version scan');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
stop: () => {
|
|
199
|
+
if (handle !== undefined) clearTimeout(handle);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 注册 /doctor 命令。
|
|
205
|
+
function registerDoctorCommand(ctx, deps) {
|
|
206
|
+
ctx.inject(['commands'], (commandCtx) => {
|
|
207
|
+
commandCtx.effect(
|
|
208
|
+
() =>
|
|
209
|
+
commandCtx.commands.register({
|
|
210
|
+
name: 'doctor',
|
|
211
|
+
description: 'Show request-doctor repair activity, repair version mismatches now, or prepare a plugin for installation',
|
|
212
|
+
input: { hint: 'install <path-or-package> | allow <package@version>' },
|
|
213
|
+
handler: async (invocation) => runDoctorCommand(invocation, deps),
|
|
214
|
+
}),
|
|
215
|
+
'request-doctor: /doctor command',
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 一行清单里被拒的那个包;没有就返回 undefined。
|
|
221
|
+
function refusedRowOf(row) {
|
|
222
|
+
const first = row?.error?.incompatible?.[0];
|
|
223
|
+
if (first === undefined) return undefined;
|
|
224
|
+
return { name: first.name, version: first.version, runtimeVersion: first.runtimeVersion };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 从管理器给的清单里挑出被拒装的包:只有带 incompatible 的行才算。
|
|
228
|
+
function refusedRowsOf(result) {
|
|
229
|
+
return (result?.value ?? []).map(refusedRowOf).filter((row) => row !== undefined);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 汇总当前需要处理的插件:本插件扫描的审计结果,加上管理器的拒装记录,按名去重。
|
|
233
|
+
async function collectPanelRows(deps, manager) {
|
|
234
|
+
const rows = [];
|
|
235
|
+
for (const item of deps.diagnostics?.versionScan?.audited ?? []) {
|
|
236
|
+
if (item.verdict === 'compatible') continue;
|
|
237
|
+
rows.push({ name: item.name, version: item.version, runtimeVersion: deps.probe.runtimeFacts().runtimeVersion });
|
|
238
|
+
}
|
|
239
|
+
if (manager !== undefined) {
|
|
240
|
+
try {
|
|
241
|
+
rows.push(...refusedRowsOf(await manager.listBundles()));
|
|
242
|
+
} catch {
|
|
243
|
+
// 管理器读不动就只报审计结果,页面仍然有内容。
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const seen = new Set();
|
|
247
|
+
return rows.filter((row) => {
|
|
248
|
+
const key = `${row.name}@${row.version}`;
|
|
249
|
+
if (seen.has(key)) return false;
|
|
250
|
+
seen.add(key);
|
|
251
|
+
return true;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 界面用的 HTTP 路由。
|
|
256
|
+
//
|
|
257
|
+
// 客户端半拿不到 typert 的 remote 门面(动态模块的上下文里没有那个服务),
|
|
258
|
+
// 但它和页面同源,可以直接 fetch。插件管理器自己的界面同样走 webServer 路由,
|
|
259
|
+
// 这条路不依赖任何远程服务,是这里最稳的一条。
|
|
260
|
+
function registerPanelRoutes(ctx, deps) {
|
|
261
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
262
|
+
const json = (res, status, payload) => {
|
|
263
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
264
|
+
res.end(JSON.stringify(payload));
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// 列出当前被拒装的插件:读管理器的清单,把带 incompatible 的行挑出来。
|
|
268
|
+
const listRoute = {
|
|
269
|
+
kind: 'exact',
|
|
270
|
+
path: '/doctor/incompatible',
|
|
271
|
+
handler: async (_req, res) => {
|
|
272
|
+
const manager = deps.pluginManager ?? ctx.get('pluginManager');
|
|
273
|
+
return json(res, 200, { ok: true, rows: await collectPanelRows(deps, manager) });
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// 装一个包:请求体里的 spec 交给和 /doctor install 完全相同的实现。
|
|
278
|
+
const installRoute = {
|
|
279
|
+
kind: 'exact',
|
|
280
|
+
path: '/doctor/install',
|
|
281
|
+
handler: (req, res) => {
|
|
282
|
+
const chunks = [];
|
|
283
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
284
|
+
req.on('end', async () => {
|
|
285
|
+
let spec = '';
|
|
286
|
+
try {
|
|
287
|
+
spec = String(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}').spec ?? '').trim();
|
|
288
|
+
} catch {
|
|
289
|
+
return json(res, 400, { ok: false, text: '请求体不是合法的 JSON' });
|
|
290
|
+
}
|
|
291
|
+
if (spec === '') return json(res, 400, { ok: false, text: 'spec 不能为空' });
|
|
292
|
+
const manager = deps.pluginManager ?? ctx.get('pluginManager');
|
|
293
|
+
if (manager === undefined) return json(res, 200, { ok: false, text: '拿不到插件管理器' });
|
|
294
|
+
try {
|
|
295
|
+
const result = await installFromRegistry(spec, { ...deps, pluginManager: manager });
|
|
296
|
+
return json(res, 200, { ok: result.kind === 'success', text: result.text });
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return json(res, 200, { ok: false, text: String(error) });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
ctx.effect(() => webCtx.webServer.register(listRoute), 'request-doctor: 界面清单路由');
|
|
305
|
+
// 让界面能主动触发一轮审计:装完东西之后不用等下一次定时扫描。
|
|
306
|
+
const rescanRoute = {
|
|
307
|
+
kind: 'exact',
|
|
308
|
+
path: '/doctor/rescan',
|
|
309
|
+
handler: async (_req, res) => {
|
|
310
|
+
try {
|
|
311
|
+
await deps.versionRepair.run('manual');
|
|
312
|
+
return json(res, 200, { ok: true });
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return json(res, 200, { ok: false, reason: String(error) });
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
ctx.effect(() => webCtx.webServer.register(installRoute), 'request-doctor: 界面安装路由');
|
|
320
|
+
ctx.effect(() => webCtx.webServer.register(rescanRoute), 'request-doctor: 界面重扫路由');
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 处理一次 /doctor 调用。
|
|
325
|
+
async function runDoctorCommand(invocation, deps) {
|
|
326
|
+
const raw = String(invocation?.rawInput ?? '').trim();
|
|
327
|
+
const [sub, ...rest] = raw.split(/\s+/).filter((part) => part !== '');
|
|
328
|
+
|
|
329
|
+
// install 先把本地插件准备好,让管理器自己的版本检查按它的规则通过,
|
|
330
|
+
// 而不是装完之后又被回滚掉。
|
|
331
|
+
if (sub === 'install') return prepareInstallCommand(rest.join(' '), deps);
|
|
332
|
+
// allow 只授权、不安装:给那些想继续在图形界面里点安装的人用。
|
|
333
|
+
if (sub === 'allow') return allowVersionCommand(rest.join(' '), deps);
|
|
334
|
+
return summarizeRepairs(deps);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 把一个本地插件目录准备好,让它能被装上。
|
|
338
|
+
async function prepareInstallCommand(spec, deps) {
|
|
339
|
+
if (spec === '') return { kind: 'error', text: 'usage: /doctor install <目录路径或包名> — 两者都行' };
|
|
340
|
+
|
|
341
|
+
// 注册表里的包:管理器自己装不上,那就我们自己装。
|
|
342
|
+
const profileDir = deps.probe.runtimeFacts().profileDir ?? '';
|
|
343
|
+
if (classifySpec(spec, profileDir).kind === 'remote') return installFromRegistry(spec, deps);
|
|
344
|
+
|
|
345
|
+
const facts = deps.probe.runtimeFacts();
|
|
346
|
+
const resolveDir = deps.probe.runtimeDirOf(facts);
|
|
347
|
+
const prepared = prepareForInstall({
|
|
348
|
+
spec,
|
|
349
|
+
profileDir: facts.profileDir ?? '',
|
|
350
|
+
runtimeVersion: facts.runtimeVersion ?? '',
|
|
351
|
+
resolveDir,
|
|
352
|
+
factsOf: (dir, entry) => deps.probe.factsOf(dir, entry, facts.runtimeVersion),
|
|
353
|
+
entryOf: entryPathOf,
|
|
354
|
+
unmetPeers,
|
|
355
|
+
});
|
|
356
|
+
if (!prepared.prepared) {
|
|
357
|
+
return { kind: 'error', text: `not prepared: ${prepared.reason ?? 'unknown reason'}` };
|
|
358
|
+
}
|
|
359
|
+
const built = prepared.packages.join(', ') || 'environment deferred to the next scan';
|
|
360
|
+
deps.record('install-prepare', `${spec} prepared with ${built}`);
|
|
361
|
+
|
|
362
|
+
// 顺带报告每个依赖能不能解析到。装不上的时候,第一件要知道的就是这个。
|
|
363
|
+
const lines = [];
|
|
364
|
+
for (const name of Object.keys(prepared.requirements ?? {})) {
|
|
365
|
+
const dir = resolveDir(name);
|
|
366
|
+
lines.push(`${name}: ${dir === undefined ? '解析不到' : dir}`);
|
|
367
|
+
}
|
|
368
|
+
const detail = lines.length === 0 ? '' : `\n\n依赖解析情况:\n${lines.join('\n')}`;
|
|
369
|
+
return {
|
|
370
|
+
kind: 'success',
|
|
371
|
+
text: `prepared ${spec} for this runtime (${built}).\nNow install it normally; the manager's compatibility check will pass.${detail}`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 提前授权一个「当前运行时本来不让装」的插件版本。
|
|
376
|
+
//
|
|
377
|
+
// 图形界面里的安装走的是管理器自己的流程,插件侧拦不住。那条路要过版本检查,
|
|
378
|
+
// 而检查只认显式授权。所以给用户一个在点安装之前把这件事做掉的入口:
|
|
379
|
+
// 先 /doctor allow,再在界面上装,装完扫描会自动把声明对齐、环境建好。
|
|
380
|
+
async function allowVersionCommand(spec, deps) {
|
|
381
|
+
const trimmed = String(spec ?? '').trim();
|
|
382
|
+
if (trimmed === '') return { kind: 'error', text: '用法:/doctor allow <包名@版本>' };
|
|
383
|
+
if (trimmed.includes('@', 1) === false) {
|
|
384
|
+
return { kind: 'error', text: '要写清楚版本,例如 /doctor allow dsh-ssh-ops@0.4.0' };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 授权必须有明确的运行时版本和管理器实例,缺一个都做不了。
|
|
388
|
+
const runtimeVersion = deps.probe.runtimeFacts().runtimeVersion ?? '';
|
|
389
|
+
const ready = runtimeVersion !== '' && deps.pluginManager !== undefined;
|
|
390
|
+
if (ready === false) return { kind: 'error', text: '拿不到当前 DSH 版本或管理器不可用,无法授权' };
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
await deps.pluginManager.setVersionExemption(trimmed, runtimeVersion, true, true);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
return { kind: 'error', text: `授权失败:${String(error)}` };
|
|
396
|
+
}
|
|
397
|
+
const note = `已放行 ${trimmed} 在 ${runtimeVersion} 上运行`;
|
|
398
|
+
deps.record('version-allowed', note);
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
kind: 'success',
|
|
402
|
+
text: [
|
|
403
|
+
`已授权 ${trimmed} 运行在 ${runtimeVersion}`,
|
|
404
|
+
'现在可以在插件面板里正常安装它了。',
|
|
405
|
+
'装完之后,声明对齐和兼容环境会自动补上。',
|
|
406
|
+
].join('\n'),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// 自己装一个注册表里的包。
|
|
411
|
+
//
|
|
412
|
+
// 管理器的安装路径是 pnpm 加一次版本检查。远程包在检查之前就已经落地了,
|
|
413
|
+
// 没法像本地目录那样提前把 manifest 改好,所以它对不兼容的包永远装不上。
|
|
414
|
+
// 这里把 pnpm add 该做的事自己做一遍:取包、取依赖、对齐声明、建环境、登记、重载。
|
|
415
|
+
async function installFromRegistry(spec, deps) {
|
|
416
|
+
const facts = deps.probe.runtimeFacts();
|
|
417
|
+
if ((facts.profileDir ?? '') === '') return { kind: 'error', text: '拿不到 profile 目录,无法安装' };
|
|
418
|
+
|
|
419
|
+
// 先授权版本豁免再装:管理器的检查按它自己的规则通过,装完我们再补上兼容形态。
|
|
420
|
+
const result = await installWithExemption({
|
|
421
|
+
spec,
|
|
422
|
+
probe: deps.probe,
|
|
423
|
+
pluginManager: deps.pluginManager,
|
|
424
|
+
runtimeVersion: facts.runtimeVersion ?? '',
|
|
425
|
+
factsOf: (dir, entry) => deps.probe.factsOf(dir, entry, facts.runtimeVersion),
|
|
426
|
+
entryOf: entryPathOf,
|
|
427
|
+
record: (kind, message) => deps.record(kind, message),
|
|
428
|
+
});
|
|
429
|
+
if (!result.installed) return { kind: 'error', text: `装不上 ${spec}:${result.reason ?? '未知原因'}` };
|
|
430
|
+
|
|
431
|
+
const summary = [
|
|
432
|
+
`已安装 ${result.name}@${result.version}`,
|
|
433
|
+
`位置:${result.path}`,
|
|
434
|
+
`兼容环境:${result.environment}`,
|
|
435
|
+
];
|
|
436
|
+
return { kind: 'success', text: summary.join('\n') };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 现在修一遍,顺带把之前修过什么报出来。
|
|
440
|
+
async function summarizeRepairs(deps) {
|
|
441
|
+
if (deps.config.autoVersionRepair) await deps.versionRepair.run('manual');
|
|
442
|
+
const { stats } = deps;
|
|
443
|
+
const lines = [
|
|
444
|
+
`request-doctor: ${stats.modelRepairs} model, ${stats.toolRepairs} tool, ${stats.connectionRepairs} carrier, ${stats.versionRepairs} version repair(s)`,
|
|
445
|
+
`budget: ${deps.config.maxRetries} model retries per step, ${deps.config.maxToolRetries} tool replays`,
|
|
446
|
+
|
|
447
|
+
'usage: /doctor install <path> — prepare a local plugin so it can be installed',
|
|
448
|
+
];
|
|
449
|
+
for (const entry of stats.last.slice(-8)) lines.push(`- ${entry.at} ${entry.kind}: ${entry.detail}`);
|
|
450
|
+
|
|
451
|
+
// 把这一轮审计的结论也带上。插件没被适配时,原因就在这里面。
|
|
452
|
+
const scan = deps.diagnostics.diagnostics.versionScan;
|
|
453
|
+
if (scan?.audited !== undefined && scan.audited.length > 0) {
|
|
454
|
+
lines.push('', '最近一轮版本审计:');
|
|
455
|
+
for (const item of scan.audited) {
|
|
456
|
+
lines.push(`- ${item.name}@${item.version}: ${item.verdict}`);
|
|
457
|
+
for (const reason of (item.reasons ?? []).slice(-2)) lines.push(` ${reason}`);
|
|
458
|
+
if ((item.missing ?? []).length > 0) lines.push(` 缺符号:${item.missing.join(', ')}`);
|
|
459
|
+
}
|
|
460
|
+
} else if (scan?.error !== undefined) {
|
|
461
|
+
lines.push('', `最近一轮扫描出错:${scan.error}`);
|
|
462
|
+
}
|
|
463
|
+
return { kind: 'success', text: lines.join('\n') };
|
|
464
|
+
}
|
package/lib/arbitrate.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 路由冲突的报告。
|
|
2
|
+
//
|
|
3
|
+
// 两个插件可能声明同一个 LLM provider id。registerAdapter 是"要么全成要么都不成",
|
|
4
|
+
// 第二个来注册的直接抛 DUPLICATE_ADAPTER。这事值得告诉用户,但**不该由本插件替他们
|
|
5
|
+
// 做决定**——该停掉别人的哪个插件,是人家自己的事,不是一个修复工具该操心的。
|
|
6
|
+
//
|
|
7
|
+
// 所以这个模块只描述现状:不停用、不启用、不卸载、不改配置。
|
|
8
|
+
/** 把某个插件声明的路由拆成"别人已经在提供"和"只有它在提供"两组。 */
|
|
9
|
+
export function routeCoverage(providers, roster, selfName) {
|
|
10
|
+
const served = new Set();
|
|
11
|
+
for (const holder of roster ?? []) {
|
|
12
|
+
if (holder.name === selfName) continue;
|
|
13
|
+
for (const provider of holder.providers ?? []) served.add(provider);
|
|
14
|
+
}
|
|
15
|
+
const all = [...new Set(providers ?? [])];
|
|
16
|
+
return {
|
|
17
|
+
covered: all.filter((id) => served.has(id)),
|
|
18
|
+
uncovered: all.filter((id) => !served.has(id)),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// 描述一个插件的路由冲突情况,纯只读。
|
|
23
|
+
//
|
|
24
|
+
// 这个区分对使用者有意义:完全被覆盖的是纯重复品,部分覆盖的还握着独有能力,
|
|
25
|
+
// 两种情况该做什么决定是不一样的。
|
|
26
|
+
export function describeContention(input) {
|
|
27
|
+
const contested = [...new Set(input.conflicts ?? [])];
|
|
28
|
+
if (contested.length === 0) {
|
|
29
|
+
return { contested: [], covered: [], uncovered: [], duplicateOf: [], note: 'no contested route' };
|
|
30
|
+
}
|
|
31
|
+
const { covered, uncovered } = routeCoverage(input.providers, input.roster, input.name);
|
|
32
|
+
const duplicateOf = (input.roster ?? [])
|
|
33
|
+
.filter((holder) => holder.name !== input.name)
|
|
34
|
+
.filter((holder) => (holder.providers ?? []).some((id) => contested.includes(id)))
|
|
35
|
+
.map((holder) => holder.name);
|
|
36
|
+
|
|
37
|
+
// 说明它到底是"纯重复"还是"另有独家能力",这两句话对用户的提示完全不同。
|
|
38
|
+
let note;
|
|
39
|
+
if (uncovered.length === 0) {
|
|
40
|
+
note = `every route it declares is already served by ${duplicateOf.join(', ') || 'another plugin'}; it is a duplicate and needs no exemption to be useful`;
|
|
41
|
+
} else {
|
|
42
|
+
note = `it also owns ${uncovered.join(', ')}, which no other enabled plugin provides`;
|
|
43
|
+
}
|
|
44
|
+
return { contested, covered, uncovered, duplicateOf, note };
|
|
45
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// 兼容环境的下载缓存。
|
|
2
|
+
//
|
|
3
|
+
// 版本搜索会把试过的版本都下载下来。这些包不小,不清理的话这个目录只会越长越大,
|
|
4
|
+
// 所以按最近使用时间留够用的量,多余的删掉——下次真需要还能重新取。
|
|
5
|
+
import { readdirSync, rmSync, statSync, utimesSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
// 记下「刚刚用过」。缓存淘汰按这个时间判断谁是热的。
|
|
9
|
+
export function touchAndReturn(dir) {
|
|
10
|
+
try {
|
|
11
|
+
const now = new Date();
|
|
12
|
+
utimesSync(dir, now, now);
|
|
13
|
+
} catch {
|
|
14
|
+
// 摸不到时间也不影响使用
|
|
15
|
+
}
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 缓存最多留这么多个包。一次闭包最多十六个,留两批足够。
|
|
20
|
+
const MAX_CACHED_PACKAGES = 32;
|
|
21
|
+
|
|
22
|
+
// 目录的修改时间,取不到就当最旧。
|
|
23
|
+
function mtimeOf(dir) {
|
|
24
|
+
try {
|
|
25
|
+
return statSync(dir).mtimeMs;
|
|
26
|
+
} catch {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 淘汰冷缓存。
|
|
32
|
+
//
|
|
33
|
+
// 版本搜索会把试过的版本都下载下来,不清理这个目录只会越长越大。
|
|
34
|
+
// 按最近使用时间留够用的量,多余的删掉——下次真需要还能重新取。
|
|
35
|
+
export function pruneCache(profileDir) {
|
|
36
|
+
if (typeof profileDir !== 'string' || profileDir === '') return 0;
|
|
37
|
+
const base = join(profileDir, '.request-doctor-cache');
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(base, { withFileTypes: true });
|
|
41
|
+
} catch {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const items = [];
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (!entry.isDirectory()) continue;
|
|
48
|
+
const path = join(base, entry.name);
|
|
49
|
+
if (!entry.name.startsWith('@')) {
|
|
50
|
+
items.push({ path, at: mtimeOf(path) });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// scoped 包多一层,真正的缓存项在它下面。
|
|
54
|
+
let inner;
|
|
55
|
+
try {
|
|
56
|
+
inner = readdirSync(path, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const child of inner) {
|
|
61
|
+
if (child.isDirectory()) items.push({ path: join(path, child.name), at: mtimeOf(join(path, child.name)) });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (items.length <= MAX_CACHED_PACKAGES) return 0;
|
|
66
|
+
items.sort((a, b) => a.at - b.at);
|
|
67
|
+
let removed = 0;
|
|
68
|
+
for (const item of items.slice(0, items.length - MAX_CACHED_PACKAGES)) {
|
|
69
|
+
try {
|
|
70
|
+
rmSync(item.path, { recursive: true, force: true });
|
|
71
|
+
removed += 1;
|
|
72
|
+
} catch {
|
|
73
|
+
// 删不掉就留着,下一轮再说
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return removed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|