UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

303 lines (302 loc) 9.26 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { logger } from "../monitoring/logger.js"; class FrameRecovery { constructor(db, options = {}) { this.db = db; this.orphanThresholdMs = (options.orphanThresholdHours ?? 24) * 60 * 60 * 1e3; } // Sessions older than this are considered orphaned (default: 24 hours) orphanThresholdMs; // Current session/run ID to exclude from orphan detection currentRunId = null; /** * Set the current run ID to exclude from orphan detection */ setCurrentRunId(runId) { this.currentRunId = runId; } /** * Main recovery entry point - call on startup */ async recoverOnStartup() { const errors = []; const timestamp = (/* @__PURE__ */ new Date()).toISOString(); logger.info("Starting crash recovery check"); const walStatus = this.checkWalStatus(); if (walStatus.checkpointNeeded) { try { this.checkpointWal(); logger.info("WAL checkpoint completed"); } catch (err) { const msg = `WAL checkpoint failed: ${err instanceof Error ? err.message : String(err)}`; errors.push(msg); logger.warn(msg); } } const integrityCheck = this.runIntegrityCheck(); if (!integrityCheck.passed) { logger.error("Database integrity check failed", { violations: integrityCheck.foreignKeyViolations, corrupted: integrityCheck.corruptedRows }); } const orphanedFrames = this.recoverOrphanedFrames(); if (orphanedFrames.detected > 0) { logger.info("Orphaned frames processed", { detected: orphanedFrames.detected, recovered: orphanedFrames.recovered, closed: orphanedFrames.closed }); } const report = { timestamp, integrityCheck, orphanedFrames, walStatus, recovered: integrityCheck.passed && orphanedFrames.detected === orphanedFrames.closed, errors }; logger.info("Crash recovery completed", { recovered: report.recovered, orphansFound: orphanedFrames.detected, integrityPassed: integrityCheck.passed }); return report; } /** * Check WAL mode status */ checkWalStatus() { try { const journalMode = this.db.pragma("journal_mode"); const isWal = journalMode[0]?.journal_mode === "wal"; if (!isWal) { return { enabled: false, checkpointNeeded: false, walSize: 0 }; } const walInfo = this.db.pragma("wal_checkpoint(PASSIVE)"); const walSize = walInfo[0]?.log ?? 0; const checkpointed = walInfo[0]?.checkpointed ?? 0; return { enabled: true, checkpointNeeded: walSize > 1e3, // Checkpoint if WAL has > 1000 pages walSize: walSize - checkpointed }; } catch (err) { logger.warn("Failed to check WAL status", { error: err }); return { enabled: false, checkpointNeeded: false, walSize: 0 }; } } /** * Force WAL checkpoint */ checkpointWal() { this.db.pragma("wal_checkpoint(TRUNCATE)"); } /** * Run database integrity checks */ runIntegrityCheck() { const errors = []; let foreignKeyViolations = 0; let corruptedRows = 0; try { const fkViolations = this.db.pragma( "foreign_key_check" ); foreignKeyViolations = fkViolations.length; if (foreignKeyViolations > 0) { errors.push(`Found ${foreignKeyViolations} foreign key violations`); logger.warn("Foreign key violations detected", { count: foreignKeyViolations, samples: fkViolations.slice(0, 5) }); } const integrity = this.db.pragma("integrity_check"); const integrityResult = integrity[0]?.integrity_check; if (integrityResult !== "ok") { corruptedRows = integrity.length; errors.push(`Integrity check failed: ${integrityResult}`); logger.error("Database corruption detected", { result: integrityResult }); } } catch (err) { errors.push( `Integrity check error: ${err instanceof Error ? err.message : String(err)}` ); } return { passed: foreignKeyViolations === 0 && corruptedRows === 0, foreignKeyViolations, corruptedRows, errors }; } /** * Detect and recover orphaned frames * Orphaned = active frames from sessions that are no longer running */ recoverOrphanedFrames() { const orphanThreshold = Date.now() - this.orphanThresholdMs; const thresholdUnix = Math.floor(orphanThreshold / 1e3); const query = ` SELECT frame_id, run_id, project_id, name, type, created_at, depth FROM frames WHERE state = 'active' AND created_at < ? ${this.currentRunId ? "AND run_id != ?" : ""} ORDER BY created_at ASC `; const params = this.currentRunId ? [thresholdUnix, this.currentRunId] : [thresholdUnix]; const orphaned = this.db.prepare(query).all(...params); if (orphaned.length === 0) { return { detected: 0, recovered: 0, closed: 0, frameIds: [] }; } const frameIds = orphaned.map((f) => f.frame_id); let closed = 0; const closeStmt = this.db.prepare(` UPDATE frames SET state = 'closed', closed_at = unixepoch(), outputs = json_set(COALESCE(outputs, '{}'), '$.recovered', true, '$.recoveryReason', 'orphan_cleanup') WHERE frame_id = ? `); const transaction = this.db.transaction(() => { for (const frame of orphaned) { try { closeStmt.run(frame.frame_id); closed++; logger.debug("Closed orphaned frame", { frameId: frame.frame_id, runId: frame.run_id, name: frame.name, age: Math.round((Date.now() / 1e3 - frame.created_at) / 3600) + "h" }); } catch (err) { logger.warn("Failed to close orphaned frame", { frameId: frame.frame_id, error: err }); } } }); transaction(); return { detected: orphaned.length, recovered: 0, // Future: could attempt to resume some frames closed, frameIds }; } /** * Validate data integrity for a specific project */ validateProjectIntegrity(projectId) { const issues = []; const invalidParents = this.db.prepare( ` SELECT f1.frame_id, f1.parent_frame_id FROM frames f1 LEFT JOIN frames f2 ON f1.parent_frame_id = f2.frame_id WHERE f1.project_id = ? AND f1.parent_frame_id IS NOT NULL AND f2.frame_id IS NULL ` ).all(projectId); if (invalidParents.length > 0) { issues.push( `${invalidParents.length} frames with invalid parent references` ); } const depthIssues = this.db.prepare( ` SELECT f1.frame_id, f1.depth as child_depth, f2.depth as parent_depth FROM frames f1 JOIN frames f2 ON f1.parent_frame_id = f2.frame_id WHERE f1.project_id = ? AND f1.depth != f2.depth + 1 ` ).all(projectId); if (depthIssues.length > 0) { issues.push(`${depthIssues.length} frames with incorrect depth values`); } const orphanEvents = this.db.prepare( ` SELECT COUNT(*) as count FROM events e LEFT JOIN frames f ON e.frame_id = f.frame_id WHERE f.frame_id IS NULL ` ).get(); if (orphanEvents.count > 0) { issues.push(`${orphanEvents.count} orphaned events without valid frames`); } return { valid: issues.length === 0, issues }; } /** * Clean up orphaned events (events without valid frames) */ cleanupOrphanedEvents() { const result = this.db.prepare( ` DELETE FROM events WHERE frame_id NOT IN (SELECT frame_id FROM frames) ` ).run(); if (result.changes > 0) { logger.info("Cleaned up orphaned events", { count: result.changes }); } return result.changes; } /** * Get recovery statistics */ getRecoveryStats() { const stats = this.db.prepare( ` SELECT COUNT(*) as total, SUM(CASE WHEN state = 'active' THEN 1 ELSE 0 END) as active, SUM(CASE WHEN state = 'closed' THEN 1 ELSE 0 END) as closed, SUM(CASE WHEN json_extract(outputs, '$.recovered') = true THEN 1 ELSE 0 END) as recovered FROM frames ` ).get(); const oldest = this.db.prepare( ` SELECT datetime(created_at, 'unixepoch') as created FROM frames WHERE state = 'active' ORDER BY created_at ASC LIMIT 1 ` ).get(); return { totalFrames: stats.total, activeFrames: stats.active, closedFrames: stats.closed, recoveredFrames: stats.recovered, oldestActiveFrame: oldest?.created ?? null }; } } async function recoverDatabase(db, currentRunId) { const recovery = new FrameRecovery(db); if (currentRunId) { recovery.setCurrentRunId(currentRunId); } return recovery.recoverOnStartup(); } export { FrameRecovery, recoverDatabase }; //# sourceMappingURL=frame-recovery.js.map