This commit is contained in:
Shreya Keshive
2026-02-10 16:14:34 -05:00
parent ef02cec2cd
commit 0fda45d9f6
6 changed files with 257 additions and 1 deletions

View File

@@ -181,6 +181,9 @@ export interface SessionRetentionSettings {
/** Minimum retention period (safety limit, defaults to "1d") */
minRetention?: string;
/** Timestamp (ISO string or ms) to start age checks from for existing files. */
gracePeriodStart?: string;
}
export interface SettingsError {

View File

@@ -319,6 +319,16 @@ const SETTINGS_SCHEMA = {
description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`,
showInDialog: false,
},
gracePeriodStart: {
type: 'string',
label: 'Grace Period Start',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Timestamp (ISO string) to start age checks from for existing files.',
showInDialog: false,
},
},
description: 'Settings for automatic session cleanup.',
},

View File

@@ -141,6 +141,10 @@ import {
QUEUE_ERROR_DISPLAY_DURATION_MS,
} from './constants.js';
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import {
type SessionRetentionChoice,
SessionRetentionWarningDialog,
} from './components/SessionRetentionWarningDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
@@ -242,6 +246,49 @@ export const AppContainer = (props: AppContainerProps) => {
const [warningBannerText, setWarningBannerText] = useState('');
const [bannerVisible, setBannerVisible] = useState(true);
const [showRetentionWarning, setShowRetentionWarning] = useState(false);
const retentionCheckDone = useRef(false);
useEffect(() => {
if (retentionCheckDone.current) return;
retentionCheckDone.current = true;
// We only want to show this if the user hasn't explicitly set it in User or Workspace scope.
const userSettings = settings.user.settings;
const workspaceSettings = settings.workspace.settings;
const userRetention = userSettings.general?.sessionRetention;
const workspaceRetention = workspaceSettings.general?.sessionRetention;
// Check if it's completely undefined in both explicit scopes
if (userRetention === undefined && workspaceRetention === undefined) {
setShowRetentionWarning(true);
}
}, [settings]);
const handleRetentionConfirm = useCallback(
(choice: SessionRetentionChoice) => {
const now = new Date().toISOString();
const retentionSettings: any = {
enabled: true,
maxAge: '60d',
};
if (choice === 'graceful') {
retentionSettings.gracePeriodStart = now;
}
// Store in Workspace scope as requested
settings.setValue(
SettingScope.Workspace,
'general.sessionRetention',
retentionSettings,
);
setShowRetentionWarning(false);
},
[settings],
);
const bannerData = useMemo(
() => ({
defaultText: defaultBannerText,
@@ -2231,6 +2278,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
}
if (showRetentionWarning) {
return <SessionRetentionWarningDialog onConfirm={handleRetentionConfirm} />;
}
return (
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>

View File

@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
import { act } from 'react';
describe('SessionRetentionWarningDialog', () => {
const onConfirm = vi.fn();
afterEach(() => {
vi.restoreAllMocks();
});
it('should render correctly', () => {
const { lastFrame } = renderWithProviders(
<SessionRetentionWarningDialog onConfirm={onConfirm} />,
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain('Session Retention Policy');
expect(lastFrame()).toContain('Graceful Cleanup');
expect(lastFrame()).toContain('Strict Cleanup');
});
it('should confirm with "graceful" when selecting "Graceful Cleanup"', async () => {
const { stdin } = renderWithProviders(
<SessionRetentionWarningDialog onConfirm={onConfirm} />,
);
// Default selection is "Graceful Cleanup", so just press Enter
act(() => {
stdin.write('\r');
});
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith('graceful');
});
});
it('should confirm with "strict" when selecting "Strict Cleanup"', async () => {
const { stdin } = renderWithProviders(
<SessionRetentionWarningDialog onConfirm={onConfirm} />,
);
// Move down to "Strict Cleanup"
act(() => {
stdin.write('\x1b[B'); // Arrow Down
});
// Press Enter
act(() => {
stdin.write('\r');
});
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith('strict');
});
});
});

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
DescriptiveRadioButtonSelect,
type DescriptiveRadioSelectItem,
} from './shared/DescriptiveRadioButtonSelect.js';
export type SessionRetentionChoice = 'graceful' | 'strict';
interface SessionRetentionWarningDialogProps {
onConfirm: (choice: SessionRetentionChoice) => void;
}
export const SessionRetentionWarningDialog = ({
onConfirm,
}: SessionRetentionWarningDialogProps): React.JSX.Element => {
const items: Array<DescriptiveRadioSelectItem<SessionRetentionChoice>> = [
{
key: 'graceful',
title: 'Graceful Cleanup (Recommended)',
description:
'Start 60-day limit from today. Existing sessions are safe for now.',
value: 'graceful',
},
{
key: 'strict',
title: 'Strict Cleanup',
description:
'Apply 60-day limit immediately. Older sessions will be deleted.',
value: 'strict',
},
];
return (
<Box
borderStyle="round"
borderColor={theme.status.warning}
flexDirection="column"
padding={1}
width={60}
>
<Box marginBottom={1}>
<Text color={theme.status.warning} bold>
Session Retention Policy
</Text>
</Box>
<Box marginBottom={1}>
<Text>
To save disk space, we recommend enabling automatic cleanup of old
sessions. Please choose how to apply the 60-day retention policy:
</Text>
</Box>
<DescriptiveRadioButtonSelect
items={items}
onSelect={onConfirm}
initialIndex={0}
/>
</Box>
);
};

View File

@@ -198,7 +198,66 @@ async function identifySessionsToDelete(
if (retentionConfig.maxAge) {
try {
const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge);
cutoffDate = new Date(now.getTime() - maxAgeMs);
const nowMs = now.getTime();
// If gracePeriodStart is set, we treat files as if they were created at
// max(file.mtime, gracePeriodStart) for the purpose of age check?
// actually, simpler:
// The deletion condition is:
// age > maxAge AND (now > gracePeriodStart + maxAge)
// Wait, if I have a file from 2020.
// gracePeriodStart = Today.
// I want it to survive for 60 days.
// So effectively, the file's "effective mtime" is max(real_mtime, gracePeriodStart).
// We check if (now - effective_mtime) > maxAge.
if (retentionConfig.gracePeriodStart) {
const graceStart = new Date(retentionConfig.gracePeriodStart).getTime();
if (!isNaN(graceStart)) {
// We can't set a single cutoff date because it depends on the file's mtime vs graceStart?
// Actually, we can.
// effectiveAge = now - max(mtime, graceStart)
// We want effectiveAge > maxAge
// now - max(mtime, graceStart) > maxAge
// max(mtime, graceStart) < now - maxAge
// mtime < now - maxAge AND graceStart < now - maxAge
// So, BOTH must be true.
// If graceStart is recent (today), then `graceStart < now - maxAge` is FALSE.
// So nothing gets deleted.
// This works perfectly!
// So checking `graceStart < now - maxAge` is a global gate.
// If we are within the grace period (globally), we delete NOTHING based on age?
// Wait, what if a file was created *after* graceStart?
// mtime (Post-Grace) < now - maxAge.
// If graceStart is today.
// File created tomorrow.
// now is tomorrow + 61 days.
// mtime < now - maxAge (True).
// graceStart < now - maxAge (True, because graceStart was 61 days ago).
// So it deletes.
// This logic holds up.
// We just need to ensure we use the SAME cutoff for all files?
// Yes, `cutoffDate` is `now - maxAge`.
// And we also verify `graceStart < cutoffDate`.
if (graceStart > nowMs - maxAgeMs) {
// We are still within the global grace period.
// UNLESS the file is somehow older than maxAge AND older than graceStart?
// No, if `graceStart > cutoffDate`, then `max(mtime, graceStart)` is at least `graceStart`.
// `graceStart > cutoffDate` means `graceStart > now - maxAge`.
// So `now - graceStart < maxAge`.
// So effective age is ALWAYS < maxAge.
// So NOTHING deletes.
cutoffDate = null; // Disable age cleanup
} else {
cutoffDate = new Date(nowMs - maxAgeMs);
}
} else {
cutoffDate = new Date(nowMs - maxAgeMs);
}
} else {
cutoffDate = new Date(nowMs - maxAgeMs);
}
} catch {
// This should not happen as validation should have caught it,
// but handle gracefully just in case