@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
JavaScript
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