feat(sessions): Add automatic session cleanup and retention policy (#7662)

This commit is contained in:
bl-ue
2025-10-06 13:34:00 -06:00
committed by GitHub
parent abe4045c63
commit 974ab66b7a
14 changed files with 2473 additions and 46 deletions

View File

@@ -0,0 +1,251 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { cleanupExpiredSessions } from './sessionCleanup.js';
import type { Settings } from '../config/settings.js';
import { SESSION_FILE_PREFIX, type Config } from '@google/gemini-cli-core';
// Create a mock config for integration testing
function createTestConfig(): Config {
return {
storage: {
getProjectTempDir: () => '/tmp/nonexistent-test-dir',
},
getSessionId: () => 'test-session-id',
getDebugMode: () => false,
initialize: async () => undefined,
} as unknown as Config;
}
describe('Session Cleanup Integration', () => {
it('should gracefully handle non-existent directories', async () => {
const config = createTestConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '30d',
},
},
};
const result = await cleanupExpiredSessions(config, settings);
// Should return empty result for non-existent directory
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(0);
expect(result.deleted).toBe(0);
expect(result.skipped).toBe(0);
expect(result.failed).toBe(0);
});
it('should not impact startup when disabled', async () => {
const config = createTestConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: false,
},
},
};
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(result.deleted).toBe(0);
expect(result.skipped).toBe(0);
expect(result.failed).toBe(0);
});
it('should handle missing sessionRetention configuration', async () => {
// Create test session files to verify they are NOT deleted when config is missing
const fs = await import('node:fs/promises');
const path = await import('node:path');
const os = await import('node:os');
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
const chatsDir = path.join(tempDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
// Create an old session file that would normally be deleted
const oldDate = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); // 60 days ago
const sessionFile = path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-01-01T10-00-00-test123.json`,
);
await fs.writeFile(
sessionFile,
JSON.stringify({
sessionId: 'test123',
messages: [],
startTime: oldDate.toISOString(),
lastUpdated: oldDate.toISOString(),
}),
);
const config = createTestConfig();
config.storage.getProjectTempDir = vi.fn().mockReturnValue(tempDir);
const settings: Settings = {};
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0); // Should not even scan when config is missing
expect(result.deleted).toBe(0);
expect(result.skipped).toBe(0);
expect(result.failed).toBe(0);
// Verify the session file still exists (was not deleted)
const filesAfter = await fs.readdir(chatsDir);
expect(filesAfter).toContain(
`${SESSION_FILE_PREFIX}2024-01-01T10-00-00-test123.json`,
);
// Cleanup
await fs.rm(tempDir, { recursive: true });
});
it('should validate configuration and fail gracefully', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const config = createTestConfig();
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: 'invalid-format',
},
},
};
const result = await cleanupExpiredSessions(config, settings);
expect(result.disabled).toBe(true);
expect(result.scanned).toBe(0);
expect(result.deleted).toBe(0);
expect(result.skipped).toBe(0);
expect(result.failed).toBe(0);
// Verify error logging provides visibility into the validation failure
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Session cleanup disabled: Error: Invalid retention period format',
),
);
errorSpy.mockRestore();
});
it('should clean up expired sessions when they exist', async () => {
// Create a temporary directory with test sessions
const fs = await import('node:fs/promises');
const path = await import('node:path');
const os = await import('node:os');
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
const chatsDir = path.join(tempDir, 'chats');
await fs.mkdir(chatsDir, { recursive: true });
// Create test session files with different ages
const now = new Date();
const oldDate = new Date(now.getTime() - 35 * 24 * 60 * 60 * 1000); // 35 days ago
const recentDate = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago
// Create an old session file that should be deleted
const oldSessionFile = path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2024-12-01T10-00-00-old12345.json`,
);
await fs.writeFile(
oldSessionFile,
JSON.stringify({
sessionId: 'old12345',
messages: [],
startTime: oldDate.toISOString(),
lastUpdated: oldDate.toISOString(),
}),
);
// Create a recent session file that should be kept
const recentSessionFile = path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2025-01-15T10-00-00-recent789.json`,
);
await fs.writeFile(
recentSessionFile,
JSON.stringify({
sessionId: 'recent789',
messages: [],
startTime: recentDate.toISOString(),
lastUpdated: recentDate.toISOString(),
}),
);
// Create a current session file that should always be kept
const currentSessionFile = path.join(
chatsDir,
`${SESSION_FILE_PREFIX}2025-01-20T10-00-00-current123.json`,
);
await fs.writeFile(
currentSessionFile,
JSON.stringify({
sessionId: 'current123',
messages: [],
startTime: now.toISOString(),
lastUpdated: now.toISOString(),
}),
);
// Configure test with real temp directory
const config: Config = {
storage: {
getProjectTempDir: () => tempDir,
},
getSessionId: () => 'current123',
getDebugMode: () => false,
initialize: async () => undefined,
} as unknown as Config;
const settings: Settings = {
general: {
sessionRetention: {
enabled: true,
maxAge: '30d', // Keep sessions for 30 days
},
},
};
try {
const result = await cleanupExpiredSessions(config, settings);
// Verify the result
expect(result.disabled).toBe(false);
expect(result.scanned).toBe(3); // Should scan all 3 sessions
expect(result.deleted).toBe(1); // Should delete the old session (35 days old)
expect(result.skipped).toBe(2); // Should keep recent and current sessions
expect(result.failed).toBe(0);
// Verify files on disk
const remainingFiles = await fs.readdir(chatsDir);
expect(remainingFiles).toHaveLength(2); // Only 2 files should remain
expect(remainingFiles).toContain(
`${SESSION_FILE_PREFIX}2025-01-15T10-00-00-recent789.json`,
);
expect(remainingFiles).toContain(
`${SESSION_FILE_PREFIX}2025-01-20T10-00-00-current123.json`,
);
expect(remainingFiles).not.toContain(
`${SESSION_FILE_PREFIX}2024-12-01T10-00-00-old12345.json`,
);
} finally {
// Clean up test directory
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,299 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { type Config } from '@google/gemini-cli-core';
import type { Settings, SessionRetentionSettings } from '../config/settings.js';
import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js';
// Constants
export const DEFAULT_MIN_RETENTION = '1d' as string;
const MIN_MAX_COUNT = 1;
const MULTIPLIERS = {
h: 60 * 60 * 1000, // hours to ms
d: 24 * 60 * 60 * 1000, // days to ms
w: 7 * 24 * 60 * 60 * 1000, // weeks to ms
m: 30 * 24 * 60 * 60 * 1000, // months (30 days) to ms
};
/**
* Result of session cleanup operation
*/
export interface CleanupResult {
disabled: boolean;
scanned: number;
deleted: number;
skipped: number;
failed: number;
}
/**
* Main entry point for session cleanup during CLI startup
*/
export async function cleanupExpiredSessions(
config: Config,
settings: Settings,
): Promise<CleanupResult> {
const result: CleanupResult = {
disabled: false,
scanned: 0,
deleted: 0,
skipped: 0,
failed: 0,
};
try {
// Early exit if cleanup is disabled
if (!settings.general?.sessionRetention?.enabled) {
return { ...result, disabled: true };
}
const retentionConfig = settings.general.sessionRetention;
const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats');
// Validate retention configuration
const validationErrorMessage = validateRetentionConfig(
config,
retentionConfig,
);
if (validationErrorMessage) {
// Log validation errors to console for visibility
console.error(`Session cleanup disabled: ${validationErrorMessage}`);
return { ...result, disabled: true };
}
// Get all session files (including corrupted ones) for this project
const allFiles = await getAllSessionFiles(chatsDir, config.getSessionId());
result.scanned = allFiles.length;
if (allFiles.length === 0) {
return result;
}
// Determine which sessions to delete (corrupted and expired)
const sessionsToDelete = await identifySessionsToDelete(
allFiles,
retentionConfig,
);
// Delete all sessions that need to be deleted
for (const sessionToDelete of sessionsToDelete) {
try {
const sessionPath = path.join(chatsDir, sessionToDelete.fileName);
await fs.unlink(sessionPath);
if (config.getDebugMode()) {
if (sessionToDelete.sessionInfo === null) {
console.debug(
`Deleted corrupted session file: ${sessionToDelete.fileName}`,
);
} else {
console.debug(
`Deleted expired session: ${sessionToDelete.sessionInfo.id} (${sessionToDelete.sessionInfo.lastUpdated})`,
);
}
}
result.deleted++;
} catch (error) {
// Ignore ENOENT errors (file already deleted)
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
// File already deleted, do nothing.
} else {
// Log error directly to console
const sessionId =
sessionToDelete.sessionInfo === null
? sessionToDelete.fileName
: sessionToDelete.sessionInfo.id;
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
console.error(
`Failed to delete session ${sessionId}: ${errorMessage}`,
);
result.failed++;
}
}
}
result.skipped = result.scanned - result.deleted - result.failed;
if (config.getDebugMode() && result.deleted > 0) {
console.debug(
`Session cleanup: deleted ${result.deleted}, skipped ${result.skipped}, failed ${result.failed}`,
);
}
} catch (error) {
// Global error handler - don't let cleanup failures break startup
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
console.error(`Session cleanup failed: ${errorMessage}`);
result.failed++;
}
return result;
}
/**
* Identifies sessions that should be deleted (corrupted or expired based on retention policy)
*/
async function identifySessionsToDelete(
allFiles: SessionFileEntry[],
retentionConfig: SessionRetentionSettings,
): Promise<SessionFileEntry[]> {
const sessionsToDelete: SessionFileEntry[] = [];
// All corrupted files should be deleted
sessionsToDelete.push(
...allFiles.filter((entry) => entry.sessionInfo === null),
);
// Now handle valid sessions based on retention policy
const validSessions = allFiles.filter((entry) => entry.sessionInfo !== null);
if (validSessions.length === 0) {
return sessionsToDelete;
}
const now = new Date();
// Calculate cutoff date for age-based retention
let cutoffDate: Date | null = null;
if (retentionConfig.maxAge) {
try {
const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge);
cutoffDate = new Date(now.getTime() - maxAgeMs);
} catch {
// This should not happen as validation should have caught it,
// but handle gracefully just in case
cutoffDate = null;
}
}
// Sort valid sessions by lastUpdated (newest first) for count-based retention
const sortedValidSessions = [...validSessions].sort(
(a, b) =>
new Date(b.sessionInfo!.lastUpdated).getTime() -
new Date(a.sessionInfo!.lastUpdated).getTime(),
);
// Separate deletable sessions from the active session
const deletableSessions = sortedValidSessions.filter(
(entry) => !entry.sessionInfo!.isCurrentSession,
);
// Calculate how many deletable sessions to keep (accounting for the active session)
const hasActiveSession = sortedValidSessions.some(
(e) => e.sessionInfo!.isCurrentSession,
);
const maxDeletableSessions =
retentionConfig.maxCount && hasActiveSession
? Math.max(0, retentionConfig.maxCount - 1)
: retentionConfig.maxCount;
for (let i = 0; i < deletableSessions.length; i++) {
const entry = deletableSessions[i];
const session = entry.sessionInfo!;
let shouldDelete = false;
// Age-based retention check
if (cutoffDate && new Date(session.lastUpdated) < cutoffDate) {
shouldDelete = true;
}
// Count-based retention check (keep only N most recent deletable sessions)
if (maxDeletableSessions !== undefined && i >= maxDeletableSessions) {
shouldDelete = true;
}
if (shouldDelete) {
sessionsToDelete.push(entry);
}
}
return sessionsToDelete;
}
/**
* Parses retention period strings like "30d", "7d", "24h" into milliseconds
* @throws {Error} If the format is invalid
*/
function parseRetentionPeriod(period: string): number {
const match = period.match(/^(\d+)([dhwm])$/);
if (!match) {
throw new Error(
`Invalid retention period format: ${period}. Expected format: <number><unit> where unit is h, d, w, or m`,
);
}
const value = parseInt(match[1], 10);
const unit = match[2];
// Reject zero values as they're semantically invalid
if (value === 0) {
throw new Error(
`Invalid retention period: ${period}. Value must be greater than 0`,
);
}
return value * MULTIPLIERS[unit as keyof typeof MULTIPLIERS];
}
/**
* Validates retention configuration
*/
function validateRetentionConfig(
config: Config,
retentionConfig: SessionRetentionSettings,
): string | null {
if (!retentionConfig.enabled) {
return 'Retention not enabled';
}
// Validate maxAge if provided
if (retentionConfig.maxAge) {
let maxAgeMs: number;
try {
maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge);
} catch (error) {
return (error as Error | string).toString();
}
// Enforce minimum retention period
const minRetention = retentionConfig.minRetention || DEFAULT_MIN_RETENTION;
let minRetentionMs: number;
try {
minRetentionMs = parseRetentionPeriod(minRetention);
} catch (error) {
// If minRetention format is invalid, fall back to default
if (config.getDebugMode()) {
console.error(`Failed to parse minRetention: ${error}`);
}
minRetentionMs = parseRetentionPeriod(DEFAULT_MIN_RETENTION);
}
if (maxAgeMs < minRetentionMs) {
return `maxAge cannot be less than minRetention (${minRetention})`;
}
}
// Validate maxCount if provided
if (retentionConfig.maxCount !== undefined) {
if (retentionConfig.maxCount < MIN_MAX_COUNT) {
return `maxCount must be at least ${MIN_MAX_COUNT}`;
}
}
// At least one retention method must be specified
if (!retentionConfig.maxAge && retentionConfig.maxCount === undefined) {
return 'Either maxAge or maxCount must be specified';
}
return null;
}

View File

@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
SESSION_FILE_PREFIX,
type ConversationRecord,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
/**
* Session information for display and selection purposes.
*/
export interface SessionInfo {
/** Unique session identifier (filename without .json) */
id: string;
/** Full filename including .json extension */
fileName: string;
/** ISO timestamp when session was last updated */
lastUpdated: string;
/** Whether this is the currently active session */
isCurrentSession: boolean;
}
/**
* Represents a session file, which may be valid or corrupted.
*/
export interface SessionFileEntry {
/** Full filename including .json extension */
fileName: string;
/** Parsed session info if valid, null if corrupted */
sessionInfo: SessionInfo | null;
}
/**
* Loads all session files (including corrupted ones) from the chats directory.
* @returns Array of session file entries, with sessionInfo null for corrupted files
*/
export const getAllSessionFiles = async (
chatsDir: string,
currentSessionId?: string,
): Promise<SessionFileEntry[]> => {
try {
const files = await fs.readdir(chatsDir);
const sessionFiles = files
.filter((f) => f.startsWith(SESSION_FILE_PREFIX) && f.endsWith('.json'))
.sort(); // Sort by filename, which includes timestamp
const sessionPromises = sessionFiles.map(
async (file): Promise<SessionFileEntry> => {
const filePath = path.join(chatsDir, file);
try {
const content: ConversationRecord = JSON.parse(
await fs.readFile(filePath, 'utf8'),
);
// Validate required fields
if (
!content.sessionId ||
!content.messages ||
!Array.isArray(content.messages) ||
!content.startTime ||
!content.lastUpdated
) {
// Missing required fields - treat as corrupted
return { fileName: file, sessionInfo: null };
}
const isCurrentSession = currentSessionId
? file.includes(currentSessionId.slice(0, 8))
: false;
const sessionInfo: SessionInfo = {
id: content.sessionId,
fileName: file,
lastUpdated: content.lastUpdated,
isCurrentSession,
};
return { fileName: file, sessionInfo };
} catch {
// File is corrupted (can't read or parse JSON)
return { fileName: file, sessionInfo: null };
}
},
);
return await Promise.all(sessionPromises);
} catch (error) {
// It's expected that the directory might not exist, which is not an error.
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return [];
}
// For other errors (e.g., permissions), re-throw to be handled by the caller.
throw error;
}
};
/**
* Loads all valid session files from the chats directory and converts them to SessionInfo.
* Corrupted files are automatically filtered out.
*/
export const getSessionFiles = async (
chatsDir: string,
currentSessionId?: string,
): Promise<SessionInfo[]> => {
const allFiles = await getAllSessionFiles(chatsDir, currentSessionId);
// Filter out corrupted files and extract SessionInfo
const validSessions = allFiles
.filter(
(entry): entry is { fileName: string; sessionInfo: SessionInfo } =>
entry.sessionInfo !== null,
)
.map((entry) => entry.sessionInfo);
return validSessions;
};