import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, openSync, closeSync, readSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; const LOG_PREFIX = "vllm_"; const LOG_SUFFIX = ".log"; const FALLBACK_LOG_DIR = tmpdir(); export interface LogFileEntry { sessionId: string; path: string; mtimeMs: number; sizeBytes: number; source: "data_dir" | "tmp"; } export interface LogCleanupOptions { maxAgeMs: number; maxFiles: number; maxTotalBytes: number; excludePaths?: Set; } export const getLogCleanupDefaultsFromEnvironment = (): Omit => { const clampInt = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max); const parseIntOr = (raw: string | undefined, fallback: number): number => { if (!raw) return fallback; const n = Number.parseInt(raw, 10); return Number.isFinite(n) ? n : fallback; }; // 0 means "no cap" for size/files and "no age expiry" for days. const days = parseIntOr(process.env["LOCAL_STUDIO_LOG_RETENTION_DAYS"], 30); const maxFiles = parseIntOr(process.env["LOCAL_STUDIO_LOG_MAX_FILES"], 200); const maxTotalBytes = parseIntOr(process.env["LOCAL_STUDIO_LOG_MAX_TOTAL_BYTES"], 1_000_000_000); const maxAgeMs = days <= 0 ? Number.POSITIVE_INFINITY : clampInt(days, 1, 3650) * 24 * 60 * 60 * 1000; return { maxAgeMs, maxFiles: maxFiles <= 0 ? Number.MAX_SAFE_INTEGER : clampInt(maxFiles, 1, 100_000), maxTotalBytes: maxTotalBytes <= 0 ? Number.MAX_SAFE_INTEGER : Math.max(1_000_000, maxTotalBytes), }; }; export const sanitizeLogSessionId = (sessionId: string): string => { const safe = Array.from(sessionId) .filter((char) => /[a-zA-Z0-9._-]/.test(char)) .join(""); return safe; }; export const ensureLogsDirectory = (dataDirectory: string): string => { const directory = resolve(dataDirectory, "logs"); mkdirSync(directory, { recursive: true }); return directory; }; export const primaryLogPathFor = (dataDirectory: string, sessionId: string): string => { const safe = sanitizeLogSessionId(sessionId); return join(ensureLogsDirectory(dataDirectory), `${LOG_PREFIX}${safe}${LOG_SUFFIX}`); }; export const fallbackLogPathFor = (sessionId: string): string => { const safe = sanitizeLogSessionId(sessionId); return join(FALLBACK_LOG_DIR, `${LOG_PREFIX}${safe}${LOG_SUFFIX}`); }; export const resolveExistingLogPath = (dataDirectory: string, sessionId: string): string | null => { const primary = primaryLogPathFor(dataDirectory, sessionId); if (existsSync(primary)) return primary; const fallback = fallbackLogPathFor(sessionId); if (existsSync(fallback)) return fallback; return null; }; const scanLogDirectory = (directory: string, source: LogFileEntry["source"]): LogFileEntry[] => { if (!existsSync(directory)) return []; try { return readdirSync(directory) .filter((name) => name.startsWith(LOG_PREFIX) && name.endsWith(LOG_SUFFIX)) .map((name) => { const path = join(directory, name); const stat = statSync(path); const sessionId = name .replace(new RegExp(`^${LOG_PREFIX}`), "") .replace(new RegExp(`${LOG_SUFFIX}$`), ""); return { sessionId, path, mtimeMs: stat.mtimeMs, sizeBytes: stat.size, source, } satisfies LogFileEntry; }); } catch { return []; } }; export const listLogFiles = (dataDirectory: string): LogFileEntry[] => { const primaryDirectory = resolve(dataDirectory, "logs"); const all = [ ...scanLogDirectory(primaryDirectory, "data_dir"), ...scanLogDirectory(FALLBACK_LOG_DIR, "tmp"), ]; // Deduplicate by session id, preferring the newest mtime. const bySession = new Map(); for (const entry of all) { const existing = bySession.get(entry.sessionId); if (!existing || entry.mtimeMs > existing.mtimeMs) { bySession.set(entry.sessionId, entry); } } return Array.from(bySession.values()).sort((a, b) => b.mtimeMs - a.mtimeMs); }; export const cleanupLogFiles = ( dataDirectory: string, options: LogCleanupOptions, ): { deleted: number } => { const { maxAgeMs, maxFiles, maxTotalBytes, excludePaths } = options; const now = Date.now(); const entries = [ ...scanLogDirectory(resolve(dataDirectory, "logs"), "data_dir"), ...scanLogDirectory(FALLBACK_LOG_DIR, "tmp"), ] .filter((entry) => !(excludePaths && excludePaths.has(entry.path))) .sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first const shouldDeleteAge = (entry: LogFileEntry): boolean => now - entry.mtimeMs > maxAgeMs; const deletedPaths: string[] = []; const safeUnlink = (path: string): void => { try { unlinkSync(path); deletedPaths.push(path); } catch { // Ignore races or permission issues; retention is best-effort. } }; // 1) Age-based retention. for (const entry of entries) { if (shouldDeleteAge(entry)) safeUnlink(entry.path); } // 2) Recompute after deletions. const remaining = entries.filter((entry) => !deletedPaths.includes(entry.path)); // 3) File-count cap. if (remaining.length > maxFiles) { const overflow = remaining.length - maxFiles; for (const entry of remaining.slice(0, overflow)) safeUnlink(entry.path); } // 4) Total-bytes cap. const stillRemaining = remaining.filter((entry) => !deletedPaths.includes(entry.path)); let totalBytes = stillRemaining.reduce((sum, entry) => sum + entry.sizeBytes, 0); if (totalBytes > maxTotalBytes) { for (const entry of stillRemaining) { if (totalBytes <= maxTotalBytes) break; safeUnlink(entry.path); totalBytes -= entry.sizeBytes; } } return { deleted: deletedPaths.length }; }; export const tailFileLines = ( path: string, limit: number, maxBytes = 10 * 1024 * 1024, ): string[] => { if (limit <= 0) return []; if (!existsSync(path)) return []; // Read from the end until we have enough newlines or hit maxBytes. const fd = openSync(path, "r"); try { const stat = statSync(path); let pos = stat.size; if (pos <= 0) return []; const chunkSize = 64 * 1024; const chunks: Buffer[] = []; let bytesRead = 0; let newlineCount = 0; while (pos > 0 && newlineCount < limit + 1 && bytesRead < maxBytes) { const readSize = Math.min(chunkSize, pos, maxBytes - bytesRead); pos -= readSize; const buf = Buffer.allocUnsafe(readSize); const n = readSync(fd, buf, 0, readSize, pos); const slice = buf.slice(0, n); chunks.push(slice); bytesRead += n; // Count newlines in this chunk. for (let index = 0; index < slice.length; index++) { if (slice[index] === 0x0a) newlineCount += 1; } } const text = Buffer.concat(chunks.reverse()).toString("utf-8"); const lines = text.split(/\r?\n/); if (lines.length > 0 && lines[lines.length - 1] === "") { lines.pop(); } return lines.slice(Math.max(0, lines.length - limit)); } finally { closeSync(fd); } };