@coclaw/openclaw-coclaw 0.26.1 → 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 +9 -7
- 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
- package/src/provider-auth/portal-model-catalog.js +11 -7
- package/src/provider-auth/reconcile.js +16 -6
|
@@ -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;
|
|
@@ -15,15 +15,18 @@
|
|
|
15
15
|
* 返回的 proper-case,name 用展示名。
|
|
16
16
|
* - 只维护**最必须的运行元数据**:`reasoning`(是否推理模型——缺省会被当成 false,导致推理
|
|
17
17
|
* 模型被按普通模型处理、思考模式出错)、`contextWindow`、`maxTokens`。**不写 `cost`**:
|
|
18
|
-
* portal 走 token plan、不按量计费,价格无意义;`input`
|
|
18
|
+
* portal 走 token plan、不按量计费,价格无意义;`input` 默认不写(系统默认即 `['text']`),
|
|
19
|
+
* 除非该型号多模态(如 M3 支持 `['text', 'image']`)才显式写出。
|
|
19
20
|
* 这几个值与上游 `model-definitions.ts`(DEFAULT_MINIMAX_CONTEXT_WINDOW=204800 /
|
|
20
|
-
* DEFAULT_MINIMAX_MAX_TOKENS=131072)+
|
|
21
|
+
* MINIMAX_M3_CONTEXT_WINDOW=1_000_000 / DEFAULT_MINIMAX_MAX_TOKENS=131072)+
|
|
22
|
+
* `provider-models.ts`(reasoning / input 标记)对齐。
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
export const PORTAL_MODEL_CATALOG = {
|
|
24
|
-
// 与 openclaw-repo/extensions/minimax/ 的 provider-models.ts(reasoning) +
|
|
25
|
-
// model-definitions.ts(contextWindow/maxTokens)
|
|
26
|
+
// 与 openclaw-repo/extensions/minimax/ 的 provider-models.ts(reasoning/input) +
|
|
27
|
+
// model-definitions.ts(contextWindow/maxTokens) 对齐;M3 起为上游默认型号、放首位
|
|
26
28
|
'minimax-portal': [
|
|
29
|
+
{ id: 'MiniMax-M3', name: 'MiniMax M3', reasoning: true, input: ['text', 'image'], contextWindow: 1000000, maxTokens: 131072 },
|
|
27
30
|
{ id: 'MiniMax-M2.7', name: 'MiniMax M2.7', reasoning: true, contextWindow: 204800, maxTokens: 131072 },
|
|
28
31
|
{ id: 'MiniMax-M2.7-highspeed', name: 'MiniMax M2.7 Highspeed', reasoning: true, contextWindow: 204800, maxTokens: 131072 },
|
|
29
32
|
],
|
|
@@ -31,15 +34,16 @@ export const PORTAL_MODEL_CATALOG = {
|
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
36
|
* 取某 provider 的静态清单。返回**深拷贝**,避免调用方改到共享常量。
|
|
34
|
-
* 未知 provider →
|
|
37
|
+
* 未知 provider → 空数组。用 `structuredClone` 而非 `{ ...m }`:条目含嵌套字段(如 M3 的
|
|
38
|
+
* `input` 数组),浅拷贝会让返回值与共享常量复用同一个数组引用,调用方 push/splice 即污染原表。
|
|
35
39
|
*
|
|
36
40
|
* @param {string} providerId
|
|
37
|
-
* @returns {{id:string, name:string, reasoning?:boolean, contextWindow?:number, maxTokens?:number}[]}
|
|
41
|
+
* @returns {{id:string, name:string, reasoning?:boolean, input?:string[], contextWindow?:number, maxTokens?:number}[]}
|
|
38
42
|
*/
|
|
39
43
|
export function getPortalModels(providerId) {
|
|
40
44
|
const list = PORTAL_MODEL_CATALOG[providerId];
|
|
41
45
|
if (!Array.isArray(list)) return [];
|
|
42
|
-
return list.map((m) => (
|
|
46
|
+
return list.map((m) => structuredClone(m));
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/**
|
|
@@ -21,25 +21,35 @@ import { getPortalModels, portalModelsCoveredById } from './portal-model-catalog
|
|
|
21
21
|
* @param {Function} opts.getConfig - () → 当前 cfg 快照(getClawConfig);null/缺时跳过
|
|
22
22
|
* @param {Function} opts.mutateConfigFile - openclaw/plugin-sdk/config-mutation 的写盘入口
|
|
23
23
|
* @param {string} [opts.providerId] - 默认 minimax-portal
|
|
24
|
+
* @param {object} [opts.logger] - gateway 注入的 pino 风格 logger(本地诊断)
|
|
25
|
+
* @param {Function} [opts.remoteLog] - remoteLog(text)(远程诊断;启动一次、低频)
|
|
24
26
|
* @returns {Promise<{changed:boolean, reason:string}>} reason: no-config|not-bound|no-catalog|in-sync|updated
|
|
25
27
|
*/
|
|
26
|
-
export async function reconcilePortalModels({ getConfig, mutateConfigFile, providerId = PORTAL_PROVIDER_ID }) {
|
|
28
|
+
export async function reconcilePortalModels({ getConfig, mutateConfigFile, providerId = PORTAL_PROVIDER_ID, logger, remoteLog }) {
|
|
27
29
|
const cfg = getConfig?.();
|
|
28
|
-
// runtime 未注入 / config
|
|
30
|
+
// runtime 未注入 / config 不可读:跳过,下次启动再对(非事件,不记诊断)
|
|
29
31
|
if (!cfg || typeof cfg !== 'object') return { changed: false, reason: 'no-config' };
|
|
30
32
|
const node = cfg.models?.providers?.[providerId];
|
|
31
|
-
// 未绑定(无 provider 节点)→ 不碰。登录成功时已写过节点 +
|
|
33
|
+
// 未绑定(无 provider 节点)→ 不碰。登录成功时已写过节点 + 清单,绑定后才谈得上对账。
|
|
34
|
+
// 同属非事件(未用该 provider 的网关每次启动都会到这)→ 不记诊断,避免刷屏
|
|
32
35
|
if (!node || typeof node !== 'object' || Array.isArray(node)) return { changed: false, reason: 'not-bound' };
|
|
36
|
+
// 到这里 provider 已绑定,"模型清单是否需要注入"才成立——后续每个结果都记诊断(本地 log + remoteLog)。
|
|
37
|
+
// remoteLog 一次启动一条、低频,符合约定。
|
|
33
38
|
const target = getPortalModels(providerId);
|
|
39
|
+
const finish = (changed, reason, count) => {
|
|
40
|
+
logger?.info?.(`[coclaw] ${providerId} model reconcile: ${changed ? 'synced' : 'no write'} (reason=${reason}, models=${count})`);
|
|
41
|
+
remoteLog?.(`providerAuth.portalReconcile reason=${reason} changed=${changed} provider=${providerId} models=${count}`);
|
|
42
|
+
return { changed, reason };
|
|
43
|
+
};
|
|
34
44
|
// 表里没这个 provider(理论不该发生)→ 不动用户已有清单
|
|
35
|
-
if (target.length === 0) return
|
|
45
|
+
if (target.length === 0) return finish(false, 'no-catalog', 0);
|
|
36
46
|
// 只按 id 判"已覆盖":目标里每个 model id 都已在配置现有清单出现 → 视为已同步、零写入。
|
|
37
47
|
// 比"全等"宽容——配置是我们的超集(别的来源,如官方 MiniMax 插件,多写了几个模型)时也判已覆盖、
|
|
38
48
|
// 不去动它,避免和它来回覆盖、反复重启。仅当配置缺了我们某个 id(升级新增模型 / 老配置不全)才写。
|
|
39
49
|
// 顺带说清读/写不对称:getConfig 读「解析后」配置(config.current()),mutateConfigFile 默认写
|
|
40
50
|
// 「源」配置。即便上游将来在解析期给第三方 portal 注入额外模型,那也只是让配置成超集、我们的 id 仍在
|
|
41
51
|
// → 判已覆盖 → 不写,不会触发"永远判不一致、每次启动都写"的循环。
|
|
42
|
-
if (portalModelsCoveredById(node.models, target)) return
|
|
52
|
+
if (portalModelsCoveredById(node.models, target)) return finish(false, 'in-sync', target.length);
|
|
43
53
|
|
|
44
54
|
await mutateConfigFile({
|
|
45
55
|
afterWrite: { mode: 'auto' },
|
|
@@ -50,5 +60,5 @@ export async function reconcilePortalModels({ getConfig, mutateConfigFile, provi
|
|
|
50
60
|
p.models = getPortalModels(providerId);
|
|
51
61
|
},
|
|
52
62
|
});
|
|
53
|
-
return
|
|
63
|
+
return finish(true, 'updated', target.length);
|
|
54
64
|
}
|