devflow-ai
Version:
Enterprise-grade AI agent orchestration with swarm management UI dashboard
406 lines (321 loc) ⢠12 kB
text/typescript
/**
* Rollback Manager - Handles rollback operations and backup management
*/
import * as fs from 'fs-extra';
import * as path from 'path';
import * as crypto from 'crypto';
import type { MigrationBackup, BackupFile } from './types.js';
import { logger } from './logger.js';
import * as chalk from 'chalk';
import * as inquirer from 'inquirer';
export class RollbackManager {
private projectPath: string;
private backupDir: string;
constructor(projectPath: string, backupDir: string = '.claude-backup') {
this.projectPath = projectPath;
this.backupDir = path.join(projectPath, backupDir);
}
async createBackup(metadata: Record<string, any> = {}): Promise<MigrationBackup> {
const timestamp = new Date();
const backupId = timestamp.toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(this.backupDir, backupId);
logger.info(`Creating backup at ${backupPath}...`);
await fs.ensureDir(backupPath);
const backup: MigrationBackup = {
timestamp,
version: '1.0.0',
files: [],
metadata: {
projectPath: this.projectPath,
backupId,
...metadata,
},
};
// Backup critical files and directories
const backupTargets = [
'.claude',
'CLAUDE.md',
'.roomodes',
'package.json',
'memory/memory-store.json',
'coordination/config.json',
];
for (const target of backupTargets) {
const sourcePath = path.join(this.projectPath, target);
const targetPath = path.join(backupPath, target);
if (await fs.pathExists(sourcePath)) {
const stats = await fs.stat(sourcePath);
if (stats.isDirectory()) {
await this.backupDirectory(sourcePath, targetPath, backup);
} else {
await this.backupFile(sourcePath, targetPath, backup, target);
}
}
}
// Save backup manifest
const manifestPath = path.join(backupPath, 'backup-manifest.json');
await fs.writeJson(manifestPath, backup, { spaces: 2 });
// Update backup index
await this.updateBackupIndex(backup);
logger.success(`Backup created with ${backup.files.length} files`);
return backup;
}
private async backupDirectory(
sourcePath: string,
targetPath: string,
backup: MigrationBackup,
): Promise<void> {
await fs.ensureDir(targetPath);
const entries = await fs.readdir(sourcePath);
for (const entry of entries) {
const entrySource = path.join(sourcePath, entry);
const entryTarget = path.join(targetPath, entry);
const stats = await fs.stat(entrySource);
if (stats.isDirectory()) {
await this.backupDirectory(entrySource, entryTarget, backup);
} else {
const relativePath = path.relative(this.projectPath, entrySource);
await this.backupFile(entrySource, entryTarget, backup, relativePath);
}
}
}
private async backupFile(
sourcePath: string,
targetPath: string,
backup: MigrationBackup,
relativePath: string,
): Promise<void> {
const content = await fs.readFile(sourcePath, 'utf-8');
const checksum = crypto.createHash('sha256').update(content).digest('hex');
await fs.ensureDir(path.dirname(targetPath));
await fs.writeFile(targetPath, content);
const backupFile: BackupFile = {
path: relativePath,
content,
checksum,
permissions: (await fs.stat(sourcePath)).mode.toString(8),
};
backup.files.push(backupFile);
}
async listBackups(): Promise<MigrationBackup[]> {
if (!(await fs.pathExists(this.backupDir))) {
return [];
}
const backupFolders = await fs.readdir(this.backupDir);
const backups: MigrationBackup[] = [];
for (const folder of backupFolders.sort().reverse()) {
const manifestPath = path.join(this.backupDir, folder, 'backup-manifest.json');
if (await fs.pathExists(manifestPath)) {
try {
const backup = await fs.readJson(manifestPath);
backups.push(backup);
} catch (error) {
logger.warn(
`Invalid backup manifest in ${folder}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
return backups;
}
async rollback(backupId?: string, interactive: boolean = true): Promise<void> {
const backups = await this.listBackups();
if (backups.length === 0) {
throw new Error('No backups found');
}
let selectedBackup: MigrationBackup;
if (backupId) {
selectedBackup = backups.find((b) => b.metadata.backupId === backupId);
if (!selectedBackup) {
throw new Error(`Backup not found: ${backupId}`);
}
} else if (interactive) {
selectedBackup = await this.selectBackupInteractively(backups);
} else {
selectedBackup = backups[0]; // Most recent
}
logger.info(`Rolling back to backup from ${selectedBackup.timestamp.toISOString()}...`);
// Confirm rollback
if (interactive) {
const confirm = await inquirer.prompt([
{
type: 'confirm',
name: 'proceed',
message: `Are you sure you want to rollback? This will overwrite current files.`,
default: false,
},
]);
if (!confirm.proceed) {
logger.info('Rollback cancelled');
return;
}
}
// Create pre-rollback backup
const preRollbackBackup = await this.createBackup({
type: 'pre-rollback',
rollingBackTo: selectedBackup.metadata.backupId,
});
try {
// Restore files
await this.restoreFiles(selectedBackup);
// Validate restoration
await this.validateRestore(selectedBackup);
logger.success('Rollback completed successfully');
} catch (error) {
logger.error('Rollback failed, attempting to restore pre-rollback state...');
try {
await this.restoreFiles(preRollbackBackup);
logger.success('Pre-rollback state restored');
} catch (restoreError) {
logger.error('Failed to restore pre-rollback state:', restoreError);
throw new Error('Rollback failed and unable to restore previous state');
}
throw error;
}
}
private async selectBackupInteractively(backups: MigrationBackup[]): Promise<MigrationBackup> {
const choices = backups.map((backup) => ({
name: `${backup.timestamp.toLocaleString()} - ${backup.files.length} files (${backup.metadata.type || 'migration'})`,
value: backup,
short: backup.metadata.backupId,
}));
const answer = await inquirer.prompt([
{
type: 'list',
name: 'backup',
message: 'Select backup to rollback to:',
choices,
pageSize: 10,
},
]);
return answer.backup;
}
private async restoreFiles(backup: MigrationBackup): Promise<void> {
logger.info(`Restoring ${backup.files.length} files...`);
for (const file of backup.files) {
const targetPath = path.join(this.projectPath, file.path);
logger.debug(`Restoring ${file.path}`);
await fs.ensureDir(path.dirname(targetPath));
await fs.writeFile(targetPath, file.content);
// Restore permissions if available
if (file.permissions) {
try {
await fs.chmod(targetPath, parseInt(file.permissions, 8));
} catch (error) {
logger.warn(
`Could not restore permissions for ${file.path}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
}
private async validateRestore(backup: MigrationBackup): Promise<void> {
logger.info('Validating restored files...');
const errors: string[] = [];
for (const file of backup.files) {
const filePath = path.join(this.projectPath, file.path);
if (!(await fs.pathExists(filePath))) {
errors.push(`Missing file: ${file.path}`);
continue;
}
const content = await fs.readFile(filePath, 'utf-8');
const checksum = crypto.createHash('sha256').update(content).digest('hex');
if (checksum !== file.checksum) {
errors.push(`Checksum mismatch: ${file.path}`);
}
}
if (errors.length > 0) {
throw new Error(`Validation failed:\n${errors.join('\n')}`);
}
logger.success('Validation passed');
}
async cleanupOldBackups(retentionDays: number = 30, maxBackups: number = 10): Promise<void> {
const backups = await this.listBackups();
if (backups.length <= maxBackups) {
return; // No cleanup needed
}
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const backupsToDelete = backups.filter((backup, index) => {
// Keep the most recent maxBackups
if (index < maxBackups) {
return false;
}
// Delete old backups
return backup.timestamp < cutoffDate;
});
if (backupsToDelete.length === 0) {
return;
}
logger.info(`Cleaning up ${backupsToDelete.length} old backups...`);
for (const backup of backupsToDelete) {
const backupPath = path.join(this.backupDir, backup.metadata.backupId);
await fs.remove(backupPath);
logger.debug(`Removed backup: ${backup.metadata.backupId}`);
}
logger.success(`Cleanup completed, removed ${backupsToDelete.length} backups`);
}
async getBackupInfo(backupId: string): Promise<MigrationBackup | null> {
const backups = await this.listBackups();
return backups.find((b) => b.metadata.backupId === backupId) || null;
}
async exportBackup(backupId: string, exportPath: string): Promise<void> {
const backup = await this.getBackupInfo(backupId);
if (!backup) {
throw new Error(`Backup not found: ${backupId}`);
}
const backupPath = path.join(this.backupDir, backup.metadata.backupId);
await fs.copy(backupPath, exportPath);
logger.success(`Backup exported to ${exportPath}`);
}
async importBackup(importPath: string): Promise<MigrationBackup> {
const manifestPath = path.join(importPath, 'backup-manifest.json');
if (!(await fs.pathExists(manifestPath))) {
throw new Error('Invalid backup: missing manifest');
}
const backup = await fs.readJson(manifestPath);
const backupPath = path.join(this.backupDir, backup.metadata.backupId);
await fs.copy(importPath, backupPath);
await this.updateBackupIndex(backup);
logger.success(`Backup imported: ${backup.metadata.backupId}`);
return backup;
}
private async updateBackupIndex(backup: MigrationBackup): Promise<void> {
const indexPath = path.join(this.backupDir, 'backup-index.json');
let index: Record<string, any> = {};
if (await fs.pathExists(indexPath)) {
index = await fs.readJson(indexPath);
}
index[backup.metadata.backupId] = {
timestamp: backup.timestamp,
version: backup.version,
fileCount: backup.files.length,
metadata: backup.metadata,
};
await fs.writeJson(indexPath, index, { spaces: 2 });
}
printBackupSummary(backups: MigrationBackup[]): void {
if (backups.length === 0) {
console.log(chalk.yellow('No backups found'));
return;
}
console.log(chalk.bold('\nš¾ Available Backups'));
console.log(chalk.gray('ā'.repeat(70)));
backups.forEach((backup, index) => {
const isRecent = index === 0;
const date = backup.timestamp.toLocaleString();
const type = backup.metadata.type || 'migration';
const fileCount = backup.files.length;
console.log(
`\n${isRecent ? chalk.green('ā') : chalk.gray('ā')} ${chalk.bold(backup.metadata.backupId)}`,
);
console.log(` ${chalk.gray('Date:')} ${date}`);
console.log(` ${chalk.gray('Type:')} ${type}`);
console.log(` ${chalk.gray('Files:')} ${fileCount}`);
if (backup.metadata.strategy) {
console.log(` ${chalk.gray('Strategy:')} ${backup.metadata.strategy}`);
}
});
console.log(chalk.gray('\n' + 'ā'.repeat(70)));
}
}