aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
733 lines (639 loc) • 22.3 kB
JavaScript
/**
* AIOS Rollback Manager
*
* Story: 5.4 - Rollback Manager
* Epic: Epic 5 - Recovery & Resilience
*
* Manages targeted rollbacks for subtasks, storing commit checkpoints
* and providing safe rollback operations with confirmation.
*
* Features:
* - AC1: Located in `.aios-core/infrastructure/scripts/`
* - AC2: Stores last good commit per subtask in `recovery/commits.json`
* - AC3: Command `*rollback {subtask-id}` in @dev
* - AC4: Targeted rollback - only affects files for specific subtask
* - AC5: Warning/confirmation before executing rollback
* - AC6: Log of rollbacks in `recovery/rollback-log.json`
* - AC7: Option `--hard` to force without confirmation
*
* @author @dev (Dex)
* @version 1.0.0
*/
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
const readline = require('readline');
// Handle different execa versions (v5 exports directly, v6+ exports under .execa)
let execa;
try {
const execaModule = require('execa');
execa = execaModule.execa || execaModule;
} catch {
execa = null;
}
// ===============================================================================
// CONFIGURATION
// ===============================================================================
const CONFIG = {
// File names for recovery data
commitsFile: 'commits.json',
rollbackLogFile: 'rollback-log.json',
// Default recovery directory relative to story path
defaultRecoveryDir: 'recovery',
// Stories base path
storiesBasePath: 'docs/stories',
};
// ===============================================================================
// ROLLBACK MANAGER CLASS
// ===============================================================================
class RollbackManager {
/**
* Create a new RollbackManager instance
*
* @param {Object} options - Configuration options
* @param {string} options.storyId - Story ID (e.g., 'STORY-42')
* @param {string} [options.recoveryPath] - Explicit path to recovery directory
* @param {string} [options.rootPath] - Project root path (defaults to cwd)
*/
constructor(options = {}) {
this.storyId = options.storyId;
this.rootPath = options.rootPath || process.cwd();
this.recoveryPath = options.recoveryPath || null;
this._initPaths();
}
/**
* Initialize file paths based on story ID or explicit recovery path
* @private
*/
_initPaths() {
if (this.recoveryPath) {
// Use explicit recovery path
if (!path.isAbsolute(this.recoveryPath)) {
this.recoveryPath = path.join(this.rootPath, this.recoveryPath);
}
} else if (this.storyId) {
// Derive recovery path from story ID
const storyPath = this._findStoryPath();
if (storyPath) {
this.recoveryPath = path.join(storyPath, CONFIG.defaultRecoveryDir);
} else {
// Default path for new stories
this.recoveryPath = path.join(
this.rootPath,
CONFIG.storiesBasePath,
this.storyId,
CONFIG.defaultRecoveryDir
);
}
}
// Define file paths
this.commitsPath = path.join(this.recoveryPath, CONFIG.commitsFile);
this.rollbackLogPath = path.join(this.recoveryPath, CONFIG.rollbackLogFile);
}
/**
* Find story path in common locations
* @private
* @returns {string|null} Path to story directory or null
*/
_findStoryPath() {
const searchPaths = [
path.join(this.rootPath, CONFIG.storiesBasePath, this.storyId),
path.join(this.rootPath, CONFIG.storiesBasePath, this.storyId.toLowerCase()),
path.join(this.rootPath, CONFIG.storiesBasePath, this.storyId.replace(/[_]/g, '-')),
];
// Also search in epic-based directories
const storiesDir = path.join(this.rootPath, CONFIG.storiesBasePath);
if (fs.existsSync(storiesDir)) {
try {
const epicDirs = fs
.readdirSync(storiesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
for (const epicDir of epicDirs) {
searchPaths.push(
path.join(storiesDir, epicDir, this.storyId),
path.join(storiesDir, epicDir, this.storyId.toLowerCase())
);
}
} catch {
// Ignore errors reading directories
}
}
for (const p of searchPaths) {
if (fs.existsSync(p)) {
return p;
}
}
return null;
}
/**
* Ensure recovery directory exists
* @private
*/
async _ensureRecoveryDir() {
if (!fs.existsSync(this.recoveryPath)) {
await fsPromises.mkdir(this.recoveryPath, { recursive: true });
}
}
/**
* Load commits data from file
* @returns {Promise<Object>} Commits data
*/
async loadCommits() {
try {
if (fs.existsSync(this.commitsPath)) {
const content = await fsPromises.readFile(this.commitsPath, 'utf-8');
return JSON.parse(content);
}
} catch (error) {
console.warn(`Warning: Could not load commits file: ${error.message}`);
}
// Return default structure
return {
storyId: this.storyId,
commits: {},
};
}
/**
* Save commits data to file (AC2)
* @param {Object} commitsData - Commits data to save
*/
async saveCommits(commitsData) {
await this._ensureRecoveryDir();
await fsPromises.writeFile(this.commitsPath, JSON.stringify(commitsData, null, 2), 'utf-8');
}
/**
* Load rollback log from file
* @returns {Promise<Object>} Rollback log data
*/
async loadRollbackLog() {
try {
if (fs.existsSync(this.rollbackLogPath)) {
const content = await fsPromises.readFile(this.rollbackLogPath, 'utf-8');
return JSON.parse(content);
}
} catch (error) {
console.warn(`Warning: Could not load rollback log: ${error.message}`);
}
// Return default structure
return {
rollbacks: [],
};
}
/**
* Save rollback log to file (AC6)
* @param {Object} logData - Rollback log data to save
*/
async saveRollbackLog(logData) {
await this._ensureRecoveryDir();
await fsPromises.writeFile(this.rollbackLogPath, JSON.stringify(logData, null, 2), 'utf-8');
}
/**
* Get current git commit hash
* @returns {Promise<string>} Current commit hash
*/
async getCurrentCommit() {
if (!execa) {
throw new Error('execa module not available for git operations');
}
try {
const { stdout } = await execa('git', ['rev-parse', 'HEAD'], {
cwd: this.rootPath,
});
return stdout.trim();
} catch (error) {
throw new Error(`Failed to get current commit: ${error.message}`);
}
}
/**
* Save a checkpoint for a subtask (AC2)
*
* @param {string} subtaskId - Subtask ID (e.g., '2.1')
* @param {Object} options - Checkpoint options
* @param {string} [options.commit] - Commit hash (defaults to current HEAD)
* @param {string[]} [options.files] - List of files associated with this subtask
* @returns {Promise<Object>} Saved checkpoint data
*/
async saveCheckpoint(subtaskId, options = {}) {
const commit = options.commit || (await this.getCurrentCommit());
const files = options.files || [];
const timestamp = new Date().toISOString();
const commitsData = await this.loadCommits();
commitsData.commits[subtaskId] = {
lastGood: commit,
timestamp,
files,
};
await this.saveCommits(commitsData);
console.log(`Checkpoint saved for subtask ${subtaskId}`);
console.log(` Commit: ${commit.substring(0, 7)}`);
console.log(` Files: ${files.length > 0 ? files.join(', ') : '(none specified)'}`);
return commitsData.commits[subtaskId];
}
/**
* Get checkpoint for a subtask
*
* @param {string} subtaskId - Subtask ID
* @returns {Promise<Object|null>} Checkpoint data or null if not found
*/
async getCheckpoint(subtaskId) {
const commitsData = await this.loadCommits();
return commitsData.commits[subtaskId] || null;
}
/**
* Prompt user for confirmation (AC5)
* @private
* @param {string} message - Confirmation message
* @returns {Promise<boolean>} True if confirmed
*/
async _confirmAction(message) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(`${message} (y/N): `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
}
/**
* Perform targeted rollback for a subtask (AC3, AC4)
*
* @param {string} subtaskId - Subtask ID to rollback
* @param {Object} options - Rollback options
* @param {boolean} [options.hard=false] - Skip confirmation (AC7)
* @param {string} [options.reason] - Reason for rollback
* @returns {Promise<Object>} Rollback result
*/
async rollback(subtaskId, options = {}) {
const { hard = false, reason = 'manual rollback' } = options;
const result = {
success: false,
subtaskId,
from: null,
to: null,
filesAffected: [],
timestamp: new Date().toISOString(),
reason,
};
// Get checkpoint for this subtask
const checkpoint = await this.getCheckpoint(subtaskId);
if (!checkpoint) {
throw new Error(`No checkpoint found for subtask ${subtaskId}`);
}
// Get current commit
const currentCommit = await this.getCurrentCommit();
result.from = currentCommit;
result.to = checkpoint.lastGood;
// Check if already at the checkpoint
if (currentCommit === checkpoint.lastGood) {
console.log(
`Already at checkpoint ${checkpoint.lastGood.substring(0, 7)} for subtask ${subtaskId}`
);
result.success = true;
result.message = 'Already at checkpoint';
return result;
}
// Display warning and get confirmation (AC5)
console.log('\n========================================');
console.log(' ROLLBACK WARNING ');
console.log('========================================\n');
console.log(`Story: ${this.storyId}`);
console.log(`Subtask: ${subtaskId}`);
console.log(`Current commit: ${currentCommit.substring(0, 7)}`);
console.log(`Target commit: ${checkpoint.lastGood.substring(0, 7)}`);
console.log(`Reason: ${reason}`);
if (checkpoint.files && checkpoint.files.length > 0) {
console.log(`\nFiles that may be affected:`);
for (const file of checkpoint.files) {
console.log(` - ${file}`);
}
result.filesAffected = checkpoint.files;
}
console.log('\nThis operation will:');
if (checkpoint.files && checkpoint.files.length > 0) {
console.log(' - Restore specific files to their checkpoint state (targeted rollback)');
} else {
console.log(' - Reset HEAD to the checkpoint commit');
}
console.log('');
// Confirm unless --hard is specified (AC7)
if (!hard) {
const confirmed = await this._confirmAction('Proceed with rollback?');
if (!confirmed) {
console.log('Rollback cancelled.');
result.message = 'Cancelled by user';
return result;
}
} else {
console.log('--hard flag specified, skipping confirmation...\n');
}
try {
if (!execa) {
throw new Error('execa module not available for git operations');
}
// Perform targeted rollback (AC4)
if (checkpoint.files && checkpoint.files.length > 0) {
// Targeted rollback: only restore specific files
console.log('Performing targeted rollback...');
for (const file of checkpoint.files) {
try {
await execa('git', ['checkout', checkpoint.lastGood, '--', file], {
cwd: this.rootPath,
});
console.log(` Restored: ${file}`);
} catch (fileError) {
console.warn(` Warning: Could not restore ${file}: ${fileError.message}`);
}
}
} else {
// Full rollback: reset to checkpoint commit
console.log('Performing full rollback (no specific files tracked)...');
await execa('git', ['checkout', checkpoint.lastGood], {
cwd: this.rootPath,
});
}
result.success = true;
result.message = 'Rollback completed successfully';
console.log('\nRollback completed successfully!');
// Log the rollback (AC6)
await this._logRollback(result);
} catch (error) {
result.success = false;
result.error = error.message;
console.error(`\nRollback failed: ${error.message}`);
// Still log the failed attempt
await this._logRollback(result);
}
return result;
}
/**
* Log a rollback operation (AC6)
* @private
* @param {Object} rollbackResult - Rollback result to log
*/
async _logRollback(rollbackResult) {
const logData = await this.loadRollbackLog();
logData.rollbacks.push({
subtaskId: rollbackResult.subtaskId,
timestamp: rollbackResult.timestamp,
from: rollbackResult.from,
to: rollbackResult.to,
reason: rollbackResult.reason,
filesAffected: rollbackResult.filesAffected,
success: rollbackResult.success,
error: rollbackResult.error || null,
});
await this.saveRollbackLog(logData);
}
/**
* Get rollback log (AC6)
* @returns {Promise<Object>} Rollback log data
*/
async getRollbackLog() {
return await this.loadRollbackLog();
}
/**
* List all checkpoints for this story
* @returns {Promise<Object>} All checkpoints
*/
async listCheckpoints() {
const commitsData = await this.loadCommits();
return commitsData.commits;
}
/**
* Remove a checkpoint
* @param {string} subtaskId - Subtask ID
* @returns {Promise<boolean>} True if removed
*/
async removeCheckpoint(subtaskId) {
const commitsData = await this.loadCommits();
if (commitsData.commits[subtaskId]) {
delete commitsData.commits[subtaskId];
await this.saveCommits(commitsData);
console.log(`Checkpoint removed for subtask ${subtaskId}`);
return true;
}
console.log(`No checkpoint found for subtask ${subtaskId}`);
return false;
}
/**
* Clear all checkpoints for this story
* @returns {Promise<void>}
*/
async clearAllCheckpoints() {
await this.saveCommits({
storyId: this.storyId,
commits: {},
});
console.log(`All checkpoints cleared for story ${this.storyId}`);
}
}
// ===============================================================================
// CLI INTERFACE
// ===============================================================================
async function main() {
const args = process.argv.slice(2);
if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
console.log(`
Rollback Manager - AIOS Recovery System (Story 5.4)
Usage:
node rollback-manager.js <command> <story-id> [subtask-id] [options]
*rollback <story-id> <subtask-id> [options]
Commands:
checkpoint <story-id> <subtask-id> Save current commit as checkpoint for subtask
rollback <story-id> <subtask-id> Rollback subtask to last checkpoint
log <story-id> View rollback history
list <story-id> List all checkpoints for story
remove <story-id> <subtask-id> Remove checkpoint for subtask
clear <story-id> Clear all checkpoints for story
Options:
--hard Skip confirmation for rollback (AC7)
--reason <text> Reason for rollback
--files <file1,file2> Files associated with checkpoint (comma-separated)
--commit <hash> Specific commit hash for checkpoint
--recovery-path <path> Custom path to recovery directory
--help, -h Show this help message
Examples:
# Save checkpoint for subtask 2.1
node rollback-manager.js checkpoint STORY-42 2.1 --files src/stores/authStore.ts
# Rollback subtask 2.1 with confirmation
node rollback-manager.js rollback STORY-42 2.1 --reason "stuck after 3 attempts"
# Force rollback without confirmation
node rollback-manager.js rollback STORY-42 2.1 --hard --reason "manual reset"
# View rollback history
node rollback-manager.js log STORY-42
# List all checkpoints
node rollback-manager.js list STORY-42
Acceptance Criteria Coverage:
AC1: Located in .aios-core/infrastructure/scripts/
AC2: Stores last good commit per subtask in recovery/commits.json
AC3: Command *rollback {subtask-id} support
AC4: Targeted rollback - only affects subtask files
AC5: Warning/confirmation before rollback
AC6: Log of rollbacks in recovery/rollback-log.json
AC7: Option --hard for force without confirmation
`);
process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1);
}
// Parse arguments
const command = args[0];
let storyId = null;
let subtaskId = null;
let reason = 'manual';
let hard = false;
let files = [];
let commit = null;
let recoveryPath = null;
// Parse positional and flag arguments
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg === '--hard') {
hard = true;
} else if (arg === '--reason' && args[i + 1]) {
reason = args[++i];
} else if (arg === '--files' && args[i + 1]) {
files = args[++i].split(',').map((f) => f.trim());
} else if (arg === '--commit' && args[i + 1]) {
commit = args[++i];
} else if (arg === '--recovery-path' && args[i + 1]) {
recoveryPath = args[++i];
} else if (!arg.startsWith('-')) {
if (!storyId) {
storyId = arg;
} else if (!subtaskId) {
subtaskId = arg;
}
}
}
// Validate required arguments
if (!storyId && !recoveryPath) {
console.error('Error: Story ID required');
process.exit(1);
}
try {
const manager = new RollbackManager({
storyId,
recoveryPath,
});
switch (command) {
case 'checkpoint': {
if (!subtaskId) {
console.error('Error: Subtask ID required for checkpoint command');
process.exit(1);
}
const checkpointOptions = {};
if (files.length > 0) checkpointOptions.files = files;
if (commit) checkpointOptions.commit = commit;
await manager.saveCheckpoint(subtaskId, checkpointOptions);
break;
}
case 'rollback': {
if (!subtaskId) {
console.error('Error: Subtask ID required for rollback command');
process.exit(1);
}
const result = await manager.rollback(subtaskId, { hard, reason });
if (
!result.success &&
result.message !== 'Cancelled by user' &&
result.message !== 'Already at checkpoint'
) {
process.exit(1);
}
break;
}
case 'log': {
const log = await manager.getRollbackLog();
if (log.rollbacks.length === 0) {
console.log(`\nNo rollbacks recorded for story ${storyId}`);
} else {
console.log(`\nRollback History for ${storyId}:`);
console.log('─'.repeat(60));
for (const entry of log.rollbacks) {
const status = entry.success ? 'SUCCESS' : 'FAILED';
console.log(`\n[${entry.timestamp}] ${status}`);
console.log(` Subtask: ${entry.subtaskId}`);
console.log(
` From: ${entry.from?.substring(0, 7) || 'N/A'} -> To: ${entry.to?.substring(0, 7) || 'N/A'}`
);
console.log(` Reason: ${entry.reason}`);
if (entry.filesAffected && entry.filesAffected.length > 0) {
console.log(` Files: ${entry.filesAffected.join(', ')}`);
}
if (entry.error) {
console.log(` Error: ${entry.error}`);
}
}
}
break;
}
case 'list': {
const checkpoints = await manager.listCheckpoints();
const entries = Object.entries(checkpoints);
if (entries.length === 0) {
console.log(`\nNo checkpoints found for story ${storyId}`);
} else {
console.log(`\nCheckpoints for ${storyId}:`);
console.log('─'.repeat(60));
for (const [id, data] of entries) {
console.log(`\n Subtask ${id}:`);
console.log(` Commit: ${data.lastGood?.substring(0, 7) || 'N/A'}`);
console.log(` Timestamp: ${data.timestamp}`);
if (data.files && data.files.length > 0) {
console.log(` Files: ${data.files.join(', ')}`);
}
}
}
break;
}
case 'remove': {
if (!subtaskId) {
console.error('Error: Subtask ID required for remove command');
process.exit(1);
}
await manager.removeCheckpoint(subtaskId);
break;
}
case 'clear': {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const confirmed = await new Promise((resolve) => {
rl.question(`Clear ALL checkpoints for ${storyId}? (y/N): `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
if (confirmed) {
await manager.clearAllCheckpoints();
} else {
console.log('Operation cancelled.');
}
break;
}
default:
console.error(`Unknown command: ${command}`);
console.log('Run with --help for usage information.');
process.exit(1);
}
} catch (error) {
console.error(`\nError: ${error.message}`);
process.exit(1);
}
}
// ===============================================================================
// EXPORTS
// ===============================================================================
module.exports = {
RollbackManager,
CONFIG,
};
// Run CLI if executed directly
if (require.main === module) {
main();
}