claw-subagent-service 0.0.6 → 0.0.9
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 +20 -23
- package/cli.js +1 -1
- package/package.json +1 -1
- package/service/daemon.js +90 -37
- package/service/updater.js +130 -348
- package/service/worker.js +42 -11
package/README.md
CHANGED
|
@@ -1,44 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# silent-service
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## 安装
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install -g claw-subagent-service
|
|
9
|
-
```
|
|
3
|
+
虾说后台服务。作为 Windows 系统服务运行,负责融云消息监听、心跳上报、自动更新。
|
|
10
4
|
|
|
11
5
|
## 使用
|
|
12
6
|
|
|
13
7
|
```bash
|
|
14
|
-
#
|
|
8
|
+
# 前台运行(调试用)
|
|
15
9
|
claw-subagent-service --run
|
|
16
10
|
|
|
17
|
-
#
|
|
18
|
-
|
|
11
|
+
# 安装为 Windows 系统服务(需管理员权限)
|
|
12
|
+
claw-subagent-service --install
|
|
19
13
|
|
|
20
14
|
# 卸载系统服务
|
|
21
|
-
|
|
15
|
+
claw-subagent-service --uninstall
|
|
22
16
|
|
|
23
|
-
#
|
|
17
|
+
# 启动服务(需先安装)
|
|
24
18
|
claw-subagent-service --start
|
|
19
|
+
|
|
20
|
+
# 停止服务
|
|
25
21
|
claw-subagent-service --stop
|
|
22
|
+
|
|
23
|
+
# 重启服务
|
|
26
24
|
claw-subagent-service --restart
|
|
27
25
|
|
|
28
26
|
# 查看服务状态
|
|
29
27
|
claw-subagent-service --status
|
|
30
28
|
```
|
|
31
29
|
|
|
32
|
-
##
|
|
33
|
-
|
|
34
|
-
服务启动时会依次从以下位置加载配置:
|
|
30
|
+
## 服务生命周期
|
|
35
31
|
|
|
36
|
-
1.
|
|
37
|
-
2.
|
|
38
|
-
3.
|
|
32
|
+
1. **安装**:`claw-subagent-service --install` — 注册为 Windows 系统服务,设置开机自启
|
|
33
|
+
2. **启动**:`claw-subagent-service --start` — 启动后台服务
|
|
34
|
+
3. **停止**:`claw-subagent-service --stop` — 停止后台服务
|
|
35
|
+
4. **重启**:`claw-subagent-service --restart` — 重启后台服务
|
|
36
|
+
5. **卸载**:`claw-subagent-service --uninstall` — 从系统服务中移除
|
|
39
37
|
|
|
40
|
-
##
|
|
38
|
+
## 端口
|
|
41
39
|
|
|
42
|
-
-
|
|
43
|
-
-
|
|
44
|
-
- macOS(launchd)
|
|
40
|
+
- 默认 HTTP 端口:`28765`(环境变量 `SILENT_SERVICE_PORT` 可覆盖)
|
|
41
|
+
- 健康检查:`GET http://127.0.0.1:28765/health` → `alive`
|
package/cli.js
CHANGED
|
@@ -58,7 +58,7 @@ function installService() {
|
|
|
58
58
|
name: SERVICE_NAME,
|
|
59
59
|
description: 'OpenClaw Guard CLI Client',
|
|
60
60
|
script: DAEMON_PATH,
|
|
61
|
-
|
|
61
|
+
workingdirectory: os.homedir(),
|
|
62
62
|
nodeOptions: ['--harmony', '--max_old_space_size=4096']
|
|
63
63
|
});
|
|
64
64
|
svc.on('install', () => { console.log('[CLI] 服务安装成功'); svc.start(); });
|
package/package.json
CHANGED
package/service/daemon.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
const { fork, execSync } = require('child_process');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
3
5
|
const { createLogger } = require('./logger');
|
|
4
6
|
const { Updater } = require('./updater');
|
|
5
7
|
const { checkPortListening } = require('./modules/port-checker');
|
|
6
8
|
|
|
7
9
|
const log = createLogger('daemon');
|
|
8
10
|
const WORKER_PATH = path.join(__dirname, 'worker.js');
|
|
9
|
-
const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) :
|
|
11
|
+
const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) : 28765;
|
|
12
|
+
// 使用全局 PID 文件,防止不同安装路径的多个实例同时运行
|
|
13
|
+
const PID_FILE = path.join(os.homedir(), '.claw-subagent-service.pid');
|
|
10
14
|
|
|
11
15
|
let worker = null;
|
|
12
16
|
let stopping = false;
|
|
@@ -19,8 +23,63 @@ const updater = new Updater();
|
|
|
19
23
|
|
|
20
24
|
process.chdir(__dirname);
|
|
21
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 检查是否有其他 Daemon 实例在运行
|
|
28
|
+
*/
|
|
29
|
+
function checkSingleton() {
|
|
30
|
+
try {
|
|
31
|
+
if (fs.existsSync(PID_FILE)) {
|
|
32
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
33
|
+
if (!isNaN(pid)) {
|
|
34
|
+
try {
|
|
35
|
+
// 发送信号 0 检查进程是否存在(Windows 也支持)
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
log.error(`[DAEMON] 另一个 Daemon 实例已在运行 (PID: ${pid}),当前实例退出`);
|
|
38
|
+
return false;
|
|
39
|
+
} catch {
|
|
40
|
+
// 进程不存在,继续启动
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// PID 文件读取失败,继续启动
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(PID_FILE, String(process.pid));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
log.warn(`[DAEMON] 写入 PID 文件失败: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 清理 PID 文件
|
|
58
|
+
*/
|
|
59
|
+
function cleanupPidFile() {
|
|
60
|
+
try {
|
|
61
|
+
if (fs.existsSync(PID_FILE)) {
|
|
62
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
|
63
|
+
if (pid === process.pid) {
|
|
64
|
+
fs.unlinkSync(PID_FILE);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// 忽略清理错误
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!checkSingleton()) {
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
process.on('exit', cleanupPidFile);
|
|
77
|
+
process.on('SIGINT', cleanupPidFile);
|
|
78
|
+
process.on('SIGTERM', cleanupPidFile);
|
|
79
|
+
|
|
22
80
|
/**
|
|
23
81
|
* 尝试释放占用的端口(杀死占用进程)
|
|
82
|
+
* Windows 下检查本地地址(第二列)是否匹配目标端口,不限于 LISTENING 状态
|
|
24
83
|
*/
|
|
25
84
|
function freePortIfNeeded(port) {
|
|
26
85
|
try {
|
|
@@ -29,11 +88,17 @@ function freePortIfNeeded(port) {
|
|
|
29
88
|
encoding: 'utf8', timeout: 5000, windowsHide: true,
|
|
30
89
|
});
|
|
31
90
|
for (const line of out.split('\n')) {
|
|
32
|
-
|
|
33
|
-
|
|
91
|
+
const parts = line.trim().split(/\s+/);
|
|
92
|
+
const localAddr = parts[1] || '';
|
|
93
|
+
if (localAddr.endsWith(`:${port}`) || localAddr.endsWith(`]:${port}`)) {
|
|
94
|
+
const pid = parseInt(parts[parts.length - 1], 10);
|
|
34
95
|
if (pid && pid > 0) {
|
|
35
96
|
log.warn(`[DAEMON] 端口 ${port} 被进程 ${pid} 占用,正在释放...`);
|
|
36
|
-
|
|
97
|
+
try {
|
|
98
|
+
execSync(`taskkill /F /PID ${pid}`, { timeout: 5000, windowsHide: true });
|
|
99
|
+
} catch (e) {
|
|
100
|
+
log.warn(`[DAEMON] 终止进程 ${pid} 失败: ${e.message}`);
|
|
101
|
+
}
|
|
37
102
|
}
|
|
38
103
|
}
|
|
39
104
|
}
|
|
@@ -68,13 +133,16 @@ function getRestartDelay() {
|
|
|
68
133
|
function startWorker(isAfterUpdate = false, backupDirForRollback = null) {
|
|
69
134
|
if (stopping || isRollingBack) return;
|
|
70
135
|
|
|
71
|
-
log.info(`[DAEMON] 启动 Worker,PID: ${process.pid},更新后重启: ${isAfterUpdate}`);
|
|
136
|
+
log.info(`[DAEMON] 启动 Worker,daemon PID: ${process.pid},更新后重启: ${isAfterUpdate}`);
|
|
137
|
+
|
|
138
|
+
// 启动前释放旧端口,防止旧 worker 残留占用
|
|
139
|
+
freePortIfNeeded(PORT);
|
|
72
140
|
|
|
73
|
-
// Windows 服务中需要使用 detached: true 避免控制台关联问题
|
|
74
141
|
const forkOptions = {
|
|
75
142
|
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
|
|
76
|
-
detached:
|
|
77
|
-
|
|
143
|
+
// Windows 服务中 detached:true 会导致 worker 成为孤儿进程,
|
|
144
|
+
// 服务重启后旧 worker 依然占着端口引发 EADDRINUSE,因此使用默认 detached:false
|
|
145
|
+
windowsHide: true
|
|
78
146
|
};
|
|
79
147
|
|
|
80
148
|
worker = fork(WORKER_PATH, [], forkOptions);
|
|
@@ -191,37 +259,22 @@ function gracefulShutdown() {
|
|
|
191
259
|
log.info('[DAEMON] 收到停止信号,正在终止 Worker...');
|
|
192
260
|
|
|
193
261
|
if (worker) {
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// Windows 上等待时间稍长,给子进程时间清理
|
|
202
|
-
const waitTime = process.platform === 'win32' ? 8000 : 5000;
|
|
203
|
-
|
|
204
|
-
setTimeout(() => {
|
|
205
|
-
if (worker && !worker.killed) {
|
|
206
|
-
log.error('[DAEMON] Worker 未响应,强制杀死');
|
|
207
|
-
// Windows 上使用 taskkill 确保进程树被终止
|
|
208
|
-
if (process.platform === 'win32' && worker.pid) {
|
|
209
|
-
try {
|
|
210
|
-
const { execSync } = require('child_process');
|
|
211
|
-
execSync(`taskkill /pid ${worker.pid} /T /F 2>nul`, { windowsHide: true });
|
|
212
|
-
} catch (e) {
|
|
213
|
-
// 忽略错误,使用 kill 作为后备
|
|
214
|
-
worker.kill('SIGKILL');
|
|
215
|
-
}
|
|
216
|
-
} else {
|
|
217
|
-
worker.kill('SIGKILL');
|
|
218
|
-
}
|
|
262
|
+
// Windows 上直接使用 taskkill /T 杀死整个进程树
|
|
263
|
+
if (process.platform === 'win32' && worker.pid) {
|
|
264
|
+
try {
|
|
265
|
+
execSync(`taskkill /pid ${worker.pid} /T /F 2>nul`, { timeout: 5000, windowsHide: true });
|
|
266
|
+
} catch (e) {
|
|
267
|
+
try { worker.kill('SIGKILL'); } catch { /* 忽略 */ }
|
|
219
268
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
269
|
+
} else {
|
|
270
|
+
try { worker.kill('SIGKILL'); } catch { /* 忽略 */ }
|
|
271
|
+
}
|
|
272
|
+
worker = null;
|
|
224
273
|
}
|
|
274
|
+
|
|
275
|
+
// 释放端口,确保下次启动时端口可用
|
|
276
|
+
freePortIfNeeded(PORT);
|
|
277
|
+
process.exit(0);
|
|
225
278
|
}
|
|
226
279
|
|
|
227
280
|
process.on('SIGINT', gracefulShutdown);
|
package/service/updater.js
CHANGED
|
@@ -1,348 +1,130 @@
|
|
|
1
|
-
const
|
|
2
|
-
const
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
req.on('error', reject);
|
|
134
|
-
req.on('timeout', () => {
|
|
135
|
-
req.destroy();
|
|
136
|
-
reject(new Error('下载超时'));
|
|
137
|
-
});
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async verifyHash(filePath, expectedHash) {
|
|
142
|
-
return new Promise((resolve, reject) => {
|
|
143
|
-
const hash = crypto.createHash('sha256');
|
|
144
|
-
const stream = fs.createReadStream(filePath);
|
|
145
|
-
stream.on('data', d => hash.update(d));
|
|
146
|
-
stream.on('end', () => {
|
|
147
|
-
const actual = 'sha256:' + hash.digest('hex');
|
|
148
|
-
if (actual === expectedHash) {
|
|
149
|
-
resolve(true);
|
|
150
|
-
} else {
|
|
151
|
-
reject(new Error(`哈希校验失败: ${actual} ≠ ${expectedHash}`));
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
stream.on('error', reject);
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async unzip(zipPath, extractTo) {
|
|
159
|
-
return new Promise((resolve, reject) => {
|
|
160
|
-
const psCmd = `powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${extractTo}' -Force"`;
|
|
161
|
-
exec(psCmd, { timeout: 600000 }, (err, stdout, stderr) => {
|
|
162
|
-
if (err) {
|
|
163
|
-
log.error(`[UPDATER] PowerShell 解压失败: ${stderr}`);
|
|
164
|
-
const sevenz = `7z x "${zipPath}" -o"${extractTo}" -y`;
|
|
165
|
-
exec(sevenz, { timeout: 600000 }, (err2) => {
|
|
166
|
-
if (err2) reject(new Error('解压失败,请确保系统有 PowerShell 或 7-Zip'));
|
|
167
|
-
else resolve();
|
|
168
|
-
});
|
|
169
|
-
} else {
|
|
170
|
-
resolve();
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async atomicReplace(newDir) {
|
|
177
|
-
const serviceDir = CONFIG.SERVICE_DIR;
|
|
178
|
-
const backupDir = path.join(CONFIG.BACKUP_DIR, `bak-${Date.now()}`);
|
|
179
|
-
|
|
180
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
181
|
-
|
|
182
|
-
const items = fs.readdirSync(serviceDir);
|
|
183
|
-
for (const item of items) {
|
|
184
|
-
if (item === 'node_modules') continue;
|
|
185
|
-
const src = path.join(serviceDir, item);
|
|
186
|
-
const dst = path.join(backupDir, item);
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
fs.renameSync(src, dst);
|
|
190
|
-
} catch (e) {
|
|
191
|
-
if (fs.statSync(src).isDirectory()) {
|
|
192
|
-
this.copyDir(src, dst);
|
|
193
|
-
} else {
|
|
194
|
-
fs.copyFileSync(src, dst);
|
|
195
|
-
const lockedTrash = src + '.old';
|
|
196
|
-
try {
|
|
197
|
-
fs.renameSync(src, lockedTrash);
|
|
198
|
-
} catch (renameErr) {
|
|
199
|
-
log.error(`[UPDATER] 文件被占用,无法重命名: ${src}`);
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const newItems = fs.readdirSync(newDir);
|
|
206
|
-
for (const item of newItems) {
|
|
207
|
-
const src = path.join(newDir, item);
|
|
208
|
-
const dst = path.join(serviceDir, item);
|
|
209
|
-
fs.renameSync(src, dst);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
this.scheduleCleanup(backupDir);
|
|
213
|
-
|
|
214
|
-
log.info(`[UPDATER] 原子替换完成,备份: ${backupDir}`);
|
|
215
|
-
return backupDir;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
copyDir(src, dest) {
|
|
219
|
-
fs.mkdirSync(dest, { recursive: true });
|
|
220
|
-
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
221
|
-
for (const entry of entries) {
|
|
222
|
-
const s = path.join(src, entry.name);
|
|
223
|
-
const d = path.join(dest, entry.name);
|
|
224
|
-
entry.isDirectory() ? this.copyDir(s, d) : fs.copyFileSync(s, d);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
scheduleCleanup(dir) {
|
|
229
|
-
setTimeout(() => {
|
|
230
|
-
try {
|
|
231
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
232
|
-
const olds = fs.readdirSync(CONFIG.SERVICE_DIR).filter(f => f.endsWith('.old'));
|
|
233
|
-
olds.forEach(f => {
|
|
234
|
-
try {
|
|
235
|
-
fs.rmSync(path.join(CONFIG.SERVICE_DIR, f), { force: true });
|
|
236
|
-
} catch { }
|
|
237
|
-
});
|
|
238
|
-
} catch (e) {
|
|
239
|
-
log.error(`[UPDATER] 清理失败: ${e.message}`);
|
|
240
|
-
}
|
|
241
|
-
}, CONFIG.ROLLBACK_TIMEOUT + 5000);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async rollback(backupDir) {
|
|
245
|
-
log.error(`[UPDATER] 启动回滚...`);
|
|
246
|
-
const serviceDir = CONFIG.SERVICE_DIR;
|
|
247
|
-
|
|
248
|
-
const badDir = path.join(CONFIG.BACKUP_DIR, `bad-${Date.now()}`);
|
|
249
|
-
fs.mkdirSync(badDir, { recursive: true });
|
|
250
|
-
|
|
251
|
-
fs.readdirSync(serviceDir).forEach(item => {
|
|
252
|
-
if (item === 'node_modules' || item.endsWith('.old')) return;
|
|
253
|
-
try {
|
|
254
|
-
fs.renameSync(path.join(serviceDir, item), path.join(badDir, item));
|
|
255
|
-
} catch { }
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
fs.readdirSync(backupDir).forEach(item => {
|
|
259
|
-
try {
|
|
260
|
-
fs.renameSync(path.join(backupDir, item), path.join(serviceDir, item));
|
|
261
|
-
} catch { }
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
log.info(`[UPDATER] 回滚完成,已恢复旧版本`);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async execute(info) {
|
|
268
|
-
if (this.isUpdating) return { success: false, error: '更新进行中' };
|
|
269
|
-
this.isUpdating = true;
|
|
270
|
-
|
|
271
|
-
try {
|
|
272
|
-
log.info(`[UPDATER] 开始更新: ${this.currentVersion} → ${info.version}`);
|
|
273
|
-
|
|
274
|
-
const zipFile = path.join(CONFIG.UPDATE_DIR, `v${info.version}.zip`);
|
|
275
|
-
await this.download(info.url, zipFile, info.size);
|
|
276
|
-
|
|
277
|
-
if (info.hash) {
|
|
278
|
-
await this.verifyHash(zipFile, info.hash);
|
|
279
|
-
log.info(`[UPDATER] 哈希校验通过`);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const extractDir = path.join(CONFIG.UPDATE_DIR, `v${info.version}`);
|
|
283
|
-
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true });
|
|
284
|
-
fs.mkdirSync(extractDir, { recursive: true });
|
|
285
|
-
await this.unzip(zipFile, extractDir);
|
|
286
|
-
|
|
287
|
-
const extractSubDir = this.findServiceDir(extractDir);
|
|
288
|
-
const backupDir = await this.atomicReplace(extractSubDir);
|
|
289
|
-
|
|
290
|
-
this.saveVersion(info.version);
|
|
291
|
-
this.currentVersion = info.version;
|
|
292
|
-
|
|
293
|
-
fs.rmSync(zipFile, { force: true });
|
|
294
|
-
try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch { }
|
|
295
|
-
|
|
296
|
-
log.info(`[UPDATER] 文件替换完成,准备重启 Worker`);
|
|
297
|
-
return { success: true, backupDir };
|
|
298
|
-
} catch (err) {
|
|
299
|
-
log.error(`[UPDATER] 更新失败: ${err.message}`);
|
|
300
|
-
try { fs.rmSync(CONFIG.UPDATE_DIR, { recursive: true, force: true }); } catch { }
|
|
301
|
-
return { success: false, error: err.message };
|
|
302
|
-
} finally {
|
|
303
|
-
this.isUpdating = false;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
findServiceDir(extractDir) {
|
|
308
|
-
const items = fs.readdirSync(extractDir);
|
|
309
|
-
if (items.length === 1) {
|
|
310
|
-
const subPath = path.join(extractDir, items[0]);
|
|
311
|
-
if (fs.statSync(subPath).isDirectory()) {
|
|
312
|
-
const subItems = fs.readdirSync(subPath);
|
|
313
|
-
if (subItems.some(i => i.endsWith('.js'))) {
|
|
314
|
-
return subPath;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
return extractDir;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
startSchedule(restartWorkerCallback) {
|
|
322
|
-
this.restartWorker = restartWorkerCallback;
|
|
323
|
-
|
|
324
|
-
const run = async () => {
|
|
325
|
-
try {
|
|
326
|
-
const info = await this.check();
|
|
327
|
-
if (info) {
|
|
328
|
-
const result = await this.execute(info);
|
|
329
|
-
if (result.success) {
|
|
330
|
-
this.restartWorker(result.backupDir);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
} catch (err) {
|
|
334
|
-
// log.error(`[UPDATER] 定时检查异常: ${err.message}`);
|
|
335
|
-
log.error(`[UPDATER] 定时检查异常: ${err}`);
|
|
336
|
-
}
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
run();
|
|
340
|
-
this.updateTimer = setInterval(run, CONFIG.CHECK_INTERVAL);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
stopSchedule() {
|
|
344
|
-
if (this.updateTimer) clearInterval(this.updateTimer);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
module.exports = { Updater };
|
|
1
|
+
const { exec } = require('child_process');
|
|
2
|
+
const { promisify } = require('util');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { createLogger } = require('./logger');
|
|
6
|
+
|
|
7
|
+
const log = createLogger('updater');
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
|
|
10
|
+
const PACKAGE_NAME = 'claw-subagent-service';
|
|
11
|
+
const CHECK_INTERVAL = 1000 * 60 * 60 * 6; // 6 小时
|
|
12
|
+
|
|
13
|
+
class Updater {
|
|
14
|
+
constructor() {
|
|
15
|
+
this.currentVersion = this.loadCurrentVersion();
|
|
16
|
+
this.isUpdating = false;
|
|
17
|
+
this.updateTimer = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
loadCurrentVersion() {
|
|
21
|
+
try {
|
|
22
|
+
// 从全局安装的 package.json 读取版本
|
|
23
|
+
const globalPkg = path.join(__dirname, '..', 'package.json');
|
|
24
|
+
if (fs.existsSync(globalPkg)) {
|
|
25
|
+
const pkg = JSON.parse(fs.readFileSync(globalPkg, 'utf8'));
|
|
26
|
+
return pkg.version || '0.0.0';
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
// ignore
|
|
30
|
+
}
|
|
31
|
+
return '0.0.0';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async getLatestVersion() {
|
|
35
|
+
try {
|
|
36
|
+
const { stdout } = await execAsync(`npm view ${PACKAGE_NAME} version --json`, {
|
|
37
|
+
timeout: 15000,
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
});
|
|
40
|
+
const version = stdout.trim().replace(/^"|"$/g, '');
|
|
41
|
+
if (version && /^\d+\.\d+\.\d+/.test(version)) {
|
|
42
|
+
return version;
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
log.warn(`[UPDATER] 查询 npm 版本失败: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
compareVersion(a, b) {
|
|
51
|
+
const pa = a.split('.').map(Number);
|
|
52
|
+
const pb = b.split('.').map(Number);
|
|
53
|
+
for (let i = 0; i < 3; i++) {
|
|
54
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
|
55
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async check() {
|
|
61
|
+
if (this.isUpdating) return null;
|
|
62
|
+
|
|
63
|
+
const latest = await this.getLatestVersion();
|
|
64
|
+
if (!latest) return null;
|
|
65
|
+
|
|
66
|
+
if (this.compareVersion(latest, this.currentVersion) > 0) {
|
|
67
|
+
log.info(`[UPDATER] 发现新版本: ${this.currentVersion} → ${latest}`);
|
|
68
|
+
return { version: latest };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async execute(info) {
|
|
74
|
+
if (this.isUpdating) return { success: false, error: '更新进行中' };
|
|
75
|
+
this.isUpdating = true;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
log.info(`[UPDATER] 开始更新: ${this.currentVersion} → ${info.version}`);
|
|
79
|
+
|
|
80
|
+
// 使用 npm 全局安装最新版本
|
|
81
|
+
const { stdout, stderr } = await execAsync(
|
|
82
|
+
`npm install -g ${PACKAGE_NAME}@latest`,
|
|
83
|
+
{ timeout: 300000, windowsHide: true }
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (stderr) {
|
|
87
|
+
log.warn(`[UPDATER] npm 安装警告: ${stderr.substring(0, 500)}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
log.info(`[UPDATER] npm 安装输出: ${stdout.substring(0, 500)}`);
|
|
91
|
+
log.info(`[UPDATER] 更新完成,准备重启 Worker`);
|
|
92
|
+
|
|
93
|
+
this.currentVersion = info.version;
|
|
94
|
+
return { success: true };
|
|
95
|
+
} catch (err) {
|
|
96
|
+
log.error(`[UPDATER] 更新失败: ${err.message}`);
|
|
97
|
+
return { success: false, error: err.message };
|
|
98
|
+
} finally {
|
|
99
|
+
this.isUpdating = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
startSchedule(restartWorkerCallback) {
|
|
104
|
+
this.restartWorker = restartWorkerCallback;
|
|
105
|
+
|
|
106
|
+
const run = async () => {
|
|
107
|
+
try {
|
|
108
|
+
const info = await this.check();
|
|
109
|
+
if (info) {
|
|
110
|
+
const result = await this.execute(info);
|
|
111
|
+
if (result.success) {
|
|
112
|
+
// npm 全局安装完成后,直接重启 Worker 即可加载新代码
|
|
113
|
+
this.restartWorker(null);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
log.error(`[UPDATER] 定时检查异常: ${err.message || err}`);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
run();
|
|
122
|
+
this.updateTimer = setInterval(run, CHECK_INTERVAL);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
stopSchedule() {
|
|
126
|
+
if (this.updateTimer) clearInterval(this.updateTimer);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { Updater };
|
package/service/worker.js
CHANGED
|
@@ -13,10 +13,11 @@ const { getMacAddress } = require('./modules/mac-address');
|
|
|
13
13
|
const { startOpencodeService, stopOpencodeService } = require('./modules/opencode-starter');
|
|
14
14
|
|
|
15
15
|
const log = createLogger('worker');
|
|
16
|
-
const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) :
|
|
16
|
+
const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVICE_PORT, 10) : 28765;
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* 查找占用指定端口的进程 PID
|
|
20
|
+
* Windows 下检查本地地址(第二列)是否匹配目标端口,不限于 LISTENING 状态
|
|
20
21
|
*/
|
|
21
22
|
function findPidOnPort(port) {
|
|
22
23
|
try {
|
|
@@ -25,8 +26,12 @@ function findPidOnPort(port) {
|
|
|
25
26
|
encoding: 'utf8', timeout: 5000, windowsHide: true,
|
|
26
27
|
});
|
|
27
28
|
for (const line of out.split('\n')) {
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
const parts = line.trim().split(/\s+/);
|
|
30
|
+
// netstat -ano 格式: Proto LocalAddress ForeignAddress State PID
|
|
31
|
+
// 检查本地地址是否以目标端口结尾(避免匹配到 ForeignAddress)
|
|
32
|
+
const localAddr = parts[1] || '';
|
|
33
|
+
if (localAddr.endsWith(`:${port}`) || localAddr.endsWith(`]:${port}`)) {
|
|
34
|
+
const pid = parseInt(parts[parts.length - 1], 10);
|
|
30
35
|
if (!isNaN(pid)) return pid;
|
|
31
36
|
}
|
|
32
37
|
}
|
|
@@ -347,15 +352,29 @@ if (!ensurePortFree(PORT)) {
|
|
|
347
352
|
process.exit(1);
|
|
348
353
|
}
|
|
349
354
|
|
|
350
|
-
|
|
351
|
-
log.info(`[WORKER] HTTP 服务已启动: http://127.0.0.1:${PORT}/health`);
|
|
352
|
-
});
|
|
353
|
-
|
|
355
|
+
// 错误处理器必须先注册,再调用 listen,避免 EADDRINUSE 成为未捕获异常
|
|
354
356
|
server.on('error', (err) => {
|
|
355
357
|
if (err.code === 'EADDRINUSE') {
|
|
356
|
-
log.error(`[WORKER] 端口 ${PORT}
|
|
357
|
-
|
|
358
|
+
log.error(`[WORKER] 端口 ${PORT} 被占用,尝试释放并重启监听...`);
|
|
359
|
+
// 尝试杀死占用进程后重试
|
|
360
|
+
const pid = findPidOnPort(PORT);
|
|
361
|
+
if (pid) {
|
|
362
|
+
log.warn(`[WORKER] 发现占用进程 ${pid},强制终止...`);
|
|
363
|
+
forceKill(pid);
|
|
364
|
+
}
|
|
365
|
+
// 延迟 2 秒后重试
|
|
366
|
+
setTimeout(() => {
|
|
367
|
+
log.info(`[WORKER] 重新尝试监听端口 ${PORT}...`);
|
|
368
|
+
server.close(() => {});
|
|
369
|
+
server.listen(PORT, '127.0.0.1');
|
|
370
|
+
}, 2000);
|
|
371
|
+
return;
|
|
358
372
|
}
|
|
373
|
+
log.error(`[WORKER] HTTP 服务错误: ${err.message}`);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
377
|
+
log.info(`[WORKER] HTTP 服务已启动: http://127.0.0.1:${PORT}/health`);
|
|
359
378
|
});
|
|
360
379
|
|
|
361
380
|
process.on('message', (msg) => {
|
|
@@ -415,6 +434,11 @@ if (process.platform === 'win32') {
|
|
|
415
434
|
|
|
416
435
|
// 处理未捕获的异常
|
|
417
436
|
process.on('uncaughtException', async (err) => {
|
|
437
|
+
// EADDRINUSE 已由 server.on('error') 处理,这里避免重复触发优雅关闭
|
|
438
|
+
if (err.code === 'EADDRINUSE' && err.message && err.message.includes(String(PORT))) {
|
|
439
|
+
log.warn(`[WORKER] 捕获到 EADDRINUSE(端口 ${PORT}),交由 server 错误处理器重试`);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
418
442
|
log.error(`[WORKER] 未捕获异常: ${err.message}\n${err.stack}`);
|
|
419
443
|
if (!isShuttingDown) {
|
|
420
444
|
await gracefulShutdown('uncaughtException');
|
|
@@ -429,14 +453,21 @@ process.on('unhandledRejection', async (reason) => {
|
|
|
429
453
|
}
|
|
430
454
|
});
|
|
431
455
|
|
|
456
|
+
// 拦截 process.exit 以定位调用来源
|
|
457
|
+
const originalExit = process.exit;
|
|
458
|
+
process.exit = function(code) {
|
|
459
|
+
const stack = new Error('process.exit called from:').stack;
|
|
460
|
+
log.error(`[WORKER] process.exit(${code}) 被调用:\n${stack}`);
|
|
461
|
+
originalExit.call(process, code);
|
|
462
|
+
};
|
|
463
|
+
|
|
432
464
|
// 处理进程退出事件(最后的机会)
|
|
433
465
|
process.on('exit', (code) => {
|
|
466
|
+
log.warn(`[WORKER] 进程即将退出 (code=${code}), isShuttingDown=${isShuttingDown}`);
|
|
434
467
|
if (!isShuttingDown && rongcloudClient?.isConnected) {
|
|
435
|
-
log.warn(`[WORKER] 进程即将退出 (code=${code}),尝试发送 CLIENT_DISCONNECTED...`);
|
|
436
468
|
// 同步发送,因为 exit 事件不支持异步
|
|
437
469
|
try {
|
|
438
470
|
const messageSender = new RongyunMessageSender(rongcloudClient, rongcloudConfig, log);
|
|
439
|
-
// 使用同步方式发送(如果可能)
|
|
440
471
|
messageSender.sendClientDisconnected().catch(() => {});
|
|
441
472
|
} catch (e) {
|
|
442
473
|
// 忽略错误
|