feat: file-level backups for self-evolve — auto-backup before every patch, backups/restore actions

This commit is contained in:
admin
2026-05-05 18:15:29 +00:00
Unverified
parent cfa92c81b0
commit 8d904af246
2 changed files with 139 additions and 7 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ logs/
data/
*.log
.DS_Store
.self-evolve-backups/

View File

@@ -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.`;
}
}
}