summaryrefslogtreecommitdiff
path: root/packages/server/src/core/logger.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/server/src/core/logger.ts')
-rw-r--r--packages/server/src/core/logger.ts290
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;
}
}