import * as plugins from '../plugins.js'; import * as paths from '../paths.js'; import type { IProcessLog } from '../shared/protocol/ipc.types.js'; import type { ProcessId } from '../shared/protocol/id.js'; /** * Manages crash log storage for failed processes */ export class CrashLogManager { private crashLogsDir: string; private readonly MAX_CRASH_LOGS = 100; private readonly MAX_LOG_SIZE_BYTES = 1024 * 1024; // 1MB constructor() { this.crashLogsDir = plugins.path.join(paths.tspmDir, 'crashlogs'); } /** * Save a crash log for a failed process */ public async saveCrashLog( processId: ProcessId, processName: string, logs: IProcessLog[], exitCode: number | null, signal: string | null, restartCount: number, memoryUsage?: number ): Promise { try { // Ensure directory exists await this.ensureCrashLogsDir(); // Generate filename with timestamp const timestamp = new Date(); const dateStr = this.formatDate(timestamp); const sanitizedName = this.sanitizeFilename(processName); const filename = `${dateStr}_${processId}_${sanitizedName}.log`; const filepath = plugins.path.join(this.crashLogsDir, filename); // Get recent logs that fit within size limit const recentLogs = this.getRecentLogs(logs, this.MAX_LOG_SIZE_BYTES); // Create crash report const crashReport = this.formatCrashReport({ processId, processName, timestamp, exitCode, signal, restartCount, memoryUsage, logs: recentLogs }); // Write crash log await plugins.smartfile.memory.toFs(crashReport, filepath); // Rotate old logs if needed await this.rotateOldLogs(); console.log(`Crash log saved: ${filename}`); } catch (error) { console.error(`Failed to save crash log for process ${processId}:`, error); } } /** * Format date for filename: YYYY-MM-DD_HH-mm-ss */ private formatDate(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`; } /** * Sanitize process name for use in filename */ private sanitizeFilename(name: string): string { // Replace problematic characters with underscore return name .replace(/[^a-zA-Z0-9-_]/g, '_') .replace(/_+/g, '_') .substring(0, 50); // Limit length } /** * Get recent logs that fit within the size limit */ private getRecentLogs(logs: IProcessLog[], maxBytes: number): IProcessLog[] { if (logs.length === 0) return []; // Start from the end and work backwards const recentLogs: IProcessLog[] = []; let currentSize = 0; for (let i = logs.length - 1; i >= 0; i--) { const log = logs[i]; const logSize = this.estimateLogSize(log); if (currentSize + logSize > maxBytes && recentLogs.length > 0) { // Would exceed limit, stop adding break; } recentLogs.unshift(log); currentSize += logSize; } return recentLogs; } /** * Estimate size of a log entry in bytes */ private estimateLogSize(log: IProcessLog): number { // Format: [timestamp] [type] message\n const formatted = `[${new Date(log.timestamp).toISOString()}] [${log.type}] ${log.message}\n`; return Buffer.byteLength(formatted, 'utf8'); } /** * Format a crash report with metadata and logs */ private formatCrashReport(data: { processId: ProcessId; processName: string; timestamp: Date; exitCode: number | null; signal: string | null; restartCount: number; memoryUsage?: number; logs: IProcessLog[]; }): string { const lines: string[] = [ '================================================================================', 'TSPM CRASH REPORT', '================================================================================', `Process: ${data.processName} (ID: ${data.processId})`, `Date: ${data.timestamp.toISOString()}`, `Exit Code: ${data.exitCode ?? 'N/A'}`, `Signal: ${data.signal ?? 'N/A'}`, `Restart Attempt: ${data.restartCount}/10`, ]; if (data.memoryUsage !== undefined && data.memoryUsage > 0) { lines.push(`Memory Usage: ${this.humanReadableBytes(data.memoryUsage)}`); } lines.push( '================================================================================', '', `LAST ${data.logs.length} LOG ENTRIES:`, '--------------------------------------------------------------------------------', '' ); // Add log entries for (const log of data.logs) { const timestamp = new Date(log.timestamp).toISOString(); const type = log.type.toUpperCase().padEnd(6); lines.push(`[${timestamp}] [${type}] ${log.message}`); } lines.push( '', '================================================================================', 'END OF CRASH REPORT', '================================================================================', '' ); return lines.join('\n'); } /** * Convert bytes to human-readable format */ private humanReadableBytes(bytes: number): string { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } /** * Ensure crash logs directory exists */ private async ensureCrashLogsDir(): Promise { await plugins.smartfile.fs.ensureDir(this.crashLogsDir); } /** * Rotate old crash logs when exceeding max count */ private async rotateOldLogs(): Promise { try { // Get all crash log files const files = await plugins.smartfile.fs.listFileTree(this.crashLogsDir, '*.log'); if (files.length <= this.MAX_CRASH_LOGS) { return; // No rotation needed } // Get file stats and sort by modification time (oldest first) const fileStats = await Promise.all( files.map(async (file) => { const filepath = plugins.path.join(this.crashLogsDir, file); const stats = await plugins.smartfile.fs.stat(filepath); return { filepath, mtime: stats.mtime.getTime() }; }) ); fileStats.sort((a, b) => a.mtime - b.mtime); // Delete oldest files to stay under limit const filesToDelete = fileStats.length - this.MAX_CRASH_LOGS; for (let i = 0; i < filesToDelete; i++) { await plugins.smartfile.fs.remove(fileStats[i].filepath); console.log(`Rotated old crash log: ${plugins.path.basename(fileStats[i].filepath)}`); } } catch (error) { console.error('Failed to rotate crash logs:', error); } } /** * Get list of crash logs for a specific process */ public async getCrashLogsForProcess(processId: ProcessId): Promise { try { await this.ensureCrashLogsDir(); const files = await plugins.smartfile.fs.listFileTree(this.crashLogsDir, `*_${processId}_*.log`); return files.map(file => plugins.path.join(this.crashLogsDir, file)); } catch (error) { console.error(`Failed to get crash logs for process ${processId}:`, error); return []; } } /** * Clean up all crash logs (for maintenance) */ public async cleanupAllCrashLogs(): Promise { try { await this.ensureCrashLogsDir(); const files = await plugins.smartfile.fs.listFileTree(this.crashLogsDir, '*.log'); for (const file of files) { const filepath = plugins.path.join(this.crashLogsDir, file); await plugins.smartfile.fs.remove(filepath); } console.log(`Cleaned up ${files.length} crash logs`); } catch (error) { console.error('Failed to cleanup crash logs:', error); } } }