@coclaw/openclaw-coclaw 0.26.2 → 0.26.3
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/index.js +7 -5
- package/package.json +1 -1
- package/src/auto-upgrade/state.js +136 -21
- package/src/auto-upgrade/updater-check.js +66 -1
- package/src/auto-upgrade/updater-spawn.js +92 -7
- package/src/auto-upgrade/updater.js +347 -119
- package/src/auto-upgrade/worker-backup.js +67 -30
- package/src/auto-upgrade/worker-verify.js +22 -21
- package/src/auto-upgrade/worker.js +221 -68
- package/src/plugin-version.js +3 -1
|
@@ -2,21 +2,26 @@
|
|
|
2
2
|
* worker.js — 由 updater-spawn 以 detached 进程启动
|
|
3
3
|
*
|
|
4
4
|
* 用法:node worker.js --pluginDir <dir> --fromVersion <ver> --toVersion <ver>
|
|
5
|
-
* --pluginId <id> --pkgName <name>
|
|
5
|
+
* --pluginId <id> --pkgName <name> [--baselineVersion <ver>]
|
|
6
6
|
*
|
|
7
|
-
* 流程:备份 →
|
|
7
|
+
* 流程:备份 → 写 inflight → openclaw plugins update → L2 结局核对(inspect 安装记录)
|
|
8
|
+
* → 等待 gateway 重启 → 验证 → 成功清理/失败回滚/未推进 no-op 跳过
|
|
9
|
+
* → 终态记账(recordUpgradeTerminal 原子写 lastUpgrade + 清 inflight)
|
|
8
10
|
*
|
|
9
11
|
* 注意:
|
|
10
12
|
* - 本模块作为独立 node 进程运行,与 gateway 进程隔离
|
|
11
13
|
* - state dir 通过 OPENCLAW_STATE_DIR 环境变量由 spawner 传入
|
|
12
14
|
* - shell 仅在 Windows 启用(openclaw 全局安装生成 .cmd 包装器,需 shell 解析)
|
|
15
|
+
* - 所有 state 写入非致命:独立 try/catch + 日志,失败继续流程(账目兜底交
|
|
16
|
+
* scheduler 的 inflight 对账);worker 是独立子进程,禁 remoteLog
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
import { execFile as nodeExecFile } from 'node:child_process';
|
|
16
20
|
import { parseArgs } from 'node:util';
|
|
17
21
|
import { createBackup, restoreFromBackup, removeBackup } from './worker-backup.js';
|
|
18
|
-
import { verifyUpgrade, triggerGatewayRestart } from './worker-verify.js';
|
|
19
|
-
import {
|
|
22
|
+
import { verifyUpgrade, triggerGatewayRestart, isVersionReached } from './worker-verify.js';
|
|
23
|
+
import { inspectPluginInstall } from './updater-check.js';
|
|
24
|
+
import { appendLog, recordUpgradeTerminal, updateInflight, writeInflight } from './state.js';
|
|
20
25
|
import { getCurrentNpmRegistry, pickFallbackRegistry } from './registry-fallback.js';
|
|
21
26
|
|
|
22
27
|
const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.-]+)?$/;
|
|
@@ -25,21 +30,55 @@ const UPDATE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
25
30
|
// 回滚兜底重装旧版本走的是同一条 npm 下载链路,且触发前置本身是"备份已丢"的异常态,
|
|
26
31
|
// 此时尽量兜住比快速失败更重要,与 UPDATE_TIMEOUT_MS 对齐
|
|
27
32
|
const FALLBACK_INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
28
|
-
|
|
33
|
+
// 子命令双流各取尾部上限:真因(prerelease 拒装等)通常在输出尾部
|
|
34
|
+
const CMD_OUTPUT_TAIL_CHARS = 500;
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
|
-
*
|
|
37
|
+
* 把子命令失败的真因拼进错误消息。
|
|
38
|
+
* 上游错误 outcome 走 console.log(stdout)而 execFile err.message 只附 stderr,
|
|
39
|
+
* 必须双流都收。先脱敏再截尾:截断可能把凭据切半导致正则漏匹配。
|
|
40
|
+
* @param {string} prefix - 错误前缀(如 "plugins update failed")
|
|
41
|
+
* @param {Error} err - execFile 回调的 err
|
|
42
|
+
* @param {string|Buffer} stdout
|
|
43
|
+
* @param {string|Buffer} stderr
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function formatCmdFailure(prefix, err, stdout, stderr) {
|
|
47
|
+
const scrub = (s) => String(s ?? '')
|
|
48
|
+
// registry URL 的 userinfo(https://user:pass@host)
|
|
49
|
+
.replace(/:\/\/[^@\s/]+@/g, '://***@')
|
|
50
|
+
// .npmrc 风格 token(//host/:_authToken=xxx)
|
|
51
|
+
.replace(/(_authToken\s*=\s*)\S+/gi, '$1***');
|
|
52
|
+
const tail = (s) => {
|
|
53
|
+
const t = s.trim();
|
|
54
|
+
return t.length > CMD_OUTPUT_TAIL_CHARS ? t.slice(-CMD_OUTPUT_TAIL_CHARS) : t;
|
|
55
|
+
};
|
|
56
|
+
/* c8 ignore next -- ?? fallback */
|
|
57
|
+
const parts = [`${prefix}: ${tail(scrub(err?.message ?? String(err)))}`];
|
|
58
|
+
const out = tail(scrub(stdout));
|
|
59
|
+
if (out) parts.push(`stdout: ${out}`);
|
|
60
|
+
const errOut = tail(scrub(stderr));
|
|
61
|
+
if (errOut) parts.push(`stderr: ${errOut}`);
|
|
62
|
+
return parts.join(' | ');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 执行 openclaw plugins update(裸 npm 包名)
|
|
32
67
|
*
|
|
33
68
|
* 仅支持 source === "npm" 的安装(updater 已做前置过滤)。
|
|
69
|
+
* 用裸包名而非插件 id:裸包名 update 走包名匹配产生 specOverride,成功后把
|
|
70
|
+
* 安装记录的 spec 重写为裸名——一次 update 同时"装 latest + 解钉"。否则
|
|
71
|
+
* fallback install 留下的 `pkg@x.y.z` 精确 spec 会让 update 永远 resolve
|
|
72
|
+
* 同一版本,自动升级被永久钉死。
|
|
34
73
|
* env 由调用方决定:缺省时子进程继承当前 process.env(含用户 .npmrc 自动生效);
|
|
35
74
|
* 显式传入时用于覆盖 registry 等 npm 配置以做兜底重试。
|
|
36
|
-
* @param {string}
|
|
75
|
+
* @param {string} pkgName - npm 包名
|
|
37
76
|
* @param {object} [opts]
|
|
38
77
|
* @param {Function} [opts.execFileFn]
|
|
39
78
|
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
40
79
|
* @returns {Promise<void>}
|
|
41
80
|
*/
|
|
42
|
-
function runPluginUpdate(
|
|
81
|
+
function runPluginUpdate(pkgName, opts) {
|
|
43
82
|
/* c8 ignore next -- ?./?? fallback */
|
|
44
83
|
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
45
84
|
return new Promise((resolve, reject) => {
|
|
@@ -49,53 +88,43 @@ function runPluginUpdate(pluginId, opts) {
|
|
|
49
88
|
};
|
|
50
89
|
// 不传 env 时让 Node 默认继承父进程;显式 env 才覆盖
|
|
51
90
|
if (opts?.env) execOpts.env = opts.env;
|
|
52
|
-
doExecFile('openclaw', ['plugins', 'update',
|
|
53
|
-
if (err) reject(new Error(
|
|
91
|
+
doExecFile('openclaw', ['plugins', 'update', pkgName], execOpts, (err, stdout, stderr) => {
|
|
92
|
+
if (err) reject(new Error(formatCmdFailure('plugins update failed', err, stdout, stderr)));
|
|
54
93
|
else resolve();
|
|
55
94
|
});
|
|
56
95
|
});
|
|
57
96
|
}
|
|
58
97
|
|
|
59
98
|
/**
|
|
60
|
-
* 尝试通过 npm
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* uninstall
|
|
99
|
+
* 尝试通过 npm 安装旧版本进行兜底回滚。
|
|
100
|
+
* 单命令 `plugins install <pkg>@<ver> --force`:--force 即覆盖装(上游映射
|
|
101
|
+
* mode=update,绕开 "already exists" 拒绝),不再需要 uninstall 前置——
|
|
102
|
+
* 旧的 uninstall 在非 TTY 下要求交互确认必失败且错误被静默吞。
|
|
64
103
|
*
|
|
65
104
|
* @param {string} pkgName - npm 包名
|
|
66
105
|
* @param {string} version
|
|
67
|
-
* @param {string} pluginId - 插件 ID
|
|
68
106
|
* @param {object} [opts]
|
|
69
107
|
* @param {Function} [opts.execFileFn]
|
|
70
108
|
* @returns {Promise<void>}
|
|
71
109
|
*/
|
|
72
|
-
|
|
73
|
-
async function fallbackInstallOldVersion(pkgName, version, pluginId, opts) {
|
|
110
|
+
async function fallbackInstallOldVersion(pkgName, version, opts) {
|
|
74
111
|
// version 来自 package.json,正常不会有异常值,但 shell: true 下做防御校验
|
|
75
112
|
if (!SEMVER_RE.test(version)) {
|
|
76
113
|
throw new Error(`invalid version format: ${version}`);
|
|
77
114
|
}
|
|
78
115
|
/* c8 ignore next -- ?./?? fallback */
|
|
79
116
|
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
80
|
-
|
|
81
|
-
doExecFile(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
doExecFile(
|
|
119
|
+
'openclaw',
|
|
120
|
+
['plugins', 'install', `${pkgName}@${version}`, '--force'],
|
|
121
|
+
{ timeout: FALLBACK_INSTALL_TIMEOUT_MS, shell: process.platform === 'win32' },
|
|
122
|
+
(err, stdout, stderr) => {
|
|
123
|
+
if (err) reject(new Error(formatCmdFailure('fallback install failed', err, stdout, stderr)));
|
|
124
|
+
else resolve();
|
|
125
|
+
},
|
|
126
|
+
);
|
|
85
127
|
});
|
|
86
|
-
|
|
87
|
-
// 先卸载:install 不支持覆盖已安装插件
|
|
88
|
-
try {
|
|
89
|
-
await run(['plugins', 'uninstall', pluginId], FALLBACK_UNINSTALL_TIMEOUT_MS);
|
|
90
|
-
} catch {
|
|
91
|
-
// uninstall 失败不阻断,继续尝试 install
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
try {
|
|
95
|
-
await run(['plugins', 'install', `${pkgName}@${version}`], FALLBACK_INSTALL_TIMEOUT_MS);
|
|
96
|
-
} catch (err) {
|
|
97
|
-
throw new Error(`fallback install failed: ${err.message}`);
|
|
98
|
-
}
|
|
99
128
|
}
|
|
100
129
|
|
|
101
130
|
/**
|
|
@@ -104,6 +133,7 @@ async function fallbackInstallOldVersion(pkgName, version, pluginId, opts) {
|
|
|
104
133
|
* @param {string} params.pluginDir - 插件安装目录
|
|
105
134
|
* @param {string} params.fromVersion - 当前版本
|
|
106
135
|
* @param {string} params.toVersion - 目标版本
|
|
136
|
+
* @param {string} [params.baselineVersion] - 升级前权威安装记录的版本(L2 基线;可缺)
|
|
107
137
|
* @param {string} params.pluginId - 插件 ID
|
|
108
138
|
* @param {string} params.pkgName - npm 包名
|
|
109
139
|
* @param {object} [params.opts] - 测试注入选项
|
|
@@ -112,7 +142,7 @@ async function fallbackInstallOldVersion(pkgName, version, pluginId, opts) {
|
|
|
112
142
|
* @param {number} [params.opts.pollIntervalMs]
|
|
113
143
|
* @param {Function} [params.logger] - 日志函数
|
|
114
144
|
*/
|
|
115
|
-
export async function runUpgrade({ pluginDir, fromVersion, toVersion, pluginId, pkgName, opts, logger }) {
|
|
145
|
+
export async function runUpgrade({ pluginDir, fromVersion, toVersion, baselineVersion, pluginId, pkgName, opts, logger }) {
|
|
116
146
|
const log = logger ?? console.log;
|
|
117
147
|
|
|
118
148
|
log(`[upgrade-worker] Starting upgrade: ${fromVersion} → ${toVersion}`);
|
|
@@ -120,14 +150,23 @@ export async function runUpgrade({ pluginDir, fromVersion, toVersion, pluginId,
|
|
|
120
150
|
|
|
121
151
|
// 1. 备份
|
|
122
152
|
log('[upgrade-worker] Creating backup...');
|
|
123
|
-
await createBackup(pluginDir);
|
|
153
|
+
await createBackup(pluginDir, pluginId);
|
|
124
154
|
log('[upgrade-worker] Backup created');
|
|
125
155
|
|
|
156
|
+
// 进入改盘阶段前写 inflight:worker 若没活到终态记账(典型:被自己触发的
|
|
157
|
+
// 网关重启杀死),scheduler 下轮锁空闲时据此对账补记终态
|
|
158
|
+
try {
|
|
159
|
+
await writeInflight({ from: fromVersion, to: toVersion, verifyTarget: toVersion, pluginDir, phase: 'update' });
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
log(`[upgrade-worker] Failed to write inflight marker (non-fatal): ${e.message}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
126
165
|
// 2. 执行升级(首次按用户原 env,失败后用反向 mirror 重试一次)
|
|
127
166
|
log('[upgrade-worker] Running plugins update...');
|
|
128
167
|
let updateErr = null;
|
|
129
168
|
try {
|
|
130
|
-
await runPluginUpdate(
|
|
169
|
+
await runPluginUpdate(pkgName, opts);
|
|
131
170
|
log('[upgrade-worker] Update command completed');
|
|
132
171
|
}
|
|
133
172
|
catch (firstErr) {
|
|
@@ -143,7 +182,7 @@ export async function runUpgrade({ pluginDir, fromVersion, toVersion, pluginId,
|
|
|
143
182
|
const retryEnv = { ...process.env };
|
|
144
183
|
delete retryEnv.NPM_CONFIG_REGISTRY;
|
|
145
184
|
retryEnv.npm_config_registry = fallback;
|
|
146
|
-
await runPluginUpdate(
|
|
185
|
+
await runPluginUpdate(pkgName, { ...opts, env: retryEnv });
|
|
147
186
|
log('[upgrade-worker] Update command completed on retry');
|
|
148
187
|
updateErr = null;
|
|
149
188
|
}
|
|
@@ -162,24 +201,106 @@ export async function runUpgrade({ pluginDir, fromVersion, toVersion, pluginId,
|
|
|
162
201
|
return;
|
|
163
202
|
}
|
|
164
203
|
|
|
165
|
-
// 3.
|
|
204
|
+
// 3. L2 结局核对:update exit 0 不代表真升级(老 host 出错也 exit 0、path/缺记录干净
|
|
205
|
+
// skip 也 exit 0、registry 假成功、latest-compatible 封顶)。经权威 inspect 读升级后
|
|
206
|
+
// 安装记录分流结局;stdout 文本无契约承诺,仅记日志不作判据。
|
|
207
|
+
// worker 是独立子进程、无 bridge 连接,禁 remoteLog——诊断只写本地日志,
|
|
208
|
+
// 结局经 lastUpgrade 接 scheduler 下轮上报链。
|
|
209
|
+
log('[upgrade-worker] Inspecting install record (post-update)...');
|
|
210
|
+
// inspectPluginInstall 契约是永不抛,但注入实现可能同步抛错;裸抛会 fatal exit 1
|
|
211
|
+
// (留备份、无状态记录),故归一化为 inspect 自身失败,走下方保守分支
|
|
212
|
+
let inspected;
|
|
213
|
+
try {
|
|
214
|
+
inspected = await inspectPluginInstall(pluginId, opts);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
/* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
|
|
218
|
+
const msg = err?.message ?? String(err);
|
|
219
|
+
inspected = { ok: false, reason: `inspect threw: ${msg}` };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let verifyTarget = toVersion;
|
|
223
|
+
// record 推进但未达标:按实装版本验证健康,成功后对 toVersion 记跳过(已知到不了)
|
|
224
|
+
let advancedShortfall = false;
|
|
225
|
+
if (inspected.ok && typeof inspected.install?.version === 'string') {
|
|
226
|
+
const recordVersion = inspected.install.version;
|
|
227
|
+
if (isVersionReached(recordVersion, toVersion)) {
|
|
228
|
+
// 达标(等于或更新,覆盖 dist-tag 前移):真升级,走现行 restart + 健康轮询流
|
|
229
|
+
log(`[upgrade-worker] Install record reached target: ${recordVersion}`);
|
|
230
|
+
}
|
|
231
|
+
else if (!baselineVersion) {
|
|
232
|
+
// 基线不可得:无从判断 record 是否推进,退化为现行流(restart + verify(toVersion))
|
|
233
|
+
log(`[upgrade-worker] Record version ${recordVersion} below target but baseline unknown; proceeding with standard verify`);
|
|
234
|
+
}
|
|
235
|
+
else if (recordVersion === baselineVersion) {
|
|
236
|
+
// record 未推进:update 干净 skip / registry 假成功——磁盘什么都没变。
|
|
237
|
+
// no-op:不重启、不回滚,删备份,立即记 skipVersion 停止每小时空转重试
|
|
238
|
+
// (瞬时故障在新 host 上走 exit≠0 原路径,不会落到这里被永久跳过)
|
|
239
|
+
log(`[upgrade-worker] Install record did not advance (still ${recordVersion}); no-op skip for ${toVersion}`);
|
|
240
|
+
try { await removeBackup(pluginId); }
|
|
241
|
+
catch (e) { log(`[upgrade-worker] Backup cleanup failed (non-fatal): ${e.message}`); }
|
|
242
|
+
try {
|
|
243
|
+
await recordUpgradeTerminal({
|
|
244
|
+
from: fromVersion, to: toVersion, result: 'noop-skip', skipVersion: toVersion,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
/* c8 ignore next -- 状态写入 catch:测试中 stub 不会失败 */
|
|
248
|
+
catch (e) { log(`[upgrade-worker] Failed to record terminal state (non-fatal): ${e.message}`); }
|
|
249
|
+
log(`[upgrade-worker] No-op skip complete. Version ${toVersion} added to skipped list`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
// record 推进但未达标(latest-compatible 封顶等):磁盘确已换代,
|
|
254
|
+
// 必须健康验证实装版本,避免未经验证的新副本在下次自然重启时静默激活
|
|
255
|
+
log(`[upgrade-worker] Install record advanced to ${recordVersion} (target ${toVersion} not reached); verifying actual version`);
|
|
256
|
+
verifyTarget = recordVersion;
|
|
257
|
+
advancedShortfall = true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
// inspect 自身失败 / 记录缺版本:保守按"真升级"处理,进现行 restart + verify
|
|
262
|
+
//(健康检查 + 回滚兜底),避免工具故障静默压制激活
|
|
263
|
+
const reason = inspected.ok ? 'install record missing version' : inspected.reason;
|
|
264
|
+
log(`[upgrade-worker] Post-update inspect unavailable (${reason}); proceeding with standard verify`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 4. 等待 gateway 重启并验证;phase/verifyTarget 推进进 inflight,
|
|
268
|
+
// 中断时 scheduler 对账据 verifyTarget 判定成败(advancedShortfall 语义同步)
|
|
269
|
+
try {
|
|
270
|
+
await updateInflight({ phase: 'verify', verifyTarget });
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
log(`[upgrade-worker] Failed to update inflight marker (non-fatal): ${e.message}`);
|
|
274
|
+
}
|
|
166
275
|
log('[upgrade-worker] Verifying upgrade...');
|
|
167
|
-
const result = await verifyUpgrade(pluginDir,
|
|
276
|
+
const result = await verifyUpgrade(pluginDir, verifyTarget, opts, log);
|
|
168
277
|
|
|
169
278
|
if (result.ok) {
|
|
170
279
|
// 4a. 成功
|
|
171
280
|
log(`[upgrade-worker] Upgrade verified. Version: ${result.version}`);
|
|
172
281
|
try {
|
|
173
|
-
await removeBackup(
|
|
282
|
+
await removeBackup(pluginId);
|
|
174
283
|
}
|
|
175
284
|
catch (e) {
|
|
176
285
|
log(`[upgrade-worker] Backup cleanup failed (non-fatal): ${e.message}`);
|
|
177
286
|
}
|
|
178
287
|
// 记录真实装上的版本而非目标版本——dist-tag 前移窗口下两者可能不同。
|
|
179
288
|
// 不加 fallback:若 result.ok 时 version 缺失,说明上游契约被破坏,
|
|
180
|
-
// 宁可让状态里直接暴露 undefined 便于排障,也不要用 toVersion
|
|
181
|
-
|
|
182
|
-
|
|
289
|
+
// 宁可让状态里直接暴露 undefined 便于排障,也不要用 toVersion 糊过去。
|
|
290
|
+
// advancedShortfall:实装版本健康但 toVersion 在本 host 装不上 → 记跳过,
|
|
291
|
+
// 停止注定不达标的重试
|
|
292
|
+
try {
|
|
293
|
+
await recordUpgradeTerminal({
|
|
294
|
+
from: fromVersion, to: result.version, result: 'ok',
|
|
295
|
+
skipVersion: advancedShortfall ? toVersion : undefined,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
log(`[upgrade-worker] Failed to record terminal state (non-fatal): ${e.message}`);
|
|
300
|
+
}
|
|
301
|
+
if (advancedShortfall) {
|
|
302
|
+
log(`[upgrade-worker] Version ${toVersion} added to skipped list (host capped below target)`);
|
|
303
|
+
}
|
|
183
304
|
log('[upgrade-worker] Upgrade complete');
|
|
184
305
|
} else {
|
|
185
306
|
// 4b. 失败,回滚
|
|
@@ -192,52 +313,83 @@ export async function runUpgrade({ pluginDir, fromVersion, toVersion, pluginId,
|
|
|
192
313
|
}
|
|
193
314
|
|
|
194
315
|
/**
|
|
195
|
-
*
|
|
316
|
+
* 回滚处理。
|
|
317
|
+
* result=rollback 指**文件态已恢复**(restore 或 fallback install 任一成功),
|
|
318
|
+
* 运行态恢复交还重启/上游 watcher;两路都失败记 rollback-failed(error 带真因)。
|
|
319
|
+
* 记账整体在 triggerGatewayRestart **之前**:回滚后文件态已定,记账不依赖重启,
|
|
320
|
+
* 且 worker 在不可脱逃形态下可能被自己触发的重启杀死。
|
|
196
321
|
*/
|
|
197
322
|
async function handleRollback({ pluginDir, fromVersion, toVersion, pluginId, pkgName, error, skipVersion, opts, log }) {
|
|
198
323
|
log('[upgrade-worker] Attempting rollback...');
|
|
324
|
+
try {
|
|
325
|
+
await updateInflight({ phase: 'rollback' });
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
log(`[upgrade-worker] Failed to update inflight marker (non-fatal): ${e.message}`);
|
|
329
|
+
}
|
|
199
330
|
|
|
200
|
-
// 首选
|
|
331
|
+
// 首选 rename 备份目录
|
|
201
332
|
let restored = false;
|
|
333
|
+
let fallbackOk = false;
|
|
334
|
+
let rollbackErrMsg = '';
|
|
202
335
|
try {
|
|
203
|
-
restored = await restoreFromBackup(pluginDir);
|
|
336
|
+
restored = await restoreFromBackup(pluginDir, pluginId, { log });
|
|
204
337
|
} catch (restoreErr) {
|
|
338
|
+
rollbackErrMsg = `restore: ${restoreErr.message}`;
|
|
205
339
|
log(`[upgrade-worker] Backup restore error: ${restoreErr.message}`);
|
|
206
340
|
}
|
|
207
341
|
|
|
208
342
|
if (restored) {
|
|
209
343
|
log('[upgrade-worker] Restored from backup');
|
|
210
344
|
} else {
|
|
211
|
-
//
|
|
345
|
+
// 兜底:从 npm 覆盖安装旧版本
|
|
212
346
|
log('[upgrade-worker] Backup restore failed, falling back to npm install');
|
|
213
347
|
try {
|
|
214
|
-
await fallbackInstallOldVersion(pkgName, fromVersion,
|
|
348
|
+
await fallbackInstallOldVersion(pkgName, fromVersion, opts);
|
|
349
|
+
fallbackOk = true;
|
|
215
350
|
log('[upgrade-worker] Fallback install completed');
|
|
216
351
|
}
|
|
217
352
|
catch (fallbackErr) {
|
|
353
|
+
rollbackErrMsg = rollbackErrMsg ? `${rollbackErrMsg}; ${fallbackErr.message}` : fallbackErr.message;
|
|
218
354
|
log(`[upgrade-worker] Fallback install also failed: ${fallbackErr.message}`);
|
|
219
355
|
}
|
|
220
356
|
}
|
|
221
357
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
358
|
+
const rollbackOk = restored || fallbackOk;
|
|
359
|
+
// rollback-failed 时 error 带真因:!rollbackOk 必经 fallback 抛错,rollbackErrMsg 非空
|
|
360
|
+
let finalError = error;
|
|
361
|
+
if (!rollbackOk) {
|
|
362
|
+
finalError = `${error}; rollback failed: ${rollbackErrMsg}`;
|
|
363
|
+
}
|
|
225
364
|
|
|
226
|
-
//
|
|
365
|
+
// 记录状态(重启前完成;失败非致命,inflight 未清交 scheduler 对账兜底)
|
|
227
366
|
// 仅验证失败(新版本确实被加载并发现有问题)才标记为 skipped;
|
|
228
367
|
// update 命令失败可能是瞬态故障(网络、磁盘等),不应永久跳过该版本
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
368
|
+
try {
|
|
369
|
+
await recordUpgradeTerminal({
|
|
370
|
+
from: fromVersion, to: toVersion,
|
|
371
|
+
result: rollbackOk ? 'rollback' : 'rollback-failed',
|
|
372
|
+
error: finalError,
|
|
373
|
+
skipVersion: skipVersion ? toVersion : undefined,
|
|
374
|
+
});
|
|
233
375
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
376
|
+
catch (e) {
|
|
377
|
+
log(`[upgrade-worker] Failed to record terminal state (non-fatal): ${e.message}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 触发 gateway 重启让老版本回到运行态(尽力而为,不验证结果)
|
|
381
|
+
log('[upgrade-worker] Triggering gateway restart after rollback...');
|
|
382
|
+
const restartOk = await triggerGatewayRestart(opts);
|
|
383
|
+
if (!restartOk) {
|
|
384
|
+
// 文件态已恢复但运行态未刷新;worker 禁 remoteLog,只落 jsonl 事件供诊断
|
|
385
|
+
log('[upgrade-worker] Gateway restart command failed after rollback');
|
|
386
|
+
try { await appendLog({ event: 'rollback-restart-failed', from: fromVersion, to: toVersion }); }
|
|
387
|
+
catch (e) { log(`[upgrade-worker] Failed to append log (non-fatal): ${e.message}`); }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!rollbackOk) {
|
|
391
|
+
log(`[upgrade-worker] Rollback failed. Version ${toVersion} may still be active on disk`);
|
|
392
|
+
} else if (skipVersion) {
|
|
241
393
|
log(`[upgrade-worker] Rollback complete. Version ${toVersion} added to skipped list`);
|
|
242
394
|
} else {
|
|
243
395
|
log(`[upgrade-worker] Rollback complete. Version ${toVersion} not skipped (transient failure)`);
|
|
@@ -252,20 +404,21 @@ async function main() {
|
|
|
252
404
|
pluginDir: { type: 'string' },
|
|
253
405
|
fromVersion: { type: 'string' },
|
|
254
406
|
toVersion: { type: 'string' },
|
|
407
|
+
baselineVersion: { type: 'string' }, // 可缺:缺时按"基线不可得"退化处理
|
|
255
408
|
pluginId: { type: 'string' },
|
|
256
409
|
pkgName: { type: 'string' },
|
|
257
410
|
},
|
|
258
411
|
strict: true,
|
|
259
412
|
});
|
|
260
413
|
|
|
261
|
-
const { pluginDir, fromVersion, toVersion, pluginId, pkgName } = values;
|
|
414
|
+
const { pluginDir, fromVersion, toVersion, baselineVersion, pluginId, pkgName } = values;
|
|
262
415
|
if (!pluginDir || !fromVersion || !toVersion || !pluginId || !pkgName) {
|
|
263
|
-
console.error('Usage: node worker.js --pluginDir <dir> --fromVersion <ver> --toVersion <ver> --pluginId <id> --pkgName <name>');
|
|
416
|
+
console.error('Usage: node worker.js --pluginDir <dir> --fromVersion <ver> --toVersion <ver> --pluginId <id> --pkgName <name> [--baselineVersion <ver>]');
|
|
264
417
|
process.exit(1);
|
|
265
418
|
}
|
|
266
419
|
|
|
267
420
|
try {
|
|
268
|
-
await runUpgrade({ pluginDir, fromVersion, toVersion, pluginId, pkgName });
|
|
421
|
+
await runUpgrade({ pluginDir, fromVersion, toVersion, baselineVersion, pluginId, pkgName });
|
|
269
422
|
process.exit(0);
|
|
270
423
|
}
|
|
271
424
|
catch (err) {
|
package/src/plugin-version.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import nodePath from 'node:path';
|
|
3
3
|
|
|
4
|
-
// 延迟读取 + 缓存:避免模块加载时 package.json
|
|
4
|
+
// 延迟读取 + 缓存:避免模块加载时 package.json 损坏导致插件整体无法注册。
|
|
5
|
+
// 仅供 info 类展示;upgradeHealth / inflight 对账的判定禁用本原语,
|
|
6
|
+
// 必须用 src/auto-upgrade/updater.js 的加载时刻快照(getLoadedPluginVersion)。
|
|
5
7
|
let __pluginVersion = null;
|
|
6
8
|
export async function getPluginVersion() {
|
|
7
9
|
if (__pluginVersion) return __pluginVersion;
|