diff options
Diffstat (limited to 'packages/server/src/core/logger.ts')
| -rw-r--r-- | packages/server/src/core/logger.ts | 290 |
1 files changed, 199 insertions, 91 deletions
diff --git a/packages/server/src/core/logger.ts b/packages/server/src/core/logger.ts index d12d4240..feb16944 100644 --- a/packages/server/src/core/logger.ts +++ b/packages/server/src/core/logger.ts @@ -5,127 +5,235 @@ */ import path from 'node:path'; -import sqlite3 from 'sqlite3'; import { promises as fs } from 'node:fs'; const GEMINI_DIR = '.gemini'; -const DB_NAME = 'logs.db'; -const CREATE_TABLE_SQL = ` -CREATE TABLE IF NOT EXISTS messages ( - session_id INTEGER, - message_id INTEGER, - timestamp TEXT, - type TEXT, - message TEXT -);`; +const LOG_FILE_NAME = 'logs.json'; export enum MessageSenderType { USER = 'user', } +export interface LogEntry { + sessionId: number; + messageId: number; + timestamp: string; + type: MessageSenderType; + message: string; +} + export class Logger { - private db: sqlite3.Database | undefined; + private logFilePath: string | undefined; private sessionId: number | undefined; - private messageId: number | undefined; + private messageId = 0; // Instance-specific counter for the next messageId + private initialized = false; + private logs: LogEntry[] = []; // In-memory cache, ideally reflects the last known state of the file constructor() {} + private async _readLogFile(): Promise<LogEntry[]> { + if (!this.logFilePath) { + throw new Error('Log file path not set during read attempt.'); + } + try { + const fileContent = await fs.readFile(this.logFilePath, 'utf-8'); + const parsedLogs = JSON.parse(fileContent); + if (!Array.isArray(parsedLogs)) { + console.debug( + `Log file at ${this.logFilePath} is not a valid JSON array. Starting with empty logs.`, + ); + await this._backupCorruptedLogFile('malformed_array'); + return []; + } + return parsedLogs.filter( + (entry) => + typeof entry.sessionId === 'number' && + typeof entry.messageId === 'number' && + typeof entry.timestamp === 'string' && + typeof entry.type === 'string' && + typeof entry.message === 'string', + ) as LogEntry[]; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + return []; + } + if (error instanceof SyntaxError) { + console.debug( + `Invalid JSON in log file ${this.logFilePath}. Backing up and starting fresh.`, + error, + ); + await this._backupCorruptedLogFile('invalid_json'); + return []; + } + console.debug( + `Failed to read or parse log file ${this.logFilePath}:`, + error, + ); + throw error; + } + } + + private async _backupCorruptedLogFile(reason: string): Promise<void> { + if (!this.logFilePath) return; + const backupPath = `${this.logFilePath}.${reason}.${Date.now()}.bak`; + try { + await fs.rename(this.logFilePath, backupPath); + console.debug(`Backed up corrupted log file to ${backupPath}`); + } catch (_backupError) { + // If rename fails (e.g. file doesn't exist), no need to log an error here as the primary error (e.g. invalid JSON) is already handled. + } + } + async initialize(): Promise<void> { - if (this.db) { + if (this.initialized) { return; } - this.sessionId = Math.floor(Date.now() / 1000); - this.messageId = 0; - - // Could be cleaner if our sqlite package supported promises. - return new Promise((resolve, reject) => { - const DB_DIR = path.resolve(process.cwd(), GEMINI_DIR); - const DB_PATH = path.join(DB_DIR, DB_NAME); - fs.mkdir(DB_DIR, { recursive: true }) - .then(() => { - this.db = new sqlite3.Database( - DB_PATH, - sqlite3.OPEN_READWRITE | - sqlite3.OPEN_CREATE | - sqlite3.OPEN_FULLMUTEX, - (err: Error | null) => { - if (err) { - reject(err); - } + const geminiDir = path.resolve(process.cwd(), GEMINI_DIR); + this.logFilePath = path.join(geminiDir, LOG_FILE_NAME); - // Read and execute the SQL script in create_tables.sql - this.db?.exec(CREATE_TABLE_SQL, (err: Error | null) => { - if (err) { - this.db?.close(); - reject(err); - } - resolve(); - }); - }, - ); - }) - .catch(reject); - }); + try { + await fs.mkdir(geminiDir, { recursive: true }); + let fileExisted = true; + try { + await fs.access(this.logFilePath); + } catch (_e) { + fileExisted = false; + } + this.logs = await this._readLogFile(); + if (!fileExisted && this.logs.length === 0) { + await fs.writeFile(this.logFilePath, '[]', 'utf-8'); + } + const sessionLogs = this.logs.filter( + (entry) => entry.sessionId === this.sessionId, + ); + this.messageId = + sessionLogs.length > 0 + ? Math.max(...sessionLogs.map((entry) => entry.messageId)) + 1 + : 0; + this.initialized = true; + } catch (err) { + console.error('Failed to initialize logger:', err); + this.initialized = false; + } } - /** - * Get list of previous user inputs sorted most recent first. - * @returns list of messages. - */ - async getPreviousUserMessages(): Promise<string[]> { - if (!this.db) { - console.error('Database not initialized.'); - return []; + private async _updateLogFile( + entryToAppend: LogEntry, + ): Promise<LogEntry | null> { + if (!this.logFilePath) { + console.debug('Log file path not set. Cannot persist log entry.'); + throw new Error('Log file path not set during update attempt.'); + } + + let currentLogsOnDisk: LogEntry[]; + try { + currentLogsOnDisk = await this._readLogFile(); + } catch (readError) { + console.debug( + 'Critical error reading log file before append:', + readError, + ); + throw readError; } - return new Promise((resolve, reject) => { - // Most recent messages first - const query = `SELECT message FROM messages - WHERE type = '${MessageSenderType.USER}' - ORDER BY session_id DESC, message_id DESC`; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.db!.all(query, [], (err: Error | null, rows: any[]) => { - if (err) { - reject(err); - } else { - resolve(rows.map((row) => row.message)); - } - }); - }); + // Determine the correct messageId for the new entry based on current disk state for its session + const sessionLogsOnDisk = currentLogsOnDisk.filter( + (e) => e.sessionId === entryToAppend.sessionId, + ); + const nextMessageIdForSession = + sessionLogsOnDisk.length > 0 + ? Math.max(...sessionLogsOnDisk.map((e) => e.messageId)) + 1 + : 0; + + // Update the messageId of the entry we are about to append + entryToAppend.messageId = nextMessageIdForSession; + + // Check if this entry (same session, same *recalculated* messageId, same content) might already exist + // This is a stricter check for true duplicates if multiple instances try to log the exact same thing + // at the exact same calculated messageId slot. + const entryExists = currentLogsOnDisk.some( + (e) => + e.sessionId === entryToAppend.sessionId && + e.messageId === entryToAppend.messageId && + e.timestamp === entryToAppend.timestamp && // Timestamps are good for distinguishing + e.message === entryToAppend.message, + ); + + if (entryExists) { + console.debug( + `Duplicate log entry detected and skipped: session ${entryToAppend.sessionId}, messageId ${entryToAppend.messageId}`, + ); + this.logs = currentLogsOnDisk; // Ensure in-memory is synced with disk + return null; // Indicate that no new entry was actually added + } + + currentLogsOnDisk.push(entryToAppend); + + try { + await fs.writeFile( + this.logFilePath, + JSON.stringify(currentLogsOnDisk, null, 2), + 'utf-8', + ); + this.logs = currentLogsOnDisk; + return entryToAppend; // Return the successfully appended entry + } catch (error) { + console.debug('Error writing to log file:', error); + throw error; + } + } + + async getPreviousUserMessages(): Promise<string[]> { + if (!this.initialized) return []; + return this.logs + .filter((entry) => entry.type === MessageSenderType.USER) + .sort((a, b) => { + if (b.sessionId !== a.sessionId) return b.sessionId - a.sessionId; + const dateA = new Date(a.timestamp).getTime(); + const dateB = new Date(b.timestamp).getTime(); + if (dateB !== dateA) return dateB - dateA; + return b.messageId - a.messageId; + }) + .map((entry) => entry.message); } async logMessage(type: MessageSenderType, message: string): Promise<void> { - if (!this.db) { - console.error('Database not initialized.'); + if (!this.initialized || this.sessionId === undefined) { + console.debug( + 'Logger not initialized or session ID missing. Cannot log message.', + ); return; } - return new Promise((resolve, reject) => { - const query = `INSERT INTO messages (session_id, message_id, type, message, timestamp) VALUES (?, ?, ?, ?, datetime('now'))`; - this.messageId = this.messageId! + 1; - this.db!.run( - query, - [this.sessionId || 0, this.messageId - 1, type, message], - (err: Error | null) => { - if (err) { - reject(err); - } else { - resolve(); - } - }, - ); - }); + // The messageId used here is the instance's idea of the next ID. + // _updateLogFile will verify and potentially recalculate based on the file's actual state. + const newEntryObject: LogEntry = { + sessionId: this.sessionId, + messageId: this.messageId, // This will be recalculated in _updateLogFile + type, + message, + timestamp: new Date().toISOString(), + }; + + try { + const writtenEntry = await this._updateLogFile(newEntryObject); + if (writtenEntry) { + // If an entry was actually written (not a duplicate skip), + // then this instance can increment its idea of the next messageId for this session. + this.messageId = writtenEntry.messageId + 1; + } + } catch (_error) { + // Error already logged by _updateLogFile or _readLogFile + } } close(): void { - if (this.db) { - this.db.close((err: Error | null) => { - if (err) { - console.error('Error closing database:', err.message); - } - }); - this.db = undefined; - } + this.initialized = false; + this.logFilePath = undefined; + this.logs = []; + this.sessionId = undefined; + this.messageId = 0; } } |
