@luolingp/request-doctor 0.0.0-stage → 0.11.1
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 +192 -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 +214 -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 +197 -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 +471 -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/restart-notice.js +21 -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 +499 -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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// 模型请求的修复。
|
|
2
|
+
//
|
|
3
|
+
// 收到一次失败的模型请求后:判断属于哪一类,按这一类的策略先做本地修复,再决定要不要
|
|
4
|
+
// 花掉一份重试预算。provider 自己声明了重试策略的,优先听它的——只有下游所有监听器都
|
|
5
|
+
// 放弃重试了,这里才接手,免得出现两条重试链。
|
|
6
|
+
//
|
|
7
|
+
// 三类防护都在这层:
|
|
8
|
+
// - 预算:按 turn:step:provider:kind 分组,同一处失败有统一上限;
|
|
9
|
+
// - 并发:同一处同时只允许一个重试在飞,避免重试风暴把本机拖死;
|
|
10
|
+
// - 无进展:压缩/修历史连续两轮都没让问题前进,就不再无限重试。
|
|
11
|
+
import { classifyModelFailure, computeDelay } from './classify.js';
|
|
12
|
+
import { cancellableSleep } from './sleep.js';
|
|
13
|
+
|
|
14
|
+
// 每个策略分到的预算倍数。鉴权类的重试更贵(要换凭据),给的次数更少。
|
|
15
|
+
const STRATEGY_BUDGET = { auth: 'auth', 'history-repair': 'history', compact: 'normal' };
|
|
16
|
+
|
|
17
|
+
export function createModelRepair(input) {
|
|
18
|
+
const { config, lifetime, diagnostics, ledger, versionRepair, services, warn } = input;
|
|
19
|
+
const { record, stats } = diagnostics;
|
|
20
|
+
|
|
21
|
+
// 同一处同时在飞的重试:key 相同就只放一个进去。
|
|
22
|
+
const inFlight = new Set();
|
|
23
|
+
// 压缩/修历史有没有让问题前进:连着两轮没进展就停手。
|
|
24
|
+
const progress = new Map();
|
|
25
|
+
|
|
26
|
+
// 把请求自己的取消信号和插件生命周期绑在一起,插件卸载时正在等的重试能立刻醒。
|
|
27
|
+
const fuse = (signal) => {
|
|
28
|
+
if (signal === undefined) return lifetime.signal;
|
|
29
|
+
try {
|
|
30
|
+
return AbortSignal.any([signal, lifetime.signal]);
|
|
31
|
+
} catch {
|
|
32
|
+
return signal;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// 上下文超长时,重试之前先试着瘦身。
|
|
37
|
+
//
|
|
38
|
+
// 压缩和裁剪工具结果分别由两个可选服务提供,profile 里一个都没有也照样重试,
|
|
39
|
+
// 所以两个都是能成就成。
|
|
40
|
+
async function compactForOverflow(payload) {
|
|
41
|
+
const compaction = services.get('compaction');
|
|
42
|
+
if (compaction !== undefined) {
|
|
43
|
+
try {
|
|
44
|
+
await compaction.compactIfNeeded(payload.agent, 'context-overflow', payload.signal);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
warn(`request-doctor: overflow compaction failed: ${String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
pruneToolResults(payload);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 裁掉过期的工具结果。裁剪器没有就跳过。
|
|
53
|
+
function pruneToolResults(payload) {
|
|
54
|
+
const pruner = services.get('toolResultPruner');
|
|
55
|
+
if (pruner === undefined) return;
|
|
56
|
+
try {
|
|
57
|
+
pruner.pruneSession(payload.agent.session);
|
|
58
|
+
} catch {
|
|
59
|
+
// 裁剪不了就算了
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 本地历史或规则文件坏了:清掉工具结果、让会话重新落盘,能救回来一部分。
|
|
64
|
+
async function repairHistory(payload) {
|
|
65
|
+
pruneToolResults(payload);
|
|
66
|
+
const store = services.get('sessionStore');
|
|
67
|
+
if (store === undefined) return;
|
|
68
|
+
try {
|
|
69
|
+
await store.flush?.(payload.agent.session);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
warn(`request-doctor: history flush failed: ${String(error)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// 请本机某个服务刷新一次。候选名字挨个试,第一个能刷的就够了;
|
|
75
|
+
// 一个都没有也不算错——下游自己的重试同样可能触发刷新。
|
|
76
|
+
async function ask(names, kind, payload) {
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
const provider = services.get(name)
|
|
79
|
+
if (provider === undefined) continue
|
|
80
|
+
try {
|
|
81
|
+
await provider.refresh?.(payload.provider, payload.signal)
|
|
82
|
+
record(kind, `asked ${name} to refresh ${kind} for ${payload.provider}`)
|
|
83
|
+
return
|
|
84
|
+
} catch (error) {
|
|
85
|
+
warn(`request-doctor: ${name} refresh failed: ${String(error)}`)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 凭据类失败:让本机的凭据层有机会刷新一次。
|
|
91
|
+
const refreshCredentials = (payload) => ask(['credentials', 'auth', 'accounts'], 'auth', payload)
|
|
92
|
+
|
|
93
|
+
// 模型选择类失败(模型 ID 写死、能力表判断错、模型列表缓存过期):
|
|
94
|
+
// 让本机的模型目录服务重新拉一次,缓存过期是最常见的成因。
|
|
95
|
+
const refreshModelCatalog = (payload) => ask(['modelCatalog', 'models', 'providerRegistry'], 'model', payload)
|
|
96
|
+
|
|
97
|
+
// 按策略先做本地修复;返回 false 表示这一类的修复没成,重试也没意义。
|
|
98
|
+
async function prepare(decision, payload) {
|
|
99
|
+
if (decision.strategy === 'version-repair') {
|
|
100
|
+
const fixed = await versionRepair.run(`model-${decision.kind}`);
|
|
101
|
+
return fixed.length > 0;
|
|
102
|
+
}
|
|
103
|
+
if (decision.strategy === 'compact') {
|
|
104
|
+
await compactForOverflow(payload);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (decision.strategy === 'history-repair') {
|
|
108
|
+
await repairHistory(payload);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (decision.strategy === 'auth') {
|
|
112
|
+
await refreshCredentials(payload);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
// retry 和 local 都是直接重来,local 的那点延迟由退避自动拉开。
|
|
116
|
+
return decision.strategy === 'retry' || decision.strategy === 'local';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 这一类的次数上限。鉴权类更贵,单独一个更小的额度。
|
|
120
|
+
function budgetFor(decision) {
|
|
121
|
+
const policy = config();
|
|
122
|
+
if (STRATEGY_BUDGET[decision.strategy] === 'auth') return policy.maxAuthRetries;
|
|
123
|
+
if (STRATEGY_BUDGET[decision.strategy] === 'history') return Math.max(1, Math.floor(policy.maxRetries / 3));
|
|
124
|
+
return policy.maxRetries;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 花掉一份重试预算,安排下一次尝试。
|
|
128
|
+
async function scheduleRetry(key, payload, decision, downstream) {
|
|
129
|
+
const entry = ledger.chainFor(payload.agent, key);
|
|
130
|
+
entry.count += 1;
|
|
131
|
+
const limit = budgetFor(decision);
|
|
132
|
+
if (entry.count > limit) {
|
|
133
|
+
stats.modelSkips += 1;
|
|
134
|
+
if (entry.count === limit + 1) record('give-up', `${decision.kind} from ${payload.provider} after ${limit} repairs`);
|
|
135
|
+
return downstream;
|
|
136
|
+
}
|
|
137
|
+
const delayMs = computeDelay(config(), entry.count, payload.failure);
|
|
138
|
+
ledger.recordScheduled(payload.agent, payload, decision.kind, entry, delayMs);
|
|
139
|
+
if (!(await cancellableSleep(delayMs, fuse(payload.signal)))) return downstream;
|
|
140
|
+
ledger.recordStarted(payload.agent, payload, entry);
|
|
141
|
+
stats.modelRepairs += 1;
|
|
142
|
+
record('model', `${decision.kind} from ${payload.provider} attempt ${entry.count} in ${delayMs}ms`);
|
|
143
|
+
return { kind: 'retry' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 处理一次失败的模型请求。
|
|
147
|
+
//
|
|
148
|
+
// 先让下游跑完:只要有人愿意重试,就不再插一脚。
|
|
149
|
+
async function handle(payload, next) {
|
|
150
|
+
const downstream = await next();
|
|
151
|
+
if (downstream?.kind === 'retry') return downstream;
|
|
152
|
+
if (payload.signal?.aborted || lifetime.signal.aborted) return downstream;
|
|
153
|
+
|
|
154
|
+
const decision = classifyModelFailure(payload.failure);
|
|
155
|
+
if (!decision.clientCaused || decision.strategy === 'skip') return downstream;
|
|
156
|
+
|
|
157
|
+
const key = `${payload.turn}:${payload.step}:${payload.provider}:${decision.kind}`;
|
|
158
|
+
// 同一处只放一个重试进去。并发的那几个直接让下游收场,避免重试互相叠加。
|
|
159
|
+
if (inFlight.has(key)) return downstream;
|
|
160
|
+
inFlight.add(key);
|
|
161
|
+
try {
|
|
162
|
+
return await attempt(key, payload, decision, downstream);
|
|
163
|
+
} finally {
|
|
164
|
+
inFlight.delete(key);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 依次过三关:本地修复成不成、还有没有进展、预算还剩不剩。
|
|
169
|
+
async function attempt(key, payload, decision, downstream) {
|
|
170
|
+
if (!(await prepare(decision, payload))) {
|
|
171
|
+
stats.modelSkips += 1;
|
|
172
|
+
return downstream;
|
|
173
|
+
}
|
|
174
|
+
if (recordProgress(key) === false) {
|
|
175
|
+
record('give-up', `${decision.kind} made no progress after two attempts`);
|
|
176
|
+
return downstream;
|
|
177
|
+
}
|
|
178
|
+
return scheduleRetry(key, payload, decision, downstream);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 记一次「这一处又失败了」。同一处连着两轮都失败,说明修复没起作用。
|
|
182
|
+
function recordProgress(key) {
|
|
183
|
+
const count = (progress.get(key) ?? 0) + 1;
|
|
184
|
+
if (count > 2) return false;
|
|
185
|
+
progress.set(key, count);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { handle };
|
|
190
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// 从 npm 取一个指定版本的包,解到本地。
|
|
2
|
+
//
|
|
3
|
+
// 兼容模式要真的把旧实现放回去,而不是假装。插件当初是对着某个版本写的,
|
|
4
|
+
// 那个版本的代码还在仓库里,取下来装上就行——这才是「给它一个能跑的旧环境」。
|
|
5
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
|
+
import { gunzipSync } from 'node:zlib';
|
|
8
|
+
|
|
9
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
10
|
+
const TIMEOUT_MS = 20000;
|
|
11
|
+
|
|
12
|
+
// 元数据留一会儿。一次扫描里同一个包会被问好几遍(取地址、列版本),
|
|
13
|
+
// 每次都打注册表既慢又没礼貌。
|
|
14
|
+
const META_TTL_MS = 10 * 60 * 1000;
|
|
15
|
+
const metaCache = new Map();
|
|
16
|
+
|
|
17
|
+
// 取包的元数据,带缓存。
|
|
18
|
+
async function metadataOf(name) {
|
|
19
|
+
const cached = metaCache.get(name);
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
if (cached !== undefined && now - cached.at < META_TTL_MS) return cached.value;
|
|
22
|
+
|
|
23
|
+
const response = await fetch(`${REGISTRY}/${encodedName(name)}`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
24
|
+
if (!response.ok) return undefined;
|
|
25
|
+
const value = await response.json();
|
|
26
|
+
metaCache.set(name, { at: now, value });
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// scoped 包的路径要编码成 %2F,否则注册表会当成两个路径段。
|
|
31
|
+
const encodedName = (name) => name.replace('/', '%2F');
|
|
32
|
+
|
|
33
|
+
// 取元数据,拿到指定版本的 tarball 地址。
|
|
34
|
+
async function tarballUrlOf(name, version) {
|
|
35
|
+
const meta = await metadataOf(name);
|
|
36
|
+
const url = meta?.versions?.[version]?.dist?.tarball;
|
|
37
|
+
return typeof url === 'string' ? url : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 列出注册表里所有已发布的版本。
|
|
41
|
+
//
|
|
42
|
+
// 缺符号时要挨个试,所以得先知道有哪些版本可选。取不到就返回空数组,
|
|
43
|
+
// 调用方据此退回「只试钉住的那个版本」。
|
|
44
|
+
export async function listVersions(name) {
|
|
45
|
+
try {
|
|
46
|
+
const meta = await metadataOf(name);
|
|
47
|
+
return Object.keys(meta?.versions ?? {});
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 读 tar 头里的字符串字段,遇 NUL 截断。
|
|
54
|
+
function readString(block, offset, length) {
|
|
55
|
+
let end = offset;
|
|
56
|
+
while (end < offset + length && block[end] !== 0) end += 1;
|
|
57
|
+
return block.subarray(offset, end).toString('utf8');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 读八进制数字字段,空值按 0 处理。
|
|
61
|
+
function readOctal(block, offset, length) {
|
|
62
|
+
const text = readString(block, offset, length).trim();
|
|
63
|
+
return text === '' ? 0 : parseInt(text, 8);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 解 PAX 扩展头。`path=` 那一行给出下一个条目的真实路径,长文件名靠它。
|
|
67
|
+
function paxPathOf(buffer) {
|
|
68
|
+
const text = buffer.toString('utf8');
|
|
69
|
+
for (const line of text.split('\n')) {
|
|
70
|
+
const match = /^\d+ path=(.*)$/.exec(line);
|
|
71
|
+
if (match !== null) return match[1];
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 解 tar。npm 的包是 gzip 压缩的 tar,里面统一带一层 `package/` 前缀。
|
|
77
|
+
//
|
|
78
|
+
// 两种长文件名写法都要认:GNU 用一个独立的 `L` 条目把名字放在数据区,PAX 用 `x` 条目里的
|
|
79
|
+
// `path=`。不认的话,路径超过一百字节的文件会被截断或丢掉——装出来的包看着完整,
|
|
80
|
+
// 加载时却报「找不到某个文件」,而且很难看出问题出在解包这一步。
|
|
81
|
+
function extractTar(buffer, destDir) {
|
|
82
|
+
let offset = 0;
|
|
83
|
+
let pendingName;
|
|
84
|
+
// npm 的包里所有条目都在同一层根目录下,但那个目录叫什么并不固定:
|
|
85
|
+
// 常见是 `package/`,也有用 `<repo>-<commit>/` 的。按实际首段剥掉,别写死。
|
|
86
|
+
let rootPrefix;
|
|
87
|
+
|
|
88
|
+
while (offset + 512 <= buffer.length) {
|
|
89
|
+
const header = buffer.subarray(offset, offset + 512);
|
|
90
|
+
if (header.every((byte) => byte === 0)) break;
|
|
91
|
+
|
|
92
|
+
const name = readString(header, 0, 100);
|
|
93
|
+
const prefix = readString(header, 345, 155);
|
|
94
|
+
const size = readOctal(header, 124, 12);
|
|
95
|
+
const type = readString(header, 156, 1) || '0';
|
|
96
|
+
const dataStart = offset + 512;
|
|
97
|
+
const data = buffer.subarray(dataStart, dataStart + size);
|
|
98
|
+
|
|
99
|
+
pendingName = nameFromHeaderRecord(type, data, pendingName);
|
|
100
|
+
if (isMetaRecord(type) === false) {
|
|
101
|
+
const declared = pendingName ?? (prefix === '' ? name : `${prefix}/${name}`);
|
|
102
|
+
pendingName = undefined;
|
|
103
|
+
rootPrefix = rootPrefix ?? rootPrefixOf(type, declared);
|
|
104
|
+
writeMember(destDir, stripRoot(declared, rootPrefix), type, data);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 数据区按 512 向上取整。
|
|
108
|
+
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 名字单独占一条记录的两种写法:GNU 的 L 和 PAX 的 x。全局头 g 跟名字无关。
|
|
113
|
+
const META_RECORDS = new Set(['L', 'x', 'g']);
|
|
114
|
+
|
|
115
|
+
function isMetaRecord(type) {
|
|
116
|
+
return META_RECORDS.has(type);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 元数据记录里带着下一个条目的真实路径,记下来交给下一条用。
|
|
120
|
+
function nameFromHeaderRecord(type, data, previous) {
|
|
121
|
+
if (type === 'L') return data.toString('utf8').replace(/\0+$/, '');
|
|
122
|
+
if (type === 'x') return paxPathOf(data) ?? previous;
|
|
123
|
+
return previous;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 包的根目录只在第一个目录条目上取一次,之后一直用它。
|
|
127
|
+
function rootPrefixOf(type, declared) {
|
|
128
|
+
if (type !== '5' || !declared.includes('/')) return undefined;
|
|
129
|
+
return declared.replace(/\/+$/, '');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 落一个文件。目录、空路径、越界路径都不写。
|
|
133
|
+
function writeMember(destDir, relative, type, data) {
|
|
134
|
+
if (relative === '' || type !== '0') return;
|
|
135
|
+
const target = join(destDir, ...relative.split('/'));
|
|
136
|
+
// 防目录穿越:解出来的路径必须还在目标目录里。
|
|
137
|
+
// 比较前统一分隔符——join 在 Windows 上给的是反斜杠。
|
|
138
|
+
if (!resolve(target).startsWith(resolve(destDir) + sep)) return;
|
|
139
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
140
|
+
writeFileSync(target, data);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 剥掉包的根目录,留下包内的相对路径。
|
|
144
|
+
//
|
|
145
|
+
// 根目录还没认出来时按第一段剥——tar 里第一个文件条目总在根目录下。
|
|
146
|
+
function stripRoot(declared, rootPrefix) {
|
|
147
|
+
if (rootPrefix !== undefined) {
|
|
148
|
+
if (!declared.startsWith(`${rootPrefix}/`)) return '';
|
|
149
|
+
return declared.slice(rootPrefix.length + 1);
|
|
150
|
+
}
|
|
151
|
+
const cut = declared.indexOf('/');
|
|
152
|
+
return cut < 0 ? declared : declared.slice(cut + 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 解一段已经拿到手的 tar 数据。抽出来单测用,也方便别处复用。
|
|
156
|
+
export function readPackageFromTar(buffer, destDir) {
|
|
157
|
+
extractTar(buffer, destDir);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 把 name@version 完整取下来解到 destDir。
|
|
161
|
+
//
|
|
162
|
+
// @param {string} name - 包名。
|
|
163
|
+
// @param {string} version - 精确版本号。
|
|
164
|
+
// @param {string} destDir - 解压目标目录。
|
|
165
|
+
// @returns {Promise<{ok: boolean, error?: string}>}
|
|
166
|
+
export async function fetchPackage(name, version, destDir) {
|
|
167
|
+
try {
|
|
168
|
+
const url = await tarballUrlOf(name, version);
|
|
169
|
+
if (url === undefined) return { ok: false, error: `${name}@${version} 在注册表里找不到` };
|
|
170
|
+
|
|
171
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
172
|
+
if (!response.ok) return { ok: false, error: `下载 ${name}@${version} 失败:HTTP ${response.status}` };
|
|
173
|
+
|
|
174
|
+
const tar = gunzipSync(Buffer.from(await response.arrayBuffer()));
|
|
175
|
+
mkdirSync(destDir, { recursive: true });
|
|
176
|
+
extractTar(tar, destDir);
|
|
177
|
+
return { ok: true };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return { ok: false, error: String(error) };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// 找出所有需要处理的插件。
|
|
2
|
+
//
|
|
3
|
+
// 一个插件需要处理,有四种可能:声明不满足、Loader 拒绝过、装好了但起不来、为安装准备的
|
|
4
|
+
// 环境还没建成。代码会从这几个来源分别收一遍,按包名去重后交给审计。
|
|
5
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { unmetPeers } from './compat.js';
|
|
8
|
+
import { entryPathOf, packageDir } from './compat-repair.js';
|
|
9
|
+
import { verifyEnvironment } from './env-verify.js';
|
|
10
|
+
import { readStagedPreparation } from './install-support.js';
|
|
11
|
+
|
|
12
|
+
// 找出所有声明不满足的已装插件,要的是全集,不是一个例子。
|
|
13
|
+
//
|
|
14
|
+
// 四个来源都看,因为不匹配可能藏在任何一处:
|
|
15
|
+
// - Loader 拒绝的 bundle 自带它算出的未满足集合;
|
|
16
|
+
// - 已启用的插件也可能不匹配,DSH 未必会说;
|
|
17
|
+
// - 已禁用但装着的也算,用户哪天启用时应该已经适配好;
|
|
18
|
+
// - 为安装准备好的插件如果环境还欠着,manifest 已经对齐,只有标记能看出来。
|
|
19
|
+
export async function collectMismatched(bundles, facts, plugins, probe) {
|
|
20
|
+
const all = [
|
|
21
|
+
...deferredCandidates(plugins, facts, probe),
|
|
22
|
+
...refusedCandidates(bundles, facts),
|
|
23
|
+
...installedCandidates(plugins, facts, probe),
|
|
24
|
+
// 加载失败的插件不会出现在 listPlugins() 里,所以还得自己去目录里翻一遍。
|
|
25
|
+
...scannedCandidates(plugins, facts, probe),
|
|
26
|
+
];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
return all.filter((item) => admit(item, seen));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 直接扫 profile 的 node_modules,把 listPlugins() 看不到的插件也找出来。
|
|
32
|
+
//
|
|
33
|
+
// 插件一旦加载失败就不在 Loader 树里,listPlugins() 自然不会返回它——可它恰恰是最需要
|
|
34
|
+
// 处理的那种。这里按目录兜底:凡是有 dsh.bundle 声明、且清单里没出现过的,都算候选。
|
|
35
|
+
function scannedCandidates(plugins, facts, probe) {
|
|
36
|
+
if (facts.profileDir === undefined) return [];
|
|
37
|
+
const known = new Set((plugins ?? []).map((entry) => entry?.moduleName).filter((name) => typeof name === 'string'));
|
|
38
|
+
return installedPluginNames(facts.profileDir)
|
|
39
|
+
.map((name) => scanOne(name, facts, probe, known))
|
|
40
|
+
.filter((item) => item !== undefined);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 扫到一个插件目录后,判断它要不要进候选。
|
|
44
|
+
function scanOne(name, facts, probe, known) {
|
|
45
|
+
const dir = packageDir(facts.profileDir, name);
|
|
46
|
+
if (dir === undefined) return;
|
|
47
|
+
const manifest = probe.readManifestCached(join(dir, 'package.json'));
|
|
48
|
+
if (manifest?.dsh?.bundle === undefined) return undefined;
|
|
49
|
+
|
|
50
|
+
const requirements = unmetPeers(manifest, facts.runtimeVersion);
|
|
51
|
+
// 声明就不满足的,肯定要处理,不必再去看源码。
|
|
52
|
+
if (Object.keys(requirements).length > 0) return candidate(name, manifest, requirements, facts);
|
|
53
|
+
|
|
54
|
+
// 声明已经满足,就看环境齐不齐:它实际导入的每个 DSH 包都要有垫片,
|
|
55
|
+
// 否则加载时照样找不到东西,表现就是装上了却起不来。
|
|
56
|
+
const imports = pluginImports(dir, manifest, probe, facts);
|
|
57
|
+
// 光有垫片不算数,内容也得对:缺一个导出插件照样起不来。
|
|
58
|
+
// 环境核对通过才算齐,否则重新适配一遍。
|
|
59
|
+
if (environmentCovers(dir, imports) && verifyEnvironment({ pluginDir: dir, imports }).ok) return undefined;
|
|
60
|
+
// 环境不齐就得补,跟它在不在 listPlugins 里无关——
|
|
61
|
+
// 恰恰是已启用却加载失败的插件最需要处理。
|
|
62
|
+
return candidate(name, manifest, declaredPeers(manifest), facts);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 读出插件实际导入了哪些 DSH 包。
|
|
66
|
+
function pluginImports(dir, manifest, probe, facts) {
|
|
67
|
+
const entry = entryPathOf(dir, manifest);
|
|
68
|
+
if (entry === undefined) return new Map();
|
|
69
|
+
try {
|
|
70
|
+
return probe.factsOf(dir, entry, facts.runtimeVersion).imports;
|
|
71
|
+
} catch {
|
|
72
|
+
return new Map();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 组装一条候选。
|
|
77
|
+
function candidate(name, manifest, requirements, facts) {
|
|
78
|
+
return {
|
|
79
|
+
name,
|
|
80
|
+
version: manifest.version ?? '0.0.0',
|
|
81
|
+
requirements,
|
|
82
|
+
runtimeVersion: facts.runtimeVersion,
|
|
83
|
+
needsEnvironment: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 检查插件实际导入的那些 DSH 包,是不是都在自己的 node_modules 里有垫片。
|
|
88
|
+
//
|
|
89
|
+
// 判据必须用它真正 import 的包,而不是 peerDependencies 里声明的全部。
|
|
90
|
+
// 声明往往列出十几个包,实际代码只用到两三个;按声明来判,环境永远显得"不完整",
|
|
91
|
+
// 于是每轮都重试,把真正该做的事淹没在重复劳动里。
|
|
92
|
+
function environmentCovers(pluginDir, imports) {
|
|
93
|
+
if (imports === undefined || imports.size === 0) return true;
|
|
94
|
+
const nested = join(pluginDir, 'node_modules');
|
|
95
|
+
for (const name of imports.keys()) {
|
|
96
|
+
if (!name.startsWith('@deepseek-ai/')) continue;
|
|
97
|
+
if (!existsSync(join(nested, ...name.split('/'), 'package.json'))) return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
// 列出 profile 的 node_modules 里所有包名,包括 @scope/name 两层结构。
|
|
102
|
+
export function installedPluginNames(profileDir) {
|
|
103
|
+
const root = join(profileDir, 'node_modules');
|
|
104
|
+
const names = [];
|
|
105
|
+
let entries;
|
|
106
|
+
try {
|
|
107
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
108
|
+
} catch {
|
|
109
|
+
return names;
|
|
110
|
+
}
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
// 符号链接也要算:link: 安装的插件在 node_modules 里就是个链接。
|
|
113
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
114
|
+
if (entry.name.startsWith('.')) continue;
|
|
115
|
+
if (entry.name.startsWith('@')) {
|
|
116
|
+
let scoped;
|
|
117
|
+
try {
|
|
118
|
+
scoped = readdirSync(join(root, entry.name), { withFileTypes: true });
|
|
119
|
+
} catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (const child of scoped) {
|
|
123
|
+
if (child.isDirectory() || child.isSymbolicLink()) names.push(entry.name + "/" + child.name);
|
|
124
|
+
}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
names.push(entry.name);
|
|
128
|
+
}
|
|
129
|
+
return names;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 一个候选该不该进审计。首次遇到以先到的为准,没有未满足要求的不必审计。
|
|
133
|
+
function admit(item, seen) {
|
|
134
|
+
if (typeof item.name !== 'string' || item.name === '') return false;
|
|
135
|
+
if (Object.keys(item.requirements).length === 0) return false;
|
|
136
|
+
if (seen.has(item.name)) return false;
|
|
137
|
+
seen.add(item.name);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
// 把一个插件条目解析成目录和 manifest。
|
|
141
|
+
export function resolveEntry(entry, facts, probe) {
|
|
142
|
+
if (typeof entry?.moduleName !== 'string') return undefined;
|
|
143
|
+
if (facts.profileDir === undefined) return undefined;
|
|
144
|
+
const dir = packageDir(facts.profileDir, entry.moduleName);
|
|
145
|
+
if (dir === undefined) return undefined;
|
|
146
|
+
const manifest = probe.readManifestCached(join(dir, 'package.json'));
|
|
147
|
+
return manifest === undefined ? undefined : { name: entry.moduleName, dir, manifest };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 为安装做好准备、但环境还欠着的插件。它们的 manifest 已经对齐,只有准备标记能说明还差什么。
|
|
151
|
+
// 准备标记还代表有待办吗。已经建好包的就是完事了,只有延后的才要补建。
|
|
152
|
+
function needsEnvironment(pending) {
|
|
153
|
+
if (pending === undefined) return false;
|
|
154
|
+
return (pending.packages ?? []).length === 0;
|
|
155
|
+
}
|
|
156
|
+
function deferredCandidates(plugins, facts, probe) {
|
|
157
|
+
return (plugins ?? [])
|
|
158
|
+
.map((entry) => resolveEntry(entry, facts, probe))
|
|
159
|
+
.filter((resolved) => resolved !== undefined)
|
|
160
|
+
.map((resolved) => ({ resolved, pending: readStagedPreparation(resolved.dir) }))
|
|
161
|
+
.filter(({ pending }) => needsEnvironment(pending))
|
|
162
|
+
.map(({ resolved, pending }) => ({
|
|
163
|
+
name: resolved.name,
|
|
164
|
+
version: resolved.manifest.version ?? '0.0.0',
|
|
165
|
+
requirements: pending.requirements ?? {},
|
|
166
|
+
runtimeVersion: facts.runtimeVersion,
|
|
167
|
+
deferred: true,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 准备标记还代表有待办吗。已经建好包的就是完事了,只有延后的才要 Host 补建。
|
|
172
|
+
function refusedCandidates(bundles, facts) {
|
|
173
|
+
const found = [];
|
|
174
|
+
for (const bundle of bundles ?? []) {
|
|
175
|
+
const issues = bundle?.error?.incompatible;
|
|
176
|
+
if (!Array.isArray(issues)) continue;
|
|
177
|
+
for (const issue of issues) {
|
|
178
|
+
if (typeof issue?.version !== 'string') continue;
|
|
179
|
+
found.push({
|
|
180
|
+
name: issue.name,
|
|
181
|
+
version: issue.version,
|
|
182
|
+
requirements: issue.requirements ?? {},
|
|
183
|
+
runtimeVersion: issue.runtimeVersion ?? facts.runtimeVersion,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// manifest 自己声明了未满足 peer 范围的已装插件。
|
|
191
|
+
//
|
|
192
|
+
// 既覆盖 DSH 没拒绝过的已启用插件,也覆盖已禁用的,这样后者被启用时已经是适配好的。
|
|
193
|
+
function installedCandidates(plugins, facts, probe) {
|
|
194
|
+
return (plugins ?? [])
|
|
195
|
+
.map((entry) => installOne(entry, facts, probe))
|
|
196
|
+
.filter((item) => item !== undefined);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 清单里已有的插件,判断它要不要进候选。
|
|
200
|
+
function installOne(entry, facts, probe) {
|
|
201
|
+
const resolved = resolveEntry(entry, facts, probe);
|
|
202
|
+
if (resolved === undefined) return undefined;
|
|
203
|
+
|
|
204
|
+
const requirements = unmetPeers(resolved.manifest, facts.runtimeVersion);
|
|
205
|
+
const unmet = Object.keys(requirements).length > 0;
|
|
206
|
+
// manifest 已经满足、但插件根本没激活起来,也要管。
|
|
207
|
+
// 这种情况通常是声明之前对齐过、环境却没建成,光看 manifest 看不出来。
|
|
208
|
+
const failedToActivate = entry?.enabled === true && entry?.fiberPhase !== 'active';
|
|
209
|
+
const needsEnvironment = !unmet && failedToActivate;
|
|
210
|
+
if (!unmet && !needsEnvironment) return undefined;
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
name: resolved.name,
|
|
214
|
+
version: resolved.manifest.version ?? '0.0.0',
|
|
215
|
+
// 声明没问题但起不来时,按它自己声明的 peer 范围去建环境。
|
|
216
|
+
requirements: needsEnvironment ? declaredPeers(resolved.manifest) : requirements,
|
|
217
|
+
runtimeVersion: facts.runtimeVersion,
|
|
218
|
+
needsEnvironment,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 把 manifest 里所有 DSH peer 都列出来,不管当前满不满足。
|
|
223
|
+
export function declaredPeers(manifest) {
|
|
224
|
+
const peers = manifest?.peerDependencies;
|
|
225
|
+
if (peers === null || typeof peers !== 'object' || Array.isArray(peers)) return {};
|
|
226
|
+
const declared = {};
|
|
227
|
+
for (const [name, range] of Object.entries(peers)) {
|
|
228
|
+
if (!/^@deepseek-ai\/dsh(?:[-/]|$)/.test(name) || typeof range !== 'string') continue;
|
|
229
|
+
declared[name] = range;
|
|
230
|
+
}
|
|
231
|
+
return declared;
|
|
232
|
+
}
|