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/
|
data/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.self-evolve-backups/
|
||||||
|
|||||||
@@ -21,12 +21,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
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';
|
import path from 'path';
|
||||||
|
|
||||||
const REPO_ROOT = '/home/uroma2/zcode-cli-x';
|
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 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
|
const RATE_LIMIT_MS = 60_000; // 1 minute between evolves
|
||||||
|
|
||||||
// Files that CANNOT be self-modified (safety critical)
|
// Files that CANNOT be self-modified (safety critical)
|
||||||
@@ -40,11 +41,11 @@ let lastEvolveTime = 0;
|
|||||||
export class SelfEvolveTool {
|
export class SelfEvolveTool {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.name = 'self_evolve';
|
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 = {
|
this.parameters = {
|
||||||
action: {
|
action: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'read | patch | list_files | git_log | diff',
|
description: 'read | patch | list_files | git_log | diff | backups | restore',
|
||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -62,6 +63,10 @@ export class SelfEvolveTool {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'For patch: git commit message describing the change',
|
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 'patch': return this.patch(file, old_code, new_code, message);
|
||||||
case 'git_log': return this.gitLog();
|
case 'git_log': return this.gitLog();
|
||||||
case 'diff': return this.diff(file);
|
case 'diff': return this.diff(file);
|
||||||
|
case 'backups': return this.listBackups(file);
|
||||||
|
case 'restore': return this.restore(backup_id, file);
|
||||||
default:
|
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 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`);
|
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);
|
const newContent = content.replace(oldCode, newCode);
|
||||||
writeFileSync(absPath, newContent, 'utf-8');
|
writeFileSync(absPath, newContent, 'utf-8');
|
||||||
|
|
||||||
@@ -223,7 +234,7 @@ export class SelfEvolveTool {
|
|||||||
// ── SUCCESS — drop stash (we don't need the pre-stash state) ──
|
// ── SUCCESS — drop stash (we don't need the pre-stash state) ──
|
||||||
this._exec(`cd ${REPO_ROOT} && git stash drop --quiet 2>/dev/null || true`);
|
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) {
|
} catch (err) {
|
||||||
// ── ROLLBACK — restore everything ──
|
// ── ROLLBACK — restore everything ──
|
||||||
@@ -295,4 +306,124 @@ export class SelfEvolveTool {
|
|||||||
return true;
|
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