pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
269 lines (268 loc) • 10.7 kB
JavaScript
/**
* Log Cleanup Utility — manages log retention and rotation
*
* Environment variables:
* PI_LENS_LOG_RETENTION_DAYS - Days to keep logs (default: 7)
* PI_LENS_MAX_LOG_SIZE_MB - Max size before rotation (default: 10)
*
* Scope:
* - ~/.pi-lens/*.log (every global log — see getManagedLogFiles())
* - ~/.pi-lens/logs/*.jsonl (daily diagnostic logs)
* - ~/.pi-lens/<name>.<timestamp>.log (rotated backups, and legacy .log.<ts>)
*
* Excluded (intentionally NOT cleaned - project-scoped or persistent):
* - <project-data>/worklog.jsonl - Agent fixable diagnostics
* - <project-data>/code-quality-warnings.jsonl - Non-fixable warning history
* - <project-data>/actionable-warnings.jsonl - Actionable warning history
* - <project-data>/metrics-history.json - Complexity trends (capped internally)
* - <project-data>/reviews/* - Code review snapshots
* - <project-data>/turn-state.json - Turn tracking
* - <project-data>/fix-session.json - Active fix sessions
* - <project-data>/todo-baseline.json - TODO baseline
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { getGlobalPiLensDir } from "./file-utils.js";
import { getRegisteredLogFiles } from "./ndjson-logger.js";
const LOG_DIR = getGlobalPiLensDir();
const LOGS_SUBDIR = path.join(LOG_DIR, "logs");
/**
* Logs that write via some mechanism OTHER than the shared
* `createNdjsonLogger` (clients/ndjson-logger.ts), so they can't self-register
* into its file registry. Keep this list as small as possible — anything that
* can reasonably move onto the shared writer should, so there's only one
* place left to remember by hand.
*
* - sessionstart.log — written by a handful of modules (installer/index.ts,
* lsp/index.ts, lsp/launch.ts, lsp/server.ts, index.ts) via a bespoke
* `fs.appendFile`, predating ndjson-logger.
*/
const UNMANAGED_STRAGGLER_LOG_FILES = ["sessionstart.log"];
/**
* Every global `.log` pi-lens writes under ~/.pi-lens, resolved dynamically so
* rotation and the storage summary can't drift out of sync with what's
* actually written — that drift is exactly what left
* actionable-warnings/ast-grep-tools/dead-code, and later bus-events.log,
* unrotated and growing unbounded.
*
* Three sources are unioned:
* 1. `getRegisteredLogFiles()` — every static-path `createNdjsonLogger`
* instance self-registers at construction (module load) time. A brand
* new `*-logger.ts` module built on the shared writer is picked up with
* zero action here, as long as it's imported before this runs (true for
* the whole current codebase — every logger module is statically
* imported from index.ts's transitive graph before session start).
* 2. A direct `~/.pi-lens/*.log` directory read, excluding rotated-backup
* names — a defensive backstop in case some future logger module is only
* *dynamically* imported and hasn't registered yet when this runs. Once
* a file has content on disk, this catches it regardless of import
* timing; an unregistered file that doesn't exist yet has nothing to
* clean up anyway.
* 3. `UNMANAGED_STRAGGLER_LOG_FILES` — the handful of logs that don't go
* through `createNdjsonLogger` at all and so can never self-register.
*
* `dir` is overridable for tests; production callers use the default
* (real `~/.pi-lens`).
*/
export function getManagedLogFiles(dir = LOG_DIR) {
const names = new Set(UNMANAGED_STRAGGLER_LOG_FILES);
for (const absPath of getRegisteredLogFiles()) {
if (path.dirname(absPath) === dir) {
names.add(path.basename(absPath));
}
}
try {
if (fs.existsSync(dir)) {
for (const entry of fs.readdirSync(dir)) {
if (entry.endsWith(".log") && !ROTATED_BACKUP_RE.test(entry)) {
names.add(entry);
}
}
}
}
catch {
// best-effort backstop — registry + straggler list still apply
}
return [...names].sort((a, b) => a.localeCompare(b));
}
/**
* Matches a rotated backup, never an active log. Rotation writes
* `name.<ISO-timestamp>.log` (timestamp before the extension); an older shape
* was `name.log.<timestamp>`. Match both so every backup — whichever version
* produced it — is reaped by the retention sweep. The active `name.log` has no
* timestamp segment and no trailing suffix after `.log`, so it never matches.
*/
export const ROTATED_BACKUP_RE = /(\.\d{4}-\d{2}-\d{2}T.*\.log|\.log\..+)$/;
function getConfig() {
return {
retentionDays: Math.max(1, Number.parseInt(process.env.PI_LENS_LOG_RETENTION_DAYS ?? "7", 10) || 7),
maxSizeMB: Math.max(1, Number.parseInt(process.env.PI_LENS_MAX_LOG_SIZE_MB ?? "10", 10) || 10),
};
}
function getFileAgeDays(filePath) {
try {
const stats = fs.statSync(filePath);
const ageMs = Date.now() - stats.mtime.getTime();
return ageMs / (1000 * 60 * 60 * 24);
}
catch {
return 0;
}
}
function getFileSizeMB(filePath) {
try {
const stats = fs.statSync(filePath);
return stats.size / (1024 * 1024);
}
catch {
return 0;
}
}
/**
* Delete files older than retentionDays
*/
export function cleanupOldLogs(directory, pattern, retentionDays) {
const config = getConfig();
const maxAge = retentionDays ?? config.retentionDays;
const deleted = [];
const errors = [];
try {
if (!fs.existsSync(directory)) {
return { deleted, errors };
}
const files = fs.readdirSync(directory);
for (const file of files) {
if (!pattern.test(file))
continue;
const filePath = path.join(directory, file);
const ageDays = getFileAgeDays(filePath);
if (ageDays > maxAge) {
try {
fs.unlinkSync(filePath);
deleted.push(file);
}
catch (err) {
errors.push(`${file}: ${err}`);
}
}
}
}
catch (err) {
errors.push(`Directory read failed: ${err}`);
}
return { deleted, errors };
}
/**
* Rotate a log file if it exceeds max size
*/
export function rotateLogIfNeeded(logFile, maxSizeMB) {
const config = getConfig();
const maxSize = maxSizeMB ?? config.maxSizeMB;
const sizeMB = getFileSizeMB(logFile);
if (sizeMB < maxSize) {
return { rotated: false };
}
try {
// Create timestamped backup
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const ext = path.extname(logFile);
const base = logFile.slice(0, -ext.length);
const backupFile = `${base}.${timestamp}${ext}`;
// Rename current to backup, create fresh file
fs.renameSync(logFile, backupFile);
fs.writeFileSync(logFile, "", "utf8");
return { rotated: true, newFile: backupFile };
}
catch {
return { rotated: false };
}
}
/**
* Main cleanup function - call on session start
* Cleans up all pi-lens log files based on retention policy
*/
export function runLogCleanup(dbg) {
const config = getConfig();
const results = {
cleaned: 0,
rotated: 0,
report: "",
};
// Cleanup old daily diagnostic logs (*.jsonl)
const dailyLogs = cleanupOldLogs(LOGS_SUBDIR, /\.jsonl$/, config.retentionDays);
results.cleaned += dailyLogs.deleted.length;
// Cleanup old rotated log backups. This sweep runs unconditionally on every
// session start, so correcting the pattern self-heals any pre-existing
// backlog on the next launch — no separate migration needed. (The prior
// `/\.log\./` only matched the legacy `name.log.<ts>` shape, never the
// current `name.<ts>.log`, so backups accumulated indefinitely.)
const rotatedLogs = cleanupOldLogs(LOG_DIR, ROTATED_BACKUP_RE, config.retentionDays);
results.cleaned += rotatedLogs.deleted.length;
// Check main logs for rotation
const mainLogs = getManagedLogFiles().map((name) => path.join(LOG_DIR, name));
for (const logFile of mainLogs) {
const rotation = rotateLogIfNeeded(logFile, config.maxSizeMB);
if (rotation.rotated) {
results.rotated++;
if (rotation.newFile) {
const sizeMB = getFileSizeMB(rotation.newFile);
dbg?.(`log_cleanup: rotated ${path.basename(logFile)} (${sizeMB.toFixed(1)}MB) → ${path.basename(rotation.newFile)}`);
}
}
}
// Build report
const parts = [];
if (dailyLogs.deleted.length > 0) {
parts.push(`${dailyLogs.deleted.length} daily logs`);
}
if (rotatedLogs.deleted.length > 0) {
parts.push(`${rotatedLogs.deleted.length} rotated logs`);
}
if (results.rotated > 0) {
parts.push(`${results.rotated} active logs rotated`);
}
results.report =
parts.length > 0
? `log_cleanup: removed ${parts.join(", ")} (retention: ${config.retentionDays}d, maxSize: ${config.maxSizeMB}MB)`
: `log_cleanup: no cleanup needed (retention: ${config.retentionDays}d, maxSize: ${config.maxSizeMB}MB)`;
if (dailyLogs.errors.length > 0 || rotatedLogs.errors.length > 0) {
dbg?.(`log_cleanup errors: ${[...dailyLogs.errors, ...rotatedLogs.errors].join("; ")}`);
}
return results;
}
/**
* Get current log storage summary
*/
export function getLogStorageSummary() {
const files = [];
let totalMB = 0;
// Main logs
for (const name of getManagedLogFiles()) {
const filePath = path.join(LOG_DIR, name);
if (fs.existsSync(filePath)) {
const sizeMB = getFileSizeMB(filePath);
const ageDays = getFileAgeDays(filePath);
files.push({ name, sizeMB, ageDays });
totalMB += sizeMB;
}
}
// Daily logs
try {
if (fs.existsSync(LOGS_SUBDIR)) {
const dailyFiles = fs.readdirSync(LOGS_SUBDIR);
for (const name of dailyFiles) {
if (!name.endsWith(".jsonl"))
continue;
const filePath = path.join(LOGS_SUBDIR, name);
const sizeMB = getFileSizeMB(filePath);
const ageDays = getFileAgeDays(filePath);
files.push({ name: `logs/${name}`, sizeMB, ageDays });
totalMB += sizeMB;
}
}
}
catch {
// Ignore
}
return { totalMB, files };
}