openclaw-rollback 1.0.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/dist/core.js ADDED
@@ -0,0 +1,842 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { fileURLToPath } from 'url';
5
+ import { recordChange } from './config-graph.js';
6
+ const IS_WINDOWS = process.platform === 'win32';
7
+ const IS_LINUX = process.platform === 'linux';
8
+ const IS_MAC = process.platform === 'darwin';
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const PLUGIN_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw', 'extensions', 'openclaw-rollback');
12
+ const CONFIG_FILE = path.join(PLUGIN_DIR, 'config.json');
13
+ const BACKUPS_DIR = path.join(PLUGIN_DIR, 'backups');
14
+ const REGULAR_BACKUPS_DIR = path.join(BACKUPS_DIR, 'regular');
15
+ const TRAVEL_BACKUPS_DIR = path.join(BACKUPS_DIR, 'travel-mode');
16
+ const OPENCLAW_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw');
17
+ // 默认配置 - 自动扫描并备份所有 .md 和 .json 文件
18
+ const DEFAULT_BACKUP_ITEMS = []; // 空数组表示使用自动扫描
19
+ const BACKUP_EXCLUDE_PATTERNS = [
20
+ 'node_modules',
21
+ 'extensions/*/node_modules',
22
+ 'backups',
23
+ 'logs',
24
+ 'cache',
25
+ '*.log',
26
+ '*.tmp',
27
+ '*.temp',
28
+ 'agents/main/sessions/*.trajectory-path.json',
29
+ 'skills/*/output',
30
+ 'npm/package-lock.json'
31
+ ];
32
+ // 动态读取版本号
33
+ let _packageVersion = '';
34
+ function getPackageVersion() {
35
+ if (_packageVersion)
36
+ return _packageVersion;
37
+ try {
38
+ // Windows 兼容:使用 path.join 直接构造路径
39
+ const pkgPath = path.join(__dirname, '..', 'package.json');
40
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
41
+ _packageVersion = pkg.version || 'unknown';
42
+ }
43
+ catch {
44
+ _packageVersion = 'unknown';
45
+ }
46
+ return _packageVersion;
47
+ }
48
+ // 需要备份的文件扩展名
49
+ const BACKUP_EXTENSIONS = ['.md', '.json', '.yaml', '.yml', '.toml', '.ini', '.conf', '.config'];
50
+ // 关键配置文件 - 需要验证有效性的文件
51
+ const CRITICAL_CONFIG_FILES = ['openclaw.json', 'gateway.json'];
52
+ // 验证 JSON 配置文件是否有效且包含必要字段
53
+ function validateConfigFile(filePath) {
54
+ try {
55
+ const content = fs.readFileSync(filePath, 'utf8');
56
+ const config = JSON.parse(content);
57
+ // 对于 openclaw.json,检查是否包含必要的顶级字段
58
+ if (filePath.endsWith('openclaw.json')) {
59
+ // 如果只有 version 和 gateway.port,说明是测试用的简化配置
60
+ const keys = Object.keys(config);
61
+ if (keys.length <= 2 && config.version && config.gateway && Object.keys(config.gateway).length <= 1) {
62
+ console.log(` ⚠️ 跳过无效配置: ${path.relative(OPENCLAW_DIR, filePath)} (简化测试配置)`);
63
+ return false;
64
+ }
65
+ // 检查是否包含必要的 agents 或 gateway.mode 字段
66
+ if (!config.agents && !config.gateway?.mode) {
67
+ console.log(` ⚠️ 跳过无效配置: ${path.relative(OPENCLAW_DIR, filePath)} (缺少必要字段)`);
68
+ return false;
69
+ }
70
+ }
71
+ return true;
72
+ }
73
+ catch {
74
+ console.log(` ⚠️ 跳过损坏的 JSON: ${path.relative(OPENCLAW_DIR, filePath)}`);
75
+ return false;
76
+ }
77
+ }
78
+ // 扫描需要备份的文件
79
+ function scanBackupItems(dir, baseDir) {
80
+ const items = [];
81
+ function scan(currentDir) {
82
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
83
+ for (const entry of entries) {
84
+ const fullPath = path.join(currentDir, entry.name);
85
+ const relativePath = path.relative(baseDir, fullPath);
86
+ // 检查是否排除
87
+ if (shouldExclude(entry.name, relativePath, BACKUP_EXCLUDE_PATTERNS)) {
88
+ continue;
89
+ }
90
+ if (entry.isDirectory()) {
91
+ scan(fullPath);
92
+ }
93
+ else if (entry.isFile()) {
94
+ // 备份配置文件类型
95
+ const ext = path.extname(entry.name).toLowerCase();
96
+ if (BACKUP_EXTENSIONS.includes(ext)) {
97
+ // 验证关键配置文件的有效性
98
+ if (CRITICAL_CONFIG_FILES.includes(entry.name)) {
99
+ if (!validateConfigFile(fullPath)) {
100
+ continue; // 跳过无效的关键配置文件
101
+ }
102
+ }
103
+ items.push(relativePath);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ scan(dir);
109
+ return items;
110
+ }
111
+ // 复制目录,支持排除模式
112
+ function copyDirWithExclude(src, dest, excludePatterns) {
113
+ fs.mkdirSync(dest, { recursive: true });
114
+ const entries = fs.readdirSync(src, { withFileTypes: true });
115
+ for (const entry of entries) {
116
+ const srcPath = path.join(src, entry.name);
117
+ const destPath = path.join(dest, entry.name);
118
+ const relativePath = path.relative(OPENCLAW_DIR, srcPath);
119
+ // 检查是否匹配排除模式
120
+ if (shouldExclude(entry.name, relativePath, excludePatterns)) {
121
+ console.log(` ⏭ 跳过: ${relativePath}`);
122
+ continue;
123
+ }
124
+ if (entry.isDirectory()) {
125
+ copyDirWithExclude(srcPath, destPath, excludePatterns);
126
+ }
127
+ else {
128
+ fs.copyFileSync(srcPath, destPath);
129
+ }
130
+ }
131
+ }
132
+ // 检查是否应该排除
133
+ function shouldExclude(fileName, relativePath, patterns) {
134
+ // Windows 兼容:将路径分隔符统一为 /
135
+ const normalizedPath = relativePath.replace(/\\/g, '/');
136
+ for (const pattern of patterns) {
137
+ // 简单匹配:检查文件名或路径是否包含模式
138
+ if (pattern.includes('*')) {
139
+ // 通配符匹配
140
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
141
+ if (regex.test(fileName) || regex.test(normalizedPath)) {
142
+ return true;
143
+ }
144
+ }
145
+ else {
146
+ // 精确匹配或包含
147
+ if (fileName === pattern || normalizedPath.includes(pattern)) {
148
+ return true;
149
+ }
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+ export function getConfig() {
155
+ if (!fs.existsSync(CONFIG_FILE)) {
156
+ const defaultConfig = {
157
+ autoBackup: true,
158
+ maxBackups: 10,
159
+ webUIPort: 3739,
160
+ backupItems: DEFAULT_BACKUP_ITEMS,
161
+ backupInterval: 30, // 默认30分钟
162
+ travelMode: false,
163
+ travelModeInterval: 120, // 默认2小时
164
+ travelModeMaxBackups: 20,
165
+ consecutiveFailures: 0
166
+ };
167
+ saveConfig(defaultConfig);
168
+ return defaultConfig;
169
+ }
170
+ const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
171
+ // 如果使用的是旧配置(包含旧的特定文件列表),切换到自动扫描
172
+ const oldItems = ['config.json', 'gateway.json', 'agents.json', 'skills.json', 'memory', 'workspace'];
173
+ const hasOldItems = config.backupItems?.some((item) => oldItems.includes(item) || item.includes('config.json') || item.includes('gateway.json'));
174
+ if (hasOldItems) {
175
+ console.log('📝 检测到旧配置,切换到自动扫描模式');
176
+ config.backupItems = DEFAULT_BACKUP_ITEMS;
177
+ saveConfig(config);
178
+ }
179
+ // 确保 backupInterval 有默认值
180
+ if (config.backupInterval === undefined) {
181
+ config.backupInterval = 30;
182
+ saveConfig(config);
183
+ }
184
+ // 确保出行模式配置有默认值
185
+ if (config.travelMode === undefined) {
186
+ config.travelMode = false;
187
+ }
188
+ if (config.travelModeInterval === undefined) {
189
+ config.travelModeInterval = 120;
190
+ }
191
+ if (config.travelModeMaxBackups === undefined) {
192
+ config.travelModeMaxBackups = 20;
193
+ }
194
+ if (config.consecutiveFailures === undefined) {
195
+ config.consecutiveFailures = 0;
196
+ }
197
+ return config;
198
+ }
199
+ export function saveConfig(config) {
200
+ fs.mkdirSync(PLUGIN_DIR, { recursive: true });
201
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
202
+ }
203
+ // 获取 Gateway 端口(从配置文件读取)
204
+ function getGatewayPort() {
205
+ const openclawJsonPath = path.join(OPENCLAW_DIR, 'openclaw.json');
206
+ try {
207
+ if (fs.existsSync(openclawJsonPath)) {
208
+ const ocConfig = JSON.parse(fs.readFileSync(openclawJsonPath, 'utf8'));
209
+ return ocConfig.gateway?.port || ocConfig.port || 3000;
210
+ }
211
+ }
212
+ catch {
213
+ // 使用默认端口
214
+ }
215
+ return 3000;
216
+ }
217
+ // 跨平台检测 TCP 端口是否可连接(同步)
218
+ // 核心修复:使用 Node.js 内置 net 模块,不再依赖 PowerShell/bash 等外部命令
219
+ // 避免 Windows 下 PowerShell 命令超时/失败导致误判 Gateway 不健康
220
+ function checkPortReachable(port) {
221
+ // 重试3次,避免偶发网络波动导致误判(Windows下尤其重要)
222
+ for (let attempt = 1; attempt <= 3; attempt++) {
223
+ try {
224
+ // 方法1:使用 Node.js 内置 net 模块(最可靠,跨平台)
225
+ const checkScript = `const net=require('net'),s=new net.Socket();s.setTimeout(3000);s.once('connect',()=>{s.end();process.exit(0)});s.once('error',()=>process.exit(1));s.once('timeout',()=>{s.destroy();process.exit(1)});s.connect(${port},'127.0.0.1');`;
226
+ const tmpFile = path.join(PLUGIN_DIR, `.check-port-${Date.now()}.tmp.js`);
227
+ fs.writeFileSync(tmpFile, checkScript);
228
+ try {
229
+ execSync(`node "${tmpFile}"`, { timeout: 5000 });
230
+ return true;
231
+ }
232
+ finally {
233
+ try {
234
+ fs.unlinkSync(tmpFile);
235
+ }
236
+ catch { /* ignore */ }
237
+ }
238
+ }
239
+ catch {
240
+ if (attempt < 3) {
241
+ // 短暂等待后重试
242
+ const start = Date.now();
243
+ while (Date.now() - start < 500) { /* busy wait 500ms */ }
244
+ }
245
+ }
246
+ }
247
+ // 方法2:备选方案(系统命令)
248
+ try {
249
+ if (IS_WINDOWS) {
250
+ // Windows 备选: netstat 查看端口监听状态
251
+ const output = execSync(`netstat -an | findstr "127.0.0.1:${port}"`, { timeout: 5000, encoding: 'utf8', windowsHide: true });
252
+ return output.includes('LISTENING') || output.includes('ESTABLISHED');
253
+ }
254
+ else {
255
+ // Linux/Mac 备选: /dev/tcp
256
+ execSync(`bash -c 'timeout 3 bash -c "cat < /dev/null > /dev/tcp/127.0.0.1/${port}"'`, { timeout: 5000 });
257
+ return true;
258
+ }
259
+ }
260
+ catch {
261
+ return false;
262
+ }
263
+ }
264
+ // 检查 OpenClaw Gateway 进程是否存在(跨平台)
265
+ // 注意:进程检测只是辅助,端口检测才是主要判断依据
266
+ function checkGatewayProcess() {
267
+ try {
268
+ if (IS_WINDOWS) {
269
+ // Windows: 使用 tasklist 查找 openclaw 相关进程
270
+ try {
271
+ // 方法1:查找 openclaw-gateway.exe
272
+ const output = execSync('tasklist /FI "IMAGENAME eq openclaw-gateway.exe" /NH', {
273
+ timeout: 5000,
274
+ encoding: 'utf8',
275
+ windowsHide: true
276
+ });
277
+ if (output.toLowerCase().includes('openclaw')) {
278
+ return true;
279
+ }
280
+ // 方法2:查找 node.exe(openclaw 可能是通过 node 运行的)
281
+ const nodeOutput = execSync('tasklist /FI "IMAGENAME eq node.exe" /NH', {
282
+ timeout: 5000,
283
+ encoding: 'utf8',
284
+ windowsHide: true
285
+ });
286
+ if (nodeOutput.toLowerCase().includes('node')) {
287
+ // node.exe 存在,再检查端口来确认是否是 Gateway
288
+ const port = getGatewayPort();
289
+ if (checkPortReachable(port)) {
290
+ return true;
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+ catch {
296
+ return false;
297
+ }
298
+ }
299
+ else {
300
+ // Linux/Mac: 使用 pgrep
301
+ try {
302
+ execSync('pgrep -f "openclaw-gateway"', { timeout: 5000 });
303
+ return true;
304
+ }
305
+ catch {
306
+ return false;
307
+ }
308
+ }
309
+ }
310
+ catch {
311
+ return false;
312
+ }
313
+ }
314
+ // 检查 OpenClaw Gateway 健康状态(兼容版)
315
+ export function checkOpenClawHealth() {
316
+ // 优先检测端口,端口通了就是健康的
317
+ const port = getGatewayPort();
318
+ if (checkPortReachable(port)) {
319
+ return true;
320
+ }
321
+ // 端口不通再检测进程
322
+ return checkGatewayProcess();
323
+ }
324
+ const DAEMON_PID_FILE = path.join(PLUGIN_DIR, 'rollback-daemon.pid');
325
+ // 检查守护进程是否在运行
326
+ export function isDaemonRunning() {
327
+ if (!fs.existsSync(DAEMON_PID_FILE)) {
328
+ return false;
329
+ }
330
+ try {
331
+ const pid = parseInt(fs.readFileSync(DAEMON_PID_FILE, 'utf8').trim(), 10);
332
+ process.kill(pid, 0);
333
+ return true;
334
+ }
335
+ catch {
336
+ try {
337
+ fs.unlinkSync(DAEMON_PID_FILE);
338
+ }
339
+ catch { }
340
+ return false;
341
+ }
342
+ }
343
+ // 更全面的健康检查:进程 + API 响应(跨平台支持 Windows/Linux/Mac)
344
+ // 核心逻辑:端口检测优先,端口通了就是健康的
345
+ export function checkGatewayHealthDeep() {
346
+ // 1. 读取端口配置
347
+ const port = getGatewayPort();
348
+ // 2. 优先检测端口(最可靠的方式)
349
+ const portReachable = checkPortReachable(port);
350
+ if (portReachable) {
351
+ // 端口通了,Gateway 肯定在运行
352
+ return { healthy: true, gatewayRunning: true };
353
+ }
354
+ // 3. 端口不通,再检测进程是否存在
355
+ const processRunning = checkGatewayProcess();
356
+ if (!processRunning) {
357
+ return { healthy: false, gatewayRunning: false, details: 'Gateway process not found' };
358
+ }
359
+ // 4. 进程存在但端口不通,可能是启动中或配置问题
360
+ // 尝试 CLI 状态检查
361
+ try {
362
+ if (IS_WINDOWS) {
363
+ try {
364
+ execSync('openclaw gateway status', { timeout: 10000, windowsHide: true });
365
+ return { healthy: true, gatewayRunning: true, details: 'Gateway process running, CLI status OK' };
366
+ }
367
+ catch {
368
+ // Windows 下 CLI 命令可能路径不同
369
+ }
370
+ }
371
+ else {
372
+ execSync('openclaw gateway status', { timeout: 10000 });
373
+ return { healthy: true, gatewayRunning: true, details: 'Gateway process running, CLI status OK' };
374
+ }
375
+ }
376
+ catch {
377
+ // CLI 状态检查失败
378
+ }
379
+ // 进程存在但端口不通且 CLI 也失败
380
+ return { healthy: false, gatewayRunning: true, details: `Gateway port ${port} not responding` };
381
+ }
382
+ export function createBackup(options) {
383
+ const config = getConfig();
384
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
385
+ const backupId = `backup-${timestamp}`;
386
+ const backupDir = options?.isTravelMode
387
+ ? path.join(TRAVEL_BACKUPS_DIR, backupId)
388
+ : path.join(REGULAR_BACKUPS_DIR, backupId);
389
+ console.log(`[${new Date().toLocaleString()}] 开始备份...`);
390
+ // 出行模式下,如果 Gateway 不健康,跳过备份(因为马上要回滚了)
391
+ if (options?.isTravelMode && options?.gatewayHealthy === false) {
392
+ console.log('⚠️ Gateway 不健康,出行模式跳过本次备份');
393
+ return { success: false, error: 'Gateway is unhealthy, skipping backup in travel mode' };
394
+ }
395
+ // 确定要备份的项目(提前确定,供后续使用)
396
+ let itemsToBackup;
397
+ if (config.backupItems.length === 0) {
398
+ // 自动扫描所有 .md 和 .json 文件
399
+ console.log('🔍 自动扫描配置文件...');
400
+ itemsToBackup = scanBackupItems(OPENCLAW_DIR, OPENCLAW_DIR);
401
+ console.log(`📋 发现 ${itemsToBackup.length} 个配置文件`);
402
+ }
403
+ else {
404
+ itemsToBackup = config.backupItems;
405
+ }
406
+ try {
407
+ fs.mkdirSync(backupDir, { recursive: true });
408
+ // 记录配置变更快照(用于后续根因分析)
409
+ recordConfigSnapshot(itemsToBackup, backupId);
410
+ // 备份元数据
411
+ const meta = {
412
+ timestamp: new Date().toISOString(),
413
+ version: getPackageVersion(),
414
+ items: [],
415
+ isTravelMode: options?.isTravelMode || false,
416
+ gatewayHealthy: options?.gatewayHealthy !== undefined ? options.gatewayHealthy : checkOpenClawHealth()
417
+ };
418
+ // 备份各项
419
+ for (const item of itemsToBackup) {
420
+ const sourcePath = path.join(OPENCLAW_DIR, item);
421
+ const destPath = path.join(backupDir, item);
422
+ if (fs.existsSync(sourcePath)) {
423
+ const stat = fs.statSync(sourcePath);
424
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
425
+ if (stat.isDirectory()) {
426
+ copyDirWithExclude(sourcePath, destPath, BACKUP_EXCLUDE_PATTERNS);
427
+ }
428
+ else {
429
+ fs.copyFileSync(sourcePath, destPath);
430
+ }
431
+ meta.items.push(item);
432
+ console.log(` ✓ ${item}`);
433
+ }
434
+ }
435
+ // 保存元数据
436
+ fs.writeFileSync(path.join(backupDir, 'meta.json'), JSON.stringify(meta, null, 2));
437
+ // 更新配置 - 保存 ISO 格式的时间戳
438
+ config.lastBackup = meta.timestamp;
439
+ // 如果是出行模式且 Gateway 健康,更新 lastKnownGoodBackup
440
+ if (options?.isTravelMode && meta.gatewayHealthy) {
441
+ config.lastKnownGoodBackup = backupId;
442
+ config.consecutiveFailures = 0;
443
+ console.log(`✅ 更新 lastKnownGoodBackup: ${backupId}`);
444
+ }
445
+ saveConfig(config);
446
+ // 清理旧备份(按各自目录分别清理)
447
+ if (options?.isTravelMode) {
448
+ cleanupOldBackups(TRAVEL_BACKUPS_DIR, config.travelModeMaxBackups);
449
+ }
450
+ else {
451
+ cleanupOldBackups(REGULAR_BACKUPS_DIR, config.maxBackups);
452
+ }
453
+ console.log(`✅ 备份完成: ${backupId}`);
454
+ // 记录本次备份变更到变更日志
455
+ logBackupChanges(itemsToBackup, backupId);
456
+ return { success: true, backupId };
457
+ }
458
+ catch (err) {
459
+ const error = err instanceof Error ? err.message : String(err);
460
+ console.error('❌ 备份失败:', error);
461
+ // 清理失败的备份
462
+ if (fs.existsSync(backupDir)) {
463
+ fs.rmSync(backupDir, { recursive: true });
464
+ }
465
+ return { success: false, error };
466
+ }
467
+ }
468
+ export function cleanupOldBackups(backupDir, maxBackups = 10) {
469
+ if (!fs.existsSync(backupDir))
470
+ return;
471
+ const backups = fs.readdirSync(backupDir)
472
+ .filter(d => d.startsWith('backup-'))
473
+ .map(d => ({
474
+ name: d,
475
+ path: path.join(backupDir, d),
476
+ time: fs.statSync(path.join(backupDir, d)).mtime.getTime()
477
+ }))
478
+ .sort((a, b) => b.time - a.time);
479
+ // 保留最近 N 个
480
+ const toDelete = backups.slice(maxBackups);
481
+ for (const backup of toDelete) {
482
+ fs.rmSync(backup.path, { recursive: true });
483
+ console.log(`🗑️ 清理旧备份: ${backup.name}`);
484
+ }
485
+ }
486
+ export function listBackups() {
487
+ const results = [];
488
+ // 读取普通备份
489
+ if (fs.existsSync(REGULAR_BACKUPS_DIR)) {
490
+ for (const d of fs.readdirSync(REGULAR_BACKUPS_DIR).filter(d => d.startsWith('backup-'))) {
491
+ const metaPath = path.join(REGULAR_BACKUPS_DIR, d, 'meta.json');
492
+ let meta = {};
493
+ if (fs.existsSync(metaPath)) {
494
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
495
+ }
496
+ results.push({
497
+ id: d,
498
+ timestamp: meta.timestamp || d.replace('backup-', ''),
499
+ items: meta.items || [],
500
+ isTravelMode: meta.isTravelMode,
501
+ gatewayHealthy: meta.gatewayHealthy
502
+ });
503
+ }
504
+ }
505
+ // 读取出行模式备份
506
+ if (fs.existsSync(TRAVEL_BACKUPS_DIR)) {
507
+ for (const d of fs.readdirSync(TRAVEL_BACKUPS_DIR).filter(d => d.startsWith('backup-'))) {
508
+ const metaPath = path.join(TRAVEL_BACKUPS_DIR, d, 'meta.json');
509
+ let meta = {};
510
+ if (fs.existsSync(metaPath)) {
511
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
512
+ }
513
+ results.push({
514
+ id: d,
515
+ timestamp: meta.timestamp || d.replace('backup-', ''),
516
+ items: meta.items || [],
517
+ isTravelMode: meta.isTravelMode,
518
+ gatewayHealthy: meta.gatewayHealthy
519
+ });
520
+ }
521
+ }
522
+ return results.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
523
+ }
524
+ // 获取最后一个已知良好的备份(用于出行模式回滚,只在 travel-mode 目录中查找)
525
+ export function getLastKnownGoodBackup() {
526
+ const config = getConfig();
527
+ // 只在出行模式备份目录中查找
528
+ if (!fs.existsSync(TRAVEL_BACKUPS_DIR)) {
529
+ return null;
530
+ }
531
+ const travelBackups = fs.readdirSync(TRAVEL_BACKUPS_DIR)
532
+ .filter(d => d.startsWith('backup-'))
533
+ .map(d => {
534
+ const metaPath = path.join(TRAVEL_BACKUPS_DIR, d, 'meta.json');
535
+ let meta = {};
536
+ if (fs.existsSync(metaPath)) {
537
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
538
+ }
539
+ return {
540
+ id: d,
541
+ timestamp: meta.timestamp || d.replace('backup-', ''),
542
+ items: meta.items || [],
543
+ isTravelMode: meta.isTravelMode,
544
+ gatewayHealthy: meta.gatewayHealthy
545
+ };
546
+ })
547
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
548
+ // 优先使用配置中记录的 lastKnownGoodBackup
549
+ if (config.lastKnownGoodBackup) {
550
+ const found = travelBackups.find(b => b.id === config.lastKnownGoodBackup);
551
+ if (found)
552
+ return found;
553
+ }
554
+ // 回退:找最后一个 gatewayHealthy=true 的备份
555
+ const goodBackup = travelBackups.find(b => b.gatewayHealthy === true);
556
+ if (goodBackup)
557
+ return goodBackup;
558
+ // 最后回退:返回最新的备份
559
+ return travelBackups.length > 0 ? travelBackups[0] : null;
560
+ }
561
+ export function restoreBackup(backupId) {
562
+ // 在两个目录中查找备份
563
+ let backupDir = path.join(REGULAR_BACKUPS_DIR, backupId);
564
+ if (!fs.existsSync(backupDir)) {
565
+ backupDir = path.join(TRAVEL_BACKUPS_DIR, backupId);
566
+ }
567
+ if (!fs.existsSync(backupDir)) {
568
+ console.error(`❌ 备份不存在: ${backupId}`);
569
+ return { success: false, error: `Backup not found: ${backupId}` };
570
+ }
571
+ console.log(`🔄 准备回滚到: ${backupId}`);
572
+ console.log('⚠️ 这将覆盖当前配置,请确认...');
573
+ try {
574
+ // 先创建当前配置的紧急备份(使用实际扫描到的文件,而非配置列表)
575
+ const emergencyBackup = path.join(BACKUPS_DIR, `emergency-${Date.now()}`);
576
+ fs.mkdirSync(emergencyBackup, { recursive: true });
577
+ // 获取当前实际存在的所有配置文件
578
+ console.log('📦 创建紧急备份(当前配置快照)...');
579
+ const currentItems = scanBackupItems(OPENCLAW_DIR, OPENCLAW_DIR);
580
+ let emergencyItemCount = 0;
581
+ for (const item of currentItems) {
582
+ const sourcePath = path.join(OPENCLAW_DIR, item);
583
+ const destPath = path.join(emergencyBackup, item);
584
+ if (fs.existsSync(sourcePath)) {
585
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
586
+ const stat = fs.statSync(sourcePath);
587
+ if (stat.isDirectory()) {
588
+ fs.cpSync(sourcePath, destPath, { recursive: true });
589
+ }
590
+ else {
591
+ fs.copyFileSync(sourcePath, destPath);
592
+ }
593
+ emergencyItemCount++;
594
+ }
595
+ }
596
+ // 保存紧急备份元数据
597
+ const emergencyMeta = {
598
+ timestamp: new Date().toISOString(),
599
+ version: getPackageVersion(),
600
+ items: currentItems
601
+ };
602
+ fs.writeFileSync(path.join(emergencyBackup, 'meta.json'), JSON.stringify(emergencyMeta, null, 2));
603
+ console.log(`📦 已创建紧急备份: ${path.basename(emergencyBackup)} (${emergencyItemCount} 个文件)`);
604
+ // 停止 gateway
605
+ console.log('🛑 停止 OpenClaw Gateway...');
606
+ try {
607
+ execSync('openclaw gateway stop', { timeout: 30000 });
608
+ }
609
+ catch {
610
+ console.log(' ⚠️ gateway 可能未运行');
611
+ }
612
+ // 恢复备份
613
+ console.log('📂 恢复配置文件中...');
614
+ const metaPath = path.join(backupDir, 'meta.json');
615
+ const meta = fs.existsSync(metaPath)
616
+ ? JSON.parse(fs.readFileSync(metaPath, 'utf8'))
617
+ : { items: [], timestamp: '', version: '' };
618
+ // 安全恢复:只覆盖备份中存在的文件,不删除任何现有文件
619
+ for (const item of meta.items || []) {
620
+ const sourcePath = path.join(backupDir, item);
621
+ const destPath = path.join(OPENCLAW_DIR, item);
622
+ if (fs.existsSync(sourcePath)) {
623
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
624
+ const stat = fs.statSync(sourcePath);
625
+ if (stat.isDirectory()) {
626
+ fs.cpSync(sourcePath, destPath, { recursive: true, force: true });
627
+ }
628
+ else {
629
+ fs.copyFileSync(sourcePath, destPath);
630
+ }
631
+ console.log(` ✓ ${item}`);
632
+ }
633
+ }
634
+ console.log('✅ 配置文件恢复完成(未删除任何现有文件)');
635
+ // 记录回滚变更
636
+ recordChange({
637
+ timestamp: new Date().toISOString(),
638
+ file: 'system',
639
+ nodeId: 'system.restore',
640
+ oldValue: `备份前状态`,
641
+ newValue: backupId,
642
+ changeType: 'auto'
643
+ });
644
+ // 验证关键配置文件是否有效
645
+ console.log('🔍 验证关键配置文件...');
646
+ const openclawJsonPath = path.join(OPENCLAW_DIR, 'openclaw.json');
647
+ if (fs.existsSync(openclawJsonPath)) {
648
+ if (!validateConfigFile(openclawJsonPath)) {
649
+ console.error('❌ openclaw.json 配置无效,无法启动 Gateway');
650
+ console.log('📦 正在从紧急备份恢复...');
651
+ const emergencyOpenclaw = path.join(emergencyBackup, 'openclaw.json');
652
+ if (fs.existsSync(emergencyOpenclaw) && validateConfigFile(emergencyOpenclaw)) {
653
+ fs.copyFileSync(emergencyOpenclaw, openclawJsonPath);
654
+ console.log('✅ 已从紧急备份恢复 openclaw.json');
655
+ }
656
+ else {
657
+ return {
658
+ success: false,
659
+ error: '回滚后的 openclaw.json 配置无效,且紧急备份也无法恢复。请手动检查 ~/.openclaw/openclaw.json'
660
+ };
661
+ }
662
+ }
663
+ }
664
+ // 启动 gateway
665
+ console.log('🚀 启动 OpenClaw Gateway...');
666
+ let gatewayStarted = false;
667
+ let gatewayError = '';
668
+ try {
669
+ execSync('openclaw gateway start', { timeout: 30000 });
670
+ console.log('✅ Gateway 已启动');
671
+ gatewayStarted = true;
672
+ }
673
+ catch (err) {
674
+ gatewayError = err instanceof Error ? err.message : String(err);
675
+ console.error('❌ Gateway 启动失败:', gatewayError);
676
+ }
677
+ console.log('✅ 回滚完成!');
678
+ return {
679
+ success: true,
680
+ message: gatewayStarted
681
+ ? '配置已恢复,Gateway 启动成功'
682
+ : '配置已恢复,但 Gateway 启动失败,请手动检查配置',
683
+ gatewayStarted,
684
+ gatewayError: gatewayError || undefined
685
+ };
686
+ }
687
+ catch (err) {
688
+ const error = err instanceof Error ? err.message : String(err);
689
+ console.error('❌ 回滚失败:', error);
690
+ return { success: false, error };
691
+ }
692
+ }
693
+ // 出行模式自动回滚:回滚到最后一个已知良好的备份
694
+ // 核心保护:回滚前进行双重确认,避免偶发检测误判导致频繁重启 Gateway
695
+ export async function autoRollbackTravelMode() {
696
+ const config = getConfig();
697
+ // 双重确认:等待3秒后再次检测,连续两次异常才执行回滚
698
+ console.log('⏳ 出行模式: 首次检测到异常,等待3秒后二次确认...');
699
+ await new Promise(resolve => setTimeout(resolve, 3000));
700
+ const secondCheck = checkGatewayHealthDeep();
701
+ if (secondCheck.healthy) {
702
+ console.log('✅ 二次确认: Gateway 已恢复正常,跳过本次回滚');
703
+ return { success: true, message: 'Gateway recovered during confirmation, rollback skipped' };
704
+ }
705
+ const goodBackup = getLastKnownGoodBackup();
706
+ if (!goodBackup) {
707
+ console.error('❌ 没有找到可用的已知良好备份,无法自动回滚');
708
+ config.consecutiveFailures = (config.consecutiveFailures || 0) + 1;
709
+ saveConfig(config);
710
+ return { success: false, error: 'No known-good backup found for auto-rollback' };
711
+ }
712
+ console.log(`🚨 出行模式检测到 Gateway 异常(二次确认),准备自动回滚到最后已知良好备份: ${goodBackup.id}`);
713
+ const result = restoreBackup(goodBackup.id);
714
+ if (result.success) {
715
+ config.consecutiveFailures = 0;
716
+ console.log('✅ 出行模式自动回滚成功');
717
+ }
718
+ else {
719
+ config.consecutiveFailures = (config.consecutiveFailures || 0) + 1;
720
+ console.error('❌ 出行模式自动回滚失败:', result.error);
721
+ }
722
+ saveConfig(config);
723
+ return result;
724
+ }
725
+ export function deleteBackup(backupId) {
726
+ // 在两个目录中查找备份
727
+ let backupDir = path.join(REGULAR_BACKUPS_DIR, backupId);
728
+ if (!fs.existsSync(backupDir)) {
729
+ backupDir = path.join(TRAVEL_BACKUPS_DIR, backupId);
730
+ }
731
+ if (!fs.existsSync(backupDir)) {
732
+ return { success: false, error: `Backup not found: ${backupId}` };
733
+ }
734
+ try {
735
+ fs.rmSync(backupDir, { recursive: true });
736
+ console.log(`🗑️ 已删除备份: ${backupId}`);
737
+ return { success: true };
738
+ }
739
+ catch (err) {
740
+ const error = err instanceof Error ? err.message : String(err);
741
+ return { success: false, error };
742
+ }
743
+ }
744
+ // ==================== 配置变更追踪辅助函数 ====================
745
+ const SNAPSHOT_FILE = path.join(PLUGIN_DIR, 'last-config-snapshot.json');
746
+ /**
747
+ * 记录配置快照(用于后续对比变更)
748
+ */
749
+ function recordConfigSnapshot(items, backupId) {
750
+ try {
751
+ const snapshot = {};
752
+ for (const item of items) {
753
+ const filePath = path.join(OPENCLAW_DIR, item);
754
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile() && item.endsWith('.json')) {
755
+ try {
756
+ snapshot[item] = JSON.parse(fs.readFileSync(filePath, 'utf8'));
757
+ }
758
+ catch {
759
+ // 非JSON文件跳过
760
+ }
761
+ }
762
+ }
763
+ snapshot['_backupId'] = backupId;
764
+ snapshot['_timestamp'] = new Date().toISOString();
765
+ fs.writeFileSync(SNAPSHOT_FILE, JSON.stringify(snapshot, null, 2));
766
+ }
767
+ catch {
768
+ // 忽略快照错误
769
+ }
770
+ }
771
+ /**
772
+ * 对比上次快照,记录变更到变更日志
773
+ */
774
+ function logBackupChanges(items, backupId) {
775
+ try {
776
+ if (!fs.existsSync(SNAPSHOT_FILE))
777
+ return;
778
+ const lastSnapshot = JSON.parse(fs.readFileSync(SNAPSHOT_FILE, 'utf8'));
779
+ const lastBackupId = lastSnapshot['_backupId'];
780
+ if (lastBackupId === backupId)
781
+ return; // 同一个备份不重复记录
782
+ for (const item of items) {
783
+ const filePath = path.join(OPENCLAW_DIR, item);
784
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile() || !item.endsWith('.json'))
785
+ continue;
786
+ const lastContent = lastSnapshot[item];
787
+ if (!lastContent)
788
+ continue;
789
+ try {
790
+ const currentContent = JSON.parse(fs.readFileSync(filePath, 'utf8'));
791
+ const diffs = findJsonDiffs(lastContent, currentContent, item);
792
+ for (const diff of diffs) {
793
+ recordChange({
794
+ timestamp: new Date().toISOString(),
795
+ file: item,
796
+ nodeId: diff.path,
797
+ oldValue: diff.oldValue,
798
+ newValue: diff.newValue,
799
+ changeType: 'manual'
800
+ });
801
+ }
802
+ }
803
+ catch {
804
+ // 解析失败跳过
805
+ }
806
+ }
807
+ }
808
+ catch {
809
+ // 忽略变更记录错误
810
+ }
811
+ }
812
+ /**
813
+ * 递归查找JSON差异
814
+ */
815
+ function findJsonDiffs(oldObj, newObj, file, path = '') {
816
+ const diffs = [];
817
+ if (typeof oldObj !== typeof newObj) {
818
+ diffs.push({ path: path || file, oldValue: oldObj, newValue: newObj });
819
+ return diffs;
820
+ }
821
+ if (typeof oldObj !== 'object' || oldObj === null || newObj === null) {
822
+ if (oldObj !== newObj) {
823
+ diffs.push({ path: path || file, oldValue: oldObj, newValue: newObj });
824
+ }
825
+ return diffs;
826
+ }
827
+ const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
828
+ for (const key of allKeys) {
829
+ const newPath = path ? `${path}.${key}` : key;
830
+ if (!(key in oldObj)) {
831
+ diffs.push({ path: newPath, oldValue: undefined, newValue: newObj[key] });
832
+ }
833
+ else if (!(key in newObj)) {
834
+ diffs.push({ path: newPath, oldValue: oldObj[key], newValue: undefined });
835
+ }
836
+ else {
837
+ diffs.push(...findJsonDiffs(oldObj[key], newObj[key], file, newPath));
838
+ }
839
+ }
840
+ return diffs;
841
+ }
842
+ //# sourceMappingURL=core.js.map