mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
feat(sessions): Add automatic session cleanup and retention policy (#7662)
This commit is contained in:
@@ -28,6 +28,10 @@ vi.mock('./trustedFolders.js', () => ({
|
||||
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
|
||||
}));
|
||||
|
||||
vi.mock('./sandboxConfig.js', () => ({
|
||||
loadSandboxConfig: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actualFs = await importOriginal<typeof import('fs')>();
|
||||
const pathMod = await import('node:path');
|
||||
|
||||
@@ -170,6 +170,20 @@ export interface AccessibilitySettings {
|
||||
screenReader?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionRetentionSettings {
|
||||
/** Enable automatic session cleanup */
|
||||
enabled?: boolean;
|
||||
|
||||
/** Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w") */
|
||||
maxAge?: string;
|
||||
|
||||
/** Alternative: Maximum number of sessions to keep (most recent) */
|
||||
maxCount?: number;
|
||||
|
||||
/** Minimum retention period (safety limit, defaults to "1d") */
|
||||
minRetention?: string;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
message: string;
|
||||
path: string;
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CustomTheme } from '../ui/themes/theme.js';
|
||||
import type { SessionRetentionSettings } from './settings.js';
|
||||
import { DEFAULT_MIN_RETENTION } from '../utils/sessionCleanup.js';
|
||||
|
||||
export type SettingsType =
|
||||
| 'boolean'
|
||||
@@ -185,6 +187,54 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable debug logging of keystrokes to the console.',
|
||||
showInDialog: true,
|
||||
},
|
||||
sessionRetention: {
|
||||
type: 'object',
|
||||
label: 'Session Retention',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as SessionRetentionSettings | undefined,
|
||||
properties: {
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Session Cleanup',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable automatic session cleanup',
|
||||
showInDialog: true,
|
||||
},
|
||||
maxAge: {
|
||||
type: 'string',
|
||||
label: 'Max Session Age',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w")',
|
||||
showInDialog: false,
|
||||
},
|
||||
maxCount: {
|
||||
type: 'number',
|
||||
label: 'Max Session Count',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as number | undefined,
|
||||
description:
|
||||
'Alternative: Maximum number of sessions to keep (most recent)',
|
||||
showInDialog: false,
|
||||
},
|
||||
minRetention: {
|
||||
type: 'string',
|
||||
label: 'Min Retention Period',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: DEFAULT_MIN_RETENTION,
|
||||
description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`,
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
description: 'Settings for automatic session cleanup.',
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
} from './core/initializer.js';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
import { cleanupExpiredSessions } from './utils/sessionCleanup.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
@@ -62,6 +64,8 @@ import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
} from './utils/relaunch.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
@@ -111,10 +115,6 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js';
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
let unhandledRejectionOccurred = false;
|
||||
process.on('unhandledRejection', (reason, _promise) => {
|
||||
@@ -351,6 +351,9 @@ export async function main() {
|
||||
argv,
|
||||
);
|
||||
|
||||
// Cleanup sessions after config initialization
|
||||
await cleanupExpiredSessions(config, settings.merged);
|
||||
|
||||
if (config.getListExtensions()) {
|
||||
console.log('Installed extensions:');
|
||||
for (const extension of extensions) {
|
||||
|
||||
@@ -14,14 +14,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -49,14 +49,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -84,14 +84,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -119,14 +119,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
||||
│ │
|
||||
│ Debug Keystroke Logging false* │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false* │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -154,14 +154,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -189,14 +189,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
||||
│ │
|
||||
│ Debug Keystroke Logging (Modified in Workspace) false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -224,14 +224,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -259,14 +259,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -294,14 +294,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
||||
│ │
|
||||
│ Debug Keystroke Logging false │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title false │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips false │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
@@ -329,14 +329,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
||||
│ │
|
||||
│ Debug Keystroke Logging true* │
|
||||
│ │
|
||||
│ Session Retention undefined │
|
||||
│ │
|
||||
│ Enable Session Cleanup false │
|
||||
│ │
|
||||
│ Output Format Text │
|
||||
│ │
|
||||
│ Hide Window Title true* │
|
||||
│ │
|
||||
│ Show Status in Title false │
|
||||
│ │
|
||||
│ Hide Tips true* │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -50,6 +50,9 @@ const MockedGeminiClientClass = vi.hoisted(() =>
|
||||
this.startChat = mockStartChat;
|
||||
this.sendMessageStream = mockSendMessageStream;
|
||||
this.addHistory = vi.fn();
|
||||
this.getChat = vi.fn().mockReturnValue({
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
});
|
||||
this.getChatRecordingService = vi.fn().mockReturnValue({
|
||||
recordThought: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
|
||||
@@ -137,6 +137,24 @@ export const useGeminiStream = (
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// Record tool calls with full metadata before sending responses.
|
||||
try {
|
||||
const currentModel =
|
||||
config.getGeminiClient().getCurrentSequenceModel() ??
|
||||
config.getModel();
|
||||
config
|
||||
.getGeminiClient()
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(
|
||||
currentModel,
|
||||
completedToolCallsFromScheduler,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error recording completed tool call information: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle tool response submission immediately when tools complete
|
||||
await handleCompletedTools(
|
||||
completedToolCallsFromScheduler as TrackedToolCall[],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user