shipdeck
Version:
Ship MVPs in 48 hours. Fix bugs in 30 seconds. The command deck for developers who ship.
669 lines (564 loc) โข 18.1 kB
JavaScript
/**
* Checkpoint Manager for Rollback System
* Creates and manages atomic checkpoints for safe recovery
*/
const { execSync } = require('child_process');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const EventEmitter = require('events');
class CheckpointManager extends EventEmitter {
constructor(options = {}) {
super();
this.projectPath = options.projectPath || process.cwd();
this.checkpointDir = options.checkpointDir || path.join(this.projectPath, '.shipdeck', 'checkpoints');
this.maxCheckpoints = options.maxCheckpoints || 50;
this.autoCheckpoint = options.autoCheckpoint !== false;
// Checkpoint metadata
this.checkpoints = new Map();
this.currentCheckpoint = null;
// Health check configuration
this.healthChecks = {
build: options.runBuildCheck !== false,
tests: options.runTestCheck !== false,
lint: options.runLintCheck !== false,
types: options.runTypeCheck !== false
};
// Statistics
this.stats = {
totalCheckpoints: 0,
successfulRollbacks: 0,
failedRollbacks: 0,
averageRollbackTime: 0
};
}
/**
* Initialize checkpoint system
*/
async initialize() {
// Create checkpoint directory
await fs.mkdir(this.checkpointDir, { recursive: true });
// Load existing checkpoints
await this.loadCheckpoints();
// Verify Git repository
this.verifyGitRepo();
console.log(`๐ Checkpoint Manager initialized with ${this.checkpoints.size} checkpoints`);
return this;
}
/**
* Create a checkpoint
*/
async createCheckpoint(metadata = {}) {
const startTime = Date.now();
try {
// Run health checks before creating checkpoint
const healthStatus = await this.runHealthChecks();
if (!healthStatus.healthy && !metadata.force) {
console.warn('โ ๏ธ Health checks failed. Use force: true to create checkpoint anyway.');
return {
success: false,
reason: 'Health checks failed',
details: healthStatus
};
}
// Generate checkpoint ID
const checkpointId = this.generateCheckpointId();
// Get current Git state
const gitStatus = this.getGitStatus();
// Create atomic commit if there are changes
if (gitStatus.hasChanges) {
const commitHash = await this.createAtomicCommit(metadata);
// Create checkpoint metadata
const checkpoint = {
id: checkpointId,
timestamp: Date.now(),
commitHash,
branch: gitStatus.branch,
metadata: {
...metadata,
healthStatus,
gitStatus
},
tags: metadata.tags || [],
description: metadata.description || `Checkpoint ${checkpointId}`,
type: metadata.type || 'auto',
node: metadata.node || null,
workflow: metadata.workflow || null
};
// Save checkpoint
await this.saveCheckpoint(checkpoint);
// Update current checkpoint
this.currentCheckpoint = checkpoint;
// Emit event
this.emit('checkpoint:created', checkpoint);
const duration = Date.now() - startTime;
console.log(`โ
Checkpoint ${checkpointId} created in ${duration}ms`);
return {
success: true,
checkpoint,
duration
};
} else {
console.log('โน๏ธ No changes to checkpoint');
return {
success: false,
reason: 'No changes to commit'
};
}
} catch (error) {
console.error(`โ Failed to create checkpoint: ${error.message}`);
return {
success: false,
error: error.message
};
}
}
/**
* Rollback to a checkpoint
*/
async rollback(checkpointId, options = {}) {
const startTime = Date.now();
try {
console.log(`๐ Rolling back to checkpoint ${checkpointId}...`);
// Get checkpoint
const checkpoint = this.checkpoints.get(checkpointId);
if (!checkpoint) {
throw new Error(`Checkpoint ${checkpointId} not found`);
}
// Save current state as recovery point
if (!options.skipRecoveryPoint) {
await this.createCheckpoint({
type: 'recovery',
description: `Recovery point before rollback to ${checkpointId}`,
tags: ['recovery', 'pre-rollback']
});
}
// Stash any uncommitted changes
const hasUncommitted = this.hasUncommittedChanges();
if (hasUncommitted) {
console.log('๐ฆ Stashing uncommitted changes...');
execSync('git stash push -m "Rollback stash"', { cwd: this.projectPath });
}
// Perform rollback
console.log(`๐ฏ Reverting to commit ${checkpoint.commitHash}...`);
if (options.hard) {
// Hard reset (destructive)
execSync(`git reset --hard ${checkpoint.commitHash}`, { cwd: this.projectPath });
} else {
// Soft rollback (preserves history)
execSync(`git revert --no-edit ${checkpoint.commitHash}..HEAD`, { cwd: this.projectPath });
}
// Run post-rollback hooks
if (options.runHooks !== false) {
await this.runPostRollbackHooks(checkpoint);
}
// Verify rollback success
const verification = await this.verifyRollback(checkpoint);
if (!verification.success) {
throw new Error(`Rollback verification failed: ${verification.reason}`);
}
// Update stats
this.stats.successfulRollbacks++;
const duration = Date.now() - startTime;
this.updateAverageRollbackTime(duration);
// Emit event
this.emit('rollback:success', {
checkpoint,
duration,
options
});
console.log(`โ
Rollback completed in ${duration}ms`);
return {
success: true,
checkpoint,
duration,
verification
};
} catch (error) {
this.stats.failedRollbacks++;
console.error(`โ Rollback failed: ${error.message}`);
// Attempt recovery
if (options.attemptRecovery !== false) {
await this.attemptRecovery();
}
this.emit('rollback:failed', {
checkpointId,
error: error.message
});
return {
success: false,
error: error.message
};
}
}
/**
* Rollback to previous checkpoint
*/
async rollbackToPrevious(options = {}) {
const checkpoints = Array.from(this.checkpoints.values())
.sort((a, b) => b.timestamp - a.timestamp);
if (checkpoints.length < 2) {
return {
success: false,
reason: 'No previous checkpoint available'
};
}
// Skip current checkpoint and rollback to previous
const previousCheckpoint = checkpoints[1];
return this.rollback(previousCheckpoint.id, options);
}
/**
* List checkpoints with filtering
*/
listCheckpoints(filters = {}) {
let checkpoints = Array.from(this.checkpoints.values());
// Apply filters
if (filters.type) {
checkpoints = checkpoints.filter(c => c.type === filters.type);
}
if (filters.tags && filters.tags.length > 0) {
checkpoints = checkpoints.filter(c =>
filters.tags.some(tag => c.tags.includes(tag))
);
}
if (filters.since) {
checkpoints = checkpoints.filter(c => c.timestamp >= filters.since);
}
if (filters.workflow) {
checkpoints = checkpoints.filter(c => c.workflow === filters.workflow);
}
// Sort by timestamp (newest first)
checkpoints.sort((a, b) => b.timestamp - a.timestamp);
// Apply limit
if (filters.limit) {
checkpoints = checkpoints.slice(0, filters.limit);
}
return checkpoints;
}
/**
* Get checkpoint details
*/
getCheckpoint(checkpointId) {
return this.checkpoints.get(checkpointId);
}
/**
* Delete old checkpoints
*/
async pruneCheckpoints(keepCount = this.maxCheckpoints) {
const checkpoints = Array.from(this.checkpoints.values())
.sort((a, b) => b.timestamp - a.timestamp);
if (checkpoints.length <= keepCount) {
return { pruned: 0 };
}
const toPrune = checkpoints.slice(keepCount);
let pruned = 0;
for (const checkpoint of toPrune) {
// Don't prune tagged checkpoints
if (checkpoint.tags.includes('keep') || checkpoint.tags.includes('important')) {
continue;
}
await this.deleteCheckpoint(checkpoint.id);
pruned++;
}
console.log(`๐งน Pruned ${pruned} old checkpoints`);
return { pruned };
}
/**
* Run health checks
*/
async runHealthChecks() {
const results = {
healthy: true,
checks: {},
errors: []
};
// Build check
if (this.healthChecks.build) {
try {
console.log('๐จ Running build check...');
execSync('npm run build', { cwd: this.projectPath, stdio: 'pipe' });
results.checks.build = 'passed';
} catch (error) {
results.checks.build = 'failed';
results.errors.push(`Build failed: ${error.message}`);
results.healthy = false;
}
}
// Test check
if (this.healthChecks.tests) {
try {
console.log('๐งช Running tests...');
execSync('npm test', { cwd: this.projectPath, stdio: 'pipe' });
results.checks.tests = 'passed';
} catch (error) {
results.checks.tests = 'failed';
results.errors.push(`Tests failed: ${error.message}`);
// Don't mark as unhealthy for test failures (they might be expected)
}
}
// Lint check
if (this.healthChecks.lint) {
try {
console.log('๐ Running lint check...');
execSync('npm run lint', { cwd: this.projectPath, stdio: 'pipe' });
results.checks.lint = 'passed';
} catch (error) {
results.checks.lint = 'failed';
results.errors.push(`Lint failed: ${error.message}`);
// Don't mark as unhealthy for lint issues
}
}
// Type check
if (this.healthChecks.types) {
try {
console.log('๐ Running type check...');
execSync('npm run type-check', { cwd: this.projectPath, stdio: 'pipe' });
results.checks.types = 'passed';
} catch (error) {
results.checks.types = 'failed';
results.errors.push(`Type check failed: ${error.message}`);
results.healthy = false;
}
}
return results;
}
/**
* Create atomic commit
*/
async createAtomicCommit(metadata) {
const message = metadata.message || `Checkpoint: ${metadata.description || 'Auto-save'}`;
// Stage all changes
execSync('git add -A', { cwd: this.projectPath });
// Create commit
const commitCmd = `git commit -m "${message}"`;
execSync(commitCmd, { cwd: this.projectPath });
// Get commit hash
const commitHash = execSync('git rev-parse HEAD', { cwd: this.projectPath })
.toString().trim();
return commitHash;
}
/**
* Verify rollback success
*/
async verifyRollback(checkpoint) {
try {
// Verify Git state
const currentHash = execSync('git rev-parse HEAD', { cwd: this.projectPath })
.toString().trim();
// Check if we're at the expected commit
const correctCommit = currentHash === checkpoint.commitHash ||
currentHash.startsWith(checkpoint.commitHash);
// Run basic health checks
const healthStatus = await this.runHealthChecks();
return {
success: correctCommit && healthStatus.healthy,
correctCommit,
healthStatus,
currentHash
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Run post-rollback hooks
*/
async runPostRollbackHooks(checkpoint) {
const hooks = [
'npm install', // Reinstall dependencies
'npm run build' // Rebuild project
];
for (const hook of hooks) {
try {
console.log(`๐ง Running: ${hook}`);
execSync(hook, { cwd: this.projectPath, stdio: 'pipe' });
} catch (error) {
console.warn(`โ ๏ธ Hook failed: ${hook}`);
}
}
}
/**
* Attempt recovery after failed rollback
*/
async attemptRecovery() {
console.log('๐ Attempting recovery...');
try {
// Try to restore from stash
const stashList = execSync('git stash list', { cwd: this.projectPath })
.toString();
if (stashList.includes('Rollback stash')) {
execSync('git stash pop', { cwd: this.projectPath });
console.log('โ
Recovered from stash');
}
// Reset to last known good state
const lastGoodCheckpoint = this.findLastGoodCheckpoint();
if (lastGoodCheckpoint) {
execSync(`git reset --hard ${lastGoodCheckpoint.commitHash}`, {
cwd: this.projectPath
});
console.log(`โ
Recovered to checkpoint ${lastGoodCheckpoint.id}`);
}
} catch (error) {
console.error('โ Recovery failed:', error.message);
}
}
/**
* Find last checkpoint marked as good
*/
findLastGoodCheckpoint() {
const checkpoints = Array.from(this.checkpoints.values())
.sort((a, b) => b.timestamp - a.timestamp);
return checkpoints.find(c =>
c.metadata?.healthStatus?.healthy ||
c.tags.includes('stable') ||
c.tags.includes('good')
);
}
/**
* Check if repository has uncommitted changes
*/
hasUncommittedChanges() {
try {
const status = execSync('git status --porcelain', { cwd: this.projectPath })
.toString();
return status.length > 0;
} catch {
return false;
}
}
/**
* Get current Git status
*/
getGitStatus() {
try {
const branch = execSync('git branch --show-current', { cwd: this.projectPath })
.toString().trim();
const status = execSync('git status --porcelain', { cwd: this.projectPath })
.toString();
const lastCommit = execSync('git rev-parse HEAD', { cwd: this.projectPath })
.toString().trim();
return {
branch,
hasChanges: status.length > 0,
lastCommit,
changes: status.split('\n').filter(Boolean)
};
} catch (error) {
return {
branch: 'unknown',
hasChanges: false,
lastCommit: null,
error: error.message
};
}
}
/**
* Verify Git repository exists
*/
verifyGitRepo() {
try {
execSync('git rev-parse --git-dir', { cwd: this.projectPath, stdio: 'pipe' });
} catch {
throw new Error('Not a Git repository. Initialize with: git init');
}
}
/**
* Generate checkpoint ID
*/
generateCheckpointId() {
const timestamp = Date.now();
const random = crypto.randomBytes(4).toString('hex');
return `cp-${timestamp}-${random}`;
}
/**
* Save checkpoint to disk
*/
async saveCheckpoint(checkpoint) {
// Add to memory
this.checkpoints.set(checkpoint.id, checkpoint);
// Save to disk
const checkpointFile = path.join(this.checkpointDir, `${checkpoint.id}.json`);
await fs.writeFile(checkpointFile, JSON.stringify(checkpoint, null, 2));
// Update stats
this.stats.totalCheckpoints++;
// Prune old checkpoints if needed
if (this.checkpoints.size > this.maxCheckpoints) {
await this.pruneCheckpoints();
}
}
/**
* Load checkpoints from disk
*/
async loadCheckpoints() {
try {
const files = await fs.readdir(this.checkpointDir);
const checkpointFiles = files.filter(f => f.endsWith('.json'));
for (const file of checkpointFiles) {
const filePath = path.join(this.checkpointDir, file);
const content = await fs.readFile(filePath, 'utf8');
const checkpoint = JSON.parse(content);
this.checkpoints.set(checkpoint.id, checkpoint);
}
} catch (error) {
// Directory doesn't exist yet
}
}
/**
* Delete a checkpoint
*/
async deleteCheckpoint(checkpointId) {
this.checkpoints.delete(checkpointId);
const checkpointFile = path.join(this.checkpointDir, `${checkpointId}.json`);
try {
await fs.unlink(checkpointFile);
} catch {
// File doesn't exist
}
}
/**
* Update average rollback time
*/
updateAverageRollbackTime(duration) {
const totalRollbacks = this.stats.successfulRollbacks;
const currentAvg = this.stats.averageRollbackTime;
this.stats.averageRollbackTime =
((currentAvg * (totalRollbacks - 1)) + duration) / totalRollbacks;
}
/**
* Get rollback statistics
*/
getStats() {
return {
...this.stats,
checkpointCount: this.checkpoints.size,
oldestCheckpoint: this.getOldestCheckpoint(),
newestCheckpoint: this.getNewestCheckpoint(),
successRate: this.stats.successfulRollbacks /
(this.stats.successfulRollbacks + this.stats.failedRollbacks) * 100
};
}
/**
* Get oldest checkpoint
*/
getOldestCheckpoint() {
const checkpoints = Array.from(this.checkpoints.values());
if (checkpoints.length === 0) return null;
return checkpoints.reduce((oldest, current) =>
current.timestamp < oldest.timestamp ? current : oldest
);
}
/**
* Get newest checkpoint
*/
getNewestCheckpoint() {
const checkpoints = Array.from(this.checkpoints.values());
if (checkpoints.length === 0) return null;
return checkpoints.reduce((newest, current) =>
current.timestamp > newest.timestamp ? current : newest
);
}
}
module.exports = { CheckpointManager };