@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.
@@ -1,33 +1,55 @@
1
1
  /**
2
2
  * worker-backup.js — 插件目录物理备份与恢复
3
3
  *
4
- * 使用 Node.js 内置 fs.cp()(16.7+)进行跨平台物理复制,无外部依赖。
5
- * 备份采用原子操作:先 cp .tmp.bak,再 rename 到 .bak,
6
- * 避免中途失败产生不完整的备份目录。
4
+ * 备份目录固定在 `<state-dir>/coclaw/upgrade-backup/<pluginId>/`——必须在
5
+ * npm 地盘(extensions/<id>/node_modules)之外:`plugins update` npm
6
+ * 会把安装目录里的陌生 `.bak` 目录当 extraneous 修剪掉(本机实测 update 后
7
+ * 约 46s 消失),导致回滚时备份已无。state-dir 下的自管目录免疫 npm prune。
7
8
  *
8
- * 命名约束:备份目录(含临时目录)必须以 .bak 结尾。
9
- * OpenClaw gateway 启动时会扫描 extensions/ 下所有子目录并尝试作为插件加载,
10
- * 但会跳过以 .bak 结尾的目录(discovery.ts shouldIgnoreScannedDirectory)。
11
- * 若临时目录不以 .bak 结尾(如曾用的 .bak-tmp),在 fs.cp 期间 gateway
12
- * 重启会将不完整的目录当作插件加载,导致 method 重复注册或加载异常。
9
+ * state-dir 解析复用 state.js 的双轨通道(gateway 进程走 runtime 注入,
10
+ * worker 子进程走 OPENCLAW_STATE_DIR 环境变量)。
11
+ *
12
+ * 备份采用原子操作:先 cp `<pluginId>.tmp`,再 rename `<pluginId>`,
13
+ * 避免中途失败产生不完整的备份目录。恢复时备份目录与插件目录可能跨文件系统
14
+ * (state-dir 与 extensions 不保证同盘),rename 报 EXDEV 时退化为 cp+rm。
15
+ *
16
+ * 残留语义:interrupted 等异常分支不主动清备份——保留给人工恢复,下次备份前
17
+ * 覆盖;固定目录 + 升级锁保证同一插件至多一份,无累积风险。
13
18
  */
14
19
  import fs from 'node:fs/promises';
15
20
  import nodePath from 'node:path';
16
21
 
22
+ import { resolveStateDir } from './state.js';
23
+
24
+ const CHANNEL_ID = 'coclaw';
25
+ const BACKUP_DIRNAME = 'upgrade-backup';
26
+
27
+ /**
28
+ * 备份目录路径:`<state-dir>/coclaw/upgrade-backup/<pluginId>`
29
+ * @param {string} pluginId
30
+ * @returns {string}
31
+ */
32
+ export function getBackupDir(pluginId) {
33
+ return nodePath.join(resolveStateDir(), CHANNEL_ID, BACKUP_DIRNAME, pluginId);
34
+ }
35
+
17
36
  /**
18
37
  * 备份插件目录
19
38
  * @param {string} pluginDir - 插件安装目录
39
+ * @param {string} pluginId - 插件 ID(备份目录名)
20
40
  * @returns {Promise<string>} 备份目录路径
21
41
  */
