feat: file-level backups for self-evolve — auto-backup before every patch, backups/restore actions
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ logs/
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
.self-evolve-backups/
|
||||
|
||||
@@ -21,12 +21,13 @@
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { writeFileSync, readFileSync, existsSync, statSync } from 'fs';
|
||||
import { writeFileSync, readFileSync, existsSync, statSync, mkdirSync, readdirSync, copyFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const REPO_ROOT = '/home/uroma2/zcode-cli-x';
|
||||
const BACKUP_DIR = '/home/uroma2/zcode-cli-x/.self-evolve-backups';
|
||||
const HEALTH_URL = 'https://zcode-bot.95-216-124-247.sslip.io/health';
|
||||
const MAX_FILE_SIZE = 50 * 1024; // 50KB
|
||||
const MAX_FILE_SIZE = 80 * 1024; // 80KB — main bot file is ~55KB
|
||||
const RATE_LIMIT_MS = 60_000; // 1 minute between evolves
|
||||
|
||||
// Files that CANNOT be self-modified (safety critical)
|
||||
@@ -40,11 +41,11 @@ let lastEvolveTime = 0;
|
||||
export class SelfEvolveTool {
|
||||
constructor() {
|
||||
this.name = 'self_evolve';
|
||||
this.description = 'Read and modify zCode\'s own source code with automatic rollback on failure. The bot can improve itself — but safely.';
|
||||
this.description = 'Read and modify zCode\'s own source code with automatic rollback on failure. Every change creates a file-level backup.';
|
||||
this.parameters = {
|
||||
action: {
|
||||
type: 'string',
|
||||
description: 'read | patch | list_files | git_log | diff',
|
||||
description: 'read | patch | list_files | git_log | diff | backups | restore',
|
||||
},
|
||||
file: {
|
||||
type: 'string',
|
||||
@@ -62,6 +63,10 @@ export class SelfEvolveTool {
|
||||
type: 'string',
|
||||
description: 'For patch: git commit message describing the change',
|
||||
},
|
||||
backup_id: {
|
||||
type: 'string',
|
||||
description: 'For restore: timestamp ID from backups list (e.g. 2026-05-05T18-30-00)',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,8 +79,10 @@ export class SelfEvolveTool {
|
||||
case 'patch': return this.patch(file, old_code, new_code, message);
|
||||
case 'git_log': return this.gitLog();
|
||||
case 'diff': return this.diff(file);
|
||||
case 'backups': return this.listBackups(file);
|
||||
case 'restore': return this.restore(backup_id, file);
|
||||
default:
|
||||
return `❌ Unknown action: ${action}. Use: read, patch, list_files, git_log, diff`;
|
||||
return `❌ Unknown action: ${action}. Use: read, patch, list_files, git_log, diff, backups, restore`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +187,11 @@ export class SelfEvolveTool {
|
||||
this._exec(`cd ${REPO_ROOT} && git stash push -m "${checkpoint}" --quiet 2>/dev/null || true`);
|
||||
this._exec(`cd ${REPO_ROOT} && git add -A && git commit -m "checkpoint: before self-evolve ${checkpoint}" --allow-empty --quiet 2>/dev/null || true`);
|
||||
|
||||
// ── STEP 2: Apply patch ──
|
||||
// ── STEP 2: File-level backup (survives even git corruption) ──
|
||||
const backupId = this._backupFile(absPath, relPath, checkpoint);
|
||||
logger.info(`💾 File backup: ${backupId}`);
|
||||
|
||||
// ── STEP 3: Apply patch ──
|
||||
const newContent = content.replace(oldCode, newCode);
|
||||
writeFileSync(absPath, newContent, 'utf-8');
|
||||
|
||||
@@ -223,7 +234,7 @@ export class SelfEvolveTool {
|
||||
// ── SUCCESS — drop stash (we don't need the pre-stash state) ──
|
||||
this._exec(`cd ${REPO_ROOT} && git stash drop --quiet 2>/dev/null || true`);
|
||||
|
||||
return `✅ **Self-evolve succeeded!**\n\n📄 File: ${relPath}\n📝 Commit: ${msg}\n\n🩺 Health: OK\n🧪 Smoke test: PASS\n\nThe bot is running with your improvement. Rollback: \`git revert HEAD\` if needed.`;
|
||||
return `✅ **Self-evolve succeeded!**\n\n📄 File: ${relPath}\n📝 Commit: ${msg}\n💾 Backup: ${backupId}\n\n🩺 Health: OK\n🧪 Smoke test: PASS\n\nTo rollback: \`self_evolve action=restore backup_id=${backupId} file=${relPath}\``;
|
||||
|
||||
} catch (err) {
|
||||
// ── ROLLBACK — restore everything ──
|
||||
@@ -295,4 +306,124 @@ export class SelfEvolveTool {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── File-level backup ──
|
||||
_backupFile(absPath, relPath, checkpoint) {
|
||||
// Create backup dir structure: .self-evolve-backups/src/bot/2026-05-05T18-30-00__index.js
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const fileName = path.basename(relPath);
|
||||
const dirPart = path.dirname(relPath);
|
||||
const backupSubDir = path.join(BACKUP_DIR, dirPart);
|
||||
const backupId = `${timestamp}__${fileName}`;
|
||||
const backupPath = path.join(backupSubDir, backupId);
|
||||
|
||||
mkdirSync(backupSubDir, { recursive: true });
|
||||
copyFileSync(absPath, backupPath);
|
||||
|
||||
// Also write a metadata file with the checkpoint info
|
||||
writeFileSync(backupPath + '.meta', JSON.stringify({
|
||||
id: backupId,
|
||||
file: relPath,
|
||||
checkpoint,
|
||||
timestamp: new Date().toISOString(),
|
||||
size: statSync(absPath).size,
|
||||
}, null, 2));
|
||||
|
||||
return backupId;
|
||||
}
|
||||
|
||||
// ── List backups ──
|
||||
listBackups(relPath) {
|
||||
if (!existsSync(BACKUP_DIR)) {
|
||||
return '📦 No backups yet. Backups are created automatically on every self-evolve patch.';
|
||||
}
|
||||
|
||||
const searchDir = relPath
|
||||
? path.join(BACKUP_DIR, path.dirname(relPath))
|
||||
: BACKUP_DIR;
|
||||
|
||||
if (!existsSync(searchDir)) {
|
||||
return `📦 No backups found for ${relPath || 'any file'}.`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Find all .meta files recursively
|
||||
const findCmd = `find ${BACKUP_DIR} -name '*.meta' -type f | sort -r | head -30`;
|
||||
const metaFiles = execSync(findCmd, { encoding: 'utf-8', timeout: 5000 }).trim().split('\n').filter(Boolean);
|
||||
|
||||
if (metaFiles.length === 0) {
|
||||
return '📦 No backups found.';
|
||||
}
|
||||
|
||||
const entries = metaFiles.map(mf => {
|
||||
try {
|
||||
const meta = JSON.parse(readFileSync(mf, 'utf-8'));
|
||||
const size = (meta.size / 1024).toFixed(1);
|
||||
return `| ${meta.id} | ${meta.file} | ${size}KB | ${meta.timestamp.slice(0, 19)} |`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
|
||||
return `📦 **File-level backups** (newest first):\n\n| ID | File | Size | Time |\n|---|---|---|---|\n${entries.join('\n')}\n\nTo restore: \`self_evolve action=restore backup_id=<ID> file=<path>\``;
|
||||
} catch (e) {
|
||||
return `❌ Backup list error: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Restore from backup ──
|
||||
restore(backupId, relPath) {
|
||||
if (!backupId) return '❌ Specify backup_id from the backups list.';
|
||||
|
||||
// Find the backup file
|
||||
try {
|
||||
const findCmd = `find ${BACKUP_DIR} -name '${backupId}' -type f | head -1`;
|
||||
const backupPath = execSync(findCmd, { encoding: 'utf-8', timeout: 5000 }).trim();
|
||||
|
||||
if (!backupPath || !existsSync(backupPath)) {
|
||||
return `❌ Backup not found: ${backupId}. Run 'backups' to see available backups.`;
|
||||
}
|
||||
|
||||
// Read metadata to get original file path
|
||||
const metaPath = backupPath + '.meta';
|
||||
let targetRelPath = relPath;
|
||||
if (existsSync(metaPath)) {
|
||||
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
||||
if (!targetRelPath) targetRelPath = meta.file;
|
||||
}
|
||||
|
||||
if (!targetRelPath) return '❌ Specify file path to restore to.';
|
||||
|
||||
const absPath = this._resolve(targetRelPath);
|
||||
if (!absPath) return `❌ Invalid path: ${targetRelPath}`;
|
||||
|
||||
// Backup current state before overwriting
|
||||
if (existsSync(absPath)) {
|
||||
const restoreCheckpoint = `pre-restore-${Date.now()}`;
|
||||
this._backupFile(absPath, targetRelPath, restoreCheckpoint);
|
||||
}
|
||||
|
||||
// Copy backup to original location
|
||||
copyFileSync(backupPath, absPath);
|
||||
|
||||
// Syntax check
|
||||
if (absPath.endsWith('.js')) {
|
||||
this._exec(`node --check ${absPath}`);
|
||||
}
|
||||
|
||||
// Commit, push, restart
|
||||
this._exec(`cd ${REPO_ROOT} && git add ${targetRelPath} && git commit -m "restore: ${backupId}" --quiet`);
|
||||
this._exec(`cd ${REPO_ROOT} && git push origin main --quiet 2>&1`);
|
||||
this._exec(`systemctl --user restart zcode`);
|
||||
|
||||
const healthOk = this._waitForHealth(15000);
|
||||
if (!healthOk) {
|
||||
throw new Error('Health check failed after restore');
|
||||
}
|
||||
|
||||
return `✅ **Restored from backup!**\n\n📄 File: ${targetRelPath}\n💾 Backup: ${backupId}\n🩺 Health: OK\n\nBot is running with the restored version.`;
|
||||
} catch (err) {
|
||||
return `❌ **Restore failed:** ${err.message}\n\nThe backup file still exists at ${BACKUP_DIR}. Manual restore: copy the backup file to the target path.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user