openclaw-agent-dashboard 1.0.33 → 1.0.34
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 +490 -104
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -10,7 +10,15 @@
|
|
|
10
10
|
* 3. openclaw.json 中 plugins.entries.openclaw-agent-dashboard.config.port
|
|
11
11
|
* 4. 默认 38271
|
|
12
12
|
*
|
|
13
|
-
* 启动前:若首选端口被占用且 /api/version 确认为本插件 Dashboard,则在 Unix
|
|
13
|
+
* 启动前:若首选端口被占用且 /api/version 确认为本插件 Dashboard,则在 Unix 上结束旧进程
|
|
14
|
+
* (SIGTERM → 必要时 SIGKILL;无 lsof 时 Linux 可试 fuser),尽量仍在 38271 起新实例。
|
|
15
|
+
*
|
|
16
|
+
* 热重载恢复(评审文档 FR-1~FR-9):
|
|
17
|
+
* - activeStartId 所有权互斥 + 世代号防止并发双起与 stop 后仍 spawn(FR-7/FR-9)
|
|
18
|
+
* - stop 带超时等待子进程退出(FR-1)
|
|
19
|
+
* - 启动重试 + 探测 + 回收循环(FR-2/FR-3/FR-5)
|
|
20
|
+
* - spawn 后就绪探测(FR-9)
|
|
21
|
+
* - PID 文件辅助孤儿发现(FR-8)
|
|
14
22
|
*/
|
|
15
23
|
const path = require('path');
|
|
16
24
|
const os = require('os');
|
|
@@ -19,8 +27,51 @@ const net = require('net');
|
|
|
19
27
|
const http = require('http');
|
|
20
28
|
const { spawn, execFileSync } = require('child_process');
|
|
21
29
|
|
|
30
|
+
// ── 模块级状态 ──────────────────────────────────────────────────────────────
|
|
22
31
|
let dashboardProcess = null;
|
|
23
32
|
|
|
33
|
+
/** 世代号:每次进入异步启动链时递增,stop 时 bump,用于作废旧的异步链 */
|
|
34
|
+
let generation = 0;
|
|
35
|
+
/** 中止标志:stop 时置 true,start 入口时重置为 false */
|
|
36
|
+
let aborted = false;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 互斥锁:保证全局最多一条「选口→spawn→就绪确认」的启动链在途。
|
|
40
|
+
* 采用所有权语义:值为本次启动的 generation(非零),0 表示空闲。
|
|
41
|
+
* 仅持有该 generation 的 IIFE 可在 finally 中释放(比较 activeStartId === myId),
|
|
42
|
+
* stop() 和其他链不得修改此值——避免旧链误清新链的锁。
|
|
43
|
+
*
|
|
44
|
+
* 残余窗口:stop() 后、旧链 finally 执行前 activeStartId !== 0,
|
|
45
|
+
* 新的 startDashboard() 会「启动链已在进行中, 跳过」。若宿主不会再次调用
|
|
46
|
+
* startDashboard,理论上存在极短窗口内「看起来没自动拉起」;但旧链会在
|
|
47
|
+
* 下一个 await 边界因 generation/aborted 快速退出并释放锁。
|
|
48
|
+
* 若需更强保证(宿主仅调一次 start),可在锁释放后自动重试(后续增强)。
|
|
49
|
+
*/
|
|
50
|
+
let activeStartId = 0;
|
|
51
|
+
|
|
52
|
+
// ── 常量 / 可通过环境变量覆盖 ────────────────────────────────────────────────
|
|
53
|
+
const DEFAULT_PORT = 38271;
|
|
54
|
+
const MAX_START_RETRIES = parseInt(process.env.DASHBOARD_START_MAX_RETRIES, 10) || 5;
|
|
55
|
+
const TOTAL_RETRY_TIMEOUT_MS = parseInt(process.env.DASHBOARD_START_TOTAL_TIMEOUT_MS, 10) || 15000;
|
|
56
|
+
const BACKOFF_BASE_MS = 500;
|
|
57
|
+
const STOP_SIGTERM_TIMEOUT_MS = 5000;
|
|
58
|
+
const STOP_SIGKILL_TIMEOUT_MS = 3000;
|
|
59
|
+
const READY_CHECK_TIMEOUT_MS = 8000;
|
|
60
|
+
const READY_CHECK_INTERVAL_MS = 500;
|
|
61
|
+
const PID_FILENAME = 'dashboard-runtime.json';
|
|
62
|
+
|
|
63
|
+
const LOG_PREFIX = '[OpenClaw-Dashboard]';
|
|
64
|
+
|
|
65
|
+
// ── 工具函数 ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
function log(...args) { console.log(LOG_PREFIX, ...args); }
|
|
68
|
+
function logWarn(...args) { console.warn(LOG_PREFIX, ...args); }
|
|
69
|
+
function logError(...args) { console.error(LOG_PREFIX, ...args); }
|
|
70
|
+
|
|
71
|
+
function sleepMs(ms) {
|
|
72
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
73
|
+
}
|
|
74
|
+
|
|
24
75
|
/** 解析系统 Python(Linux/macOS 多为 python3,Windows 多为 python) */
|
|
25
76
|
function resolveSystemPython() {
|
|
26
77
|
if (process.env.PYTHON_CMD) {
|
|
@@ -41,17 +92,16 @@ function getOpenClawHome() {
|
|
|
41
92
|
return process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
|
|
42
93
|
}
|
|
43
94
|
|
|
44
|
-
/** Dashboard
|
|
95
|
+
/** Dashboard 数据目录 */
|
|
45
96
|
function getDashboardDataDir() {
|
|
46
97
|
return process.env.OPENCLAW_AGENT_DASHBOARD_DATA || path.join(os.homedir(), '.openclaw-agent-dashboard');
|
|
47
98
|
}
|
|
48
99
|
|
|
49
100
|
function getDashboardDir() {
|
|
50
|
-
|
|
51
|
-
return path.join(pluginDir, 'dashboard');
|
|
101
|
+
return path.join(__dirname, 'dashboard');
|
|
52
102
|
}
|
|
53
103
|
|
|
54
|
-
/**
|
|
104
|
+
/** 兼容旧路径:若存在旧 config.json 则迁移到新目录 */
|
|
55
105
|
function migrateLegacyConfigIfNeeded() {
|
|
56
106
|
const openclawHome = getOpenClawHome();
|
|
57
107
|
const newDir = getDashboardDataDir();
|
|
@@ -59,7 +109,7 @@ function migrateLegacyConfigIfNeeded() {
|
|
|
59
109
|
if (fs.existsSync(newPath)) return;
|
|
60
110
|
const legacyPaths = [
|
|
61
111
|
path.join(openclawHome, 'dashboard', 'config.json'),
|
|
62
|
-
path.join(os.homedir(), '.openclaw-dashboard', 'config.json')
|
|
112
|
+
path.join(os.homedir(), '.openclaw-dashboard', 'config.json'),
|
|
63
113
|
];
|
|
64
114
|
for (const legacyPath of legacyPaths) {
|
|
65
115
|
if (!fs.existsSync(legacyPath)) continue;
|
|
@@ -75,8 +125,7 @@ function migrateLegacyConfigIfNeeded() {
|
|
|
75
125
|
* 加载配置,优先级:env > 用户 config.json(新路径 > 旧路径)> api.pluginConfig > 默认
|
|
76
126
|
*/
|
|
77
127
|
function loadConfig(apiPluginConfig = {}) {
|
|
78
|
-
|
|
79
|
-
let config = { port: 38271, autoStart: true }; // 使用罕见端口避免冲突
|
|
128
|
+
let config = { port: DEFAULT_PORT, autoStart: true };
|
|
80
129
|
|
|
81
130
|
if (apiPluginConfig && typeof apiPluginConfig.port === 'number') {
|
|
82
131
|
config.port = apiPluginConfig.port;
|
|
@@ -95,7 +144,7 @@ function loadConfig(apiPluginConfig = {}) {
|
|
|
95
144
|
|
|
96
145
|
migrateLegacyConfigIfNeeded();
|
|
97
146
|
const userConfigPath = path.join(getDashboardDataDir(), 'config.json');
|
|
98
|
-
const legacyUserConfigPath = path.join(
|
|
147
|
+
const legacyUserConfigPath = path.join(getOpenClawHome(), 'dashboard', 'config.json');
|
|
99
148
|
const prevUserConfigPath = path.join(os.homedir(), '.openclaw-dashboard', 'config.json');
|
|
100
149
|
const pathToRead = fs.existsSync(userConfigPath) ? userConfigPath
|
|
101
150
|
: (fs.existsSync(prevUserConfigPath) ? prevUserConfigPath : legacyUserConfigPath);
|
|
@@ -115,6 +164,8 @@ function loadConfig(apiPluginConfig = {}) {
|
|
|
115
164
|
return config;
|
|
116
165
|
}
|
|
117
166
|
|
|
167
|
+
// ── 端口检测 ────────────────────────────────────────────────────────────────
|
|
168
|
+
|
|
118
169
|
/** 检测端口是否可用 */
|
|
119
170
|
function isPortAvailable(port) {
|
|
120
171
|
return new Promise((resolve) => {
|
|
@@ -127,7 +178,7 @@ function isPortAvailable(port) {
|
|
|
127
178
|
});
|
|
128
179
|
}
|
|
129
180
|
|
|
130
|
-
/**
|
|
181
|
+
/** 检测端口是否已被占用 */
|
|
131
182
|
function isPortInUse(port) {
|
|
132
183
|
return new Promise((resolve) => {
|
|
133
184
|
const server = net.createServer();
|
|
@@ -148,7 +199,9 @@ async function findAvailablePort(basePort, maxAttempts = 10) {
|
|
|
148
199
|
return basePort;
|
|
149
200
|
}
|
|
150
201
|
|
|
151
|
-
|
|
202
|
+
// ── 探测与回收 ──────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/** 与 Python 端 VersionInfo.name 一致 */
|
|
152
205
|
function getExpectedDashboardApiName() {
|
|
153
206
|
try {
|
|
154
207
|
const manifestPath = path.join(__dirname, 'openclaw.plugin.json');
|
|
@@ -163,7 +216,6 @@ function getExpectedDashboardApiName() {
|
|
|
163
216
|
|
|
164
217
|
/**
|
|
165
218
|
* 探测本机 port 上是否为当前插件的 Dashboard(FastAPI /api/version)
|
|
166
|
-
* @returns {Promise<boolean>}
|
|
167
219
|
*/
|
|
168
220
|
function probeOurDashboardListening(port) {
|
|
169
221
|
const expected = getExpectedDashboardApiName();
|
|
@@ -194,7 +246,6 @@ function probeOurDashboardListening(port) {
|
|
|
194
246
|
|
|
195
247
|
/**
|
|
196
248
|
* 获取在 port 上 LISTEN 的进程 PID(Unix)。Windows 暂不支持,返回空数组。
|
|
197
|
-
* @returns {number[]}
|
|
198
249
|
*/
|
|
199
250
|
function getListenPidsOnPort(port) {
|
|
200
251
|
if (process.platform === 'win32') {
|
|
@@ -220,13 +271,9 @@ function getListenPidsOnPort(port) {
|
|
|
220
271
|
return [...new Set(pids)];
|
|
221
272
|
}
|
|
222
273
|
|
|
223
|
-
function sleepMs(ms) {
|
|
224
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
225
|
-
}
|
|
226
|
-
|
|
227
274
|
/**
|
|
228
|
-
* Linux:无 lsof PID 时尝试 fuser 释放监听该 TCP
|
|
229
|
-
*
|
|
275
|
+
* Linux:无 lsof PID 时尝试 fuser 释放监听该 TCP 端口的进程。
|
|
276
|
+
* 仅在已确认 /api/version 为本插件后调用。
|
|
230
277
|
*/
|
|
231
278
|
function tryFuserKillTcpPort(port) {
|
|
232
279
|
if (process.platform !== 'linux') return;
|
|
@@ -238,8 +285,41 @@ function tryFuserKillTcpPort(port) {
|
|
|
238
285
|
}
|
|
239
286
|
|
|
240
287
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
288
|
+
* 单轮回收:尝试结束占用 port 的本插件 Dashboard 进程。
|
|
289
|
+
*
|
|
290
|
+
* 前提:调用方必须已通过 probeOurDashboardListening 确认端口上为本插件 Dashboard,
|
|
291
|
+
* 本函数不做二次 probe(与 tryFuserKillTcpPort 约定一致)。
|
|
292
|
+
*
|
|
293
|
+
* 返回 true 表示本轮执行了 kill / fuser 操作。
|
|
294
|
+
*/
|
|
295
|
+
function reclaimOneRound(port) {
|
|
296
|
+
const pids = getListenPidsOnPort(port);
|
|
297
|
+
if (pids.length > 0) {
|
|
298
|
+
for (const pid of pids) {
|
|
299
|
+
try {
|
|
300
|
+
process.kill(pid, 'SIGTERM');
|
|
301
|
+
} catch (_) {}
|
|
302
|
+
}
|
|
303
|
+
log(`回收端口 ${port}: 已向 PID ${pids.join(', ')} 发送 SIGTERM`);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/* 有 lsof 但拿不到 PID(极少见),或 Windows 无 PID 手段 */
|
|
308
|
+
if (process.platform === 'linux') {
|
|
309
|
+
log(`回收端口 ${port}: 无 lsof PID, 尝试 fuser`);
|
|
310
|
+
tryFuserKillTcpPort(port);
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (process.platform === 'win32') {
|
|
315
|
+
logWarn(`回收端口 ${port}: Windows 无 PID 手段, 跳过 reclaim (将走备用端口或告警)`);
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Gateway 重启/升级后旧 Dashboard 常仍占用首选端口。
|
|
322
|
+
* 若占用者为本插件 Dashboard(/api/version 校验),则多轮结束旧进程后再启动。
|
|
243
323
|
*/
|
|
244
324
|
async function reclaimStaleOurDashboardPort(port) {
|
|
245
325
|
const maxPasses = 4;
|
|
@@ -256,138 +336,441 @@ async function reclaimStaleOurDashboardPort(port) {
|
|
|
256
336
|
const pids = getListenPidsOnPort(port);
|
|
257
337
|
if (pids.length > 0) {
|
|
258
338
|
const sig = pass === 0 ? 'SIGTERM' : 'SIGKILL';
|
|
259
|
-
|
|
260
|
-
|
|
339
|
+
log(
|
|
340
|
+
`端口 ${port} 被上一实例 Dashboard 占用 (PID: ${pids.join(', ')}), ${sig} 以便在 ${port} 启动新实例`
|
|
261
341
|
);
|
|
262
342
|
for (const pid of pids) {
|
|
263
343
|
try {
|
|
264
344
|
process.kill(pid, sig);
|
|
265
345
|
} catch (e) {
|
|
266
|
-
|
|
346
|
+
logWarn(`无法向 PID ${pid} 发送 ${sig}:`, e.message || e);
|
|
267
347
|
}
|
|
268
348
|
}
|
|
269
349
|
await sleepMs(pass === 0 ? 1200 : 800);
|
|
270
350
|
continue;
|
|
271
351
|
}
|
|
272
352
|
|
|
273
|
-
/* 已确认是本插件 HTTP 服务,但拿不到 PID(常见:未装 lsof) */
|
|
274
353
|
if (process.platform === 'linux' && pass >= maxPasses - 2) {
|
|
275
|
-
|
|
276
|
-
`[OpenClaw-Dashboard] 端口 ${port} 上为本插件 Dashboard,尝试 fuser 释放(建议安装 lsof 以便精确杀进程)`
|
|
277
|
-
);
|
|
354
|
+
log(`端口 ${port} 上为本插件 Dashboard, 尝试 fuser 释放(建议安装 lsof)`);
|
|
278
355
|
tryFuserKillTcpPort(port);
|
|
279
356
|
await sleepMs(700);
|
|
280
357
|
continue;
|
|
281
358
|
}
|
|
282
359
|
|
|
283
360
|
if (pass === 0) {
|
|
284
|
-
|
|
285
|
-
`[OpenClaw-Dashboard] 端口 ${port} 上检测到本插件 Dashboard,但无法解析占用进程(可安装 lsof),稍后重试或换端口`
|
|
286
|
-
);
|
|
361
|
+
log(`端口 ${port} 上检测到本插件 Dashboard, 但无法解析占用进程(可安装 lsof), 稍后重试或换端口`);
|
|
287
362
|
}
|
|
288
363
|
await sleepMs(500);
|
|
289
364
|
}
|
|
290
365
|
}
|
|
291
366
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
367
|
+
// ── PID 文件(FR-8)────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
function getPidFilePath() {
|
|
370
|
+
return path.join(getDashboardDataDir(), PID_FILENAME);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function writePidFile(childPid, port) {
|
|
374
|
+
try {
|
|
375
|
+
const dir = getDashboardDataDir();
|
|
376
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
377
|
+
const data = {
|
|
378
|
+
gatewayPid: process.pid,
|
|
379
|
+
childPid,
|
|
380
|
+
port,
|
|
381
|
+
startedAt: new Date().toISOString(),
|
|
382
|
+
};
|
|
383
|
+
fs.writeFileSync(getPidFilePath(), JSON.stringify(data, null, 2), 'utf8');
|
|
384
|
+
} catch (_) {}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function removePidFile() {
|
|
388
|
+
try {
|
|
389
|
+
const p = getPidFilePath();
|
|
390
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
391
|
+
} catch (_) {}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 读取旧 PID 文件,若端口被占且为本插件则执行 reclaim。
|
|
396
|
+
* PID 文件仅作辅助线索,实际 kill 前仍须 /api/version 二次确认。
|
|
397
|
+
*
|
|
398
|
+
* 返回 true 表示回收成功(端口已释放或本来就是空的);
|
|
399
|
+
* 返回 false 表示端口仍被占用(可能是本插件但回收失败,也可能是其他服务)。
|
|
400
|
+
*/
|
|
401
|
+
async function reclaimFromPidFile(basePort) {
|
|
402
|
+
try {
|
|
403
|
+
const p = getPidFilePath();
|
|
404
|
+
if (!fs.existsSync(p)) return true;
|
|
405
|
+
const data = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
406
|
+
if (!data || typeof data.port !== 'number') {
|
|
407
|
+
removePidFile();
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const port = data.port;
|
|
412
|
+
|
|
413
|
+
if (!(await isPortInUse(port))) {
|
|
414
|
+
// 端口已空闲,旧实例已退出,清理文件
|
|
415
|
+
removePidFile();
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const ours = await probeOurDashboardListening(port);
|
|
420
|
+
if (!ours) {
|
|
421
|
+
// 非本插件占用,不动,保留 PID 文件供后续参考
|
|
422
|
+
log(`PID 文件记录端口 ${port}, 但 /api/version 表明非本插件, 保留文件`);
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
log(`PID 文件发现旧实例占用端口 ${port} (旧 childPid: ${data.childPid}), 执行回收`);
|
|
427
|
+
await reclaimStaleOurDashboardPort(port);
|
|
428
|
+
|
|
429
|
+
// 仅在确认端口已释放时才删文件,否则保留供下次启动再试
|
|
430
|
+
if (await isPortAvailable(port)) {
|
|
431
|
+
removePidFile();
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
logWarn(`PID 文件旧实例回收后端口 ${port} 仍被占用, 保留 PID 文件`);
|
|
436
|
+
return false;
|
|
437
|
+
} catch (_) {
|
|
438
|
+
return true;
|
|
297
439
|
}
|
|
440
|
+
}
|
|
298
441
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
442
|
+
// ── 就绪探测(FR-9)────────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* spawn 后轮询 /api/version 直至就绪或超时。
|
|
446
|
+
* 返回 true 表示 Dashboard 已就绪;false 表示超时或世代失配。
|
|
447
|
+
*/
|
|
448
|
+
async function waitForReady(port, startGen, timeoutMs = READY_CHECK_TIMEOUT_MS) {
|
|
449
|
+
const start = Date.now();
|
|
450
|
+
while (Date.now() - start < timeoutMs) {
|
|
451
|
+
if (generation !== startGen || aborted) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
const ready = await probeOurDashboardListening(port);
|
|
455
|
+
if (ready) {
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
await sleepMs(READY_CHECK_INTERVAL_MS);
|
|
302
459
|
}
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
303
462
|
|
|
304
|
-
|
|
305
|
-
const openclawHome = getOpenClawHome();
|
|
463
|
+
// ── 核心:stop / start ─────────────────────────────────────────────────────
|
|
306
464
|
|
|
307
|
-
|
|
308
|
-
|
|
465
|
+
/**
|
|
466
|
+
* FR-1: 停止 Dashboard 子进程,带后台超时等待。
|
|
467
|
+
* stop() 本身保持同步(不阻塞调用方),子进程退出等待异步进行。
|
|
468
|
+
*/
|
|
469
|
+
function stopDashboard() {
|
|
470
|
+
// FR-7: bump generation + set aborted
|
|
471
|
+
// 注意:不修改 activeStartId——旧链会因 generation !== startGen 自行退出,
|
|
472
|
+
// 其 finally 中只有 activeStartId === myId 时才会清锁,不会误清新链。
|
|
473
|
+
generation++;
|
|
474
|
+
aborted = true;
|
|
475
|
+
|
|
476
|
+
const child = dashboardProcess;
|
|
477
|
+
dashboardProcess = null;
|
|
478
|
+
|
|
479
|
+
if (!child || child.killed) {
|
|
480
|
+
removePidFile();
|
|
309
481
|
return;
|
|
310
482
|
}
|
|
311
483
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
484
|
+
// 发送 SIGTERM
|
|
485
|
+
try {
|
|
486
|
+
child.kill('SIGTERM');
|
|
487
|
+
} catch (_) {}
|
|
488
|
+
log('服务停止中 (SIGTERM)');
|
|
489
|
+
|
|
490
|
+
// 后台异步等待退出(不阻塞 stop 调用方)
|
|
491
|
+
let settled = false;
|
|
492
|
+
let sigtermTimer = null;
|
|
493
|
+
let sigkillTimer = null;
|
|
494
|
+
|
|
495
|
+
const settle = (exited) => {
|
|
496
|
+
if (settled) return;
|
|
497
|
+
settled = true;
|
|
498
|
+
if (sigtermTimer) clearTimeout(sigtermTimer);
|
|
499
|
+
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
500
|
+
removePidFile();
|
|
501
|
+
if (exited) {
|
|
502
|
+
log('服务已停止, 子进程已退出');
|
|
503
|
+
} else {
|
|
504
|
+
logWarn('服务停止超时, 子进程可能仍在运行');
|
|
505
|
+
}
|
|
506
|
+
};
|
|
315
507
|
|
|
316
|
-
|
|
317
|
-
|
|
508
|
+
child.once('exit', () => {
|
|
509
|
+
settle(true);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// T1 超时后 SIGKILL
|
|
513
|
+
sigtermTimer = setTimeout(() => {
|
|
514
|
+
if (settled) return;
|
|
515
|
+
try {
|
|
516
|
+
if (!child.killed) {
|
|
517
|
+
child.kill('SIGKILL');
|
|
518
|
+
logWarn('子进程未在 SIGTERM 超时内退出, 已发送 SIGKILL');
|
|
519
|
+
}
|
|
520
|
+
} catch (_) {}
|
|
521
|
+
|
|
522
|
+
// T2: SIGKILL 后最终超时
|
|
523
|
+
sigkillTimer = setTimeout(() => {
|
|
524
|
+
settle(false);
|
|
525
|
+
}, STOP_SIGKILL_TIMEOUT_MS);
|
|
526
|
+
}, STOP_SIGTERM_TIMEOUT_MS);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* FR-9 辅助: spawn + 就绪探测。
|
|
531
|
+
* 调用前必须已持有 activeStartId 锁(activeStartId === startGen)。
|
|
532
|
+
* 返回 true 表示成功启动并就绪。
|
|
533
|
+
*/
|
|
534
|
+
async function spawnAndReadyCheck(port, startGen, env, dashboardDir, pythonCmd, args) {
|
|
535
|
+
if (generation !== startGen || aborted) return false;
|
|
536
|
+
// activeStartId 锁已由调用方保证,直接 spawn
|
|
537
|
+
const child = spawn(pythonCmd, args, {
|
|
538
|
+
env,
|
|
539
|
+
cwd: dashboardDir,
|
|
540
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
child.on('error', (err) => {
|
|
544
|
+
logError('启动失败:', err.message);
|
|
545
|
+
if (dashboardProcess === child) dashboardProcess = null;
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
child.on('exit', (code, signal) => {
|
|
549
|
+
if (dashboardProcess === child) dashboardProcess = null;
|
|
550
|
+
if (code !== 0 && code !== null) {
|
|
551
|
+
log(`进程退出 code=${code} signal=${signal}`);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// 立即占位 dashboardProcess(spawn 原子赋值,JS 单线程无竞态)
|
|
556
|
+
dashboardProcess = child;
|
|
557
|
+
|
|
558
|
+
// FR-9: 就绪探测
|
|
559
|
+
const ready = await waitForReady(port, startGen);
|
|
560
|
+
if (!ready) {
|
|
561
|
+
// 就绪超时或世代失配 → kill 该子进程
|
|
562
|
+
try { child.kill('SIGKILL'); } catch (_) {}
|
|
563
|
+
if (dashboardProcess === child) dashboardProcess = null;
|
|
564
|
+
return false;
|
|
318
565
|
}
|
|
319
566
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
567
|
+
// 就绪成功 → 写 PID 文件(FR-8)
|
|
568
|
+
writePidFile(child.pid, port);
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
324
571
|
|
|
325
|
-
|
|
326
|
-
|
|
572
|
+
/**
|
|
573
|
+
* FR-2/FR-3/FR-5: 带退避重试的启动循环。
|
|
574
|
+
* 调用前必须已持有 activeStartId 锁(activeStartId === startGen)。
|
|
575
|
+
* 锁由外层 IIFE 的 finally 释放,本函数不操作 activeStartId。
|
|
576
|
+
*/
|
|
577
|
+
async function startWithRetry(port, isExplicitPort, startGen) {
|
|
578
|
+
const startTime = Date.now();
|
|
579
|
+
let currentPort = port;
|
|
580
|
+
|
|
581
|
+
for (let attempt = 1; attempt <= MAX_START_RETRIES; attempt++) {
|
|
582
|
+
// 世代校验
|
|
583
|
+
if (generation !== startGen || aborted) {
|
|
584
|
+
log(`启动链已作废 (世代 ${startGen} → ${generation})`);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
327
587
|
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
|
|
588
|
+
const elapsed = Date.now() - startTime;
|
|
589
|
+
if (elapsed >= TOTAL_RETRY_TIMEOUT_MS) {
|
|
590
|
+
logError(`启动失败: 重试总超时 (${TOTAL_RETRY_TIMEOUT_MS}ms), 端口 ${currentPort}`);
|
|
331
591
|
return;
|
|
332
592
|
}
|
|
333
593
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
594
|
+
// ── 端口可用 → spawn ──
|
|
595
|
+
const portAvailable = await isPortAvailable(currentPort);
|
|
596
|
+
if (portAvailable) {
|
|
597
|
+
const env = {
|
|
598
|
+
...process.env,
|
|
599
|
+
OPENCLAW_HOME: getOpenClawHome(),
|
|
600
|
+
DASHBOARD_PORT: String(currentPort),
|
|
601
|
+
};
|
|
339
602
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (!pythonCmd) {
|
|
346
|
-
const venvPython = fs.existsSync(venvPythonUnix) ? venvPythonUnix : (fs.existsSync(venvPythonWin) ? venvPythonWin : null);
|
|
347
|
-
if (venvPython) {
|
|
348
|
-
try {
|
|
349
|
-
execFileSync(venvPython, ['-c', 'import uvicorn'], { stdio: 'ignore', timeout: 3000 });
|
|
350
|
-
pythonCmd = venvPython;
|
|
351
|
-
} catch (_) {
|
|
352
|
-
pythonCmd = resolveSystemPython(); // venv 不完整,回退到系统 Python
|
|
353
|
-
}
|
|
603
|
+
const pythonCmd = resolvePythonCmd();
|
|
604
|
+
const args = ['-m', 'uvicorn', 'main:app', '--host', '0.0.0.0', '--port', String(currentPort)];
|
|
605
|
+
|
|
606
|
+
if (!isExplicitPort && currentPort !== port) {
|
|
607
|
+
log(`端口 ${port} 被占用, 尝试 ${currentPort} (${attempt}/${MAX_START_RETRIES})`);
|
|
354
608
|
} else {
|
|
355
|
-
|
|
609
|
+
log(`尝试启动 (${attempt}/${MAX_START_RETRIES}), 端口 ${currentPort}`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const ok = await spawnAndReadyCheck(currentPort, startGen, env, getDashboardDir(), pythonCmd, args);
|
|
613
|
+
|
|
614
|
+
if (ok) {
|
|
615
|
+
log(`插件服务已启动`);
|
|
616
|
+
log(`访问地址: http://localhost:${currentPort}`);
|
|
617
|
+
return;
|
|
356
618
|
}
|
|
619
|
+
|
|
620
|
+
// spawn 或就绪失败 → 退避重试
|
|
621
|
+
const backoff = BACKOFF_BASE_MS * attempt;
|
|
622
|
+
logWarn(`端口 ${currentPort} spawn/就绪失败, ${backoff}ms 后重试 (${attempt}/${MAX_START_RETRIES})`);
|
|
623
|
+
await sleepMs(backoff);
|
|
624
|
+
continue;
|
|
357
625
|
}
|
|
358
|
-
const args = ['-m', 'uvicorn', 'main:app', '--host', '0.0.0.0', '--port', String(port)];
|
|
359
626
|
|
|
360
|
-
|
|
361
|
-
|
|
627
|
+
// ── 端口被占用 → 探测 + 决策 ──
|
|
628
|
+
const ours = await probeOurDashboardListening(currentPort);
|
|
629
|
+
|
|
630
|
+
if (ours) {
|
|
631
|
+
// FR-3: 本插件旧实例 → 回收
|
|
632
|
+
log(`[ours] 端口 ${currentPort} 被本插件旧实例占用, 执行回收 (${attempt}/${MAX_START_RETRIES})`);
|
|
633
|
+
reclaimOneRound(currentPort);
|
|
634
|
+
const backoff = BACKOFF_BASE_MS * attempt;
|
|
635
|
+
await sleepMs(backoff);
|
|
636
|
+
continue;
|
|
362
637
|
}
|
|
363
|
-
console.log(`[OpenClaw-Dashboard] 插件服务已启动`);
|
|
364
|
-
console.log(`[OpenClaw-Dashboard] 访问地址: http://localhost:${port}`);
|
|
365
638
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
639
|
+
// 非本插件占用
|
|
640
|
+
if (isExplicitPort) {
|
|
641
|
+
// FR-5: 显式固定端口 → 不误杀,重试 + 告警
|
|
642
|
+
logWarn(
|
|
643
|
+
`[other] 端口 ${currentPort} 被非本插件服务占用, 不可回收, 重试中 (${attempt}/${MAX_START_RETRIES})`
|
|
644
|
+
);
|
|
645
|
+
const backoff = BACKOFF_BASE_MS * attempt;
|
|
646
|
+
await sleepMs(backoff);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
371
649
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
650
|
+
// FR-4: 非显式端口 → 尝试备用端口
|
|
651
|
+
const altPort = await findAvailablePort(currentPort + 1);
|
|
652
|
+
if (altPort !== currentPort) {
|
|
653
|
+
log(`[other] 端口 ${currentPort} 被占用, 切换到备用端口 ${altPort}`);
|
|
654
|
+
currentPort = altPort;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 无可用备用端口
|
|
659
|
+
logWarn(
|
|
660
|
+
`[unknown] 端口 ${currentPort} 被占用且无备用端口, 重试中 (${attempt}/${MAX_START_RETRIES})`
|
|
661
|
+
);
|
|
662
|
+
const backoff = BACKOFF_BASE_MS * attempt;
|
|
663
|
+
await sleepMs(backoff);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
logError(
|
|
667
|
+
`启动失败: 重试耗尽 (${MAX_START_RETRIES} 次), 最后尝试端口 ${currentPort}`
|
|
668
|
+
);
|
|
383
669
|
}
|
|
384
670
|
|
|
385
|
-
|
|
671
|
+
/**
|
|
672
|
+
* 解析 Python 命令(venv 优先 → 系统 Python)
|
|
673
|
+
*/
|
|
674
|
+
function resolvePythonCmd() {
|
|
675
|
+
const dashboardDir = getDashboardDir();
|
|
676
|
+
const venvPythonUnix = path.join(dashboardDir, '.venv', 'bin', 'python');
|
|
677
|
+
const venvPythonWin = path.join(dashboardDir, '.venv', 'Scripts', 'python.exe');
|
|
678
|
+
|
|
679
|
+
if (process.env.PYTHON_CMD) return process.env.PYTHON_CMD;
|
|
680
|
+
|
|
681
|
+
const venvPython = fs.existsSync(venvPythonUnix) ? venvPythonUnix
|
|
682
|
+
: (fs.existsSync(venvPythonWin) ? venvPythonWin : null);
|
|
683
|
+
|
|
684
|
+
if (venvPython) {
|
|
685
|
+
try {
|
|
686
|
+
execFileSync(venvPython, ['-c', 'import uvicorn'], { stdio: 'ignore', timeout: 3000 });
|
|
687
|
+
return venvPython;
|
|
688
|
+
} catch (_) {
|
|
689
|
+
return resolveSystemPython();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return resolveSystemPython();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function startDashboard(config = {}) {
|
|
696
|
+
// 仅在 Gateway 进程内自动启动
|
|
697
|
+
if (!process.env.OPENCLAW_GATEWAY_PORT?.trim()) {
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
|
|
386
701
|
if (dashboardProcess) {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
702
|
+
log('服务已在运行');
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (activeStartId !== 0) {
|
|
707
|
+
log('启动链已在进行中, 跳过');
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const dashboardDir = getDashboardDir();
|
|
712
|
+
if (!fs.existsSync(dashboardDir)) {
|
|
713
|
+
logWarn('dashboard 目录不存在, 请先执行 npm run deploy 安装插件');
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const basePort = config.port ?? DEFAULT_PORT;
|
|
718
|
+
const isExplicitPort = basePort !== DEFAULT_PORT;
|
|
719
|
+
const autoStart = config.autoStart !== false;
|
|
720
|
+
|
|
721
|
+
if (!autoStart) {
|
|
722
|
+
return;
|
|
390
723
|
}
|
|
724
|
+
|
|
725
|
+
// 仅在即将启动异步链时递增世代 + 重置中止标志
|
|
726
|
+
generation++;
|
|
727
|
+
aborted = false;
|
|
728
|
+
const startGen = generation;
|
|
729
|
+
|
|
730
|
+
// 占位互斥锁:activeStartId 设为本次 generation
|
|
731
|
+
activeStartId = startGen;
|
|
732
|
+
|
|
733
|
+
// 异步启动链
|
|
734
|
+
(async () => {
|
|
735
|
+
try {
|
|
736
|
+
// FR-8: 先从 PID 文件检查旧实例
|
|
737
|
+
await reclaimFromPidFile(basePort);
|
|
738
|
+
|
|
739
|
+
// 世代校验(PID 文件回收可能耗时)
|
|
740
|
+
if (generation !== startGen || aborted) {
|
|
741
|
+
log(`启动链 ${startGen} 在 PID 文件回收后被作废`);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// 回收已知旧实例
|
|
746
|
+
await reclaimStaleOurDashboardPort(basePort);
|
|
747
|
+
|
|
748
|
+
// 世代校验
|
|
749
|
+
if (generation !== startGen || aborted) {
|
|
750
|
+
log(`启动链 ${startGen} 在 stale port 回收后被作废`);
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// 选择端口
|
|
755
|
+
const port = isExplicitPort ? basePort : await findAvailablePort(basePort);
|
|
756
|
+
|
|
757
|
+
// 世代校验
|
|
758
|
+
if (generation !== startGen || aborted) {
|
|
759
|
+
log(`启动链 ${startGen} 在端口选择后被作废`);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// FR-2/3/5: 带重试的启动
|
|
764
|
+
await startWithRetry(port, isExplicitPort, startGen);
|
|
765
|
+
} catch (err) {
|
|
766
|
+
logError(`启动链 ${startGen} 异常:`, err.message || err);
|
|
767
|
+
} finally {
|
|
768
|
+
// 所有权释放:仅当 activeStartId 仍归我所有时才清锁
|
|
769
|
+
if (activeStartId === startGen) {
|
|
770
|
+
activeStartId = 0;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
})();
|
|
391
774
|
}
|
|
392
775
|
|
|
393
776
|
/**
|
|
@@ -395,10 +778,13 @@ function stopDashboard() {
|
|
|
395
778
|
* @param {object} api - OpenClaw 插件 API,pluginConfig 来自 openclaw.json 的 config 字段
|
|
396
779
|
*/
|
|
397
780
|
function DashboardPlugin(api) {
|
|
398
|
-
console.log('[OpenClaw-Dashboard] 插件已加载');
|
|
399
|
-
|
|
400
781
|
const pluginConfig = (api && api.pluginConfig) || {};
|
|
401
782
|
const config = loadConfig(pluginConfig);
|
|
783
|
+
|
|
784
|
+
if (process.env.OPENCLAW_GATEWAY_PORT?.trim()) {
|
|
785
|
+
log('插件已加载, 准备启动 Dashboard');
|
|
786
|
+
}
|
|
787
|
+
|
|
402
788
|
startDashboard(config);
|
|
403
789
|
|
|
404
790
|
return {
|
package/openclaw.plugin.json
CHANGED