@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// 几个修复监听器。
|
|
2
|
+
//
|
|
3
|
+
// 一个管模型请求失败,一个管工具调用失败,一个管浏览器载体请求失败。三者共用重试预算、
|
|
4
|
+
// 诊断记录和插件生命周期,这些都是从参数传进来而不是从闭包里摸出去的,所以每个函数
|
|
5
|
+
// 单独拎出来也能看懂。
|
|
6
|
+
import { computeDelay, isTransientNetworkError, isTransientToolFailure } from './classify.js';
|
|
7
|
+
import { cancellableSleep } from './sleep.js';
|
|
8
|
+
import { createModelRepair } from './model-repair.js';
|
|
9
|
+
|
|
10
|
+
export function createRepairListeners(input) {
|
|
11
|
+
const { config, lifetime, diagnostics, ledger, versionRepair, services, warn } = input;
|
|
12
|
+
const { record, stats } = diagnostics;
|
|
13
|
+
const modelRepair = createModelRepair({ config, lifetime, diagnostics, ledger, versionRepair, services, warn });
|
|
14
|
+
|
|
15
|
+
// 把请求自己的取消信号和插件生命周期绑在一起,插件卸载时正在等的重试能立刻醒。
|
|
16
|
+
const fuse = (signal) => {
|
|
17
|
+
if (signal === undefined) return lifetime.signal;
|
|
18
|
+
try {
|
|
19
|
+
return AbortSignal.any([signal, lifetime.signal]);
|
|
20
|
+
} catch {
|
|
21
|
+
return signal;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// 工具调用被瞬时传输故障打断时,只读的那些可以重放一次。
|
|
26
|
+
//
|
|
27
|
+
// 必须卡着白名单来:带副作用的工具重放一遍,就是真的多跑了一条命令、多发了一条消息。
|
|
28
|
+
async function onToolExecute(exec, next) {
|
|
29
|
+
let attempt = 0;
|
|
30
|
+
for (;;) {
|
|
31
|
+
const outcome = await attemptDispatch(exec, next, attempt);
|
|
32
|
+
if (outcome.retry === false) {
|
|
33
|
+
if (outcome.error !== undefined) throw outcome.error;
|
|
34
|
+
return outcome.result;
|
|
35
|
+
}
|
|
36
|
+
attempt += 1;
|
|
37
|
+
const policy = config();
|
|
38
|
+
if (attempt > policy.maxToolRetries) {
|
|
39
|
+
if (outcome.error !== undefined) throw outcome.error;
|
|
40
|
+
return outcome.result;
|
|
41
|
+
}
|
|
42
|
+
const delayMs = computeDelay(policy, attempt, undefined);
|
|
43
|
+
const waited = await cancellableSleep(delayMs, fuse(exec.signal));
|
|
44
|
+
if (!waited) {
|
|
45
|
+
if (outcome.error !== undefined) throw outcome.error;
|
|
46
|
+
return outcome.result;
|
|
47
|
+
}
|
|
48
|
+
stats.toolRepairs += 1;
|
|
49
|
+
record('tool', `${exec.name} replayed after a transient ${outcome.reason} failure (attempt ${attempt})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 循环检测:同名同参的调用连着出现太多次,就不再重放。
|
|
54
|
+
//
|
|
55
|
+
// 宿主自己的循环检测若失效,重放会把循环放大。这里只约束「我们自己的重放」,
|
|
56
|
+
// 不改变调用本身的语义——正常调用该怎么走还怎么走。
|
|
57
|
+
const recent = new Map();
|
|
58
|
+
function signatureOf(exec) {
|
|
59
|
+
try {
|
|
60
|
+
return `${exec.name}:${JSON.stringify(exec.arguments ?? {}).slice(0, 400)}`;
|
|
61
|
+
} catch {
|
|
62
|
+
return String(exec.name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function seenTooOften(exec) {
|
|
66
|
+
const key = signatureOf(exec);
|
|
67
|
+
const count = (recent.get(key) ?? 0) + 1;
|
|
68
|
+
recent.set(key, count);
|
|
69
|
+
if (recent.size > 64) recent.clear();
|
|
70
|
+
return count > config().maxRepeatedToolCalls;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 跑一次调用,顺便判断这次失败值不值得重放。
|
|
74
|
+
async function attemptDispatch(exec, next, attempt) {
|
|
75
|
+
const policy = config();
|
|
76
|
+
if (attempt >= policy.maxToolRetries) {
|
|
77
|
+
try {
|
|
78
|
+
return { retry: false, result: await next() };
|
|
79
|
+
} catch (error) {
|
|
80
|
+
return { retry: false, error };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (exec.signal?.aborted || lifetime.signal.aborted || seenTooOften(exec)) {
|
|
84
|
+
try {
|
|
85
|
+
return { retry: false, result: await next() };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return { retry: false, error };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const result = await next();
|
|
92
|
+
if (!isTransientToolFailure(exec.name, result, policy.retryableToolPattern)) return { retry: false, result };
|
|
93
|
+
return { retry: true, reason: 'dispatch', result };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!isTransientNetworkError(error)) return { retry: false, error };
|
|
96
|
+
return { retry: true, reason: 'thrown transport', error };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 浏览器载体的传输失败,如果响应头还没发出去,就换成可重试的 503。
|
|
101
|
+
//
|
|
102
|
+
// 头一发出去响应形状就定了,这时候只能把原始错误抛回去,硬写半截回复反而更糟。
|
|
103
|
+
async function onConnectionRequest(request, response, next) {
|
|
104
|
+
try {
|
|
105
|
+
return await next();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (response.headersSent || response.writableEnded || !isTransientNetworkError(error)) throw error;
|
|
108
|
+
stats.connectionRepairs += 1;
|
|
109
|
+
record('connection', `replaced a failed ${String(request?.method ?? '')} ${String(request?.url ?? '')} with a retryable 503`);
|
|
110
|
+
try {
|
|
111
|
+
response.writeHead(503, { 'retry-after': '1', 'content-type': 'text/plain; charset=utf-8' });
|
|
112
|
+
response.end('request-doctor: transient client transport failure; retry the request');
|
|
113
|
+
} catch {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { onRequestError, onToolExecute, onConnectionRequest };
|
|
120
|
+
|
|
121
|
+
// 模型请求的修复交给 model-repair 那个模块,这里只转发一下。
|
|
122
|
+
// 放在后面是为了让工具和载体两个处理更靠前,读起来顺。
|
|
123
|
+
function onRequestError(payload, next) {
|
|
124
|
+
return modelRepair.handle(payload, next);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// 重试账本。
|
|
2
|
+
//
|
|
3
|
+
// 记住每个 agent 每条链已经重试过几次,并且把修复写进既有的 llm/retry 事件里,
|
|
4
|
+
// 这样对话里能直接看到重试记录。单拎出来是为了让预算规则能被单独读懂和测试。
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// 每个 agent 最多留这么多条链。老 turn 不可能再重试了,留着没用。
|
|
8
|
+
const MAX_CHAINS_PER_AGENT = 128;
|
|
9
|
+
|
|
10
|
+
export function createRetryLedger(input) {
|
|
11
|
+
const chains = new WeakMap();
|
|
12
|
+
|
|
13
|
+
// 取一条链的预算记录,没有就建。键是 turn:step:provider:kind,
|
|
14
|
+
// 换句话说同一处失败共用一份预算,换一处就重新开始算。
|
|
15
|
+
const chainFor = (agent, key) => {
|
|
16
|
+
let map = chains.get(agent);
|
|
17
|
+
if (map === undefined) chains.set(agent, (map = new Map()));
|
|
18
|
+
let entry = map.get(key);
|
|
19
|
+
if (entry === undefined) {
|
|
20
|
+
if (map.size >= MAX_CHAINS_PER_AGENT) {
|
|
21
|
+
const oldest = map.keys().next().value;
|
|
22
|
+
if (oldest !== undefined) map.delete(oldest);
|
|
23
|
+
}
|
|
24
|
+
map.set(key, (entry = { count: 0, retryId: randomUUID() }));
|
|
25
|
+
}
|
|
26
|
+
return entry;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// 排上重试时写 llm/retry。只用既有事件类型,新类型会被读取端当成未知事件拒掉。
|
|
30
|
+
const recordScheduled = (agent, payload, kind, entry, delayMs) => {
|
|
31
|
+
const config = input.config();
|
|
32
|
+
if (!config.recordRetryEvents) return;
|
|
33
|
+
try {
|
|
34
|
+
agent.session.append('llm/retry', {
|
|
35
|
+
retryId: entry.retryId,
|
|
36
|
+
turn: payload.turn,
|
|
37
|
+
step: payload.step,
|
|
38
|
+
provider: payload.provider,
|
|
39
|
+
mode: 'normal',
|
|
40
|
+
policyKey: `request-doctor:${kind}`,
|
|
41
|
+
retry: entry.count,
|
|
42
|
+
maxRetries: config.maxRetries,
|
|
43
|
+
delayMs,
|
|
44
|
+
failure: payload.failure,
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
input.warn(`request-doctor: could not record the repair event: ${String(error)}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// 真正开始重试时补一条 llm/retry-started,把这条记录闭环。
|
|
52
|
+
const recordStarted = (agent, payload, entry) => {
|
|
53
|
+
if (!input.config().recordRetryEvents) return;
|
|
54
|
+
try {
|
|
55
|
+
agent.session.append('llm/retry-started', {
|
|
56
|
+
retryId: entry.retryId,
|
|
57
|
+
turn: payload.turn,
|
|
58
|
+
step: payload.step,
|
|
59
|
+
retry: entry.count,
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
// 只是展示用,写不进去不影响修复
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return { chainFor, recordScheduled, recordStarted };
|
|
67
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// 运行时探测。
|
|
2
|
+
//
|
|
3
|
+
// 负责问清楚:当前跑的是哪个 DSH 版本、profile 在哪儿、DSH 自己的包装在哪儿、
|
|
4
|
+
// 以及现在真正注册了哪些 provider 路由。这些都是"查一下"的事,不涉及"怎么修",
|
|
5
|
+
// 所以跟用它们的修复流程分开放。
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { entryPathOf, packageDir } from './compat-repair.js';
|
|
9
|
+
import { createFactsReader, createManifestReader } from './stat-cache.js';
|
|
10
|
+
|
|
11
|
+
// DSH 自己的包可能装在安装目录里,也可能打在 asar 里。
|
|
12
|
+
// 前者能直接用文件系统找到,后者 existsSync 看不见,只能靠路径拼。
|
|
13
|
+
function packageDirInInstallation(packageName) {
|
|
14
|
+
for (const root of installationRoots()) {
|
|
15
|
+
const candidate = join(root, 'node_modules', ...packageName.split('/'));
|
|
16
|
+
// asar 里的目录 existsSync 返回 false,但路径本身可用,直接交出去。
|
|
17
|
+
if (root.includes('app.asar')) return candidate;
|
|
18
|
+
if (existsSync(join(candidate, 'package.json'))) return candidate;
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 找出 DSH 装在哪里。按可靠性从高到低试几个来源。
|
|
24
|
+
function installationRoots() {
|
|
25
|
+
const roots = [];
|
|
26
|
+
|
|
27
|
+
// 最可靠的一条:从正在运行的模块 URL 反推。插件自己就加载自 DSH 安装目录。
|
|
28
|
+
try {
|
|
29
|
+
const self = import.meta.url;
|
|
30
|
+
const marker = self.indexOf('/dsh/');
|
|
31
|
+
if (marker > 0) roots.push(decodeURIComponent(new URL(self).pathname).slice(0, marker).replace(/^\//, '') + '/dsh');
|
|
32
|
+
} catch {
|
|
33
|
+
/* 拿不到就算了 */
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Electron 会给出 resources 目录。
|
|
37
|
+
if (typeof process.resourcesPath === 'string' && process.resourcesPath !== '') {
|
|
38
|
+
roots.push(join(process.resourcesPath, 'app.asar', 'dsh'));
|
|
39
|
+
roots.push(join(process.resourcesPath, 'app.asar.unpacked', 'dsh'));
|
|
40
|
+
roots.push(join(process.resourcesPath, 'dsh'));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 从可执行文件位置推,覆盖非 Electron 的安装方式。
|
|
44
|
+
if (typeof process.execPath === 'string' && process.execPath !== '') {
|
|
45
|
+
const exeDir = dirname(process.execPath);
|
|
46
|
+
roots.push(join(exeDir, 'resources', 'app.asar', 'dsh'));
|
|
47
|
+
roots.push(join(exeDir, 'resources', 'dsh'));
|
|
48
|
+
roots.push(join(exeDir, 'dsh'));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return [...new Set(roots.filter((root) => root !== ''))];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createRuntimeProbe(input) {
|
|
55
|
+
const readManifestCached = createManifestReader();
|
|
56
|
+
const factsOf = createFactsReader(input.collectFacts);
|
|
57
|
+
|
|
58
|
+
// 可选服务,没有就算了,不该因此中断。
|
|
59
|
+
const safeGet = (name) => {
|
|
60
|
+
try {
|
|
61
|
+
return input.ctx.get(name);
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// DSH 的包走的是 profile 的运行时拦截,不是普通 Node 解析,
|
|
68
|
+
// 所以得问管这块映射的服务,而不是自己去 require。
|
|
69
|
+
const runtimeVersionOf = (packages, baseUrl) => {
|
|
70
|
+
try {
|
|
71
|
+
const manifestPath = packages?.packageOf('@deepseek-ai/dsh-app-boot', baseUrl)?.manifestPath;
|
|
72
|
+
return manifestPath === undefined ? undefined : readManifestCached(manifestPath)?.version;
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// 先问服务,问不到再看环境变量——有些 profile 是在没有这些服务的情况下跑起来的。
|
|
79
|
+
const profileDirOf = () => {
|
|
80
|
+
const fromServices = safeGet('profileContext')?.dir ?? safeGet('pluginManager')?.profile?.dir;
|
|
81
|
+
if (fromServices !== undefined) return fromServices;
|
|
82
|
+
const home = process.env.DSH_HOME;
|
|
83
|
+
const profile = process.env.DSH_PROFILE;
|
|
84
|
+
return home === undefined || profile === undefined ? undefined : join(home, 'profiles', profile);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const runtimeFacts = () => {
|
|
88
|
+
const packages = safeGet('pluginPackages');
|
|
89
|
+
const baseUrl = input.ctx.baseUrl;
|
|
90
|
+
return {
|
|
91
|
+
runtimeVersion: runtimeVersionOf(packages, baseUrl),
|
|
92
|
+
profileDir: profileDirOf(),
|
|
93
|
+
packages,
|
|
94
|
+
baseUrl,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// DSH 自带的包在安装目录里(甚至是 asar 里),不在 profile 的 node_modules。
|
|
99
|
+
// 先问 pluginPackages,它最清楚;问不到就用别的包探出安装根,最后才去翻安装目录。
|
|
100
|
+
const runtimeDirOf = (facts) => (packageName) => {
|
|
101
|
+
try {
|
|
102
|
+
const resolved = facts.packages?.packageOf(packageName, facts.baseUrl);
|
|
103
|
+
if (resolved?.dir !== undefined) return resolved.dir;
|
|
104
|
+
} catch {
|
|
105
|
+
// 落到后面几条路
|
|
106
|
+
}
|
|
107
|
+
// 同一个安装里的包都在同一个 node_modules 下,拿找得到的那个反推即可。
|
|
108
|
+
const anchor = probeAnchor(facts);
|
|
109
|
+
if (anchor !== undefined) {
|
|
110
|
+
const candidate = join(anchor, ...packageName.split('/'));
|
|
111
|
+
if (candidate.includes('app.asar') || existsSync(join(candidate, 'package.json'))) return candidate;
|
|
112
|
+
}
|
|
113
|
+
if (facts.profileDir !== undefined) {
|
|
114
|
+
const inProfile = packageDir(facts.profileDir, packageName);
|
|
115
|
+
if (inProfile !== undefined) return inProfile;
|
|
116
|
+
}
|
|
117
|
+
return packageDirInInstallation(packageName);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// 用运行时里一定找得到的包,反推它所在的 node_modules 目录。
|
|
121
|
+
// app-boot 每个 profile 都必然加载,拿它当锚点最稳。
|
|
122
|
+
const probeAnchor = (facts) => {
|
|
123
|
+
for (const name of ['@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh-llm', '@deepseek-ai/cordis']) {
|
|
124
|
+
try {
|
|
125
|
+
const resolved = facts.packages?.packageOf(name, facts.baseUrl);
|
|
126
|
+
if (resolved?.dir === undefined) continue;
|
|
127
|
+
const parts = resolved.dir.replace(/\\/g, '/').split('/');
|
|
128
|
+
const scope = parts.lastIndexOf('@deepseek-ai');
|
|
129
|
+
if (scope > 0) return parts.slice(0, scope).join('/');
|
|
130
|
+
} catch {
|
|
131
|
+
// 换下一个
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const runtimeEntryOf = (facts) => (packageName) => {
|
|
138
|
+
const dir = runtimeDirOf(facts)(packageName);
|
|
139
|
+
if (dir === undefined) return;
|
|
140
|
+
const manifest = readManifestCached(join(dir, 'package.json'));
|
|
141
|
+
if (manifest !== undefined) return entryPathOf(dir, manifest);
|
|
142
|
+
// manifest 读不到(asar 里的情况)就按惯例猜入口:先 lib/index.js,再 index.js。
|
|
143
|
+
for (const guess of ['lib/index.js', 'index.js', 'lib/index.mjs']) {
|
|
144
|
+
const candidate = join(dir, guess);
|
|
145
|
+
if (candidate.includes('app.asar') || existsSync(candidate)) return candidate;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// 以运行时注册表为准。插件常常声明好几条路由,实际按地区或配置只注册一部分,
|
|
151
|
+
// 光看源码会把根本不存在冲突也算出来。
|
|
152
|
+
const activeProviders = () => {
|
|
153
|
+
const llm = safeGet('llm');
|
|
154
|
+
if (llm === undefined || typeof llm.listProviders !== 'function') return undefined;
|
|
155
|
+
try {
|
|
156
|
+
return llm.listProviders().map((entry) => entry?.id).filter((id) => typeof id === 'string');
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
readManifestCached,
|
|
164
|
+
runtimeFacts,
|
|
165
|
+
runtimeDirOf,
|
|
166
|
+
runtimeEntryOf,
|
|
167
|
+
activeProviders,
|
|
168
|
+
factsOf,
|
|
169
|
+
get: safeGet,
|
|
170
|
+
};
|
|
171
|
+
}
|
package/lib/semver.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// 版本号解析、比较,以及判断某个版本是否满足 manifest 里写的范围。
|
|
2
|
+
//
|
|
3
|
+
// 自己实现而不是引依赖:本插件要在没装任何额外包的 profile 里也能跑,而 DSH manifest
|
|
4
|
+
// 用到的语法其实不多,正好可以全测到。
|
|
5
|
+
/** 解析 major.minor.patch[-prerelease][+build]。 */
|
|
6
|
+
export function parseSemver(text) {
|
|
7
|
+
if (typeof text !== 'string') return undefined;
|
|
8
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/.exec(text.trim());
|
|
9
|
+
if (match === null) return undefined;
|
|
10
|
+
return {
|
|
11
|
+
major: Number(match[1]),
|
|
12
|
+
minor: Number(match[2]),
|
|
13
|
+
patch: Number(match[3]),
|
|
14
|
+
prerelease: match[4] === undefined ? [] : match[4].split('.'),
|
|
15
|
+
raw: text.trim(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 按 SemVer 规范第 11 条比预发布标识:数字段按数值比,字母段按字典序比,
|
|
20
|
+
// 数字段永远小于字母段,短的排在长的前面。
|
|
21
|
+
function comparePrerelease(a, b) {
|
|
22
|
+
if (a.length === 0 && b.length === 0) return 0;
|
|
23
|
+
if (a.length === 0) return 1; // 正式版比它的任何预发布都大
|
|
24
|
+
if (b.length === 0) return -1;
|
|
25
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
|
|
26
|
+
const left = a[i];
|
|
27
|
+
const right = b[i];
|
|
28
|
+
if (left === undefined) return -1;
|
|
29
|
+
if (right === undefined) return 1;
|
|
30
|
+
const leftNumeric = /^\d+$/.test(left);
|
|
31
|
+
const rightNumeric = /^\d+$/.test(right);
|
|
32
|
+
if (leftNumeric && rightNumeric) {
|
|
33
|
+
const delta = Number(left) - Number(right);
|
|
34
|
+
if (delta !== 0) return delta < 0 ? -1 : 1;
|
|
35
|
+
} else if (leftNumeric !== rightNumeric) {
|
|
36
|
+
return leftNumeric ? -1 : 1;
|
|
37
|
+
} else if (left !== right) {
|
|
38
|
+
return left < right ? -1 : 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 比较两个已解析的版本,返回 -1 / 0 / 1。 */
|
|
45
|
+
export function compareVersions(a, b) {
|
|
46
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
47
|
+
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
|
|
48
|
+
}
|
|
49
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 把范围拆成若干组比较项。用 || 分隔的每一组都要单独满足。
|
|
53
|
+
function comparatorTerms(range) {
|
|
54
|
+
return String(range)
|
|
55
|
+
.split('||')
|
|
56
|
+
.map((alternative) => alternative.trim().split(/\s+/).filter((term) => term !== ''));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 判断单个比较项,比如 ">=1.2.3" 或 "^0.2.1"。
|
|
60
|
+
function termSatisfied(version, term) {
|
|
61
|
+
const operator = /^(>=|<=|>|<|=|\^|~)?/.exec(term)?.[1] ?? '';
|
|
62
|
+
const body = term.slice(operator.length);
|
|
63
|
+
if (body === '' || body === '*' || body === 'x' || body === 'X') return true;
|
|
64
|
+
const parsed = parseSemver(body.replace(/\.(?:x|X|\*)$/, '.0'));
|
|
65
|
+
if (parsed === undefined) return undefined; // 解析不了,交给调用方决定
|
|
66
|
+
const order = compareVersions(version, parsed);
|
|
67
|
+
return compareByOperator(operator, order, version, parsed);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 从 termSatisfied 里拆出来的,专门处理各个比较符。
|
|
71
|
+
// 一开始全塞在一起的时候,^ 和 ~ 的边界规则读起来像一堆嵌套 if。
|
|
72
|
+
function compareByOperator(operator, order, version, parsed) {
|
|
73
|
+
const simple = {
|
|
74
|
+
'': order === 0,
|
|
75
|
+
'=': order === 0,
|
|
76
|
+
'>': order > 0,
|
|
77
|
+
'>=': order >= 0,
|
|
78
|
+
'<': order < 0,
|
|
79
|
+
'<=': order <= 0,
|
|
80
|
+
};
|
|
81
|
+
if (Object.hasOwn(simple, operator)) return simple[operator];
|
|
82
|
+
if (operator === '^') return satisfiesCaret(version, parsed, order);
|
|
83
|
+
if (operator === '~') return satisfiesTilde(version, parsed, order);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ^1.2.3 的意思是最左边那个非零位锁住:1.x 都行,0.2.x 只到 0.2,0.0.x 就只能 0.0.x。
|
|
88
|
+
function satisfiesCaret(version, parsed, order) {
|
|
89
|
+
if (order < 0) return false;
|
|
90
|
+
if (parsed.major > 0) return version.major === parsed.major;
|
|
91
|
+
if (parsed.minor > 0) return version.major === 0 && version.minor === parsed.minor;
|
|
92
|
+
return version.major === 0 && version.minor === 0 && version.patch === parsed.patch;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ~1.2.3 是主次版本钉死,补丁位往上都行。
|
|
96
|
+
function satisfiesTilde(version, parsed, order) {
|
|
97
|
+
return order >= 0 && version.major === parsed.major && version.minor === parsed.minor;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 判断版本满不满足范围。返回 true/false;范围本身解析不了就返回 undefined
|
|
101
|
+
// (workspace: 协议、git tag、写坏的字符串都会走这条路),由调用方自己拿主意。
|
|
102
|
+
// 预发布版本能不能落进这个范围。
|
|
103
|
+
//
|
|
104
|
+
// 这是 semver 里最容易被忽略的一条:预发布版本只对它自己那一组 major.minor.patch 的
|
|
105
|
+
// 比较符有效。所以 0.2.1-rc.3 不满足 ">=0.2.0-rc.2 <0.3.0-0"——那组的预发布是
|
|
106
|
+
// 0.2.0 和 0.3.0,跟它不同组。不按这条判,会把运行时明确拒绝的版本当成满足,
|
|
107
|
+
// 于是插件永远显示成「已经没问题」,实际根本加载不了。
|
|
108
|
+
function prereleaseAllowed(parsed, terms) {
|
|
109
|
+
if (parsed.prerelease.length === 0) return true;
|
|
110
|
+
return terms.some((term) => {
|
|
111
|
+
const body = term.replace(/^(?:>=|<=|>|<|=|\^|~)/, '');
|
|
112
|
+
// 通配符没有具体版本可比,对预发布一视同仁。
|
|
113
|
+
if (body === '' || body === '*' || body === 'x' || body === 'X') return true;
|
|
114
|
+
const candidate = parseSemver(body.replace(/\.(?:x|X|\*)$/, '.0'));
|
|
115
|
+
if (candidate === undefined || candidate.prerelease.length === 0) return false;
|
|
116
|
+
return candidate.major === parsed.major
|
|
117
|
+
&& candidate.minor === parsed.minor
|
|
118
|
+
&& candidate.patch === parsed.patch;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function satisfies(version, range) {
|
|
123
|
+
const parsed = typeof version === 'string' ? parseSemver(version) : version;
|
|
124
|
+
if (parsed === undefined) return undefined;
|
|
125
|
+
if (typeof range !== 'string' || range.trim() === '') return undefined;
|
|
126
|
+
const groups = comparatorTerms(range);
|
|
127
|
+
let answered = false;
|
|
128
|
+
for (const terms of groups) {
|
|
129
|
+
let all = true;
|
|
130
|
+
for (const term of terms) {
|
|
131
|
+
const result = termSatisfied(parsed, term);
|
|
132
|
+
if (result === undefined) continue;
|
|
133
|
+
answered = true;
|
|
134
|
+
if (!result) {
|
|
135
|
+
all = false;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (all && answered) return prereleaseAllowed(parsed, terms);
|
|
140
|
+
}
|
|
141
|
+
return answered ? false : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 估算声明的版本要求跟当前运行时差多远。用来区分"差不多能用"和"差了一个大版本",
|
|
145
|
+
// 前者值得自动适配,后者得让人看一眼。
|
|
146
|
+
export function classifyDistance(declaredRange, runtimeVersion) {
|
|
147
|
+
const satisfied = satisfies(runtimeVersion, declaredRange);
|
|
148
|
+
if (satisfied === true) return 'satisfied';
|
|
149
|
+
if (satisfied === undefined) return 'unknown';
|
|
150
|
+
const runtime = parseSemver(runtimeVersion);
|
|
151
|
+
const floor = typeof declaredRange === 'string'
|
|
152
|
+
? parseSemver(String(declaredRange).replace(/^[\^~>=<\s]+/, '').split(/\s+/)[0])
|
|
153
|
+
: undefined;
|
|
154
|
+
if (runtime === undefined || floor === undefined) return 'unknown';
|
|
155
|
+
if (runtime.major !== floor.major) return 'major-delta';
|
|
156
|
+
if (runtime.minor !== floor.minor) return 'minor-delta';
|
|
157
|
+
if (runtime.patch !== floor.patch) return 'patch-delta';
|
|
158
|
+
return 'prerelease-delta';
|
|
159
|
+
}
|
package/lib/sleep.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// 可中断的等待。
|
|
2
|
+
//
|
|
3
|
+
// 退避期间如果请求被取消或插件被卸载,必须立刻醒过来,所以这里返回布尔值而不是抛错——
|
|
4
|
+
// 由调用方决定是交还下游结果还是把原始异常抛出去。
|
|
5
|
+
export function cancellableSleep(ms, signal) {
|
|
6
|
+
if (signal?.aborted) return Promise.resolve(false);
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
signal?.removeEventListener('abort', onAbort);
|
|
10
|
+
resolve(true);
|
|
11
|
+
}, ms);
|
|
12
|
+
function onAbort() {
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
resolve(false);
|
|
15
|
+
}
|
|
16
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// manifest 和模块图的缓存。
|
|
2
|
+
//
|
|
3
|
+
// 一次扫描要摸遍所有插件,后台轮询还会定期重来;但 manifest 和源码图只在安装、升级、
|
|
4
|
+
// 换运行时的时候才会变。所以两边都用文件签名做键,文件一动自然失效,不需要挂文件监听。
|
|
5
|
+
// 两个缓存都有上限,卸载掉的插件不会一直占着内存。
|
|
6
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
const MAX_MANIFESTS = 512;
|
|
9
|
+
const MAX_FACTS = 256;
|
|
10
|
+
|
|
11
|
+
// 塞进去,满了就丢最早的那个。
|
|
12
|
+
function put(cache, limit, key, value) {
|
|
13
|
+
if (cache.size >= limit && !cache.has(key)) {
|
|
14
|
+
const oldest = cache.keys().next().value;
|
|
15
|
+
if (oldest !== undefined) cache.delete(oldest);
|
|
16
|
+
}
|
|
17
|
+
cache.set(key, value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// mtime + size 就够判断变没变,不读文件内容。
|
|
21
|
+
function signature(path) {
|
|
22
|
+
try {
|
|
23
|
+
const stat = statSync(path);
|
|
24
|
+
return `${stat.mtimeMs}:${stat.size}`;
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createManifestReader() {
|
|
31
|
+
const cache = new Map();
|
|
32
|
+
return (path) => {
|
|
33
|
+
const key = signature(path);
|
|
34
|
+
// asar 里的文件 stat 拿不到签名,但内容能读,所以不能因此直接放弃。
|
|
35
|
+
// 这类路径内容不会变,用固定键缓存即可。
|
|
36
|
+
const fallbackKey = path.includes('app.asar') ? 'asar' : undefined;
|
|
37
|
+
const effectiveKey = key ?? fallbackKey;
|
|
38
|
+
if (effectiveKey === undefined) {
|
|
39
|
+
cache.delete(path);
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const cached = cache.get(path);
|
|
43
|
+
if (cached !== undefined && cached.key === effectiveKey) return cached.manifest;
|
|
44
|
+
try {
|
|
45
|
+
const manifest = JSON.parse(readFileSync(path, 'utf8'));
|
|
46
|
+
put(cache, MAX_MANIFESTS, path, { key: effectiveKey, manifest });
|
|
47
|
+
return manifest;
|
|
48
|
+
} catch {
|
|
49
|
+
cache.delete(path);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 运行时版本也算进键里:同一个插件在换 DSH 版本后必须重新解析。
|
|
56
|
+
export function createFactsReader(collect) {
|
|
57
|
+
const cache = new Map();
|
|
58
|
+
return (dir, entry, runtimeVersion) => {
|
|
59
|
+
const stamp = signature(entry);
|
|
60
|
+
const key = stamp === undefined ? `${dir}:${runtimeVersion}` : `${dir}:${runtimeVersion}:${stamp}`;
|
|
61
|
+
const cached = cache.get(dir);
|
|
62
|
+
if (cached !== undefined && cached.key === key) return cached.value;
|
|
63
|
+
const value = collect(dir, entry);
|
|
64
|
+
put(cache, MAX_FACTS, dir, { key, value });
|
|
65
|
+
return value;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 从旧符号名猜它在新版本里叫什么。
|
|
3
|
+
*
|
|
4
|
+
* 老插件引用的名字消失时,绝大多数情况不是功能没了,而是被改名或者挪了位置。
|
|
5
|
+
* 这里按几种常见的改名规律去猜,猜中就当成别名接上,猜不中再报缺。
|
|
6
|
+
*
|
|
7
|
+
* @param {string} symbol - 插件要的符号名。
|
|
8
|
+
* @param {string[]} available - 当前运行时实际导出的名字。
|
|
9
|
+
* @returns {string|undefined} 猜到的名字,猜不到就 undefined。
|
|
10
|
+
*/
|
|
11
|
+
export function guessRenamedSymbol(symbol, available) {
|
|
12
|
+
if (!Array.isArray(available) || available.length === 0) return undefined;
|
|
13
|
+
|
|
14
|
+
const lower = symbol.toLowerCase();
|
|
15
|
+
const matches = (candidate) => candidate.toLowerCase() === lower;
|
|
16
|
+
const direct = available.find(matches);
|
|
17
|
+
if (direct !== undefined) return direct;
|
|
18
|
+
|
|
19
|
+
// 去掉常见前缀再比:getXxx / useXxx / createXxx 这类包装。
|
|
20
|
+
const stripped = symbol.replace(/^(get|use|create|make|build|resolve|load|read|parse)/, '');
|
|
21
|
+
if (stripped !== '' && stripped !== symbol) {
|
|
22
|
+
const byStripped = available.find((candidate) => candidate.toLowerCase() === stripped.toLowerCase());
|
|
23
|
+
if (byStripped !== undefined) return byStripped;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 反向:给新名字加上同样的前缀。
|
|
27
|
+
const prefixed = /^(get|use|create|make|build|resolve|load|read|parse)/.exec(symbol)?.[1];
|
|
28
|
+
if (prefixed !== undefined) {
|
|
29
|
+
const withPrefix = available.find((candidate) => candidate === `${prefixed}${symbol.slice(prefixed.length)}`);
|
|
30
|
+
if (withPrefix !== undefined) return withPrefix;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 单复数差异,比如 homePaths / homePath。
|
|
34
|
+
const singular = symbol.endsWith('s') ? symbol.slice(0, -1) : `${symbol}s`;
|
|
35
|
+
const byPlural = available.find((candidate) => candidate.toLowerCase() === singular.toLowerCase());
|
|
36
|
+
if (byPlural !== undefined) return byPlural;
|
|
37
|
+
|
|
38
|
+
// 只差大小写或者下划线,比如 home_paths / homePaths。
|
|
39
|
+
const normalized = (text) => text.replace(/[_-]/g, '').toLowerCase();
|
|
40
|
+
const byShape = available.find((candidate) => normalized(candidate) === normalized(symbol));
|
|
41
|
+
if (byShape !== undefined) return byShape;
|
|
42
|
+
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|