Skip to main content
Glama
ooples

MCP Console Automation Server

SnapshotManager.ts10.7 kB
/** * SnapshotManager - Capture and store session state snapshots * Phase 2: Assertion Framework */ import { SessionSnapshot } from '../types/test-framework.js'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; export interface SnapshotOptions { includeMetadata?: boolean; computeHash?: boolean; pretty?: boolean; } export class SnapshotManager { private snapshotsDir: string; constructor(snapshotsDir?: string) { this.snapshotsDir = snapshotsDir || path.join(process.cwd(), 'data', 'snapshots'); this.ensureSnapshotsDir(); } /** * Ensure snapshots directory exists */ private ensureSnapshotsDir(): void { if (!fs.existsSync(this.snapshotsDir)) { fs.mkdirSync(this.snapshotsDir, { recursive: true }); } } /** * Capture a snapshot of current session state */ async capture( sessionId: string, output: string, state: any, metadata?: Record<string, any> ): Promise<SessionSnapshot> { const timestamp = Date.now(); const snapshot: SessionSnapshot = { sessionId, timestamp, output, state, metadata: metadata || {}, }; // Compute hash for integrity verification snapshot.hash = this.computeHash(snapshot); return snapshot; } /** * Save snapshot to disk */ async save( snapshot: SessionSnapshot, options: SnapshotOptions = {} ): Promise<string> { const { pretty = true } = options; const filename = this.generateFilename(snapshot); const filepath = path.join(this.snapshotsDir, filename); const content = pretty ? JSON.stringify(snapshot, null, 2) : JSON.stringify(snapshot); await fs.promises.writeFile(filepath, content, 'utf8'); return filepath; } /** * Load snapshot from disk */ async load(filepath: string): Promise<SessionSnapshot> { const content = await fs.promises.readFile(filepath, 'utf8'); const snapshot = JSON.parse(content) as SessionSnapshot; // Verify hash if present if (snapshot.hash) { const computedHash = this.computeHash(snapshot); if (computedHash !== snapshot.hash) { throw new Error('Snapshot hash mismatch - file may be corrupted'); } } return snapshot; } /** * Load snapshot by session ID and timestamp */ async loadBySessionId( sessionId: string, timestamp?: number ): Promise<SessionSnapshot> { const snapshots = await this.listSnapshots(sessionId); if (snapshots.length === 0) { throw new Error(`No snapshots found for session: ${sessionId}`); } if (timestamp === undefined) { // Return most recent snapshot return this.load(snapshots[0]); } // Find snapshot with matching timestamp for (const filepath of snapshots) { const snapshot = await this.load(filepath); if (snapshot.timestamp === timestamp) { return snapshot; } } throw new Error( `No snapshot found for session ${sessionId} at timestamp ${timestamp}` ); } /** * List all snapshot files */ async listSnapshots(sessionId?: string): Promise<string[]> { const files = await fs.promises.readdir(this.snapshotsDir); const snapshotFiles = files .filter((f) => f.endsWith('.json')) .map((f) => path.join(this.snapshotsDir, f)); if (!sessionId) { // Sort by modification time (newest first) const withStats = await Promise.all( snapshotFiles.map(async (f) => ({ file: f, mtime: (await fs.promises.stat(f)).mtime.getTime(), })) ); return withStats.sort((a, b) => b.mtime - a.mtime).map((s) => s.file); } // Filter by session ID const filtered: string[] = []; for (const filepath of snapshotFiles) { try { const snapshot = await this.load(filepath); if (snapshot.sessionId === sessionId) { filtered.push(filepath); } } catch (error) { // Skip corrupted files continue; } } // Sort by timestamp (newest first) const withTimestamps = await Promise.all( filtered.map(async (f) => { const snapshot = await this.load(f); return { file: f, timestamp: snapshot.timestamp }; }) ); return withTimestamps .sort((a, b) => b.timestamp - a.timestamp) .map((s) => s.file); } /** * Delete a snapshot */ async delete(filepath: string): Promise<void> { await fs.promises.unlink(filepath); } /** * Delete all snapshots for a session */ async deleteBySessionId(sessionId: string): Promise<number> { const snapshots = await this.listSnapshots(sessionId); await Promise.all(snapshots.map((s) => this.delete(s))); return snapshots.length; } /** * Delete all snapshots */ async deleteAll(): Promise<number> { const snapshots = await this.listSnapshots(); await Promise.all(snapshots.map((s) => this.delete(s))); return snapshots.length; } /** * Get snapshot statistics */ async getStats(): Promise<{ totalSnapshots: number; totalSize: number; sessions: number; oldestTimestamp?: number; newestTimestamp?: number; }> { const snapshots = await this.listSnapshots(); if (snapshots.length === 0) { return { totalSnapshots: 0, totalSize: 0, sessions: 0, }; } let totalSize = 0; const sessionIds = new Set<string>(); let oldestTimestamp = Infinity; let newestTimestamp = -Infinity; for (const filepath of snapshots) { const stats = await fs.promises.stat(filepath); totalSize += stats.size; try { const snapshot = await this.load(filepath); sessionIds.add(snapshot.sessionId); oldestTimestamp = Math.min(oldestTimestamp, snapshot.timestamp); newestTimestamp = Math.max(newestTimestamp, snapshot.timestamp); } catch (error) { // Skip corrupted files continue; } } return { totalSnapshots: snapshots.length, totalSize, sessions: sessionIds.size, oldestTimestamp: oldestTimestamp === Infinity ? undefined : oldestTimestamp, newestTimestamp: newestTimestamp === -Infinity ? undefined : newestTimestamp, }; } /** * Clean up old snapshots (keep only N most recent per session) */ async cleanup(keepPerSession: number = 10): Promise<number> { const snapshots = await this.listSnapshots(); const sessionMap = new Map<string, string[]>(); // Group by session ID for (const filepath of snapshots) { try { const snapshot = await this.load(filepath); const existing = sessionMap.get(snapshot.sessionId) || []; existing.push(filepath); sessionMap.set(snapshot.sessionId, existing); } catch (error) { // Skip corrupted files continue; } } let deletedCount = 0; // Delete old snapshots for each session for (const [sessionId, files] of sessionMap.entries()) { // Sort by timestamp (newest first) const sorted = await Promise.all( files.map(async (f) => { const snapshot = await this.load(f); return { file: f, timestamp: snapshot.timestamp }; }) ); sorted.sort((a, b) => b.timestamp - a.timestamp); // Delete old snapshots const toDelete = sorted.slice(keepPerSession); for (const { file } of toDelete) { await this.delete(file); deletedCount++; } } return deletedCount; } /** * Generate filename for snapshot */ private generateFilename(snapshot: SessionSnapshot): string { const sanitizedSessionId = snapshot.sessionId.replace( /[^a-zA-Z0-9_-]/g, '_' ); return `${sanitizedSessionId}-${snapshot.timestamp}.json`; } /** * Compute hash of snapshot for integrity verification */ private computeHash(snapshot: SessionSnapshot): string { // Create a copy without the hash field const { hash, ...data } = snapshot; const content = JSON.stringify(data, Object.keys(data).sort()); return crypto.createHash('sha256').update(content).digest('hex'); } /** * Export snapshot to different format */ async export( snapshot: SessionSnapshot, format: 'json' | 'yaml' | 'text' ): Promise<string> { switch (format) { case 'json': return JSON.stringify(snapshot, null, 2); case 'yaml': // Simple YAML-like format return this.toYAML(snapshot); case 'text': return this.toText(snapshot); default: throw new Error(`Unsupported export format: ${format}`); } } /** * Convert snapshot to YAML-like format */ private toYAML(snapshot: SessionSnapshot): string { const lines: string[] = []; lines.push('---'); lines.push(`sessionId: ${snapshot.sessionId}`); lines.push(`timestamp: ${snapshot.timestamp}`); lines.push(`hash: ${snapshot.hash}`); lines.push('output: |'); snapshot.output.split('\n').forEach((line) => { lines.push(` ${line}`); }); lines.push('state:'); lines.push(this.objectToYAML(snapshot.state, 2)); lines.push('metadata:'); lines.push(this.objectToYAML(snapshot.metadata, 2)); return lines.join('\n'); } /** * Convert object to YAML-like format with indentation */ private objectToYAML(obj: any, indent: number): string { const spaces = ' '.repeat(indent); const lines: string[] = []; for (const [key, value] of Object.entries(obj)) { if ( typeof value === 'object' && value !== null && !Array.isArray(value) ) { lines.push(`${spaces}${key}:`); lines.push(this.objectToYAML(value, indent + 2)); } else { lines.push(`${spaces}${key}: ${JSON.stringify(value)}`); } } return lines.join('\n'); } /** * Convert snapshot to human-readable text */ private toText(snapshot: SessionSnapshot): string { const lines: string[] = []; lines.push('=== Session Snapshot ==='); lines.push(`Session ID: ${snapshot.sessionId}`); lines.push(`Timestamp: ${new Date(snapshot.timestamp).toISOString()}`); lines.push(`Hash: ${snapshot.hash}`); lines.push(''); lines.push('--- Output ---'); lines.push(snapshot.output); lines.push(''); lines.push('--- State ---'); lines.push(JSON.stringify(snapshot.state, null, 2)); lines.push(''); lines.push('--- Metadata ---'); lines.push(JSON.stringify(snapshot.metadata, null, 2)); return lines.join('\n'); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ooples/mcp-console-automation'

If you have feedback or need assistance with the MCP directory API, please join our Discord server