@coclaw/openclaw-coclaw 0.1.7 → 0.2.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/README.md +21 -3
- package/index.js +19 -0
- package/package.json +1 -1
- package/src/auto-upgrade/state.js +127 -0
- package/src/auto-upgrade/updater-check.js +103 -0
- package/src/auto-upgrade/updater-spawn.js +63 -0
- package/src/auto-upgrade/updater.js +254 -0
- package/src/auto-upgrade/worker-backup.js +86 -0
- package/src/auto-upgrade/worker-verify.js +133 -0
- package/src/auto-upgrade/worker.js +248 -0
- package/src/realtime-bridge.js +34 -0
- package/src/session-manager/manager.js +7 -5
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@ CoClaw 的 OpenClaw 插件(npm: `@coclaw/openclaw-coclaw`,plugin id: `opencl
|
|
|
4
4
|
|
|
5
5
|
- **transport bridge** — CoClaw server 与 OpenClaw gateway 之间的实时消息桥接
|
|
6
6
|
- **session-manager** — 会话列表/读取能力(`nativeui.sessions.listAll` / `nativeui.sessions.get`)
|
|
7
|
+
- **auto-upgrade** — 从 npm 安装的插件自动检查并升级到最新版本
|
|
7
8
|
|
|
8
9
|
## 安装与模式切换
|
|
9
10
|
|
|
@@ -46,11 +47,10 @@ pnpm run prerelease -- --upgrade # 升级验证(先装 npm 旧版,再用本
|
|
|
46
47
|
### 发布到 npm
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
|
-
pnpm run release
|
|
50
|
+
pnpm run release # 默认:verify → 发布 → 轮询确认
|
|
51
|
+
pnpm run release -- --prerelease # 含预发布验证(pack + 安装测试 + 发布)
|
|
50
52
|
```
|
|
51
53
|
|
|
52
|
-
发布流程:预发布验证(自动) → npm 凭据检查 → dry-run → 发布 → 轮询确认生效。
|
|
53
|
-
|
|
54
54
|
### 检查发布状态
|
|
55
55
|
|
|
56
56
|
```bash
|
|
@@ -113,6 +113,24 @@ node ~/.openclaw/extensions/coclaw/src/cli.js unbind --server <url>
|
|
|
113
113
|
- `config.js` 是读写绑定信息的唯一入口。
|
|
114
114
|
- 绑定时不提交 bot `name`;server 通过 gateway WebSocket 获取 OpenClaw 实例名。若未设置实例名,前端回退显示 `OpenClaw`。
|
|
115
115
|
|
|
116
|
+
## 自动升级
|
|
117
|
+
|
|
118
|
+
从 npm 安装的插件(`source: "npm"`)会自动检查并升级。link 模式和 tarball 安装不触发自动升级。
|
|
119
|
+
|
|
120
|
+
- **检查频率**:gateway 启动后延迟 5~10 分钟首次检查,之后每 1 小时
|
|
121
|
+
- **升级方式**:独立 detached 进程执行,不阻塞 gateway
|
|
122
|
+
- **安全机制**:升级前物理备份;升级后验证 gateway + 插件状态;验证失败自动回滚
|
|
123
|
+
- **状态文件**:`~/.openclaw/coclaw/upgrade-state.json`(运行时状态)、`upgrade-log.jsonl`(升级历史)
|
|
124
|
+
|
|
125
|
+
可通过 gateway RPC 检查升级模块状态:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
openclaw gateway call coclaw.upgradeHealth --json
|
|
129
|
+
# → {"version":"0.1.7"}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
详见设计文档 `docs/auto-upgrade.md`。
|
|
133
|
+
|
|
116
134
|
## 运行与排障日志
|
|
117
135
|
|
|
118
136
|
### 日志级别建议
|
package/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { coclawChannelPlugin } from './src/channel-plugin.js';
|
|
|
6
6
|
import { refreshRealtimeBridge, startRealtimeBridge, stopRealtimeBridge } from './src/realtime-bridge.js';
|
|
7
7
|
import { setRuntime } from './src/runtime.js';
|
|
8
8
|
import { createSessionManager } from './src/session-manager/manager.js';
|
|
9
|
+
import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
|
|
10
|
+
import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
function parseCommandArgs(args) {
|
|
@@ -104,6 +106,23 @@ const plugin = {
|
|
|
104
106
|
}
|
|
105
107
|
});
|
|
106
108
|
|
|
109
|
+
api.registerGatewayMethod('coclaw.upgradeHealth', async ({ respond }) => {
|
|
110
|
+
try {
|
|
111
|
+
const { version } = await getPackageInfo();
|
|
112
|
+
respond(true, { version });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
respondError(respond, err);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const scheduler = new AutoUpgradeScheduler({ pluginId: api.id, logger });
|
|
120
|
+
api.registerService({
|
|
121
|
+
id: 'coclaw-auto-upgrade',
|
|
122
|
+
start() { scheduler.start(); },
|
|
123
|
+
stop() { scheduler.stop(); },
|
|
124
|
+
});
|
|
125
|
+
|
|
107
126
|
api.registerCli(registerCoclawCli, { commands: ['coclaw'] });
|
|
108
127
|
|
|
109
128
|
api.registerCommand({
|
package/package.json
CHANGED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state.js — upgrade-state.json 与 upgrade-log.jsonl 读写
|
|
3
|
+
*
|
|
4
|
+
* 状态文件存储在 OpenClaw state 目录下(~/.openclaw/coclaw/),
|
|
5
|
+
* 与 bindings.json 共享同一目录。路径解析优先级:
|
|
6
|
+
* 1. runtime.state.resolveStateDir()(gateway 进程内)
|
|
7
|
+
* 2. OPENCLAW_STATE_DIR 环境变量(worker 进程,由 spawner 传入)
|
|
8
|
+
* 3. ~/.openclaw(兜底默认值)
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import nodePath from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
|
|
14
|
+
import { getRuntime } from '../runtime.js';
|
|
15
|
+
|
|
16
|
+
const CHANNEL_ID = 'coclaw';
|
|
17
|
+
const STATE_FILENAME = 'upgrade-state.json';
|
|
18
|
+
const LOG_FILENAME = 'upgrade-log.jsonl';
|
|
19
|
+
const LOG_MAX_LINES = 200;
|
|
20
|
+
const LOG_KEEP_LINES = 100;
|
|
21
|
+
|
|
22
|
+
export function resolveStateDir() {
|
|
23
|
+
const rt = getRuntime();
|
|
24
|
+
if (rt?.state?.resolveStateDir) {
|
|
25
|
+
return rt.state.resolveStateDir();
|
|
26
|
+
}
|
|
27
|
+
return process.env.OPENCLAW_STATE_DIR
|
|
28
|
+
? nodePath.resolve(process.env.OPENCLAW_STATE_DIR)
|
|
29
|
+
: nodePath.join(os.homedir(), '.openclaw');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getStatePath() {
|
|
33
|
+
return nodePath.join(resolveStateDir(), CHANNEL_ID, STATE_FILENAME);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getLogPath() {
|
|
37
|
+
return nodePath.join(resolveStateDir(), CHANNEL_ID, LOG_FILENAME);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 读取 upgrade-state.json
|
|
42
|
+
* @returns {Promise<{ skippedVersions?: string[], lastCheck?: string, lastUpgrade?: object }>}
|
|
43
|
+
*/
|
|
44
|
+
export async function readState() {
|
|
45
|
+
const filePath = getStatePath();
|
|
46
|
+
try {
|
|
47
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
48
|
+
const trimmed = raw.trim();
|
|
49
|
+
if (!trimmed) return {};
|
|
50
|
+
return JSON.parse(trimmed);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err?.code === 'ENOENT') return {};
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 写入 upgrade-state.json(完整覆盖)
|
|
60
|
+
* @param {object} state
|
|
61
|
+
*/
|
|
62
|
+
export async function writeState(state) {
|
|
63
|
+
const filePath = getStatePath();
|
|
64
|
+
await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
|
|
65
|
+
await fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 将版本加入 skippedVersions
|
|
70
|
+
* @param {string} version
|
|
71
|
+
*/
|
|
72
|
+
export async function addSkippedVersion(version) {
|
|
73
|
+
const state = await readState();
|
|
74
|
+
const skipped = Array.isArray(state.skippedVersions) ? state.skippedVersions : [];
|
|
75
|
+
if (!skipped.includes(version)) {
|
|
76
|
+
skipped.push(version);
|
|
77
|
+
}
|
|
78
|
+
state.skippedVersions = skipped;
|
|
79
|
+
await writeState(state);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 更新 lastCheck 时间戳
|
|
84
|
+
*/
|
|
85
|
+
export async function updateLastCheck() {
|
|
86
|
+
const state = await readState();
|
|
87
|
+
state.lastCheck = new Date().toISOString();
|
|
88
|
+
await writeState(state);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 更新 lastUpgrade 信息
|
|
93
|
+
* @param {{ from: string, to: string, result: string }} info
|
|
94
|
+
*/
|
|
95
|
+
export async function updateLastUpgrade(info) {
|
|
96
|
+
const state = await readState();
|
|
97
|
+
state.lastUpgrade = { ...info, ts: new Date().toISOString() };
|
|
98
|
+
await writeState(state);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 追加升级日志
|
|
103
|
+
* @param {{ from: string, to: string, result: string, error?: string }} entry
|
|
104
|
+
*/
|
|
105
|
+
export async function appendLog(entry) {
|
|
106
|
+
const filePath = getLogPath();
|
|
107
|
+
await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
|
|
108
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry });
|
|
109
|
+
await fs.appendFile(filePath, `${line}\n`, 'utf8');
|
|
110
|
+
await trimLog(filePath);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 日志超过 LOG_MAX_LINES 时截断,保留最近 LOG_KEEP_LINES 行
|
|
115
|
+
*/
|
|
116
|
+
async function trimLog(filePath) {
|
|
117
|
+
try {
|
|
118
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
119
|
+
const lines = content.split('\n').filter(Boolean);
|
|
120
|
+
if (lines.length <= LOG_MAX_LINES) return;
|
|
121
|
+
const kept = lines.slice(-LOG_KEEP_LINES);
|
|
122
|
+
await fs.writeFile(filePath, `${kept.join('\n')}\n`, 'utf8');
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// 截断失败不影响主流程
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* updater-check.js — 版本检查
|
|
3
|
+
*
|
|
4
|
+
* 通过 `npm view` 查询 registry 最新版本,与本地 package.json 对比。
|
|
5
|
+
* 选择 npm view 而非直接 fetch registry API,是因为它自动继承用户完整的
|
|
6
|
+
* npm 环境配置(registry 镜像、proxy、scoped registry、auth token 等),
|
|
7
|
+
* 避免自行解析多层 .npmrc 的复杂性。每小时一次的频率下进程启动开销可忽略。
|
|
8
|
+
*/
|
|
9
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import nodePath from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { readState, updateLastCheck } from './state.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 读取本地 package.json 获取包名和版本号
|
|
17
|
+
* @param {string} [pluginDir] - 插件根目录(默认自动检测)
|
|
18
|
+
* @returns {Promise<{ name: string, version: string }>}
|
|
19
|
+
*/
|
|
20
|
+
export async function getPackageInfo(pluginDir) {
|
|
21
|
+
const dir = pluginDir ?? nodePath.resolve(import.meta.dirname, '../..');
|
|
22
|
+
const pkgPath = nodePath.join(dir, 'package.json');
|
|
23
|
+
const raw = await readFile(pkgPath, 'utf8');
|
|
24
|
+
const pkg = JSON.parse(raw);
|
|
25
|
+
return { name: pkg.name, version: pkg.version };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 查询 npm registry 上的最新版本
|
|
30
|
+
* @param {string} pkgName - npm 包名
|
|
31
|
+
* @param {object} [opts]
|
|
32
|
+
* @param {Function} [opts.execFileFn] - 可注入的 execFile(测试用)
|
|
33
|
+
* @returns {Promise<string>}
|
|
34
|
+
*/
|
|
35
|
+
export async function getLatestVersion(pkgName, opts) {
|
|
36
|
+
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
doExecFile('npm', ['view', pkgName, 'version'], {
|
|
39
|
+
timeout: 30_000,
|
|
40
|
+
shell: true,
|
|
41
|
+
}, (err, stdout) => {
|
|
42
|
+
if (err) {
|
|
43
|
+
reject(new Error(`npm view failed: ${err.message}`));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const version = String(stdout).trim();
|
|
47
|
+
if (!version) {
|
|
48
|
+
reject(new Error('npm view returned empty version'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
resolve(version);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 简易 semver 比较:a > b 返回 true
|
|
58
|
+
* @param {string} a
|
|
59
|
+
* @param {string} b
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function isNewerVersion(a, b) {
|
|
63
|
+
// 先比较 major.minor.patch(去掉 pre-release 后缀)
|
|
64
|
+
const parse = (v) => v.replace(/-.*$/, '').split('.').map(Number);
|
|
65
|
+
const pa = parse(a);
|
|
66
|
+
const pb = parse(b);
|
|
67
|
+
for (let i = 0; i < 3; i++) {
|
|
68
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0)) return true;
|
|
69
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0)) return false;
|
|
70
|
+
}
|
|
71
|
+
// x.y.z 相同时:release > pre-release(semver 规则)
|
|
72
|
+
const aHasPre = a.includes('-');
|
|
73
|
+
const bHasPre = b.includes('-');
|
|
74
|
+
if (bHasPre && !aHasPre) return true;
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 检查是否有可用更新
|
|
80
|
+
* @param {object} [opts]
|
|
81
|
+
* @param {Function} [opts.execFileFn] - 可注入的 execFile(测试用)
|
|
82
|
+
* @param {string} [opts.pluginDir] - 插件目录
|
|
83
|
+
* @returns {Promise<{ available: boolean, currentVersion: string, latestVersion?: string, pkgName: string }>}
|
|
84
|
+
*/
|
|
85
|
+
export async function checkForUpdate(opts) {
|
|
86
|
+
const { name: pkgName, version: currentVersion } = await getPackageInfo(opts?.pluginDir);
|
|
87
|
+
const latestVersion = await getLatestVersion(pkgName, opts);
|
|
88
|
+
|
|
89
|
+
await updateLastCheck();
|
|
90
|
+
|
|
91
|
+
if (!isNewerVersion(latestVersion, currentVersion)) {
|
|
92
|
+
return { available: false, currentVersion, pkgName };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 检查是否在 skippedVersions 中(曾升级失败并回滚的版本)
|
|
96
|
+
const state = await readState();
|
|
97
|
+
const skipped = Array.isArray(state.skippedVersions) ? state.skippedVersions : [];
|
|
98
|
+
if (skipped.includes(latestVersion)) {
|
|
99
|
+
return { available: false, currentVersion, latestVersion, pkgName, skipped: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { available: true, currentVersion, latestVersion, pkgName };
|
|
103
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import nodePath from 'node:path';
|
|
3
|
+
import { resolveStateDir } from './state.js';
|
|
4
|
+
|
|
5
|
+
const WORKER_FILENAME = 'worker.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 获取 worker.js 的路径
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function getWorkerPath() {
|
|
12
|
+
return nodePath.join(import.meta.dirname, WORKER_FILENAME);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 以 detached 进程方式启动 upgrade worker
|
|
17
|
+
*
|
|
18
|
+
* 使用 process.execPath 确保与 gateway 使用同一 node 版本。
|
|
19
|
+
* detached + unref 确保 gateway 进程不会等待 worker。
|
|
20
|
+
* 通过 -- 命名参数传递业务数据,worker 使用 util.parseArgs 解析。
|
|
21
|
+
*
|
|
22
|
+
* @param {object} params
|
|
23
|
+
* @param {string} params.pluginDir - 插件安装目录
|
|
24
|
+
* @param {string} params.fromVersion - 当前版本
|
|
25
|
+
* @param {string} params.toVersion - 目标版本
|
|
26
|
+
* @param {string} params.pluginId - 插件 ID
|
|
27
|
+
* @param {string} params.pkgName - npm 包名
|
|
28
|
+
* @param {object} [params.opts]
|
|
29
|
+
* @param {Function} [params.opts.spawnFn] - 可注入的 spawn(测试用)
|
|
30
|
+
* @param {Function} [params.logger]
|
|
31
|
+
* @returns {{ child: object }}
|
|
32
|
+
*/
|
|
33
|
+
export function spawnUpgradeWorker({ pluginDir, fromVersion, toVersion, pluginId, pkgName, opts, logger }) {
|
|
34
|
+
const log = typeof logger?.log === 'function' ? logger.log.bind(logger) : (logger ?? console.log);
|
|
35
|
+
const doSpawn = opts?.spawnFn ?? nodeSpawn;
|
|
36
|
+
const workerPath = getWorkerPath();
|
|
37
|
+
|
|
38
|
+
log(`[spawner] Spawning upgrade worker: ${fromVersion} → ${toVersion}`);
|
|
39
|
+
|
|
40
|
+
// 将 state dir 传递给 worker,确保 worker 写入正确的路径
|
|
41
|
+
const stateDir = resolveStateDir();
|
|
42
|
+
const env = { ...process.env };
|
|
43
|
+
if (stateDir) env.OPENCLAW_STATE_DIR = stateDir;
|
|
44
|
+
|
|
45
|
+
const child = doSpawn(process.execPath, [
|
|
46
|
+
workerPath,
|
|
47
|
+
'--pluginDir', pluginDir,
|
|
48
|
+
'--fromVersion', fromVersion,
|
|
49
|
+
'--toVersion', toVersion,
|
|
50
|
+
'--pluginId', pluginId,
|
|
51
|
+
'--pkgName', pkgName,
|
|
52
|
+
], {
|
|
53
|
+
detached: true,
|
|
54
|
+
stdio: 'ignore',
|
|
55
|
+
env,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
child.unref();
|
|
59
|
+
|
|
60
|
+
log(`[spawner] Worker spawned (pid: ${child.pid})`);
|
|
61
|
+
return { child };
|
|
62
|
+
}
|
|
63
|
+
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import nodePath from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { checkForUpdate } from './updater-check.js';
|
|
5
|
+
import { spawnUpgradeWorker } from './updater-spawn.js';
|
|
6
|
+
import { resolveStateDir } from './state.js';
|
|
7
|
+
import { getRuntime } from '../runtime.js';
|
|
8
|
+
|
|
9
|
+
const INITIAL_DELAY_MS = 5 * 60 * 1000; // 5 分钟
|
|
10
|
+
const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 小时
|
|
11
|
+
const CHANNEL_ID = 'coclaw';
|
|
12
|
+
const LOCK_FILENAME = 'upgrade.lock';
|
|
13
|
+
|
|
14
|
+
// ── upgrade.lock:保证同时最多一个 worker 进程 ──
|
|
15
|
+
|
|
16
|
+
export function getLockPath() {
|
|
17
|
+
return nodePath.join(resolveStateDir(), CHANNEL_ID, LOCK_FILENAME);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 检查升级锁是否被持有(worker 进程是否存活)
|
|
22
|
+
*
|
|
23
|
+
* 若锁文件存在但 PID 已死(过期锁),顺手清理残留文件。
|
|
24
|
+
* @param {object} [opts]
|
|
25
|
+
* @param {object} [opts.logger]
|
|
26
|
+
* @returns {Promise<boolean>}
|
|
27
|
+
*/
|
|
28
|
+
export async function isUpgradeLocked(opts) {
|
|
29
|
+
const lockPath = getLockPath();
|
|
30
|
+
const logger = opts?.logger;
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = await fs.readFile(lockPath, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false; // 文件不存在,无需清理
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const lock = JSON.parse(raw);
|
|
40
|
+
if (!lock.pid) {
|
|
41
|
+
logger?.log?.('[auto-upgrade] Stale lock removed (missing pid)');
|
|
42
|
+
await fs.rm(lockPath, { force: true }).catch(() => {});
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
// signal 0 不发信号,仅检查进程存活性;进程不存在时抛异常
|
|
46
|
+
process.kill(lock.pid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// JSON 无效 / PID 已死 → 清理过期锁
|
|
51
|
+
logger?.log?.('[auto-upgrade] Stale lock removed (worker pid no longer alive)');
|
|
52
|
+
await fs.rm(lockPath, { force: true }).catch(() => {});
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 写入升级锁(spawn worker 后调用)
|
|
59
|
+
* @param {number} pid - worker 进程 PID
|
|
60
|
+
*/
|
|
61
|
+
export async function writeUpgradeLock(pid) {
|
|
62
|
+
const lockPath = getLockPath();
|
|
63
|
+
await fs.mkdir(nodePath.dirname(lockPath), { recursive: true });
|
|
64
|
+
await fs.writeFile(
|
|
65
|
+
lockPath,
|
|
66
|
+
`${JSON.stringify({ pid, ts: new Date().toISOString() })}\n`,
|
|
67
|
+
'utf8',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 判断是否应跳过自动升级
|
|
73
|
+
*
|
|
74
|
+
* `openclaw plugins update` 仅对 source === "npm" 的安装生效。
|
|
75
|
+
* source 的可能值:
|
|
76
|
+
* - "npm":从 npm registry 安装(生产环境,允许自动升级)
|
|
77
|
+
* - "path":link 模式(本地开发,跳过)
|
|
78
|
+
* - "archive":从 tarball 安装(跳过)
|
|
79
|
+
*
|
|
80
|
+
* @param {string} pluginId
|
|
81
|
+
* @returns {boolean} true 表示应跳过自动升级
|
|
82
|
+
*/
|
|
83
|
+
export function shouldSkipAutoUpgrade(pluginId) {
|
|
84
|
+
const rt = getRuntime();
|
|
85
|
+
if (!rt?.config?.loadConfig) return true;
|
|
86
|
+
try {
|
|
87
|
+
const config = rt.config.loadConfig();
|
|
88
|
+
const installInfo = config?.plugins?.installs?.[pluginId];
|
|
89
|
+
return installInfo?.source !== 'npm';
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 获取插件安装路径
|
|
98
|
+
* @param {string} pluginId
|
|
99
|
+
* @returns {string|null}
|
|
100
|
+
*/
|
|
101
|
+
export function getPluginInstallPath(pluginId) {
|
|
102
|
+
const rt = getRuntime();
|
|
103
|
+
if (!rt?.config?.loadConfig) return null;
|
|
104
|
+
try {
|
|
105
|
+
const config = rt.config.loadConfig();
|
|
106
|
+
return config?.plugins?.installs?.[pluginId]?.installPath ?? null;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 自动升级调度器
|
|
115
|
+
*/
|
|
116
|
+
export class AutoUpgradeScheduler {
|
|
117
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
118
|
+
__initialTimer = null;
|
|
119
|
+
/** @type {ReturnType<typeof setInterval>|null} */
|
|
120
|
+
__intervalTimer = null;
|
|
121
|
+
__running = false;
|
|
122
|
+
__checking = false;
|
|
123
|
+
__pluginId = null;
|
|
124
|
+
__logger = console;
|
|
125
|
+
__opts = {};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {object} [params]
|
|
129
|
+
* @param {string} [params.pluginId] - 插件 ID(来自 api.id)
|
|
130
|
+
* @param {Function} [params.logger]
|
|
131
|
+
* @param {object} [params.opts] - 测试注入选项
|
|
132
|
+
* @param {number} [params.opts.initialDelayMs]
|
|
133
|
+
* @param {number} [params.opts.checkIntervalMs]
|
|
134
|
+
* @param {Function} [params.opts.execFileFn]
|
|
135
|
+
* @param {Function} [params.opts.spawnFn]
|
|
136
|
+
* @param {Function} [params.opts.shouldSkipFn]
|
|
137
|
+
* @param {Function} [params.opts.getPluginInstallPathFn]
|
|
138
|
+
*/
|
|
139
|
+
constructor(params) {
|
|
140
|
+
if (params?.pluginId) this.__pluginId = params.pluginId;
|
|
141
|
+
if (params?.logger) this.__logger = params.logger;
|
|
142
|
+
if (params?.opts) this.__opts = params.opts;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 启动调度器
|
|
147
|
+
*/
|
|
148
|
+
start() {
|
|
149
|
+
if (this.__running) return;
|
|
150
|
+
this.__running = true;
|
|
151
|
+
|
|
152
|
+
if (!this.__pluginId) {
|
|
153
|
+
this.__logger.warn?.('[auto-upgrade] Skipping: pluginId not provided');
|
|
154
|
+
this.__running = false;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const shouldSkip = this.__opts.shouldSkipFn ?? shouldSkipAutoUpgrade;
|
|
159
|
+
if (shouldSkip(this.__pluginId)) {
|
|
160
|
+
this.__logger.log?.('[auto-upgrade] Skipping: not an npm-installed plugin');
|
|
161
|
+
this.__running = false;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 默认 5~10 分钟随机延迟,避免多实例同时发起检查
|
|
166
|
+
const initialDelay = this.__opts.initialDelayMs
|
|
167
|
+
?? (INITIAL_DELAY_MS + Math.floor(Math.random() * INITIAL_DELAY_MS));
|
|
168
|
+
this.__logger.log?.(`[auto-upgrade] Scheduler started. First check in ${Math.round(initialDelay / 1000)}s`);
|
|
169
|
+
|
|
170
|
+
this.__initialTimer = setTimeout(() => {
|
|
171
|
+
this.__initialTimer = null;
|
|
172
|
+
this.__check().catch(() => {});
|
|
173
|
+
const interval = this.__opts.checkIntervalMs ?? CHECK_INTERVAL_MS;
|
|
174
|
+
this.__intervalTimer = setInterval(() => this.__check().catch(() => {}), interval);
|
|
175
|
+
}, initialDelay);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 停止调度器
|
|
180
|
+
*/
|
|
181
|
+
stop() {
|
|
182
|
+
if (!this.__running) return;
|
|
183
|
+
this.__running = false;
|
|
184
|
+
|
|
185
|
+
if (this.__initialTimer) {
|
|
186
|
+
clearTimeout(this.__initialTimer);
|
|
187
|
+
this.__initialTimer = null;
|
|
188
|
+
}
|
|
189
|
+
if (this.__intervalTimer) {
|
|
190
|
+
clearInterval(this.__intervalTimer);
|
|
191
|
+
this.__intervalTimer = null;
|
|
192
|
+
}
|
|
193
|
+
this.__logger.log?.('[auto-upgrade] Scheduler stopped');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 执行一次检查
|
|
198
|
+
*/
|
|
199
|
+
async __check() {
|
|
200
|
+
if (this.__checking) return;
|
|
201
|
+
this.__checking = true;
|
|
202
|
+
try {
|
|
203
|
+
// 若上一次 spawn 的 worker 仍在运行,跳过本次检查
|
|
204
|
+
const isLocked = this.__opts.isUpgradeLockedFn ?? isUpgradeLocked;
|
|
205
|
+
if (await isLocked({ logger: this.__logger })) {
|
|
206
|
+
this.__logger.log?.('[auto-upgrade] Upgrade worker still running, skipping check');
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.__logger.log?.('[auto-upgrade] Checking for updates...');
|
|
211
|
+
const result = await checkForUpdate({
|
|
212
|
+
execFileFn: this.__opts.execFileFn,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!result.available) {
|
|
216
|
+
if (result.skipped) {
|
|
217
|
+
this.__logger.log?.(`[auto-upgrade] Version ${result.latestVersion} skipped (previously failed)`);
|
|
218
|
+
} else {
|
|
219
|
+
this.__logger.log?.(`[auto-upgrade] No update available (current: ${result.currentVersion})`);
|
|
220
|
+
}
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.__logger.log?.(`[auto-upgrade] Update available: ${result.currentVersion} → ${result.latestVersion}`);
|
|
225
|
+
|
|
226
|
+
const getInstallPath = this.__opts.getPluginInstallPathFn ?? getPluginInstallPath;
|
|
227
|
+
const pluginDir = getInstallPath(this.__pluginId);
|
|
228
|
+
if (!pluginDir) {
|
|
229
|
+
this.__logger.warn?.('[auto-upgrade] Cannot determine plugin install path');
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { child } = spawnUpgradeWorker({
|
|
234
|
+
pluginDir,
|
|
235
|
+
fromVersion: result.currentVersion,
|
|
236
|
+
toVersion: result.latestVersion,
|
|
237
|
+
pluginId: this.__pluginId,
|
|
238
|
+
pkgName: result.pkgName,
|
|
239
|
+
opts: { spawnFn: this.__opts.spawnFn },
|
|
240
|
+
logger: this.__logger,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// 记录 worker PID,下次 check 时据此判断 worker 是否仍在运行
|
|
244
|
+
const writeLock = this.__opts.writeUpgradeLockFn ?? writeUpgradeLock;
|
|
245
|
+
await writeLock(child.pid);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
this.__logger.warn?.(`[auto-upgrade] Check failed: ${err.message}`);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
this.__checking = false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worker-backup.js — 插件目录物理备份与恢复
|
|
3
|
+
*
|
|
4
|
+
* 使用 Node.js 内置 fs.cp()(16.7+)进行跨平台物理复制,无外部依赖。
|
|
5
|
+
* 备份采用原子操作:先 cp 到 .tmp.bak,再 rename 到 .bak,
|
|
6
|
+
* 避免中途失败产生不完整的备份目录。
|
|
7
|
+
*
|
|
8
|
+
* 命名约束:备份目录(含临时目录)必须以 .bak 结尾。
|
|
9
|
+
* OpenClaw gateway 启动时会扫描 extensions/ 下所有子目录并尝试作为插件加载,
|
|
10
|
+
* 但会跳过以 .bak 结尾的目录(discovery.ts shouldIgnoreScannedDirectory)。
|
|
11
|
+
* 若临时目录不以 .bak 结尾(如曾用的 .bak-tmp),在 fs.cp 期间 gateway
|
|
12
|
+
* 重启会将不完整的目录当作插件加载,导致 method 重复注册或加载异常。
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import nodePath from 'node:path';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 备份插件目录
|
|
19
|
+
* @param {string} pluginDir - 插件安装目录
|
|
20
|
+
* @returns {Promise<string>} 备份目录路径
|
|
21
|
+
*/
|
|
22
|
+
export async function createBackup(pluginDir) {
|
|
23
|
+
const backupDir = `${pluginDir}.bak`;
|
|
24
|
+
|
|
25
|
+
// 若上次异常退出遗留了 .bak,先清理
|
|
26
|
+
await fs.rm(backupDir, { recursive: true, force: true });
|
|
27
|
+
|
|
28
|
+
// 先复制到临时名,再 rename,确保原子性
|
|
29
|
+
const tmpDir = `${pluginDir}.tmp.bak`;
|
|
30
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
31
|
+
await fs.cp(pluginDir, tmpDir, { recursive: true });
|
|
32
|
+
await fs.rename(tmpDir, backupDir);
|
|
33
|
+
|
|
34
|
+
return backupDir;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 从备份恢复插件目录
|
|
39
|
+
* @param {string} pluginDir - 插件安装目录
|
|
40
|
+
* @returns {Promise<boolean>} 是否成功恢复
|
|
41
|
+
*/
|
|
42
|
+
export async function restoreFromBackup(pluginDir) {
|
|
43
|
+
const backupDir = `${pluginDir}.bak`;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await fs.access(backupDir);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 删除损坏的新版本
|
|
53
|
+
await fs.rm(pluginDir, { recursive: true, force: true });
|
|
54
|
+
// 恢复备份
|
|
55
|
+
await fs.rename(backupDir, pluginDir);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 删除备份目录
|
|
61
|
+
* @param {string} pluginDir - 插件安装目录
|
|
62
|
+
*/
|
|
63
|
+
export async function removeBackup(pluginDir) {
|
|
64
|
+
const backupDir = `${pluginDir}.bak`;
|
|
65
|
+
await fs.rm(backupDir, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 从 extensions 目录路径推算备份目录路径
|
|
70
|
+
* @param {string} pluginDir
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
export function getBackupDir(pluginDir) {
|
|
74
|
+
return `${pluginDir}.bak`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 读取指定目录下 package.json 的版本号
|
|
79
|
+
* @param {string} dir
|
|
80
|
+
* @returns {Promise<string>}
|
|
81
|
+
*/
|
|
82
|
+
export async function readVersionFromDir(dir) {
|
|
83
|
+
const pkgPath = nodePath.join(dir, 'package.json');
|
|
84
|
+
const raw = await fs.readFile(pkgPath, 'utf8');
|
|
85
|
+
return JSON.parse(raw).version;
|
|
86
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worker-verify.js — 升级后验证
|
|
3
|
+
*
|
|
4
|
+
* 三步验证策略(任一失败即判定升级失败):
|
|
5
|
+
* 1. Gateway 存活:轮询 `openclaw gateway status`,超时 60s
|
|
6
|
+
* 2. 插件已加载:`openclaw plugins list` 包含指定插件
|
|
7
|
+
* 3. 升级模块健康:`openclaw gateway call coclaw.upgradeHealth` 返回版本号
|
|
8
|
+
*
|
|
9
|
+
* 第 3 步同时验证了插件代码能正常执行、gateway method 注册链路正常,
|
|
10
|
+
* 确保插件仍具备自我升级能力。
|
|
11
|
+
*/
|
|
12
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
const GATEWAY_READY_TIMEOUT_MS = 60_000;
|
|
15
|
+
const POLL_INTERVAL_MS = 2000;
|
|
16
|
+
const CMD_TIMEOUT_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 执行命令并返回 stdout
|
|
20
|
+
* @param {string} cmd
|
|
21
|
+
* @param {string[]} args
|
|
22
|
+
* @param {object} [opts]
|
|
23
|
+
* @param {Function} [opts.execFileFn]
|
|
24
|
+
* @returns {Promise<string>}
|
|
25
|
+
*/
|
|
26
|
+
function exec(cmd, args, opts) {
|
|
27
|
+
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
doExecFile(cmd, args, { timeout: CMD_TIMEOUT_MS, shell: true }, (err, stdout) => {
|
|
30
|
+
if (err) reject(err);
|
|
31
|
+
else resolve(String(stdout).trim());
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 步骤 1:等待 gateway 恢复运行
|
|
38
|
+
* @param {object} [opts]
|
|
39
|
+
* @param {Function} [opts.execFileFn]
|
|
40
|
+
* @param {number} [opts.timeoutMs]
|
|
41
|
+
* @param {number} [opts.pollIntervalMs]
|
|
42
|
+
* @returns {Promise<void>}
|
|
43
|
+
*/
|
|
44
|
+
export async function waitForGateway(opts) {
|
|
45
|
+
// 主动触发重启,不依赖 OpenClaw 的文件变更自动重启策略
|
|
46
|
+
try {
|
|
47
|
+
await exec('openclaw', ['gateway', 'restart'], opts);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// restart 命令失败不阻断流程,仍尝试等待
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const timeout = opts?.timeoutMs ?? GATEWAY_READY_TIMEOUT_MS;
|
|
54
|
+
const interval = opts?.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
55
|
+
const start = Date.now();
|
|
56
|
+
|
|
57
|
+
while (Date.now() - start < timeout) {
|
|
58
|
+
try {
|
|
59
|
+
const output = await exec('openclaw', ['gateway', 'status'], opts);
|
|
60
|
+
if (output.includes('running')) return;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// gateway 未就绪,继续轮询
|
|
64
|
+
}
|
|
65
|
+
await sleep(interval);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
throw new Error('Gateway did not become ready within timeout');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 步骤 2:验证插件已加载
|
|
73
|
+
* @param {string} pluginId - 插件 ID
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
* @param {Function} [opts.execFileFn]
|
|
76
|
+
* @returns {Promise<void>}
|
|
77
|
+
*/
|
|
78
|
+
export async function verifyPluginLoaded(pluginId, opts) {
|
|
79
|
+
const output = await exec('openclaw', ['plugins', 'list'], opts);
|
|
80
|
+
if (!output.includes(pluginId)) {
|
|
81
|
+
throw new Error(`Plugin ${pluginId} not found in plugins list`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 步骤 3:验证升级模块健康
|
|
87
|
+
* @param {object} [opts]
|
|
88
|
+
* @param {Function} [opts.execFileFn]
|
|
89
|
+
* @returns {Promise<string>} 返回版本号
|
|
90
|
+
*/
|
|
91
|
+
export async function verifyUpgradeHealth(opts) {
|
|
92
|
+
const output = await exec(
|
|
93
|
+
'openclaw',
|
|
94
|
+
['gateway', 'call', 'coclaw.upgradeHealth', '--json'],
|
|
95
|
+
opts,
|
|
96
|
+
);
|
|
97
|
+
try {
|
|
98
|
+
const result = JSON.parse(output);
|
|
99
|
+
if (!result.version) {
|
|
100
|
+
throw new Error('upgradeHealth response missing version');
|
|
101
|
+
}
|
|
102
|
+
return result.version;
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (err.message?.includes('upgradeHealth')) throw err;
|
|
106
|
+
throw new Error(`Failed to parse upgradeHealth response: ${output}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 执行完整验证流程
|
|
112
|
+
* @param {string} pluginId - 插件 ID
|
|
113
|
+
* @param {object} [opts]
|
|
114
|
+
* @param {Function} [opts.execFileFn]
|
|
115
|
+
* @param {number} [opts.timeoutMs]
|
|
116
|
+
* @param {number} [opts.pollIntervalMs]
|
|
117
|
+
* @returns {Promise<{ ok: boolean, version?: string, error?: string }>}
|
|
118
|
+
*/
|
|
119
|
+
export async function verifyUpgrade(pluginId, opts) {
|
|
120
|
+
try {
|
|
121
|
+
await waitForGateway(opts);
|
|
122
|
+
await verifyPluginLoaded(pluginId, opts);
|
|
123
|
+
const version = await verifyUpgradeHealth(opts);
|
|
124
|
+
return { ok: true, version };
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
return { ok: false, error: String(err?.message ?? err) };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function sleep(ms) {
|
|
132
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
133
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worker.js — 由 updater-spawn 以 detached 进程启动
|
|
3
|
+
*
|
|
4
|
+
* 用法:node worker.js --pluginDir <dir> --fromVersion <ver> --toVersion <ver>
|
|
5
|
+
* --pluginId <id> --pkgName <name>
|
|
6
|
+
*
|
|
7
|
+
* 流程:备份 → openclaw plugins update → 等待 gateway 重启 → 验证 → 成功清理/失败回滚
|
|
8
|
+
*
|
|
9
|
+
* 注意:
|
|
10
|
+
* - 本模块作为独立 node 进程运行,与 gateway 进程隔离
|
|
11
|
+
* - state dir 通过 OPENCLAW_STATE_DIR 环境变量由 spawner 传入
|
|
12
|
+
* - shell: true 为 Windows 兼容性所需(openclaw 全局安装生成 .cmd 包装器)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
16
|
+
import { parseArgs } from 'node:util';
|
|
17
|
+
import { createBackup, restoreFromBackup, removeBackup } from './worker-backup.js';
|
|
18
|
+
import { verifyUpgrade, waitForGateway } from './worker-verify.js';
|
|
19
|
+
import { addSkippedVersion, updateLastUpgrade, appendLog } from './state.js';
|
|
20
|
+
|
|
21
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.-]+)?$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 执行 openclaw plugins update
|
|
25
|
+
* @param {string} pluginId - 插件 ID
|
|
26
|
+
* @param {object} [opts]
|
|
27
|
+
* @param {Function} [opts.execFileFn]
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
*/
|
|
30
|
+
// openclaw plugins update 内部实现为 staged backup-and-replace,
|
|
31
|
+
// 仅支持 source === "npm" 的安装(updater 已做前置过滤)
|
|
32
|
+
function runPluginUpdate(pluginId, opts) {
|
|
33
|
+
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
doExecFile('openclaw', ['plugins', 'update', pluginId], {
|
|
36
|
+
timeout: 120_000,
|
|
37
|
+
shell: true,
|
|
38
|
+
}, (err) => {
|
|
39
|
+
if (err) reject(new Error(`plugins update failed: ${err.message}`));
|
|
40
|
+
else resolve();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 尝试通过 npm 安装旧版本进行兜底回滚
|
|
47
|
+
*
|
|
48
|
+
* openclaw plugins install 不支持覆盖已安装插件,因此需先 uninstall。
|
|
49
|
+
* uninstall 失败不阻断流程(插件可能已处于异常状态)。
|
|
50
|
+
*
|
|
51
|
+
* @param {string} pkgName - npm 包名
|
|
52
|
+
* @param {string} version
|
|
53
|
+
* @param {string} pluginId - 插件 ID
|
|
54
|
+
* @param {object} [opts]
|
|
55
|
+
* @param {Function} [opts.execFileFn]
|
|
56
|
+
* @returns {Promise<void>}
|
|
57
|
+
*/
|
|
58
|
+
// 回滚兜底:当物理备份恢复失败时,尝试从 npm 重新安装旧版本
|
|
59
|
+
async function fallbackInstallOldVersion(pkgName, version, pluginId, opts) {
|
|
60
|
+
// version 来自 package.json,正常不会有异常值,但 shell: true 下做防御校验
|
|
61
|
+
if (!SEMVER_RE.test(version)) {
|
|
62
|
+
throw new Error(`invalid version format: ${version}`);
|
|
63
|
+
}
|
|
64
|
+
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
65
|
+
const run = (args, timeout = 120_000) => new Promise((resolve, reject) => {
|
|
66
|
+
doExecFile('openclaw', args, { timeout, shell: true }, (err) => {
|
|
67
|
+
if (err) reject(err);
|
|
68
|
+
else resolve();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 先卸载:install 不支持覆盖已安装插件
|
|
73
|
+
try {
|
|
74
|
+
await run(['plugins', 'uninstall', pluginId], 60_000);
|
|
75
|
+
} catch {
|
|
76
|
+
// uninstall 失败不阻断,继续尝试 install
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await run(['plugins', 'install', `${pkgName}@${version}`]);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
throw new Error(`fallback install failed: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 执行升级流程
|
|
88
|
+
* @param {object} params
|
|
89
|
+
* @param {string} params.pluginDir - 插件安装目录
|
|
90
|
+
* @param {string} params.fromVersion - 当前版本
|
|
91
|
+
* @param {string} params.toVersion - 目标版本
|
|
92
|
+
* @param {string} params.pluginId - 插件 ID
|
|
93
|
+
* @param {string} params.pkgName - npm 包名
|
|
94
|
+
* @param {object} [params.opts] - 测试注入选项
|
|
95
|
+
* @param {Function} [params.opts.execFileFn]
|
|
96
|
+
* @param {number} [params.opts.timeoutMs]
|
|
97
|
+
* @param {number} [params.opts.pollIntervalMs]
|
|
98
|
+
* @param {Function} [params.logger] - 日志函数
|
|
99
|
+
*/
|
|
100
|
+
export async function runUpgrade({ pluginDir, fromVersion, toVersion, pluginId, pkgName, opts, logger }) {
|
|
101
|
+
const log = logger ?? console.log;
|
|
102
|
+
|
|
103
|
+
log(`[upgrade-worker] Starting upgrade: ${fromVersion} → ${toVersion}`);
|
|
104
|
+
log(`[upgrade-worker] Plugin dir: ${pluginDir}`);
|
|
105
|
+
|
|
106
|
+
// 1. 备份
|
|
107
|
+
log('[upgrade-worker] Creating backup...');
|
|
108
|
+
await createBackup(pluginDir);
|
|
109
|
+
log('[upgrade-worker] Backup created');
|
|
110
|
+
|
|
111
|
+
// 2. 执行升级
|
|
112
|
+
log('[upgrade-worker] Running plugins update...');
|
|
113
|
+
try {
|
|
114
|
+
await runPluginUpdate(pluginId, opts);
|
|
115
|
+
}
|
|
116
|
+
catch (updateErr) {
|
|
117
|
+
// 升级命令本身失败(可能是瞬态故障),恢复备份但不标记版本为 skipped
|
|
118
|
+
log(`[upgrade-worker] Update command failed: ${updateErr.message}`);
|
|
119
|
+
await handleRollback({
|
|
120
|
+
pluginDir, fromVersion, toVersion, pluginId, pkgName,
|
|
121
|
+
error: updateErr.message, skipVersion: false, opts, log,
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
log('[upgrade-worker] Update command completed');
|
|
126
|
+
|
|
127
|
+
// 3. 等待 gateway 重启并验证
|
|
128
|
+
log('[upgrade-worker] Verifying upgrade...');
|
|
129
|
+
const result = await verifyUpgrade(pluginId, opts);
|
|
130
|
+
|
|
131
|
+
if (result.ok) {
|
|
132
|
+
// 4a. 成功
|
|
133
|
+
log(`[upgrade-worker] Upgrade verified. Version: ${result.version}`);
|
|
134
|
+
try {
|
|
135
|
+
await removeBackup(pluginDir);
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
log(`[upgrade-worker] Backup cleanup failed (non-fatal): ${e.message}`);
|
|
139
|
+
}
|
|
140
|
+
await updateLastUpgrade({ from: fromVersion, to: toVersion, result: 'ok' });
|
|
141
|
+
await appendLog({ from: fromVersion, to: toVersion, result: 'ok' });
|
|
142
|
+
log('[upgrade-worker] Upgrade complete');
|
|
143
|
+
} else {
|
|
144
|
+
// 4b. 失败,回滚
|
|
145
|
+
log(`[upgrade-worker] Verification failed: ${result.error}`);
|
|
146
|
+
await handleRollback({
|
|
147
|
+
pluginDir, fromVersion, toVersion, pluginId, pkgName,
|
|
148
|
+
error: result.error, skipVersion: true, opts, log,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 回滚处理
|
|
155
|
+
*/
|
|
156
|
+
async function handleRollback({ pluginDir, fromVersion, toVersion, pluginId, pkgName, error, skipVersion, opts, log }) {
|
|
157
|
+
log('[upgrade-worker] Attempting rollback...');
|
|
158
|
+
|
|
159
|
+
// 首选 mv 备份目录
|
|
160
|
+
let restored = false;
|
|
161
|
+
try {
|
|
162
|
+
restored = await restoreFromBackup(pluginDir);
|
|
163
|
+
} catch (restoreErr) {
|
|
164
|
+
log(`[upgrade-worker] Backup restore error: ${restoreErr.message}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (restored) {
|
|
168
|
+
log('[upgrade-worker] Restored from backup');
|
|
169
|
+
} else {
|
|
170
|
+
// 兜底:先卸载再从 npm 安装旧版本
|
|
171
|
+
log('[upgrade-worker] Backup restore failed, falling back to npm install');
|
|
172
|
+
try {
|
|
173
|
+
await fallbackInstallOldVersion(pkgName, fromVersion, pluginId, opts);
|
|
174
|
+
log('[upgrade-worker] Fallback install completed');
|
|
175
|
+
}
|
|
176
|
+
catch (fallbackErr) {
|
|
177
|
+
log(`[upgrade-worker] Fallback install also failed: ${fallbackErr.message}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 等待 gateway 重启
|
|
182
|
+
log('[upgrade-worker] Waiting for gateway to restart after rollback...');
|
|
183
|
+
try {
|
|
184
|
+
await waitForGateway(opts);
|
|
185
|
+
log('[upgrade-worker] Gateway restarted after rollback');
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
log('[upgrade-worker] Gateway did not restart after rollback');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 记录状态(顺序执行因共享 state 文件,但各自 try/catch 避免单个失败阻断其余)
|
|
192
|
+
// 仅验证失败(新版本确实被加载并发现有问题)才标记为 skipped;
|
|
193
|
+
// update 命令失败可能是瞬态故障(网络、磁盘等),不应永久跳过该版本
|
|
194
|
+
if (skipVersion) {
|
|
195
|
+
try { await addSkippedVersion(toVersion); }
|
|
196
|
+
catch (e) { log(`[upgrade-worker] Failed to record skipped version (non-fatal): ${e.message}`); }
|
|
197
|
+
}
|
|
198
|
+
try { await updateLastUpgrade({ from: fromVersion, to: toVersion, result: 'rollback' }); }
|
|
199
|
+
catch (e) { log(`[upgrade-worker] Failed to update lastUpgrade (non-fatal): ${e.message}`); }
|
|
200
|
+
try { await appendLog({ from: fromVersion, to: toVersion, result: 'rollback', error }); }
|
|
201
|
+
catch (e) { log(`[upgrade-worker] Failed to append log (non-fatal): ${e.message}`); }
|
|
202
|
+
if (skipVersion) {
|
|
203
|
+
log(`[upgrade-worker] Rollback complete. Version ${toVersion} added to skipped list`);
|
|
204
|
+
} else {
|
|
205
|
+
log(`[upgrade-worker] Rollback complete. Version ${toVersion} not skipped (transient failure)`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 作为独立进程执行时的入口
|
|
210
|
+
/* c8 ignore start */
|
|
211
|
+
async function main() {
|
|
212
|
+
const { values } = parseArgs({
|
|
213
|
+
options: {
|
|
214
|
+
pluginDir: { type: 'string' },
|
|
215
|
+
fromVersion: { type: 'string' },
|
|
216
|
+
toVersion: { type: 'string' },
|
|
217
|
+
pluginId: { type: 'string' },
|
|
218
|
+
pkgName: { type: 'string' },
|
|
219
|
+
},
|
|
220
|
+
strict: true,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const { pluginDir, fromVersion, toVersion, pluginId, pkgName } = values;
|
|
224
|
+
if (!pluginDir || !fromVersion || !toVersion || !pluginId || !pkgName) {
|
|
225
|
+
console.error('Usage: node worker.js --pluginDir <dir> --fromVersion <ver> --toVersion <ver> --pluginId <id> --pkgName <name>');
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await runUpgrade({ pluginDir, fromVersion, toVersion, pluginId, pkgName });
|
|
231
|
+
process.exit(0);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
console.error(`[upgrade-worker] Fatal error: ${err.message}`);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 仅在直接执行时运行 main
|
|
240
|
+
import { fileURLToPath } from 'node:url';
|
|
241
|
+
import nodePath from 'node:path';
|
|
242
|
+
if (process.argv[1] && nodePath.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
243
|
+
main().catch((err) => {
|
|
244
|
+
console.error(`[upgrade-worker] Fatal: ${err.message}`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/* c8 ignore stop */
|
package/src/realtime-bridge.js
CHANGED
|
@@ -8,6 +8,8 @@ import { getRuntime } from './runtime.js';
|
|
|
8
8
|
const DEFAULT_GATEWAY_WS_URL = 'ws://127.0.0.1:18789';
|
|
9
9
|
const RECONNECT_MS = 10_000;
|
|
10
10
|
const CONNECT_TIMEOUT_MS = 10_000;
|
|
11
|
+
const SERVER_HB_PING_MS = 25_000;
|
|
12
|
+
const SERVER_HB_TIMEOUT_MS = 45_000;
|
|
11
13
|
|
|
12
14
|
function toServerWsUrl(baseUrl, token) {
|
|
13
15
|
const url = new URL(baseUrl);
|
|
@@ -84,6 +86,8 @@ export class RealtimeBridge {
|
|
|
84
86
|
this.logger = console;
|
|
85
87
|
this.pluginConfig = {};
|
|
86
88
|
this.intentionallyClosed = false;
|
|
89
|
+
this.serverHbInterval = null;
|
|
90
|
+
this.serverHbTimer = null;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
__resolveWebSocket() {
|
|
@@ -96,6 +100,31 @@ export class RealtimeBridge {
|
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
__startServerHeartbeat(sock) {
|
|
104
|
+
this.__clearServerHeartbeat();
|
|
105
|
+
this.serverHbInterval = setInterval(() => {
|
|
106
|
+
if (sock.readyState === 1) {
|
|
107
|
+
try { sock.send(JSON.stringify({ type: 'ping' })); } catch {}
|
|
108
|
+
}
|
|
109
|
+
}, SERVER_HB_PING_MS);
|
|
110
|
+
this.serverHbInterval.unref?.();
|
|
111
|
+
this.__resetServerHbTimeout(sock);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
__resetServerHbTimeout(sock) {
|
|
115
|
+
if (this.serverHbTimer) clearTimeout(this.serverHbTimer);
|
|
116
|
+
this.serverHbTimer = setTimeout(() => {
|
|
117
|
+
this.logger.warn?.(`[coclaw] server ws heartbeat timeout (${SERVER_HB_TIMEOUT_MS / 1000}s), closing`);
|
|
118
|
+
try { sock.close(4000, 'heartbeat_timeout'); } catch {}
|
|
119
|
+
}, SERVER_HB_TIMEOUT_MS);
|
|
120
|
+
this.serverHbTimer.unref?.();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
__clearServerHeartbeat() {
|
|
124
|
+
if (this.serverHbInterval) { clearInterval(this.serverHbInterval); this.serverHbInterval = null; }
|
|
125
|
+
if (this.serverHbTimer) { clearTimeout(this.serverHbTimer); this.serverHbTimer = null; }
|
|
126
|
+
}
|
|
127
|
+
|
|
99
128
|
__resolveGatewayWsUrl() {
|
|
100
129
|
return this.pluginConfig?.gatewayWsUrl
|
|
101
130
|
?? process.env.COCLAW_GATEWAY_WS_URL
|
|
@@ -486,10 +515,12 @@ export class RealtimeBridge {
|
|
|
486
515
|
sock.addEventListener('open', () => {
|
|
487
516
|
this.__clearConnectTimer();
|
|
488
517
|
this.logger.info?.(`[coclaw] realtime bridge connected: ${maskedTarget}`);
|
|
518
|
+
this.__startServerHeartbeat(sock);
|
|
489
519
|
this.__ensureGatewayConnection();
|
|
490
520
|
});
|
|
491
521
|
|
|
492
522
|
sock.addEventListener('message', async (event) => {
|
|
523
|
+
this.__resetServerHbTimeout(sock);
|
|
493
524
|
try {
|
|
494
525
|
const payload = JSON.parse(String(event.data ?? '{}'));
|
|
495
526
|
if (payload?.type === 'bot.unbound') {
|
|
@@ -513,6 +544,7 @@ export class RealtimeBridge {
|
|
|
513
544
|
});
|
|
514
545
|
|
|
515
546
|
sock.addEventListener('close', async (event) => {
|
|
547
|
+
this.__clearServerHeartbeat();
|
|
516
548
|
this.__clearConnectTimer();
|
|
517
549
|
// 若 serverWs 已指向新实例(如 refresh 后),跳过旧 sock 的清理
|
|
518
550
|
if (this.serverWs !== null && this.serverWs !== sock) {
|
|
@@ -538,6 +570,7 @@ export class RealtimeBridge {
|
|
|
538
570
|
if (this.serverWs !== sock || this.intentionallyClosed) {
|
|
539
571
|
return;
|
|
540
572
|
}
|
|
573
|
+
this.__clearServerHeartbeat();
|
|
541
574
|
this.__clearConnectTimer();
|
|
542
575
|
this.logger.warn?.(`[coclaw] realtime bridge error, will retry in ${RECONNECT_MS}ms: ${String(err?.message ?? err)}`);
|
|
543
576
|
this.serverWs = null;
|
|
@@ -568,6 +601,7 @@ export class RealtimeBridge {
|
|
|
568
601
|
this.started = false;
|
|
569
602
|
this.mainSessionEnsured = false;
|
|
570
603
|
this.mainSessionEnsurePromise = null;
|
|
604
|
+
this.__clearServerHeartbeat();
|
|
571
605
|
this.__clearConnectTimer();
|
|
572
606
|
if (this.reconnectTimer) {
|
|
573
607
|
clearTimeout(this.reconnectTimer);
|
|
@@ -103,7 +103,7 @@ function parseSessionFileName(fileName) {
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
function archiveTypePriority(archiveType) {
|
|
106
|
-
return archiveType === '
|
|
106
|
+
return archiveType === 'live' ? 2 : 1;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function shouldReplaceByPriority(current, next) {
|
|
@@ -266,6 +266,12 @@ export function createSessionManager(options = {}) {
|
|
|
266
266
|
|
|
267
267
|
function resolveTranscriptFile(agentId, sessionId) {
|
|
268
268
|
const dir = sessionsDir(agentId);
|
|
269
|
+
// live 文件优先:同一 sessionId 可能同时存在 live 和 reset 文件
|
|
270
|
+
// (OpenClaw reset 后复用 sessionId),live 代表当前活跃 transcript
|
|
271
|
+
const livePath = nodePath.join(dir, `${sessionId}.jsonl`);
|
|
272
|
+
if (fs.existsSync(livePath)) {
|
|
273
|
+
return livePath;
|
|
274
|
+
}
|
|
269
275
|
const files = fs.existsSync(dir) ? fs.readdirSync(dir) : [];
|
|
270
276
|
const resetPrefix = `${sessionId}.jsonl.reset.`;
|
|
271
277
|
const resetCandidates = files
|
|
@@ -288,10 +294,6 @@ export function createSessionManager(options = {}) {
|
|
|
288
294
|
if (resetCandidates.length > 0) {
|
|
289
295
|
return resetCandidates[0].path;
|
|
290
296
|
}
|
|
291
|
-
const livePath = nodePath.join(dir, `${sessionId}.jsonl`);
|
|
292
|
-
if (fs.existsSync(livePath)) {
|
|
293
|
-
return livePath;
|
|
294
|
-
}
|
|
295
297
|
return null;
|
|
296
298
|
}
|
|
297
299
|
|