UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

494 lines (418 loc) 13.8 kB
/** * Checkpoint Manager for External Ralph Loop * * Manages periodic checkpoints during long-running Claude sessions (6-8 hours) * to enable crash recovery and progress tracking. * * @implements @.aiwg/requirements/design-ralph-external.md */ import { EventEmitter } from 'events'; import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import { execSync } from 'child_process'; /** * @typedef {Object} CheckpointData * @property {number} number - Checkpoint number * @property {string} timestamp - ISO timestamp of checkpoint * @property {number} duration - Milliseconds since session start * @property {Object} gitStatus - Git status snapshot * @property {string} gitStatus.branch - Current git branch * @property {string[]} gitStatus.modified - Modified files * @property {string[]} gitStatus.untracked - Untracked files * @property {string[]} gitStatus.staged - Staged files * @property {Object} aiwgState - .aiwg/ directory state * @property {string[]} aiwgState.files - Files in .aiwg/ * @property {Object} aiwgState.sizes - File sizes in bytes * @property {string[]} filesModified - Cumulative files modified since session start * @property {string} sessionId - Claude session ID * @property {Object} memory - Memory snapshot * @property {number} memory.heapUsed - Heap memory used in bytes * @property {number} memory.rss - Resident set size in bytes */ /** * @typedef {Object} CheckpointOptions * @property {string} stateDir - State directory (.aiwg/ralph-external) * @property {string} projectRoot - Project root directory * @property {number} [interval=1800000] - Checkpoint interval in milliseconds (default: 30 min) * @property {Function} [onCheckpoint] - Callback when checkpoint is captured * @property {string} [sessionId] - Claude session ID */ const DEFAULT_INTERVAL = 30 * 60 * 1000; // 30 minutes const CHECKPOINTS_DIR = 'checkpoints'; export class CheckpointManager extends EventEmitter { /** * @param {CheckpointOptions} options */ constructor(options = {}) { super(); this.stateDir = options.stateDir; this.projectRoot = options.projectRoot; this.interval = options.interval || DEFAULT_INTERVAL; this.onCheckpoint = options.onCheckpoint; this.sessionId = options.sessionId || 'unknown'; this.timerId = null; this.checkpointNumber = 0; this.sessionStartTime = Date.now(); this.checkpointsDir = null; this.isRunning = false; if (this.stateDir) { this.checkpointsDir = join(this.stateDir, CHECKPOINTS_DIR); } } /** * Start periodic checkpoint capture * @param {CheckpointOptions} options - Checkpoint options (overrides constructor) * @returns {CheckpointManager} This instance for chaining */ start(options = {}) { if (this.isRunning) { throw new Error('Checkpoint manager already running'); } // Override constructor options if provided if (options.stateDir) this.stateDir = options.stateDir; if (options.projectRoot) this.projectRoot = options.projectRoot; if (options.interval) this.interval = options.interval; if (options.onCheckpoint) this.onCheckpoint = options.onCheckpoint; if (options.sessionId) this.sessionId = options.sessionId; if (!this.stateDir || !this.projectRoot) { throw new Error('stateDir and projectRoot are required'); } this.checkpointsDir = join(this.stateDir, CHECKPOINTS_DIR); this.ensureCheckpointsDir(); // Determine starting checkpoint number this.checkpointNumber = this.getNextCheckpointNumber(); this.sessionStartTime = Date.now(); this.isRunning = true; // Capture initial checkpoint immediately this.captureCheckpoint(this.checkpointNumber); // Start periodic timer this.timerId = setInterval(() => { this.checkpointNumber++; this.captureCheckpoint(this.checkpointNumber); }, this.interval); this.emit('started', { interval: this.interval, checkpointsDir: this.checkpointsDir, }); return this; } /** * Ensure checkpoints directory exists */ ensureCheckpointsDir() { if (!existsSync(this.checkpointsDir)) { mkdirSync(this.checkpointsDir, { recursive: true }); } } /** * Get next checkpoint number based on existing checkpoints * @returns {number} */ getNextCheckpointNumber() { if (!existsSync(this.checkpointsDir)) { return 1; } const files = readdirSync(this.checkpointsDir); const checkpointFiles = files.filter(f => f.match(/^\d+-checkpoint\.json$/)); if (checkpointFiles.length === 0) { return 1; } const numbers = checkpointFiles.map(f => parseInt(f.split('-')[0], 10)); return Math.max(...numbers) + 1; } /** * Capture git status snapshot * @returns {Object} Git status */ captureGitStatus() { try { const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: this.projectRoot, encoding: 'utf8', }).trim(); const statusOutput = execSync('git status --porcelain', { cwd: this.projectRoot, encoding: 'utf8', }); const modified = []; const untracked = []; const staged = []; for (const line of statusOutput.split('\n')) { if (!line) continue; const status = line.substring(0, 2); const file = line.substring(3); if (status[0] !== ' ' && status[0] !== '?') { staged.push(file); } if (status[1] === 'M' || status[1] === 'D') { modified.push(file); } if (status === '??') { untracked.push(file); } } return { branch, modified, untracked, staged }; } catch (e) { return { branch: 'unknown', modified: [], untracked: [], staged: [], error: e.message, }; } } /** * Capture .aiwg/ directory state * @returns {Object} AIWG state */ captureAiwgState() { const aiwgDir = join(this.projectRoot, '.aiwg'); if (!existsSync(aiwgDir)) { return { files: [], sizes: {} }; } try { const files = []; const sizes = {}; const walk = (dir, prefix = '') => { const entries = readdirSync(dir); for (const entry of entries) { const fullPath = join(dir, entry); const relativePath = prefix ? `${prefix}/${entry}` : entry; const stats = statSync(fullPath); if (stats.isDirectory()) { walk(fullPath, relativePath); } else { files.push(relativePath); sizes[relativePath] = stats.size; } } }; walk(aiwgDir); return { files, sizes }; } catch (e) { return { files: [], sizes: {}, error: e.message, }; } } /** * Get cumulative files modified since session start * @param {Object} gitStatus - Current git status * @returns {string[]} All modified files */ getCumulativeFilesModified(gitStatus) { const allFiles = new Set(); // Add current modified files for (const file of gitStatus.modified || []) { allFiles.add(file); } for (const file of gitStatus.staged || []) { allFiles.add(file); } // If we have previous checkpoints, merge their files const previousCheckpoints = this.getAllCheckpoints(); for (const checkpoint of previousCheckpoints) { if (checkpoint.filesModified) { for (const file of checkpoint.filesModified) { allFiles.add(file); } } } return Array.from(allFiles).sort(); } /** * Capture checkpoint snapshot * @param {number} checkpointNum - Checkpoint number * @returns {CheckpointData} Checkpoint data */ captureCheckpoint(checkpointNum) { const timestamp = new Date().toISOString(); const duration = Date.now() - this.sessionStartTime; const gitStatus = this.captureGitStatus(); const aiwgState = this.captureAiwgState(); const filesModified = this.getCumulativeFilesModified(gitStatus); const memory = process.memoryUsage(); /** @type {CheckpointData} */ const checkpoint = { number: checkpointNum, timestamp, duration, gitStatus, aiwgState, filesModified, sessionId: this.sessionId, memory: { heapUsed: memory.heapUsed, rss: memory.rss, }, }; // Save checkpoint to file const filename = `${String(checkpointNum).padStart(3, '0')}-checkpoint.json`; const filepath = join(this.checkpointsDir, filename); writeFileSync(filepath, JSON.stringify(checkpoint, null, 2)); // Emit event this.emit('checkpoint', checkpoint); // Call callback if provided if (this.onCheckpoint) { this.onCheckpoint(checkpoint); } return checkpoint; } /** * Stop checkpoint timer and capture final checkpoint * @returns {Object} Summary of all checkpoints */ stop() { if (!this.isRunning) { return this.summarizeCheckpoints(); } if (this.timerId) { clearInterval(this.timerId); this.timerId = null; } // Capture final checkpoint const finalCheckpoint = this.captureCheckpoint(this.checkpointNumber + 1); this.isRunning = false; const summary = this.summarizeCheckpoints(); this.emit('stopped', { finalCheckpoint, summary, }); return summary; } /** * Get all checkpoints for current session * @returns {CheckpointData[]} Array of checkpoints */ getAllCheckpoints() { if (!existsSync(this.checkpointsDir)) { return []; } const files = readdirSync(this.checkpointsDir); const checkpointFiles = files.filter(f => f.match(/^\d+-checkpoint\.json$/)); const checkpoints = checkpointFiles .map(filename => { try { const filepath = join(this.checkpointsDir, filename); const content = readFileSync(filepath, 'utf8'); return JSON.parse(content); } catch (e) { return null; } }) .filter(cp => cp !== null) .sort((a, b) => a.number - b.number); return checkpoints; } /** * Get latest checkpoint * @param {string} [stateDir] - State directory (optional, uses instance stateDir if not provided) * @returns {CheckpointData|null} Latest checkpoint or null */ static getLatestCheckpoint(stateDir) { const checkpointsDir = join(stateDir, CHECKPOINTS_DIR); if (!existsSync(checkpointsDir)) { return null; } const files = readdirSync(checkpointsDir); const checkpointFiles = files.filter(f => f.match(/^\d+-checkpoint\.json$/)); if (checkpointFiles.length === 0) { return null; } // Get latest checkpoint file const numbers = checkpointFiles.map(f => parseInt(f.split('-')[0], 10)); const latestNum = Math.max(...numbers); const latestFile = `${String(latestNum).padStart(3, '0')}-checkpoint.json`; try { const filepath = join(checkpointsDir, latestFile); const content = readFileSync(filepath, 'utf8'); return JSON.parse(content); } catch (e) { return null; } } /** * Summarize all checkpoints * @param {string} [stateDir] - State directory (optional, uses instance stateDir if not provided) * @returns {Object} Checkpoint summary */ summarizeCheckpoints(stateDir = null) { const dir = stateDir || this.stateDir; if (!dir) { throw new Error('State directory not set'); } const checkpointsDir = join(dir, CHECKPOINTS_DIR); if (!existsSync(checkpointsDir)) { return { total: 0, checkpoints: [], totalDuration: 0, filesModified: [], }; } const files = readdirSync(checkpointsDir); const checkpointFiles = files.filter(f => f.match(/^\d+-checkpoint\.json$/)); const checkpoints = checkpointFiles .map(filename => { try { const filepath = join(checkpointsDir, filename); const content = readFileSync(filepath, 'utf8'); return JSON.parse(content); } catch (e) { return null; } }) .filter(cp => cp !== null) .sort((a, b) => a.number - b.number); const totalDuration = checkpoints.length > 0 ? checkpoints[checkpoints.length - 1].duration : 0; const allFilesModified = new Set(); for (const cp of checkpoints) { if (cp.filesModified) { for (const file of cp.filesModified) { allFilesModified.add(file); } } } const summary = checkpoints.map(cp => ({ number: cp.number, timestamp: cp.timestamp, duration: cp.duration, filesModified: cp.filesModified.length, gitStatus: { modified: cp.gitStatus.modified.length, staged: cp.gitStatus.staged.length, untracked: cp.gitStatus.untracked.length, }, aiwgFiles: cp.aiwgState.files.length, })); return { total: checkpoints.length, checkpoints: summary, totalDuration, filesModified: Array.from(allFilesModified).sort(), }; } /** * Format duration in human-readable format * @param {number} ms - Duration in milliseconds * @returns {string} Formatted duration */ static formatDuration(ms) { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); if (hours > 0) { return `${hours}h ${minutes % 60}m`; } else if (minutes > 0) { return `${minutes}m ${seconds % 60}s`; } else { return `${seconds}s`; } } } export default CheckpointManager;