22
- export async function createBackup(pluginDir) {
23
- const backupDir = `${pluginDir}.bak`;
42
+ export async function createBackup(pluginDir, pluginId) {
43
+ const backupDir = getBackupDir(pluginId);
24
44
 
25
- // 若上次异常退出遗留了 .bak,先清理
45
+ // 若上次异常退出遗留了旧备份/临时目录,先清理
26
46
  await fs.rm(backupDir, { recursive: true, force: true });
27
-
28
- // 先复制到临时名,再 rename,确保原子性
29
- const tmpDir = `${pluginDir}.tmp.bak`;
47
+ const tmpDir = `${backupDir}.tmp`;
30
48
  await fs.rm(tmpDir, { recursive: true, force: true });
49
+
50
+ // 先复制到临时名,再 rename(同目录内必为同文件系统,rename 原子),
51
+ // 确保备份目录要么完整要么不存在
52
+ await fs.mkdir(nodePath.dirname(backupDir), { recursive: true });
31
53
  await fs.cp(pluginDir, tmpDir, { recursive: true });
32
54
  await fs.rename(tmpDir, backupDir);
33
55
 
@@ -37,10 +59,17 @@ export async function createBackup(pluginDir) {
37
59
  /**
38
60
  * 从备份恢复插件目录
39
61
  * @param {string} pluginDir - 插件安装目录
62
+ * @param {string} pluginId - 插件 ID
63
+ * @param {object} [opts]
64
+ * @param {Function} [opts.renameFn] - 测试注入(EXDEV 分支)
65
+ * @param {Function} [opts.rmFn] - 测试注入(EXDEV 退化路径的备份清理)
66
+ * @param {Function} [opts.log] - 本地日志函数(worker 进程禁 remoteLog)
40
67
  * @returns {Promise<boolean>} 是否成功恢复
41
68
  */
42
- export async function restoreFromBackup(pluginDir) {
43
- const backupDir = `${pluginDir}.bak`;
69
+ export async function restoreFromBackup(pluginDir, pluginId, opts) {
70
+ const backupDir = getBackupDir(pluginId);
71
+ /* c8 ignore next -- ?./?? fallback */
72
+ const doRename = opts?.renameFn ?? fs.rename;
44
73
 
45
74
  try {
46
75
  await fs.access(backupDir);
@@ -51,29 +80,37 @@ export async function restoreFromBackup(pluginDir) {
51
80
 
52
81
  // 删除损坏的新版本
53
82
  await fs.rm(pluginDir, { recursive: true, force: true });
54
- // 恢复备份
55
- await fs.rename(backupDir, pluginDir);
83
+ // 恢复备份:state-dir 与 extensions 可能跨文件系统,EXDEV 时退化 cp+rm
84
+ try {
85
+ await doRename(backupDir, pluginDir);
86
+ }
87
+ catch (err) {
88
+ if (err?.code !== 'EXDEV') throw err;
89
+ await fs.cp(backupDir, pluginDir, { recursive: true });
90
+ // cp 成功即文件态已恢复,restore 成立;残留备份目录清理失败只降级为告警——
91
+ // 整体抛出会让调用方误走 fallback install / 误记 rollback-failed
92
+ try {
93
+ /* c8 ignore next -- ?./?? fallback */
94
+ const doRm = opts?.rmFn ?? fs.rm;
95
+ await doRm(backupDir, { recursive: true, force: true });
96
+ }
97
+ catch (rmErr) {
98
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
99
+ opts?.log?.(`[upgrade-worker] Backup cleanup after EXDEV restore failed (non-fatal): ${rmErr?.message ?? String(rmErr)}`);
100
+ }
101
+ }
56
102
  return true;
57
103
  }
58
104
 
59
105
  /**
60
106
  * 删除备份目录
61
- * @param {string} pluginDir - 插件安装目录
107
+ * @param {string} pluginId - 插件 ID
62
108
  */
63
- export async function removeBackup(pluginDir) {
64
- const backupDir = `${pluginDir}.bak`;
109
+ export async function removeBackup(pluginId) {
110
+ const backupDir = getBackupDir(pluginId);
65
111
  await fs.rm(backupDir, { recursive: true, force: true });
66
112
  }
67
113
 
68
- /**
69
- * 从 extensions 目录路径推算备份目录路径
70
- * @param {string} pluginDir
71
- * @returns {string}
72
- */
73
- export function getBackupDir(pluginDir) {
74
- return `${pluginDir}.bak`;
75
- }
76
-
77
114
  /**
78
115
  * 读取指定目录下 package.json 的版本号
79
116
  * @param {string} dir
@@ -9,9 +9,10 @@
9
9
  * 执行 `plugins update` 之间 npm dist-tag 可能已指向 x+1;严格等 x 会把
10
10
  * 这种"升级到了更新版本"误判为失败并回滚。
11
11
  *
12
- * 磁盘 package.json 的版本仅作为诊断写入本地日志,不参与判定——openclaw 侧
13
- * `plugins.installs[id].installPath` 可能在 id-migration 等极端场景发生漂移,
14
- * upgradeHealth gateway 进程内"新代码真的被加载"的权威信号。
12
+ * 磁盘 package.json 的版本仅作为诊断写入本地日志,不参与判定——安装目录路径
13
+ * 取自权威记录(经 openclaw plugins inspect 读取),仍可能在 id-migration
14
+ * 极端场景发生漂移,而 upgradeHealth 返回 gateway 进程模块加载时刻钉住的版本
15
+ * 快照(handler 不读磁盘),是"新代码真的被加载"的权威信号。
15
16
  *
16
17
  * worker 运行在独立子进程中,禁止使用 remoteLog;诊断信息全部通过 logger
17
18
  * (本地日志)输出,由 updater 记录到 upgrade-log.jsonl。
@@ -20,21 +21,17 @@ import { execFile as nodeExecFile } from 'node:child_process';
20
21
  import { readFile } from 'node:fs/promises';
21
22
  import nodePath from 'node:path';
22
23
 
23
- // updater-check.js 同逻辑,worker 运行在独立子进程,不跨进程复用 gateway 模块
24
- function isNewerVersion(a, b) {
25
- const parse = (v) => v.replace(/-.*$/, '').split('.').map(Number);
26
- const pa = parse(a);
27
- const pb = parse(b);
28
- for (let i = 0; i < 3; i++) {
29
- /* c8 ignore next 2 -- ?? fallback:正常 semver 不会有缺失段 */
30
- if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
31
- if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
32
- }
33
- // x.y.z 相同时:release > pre-release(semver 规则)
34
- const aHasPre = a.includes('-');
35
- const bHasPre = b.includes('-');
36
- if (bHasPre && !aHasPre) return true;
37
- return false;
24
+ import { isNewerVersion } from './updater-check.js';
25
+
26
+ /**
27
+ * 升级达标判据:实际版本等于或新于目标即达标(isNewerVersion 是严格大于,须显式加等号)。
28
+ * 健康轮询与 worker 的 L2 结局核对共用本函数,保证两处判据同构。
29
+ * @param {string} version - 实际观测到的版本
30
+ * @param {string} target - 目标版本
31
+ * @returns {boolean}
32
+ */
33
+ export function isVersionReached(version, target) {
34
+ return version === target || isNewerVersion(version, target);
38
35
  }
39
36
 
40
37
  const CMD_TIMEOUT_MS = 30_000;
@@ -77,18 +74,22 @@ function runCmd(cmd, args, opts) {
77
74
  }
78
75
 
79
76
  /**
80
- * 触发一次 gateway 重启;失败不抛(后续轮询 RPC 会兜底验证 gateway 是否就绪)
77
+ * 触发一次 gateway 重启;失败不抛(后续轮询 RPC 会兜底验证 gateway 是否就绪)。
78
+ * 返回命令是否成功——回滚路径据此追加 rollback-restart-failed 诊断事件,
79
+ * verify 路径忽略返回值(轮询兜底)。
81
80
  * @param {object} [opts]
82
81
  * @param {Function} [opts.execFileFn]
83
- * @returns {Promise<void>}
82
+ * @returns {Promise<boolean>}
84
83
  */
85
84
  export async function triggerGatewayRestart(opts) {
86
85
  try {
87
86
  await runCmd('openclaw', ['gateway', 'restart'], opts);
87
+ return true;
88
88
  }
89
89
  catch {
90
90
  // restart 命令本身失败不阻断:openclaw 可能已在重启/daemon 自恢复;
91
91
  // 无论如何都进入后续 upgradeHealth 轮询,由它判定 gateway 最终是否可用
92
+ return false;
92
93
  }
93
94
  }
94
95
 
@@ -166,7 +167,7 @@ export async function pollUpgradeHealth(toVersion, opts) {
166
167
  const result = await callUpgradeHealthOnce(opts);
167
168
  if (result.ok) {
168
169
  // 等于或更新均视为成功,覆盖"升级窗口期 dist-tag 前移"的情形
169
- if (result.version === toVersion || isNewerVersion(result.version, toVersion)) {
170
+ if (isVersionReached(result.version, toVersion)) {
170
171
  return {
171
172
  ok: true,
172
173
  version: result.version,